You need to sign in or sign up before continuing.
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>
......@@ -6,105 +6,163 @@
* @Description: 用户打卡详情页
-->
<template>
<div class="checkin-detail-page">
<!-- 页面内容 -->
<div class="page-content">
<!-- 作业描述 -->
<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>
<div class="checkin-detail-page">
<!-- 页面内容 -->
<div class="page-content">
<!-- 作业描述 -->
<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>
</div>
<!-- 打卡内容区域 -->
<div class="section-wrapper">
<div class="section-title">提交作业</div>
<div class="section-content">
<!-- 作业选择区域 -->
<div class="mb-4">
<!-- 编辑模式下直接显示文本 -->
<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>
<!-- 打卡内容区域 -->
<div class="section-wrapper">
<div class="section-title">提交作业</div>
<div class="section-content">
<!-- 作业选择区域 -->
<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>
<!-- 非编辑模式下显示选择框 -->
<template v-else>
<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-popup>
</template>
</div>
<!-- 计数对象 -->
<CheckinTargetList
v-if="taskType === 'count' && selectedTaskValue && selectedTaskValue.length > 0"
:dynamic-field-text="dynamicFieldText"
:target-list="targetList"
:selected-targets="selectedTargets"
@add="openAddTargetDialog"
@toggle="toggleTarget"
@edit="handleTargetEdit"
@delete="handleTargetDelete"
/>
<!-- 计数次数 -->
<div v-if="taskType === 'count'"
class="mb-4 flex items-center justify-between bg-gray-50 p-3 rounded-lg">
<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" />
</div>
<!-- 新增计数对象弹框 -->
<AddTargetDialog
v-model:show="showAddTargetDialog"
:title="editingTarget ? (isConfirmMode ? `确认${dynamicFieldText}项` : `编辑${dynamicFieldText}项`) : `添加${dynamicFieldText}项`"
:fields="dynamicFormFields"
:initial-values="editingTarget"
@confirm="confirmAddTarget"
/>
<!-- 文本输入区域 -->
<div class="text-input-area">
<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="tabs-nav">
<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">
{{ getTypeCount(option.key) }}
</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-popup v-model:show="showTaskPicker" round position="bottom">
<van-picker
:columns="taskOptions"
@cancel="showTaskPicker = false"
@confirm="onConfirmTask"
/>
</van-popup>
</template>
</div>
<!-- 计数对象 -->
<CheckinTargetList
v-if="taskType === 'count' && selectedTaskValue && selectedTaskValue.length > 0"
:dynamic-field-text="dynamicFieldText"
:target-list="targetList"
:selected-targets="selectedTargets"
@add="openAddTargetDialog"
@toggle="toggleTarget"
@edit="handleTargetEdit"
@delete="handleTargetDelete"
/>
<!-- 计数次数 -->
<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"
/>
</div>
<!-- 新增计数对象弹框 -->
<AddTargetDialog
v-model:show="showAddTargetDialog"
:title="
editingTarget
? isConfirmMode
? `确认${dynamicFieldText}项`
: `编辑${dynamicFieldText}项`
: `添加${dynamicFieldText}项`
"
:fields="dynamicFormFields"
:initial-values="editingTarget"
@confirm="confirmAddTarget"
/>
<!-- 文本输入区域 -->
<div class="text-input-area">
<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="tabs-nav">
<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 -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>
</div>
</div>
<!-- 文件上传区域 -->
<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" />
<!-- 文件列表显示 -->
<!-- <div v-if="fileList.length > 0" class="file-list">
</div>
</div>
<!-- 文件上传区域 -->
<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"
/>
<!-- 文件列表显示 -->
<!-- <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" @click="previewFile(item)">
<van-icon :name="getFileIcon()" size="1rem" />
......@@ -115,75 +173,123 @@
</div>
</div> -->
<div class="upload-tips">
<div class="tip-text">最多上传{{ maxCount }}个文件,每个不超过{{ maxFileSizeMb }}MB</div>
<div class="tip-text">{{ getUploadTips() }}</div>
</div>
</div>
</div>
<div class="upload-tips">
<div class="tip-text">
最多上传{{ maxCount }}个文件,每个不超过{{ maxFileSizeMb }}MB
</div>
<div class="tip-text">{{ getUploadTips() }}</div>
</div>
</div>
<!-- 提交按钮 -->
<div v-if="!taskDetail.is_finish || isEditMode" class="submit-area">
<van-button type="primary" block size="large" :loading="uploading" :disabled="isSubmitDisabled" @click="handleSubmit">
{{ isEditMode ? '保存修改' : '提交' }}
</van-button>
</div>
</div>
</div>
</div>
<!-- 提交按钮 -->
<div v-if="!taskDetail.is_finish || isEditMode" class="submit-area">
<van-button
type="primary"
block
size="large"
:loading="uploading"
:disabled="isSubmitDisabled"
@click="handleSubmit"
>
{{ isEditMode ? '保存修改' : '提交' }}
</van-button>
</div>
</div>
<!-- 上传加载遮罩 -->
<van-overlay :show="loading" z-index="9999">
<div class="loading-wrapper" @click.stop>
<van-loading vertical color="#FFFFFF">上传中...</van-loading>
</div>
</van-overlay>
<!-- 音频播放器弹窗 -->
<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" />
</div>
</van-popup>
<!-- 视频播放器弹窗 -->
<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;">
<!-- 视频封面 -->
<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 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">
<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" />
</div>
<!-- 上传加载遮罩 -->
<van-overlay :show="loading" z-index="9999">
<div class="loading-wrapper" @click.stop>
<van-loading vertical color="#FFFFFF">上传中...</van-loading>
</div>
</van-overlay>
<!-- 音频播放器弹窗 -->
<van-popup
v-model:show="audioShow"
position="bottom"
round
closeable
:style="{ height: '60%', width: '100%' }"
>
<div class="p-4">
<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"
>
<div class="p-4">
<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 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="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>
</van-popup>
<!-- 图片预览弹窗 -->
<van-image-preview v-model:show="imageShow" :images="imageList" :start-position="imageIndex" :show-index="true" />
</div>
</div>
<!-- 视频播放器 -->
<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"
/>
</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'
......@@ -204,51 +310,51 @@ useTitle('提交作业')
// 使用打卡composable
const {
uploading,
loading,
message,
fileList,
activeType,
multiAttachmentEnabled,
subTaskId,
selectedTaskText,
selectedTaskValue,
isMakeup,
maxCount,
maxFileSizeMb,
canSubmit,
setMaxFileSizeMbMap,
beforeRead,
afterRead,
onDelete,
delItem,
onSubmit,
switchType,
initEditData,
gratitudeCount,
gratitudeFormList
uploading,
loading,
message,
fileList,
activeType,
multiAttachmentEnabled,
subTaskId,
selectedTaskText,
selectedTaskValue,
isMakeup,
maxCount,
maxFileSizeMb,
canSubmit,
setMaxFileSizeMbMap,
beforeRead,
afterRead,
onDelete,
delItem,
onSubmit,
switchType,
initEditData,
gratitudeCount,
gratitudeFormList,
} = useCheckin()
// 使用草稿缓存composable
const {
is_enabled: isDraftEnabled,
build_key: buildDraftKey,
save_draft: saveDraft,
read_draft: readDraft,
clear_draft: clearDraft,
cleanup_expired: cleanupExpiredDrafts
is_enabled: isDraftEnabled,
build_key: buildDraftKey,
save_draft: saveDraft,
read_draft: readDraft,
clear_draft: clearDraft,
cleanup_expired: cleanupExpiredDrafts,
} = useCheckinDraft()
// 草稿Key
const draftKey = computed(() => {
return 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'
})
})
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',
})
)
// 动态字段文字
const dynamicFieldText = ref('感恩')
......@@ -297,249 +403,260 @@ const imageIndex = ref(0)
* @description 使用debounce防抖,避免频繁写入
*/
const autoSaveDraft = debounce(() => {
if (!isDraftEnabled() || route.query.status === 'edit') return
const payload = {
message: message.value,
active_type: activeType.value,
subtask_id: selectedTaskValue.value?.[0],
selected_task_value: selectedTaskValue.value,
file_list: fileList.value, // save_draft内部会过滤done状态
count: {
gratitude_count: countValue.value,
gratitude_form_list: selectedTargets.value
}
}
if (!isDraftEnabled() || route.query.status === 'edit') return
const payload = {
message: message.value,
active_type: activeType.value,
subtask_id: selectedTaskValue.value?.[0],
selected_task_value: selectedTaskValue.value,
file_list: fileList.value, // save_draft内部会过滤done状态
count: {
gratitude_count: countValue.value,
gratitude_form_list: selectedTargets.value,
},
}
saveDraft(draftKey.value, payload)
saveDraft(draftKey.value, payload)
}, 500)
// 监听数据变化触发自动保存
watch([message, fileList, selectedTaskValue, countValue, selectedTargets], () => {
watch(
[message, fileList, selectedTaskValue, countValue, selectedTargets],
() => {
autoSaveDraft()
}, { deep: true })
},
{ deep: true }
)
// 页面离开前强制保存一次
onBeforeRouteLeave(() => {
autoSaveDraft()
autoSaveDraft.flush() // 立即执行待处理的保存
autoSaveDraft()
autoSaveDraft.flush() // 立即执行待处理的保存
})
// 页面卸载前保存(兼容刷新/关闭tab)
const handleBeforeUnload = () => {
autoSaveDraft()
autoSaveDraft.flush()
autoSaveDraft()
autoSaveDraft.flush()
}
window.addEventListener('beforeunload', handleBeforeUnload)
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
/**
* 检查并恢复草稿
*/
const checkAndRestoreDraft = async () => {
if (!isDraftEnabled() || route.query.status === 'edit') return
// 先清理过期草稿
cleanupExpiredDrafts()
const draft = readDraft(draftKey.value)
if (!draft || !draft.payload) return
const { payload } = draft
// 校验草稿中的作业是否仍然有效
// 如果草稿中包含具体的作业ID,必须确保该作业在当前可用的作业列表(taskOptions)中存在
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)
if (!isValidTask) {
console.log('[草稿清理] 作业已失效,中断恢复流程', draftSubtaskId)
try {
await showDialog({
title: '草稿已失效',
message: '您之前暂存的作业已失效(可能已截止或被删除),无法恢复。',
confirmButtonText: '清空草稿',
theme: 'round-button',
})
} catch (e) {
// 用户点击确认或其他关闭操作
} finally {
clearDraft(draftKey.value)
}
return
}
if (!isDraftEnabled() || route.query.status === 'edit') return
// 先清理过期草稿
cleanupExpiredDrafts()
const draft = readDraft(draftKey.value)
if (!draft || !draft.payload) return
const { payload } = draft
// 校验草稿中的作业是否仍然有效
// 如果草稿中包含具体的作业ID,必须确保该作业在当前可用的作业列表(taskOptions)中存在
const draftSubtaskId =
payload.subtask_id || (payload.selected_task_value && payload.selected_task_value[0])
if (draftSubtaskId) {
// taskOptions 已经在 onMounted 中加载完毕
const isValidTask = taskOptions.value.some(
option => String(option.value) === String(draftSubtaskId)
)
if (!isValidTask) {
console.warn('[草稿清理] 作业已失效,中断恢复流程', draftSubtaskId)
try {
await showDialog({
title: '草稿已失效',
message: '您之前暂存的作业已失效(可能已截止或被删除),无法恢复。',
confirmButtonText: '清空草稿',
theme: 'round-button',
})
} catch (e) {
// 用户点击确认或其他关闭操作
} finally {
clearDraft(draftKey.value)
}
return
}
}
// 检查是否有实质内容
const hasContent = (payload.message && payload.message.trim()) ||
(payload.file_list && payload.file_list.length > 0)
// 检查是否有实质内容
const hasContent =
(payload.message && payload.message.trim()) ||
(payload.file_list && payload.file_list.length > 0)
if (!hasContent) return
if (!hasContent) return
try {
await showConfirmDialog({
title: '发现未提交的草稿',
message: '上次编辑的内容未提交,是否恢复?',
confirmButtonText: '恢复',
cancelButtonText: '丢弃'
})
// 确认恢复
console.log('[草稿恢复] 开始恢复数据', payload)
if (payload.message) message.value = payload.message
if (payload.active_type) activeType.value = payload.active_type
try {
await showConfirmDialog({
title: '发现未提交的草稿',
message: '上次编辑的内容未提交,是否恢复?',
confirmButtonText: '恢复',
cancelButtonText: '丢弃',
})
// 确认恢复
if (payload.message) message.value = payload.message
if (payload.active_type) activeType.value = payload.active_type
// 恢复文件列表 (注意:只恢复了已完成上传的元数据,无法恢复File对象,所以无法继续上传/断点续传)
// 恢复后的文件状态设为done
if (payload.file_list && payload.file_list.length > 0) {
fileList.value = payload.file_list.map(f => ({
...f,
status: 'done',
message: '已上传',
}))
}
// 恢复文件列表 (注意:只恢复了已完成上传的元数据,无法恢复File对象,所以无法继续上传/断点续传)
// 恢复后的文件状态设为done
if (payload.file_list && payload.file_list.length > 0) {
fileList.value = payload.file_list.map(f => ({
...f,
status: 'done',
message: '已上传'
}))
}
// 恢复选中的作业
if (payload.selected_task_value && payload.selected_task_value.length > 0) {
selectedTaskValue.value = payload.selected_task_value
// 触发联动逻辑 (如updateDynamicFormFields等),这部分在selectedTaskValue的watcher或onConfirmTask中处理
// 但这里直接赋值可能不会触发onConfirmTask的逻辑,需要手动处理部分联动
// 恢复选中的作业
if (payload.selected_task_value && payload.selected_task_value.length > 0) {
selectedTaskValue.value = payload.selected_task_value
// 触发联动逻辑 (如updateDynamicFormFields等),这部分在selectedTaskValue的watcher或onConfirmTask中处理
// 但这里直接赋值可能不会触发onConfirmTask的逻辑,需要手动处理部分联动
// 等待taskOptions加载完毕后再匹配text
const matchOption = () => {
const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0])
if (option) {
selectedTaskText.value = option.text
isMakeup.value = !!option.is_makeup
personType.value = option.person_type
updateDynamicFormFields(option)
if (option.attachment_type) updateAttachmentTypeOptions(option.attachment_type)
}
}
if (taskOptions.value.length > 0) {
matchOption()
} else {
// 如果选项还没加载完,watch taskOptions
const unwatch = watch(taskOptions, () => {
matchOption()
unwatch()
})
}
// 等待taskOptions加载完毕后再匹配text
const matchOption = () => {
const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0])
if (option) {
selectedTaskText.value = option.text
isMakeup.value = !!option.is_makeup
personType.value = option.person_type
updateDynamicFormFields(option)
if (option.attachment_type) updateAttachmentTypeOptions(option.attachment_type)
}
}
if (taskOptions.value.length > 0) {
matchOption()
} else {
// 如果选项还没加载完,watch taskOptions
const unwatch = watch(taskOptions, () => {
matchOption()
unwatch()
})
}
}
// 恢复计数
if (payload.count?.gratitude_count) {
countValue.value = payload.count.gratitude_count
}
// 恢复计数
if (payload.count?.gratitude_count) {
countValue.value = payload.count.gratitude_count
}
// 恢复感恩列表(计数对象)
// 必须在恢复 selectedTaskValue 之后执行,因为 fetchTargetList 依赖 subtask_id
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,先获取基础列表
if (selectedTaskValue.value && selectedTaskValue.value.length > 0) {
// 等待 fetchTargetList 完成,然后覆盖默认选中的项
await fetchTargetList(selectedTaskValue.value[0])
// 使用草稿中的列表覆盖 selectedTargets (注意去重或合并策略)
// 这里选择:完全信任草稿中的选中状态。
// 但需要注意:草稿中的对象可能只包含 id/name,而 targetList 中有完整信息。
// 最好是基于 targetList 重新构建 selectedTargets,如果 targetList 中没有(比如新增的),则直接使用草稿中的。
const restoredTargets = []
savedList.forEach(savedItem => {
// 尝试在 targetList 中找到对应项(获取最新状态/引用)
const existingItem = targetList.value.find(t =>
(savedItem.id && t.id && t.id == savedItem.id) ||
(!savedItem.id && savedItem.name === t.name)
)
if (existingItem) {
existingItem.has_confirmed = true // 既然都在草稿里了,肯定是确认过的
restoredTargets.push(existingItem)
} else {
// 如果 targetList 里没有(可能是新增的,或者 targetList 变了),则直接使用草稿项
restoredTargets.push({
...savedItem,
has_confirmed: true
})
// 同时也加到 targetList 里显示出来(如果是新增的)
targetList.value.push(restoredTargets[restoredTargets.length - 1])
}
})
selectedTargets.value = restoredTargets
} else {
// 如果没有作业ID(理论上不应该,因为计数打卡必须选作业),直接恢复
selectedTargets.value = savedList
}
}
// 恢复感恩列表(计数对象)
// 必须在恢复 selectedTaskValue 之后执行,因为 fetchTargetList 依赖 subtask_id
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,先获取基础列表
if (selectedTaskValue.value && selectedTaskValue.value.length > 0) {
// 等待 fetchTargetList 完成,然后覆盖默认选中的项
await fetchTargetList(selectedTaskValue.value[0])
showToast('已恢复草稿')
// 使用草稿中的列表覆盖 selectedTargets (注意去重或合并策略)
// 这里选择:完全信任草稿中的选中状态。
// 但需要注意:草稿中的对象可能只包含 id/name,而 targetList 中有完整信息。
// 最好是基于 targetList 重新构建 selectedTargets,如果 targetList 中没有(比如新增的),则直接使用草稿中的。
const restoredTargets = []
savedList.forEach(savedItem => {
// 尝试在 targetList 中找到对应项(获取最新状态/引用)
const existingItem = targetList.value.find(
t =>
(savedItem.id && t.id && String(t.id) === String(savedItem.id)) ||
(!savedItem.id && savedItem.name === t.name)
)
if (existingItem) {
existingItem.has_confirmed = true // 既然都在草稿里了,肯定是确认过的
restoredTargets.push(existingItem)
} else {
// 如果 targetList 里没有(可能是新增的,或者 targetList 变了),则直接使用草稿项
restoredTargets.push({
...savedItem,
has_confirmed: true,
})
// 同时也加到 targetList 里显示出来(如果是新增的)
targetList.value.push(restoredTargets[restoredTargets.length - 1])
}
})
} catch (e) {
// 取消恢复,清除草稿
if (e !== 'cancel') console.error(e)
clearDraft(draftKey.value)
showToast('已丢弃草稿')
selectedTargets.value = restoredTargets
} else {
// 如果没有作业ID(理论上不应该,因为计数打卡必须选作业),直接恢复
selectedTargets.value = savedList
}
}
}
const beforeReadGuard = (file) => {
const files = Array.isArray(file) ? file : [file]
if (activeType.value === 'video') {
const hasMov = files.some(item => {
const fileName = String(item?.name || '').toLowerCase()
const fileType = String(item?.type || '').toLowerCase()
return fileName.endsWith('.mov') || fileType.includes('quicktime')
})
showToast('已恢复草稿')
} catch (e) {
// 取消恢复,清除草稿
if (e !== 'cancel') console.error(e)
clearDraft(draftKey.value)
showToast('已丢弃草稿')
}
}
if (hasMov) {
showDialog({
title: '不支持 MOV 格式',
message: 'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。',
confirmButtonText: '我知道了',
})
return false
}
const beforeReadGuard = file => {
const files = Array.isArray(file) ? file : [file]
if (activeType.value === 'video') {
const hasMov = files.some(item => {
const fileName = String(item?.name || '').toLowerCase()
const fileType = String(item?.type || '').toLowerCase()
return fileName.endsWith('.mov') || fileType.includes('quicktime')
})
return beforeRead(file)
if (hasMov) {
showDialog({
title: '不支持 MOV 格式',
message:
'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。',
confirmButtonText: '我知道了',
})
return false
}
if (activeType.value === 'audio') {
const supportedExt = ['mp3', 'm4a', 'aac', 'wav']
const unsupportedFiles = files.filter(item => {
const fileName = String(item?.name || '').toLowerCase()
const ext = fileName.includes('.') ? fileName.split('.').pop() : ''
if (supportedExt.includes(ext)) return false
const fileType = String(item?.type || '').toLowerCase()
if (!fileType) return true
if (!fileType.startsWith('audio/')) return true
return true
})
if (unsupportedFiles.length > 0) {
showDialog({
title: '不支持的音频格式',
message: '当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。',
confirmButtonText: '我知道了',
})
return false
}
return beforeRead(file)
}
if (activeType.value === 'audio') {
const supportedExt = ['mp3', 'm4a', 'aac', 'wav']
const unsupportedFiles = files.filter(item => {
const fileName = String(item?.name || '').toLowerCase()
const ext = fileName.includes('.') ? fileName.split('.').pop() : ''
if (supportedExt.includes(ext)) return false
const fileType = String(item?.type || '').toLowerCase()
if (!fileType) return true
if (!fileType.startsWith('audio/')) return true
return true
})
return beforeRead(file)
if (unsupportedFiles.length > 0) {
showDialog({
title: '不支持的音频格式',
message:
'当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。',
confirmButtonText: '我知道了',
})
return false
}
return beforeRead(file)
}
return beforeRead(file)
}
/**
......@@ -547,20 +664,19 @@ const beforeReadGuard = (file) => {
* @param {string} type - 文件类型
* @returns {number} 文件数量
*/
const getTypeCount = (type) => {
return fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === type
}
// 处理刚选择但未上传完成的文件(尝试推断类型)
if (item.file && item.file.type) {
if (type === 'image') return item.file.type.startsWith('image/')
if (type === 'video') return item.file.type.startsWith('video/')
if (type === 'audio') return item.file.type.startsWith('audio/')
}
return false
}).length
}
const getTypeCount = type =>
fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === type
}
// 处理刚选择但未上传完成的文件(尝试推断类型)
if (item.file && item.file.type) {
if (type === 'image') return item.file.type.startsWith('image/')
if (type === 'video') return item.file.type.startsWith('video/')
if (type === 'audio') return item.file.type.startsWith('audio/')
}
return false
}).length
/**
* 当前显示的(经过类型过滤的)文件列表
......@@ -569,139 +685,130 @@ const getTypeCount = (type) => {
* 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList
*/
const displayFileList = computed({
get: () => {
return fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === activeType.value
}
if (item.file && item.file.type) {
if (activeType.value === 'image') return item.file.type.startsWith('image/')
if (activeType.value === 'video') return item.file.type.startsWith('video/')
if (activeType.value === 'audio') return item.file.type.startsWith('audio/')
}
return false
})
},
set: (val) => {
// 找出不属于当前视图的其他文件(保留它们)
const otherFiles = fileList.value.filter(item => {
if (item.file_type) {
return item.file_type !== activeType.value
}
if (item.file && item.file.type) {
if (activeType.value === 'image') return !item.file.type.startsWith('image/')
if (activeType.value === 'video') return !item.file.type.startsWith('video/')
if (activeType.value === 'audio') return !item.file.type.startsWith('audio/')
}
// 如果无法判断类型,且 activeType 不是 text,保守起见保留它?
// 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other
return true
})
get: () =>
fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === activeType.value
}
if (item.file && item.file.type) {
if (activeType.value === 'image') return item.file.type.startsWith('image/')
if (activeType.value === 'video') return item.file.type.startsWith('video/')
if (activeType.value === 'audio') return item.file.type.startsWith('audio/')
}
return false
}),
set: val => {
// 找出不属于当前视图的其他文件(保留它们)
const otherFiles = fileList.value.filter(item => {
if (item.file_type) {
return item.file_type !== activeType.value
}
if (item.file && item.file.type) {
if (activeType.value === 'image') return !item.file.type.startsWith('image/')
if (activeType.value === 'video') return !item.file.type.startsWith('video/')
if (activeType.value === 'audio') return !item.file.type.startsWith('audio/')
}
// 如果无法判断类型,且 activeType 不是 text,保守起见保留它?
// 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other
return true
})
// 合并其他文件和当前视图的新文件列表
fileList.value = [...otherFiles, ...val]
}
// 合并其他文件和当前视图的新文件列表
fileList.value = [...otherFiles, ...val]
},
})
const maxFileSizeBytes = computed(() => {
const size = Number(maxFileSizeMb.value || 0)
if (!Number.isFinite(size) || size <= 0) return 20 * 1024 * 1024
return Math.floor(size * 1024 * 1024)
const size = Number(maxFileSizeMb.value || 0)
if (!Number.isFinite(size) || size <= 0) return 20 * 1024 * 1024
return Math.floor(size * 1024 * 1024)
})
// 显示的作业描述
const displayTaskNote = computed(() => {
const selected_subtask_id = selectedTaskValue.value?.[0]
if (selected_subtask_id) {
const option = taskOptions.value.find(o => o.value === selected_subtask_id)
return option?.note || taskDetail.value?.note || ''
}
return taskDetail.value?.note || ''
const selected_subtask_id = selectedTaskValue.value?.[0]
if (selected_subtask_id) {
const option = taskOptions.value.find(o => o.value === selected_subtask_id)
return option?.note || taskDetail.value?.note || ''
}
return taskDetail.value?.note || ''
})
// 打卡类型
const taskType = computed(() => route.query.task_type)
const fetchTargetList = async (subtask_id) => {
const { code, data } = await reuseGratitudeFormAPI({ subtask_id })
if (code === 1) {
targetList.value = data.gratitude_form_list || []
lastUsedTargetList.value = data.last_used_list || []
// 自动选中上次使用的对象
if (lastUsedTargetList.value.length > 0) {
// 找出 lastUsedTargetList 中存在于 targetList 的项(并获取 targetList 中的完整对象引用)
const validTargets = []
lastUsedTargetList.value.forEach(lastItem => {
const targetItem = targetList.value.find(t =>
(lastItem.id && t.id && t.id == lastItem.id) ||
(!lastItem.id && lastItem.name === t.name)
)
if (targetItem) {
// 标记为已确认,避免再次弹出确认框
targetItem.has_confirmed = true
validTargets.push(targetItem)
}
})
// 将这些项加入 selectedTargets(去重)
validTargets.forEach(item => {
const exists = selectedTargets.value.some(t =>
(item.id && t.id && t.id == item.id) ||
(!item.id && t.name === item.name)
)
if (!exists) {
selectedTargets.value.push(item)
}
})
const fetchTargetList = async subtask_id => {
const { code, data } = await reuseGratitudeFormAPI({ subtask_id })
if (code === 1) {
targetList.value = data.gratitude_form_list || []
lastUsedTargetList.value = data.last_used_list || []
// 自动选中上次使用的对象
if (lastUsedTargetList.value.length > 0) {
// 找出 lastUsedTargetList 中存在于 targetList 的项(并获取 targetList 中的完整对象引用)
const validTargets = []
lastUsedTargetList.value.forEach(lastItem => {
const targetItem = targetList.value.find(
t =>
(lastItem.id && t.id && String(t.id) === String(lastItem.id)) ||
(!lastItem.id && lastItem.name === t.name)
)
if (targetItem) {
// 标记为已确认,避免再次弹出确认框
targetItem.has_confirmed = true
validTargets.push(targetItem)
}
})
// 将这些项加入 selectedTargets(去重)
validTargets.forEach(item => {
const exists = selectedTargets.value.some(
t =>
(item.id && t.id && String(t.id) === String(item.id)) ||
(!item.id && t.name === item.name)
)
if (!exists) {
selectedTargets.value.push(item)
}
})
}
}
}
/**
* 更新动态表单字段
* @description 根据选中的作业选项更新动态表单字段配置
* @param {Object} option - 选中的作业选项
*/
const updateDynamicFormFields = (option) => {
if (option.field_list && Array.isArray(option.field_list)) {
// 处理动态表单字段
dynamicFormFields.value = option.field_list.map(field => {
return {
id: field.field || field.field_name || field.name || field.id, // 兼容多种字段名
label: field.label || '未命名',
type: field.type || 'text', // 默认类型,如果后端有类型字段可替换
required: true // 默认必填,如果后端有必填字段可替换
}
})
// 确保如果有city字段,类型为textarea
const cityField = dynamicFormFields.value.find(f => f.id === 'city')
if (cityField) {
cityField.type = 'textarea'
}
// 确保如果有unit字段,类型为textarea
const unitField = dynamicFormFields.value.find(f => f.id === 'unit')
if (unitField) {
unitField.type = 'textarea'
}
} else {
// 如果没有配置字段,使用默认字段
dynamicFormFields.value = [
{ id: 'name', label: '姓名', type: 'text', required: true },
{ id: 'city', label: '城市', type: 'textarea', required: true },
{ id: 'unit', label: '单位', type: 'textarea', required: true },
]
const updateDynamicFormFields = option => {
if (option.field_list && Array.isArray(option.field_list)) {
// 处理动态表单字段
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, // 默认必填,如果后端有必填字段可替换
}))
// 确保如果有city字段,类型为textarea
const cityField = dynamicFormFields.value.find(f => f.id === 'city')
if (cityField) {
cityField.type = 'textarea'
}
// 确保如果有unit字段,类型为textarea
const unitField = dynamicFormFields.value.find(f => f.id === 'unit')
if (unitField) {
unitField.type = 'textarea'
}
} else {
// 如果没有配置字段,使用默认字段
dynamicFormFields.value = [
{ id: 'name', label: '姓名', type: 'text', required: true },
{ id: 'city', label: '城市', type: 'textarea', required: true },
{ id: 'unit', label: '单位', type: 'textarea', required: true },
]
}
}
/**
......@@ -711,30 +818,30 @@ const updateDynamicFormFields = (option) => {
* @param {Array} param0.selectedOptions - 选中的选项数组
*/
const onConfirmTask = async ({ selectedOptions }) => {
const option = selectedOptions[0]
selectedTaskText.value = option.text
selectedTaskValue.value = [option.value]
isMakeup.value = !!option.is_makeup
showTaskPicker.value = false
personType.value = option.person_type
// 更新动态表单字段
updateDynamicFormFields(option)
// 更新附件类型选项
if (option.attachment_type) {
updateAttachmentTypeOptions(option.attachment_type)
} else {
// 如果小作业没有配置附件类型,尝试使用大作业的默认配置
updateAttachmentTypeOptions(taskDetail.value.attachment_type)
}
// 如果是计数打卡,根据选中的作业ID查询计数对象
if (taskType.value === 'count') {
// 切换作业时,清空之前选中的对象,避免混淆
selectedTargets.value = []
await fetchTargetList(selectedTaskValue.value[0])
}
const option = selectedOptions[0]
selectedTaskText.value = option.text
selectedTaskValue.value = [option.value]
isMakeup.value = !!option.is_makeup
showTaskPicker.value = false
personType.value = option.person_type
// 更新动态表单字段
updateDynamicFormFields(option)
// 更新附件类型选项
if (option.attachment_type) {
updateAttachmentTypeOptions(option.attachment_type)
} else {
// 如果小作业没有配置附件类型,尝试使用大作业的默认配置
updateAttachmentTypeOptions(taskDetail.value.attachment_type)
}
// 如果是计数打卡,根据选中的作业ID查询计数对象
if (taskType.value === 'count') {
// 切换作业时,清空之前选中的对象,避免混淆
selectedTargets.value = []
await fetchTargetList(selectedTaskValue.value[0])
}
}
// 监听作业选择变化, 当选中的作业ID变化时, 查询对应的计数对象
......@@ -745,25 +852,25 @@ const onConfirmTask = async ({ selectedOptions }) => {
// }
// })
const toggleTarget = (item) => {
// 优先使用id匹配,如果id不存在,则使用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)
const toggleTarget = item => {
// 优先使用id匹配,如果id不存在,则使用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)
} else {
// 选中逻辑:如果是第一次选中(未确认过),则弹出确认框
if (!item.has_confirmed) {
editingTarget.value = item
isConfirmMode.value = true
showAddTargetDialog.value = true
} else {
// 选中逻辑:如果是第一次选中(未确认过),则弹出确认框
if (!item.has_confirmed) {
editingTarget.value = item
isConfirmMode.value = true
showAddTargetDialog.value = true
} else {
// 已确认过,直接选中
selectedTargets.value.push(item)
}
// 已确认过,直接选中
selectedTargets.value.push(item)
}
}
}
/**
......@@ -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,52 +888,53 @@ const openAddTargetDialog = () => {
* @description 处理弹窗确认事件,更新本地列表和选中状态
* @param {Array} formFields - 表单字段数组,包含字段ID和值
*/
const confirmAddTarget = async (formFields) => {
// 将表单字段数组转换为对象
const formData = formFields.reduce((acc, field) => {
if (field.id) {
acc[field.id] = field.value
}
return acc
}, {})
if (editingTarget.value) {
// 编辑模式或确认模式
const index = targetList.value.findIndex(t => t === editingTarget.value)
if (index > -1) {
// 更新对象 (使用 Object.assign 保持引用)
Object.assign(targetList.value[index], formData)
if (isConfirmMode.value) {
targetList.value[index].has_confirmed = true // 标记为已确认
}
// 检查是否在选中列表中
const selectedIndex = selectedTargets.value.findIndex(t =>
(editingTarget.value.id && t.id && t.id == editingTarget.value.id) ||
(!editingTarget.value.id && t.name === editingTarget.value.name)
)
// 如果是确认模式,确认后自动加入选中列表
if (isConfirmMode.value && selectedIndex === -1) {
selectedTargets.value.push(targetList.value[index])
}
showToast(isConfirmMode.value ? '确认成功' : '修改成功')
}
} else {
// 新增成功,更新本地列表
const newTarget = {
...formData,
has_confirmed: true // 新增的对象默认已确认
}
targetList.value.push(newTarget)
// 默认勾选新增的对象
selectedTargets.value.push(newTarget)
showToast('新增成功')
const confirmAddTarget = formFields => {
// 将表单字段数组转换为对象
const formData = formFields.reduce((acc, field) => {
if (field.id) {
acc[field.id] = field.value
}
return acc
}, {})
if (editingTarget.value) {
// 编辑模式或确认模式
const index = targetList.value.findIndex(t => t === editingTarget.value)
if (index > -1) {
// 更新对象 (使用 Object.assign 保持引用)
Object.assign(targetList.value[index], formData)
if (isConfirmMode.value) {
targetList.value[index].has_confirmed = true // 标记为已确认
}
// 检查是否在选中列表中
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)
)
// 如果是确认模式,确认后自动加入选中列表
if (isConfirmMode.value && selectedIndex === -1) {
selectedTargets.value.push(targetList.value[index])
}
showToast(isConfirmMode.value ? '确认成功' : '修改成功')
}
} else {
// 新增成功,更新本地列表
const newTarget = {
...formData,
has_confirmed: true, // 新增的对象默认已确认
}
targetList.value.push(newTarget)
// 默认勾选新增的对象
selectedTargets.value.push(newTarget)
showToast('新增成功')
}
showAddTargetDialog.value = false;
showAddTargetDialog.value = false
}
/**
......@@ -834,10 +942,10 @@ const confirmAddTarget = async (formFields) => {
* @description 打开弹窗并填充当前对象数据进行编辑
* @param {Object} item - 待编辑的计数对象
*/
const handleTargetEdit = (item) => {
editingTarget.value = item
isConfirmMode.value = false // 明确设置为非确认模式
showAddTargetDialog.value = true
const handleTargetEdit = item => {
editingTarget.value = item
isConfirmMode.value = false // 明确设置为非确认模式
showAddTargetDialog.value = true
}
/**
......@@ -845,24 +953,22 @@ const handleTargetEdit = (item) => {
* @description 从本地列表和选中列表中移除对象(暂未调用后端接口)
* @param {Object} 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 selectedIndex = selectedTargets.value.findIndex(t => t.id === item.id)
// if (selectedIndex > -1) {
// selectedTargets.value.splice(selectedIndex, 1)
// }
// showToast('删除成功')
// }
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 selectedIndex = selectedTargets.value.findIndex(t => t.id === item.id)
// if (selectedIndex > -1) {
// selectedTargets.value.splice(selectedIndex, 1)
// }
// showToast('删除成功')
// }
}
/**
......@@ -871,26 +977,25 @@ const handleTargetDelete = async (item) => {
* @returns {boolean}
*/
const isSubmitDisabled = computed(() => {
// 1. 校验作业选择
if (!selectedTaskValue.value || selectedTaskValue.value.length === 0) return true
// 2. 计数打卡特定校验
if (taskType.value === 'count') {
// 必须选择至少一个对象
if (selectedTargets.value.length === 0) return true
// 次数必须大于0
if (!countValue.value || countValue.value <= 0) return true
return false
}
// 3. 普通打卡校验
if (activeType.value === 'text') {
// 文本打卡:必须填写内容且长度不少于10个字符
return !message.value.trim() || message.value.trim().length < 10
} else {
// 其他类型:必须有文件 (如果是混合模式,只要有文件就行)
return fileList.value.length === 0
}
// 1. 校验作业选择
if (!selectedTaskValue.value || selectedTaskValue.value.length === 0) return true
// 2. 计数打卡特定校验
if (taskType.value === 'count') {
// 必须选择至少一个对象
if (selectedTargets.value.length === 0) return true
// 次数必须大于0
if (!countValue.value || countValue.value <= 0) return true
return false
}
// 3. 普通打卡校验
if (activeType.value === 'text') {
// 文本打卡:必须填写内容且长度不少于10个字符
return !message.value.trim() || message.value.trim().length < 10
}
// 其他类型:必须有文件 (如果是混合模式,只要有文件就行)
return fileList.value.length === 0
})
/**
......@@ -899,52 +1004,49 @@ const isSubmitDisabled = computed(() => {
* @returns {Promise<void>}
*/
const handleSubmit = async () => {
// 计数打卡校验
if (taskType.value === 'count') {
if (selectedTaskValue.value.length === 0) {
const taskText = taskOptions.value.find(t => t.value === selectedTaskValue.value[0])?.text || '作业'
showToast(`请选择${taskText}`)
return
}
if (selectedTargets.value.length === 0) {
const targetText = dynamicFieldText.value || '对象'
showToast(`请选择${targetText}`)
return
}
// 计数打卡校验
if (taskType.value === 'count') {
if (selectedTaskValue.value.length === 0) {
const taskText =
taskOptions.value.find(t => t.value === selectedTaskValue.value[0])?.text || '作业'
showToast(`请选择${taskText}`)
return
}
const extraData = {
subtask_id: selectedTaskValue.value.length > 0 ? selectedTaskValue.value[0] : ''
if (selectedTargets.value.length === 0) {
const targetText = dynamicFieldText.value || '对象'
showToast(`请选择${targetText}`)
return
}
// 如果是计数打卡,添加选中的计数对象列表, 并添加次数
if (taskType.value === 'count') {
extraData.gratitude_form_list = selectedTargets.value
extraData.gratitude_count = countValue.value
}
const extraData = {
subtask_id: selectedTaskValue.value.length > 0 ? selectedTaskValue.value[0] : '',
}
// 如果是计数打卡,添加选中的计数对象列表, 并添加次数
if (taskType.value === 'count') {
extraData.gratitude_form_list = selectedTargets.value
extraData.gratitude_count = countValue.value
}
// 提交成功后的回调,清除草稿
const onSuccess = () => {
if (isDraftEnabled()) {
clearDraft(draftKey.value)
}
}
// 提交成功后的回调,清除草稿
const onSuccess = () => {
if (isDraftEnabled()) {
clearDraft(draftKey.value)
}
}
await onSubmit(extraData, onSuccess)
await onSubmit(extraData, onSuccess)
}
// 是否为编辑模式
const isEditMode = computed(() => route.query.status === 'edit')
/**
* 返回上一页
*/
const onClickLeft = () => {
router.back()
router.back()
}
/**
......@@ -952,14 +1054,14 @@ const onClickLeft = () => {
* @param {string} type - 打卡类型
* @returns {string} 图标名称
*/
const getIconName = (type) => {
const iconMap = {
'text': 'edit',
'image': 'photo',
'video': 'video',
'audio': 'music'
}
return iconMap[type] || 'edit'
const getIconName = type => {
const iconMap = {
text: 'edit',
image: 'photo',
video: 'video',
audio: 'music',
}
return iconMap[type] || 'edit'
}
/**
......@@ -967,12 +1069,12 @@ const getIconName = (type) => {
* @returns {string} 文件图标名称
*/
const getFileIcon = () => {
const iconMap = {
'image': 'photo',
'video': 'video',
'audio': 'music'
}
return iconMap[activeType.value] || 'description'
const iconMap = {
image: 'photo',
video: 'video',
audio: 'music',
}
return iconMap[activeType.value] || 'description'
}
/**
......@@ -980,12 +1082,12 @@ const getFileIcon = () => {
* @returns {string} accept属性值
*/
const getAcceptType = () => {
const acceptMap = {
'image': 'image/*',
'video': '.mp4,video/mp4',
'audio': '.mp3,.m4a,.aac,.wav'
}
return acceptMap[activeType.value] || '*'
const acceptMap = {
image: 'image/*',
video: '.mp4,video/mp4',
audio: '.mp3,.m4a,.aac,.wav',
}
return acceptMap[activeType.value] || '*'
}
/**
......@@ -993,50 +1095,52 @@ const getAcceptType = () => {
* @returns {string} 提示文本
*/
const getUploadTips = () => {
const tipsMap = {
'image': '支持格式:.jpg/.jpeg/.png',
'video': '支持格式:.mp4(不支持 .mov)',
'audio': '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)'
}
return tipsMap[activeType.value] || ''
const tipsMap = {
image: '支持格式:.jpg/.jpeg/.png',
video: '支持格式:.mp4(不支持 .mov)',
audio: '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)',
}
return tipsMap[activeType.value] || ''
}
/**
* 获取任务详情
* @param {string} month - 月份
*/
const getTaskDetail = async (month) => {
const { code, data } = await getTaskDetailAPI({ i: route.query.task_id, month })
if (code === 1) {
taskDetail.value = data
}
const getTaskDetail = async month => {
const { code, data } = await getTaskDetailAPI({ i: route.query.task_id, month })
if (code === 1) {
taskDetail.value = data
}
}
/**
* 更新附件类型选项
* @param {Array|Object} attachmentType - 附件类型数据
*/
const updateAttachmentTypeOptions = (attachmentType) => {
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig(attachmentType)
attachmentTypeOptions.value = options
// 设置最大文件大小映射
if (upload_size_limit_mb_map) {
setMaxFileSizeMbMap(upload_size_limit_mb_map)
}
// 如果是计数打卡(count),过滤掉文本(text)类型
if (taskType.value === 'count') {
attachmentTypeOptions.value = attachmentTypeOptions.value.filter(option => option.key !== 'text')
}
// 设置默认选中类型(非计数打卡模式下)
if (taskType.value !== 'count' && attachmentTypeOptions.value.length > 0) {
// 如果当前选中的类型不在新的选项中,则重置为第一个
if (!activeType.value || !attachmentTypeOptions.value.find(o => o.key === activeType.value)) {
activeType.value = attachmentTypeOptions.value[0].key
}
const updateAttachmentTypeOptions = attachmentType => {
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig(attachmentType)
attachmentTypeOptions.value = options
// 设置最大文件大小映射
if (upload_size_limit_mb_map) {
setMaxFileSizeMbMap(upload_size_limit_mb_map)
}
// 如果是计数打卡(count),过滤掉文本(text)类型
if (taskType.value === 'count') {
attachmentTypeOptions.value = attachmentTypeOptions.value.filter(
option => option.key !== 'text'
)
}
// 设置默认选中类型(非计数打卡模式下)
if (taskType.value !== 'count' && attachmentTypeOptions.value.length > 0) {
// 如果当前选中的类型不在新的选项中,则重置为第一个
if (!activeType.value || !attachmentTypeOptions.value.find(o => o.key === activeType.value)) {
activeType.value = attachmentTypeOptions.value[0].key
}
}
}
/**
......@@ -1044,84 +1148,62 @@ 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 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) {
fileUrl = file.content
console.log('从file.content获取URL:', fileUrl)
}
// 方式3: 从file.objectURL获取
else if (file.objectURL) {
fileUrl = file.objectURL
console.log('从file.objectURL获取URL:', fileUrl)
}
// 方式4: 从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 {
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
}
}
const onClickPreview = file => {
const fileName = file.name || file.file?.name || ''
let fileUrl = ''
if (file.url) {
fileUrl = file.url
} else if (file.content) {
fileUrl = file.content
} else if (file.objectURL) {
fileUrl = file.objectURL
} else if (file.file) {
if (file.file.url) {
fileUrl = file.file.url
} else {
try {
fileUrl = URL.createObjectURL(file.file)
} catch (error) {
console.error('创建ObjectURL失败:', error)
}
}
console.log('最终提取的文件名:', fileName)
console.log('最终提取的文件URL:', fileUrl)
if (!fileUrl) {
console.warn('文件URL不存在,文件对象完整结构:', JSON.stringify(file, null, 2))
showToast('无法获取文件URL,请检查文件是否上传成功')
return
} else {
const possibleUrlFields = ['src', 'path', 'value', 'href', 'link']
for (const field of possibleUrlFields) {
if (file[field]) {
fileUrl = file[field]
break
}
}
// 根据打卡类型或文件扩展名判断文件类型
const finalFileType = file.file_type || (isAudioFile(fileName) ? 'audio' : (isVideoFile(fileName) ? 'video' : '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
}
if (!fileUrl) {
console.warn('文件URL不存在,文件对象完整结构:', JSON.stringify(file, null, 2))
showToast('无法获取文件URL,请检查文件是否上传成功')
return
}
let finalFileType = file.file_type
if (!finalFileType) {
if (isAudioFile(fileName)) {
finalFileType = 'audio'
} else if (isVideoFile(fileName)) {
finalFileType = 'video'
} else {
console.log('该文件类型不支持预览,文件名:', fileName, '类型:', finalFileType)
showToast('该文件类型不支持预览')
finalFileType = 'image'
}
}
if (finalFileType === 'audio') {
showAudio(fileName, fileUrl)
} else if (finalFileType === 'video') {
showVideo(fileName, fileUrl)
} else if (finalFileType === 'image') {
return
} else {
showToast('该文件类型不支持预览')
}
}
/**
......@@ -1214,9 +1296,9 @@ const onClickPreview = (file, detail) => {
* @param {string} fileName - 文件名
* @returns {boolean}
*/
const isAudioFile = (fileName) => {
const audioExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma']
return audioExtensions.some(ext => fileName.toLowerCase().includes(ext))
const isAudioFile = fileName => {
const audioExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma']
return audioExtensions.some(ext => fileName.toLowerCase().includes(ext))
}
/**
......@@ -1224,9 +1306,9 @@ const isAudioFile = (fileName) => {
* @param {string} fileName - 文件名
* @returns {boolean}
*/
const isVideoFile = (fileName) => {
const videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv']
return videoExtensions.some(ext => fileName.toLowerCase().includes(ext))
const isVideoFile = fileName => {
const videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv']
return videoExtensions.some(ext => fileName.toLowerCase().includes(ext))
}
/**
......@@ -1234,9 +1316,9 @@ const isVideoFile = (fileName) => {
* @param {string} fileName - 文件名
* @returns {boolean}
*/
const isImageFile = (fileName) => {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
return imageExtensions.some(ext => fileName.toLowerCase().includes(ext))
const isImageFile = fileName => {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
return imageExtensions.some(ext => fileName.toLowerCase().includes(ext))
}
/**
......@@ -1245,9 +1327,9 @@ const isImageFile = (fileName) => {
* @param {string} url - 音频URL
*/
const showAudio = (title, url) => {
audioTitle.value = title
audioUrl.value = url
audioShow.value = true
audioTitle.value = title
audioUrl.value = url
audioShow.value = true
}
/**
......@@ -1257,11 +1339,11 @@ const showAudio = (title, url) => {
* @param {string} cover - 视频封面URL(可选)
*/
const showVideo = (title, url, cover = '') => {
videoTitle.value = title
videoUrl.value = url
videoCover.value = cover
videoShow.value = true
isVideoPlaying.value = false // 重置播放状态
videoTitle.value = title
videoUrl.value = url
videoCover.value = cover
videoShow.value = true
isVideoPlaying.value = false // 重置播放状态
}
/**
......@@ -1270,528 +1352,531 @@ const showVideo = (title, url, cover = '') => {
* @param {number} index - 图片索引(可选)
*/
const showImage = (url, index = 0) => {
imageList.value = [url]
imageIndex.value = index
imageShow.value = true
imageList.value = [url]
imageIndex.value = index
imageShow.value = true
}
/**
* 开始播放视频
*/
const startVideoPlay = async () => {
isVideoPlaying.value = true
await nextTick()
if (videoPlayerRef.value) {
videoPlayerRef.value.play()
}
isVideoPlaying.value = true
await nextTick()
if (videoPlayerRef.value) {
videoPlayerRef.value.play()
}
}
/**
* 处理视频播放事件
*/
const handleVideoPlay = () => {
isVideoPlaying.value = true
isVideoPlaying.value = true
}
/**
* 处理视频暂停事件
*/
const handleVideoPause = () => {
// 保持视频播放器可见,只在初始状态显示封面
// 保持视频播放器可见,只在初始状态显示封面
}
/**
* 停止视频播放
*/
const stopVideoPlay = () => {
if (videoPlayerRef.value && typeof videoPlayerRef.value.pause === 'function') {
videoPlayerRef.value.pause()
// 重置视频播放进度到开始位置
const player = videoPlayerRef.value.getPlayer()
if (player && typeof player.currentTime === 'function') {
player.currentTime(0)
}
if (videoPlayerRef.value && typeof videoPlayerRef.value.pause === 'function') {
videoPlayerRef.value.pause()
// 重置视频播放进度到开始位置
const player = videoPlayerRef.value.getPlayer()
if (player && typeof player.currentTime === 'function') {
player.currentTime(0)
}
isVideoPlaying.value = false
}
isVideoPlaying.value = false
}
/**
* 页面挂载时的初始化逻辑
*/
onMounted(async () => {
// 获取任务详情
const current_date = route.query.date;
if (current_date) {
getTaskDetail(dayjs(current_date).format('YYYY-MM'));
} else {
getTaskDetail(dayjs().format('YYYY-MM'));
}
// 初始化选中的子任务ID
selectedTaskValue.value = route.query.subtask_id ? [+route.query.subtask_id] : []
// 获取小作业列表
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,
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, // 附件类型
}))
]
// 获取任务详情
const current_date = route.query.date
if (current_date) {
getTaskDetail(dayjs(current_date).format('YYYY-MM'))
} else {
getTaskDetail(dayjs().format('YYYY-MM'))
}
// 初始化选中的子任务ID
selectedTaskValue.value = route.query.subtask_id ? [+route.query.subtask_id] : []
// 获取小作业列表
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,
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, // 附件类型
})),
]
}
// 如果有默认选中值,且非编辑模式(编辑模式下由initEditData统一处理,避免逻辑重复)
if (selectedTaskValue.value.length > 0 && !isEditMode.value) {
const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0])
if (option) {
selectedTaskText.value = option.text
isMakeup.value = !!option.is_makeup
personType.value = option.person_type
// 初始化动态表单字段
updateDynamicFormFields(option)
// 更新附件类型选项
if (option.attachment_type) {
updateAttachmentTypeOptions(option.attachment_type)
} else {
updateAttachmentTypeOptions(taskDetail.value.attachment_type)
}
}
// 如果有默认选中值,且非编辑模式(编辑模式下由initEditData统一处理,避免逻辑重复)
if (selectedTaskValue.value.length > 0 && !isEditMode.value) {
const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0])
if (option) {
selectedTaskText.value = option.text
isMakeup.value = !!option.is_makeup
personType.value = option.person_type
// 初始化动态表单字段
updateDynamicFormFields(option)
// 更新附件类型选项
if (option.attachment_type) {
updateAttachmentTypeOptions(option.attachment_type)
} else {
updateAttachmentTypeOptions(taskDetail.value.attachment_type)
}
}
// 如果是计数打卡,根据选中的作业ID查询计数对象
if (taskType.value === 'count') {
await fetchTargetList(selectedTaskValue.value[0])
}
// 如果是计数打卡,根据选中的作业ID查询计数对象
if (taskType.value === 'count') {
await fetchTargetList(selectedTaskValue.value[0])
}
}
// 初始化编辑数据
await initEditData(taskOptions.value, {
onTaskFound: option => {
updateDynamicFormFields(option)
// 更新附件类型选项
if (option.attachment_type) {
updateAttachmentTypeOptions(option.attachment_type)
} else {
updateAttachmentTypeOptions(taskDetail.value.attachment_type)
}
},
ensureTargetList: async id => {
if (targetList.value.length === 0) {
await fetchTargetList(id)
}
},
// setTargets: (list) => {
// // 只有当 list 不为空时才覆盖,避免覆盖掉 fetchTargetList 中设置的默认选中项
// if (list && list.length > 0) {
// selectedTargets.value = list
// }
// },
setCount: val => {
countValue.value = val
},
})
// 初始化编辑数据
await initEditData(taskOptions.value, {
onTaskFound: (option) => {
updateDynamicFormFields(option)
// 更新附件类型选项
if (option.attachment_type) {
updateAttachmentTypeOptions(option.attachment_type)
} else {
updateAttachmentTypeOptions(taskDetail.value.attachment_type)
}
},
ensureTargetList: async (id) => {
if (targetList.value.length === 0) {
await fetchTargetList(id)
}
},
// setTargets: (list) => {
// // 只有当 list 不为空时才覆盖,避免覆盖掉 fetchTargetList 中设置的默认选中项
// if (list && list.length > 0) {
// selectedTargets.value = list
// }
// },
setCount: (val) => {
countValue.value = val
}
})
// 尝试恢复草稿 (非编辑模式)
if (!isEditMode.value) {
await checkAndRestoreDraft()
}
// 尝试恢复草稿 (非编辑模式)
if (!isEditMode.value) {
await checkAndRestoreDraft()
}
})
</script>
<style lang="less" scoped>
.checkin-detail-page {
min-height: 100vh;
background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff);
padding-bottom: 100px;
min-height: 100vh;
background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff);
padding-bottom: 100px;
}
.page-content {
padding: 1rem;
padding: 1rem;
}
.section-wrapper {
background-color: #fff;
border-radius: 12px;
margin-bottom: 1rem;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
background-color: #fff;
border-radius: 12px;
margin-bottom: 1rem;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: #4caf50;
padding: 1rem 1rem 0.5rem;
border-bottom: 1px solid #f0f0f0;
font-size: 1.1rem;
font-weight: 600;
color: #4caf50;
padding: 1rem 1rem 0.5rem;
border-bottom: 1px solid #f0f0f0;
}
.section-content {
padding: 1rem;
overflow: hidden;
padding: 1rem;
overflow: hidden;
}
.description-text {
color: #666;
line-height: 1.6;
font-size: 0.95rem;
color: #666;
line-height: 1.6;
font-size: 0.95rem;
word-break: break-word;
overflow-wrap: anywhere;
width: 100%;
box-sizing: border-box;
:deep(*) {
max-width: 100% !important;
box-sizing: border-box;
white-space: normal !important;
overflow-wrap: anywhere;
word-break: break-word;
overflow-wrap: break-word;
}
:deep(img) {
max-width: 100% !important;
height: auto;
display: block;
}
:deep(p) {
margin: 0.5rem 0;
}
:deep(table) {
max-width: 100% !important;
border-collapse: collapse;
overflow-x: auto;
display: table;
width: 100%;
box-sizing: border-box;
overflow: hidden;
:deep(*) {
max-width: 100% !important;
box-sizing: border-box;
}
:deep(img) {
max-width: 100% !important;
height: auto;
display: block;
}
:deep(p) {
margin: 0.5rem 0;
}
:deep(table) {
max-width: 100% !important;
border-collapse: collapse;
overflow-x: auto;
display: table;
width: 100%;
}
:deep(pre) {
max-width: 100% !important;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
// 富文本内容样式
// :deep(.rich-content) {
// h1, h2, h3, h4, h5, h6 {
// margin: 16px 0 12px 0;
// font-weight: 600;
// line-height: 1.4;
// }
// h2 {
// font-size: 20px;
// color: #2563eb;
// }
// h3 {
// font-size: 18px;
// color: #059669;
// }
// h4 {
// font-size: 16px;
// }
// p {
// margin: 12px 0;
// line-height: 1.6;
// color: #374151;
// }
// strong {
// font-weight: 600;
// color: #dc2626;
// }
// code {
// background: #f3f4f6;
// padding: 2px 6px;
// border-radius: 4px;
// color: #dc2626;
// font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
// font-size: 0.9em;
// }
// ol, ul {
// margin: 16px 0;
// padding-left: 24px;
// li {
// margin-bottom: 8px;
// line-height: 1.8;
// }
// }
// blockquote {
// border-left: 4px solid #10b981;
// background: #f0fdf4;
// padding: 16px;
// margin: 20px 0;
// border-radius: 0 8px 8px 0;
// p {
// margin: 0;
// color: #065f46;
// font-style: italic;
// }
// }
// img {
// max-width: 100%;
// height: auto;
// border-radius: 8px;
// margin: 12px 0;
// display: block;
// }
// // 特殊样式容器
// .warning-box {
// background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
// padding: 16px;
// border-radius: 12px;
// margin: 16px 0;
// border-left: 4px solid #f59e0b;
// }
// .info-box {
// background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
// padding: 16px;
// border-radius: 12px;
// margin: 20px 0;
// }
// .deadline-box {
// background: #fef2f2;
// border: 1px solid #fecaca;
// border-radius: 8px;
// padding: 16px;
// margin: 20px 0;
// }
// // 图片容器居中
// div[style*="text-align: center"] {
// text-align: center;
// img {
// margin: 12px auto;
// }
// p {
// color: #6b7280;
// font-size: 14px;
// font-style: italic;
// margin-top: 8px;
// }
// }
// }
}
:deep(pre) {
max-width: 100% !important;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
// 富文本内容样式
// :deep(.rich-content) {
// h1, h2, h3, h4, h5, h6 {
// margin: 16px 0 12px 0;
// font-weight: 600;
// line-height: 1.4;
// }
// h2 {
// font-size: 20px;
// color: #2563eb;
// }
// h3 {
// font-size: 18px;
// color: #059669;
// }
// h4 {
// font-size: 16px;
// }
// p {
// margin: 12px 0;
// line-height: 1.6;
// color: #374151;
// }
// strong {
// font-weight: 600;
// color: #dc2626;
// }
// code {
// background: #f3f4f6;
// padding: 2px 6px;
// border-radius: 4px;
// color: #dc2626;
// font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
// font-size: 0.9em;
// }
// ol, ul {
// margin: 16px 0;
// padding-left: 24px;
// li {
// margin-bottom: 8px;
// line-height: 1.8;
// }
// }
// blockquote {
// border-left: 4px solid #10b981;
// background: #f0fdf4;
// padding: 16px;
// margin: 20px 0;
// border-radius: 0 8px 8px 0;
// p {
// margin: 0;
// color: #065f46;
// font-style: italic;
// }
// }
// img {
// max-width: 100%;
// height: auto;
// border-radius: 8px;
// margin: 12px 0;
// display: block;
// }
// // 特殊样式容器
// .warning-box {
// background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
// padding: 16px;
// border-radius: 12px;
// margin: 16px 0;
// border-left: 4px solid #f59e0b;
// }
// .info-box {
// background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
// padding: 16px;
// border-radius: 12px;
// margin: 20px 0;
// }
// .deadline-box {
// background: #fef2f2;
// border: 1px solid #fecaca;
// border-radius: 8px;
// padding: 16px;
// margin: 20px 0;
// }
// // 图片容器居中
// div[style*="text-align: center"] {
// text-align: center;
// img {
// margin: 12px auto;
// }
// p {
// color: #6b7280;
// font-size: 14px;
// font-style: italic;
// margin-top: 8px;
// }
// }
// }
}
.no-description {
color: #999;
font-style: italic;
text-align: center;
padding: 2rem 0;
color: #999;
font-style: italic;
text-align: center;
padding: 2rem 0;
}
.text-input-area {
margin-bottom: 1.5rem;
margin-bottom: 1.5rem;
.van-field {
border-radius: 8px;
background-color: #f8f9fa;
border: 1px solid #e9ecef;
}
.van-field {
border-radius: 8px;
background-color: #f8f9fa;
border: 1px solid #e9ecef;
}
}
.checkin-tabs {
.tabs-header {
margin-bottom: 1rem;
}
.tabs-header {
margin-bottom: 1rem;
}
.tab-title {
font-size: 1rem;
font-weight: 600;
color: #333;
margin-bottom: 0.8rem;
}
.tab-title {
font-size: 1rem;
font-weight: 600;
color: #333;
margin-bottom: 0.8rem;
}
.tabs-nav {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.tabs-nav {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.8rem 0.5rem;
border: 2px solid #e8f5e8;
border-radius: 8px;
background-color: #fafffe;
cursor: pointer;
transition: all 0.3s ease;
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.8rem 0.5rem;
border: 2px solid #e8f5e8;
border-radius: 8px;
background-color: #fafffe;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: #4caf50;
background-color: #f0fdf4;
}
&:hover {
border-color: #4caf50;
background-color: #f0fdf4;
}
&.active {
border-color: #4caf50;
background-color: #f0fdf4;
&.active {
border-color: #4caf50;
background-color: #f0fdf4;
.van-icon {
color: #4caf50;
}
.van-icon {
color: #4caf50;
}
.tab-text {
color: #4caf50;
font-weight: 600;
}
}
.tab-text {
color: #4caf50;
font-weight: 600;
}
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
border-color: #e8f5e8;
background-color: #fafffe;
}
}
&:hover {
border-color: #e8f5e8;
background-color: #fafffe;
}
}
}
.tab-text {
margin-top: 0.3rem;
font-size: 0.8rem;
color: #666;
text-align: center;
}
.tab-text {
margin-top: 0.3rem;
font-size: 0.8rem;
color: #666;
text-align: center;
}
}
.upload-area {
margin-top: 1rem;
margin-top: 1rem;
.van-uploader {
margin-bottom: 1rem;
}
.van-uploader {
margin-bottom: 1rem;
}
}
.file-list {
margin: 1rem 0;
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;
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;
cursor: pointer;
padding: 0.5rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
&:hover {
background-color: #f5f5f5;
}
flex: 1;
gap: 0.5rem;
cursor: pointer;
padding: 0.5rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
&:hover {
background-color: #f5f5f5;
}
}
.file-name {
flex: 1;
font-size: 0.9rem;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
word-wrap: break-word;
// white-space: nowrap;
.file-name {
flex: 1;
font-size: 0.9rem;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
word-wrap: break-word;
// white-space: nowrap;
}
.file-status {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
&.uploading {
color: #1890ff;
background-color: #e6f7ff;
}
.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;
}
&.done {
color: #52c41a;
background-color: #f6ffed;
}
&.failed {
color: #ff4d4f;
background-color: #fff2f0;
}
&.failed {
color: #ff4d4f;
background-color: #fff2f0;
}
}
.delete-icon {
color: #999;
cursor: pointer;
.delete-icon {
color: #999;
cursor: pointer;
&:hover {
color: #ff4d4f;
}
&:hover {
color: #ff4d4f;
}
}
}
.upload-tips {
.tip-text {
font-size: 0.8rem;
color: #999;
margin-bottom: 0.3rem;
}
.tip-text {
font-size: 0.8rem;
color: #999;
margin-bottom: 0.3rem;
}
}
.finished-notice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
}
.finished-text {
margin-top: 1rem;
font-size: 1.1rem;
color: #4caf50;
font-weight: 600;
margin-top: 1rem;
font-size: 1.1rem;
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;
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%;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
......