hookehuyr

fix(checkin): allow task description rich text to wrap

Override inline rich-text no-wrap styles so long assignment descriptions render fully in the checkin detail page.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
......@@ -13,11 +13,8 @@
<div class="section-wrapper">
<div class="section-title">作业描述</div>
<div class="section-content">
<div v-if="displayTaskNote" class="description-text" v-html="displayTaskNote">
</div>
<div v-else class="no-description">
暂无作业描述
</div>
<div v-if="displayTaskNote" class="description-text" v-html="displayTaskNote"></div>
<div v-else class="no-description">暂无作业描述</div>
</div>
</div>
......@@ -28,18 +25,31 @@
<!-- 作业选择区域 -->
<div class="mb-4">
<!-- 编辑模式下直接显示文本 -->
<div v-if="isEditMode" class="bg-gray-50 rounded-lg p-3 border border-gray-100 flex items-center justify-between">
<span class="text-gray-700 font-medium">当前作业</span>
<span class="text-gray-900 font-bold">{{ selectedTaskText }}</span>
<div
v-if="isEditMode"
class="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 p-3"
>
<span class="font-medium text-gray-700">当前作业</span>
<span class="font-bold text-gray-900">{{ selectedTaskText }}</span>
</div>
<!-- 非编辑模式下显示选择框 -->
<template v-else>
<van-field v-model="selectedTaskText" is-link readonly label="选择作业" placeholder="请选择本次打卡的作业"
@click="showTaskPicker = true" class="rounded-lg border border-gray-100" />
<van-field
v-model="selectedTaskText"
is-link
readonly
label="选择作业"
placeholder="请选择本次打卡的作业"
@click="showTaskPicker = true"
class="rounded-lg border border-gray-100"
/>
<van-popup v-model:show="showTaskPicker" round position="bottom">
<van-picker :columns="taskOptions" @cancel="showTaskPicker = false"
@confirm="onConfirmTask" />
<van-picker
:columns="taskOptions"
@cancel="showTaskPicker = false"
@confirm="onConfirmTask"
/>
</van-popup>
</template>
</div>
......@@ -56,16 +66,30 @@
/>
<!-- 计数次数 -->
<div v-if="taskType === 'count'"
class="mb-4 flex items-center justify-between bg-gray-50 p-3 rounded-lg">
<div
v-if="taskType === 'count'"
class="mb-4 flex items-center justify-between rounded-lg bg-gray-50 p-3"
>
<div class="text-sm font-bold text-gray-700">{{ dynamicFieldText }}次数</div>
<van-stepper v-model="countValue" min="1" integer input-width="80px" button-size="28px" />
<van-stepper
v-model="countValue"
min="1"
integer
input-width="80px"
button-size="28px"
/>
</div>
<!-- 新增计数对象弹框 -->
<AddTargetDialog
v-model:show="showAddTargetDialog"
:title="editingTarget ? (isConfirmMode ? `确认${dynamicFieldText}项` : `编辑${dynamicFieldText}项`) : `添加${dynamicFieldText}项`"
:title="
editingTarget
? isConfirmMode
? `确认${dynamicFieldText}项`
: `编辑${dynamicFieldText}项`
: `添加${dynamicFieldText}项`
"
:fields="dynamicFormFields"
:initial-values="editingTarget"
@confirm="confirmAddTarget"
......@@ -73,23 +97,47 @@
<!-- 文本输入区域 -->
<div class="text-input-area">
<van-field v-model="message" rows="6" autosize type="textarea"
:placeholder="taskType === 'count' ? '请输入留言(可选)' : (activeType === 'text' ? '请输入留言,至少需要10个字符' : '请输入留言(可选)')" />
<van-field
v-model="message"
rows="6"
autosize
type="textarea"
:placeholder="
taskType === 'count'
? '请输入留言(可选)'
: activeType === 'text'
? '请输入留言,至少需要10个字符'
: '请输入留言(可选)'
"
/>
</div>
<!-- 类型选项卡 -->
<div class="checkin-tabs" v-if="selectedTaskValue.length > 0">
<div class="tabs-header">
<div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div>
<div class="tab-title">
{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}
</div>
<div class="tabs-nav">
<div v-for="option in attachmentTypeOptions" :key="option.key"
@click="switchType(option.key)" :class="['tab-item', 'relative', {
active: activeType === option.key
}]">
<div
v-for="option in attachmentTypeOptions"
:key="option.key"
@click="switchType(option.key)"
:class="[
'tab-item',
'relative',
{
active: activeType === option.key,
},
]"
>
<van-icon :name="getIconName(option.key)" size="1.2rem" />
<span class="tab-text">{{ option.value }}</span>
<!-- <div v-if="multiAttachmentEnabled && getTypeCount(option.key) > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-1"> -->
<div v-if="getTypeCount(option.key) > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-1">
<div
v-if="getTypeCount(option.key) > 0"
class="absolute -right-2 -top-2 flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] text-white"
>
{{ getTypeCount(option.key) }}
</div>
</div>
......@@ -98,10 +146,20 @@
<!-- 文件上传区域 -->
<div v-if="activeType !== '' && activeType !== 'text'" class="upload-area">
<van-uploader v-model="displayFileList" :max-count="maxCount" :max-size="maxFileSizeBytes"
:before-read="beforeReadGuard" :after-read="afterRead" @delete="onDelete"
@click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file"
:deletable="true" upload-icon="plus" />
<van-uploader
v-model="displayFileList"
:max-count="maxCount"
:max-size="maxFileSizeBytes"
:before-read="beforeReadGuard"
:after-read="afterRead"
@delete="onDelete"
@click-preview="onClickPreview"
multiple
:accept="getAcceptType()"
result-type="file"
:deletable="true"
upload-icon="plus"
/>
<!-- 文件列表显示 -->
<!-- <div v-if="fileList.length > 0" class="file-list">
......@@ -116,7 +174,9 @@
</div> -->
<div class="upload-tips">
<div class="tip-text">最多上传{{ maxCount }}个文件,每个不超过{{ maxFileSizeMb }}MB</div>
<div class="tip-text">
最多上传{{ maxCount }}个文件,每个不超过{{ maxFileSizeMb }}MB
</div>
<div class="tip-text">{{ getUploadTips() }}</div>
</div>
</div>
......@@ -126,7 +186,14 @@
<!-- 提交按钮 -->
<div v-if="!taskDetail.is_finish || isEditMode" class="submit-area">
<van-button type="primary" block size="large" :loading="uploading" :disabled="isSubmitDisabled" @click="handleSubmit">
<van-button
type="primary"
block
size="large"
:loading="uploading"
:disabled="isSubmitDisabled"
@click="handleSubmit"
>
{{ isEditMode ? '保存修改' : '提交' }}
</van-button>
</div>
......@@ -140,50 +207,89 @@
</van-overlay>
<!-- 音频播放器弹窗 -->
<van-popup v-model:show="audioShow" position="bottom" round closeable :style="{ height: '60%', width: '100%' }">
<van-popup
v-model:show="audioShow"
position="bottom"
round
closeable
:style="{ height: '60%', width: '100%' }"
>
<div class="p-4">
<h3 class="text-lg font-medium mb-4 text-center">{{ audioTitle }}</h3>
<AudioPlayer v-if="audioShow && audioUrl" :songs="[{ title: audioTitle, url: audioUrl }]"
class="w-full" />
<h3 class="mb-4 text-center text-lg font-medium">{{ audioTitle }}</h3>
<AudioPlayer
v-if="audioShow && audioUrl"
:songs="[{ title: audioTitle, url: audioUrl }]"
class="w-full"
/>
</div>
</van-popup>
<!-- 视频播放器弹窗 -->
<van-popup v-model:show="videoShow" position="center" round closeable
:style="{ width: '95%', maxHeight: '80vh' }" @close="stopVideoPlay">
<van-popup
v-model:show="videoShow"
position="center"
round
closeable
:style="{ width: '95%', maxHeight: '80vh' }"
@close="stopVideoPlay"
>
<div class="p-4">
<h3 class="text-lg font-medium mb-4 text-center">视频预览</h3>
<div class="relative w-full bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
<h3 class="mb-4 text-center text-lg font-medium">视频预览</h3>
<div class="relative w-full overflow-hidden rounded-lg bg-black" style="aspect-ratio: 16/9">
<!-- 视频封面 -->
<div v-show="!isVideoPlaying"
class="absolute inset-0 flex items-center justify-center cursor-pointer"
@click="startVideoPlay">
<img :src="videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png'"
:alt="videoTitle" class="w-full h-full object-cover" />
<div
v-show="!isVideoPlaying"
class="absolute inset-0 flex cursor-pointer items-center justify-center"
@click="startVideoPlay"
>
<img
:src="videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png'"
:alt="videoTitle"
class="h-full w-full object-cover"
/>
<div class="absolute inset-0 flex items-center justify-center bg-black/20">
<div
class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
class="flex h-16 w-16 items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70"
>
<van-icon name="play-circle-o" class="text-white" size="40" />
</div>
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer v-if="isVideoPlaying" ref="videoPlayerRef" :video-url="videoUrl"
:video-id="videoTitle" :use-native-on-ios="false" :autoplay="false" class="w-full h-full" @play="handleVideoPlay"
@pause="handleVideoPause" />
<VideoPlayer
v-if="isVideoPlaying"
ref="videoPlayerRef"
:video-url="videoUrl"
:video-id="videoTitle"
:use-native-on-ios="false"
:autoplay="false"
class="h-full w-full"
@play="handleVideoPlay"
@pause="handleVideoPause"
/>
</div>
</div>
</van-popup>
<!-- 图片预览弹窗 -->
<van-image-preview v-model:show="imageShow" :images="imageList" :start-position="imageIndex" :show-index="true" />
<van-image-preview
v-model:show="imageShow"
:images="imageList"
:start-position="imageIndex"
:show-index="true"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, reactive, watch, onBeforeUnmount } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { getTaskDetailAPI, getUploadTaskInfoAPI, getSubtaskListAPI, reuseGratitudeFormAPI } from "@/api/checkin"
import {
getTaskDetailAPI,
getUploadTaskInfoAPI,
getSubtaskListAPI,
reuseGratitudeFormAPI,
} from '@/api/checkin'
import { useTitle } from '@vueuse/core'
import { useCheckin } from '@/composables/useCheckin'
import { useCheckinDraft } from '@/composables/useCheckinDraft'
......@@ -226,7 +332,7 @@ const {
switchType,
initEditData,
gratitudeCount,
gratitudeFormList
gratitudeFormList,
} = useCheckin()
// 使用草稿缓存composable
......@@ -236,19 +342,19 @@ const {
save_draft: saveDraft,
read_draft: readDraft,
clear_draft: clearDraft,
cleanup_expired: cleanupExpiredDrafts
cleanup_expired: cleanupExpiredDrafts,
} = useCheckinDraft()
// 草稿Key
const draftKey = computed(() => {
return buildDraftKey({
const draftKey = computed(() =>
buildDraftKey({
user_id: currentUser.value?.id,
task_id: route.query.task_id,
date: route.query.date,
task_type: route.query.task_type,
status: route.query.status || 'create'
status: route.query.status || 'create',
})
})
)
// 动态字段文字
const dynamicFieldText = ref('感恩')
......@@ -307,17 +413,21 @@ const autoSaveDraft = debounce(() => {
file_list: fileList.value, // save_draft内部会过滤done状态
count: {
gratitude_count: countValue.value,
gratitude_form_list: selectedTargets.value
}
gratitude_form_list: selectedTargets.value,
},
}
saveDraft(draftKey.value, payload)
}, 500)
// 监听数据变化触发自动保存
watch([message, fileList, selectedTaskValue, countValue, selectedTargets], () => {
watch(
[message, fileList, selectedTaskValue, countValue, selectedTargets],
() => {
autoSaveDraft()
}, { deep: true })
},
{ deep: true }
)
// 页面离开前强制保存一次
onBeforeRouteLeave(() => {
......@@ -351,13 +461,16 @@ const checkAndRestoreDraft = async () => {
// 校验草稿中的作业是否仍然有效
// 如果草稿中包含具体的作业ID,必须确保该作业在当前可用的作业列表(taskOptions)中存在
const draftSubtaskId = payload.subtask_id || (payload.selected_task_value && payload.selected_task_value[0])
const draftSubtaskId =
payload.subtask_id || (payload.selected_task_value && payload.selected_task_value[0])
if (draftSubtaskId) {
// taskOptions 已经在 onMounted 中加载完毕
const isValidTask = taskOptions.value.some(option => option.value == draftSubtaskId)
const isValidTask = taskOptions.value.some(
option => String(option.value) === String(draftSubtaskId)
)
if (!isValidTask) {
console.log('[草稿清理] 作业已失效,中断恢复流程', draftSubtaskId)
console.warn('[草稿清理] 作业已失效,中断恢复流程', draftSubtaskId)
try {
await showDialog({
title: '草稿已失效',
......@@ -375,7 +488,8 @@ const checkAndRestoreDraft = async () => {
}
// 检查是否有实质内容
const hasContent = (payload.message && payload.message.trim()) ||
const hasContent =
(payload.message && payload.message.trim()) ||
(payload.file_list && payload.file_list.length > 0)
if (!hasContent) return
......@@ -385,11 +499,9 @@ const checkAndRestoreDraft = async () => {
title: '发现未提交的草稿',
message: '上次编辑的内容未提交,是否恢复?',
confirmButtonText: '恢复',
cancelButtonText: '丢弃'
cancelButtonText: '丢弃',
})
// 确认恢复
console.log('[草稿恢复] 开始恢复数据', payload)
if (payload.message) message.value = payload.message
if (payload.active_type) activeType.value = payload.active_type
......@@ -400,7 +512,7 @@ const checkAndRestoreDraft = async () => {
fileList.value = payload.file_list.map(f => ({
...f,
status: 'done',
message: '已上传'
message: '已上传',
}))
}
......@@ -440,7 +552,11 @@ const checkAndRestoreDraft = async () => {
// 恢复感恩列表(计数对象)
// 必须在恢复 selectedTaskValue 之后执行,因为 fetchTargetList 依赖 subtask_id
if (payload.count?.gratitude_form_list && Array.isArray(payload.count.gratitude_form_list) && payload.count.gratitude_form_list.length > 0) {
if (
payload.count?.gratitude_form_list &&
Array.isArray(payload.count.gratitude_form_list) &&
payload.count.gratitude_form_list.length > 0
) {
const savedList = payload.count.gratitude_form_list
// 如果有作业ID,先获取基础列表
......@@ -457,8 +573,9 @@ const checkAndRestoreDraft = async () => {
savedList.forEach(savedItem => {
// 尝试在 targetList 中找到对应项(获取最新状态/引用)
const existingItem = targetList.value.find(t =>
(savedItem.id && t.id && t.id == savedItem.id) ||
const existingItem = targetList.value.find(
t =>
(savedItem.id && t.id && String(t.id) === String(savedItem.id)) ||
(!savedItem.id && savedItem.name === t.name)
)
......@@ -469,7 +586,7 @@ const checkAndRestoreDraft = async () => {
// 如果 targetList 里没有(可能是新增的,或者 targetList 变了),则直接使用草稿项
restoredTargets.push({
...savedItem,
has_confirmed: true
has_confirmed: true,
})
// 同时也加到 targetList 里显示出来(如果是新增的)
targetList.value.push(restoredTargets[restoredTargets.length - 1])
......@@ -484,7 +601,6 @@ const checkAndRestoreDraft = async () => {
}
showToast('已恢复草稿')
} catch (e) {
// 取消恢复,清除草稿
if (e !== 'cancel') console.error(e)
......@@ -493,8 +609,7 @@ const checkAndRestoreDraft = async () => {
}
}
const beforeReadGuard = (file) => {
const beforeReadGuard = file => {
const files = Array.isArray(file) ? file : [file]
if (activeType.value === 'video') {
const hasMov = files.some(item => {
......@@ -506,7 +621,8 @@ const beforeReadGuard = (file) => {
if (hasMov) {
showDialog({
title: '不支持 MOV 格式',
message: 'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。',
message:
'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。',
confirmButtonText: '我知道了',
})
return false
......@@ -530,7 +646,8 @@ const beforeReadGuard = (file) => {
if (unsupportedFiles.length > 0) {
showDialog({
title: '不支持的音频格式',
message: '当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。',
message:
'当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。',
confirmButtonText: '我知道了',
})
return false
......@@ -547,8 +664,8 @@ const beforeReadGuard = (file) => {
* @param {string} type - 文件类型
* @returns {number} 文件数量
*/
const getTypeCount = (type) => {
return fileList.value.filter(item => {
const getTypeCount = type =>
fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === type
}
......@@ -560,7 +677,6 @@ const getTypeCount = (type) => {
}
return false
}).length
}
/**
* 当前显示的(经过类型过滤的)文件列表
......@@ -569,8 +685,8 @@ const getTypeCount = (type) => {
* 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList
*/
const displayFileList = computed({
get: () => {
return fileList.value.filter(item => {
get: () =>
fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === activeType.value
}
......@@ -580,9 +696,8 @@ const displayFileList = computed({
if (activeType.value === 'audio') return item.file.type.startsWith('audio/')
}
return false
})
},
set: (val) => {
}),
set: val => {
// 找出不属于当前视图的其他文件(保留它们)
const otherFiles = fileList.value.filter(item => {
if (item.file_type) {
......@@ -600,13 +715,9 @@ const displayFileList = computed({
// 合并其他文件和当前视图的新文件列表
fileList.value = [...otherFiles, ...val]
}
},
})
const maxFileSizeBytes = computed(() => {
const size = Number(maxFileSizeMb.value || 0)
if (!Number.isFinite(size) || size <= 0) return 20 * 1024 * 1024
......@@ -626,9 +737,7 @@ const displayTaskNote = computed(() => {
// 打卡类型
const taskType = computed(() => route.query.task_type)
const fetchTargetList = async (subtask_id) => {
const fetchTargetList = async subtask_id => {
const { code, data } = await reuseGratitudeFormAPI({ subtask_id })
if (code === 1) {
targetList.value = data.gratitude_form_list || []
......@@ -640,8 +749,9 @@ const fetchTargetList = async (subtask_id) => {
const validTargets = []
lastUsedTargetList.value.forEach(lastItem => {
const targetItem = targetList.value.find(t =>
(lastItem.id && t.id && t.id == lastItem.id) ||
const targetItem = targetList.value.find(
t =>
(lastItem.id && t.id && String(t.id) === String(lastItem.id)) ||
(!lastItem.id && lastItem.name === t.name)
)
......@@ -654,8 +764,9 @@ const fetchTargetList = async (subtask_id) => {
// 将这些项加入 selectedTargets(去重)
validTargets.forEach(item => {
const exists = selectedTargets.value.some(t =>
(item.id && t.id && t.id == item.id) ||
const exists = selectedTargets.value.some(
t =>
(item.id && t.id && String(t.id) === String(item.id)) ||
(!item.id && t.name === item.name)
)
if (!exists) {
......@@ -666,24 +777,20 @@ const fetchTargetList = async (subtask_id) => {
}
}
/**
* 更新动态表单字段
* @description 根据选中的作业选项更新动态表单字段配置
* @param {Object} option - 选中的作业选项
*/
const updateDynamicFormFields = (option) => {
const updateDynamicFormFields = option => {
if (option.field_list && Array.isArray(option.field_list)) {
// 处理动态表单字段
dynamicFormFields.value = option.field_list.map(field => {
return {
dynamicFormFields.value = option.field_list.map(field => ({
id: field.field || field.field_name || field.name || field.id, // 兼容多种字段名
label: field.label || '未命名',
type: field.type || 'text', // 默认类型,如果后端有类型字段可替换
required: true // 默认必填,如果后端有必填字段可替换
}
})
required: true, // 默认必填,如果后端有必填字段可替换
}))
// 确保如果有city字段,类型为textarea
const cityField = dynamicFormFields.value.find(f => f.id === 'city')
if (cityField) {
......@@ -745,11 +852,11 @@ const onConfirmTask = async ({ selectedOptions }) => {
// }
// })
const toggleTarget = (item) => {
const toggleTarget = item => {
// 优先使用id匹配,如果id不存在,则使用name匹配
const index = selectedTargets.value.findIndex(t => (item.id ? t.id === item.id : t.name === item.name))
const index = selectedTargets.value.findIndex(t =>
item.id ? t.id === item.id : t.name === item.name
)
if (index > -1) {
// 取消选中
selectedTargets.value.splice(index, 1)
......@@ -771,9 +878,9 @@ const toggleTarget = (item) => {
* @description 重置编辑状态并显示弹窗
*/
const openAddTargetDialog = () => {
editingTarget.value = null; // 重置编辑对象
isConfirmMode.value = false;
showAddTargetDialog.value = true;
editingTarget.value = null // 重置编辑对象
isConfirmMode.value = false
showAddTargetDialog.value = true
}
/**
......@@ -781,7 +888,7 @@ const openAddTargetDialog = () => {
* @description 处理弹窗确认事件,更新本地列表和选中状态
* @param {Array} formFields - 表单字段数组,包含字段ID和值
*/
const confirmAddTarget = async (formFields) => {
const confirmAddTarget = formFields => {
// 将表单字段数组转换为对象
const formData = formFields.reduce((acc, field) => {
if (field.id) {
......@@ -802,8 +909,9 @@ const confirmAddTarget = async (formFields) => {
}
// 检查是否在选中列表中
const selectedIndex = selectedTargets.value.findIndex(t =>
(editingTarget.value.id && t.id && t.id == editingTarget.value.id) ||
const selectedIndex = selectedTargets.value.findIndex(
t =>
(editingTarget.value.id && t.id && String(t.id) === String(editingTarget.value.id)) ||
(!editingTarget.value.id && t.name === editingTarget.value.name)
)
......@@ -818,7 +926,7 @@ const confirmAddTarget = async (formFields) => {
// 新增成功,更新本地列表
const newTarget = {
...formData,
has_confirmed: true // 新增的对象默认已确认
has_confirmed: true, // 新增的对象默认已确认
}
targetList.value.push(newTarget)
// 默认勾选新增的对象
......@@ -826,7 +934,7 @@ const confirmAddTarget = async (formFields) => {
showToast('新增成功')
}
showAddTargetDialog.value = false;
showAddTargetDialog.value = false
}
/**
......@@ -834,7 +942,7 @@ const confirmAddTarget = async (formFields) => {
* @description 打开弹窗并填充当前对象数据进行编辑
* @param {Object} item - 待编辑的计数对象
*/
const handleTargetEdit = (item) => {
const handleTargetEdit = item => {
editingTarget.value = item
isConfirmMode.value = false // 明确设置为非确认模式
showAddTargetDialog.value = true
......@@ -845,7 +953,7 @@ const handleTargetEdit = (item) => {
* @description 从本地列表和选中列表中移除对象(暂未调用后端接口)
* @param {Object} item - 待删除的计数对象
*/
const handleTargetDelete = async (item) => {
const handleTargetDelete = async item => {
// 屏蔽删除功能, 那个接口也是不存在的
// const { code } = await gratitudeDeleteAPI({ id: item.id })
// if (code === 1) {
......@@ -854,13 +962,11 @@ const handleTargetDelete = async (item) => {
// 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)
// }
// showToast('删除成功')
// }
}
......@@ -887,10 +993,9 @@ const isSubmitDisabled = computed(() => {
if (activeType.value === 'text') {
// 文本打卡:必须填写内容且长度不少于10个字符
return !message.value.trim() || message.value.trim().length < 10
} else {
}
// 其他类型:必须有文件 (如果是混合模式,只要有文件就行)
return fileList.value.length === 0
}
})
/**
......@@ -902,7 +1007,8 @@ const handleSubmit = async () => {
// 计数打卡校验
if (taskType.value === 'count') {
if (selectedTaskValue.value.length === 0) {
const taskText = taskOptions.value.find(t => t.value === selectedTaskValue.value[0])?.text || '作业'
const taskText =
taskOptions.value.find(t => t.value === selectedTaskValue.value[0])?.text || '作业'
showToast(`请选择${taskText}`)
return
}
......@@ -914,7 +1020,7 @@ const handleSubmit = async () => {
}
const extraData = {
subtask_id: selectedTaskValue.value.length > 0 ? selectedTaskValue.value[0] : ''
subtask_id: selectedTaskValue.value.length > 0 ? selectedTaskValue.value[0] : '',
}
// 如果是计数打卡,添加选中的计数对象列表, 并添加次数
......@@ -933,13 +1039,9 @@ const handleSubmit = async () => {
await onSubmit(extraData, onSuccess)
}
// 是否为编辑模式
const isEditMode = computed(() => route.query.status === 'edit')
/**
* 返回上一页
*/
......@@ -952,12 +1054,12 @@ const onClickLeft = () => {
* @param {string} type - 打卡类型
* @returns {string} 图标名称
*/
const getIconName = (type) => {
const getIconName = type => {
const iconMap = {
'text': 'edit',
'image': 'photo',
'video': 'video',
'audio': 'music'
text: 'edit',
image: 'photo',
video: 'video',
audio: 'music',
}
return iconMap[type] || 'edit'
}
......@@ -968,9 +1070,9 @@ const getIconName = (type) => {
*/
const getFileIcon = () => {
const iconMap = {
'image': 'photo',
'video': 'video',
'audio': 'music'
image: 'photo',
video: 'video',
audio: 'music',
}
return iconMap[activeType.value] || 'description'
}
......@@ -981,9 +1083,9 @@ const getFileIcon = () => {
*/
const getAcceptType = () => {
const acceptMap = {
'image': 'image/*',
'video': '.mp4,video/mp4',
'audio': '.mp3,.m4a,.aac,.wav'
image: 'image/*',
video: '.mp4,video/mp4',
audio: '.mp3,.m4a,.aac,.wav',
}
return acceptMap[activeType.value] || '*'
}
......@@ -994,9 +1096,9 @@ const getAcceptType = () => {
*/
const getUploadTips = () => {
const tipsMap = {
'image': '支持格式:.jpg/.jpeg/.png',
'video': '支持格式:.mp4(不支持 .mov)',
'audio': '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)'
image: '支持格式:.jpg/.jpeg/.png',
video: '支持格式:.mp4(不支持 .mov)',
audio: '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)',
}
return tipsMap[activeType.value] || ''
}
......@@ -1005,7 +1107,7 @@ const getUploadTips = () => {
* 获取任务详情
* @param {string} month - 月份
*/
const getTaskDetail = async (month) => {
const getTaskDetail = async month => {
const { code, data } = await getTaskDetailAPI({ i: route.query.task_id, month })
if (code === 1) {
taskDetail.value = data
......@@ -1016,7 +1118,7 @@ const getTaskDetail = async (month) => {
* 更新附件类型选项
* @param {Array|Object} attachmentType - 附件类型数据
*/
const updateAttachmentTypeOptions = (attachmentType) => {
const updateAttachmentTypeOptions = attachmentType => {
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig(attachmentType)
attachmentTypeOptions.value = options
......@@ -1027,7 +1129,9 @@ const updateAttachmentTypeOptions = (attachmentType) => {
// 如果是计数打卡(count),过滤掉文本(text)类型
if (taskType.value === 'count') {
attachmentTypeOptions.value = attachmentTypeOptions.value.filter(option => option.key !== 'text')
attachmentTypeOptions.value = attachmentTypeOptions.value.filter(
option => option.key !== 'text'
)
}
// 设置默认选中类型(非计数打卡模式下)
......@@ -1044,82 +1148,60 @@ const updateAttachmentTypeOptions = (attachmentType) => {
* @param {Object} file - 文件对象
* @param {Object} detail - 详细信息
*/
const onClickPreview = (file, detail) => {
console.log('onClickPreview - file:', file)
console.log('onClickPreview - detail:', detail)
console.log('file对象的所有属性:', Object.keys(file))
const onClickPreview = file => {
const fileName = file.name || file.file?.name || ''
// 尝试多种方式获取文件URL
let fileUrl = ''
// 方式1: 直接从file对象获取
if (file.url) {
fileUrl = file.url
console.log('从file.url获取URL:', fileUrl)
}
// 方式2: 从file.content获取
else if (file.content) {
} else if (file.content) {
fileUrl = file.content
console.log('从file.content获取URL:', fileUrl)
}
// 方式3: 从file.objectURL获取
else if (file.objectURL) {
} else if (file.objectURL) {
fileUrl = file.objectURL
console.log('从file.objectURL获取URL:', fileUrl)
}
// 方式4: 从file.file获取
else if (file.file) {
} else if (file.file) {
if (file.file.url) {
fileUrl = file.file.url
console.log('从file.file.url获取URL:', fileUrl)
} else {
// 创建临时URL
try {
fileUrl = URL.createObjectURL(file.file)
console.log('通过URL.createObjectURL创建URL:', fileUrl)
} catch (error) {
console.error('创建ObjectURL失败:', error)
}
}
}
// 方式5: 检查是否有其他可能的URL字段
else {
} else {
const possibleUrlFields = ['src', 'path', 'value', 'href', 'link']
for (const field of possibleUrlFields) {
if (file[field]) {
fileUrl = file[field]
console.log(`从file.${field}获取URL:`, fileUrl)
break
}
}
}
console.log('最终提取的文件名:', fileName)
console.log('最终提取的文件URL:', fileUrl)
if (!fileUrl) {
console.warn('文件URL不存在,文件对象完整结构:', JSON.stringify(file, null, 2))
showToast('无法获取文件URL,请检查文件是否上传成功')
return
}
// 根据打卡类型或文件扩展名判断文件类型
const finalFileType = file.file_type || (isAudioFile(fileName) ? 'audio' : (isVideoFile(fileName) ? 'video' : 'image'))
let finalFileType = file.file_type
if (!finalFileType) {
if (isAudioFile(fileName)) {
finalFileType = 'audio'
} else if (isVideoFile(fileName)) {
finalFileType = 'video'
} else {
finalFileType = 'image'
}
}
if (finalFileType === 'audio') {
console.log('准备播放音频:', fileName, fileUrl)
showAudio(fileName, fileUrl)
} else if (finalFileType === 'video') {
console.log('准备播放视频:', fileName, fileUrl)
showVideo(fileName, fileUrl)
} else if (finalFileType === 'image') {
console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览')
// 图片预览由van-uploader的@click-preview事件处理,避免重复弹出
return
} else {
console.log('该文件类型不支持预览,文件名:', fileName, '类型:', finalFileType)
showToast('该文件类型不支持预览')
}
}
......@@ -1214,7 +1296,7 @@ const onClickPreview = (file, detail) => {
* @param {string} fileName - 文件名
* @returns {boolean}
*/
const isAudioFile = (fileName) => {
const isAudioFile = fileName => {
const audioExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma']
return audioExtensions.some(ext => fileName.toLowerCase().includes(ext))
}
......@@ -1224,7 +1306,7 @@ const isAudioFile = (fileName) => {
* @param {string} fileName - 文件名
* @returns {boolean}
*/
const isVideoFile = (fileName) => {
const isVideoFile = fileName => {
const videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv']
return videoExtensions.some(ext => fileName.toLowerCase().includes(ext))
}
......@@ -1234,7 +1316,7 @@ const isVideoFile = (fileName) => {
* @param {string} fileName - 文件名
* @returns {boolean}
*/
const isImageFile = (fileName) => {
const isImageFile = fileName => {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
return imageExtensions.some(ext => fileName.toLowerCase().includes(ext))
}
......@@ -1320,11 +1402,11 @@ const stopVideoPlay = () => {
*/
onMounted(async () => {
// 获取任务详情
const current_date = route.query.date;
const current_date = route.query.date
if (current_date) {
getTaskDetail(dayjs(current_date).format('YYYY-MM'));
getTaskDetail(dayjs(current_date).format('YYYY-MM'))
} else {
getTaskDetail(dayjs().format('YYYY-MM'));
getTaskDetail(dayjs().format('YYYY-MM'))
}
// 初始化选中的子任务ID
......@@ -1333,15 +1415,16 @@ onMounted(async () => {
// 获取小作业列表
const subtask_list = await getSubtaskListAPI({ task_id: route.query.task_id, date: current_date })
if (subtask_list.code === 1) {
taskOptions.value = [...subtask_list.data.map(item => ({
text: item.is_makeup ? '补卡:' + item.title : item.title,
taskOptions.value = [
...subtask_list.data.map(item => ({
text: item.is_makeup ? `补卡:${item.title}` : item.title,
value: item.id,
note: item.note, // 作业描述
is_makeup: item.is_makeup, // 是否为补录
field_list: item.field_list, // 动态字段列表
person_type: item.person_type, // 打卡对象类型
attachment_type: item.attachment_type, // 附件类型
}))
})),
]
}
......@@ -1370,7 +1453,7 @@ onMounted(async () => {
// 初始化编辑数据
await initEditData(taskOptions.value, {
onTaskFound: (option) => {
onTaskFound: option => {
updateDynamicFormFields(option)
// 更新附件类型选项
if (option.attachment_type) {
......@@ -1379,7 +1462,7 @@ onMounted(async () => {
updateAttachmentTypeOptions(taskDetail.value.attachment_type)
}
},
ensureTargetList: async (id) => {
ensureTargetList: async id => {
if (targetList.value.length === 0) {
await fetchTargetList(id)
}
......@@ -1390,9 +1473,9 @@ onMounted(async () => {
// selectedTargets.value = list
// }
// },
setCount: (val) => {
setCount: val => {
countValue.value = val
}
},
})
// 尝试恢复草稿 (非编辑模式)
......@@ -1439,14 +1522,16 @@ onMounted(async () => {
line-height: 1.6;
font-size: 0.95rem;
word-break: break-word;
overflow-wrap: break-word;
overflow-wrap: anywhere;
width: 100%;
box-sizing: border-box;
overflow: hidden;
:deep(*) {
max-width: 100% !important;
box-sizing: border-box;
white-space: normal !important;
overflow-wrap: anywhere;
word-break: break-word;
}
:deep(img) {
......