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 @@ ...@@ -6,105 +6,163 @@
6 * @Description: 用户打卡详情页 6 * @Description: 用户打卡详情页
7 --> 7 -->
8 <template> 8 <template>
9 - <div class="checkin-detail-page"> 9 + <div class="checkin-detail-page">
10 - <!-- 页面内容 --> 10 + <!-- 页面内容 -->
11 - <div class="page-content"> 11 + <div class="page-content">
12 - <!-- 作业描述 --> 12 + <!-- 作业描述 -->
13 - <div class="section-wrapper"> 13 + <div class="section-wrapper">
14 - <div class="section-title">作业描述</div> 14 + <div class="section-title">作业描述</div>
15 - <div class="section-content"> 15 + <div class="section-content">
16 - <div v-if="displayTaskNote" class="description-text" v-html="displayTaskNote"> 16 + <div v-if="displayTaskNote" class="description-text" v-html="displayTaskNote"></div>
17 - </div> 17 + <div v-else class="no-description">暂无作业描述</div>
18 - <div v-else class="no-description"> 18 + </div>
19 - 暂无作业描述 19 + </div>
20 - </div> 20 +
21 - </div> 21 + <!-- 打卡内容区域 -->
22 + <div class="section-wrapper">
23 + <div class="section-title">提交作业</div>
24 + <div class="section-content">
25 + <!-- 作业选择区域 -->
26 + <div class="mb-4">
27 + <!-- 编辑模式下直接显示文本 -->
28 + <div
29 + v-if="isEditMode"
30 + class="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 p-3"
31 + >
32 + <span class="font-medium text-gray-700">当前作业</span>
33 + <span class="font-bold text-gray-900">{{ selectedTaskText }}</span>
22 </div> 34 </div>
23 35
24 - <!-- 打卡内容区域 --> 36 + <!-- 非编辑模式下显示选择框 -->
25 - <div class="section-wrapper"> 37 + <template v-else>
26 - <div class="section-title">提交作业</div> 38 + <van-field
27 - <div class="section-content"> 39 + v-model="selectedTaskText"
28 - <!-- 作业选择区域 --> 40 + is-link
29 - <div class="mb-4"> 41 + readonly
30 - <!-- 编辑模式下直接显示文本 --> 42 + label="选择作业"
31 - <div v-if="isEditMode" class="bg-gray-50 rounded-lg p-3 border border-gray-100 flex items-center justify-between"> 43 + placeholder="请选择本次打卡的作业"
32 - <span class="text-gray-700 font-medium">当前作业</span> 44 + @click="showTaskPicker = true"
33 - <span class="text-gray-900 font-bold">{{ selectedTaskText }}</span> 45 + class="rounded-lg border border-gray-100"
34 - </div> 46 + />
35 - 47 + <van-popup v-model:show="showTaskPicker" round position="bottom">
36 - <!-- 非编辑模式下显示选择框 --> 48 + <van-picker
37 - <template v-else> 49 + :columns="taskOptions"
38 - <van-field v-model="selectedTaskText" is-link readonly label="选择作业" placeholder="请选择本次打卡的作业" 50 + @cancel="showTaskPicker = false"
39 - @click="showTaskPicker = true" class="rounded-lg border border-gray-100" /> 51 + @confirm="onConfirmTask"
40 - <van-popup v-model:show="showTaskPicker" round position="bottom"> 52 + />
41 - <van-picker :columns="taskOptions" @cancel="showTaskPicker = false" 53 + </van-popup>
42 - @confirm="onConfirmTask" /> 54 + </template>
43 - </van-popup> 55 + </div>
44 - </template> 56 + <!-- 计数对象 -->
45 - </div> 57 + <CheckinTargetList
46 - <!-- 计数对象 --> 58 + v-if="taskType === 'count' && selectedTaskValue && selectedTaskValue.length > 0"
47 - <CheckinTargetList 59 + :dynamic-field-text="dynamicFieldText"
48 - v-if="taskType === 'count' && selectedTaskValue && selectedTaskValue.length > 0" 60 + :target-list="targetList"
49 - :dynamic-field-text="dynamicFieldText" 61 + :selected-targets="selectedTargets"
50 - :target-list="targetList" 62 + @add="openAddTargetDialog"
51 - :selected-targets="selectedTargets" 63 + @toggle="toggleTarget"
52 - @add="openAddTargetDialog" 64 + @edit="handleTargetEdit"
53 - @toggle="toggleTarget" 65 + @delete="handleTargetDelete"
54 - @edit="handleTargetEdit" 66 + />
55 - @delete="handleTargetDelete" 67 +
56 - /> 68 + <!-- 计数次数 -->
57 - 69 + <div
58 - <!-- 计数次数 --> 70 + v-if="taskType === 'count'"
59 - <div v-if="taskType === 'count'" 71 + class="mb-4 flex items-center justify-between rounded-lg bg-gray-50 p-3"
60 - class="mb-4 flex items-center justify-between bg-gray-50 p-3 rounded-lg"> 72 + >
61 - <div class="text-sm font-bold text-gray-700">{{ dynamicFieldText }}次数</div> 73 + <div class="text-sm font-bold text-gray-700">{{ dynamicFieldText }}次数</div>
62 - <van-stepper v-model="countValue" min="1" integer input-width="80px" button-size="28px" /> 74 + <van-stepper
63 - </div> 75 + v-model="countValue"
64 - 76 + min="1"
65 - <!-- 新增计数对象弹框 --> 77 + integer
66 - <AddTargetDialog 78 + input-width="80px"
67 - v-model:show="showAddTargetDialog" 79 + button-size="28px"
68 - :title="editingTarget ? (isConfirmMode ? `确认${dynamicFieldText}项` : `编辑${dynamicFieldText}项`) : `添加${dynamicFieldText}项`" 80 + />
69 - :fields="dynamicFormFields" 81 + </div>
70 - :initial-values="editingTarget" 82 +
71 - @confirm="confirmAddTarget" 83 + <!-- 新增计数对象弹框 -->
72 - /> 84 + <AddTargetDialog
73 - 85 + v-model:show="showAddTargetDialog"
74 - <!-- 文本输入区域 --> 86 + :title="
75 - <div class="text-input-area"> 87 + editingTarget
76 - <van-field v-model="message" rows="6" autosize type="textarea" 88 + ? isConfirmMode
77 - :placeholder="taskType === 'count' ? '请输入留言(可选)' : (activeType === 'text' ? '请输入留言,至少需要10个字符' : '请输入留言(可选)')" /> 89 + ? `确认${dynamicFieldText}项`
78 - </div> 90 + : `编辑${dynamicFieldText}项`
79 - 91 + : `添加${dynamicFieldText}项`
80 - <!-- 类型选项卡 --> 92 + "
81 - <div class="checkin-tabs" v-if="selectedTaskValue.length > 0"> 93 + :fields="dynamicFormFields"
82 - <div class="tabs-header"> 94 + :initial-values="editingTarget"
83 - <div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div> 95 + @confirm="confirmAddTarget"
84 - <div class="tabs-nav"> 96 + />
85 - <div v-for="option in attachmentTypeOptions" :key="option.key" 97 +
86 - @click="switchType(option.key)" :class="['tab-item', 'relative', { 98 + <!-- 文本输入区域 -->
87 - active: activeType === option.key 99 + <div class="text-input-area">
88 - }]"> 100 + <van-field
89 - <van-icon :name="getIconName(option.key)" size="1.2rem" /> 101 + v-model="message"
90 - <span class="tab-text">{{ option.value }}</span> 102 + rows="6"
91 - <!-- <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"> --> 103 + autosize
92 - <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"> 104 + type="textarea"
93 - {{ getTypeCount(option.key) }} 105 + :placeholder="
94 - </div> 106 + taskType === 'count'
107 + ? '请输入留言(可选)'
108 + : activeType === 'text'
109 + ? '请输入留言,至少需要10个字符'
110 + : '请输入留言(可选)'
111 + "
112 + />
113 + </div>
114 +
115 + <!-- 类型选项卡 -->
116 + <div class="checkin-tabs" v-if="selectedTaskValue.length > 0">
117 + <div class="tabs-header">
118 + <div class="tab-title">
119 + {{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}
120 + </div>
121 + <div class="tabs-nav">
122 + <div
123 + v-for="option in attachmentTypeOptions"
124 + :key="option.key"
125 + @click="switchType(option.key)"
126 + :class="[
127 + 'tab-item',
128 + 'relative',
129 + {
130 + active: activeType === option.key,
131 + },
132 + ]"
133 + >
134 + <van-icon :name="getIconName(option.key)" size="1.2rem" />
135 + <span class="tab-text">{{ option.value }}</span>
136 + <!-- <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"> -->
137 + <div
138 + v-if="getTypeCount(option.key) > 0"
139 + 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"
140 + >
141 + {{ getTypeCount(option.key) }}
142 + </div>
95 </div> 143 </div>
96 - </div> 144 + </div>
97 - </div> 145 + </div>
98 - 146 +
99 - <!-- 文件上传区域 --> 147 + <!-- 文件上传区域 -->
100 - <div v-if="activeType !== '' && activeType !== 'text'" class="upload-area"> 148 + <div v-if="activeType !== '' && activeType !== 'text'" class="upload-area">
101 - <van-uploader v-model="displayFileList" :max-count="maxCount" :max-size="maxFileSizeBytes" 149 + <van-uploader
102 - :before-read="beforeReadGuard" :after-read="afterRead" @delete="onDelete" 150 + v-model="displayFileList"
103 - @click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file" 151 + :max-count="maxCount"
104 - :deletable="true" upload-icon="plus" /> 152 + :max-size="maxFileSizeBytes"
105 - 153 + :before-read="beforeReadGuard"
106 - <!-- 文件列表显示 --> 154 + :after-read="afterRead"
107 - <!-- <div v-if="fileList.length > 0" class="file-list"> 155 + @delete="onDelete"
156 + @click-preview="onClickPreview"
157 + multiple
158 + :accept="getAcceptType()"
159 + result-type="file"
160 + :deletable="true"
161 + upload-icon="plus"
162 + />
163 +
164 + <!-- 文件列表显示 -->
165 + <!-- <div v-if="fileList.length > 0" class="file-list">
108 <div v-for="(item, index) in fileList" :key="index" class="file-item"> 166 <div v-for="(item, index) in fileList" :key="index" class="file-item">
109 <div class="file-info" @click="previewFile(item)"> 167 <div class="file-info" @click="previewFile(item)">
110 <van-icon :name="getFileIcon()" size="1rem" /> 168 <van-icon :name="getFileIcon()" size="1rem" />
...@@ -115,75 +173,123 @@ ...@@ -115,75 +173,123 @@
115 </div> 173 </div>
116 </div> --> 174 </div> -->
117 175
118 - <div class="upload-tips"> 176 + <div class="upload-tips">
119 - <div class="tip-text">最多上传{{ maxCount }}个文件,每个不超过{{ maxFileSizeMb }}MB</div> 177 + <div class="tip-text">
120 - <div class="tip-text">{{ getUploadTips() }}</div> 178 + 最多上传{{ maxCount }}个文件,每个不超过{{ maxFileSizeMb }}MB
121 - </div>
122 - </div>
123 - </div>
124 </div> 179 </div>
180 + <div class="tip-text">{{ getUploadTips() }}</div>
181 + </div>
125 </div> 182 </div>
126 - 183 + </div>
127 - <!-- 提交按钮 -->
128 - <div v-if="!taskDetail.is_finish || isEditMode" class="submit-area">
129 - <van-button type="primary" block size="large" :loading="uploading" :disabled="isSubmitDisabled" @click="handleSubmit">
130 - {{ isEditMode ? '保存修改' : '提交' }}
131 - </van-button>
132 - </div>
133 </div> 184 </div>
185 + </div>
186 +
187 + <!-- 提交按钮 -->
188 + <div v-if="!taskDetail.is_finish || isEditMode" class="submit-area">
189 + <van-button
190 + type="primary"
191 + block
192 + size="large"
193 + :loading="uploading"
194 + :disabled="isSubmitDisabled"
195 + @click="handleSubmit"
196 + >
197 + {{ isEditMode ? '保存修改' : '提交' }}
198 + </van-button>
199 + </div>
200 + </div>
134 201
135 - <!-- 上传加载遮罩 --> 202 + <!-- 上传加载遮罩 -->
136 - <van-overlay :show="loading" z-index="9999"> 203 + <van-overlay :show="loading" z-index="9999">
137 - <div class="loading-wrapper" @click.stop> 204 + <div class="loading-wrapper" @click.stop>
138 - <van-loading vertical color="#FFFFFF">上传中...</van-loading> 205 + <van-loading vertical color="#FFFFFF">上传中...</van-loading>
139 - </div> 206 + </div>
140 - </van-overlay> 207 + </van-overlay>
141 - 208 +
142 - <!-- 音频播放器弹窗 --> 209 + <!-- 音频播放器弹窗 -->
143 - <van-popup v-model:show="audioShow" position="bottom" round closeable :style="{ height: '60%', width: '100%' }"> 210 + <van-popup
144 - <div class="p-4"> 211 + v-model:show="audioShow"
145 - <h3 class="text-lg font-medium mb-4 text-center">{{ audioTitle }}</h3> 212 + position="bottom"
146 - <AudioPlayer v-if="audioShow && audioUrl" :songs="[{ title: audioTitle, url: audioUrl }]" 213 + round
147 - class="w-full" /> 214 + closeable
148 - </div> 215 + :style="{ height: '60%', width: '100%' }"
149 - </van-popup> 216 + >
150 - 217 + <div class="p-4">
151 - <!-- 视频播放器弹窗 --> 218 + <h3 class="mb-4 text-center text-lg font-medium">{{ audioTitle }}</h3>
152 - <van-popup v-model:show="videoShow" position="center" round closeable 219 + <AudioPlayer
153 - :style="{ width: '95%', maxHeight: '80vh' }" @close="stopVideoPlay"> 220 + v-if="audioShow && audioUrl"
154 - <div class="p-4"> 221 + :songs="[{ title: audioTitle, url: audioUrl }]"
155 - <h3 class="text-lg font-medium mb-4 text-center">视频预览</h3> 222 + class="w-full"
156 - <div class="relative w-full bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;"> 223 + />
157 - <!-- 视频封面 --> 224 + </div>
158 - <div v-show="!isVideoPlaying" 225 + </van-popup>
159 - class="absolute inset-0 flex items-center justify-center cursor-pointer" 226 +
160 - @click="startVideoPlay"> 227 + <!-- 视频播放器弹窗 -->
161 - <img :src="videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png'" 228 + <van-popup
162 - :alt="videoTitle" class="w-full h-full object-cover" /> 229 + v-model:show="videoShow"
163 - <div class="absolute inset-0 flex items-center justify-center bg-black/20"> 230 + position="center"
164 - <div 231 + round
165 - class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors"> 232 + closeable
166 - <van-icon name="play-circle-o" class="text-white" size="40" /> 233 + :style="{ width: '95%', maxHeight: '80vh' }"
167 - </div> 234 + @close="stopVideoPlay"
168 - </div> 235 + >
169 - </div> 236 + <div class="p-4">
170 - <!-- 视频播放器 --> 237 + <h3 class="mb-4 text-center text-lg font-medium">视频预览</h3>
171 - <VideoPlayer v-if="isVideoPlaying" ref="videoPlayerRef" :video-url="videoUrl" 238 + <div class="relative w-full overflow-hidden rounded-lg bg-black" style="aspect-ratio: 16/9">
172 - :video-id="videoTitle" :use-native-on-ios="false" :autoplay="false" class="w-full h-full" @play="handleVideoPlay" 239 + <!-- 视频封面 -->
173 - @pause="handleVideoPause" /> 240 + <div
174 - </div> 241 + v-show="!isVideoPlaying"
242 + class="absolute inset-0 flex cursor-pointer items-center justify-center"
243 + @click="startVideoPlay"
244 + >
245 + <img
246 + :src="videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png'"
247 + :alt="videoTitle"
248 + class="h-full w-full object-cover"
249 + />
250 + <div class="absolute inset-0 flex items-center justify-center bg-black/20">
251 + <div
252 + class="flex h-16 w-16 items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70"
253 + >
254 + <van-icon name="play-circle-o" class="text-white" size="40" />
255 + </div>
175 </div> 256 </div>
176 - </van-popup> 257 + </div>
177 - 258 + <!-- 视频播放器 -->
178 - <!-- 图片预览弹窗 --> 259 + <VideoPlayer
179 - <van-image-preview v-model:show="imageShow" :images="imageList" :start-position="imageIndex" :show-index="true" /> 260 + v-if="isVideoPlaying"
180 - </div> 261 + ref="videoPlayerRef"
262 + :video-url="videoUrl"
263 + :video-id="videoTitle"
264 + :use-native-on-ios="false"
265 + :autoplay="false"
266 + class="h-full w-full"
267 + @play="handleVideoPlay"
268 + @pause="handleVideoPause"
269 + />
270 + </div>
271 + </div>
272 + </van-popup>
273 +
274 + <!-- 图片预览弹窗 -->
275 + <van-image-preview
276 + v-model:show="imageShow"
277 + :images="imageList"
278 + :start-position="imageIndex"
279 + :show-index="true"
280 + />
281 + </div>
181 </template> 282 </template>
182 283
183 <script setup> 284 <script setup>
184 import { ref, computed, onMounted, nextTick, reactive, watch, onBeforeUnmount } from 'vue' 285 import { ref, computed, onMounted, nextTick, reactive, watch, onBeforeUnmount } from 'vue'
185 import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router' 286 import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
186 -import { getTaskDetailAPI, getUploadTaskInfoAPI, getSubtaskListAPI, reuseGratitudeFormAPI } from "@/api/checkin" 287 +import {
288 + getTaskDetailAPI,
289 + getUploadTaskInfoAPI,
290 + getSubtaskListAPI,
291 + reuseGratitudeFormAPI,
292 +} from '@/api/checkin'
187 import { useTitle } from '@vueuse/core' 293 import { useTitle } from '@vueuse/core'
188 import { useCheckin } from '@/composables/useCheckin' 294 import { useCheckin } from '@/composables/useCheckin'
189 import { useCheckinDraft } from '@/composables/useCheckinDraft' 295 import { useCheckinDraft } from '@/composables/useCheckinDraft'
...@@ -204,51 +310,51 @@ useTitle('提交作业') ...@@ -204,51 +310,51 @@ useTitle('提交作业')
204 310
205 // 使用打卡composable 311 // 使用打卡composable
206 const { 312 const {
207 - uploading, 313 + uploading,
208 - loading, 314 + loading,
209 - message, 315 + message,
210 - fileList, 316 + fileList,
211 - activeType, 317 + activeType,
212 - multiAttachmentEnabled, 318 + multiAttachmentEnabled,
213 - subTaskId, 319 + subTaskId,
214 - selectedTaskText, 320 + selectedTaskText,
215 - selectedTaskValue, 321 + selectedTaskValue,
216 - isMakeup, 322 + isMakeup,
217 - maxCount, 323 + maxCount,
218 - maxFileSizeMb, 324 + maxFileSizeMb,
219 - canSubmit, 325 + canSubmit,
220 - setMaxFileSizeMbMap, 326 + setMaxFileSizeMbMap,
221 - beforeRead, 327 + beforeRead,
222 - afterRead, 328 + afterRead,
223 - onDelete, 329 + onDelete,
224 - delItem, 330 + delItem,
225 - onSubmit, 331 + onSubmit,
226 - switchType, 332 + switchType,
227 - initEditData, 333 + initEditData,
228 - gratitudeCount, 334 + gratitudeCount,
229 - gratitudeFormList 335 + gratitudeFormList,
230 } = useCheckin() 336 } = useCheckin()
231 337
232 // 使用草稿缓存composable 338 // 使用草稿缓存composable
233 const { 339 const {
234 - is_enabled: isDraftEnabled, 340 + is_enabled: isDraftEnabled,
235 - build_key: buildDraftKey, 341 + build_key: buildDraftKey,
236 - save_draft: saveDraft, 342 + save_draft: saveDraft,
237 - read_draft: readDraft, 343 + read_draft: readDraft,
238 - clear_draft: clearDraft, 344 + clear_draft: clearDraft,
239 - cleanup_expired: cleanupExpiredDrafts 345 + cleanup_expired: cleanupExpiredDrafts,
240 } = useCheckinDraft() 346 } = useCheckinDraft()
241 347
242 // 草稿Key 348 // 草稿Key
243 -const draftKey = computed(() => { 349 +const draftKey = computed(() =>
244 - return buildDraftKey({ 350 + buildDraftKey({
245 - user_id: currentUser.value?.id, 351 + user_id: currentUser.value?.id,
246 - task_id: route.query.task_id, 352 + task_id: route.query.task_id,
247 - date: route.query.date, 353 + date: route.query.date,
248 - task_type: route.query.task_type, 354 + task_type: route.query.task_type,
249 - status: route.query.status || 'create' 355 + status: route.query.status || 'create',
250 - }) 356 + })
251 -}) 357 +)
252 358
253 // 动态字段文字 359 // 动态字段文字
254 const dynamicFieldText = ref('感恩') 360 const dynamicFieldText = ref('感恩')
...@@ -297,249 +403,260 @@ const imageIndex = ref(0) ...@@ -297,249 +403,260 @@ const imageIndex = ref(0)
297 * @description 使用debounce防抖,避免频繁写入 403 * @description 使用debounce防抖,避免频繁写入
298 */ 404 */
299 const autoSaveDraft = debounce(() => { 405 const autoSaveDraft = debounce(() => {
300 - if (!isDraftEnabled() || route.query.status === 'edit') return 406 + if (!isDraftEnabled() || route.query.status === 'edit') return
301 - 407 +
302 - const payload = { 408 + const payload = {
303 - message: message.value, 409 + message: message.value,
304 - active_type: activeType.value, 410 + active_type: activeType.value,
305 - subtask_id: selectedTaskValue.value?.[0], 411 + subtask_id: selectedTaskValue.value?.[0],
306 - selected_task_value: selectedTaskValue.value, 412 + selected_task_value: selectedTaskValue.value,
307 - file_list: fileList.value, // save_draft内部会过滤done状态 413 + file_list: fileList.value, // save_draft内部会过滤done状态
308 - count: { 414 + count: {
309 - gratitude_count: countValue.value, 415 + gratitude_count: countValue.value,
310 - gratitude_form_list: selectedTargets.value 416 + gratitude_form_list: selectedTargets.value,
311 - } 417 + },
312 - } 418 + }
313 419
314 - saveDraft(draftKey.value, payload) 420 + saveDraft(draftKey.value, payload)
315 }, 500) 421 }, 500)
316 422
317 // 监听数据变化触发自动保存 423 // 监听数据变化触发自动保存
318 -watch([message, fileList, selectedTaskValue, countValue, selectedTargets], () => { 424 +watch(
425 + [message, fileList, selectedTaskValue, countValue, selectedTargets],
426 + () => {
319 autoSaveDraft() 427 autoSaveDraft()
320 -}, { deep: true }) 428 + },
429 + { deep: true }
430 +)
321 431
322 // 页面离开前强制保存一次 432 // 页面离开前强制保存一次
323 onBeforeRouteLeave(() => { 433 onBeforeRouteLeave(() => {
324 - autoSaveDraft() 434 + autoSaveDraft()
325 - autoSaveDraft.flush() // 立即执行待处理的保存 435 + autoSaveDraft.flush() // 立即执行待处理的保存
326 }) 436 })
327 437
328 // 页面卸载前保存(兼容刷新/关闭tab) 438 // 页面卸载前保存(兼容刷新/关闭tab)
329 const handleBeforeUnload = () => { 439 const handleBeforeUnload = () => {
330 - autoSaveDraft() 440 + autoSaveDraft()
331 - autoSaveDraft.flush() 441 + autoSaveDraft.flush()
332 } 442 }
333 window.addEventListener('beforeunload', handleBeforeUnload) 443 window.addEventListener('beforeunload', handleBeforeUnload)
334 onBeforeUnmount(() => { 444 onBeforeUnmount(() => {
335 - window.removeEventListener('beforeunload', handleBeforeUnload) 445 + window.removeEventListener('beforeunload', handleBeforeUnload)
336 }) 446 })
337 447
338 /** 448 /**
339 * 检查并恢复草稿 449 * 检查并恢复草稿
340 */ 450 */
341 const checkAndRestoreDraft = async () => { 451 const checkAndRestoreDraft = async () => {
342 - if (!isDraftEnabled() || route.query.status === 'edit') return 452 + if (!isDraftEnabled() || route.query.status === 'edit') return
343 - 453 +
344 - // 先清理过期草稿 454 + // 先清理过期草稿
345 - cleanupExpiredDrafts() 455 + cleanupExpiredDrafts()
346 - 456 +
347 - const draft = readDraft(draftKey.value) 457 + const draft = readDraft(draftKey.value)
348 - if (!draft || !draft.payload) return 458 + if (!draft || !draft.payload) return
349 - 459 +
350 - const { payload } = draft 460 + const { payload } = draft
351 - 461 +
352 - // 校验草稿中的作业是否仍然有效 462 + // 校验草稿中的作业是否仍然有效
353 - // 如果草稿中包含具体的作业ID,必须确保该作业在当前可用的作业列表(taskOptions)中存在 463 + // 如果草稿中包含具体的作业ID,必须确保该作业在当前可用的作业列表(taskOptions)中存在
354 - const draftSubtaskId = payload.subtask_id || (payload.selected_task_value && payload.selected_task_value[0]) 464 + const draftSubtaskId =
355 - if (draftSubtaskId) { 465 + payload.subtask_id || (payload.selected_task_value && payload.selected_task_value[0])
356 - // taskOptions 已经在 onMounted 中加载完毕 466 + if (draftSubtaskId) {
357 - const isValidTask = taskOptions.value.some(option => option.value == draftSubtaskId) 467 + // taskOptions 已经在 onMounted 中加载完毕
358 - 468 + const isValidTask = taskOptions.value.some(
359 - if (!isValidTask) { 469 + option => String(option.value) === String(draftSubtaskId)
360 - console.log('[草稿清理] 作业已失效,中断恢复流程', draftSubtaskId) 470 + )
361 - try { 471 +
362 - await showDialog({ 472 + if (!isValidTask) {
363 - title: '草稿已失效', 473 + console.warn('[草稿清理] 作业已失效,中断恢复流程', draftSubtaskId)
364 - message: '您之前暂存的作业已失效(可能已截止或被删除),无法恢复。', 474 + try {
365 - confirmButtonText: '清空草稿', 475 + await showDialog({
366 - theme: 'round-button', 476 + title: '草稿已失效',
367 - }) 477 + message: '您之前暂存的作业已失效(可能已截止或被删除),无法恢复。',
368 - } catch (e) { 478 + confirmButtonText: '清空草稿',
369 - // 用户点击确认或其他关闭操作 479 + theme: 'round-button',
370 - } finally { 480 + })
371 - clearDraft(draftKey.value) 481 + } catch (e) {
372 - } 482 + // 用户点击确认或其他关闭操作
373 - return 483 + } finally {
374 - } 484 + clearDraft(draftKey.value)
485 + }
486 + return
375 } 487 }
488 + }
376 489
377 - // 检查是否有实质内容 490 + // 检查是否有实质内容
378 - const hasContent = (payload.message && payload.message.trim()) || 491 + const hasContent =
379 - (payload.file_list && payload.file_list.length > 0) 492 + (payload.message && payload.message.trim()) ||
493 + (payload.file_list && payload.file_list.length > 0)
380 494
381 - if (!hasContent) return 495 + if (!hasContent) return
382 496
383 - try { 497 + try {
384 - await showConfirmDialog({ 498 + await showConfirmDialog({
385 - title: '发现未提交的草稿', 499 + title: '发现未提交的草稿',
386 - message: '上次编辑的内容未提交,是否恢复?', 500 + message: '上次编辑的内容未提交,是否恢复?',
387 - confirmButtonText: '恢复', 501 + confirmButtonText: '恢复',
388 - cancelButtonText: '丢弃' 502 + cancelButtonText: '丢弃',
389 - }) 503 + })
390 - 504 + // 确认恢复
391 - // 确认恢复 505 +
392 - console.log('[草稿恢复] 开始恢复数据', payload) 506 + if (payload.message) message.value = payload.message
393 - 507 + if (payload.active_type) activeType.value = payload.active_type
394 - if (payload.message) message.value = payload.message 508 +
395 - if (payload.active_type) activeType.value = payload.active_type 509 + // 恢复文件列表 (注意:只恢复了已完成上传的元数据,无法恢复File对象,所以无法继续上传/断点续传)
510 + // 恢复后的文件状态设为done
511 + if (payload.file_list && payload.file_list.length > 0) {
512 + fileList.value = payload.file_list.map(f => ({
513 + ...f,
514 + status: 'done',
515 + message: '已上传',
516 + }))
517 + }
396 518
397 - // 恢复文件列表 (注意:只恢复了已完成上传的元数据,无法恢复File对象,所以无法继续上传/断点续传) 519 + // 恢复选中的作业
398 - // 恢复后的文件状态设为done 520 + if (payload.selected_task_value && payload.selected_task_value.length > 0) {
399 - if (payload.file_list && payload.file_list.length > 0) { 521 + selectedTaskValue.value = payload.selected_task_value
400 - fileList.value = payload.file_list.map(f => ({ 522 + // 触发联动逻辑 (如updateDynamicFormFields等),这部分在selectedTaskValue的watcher或onConfirmTask中处理
401 - ...f, 523 + // 但这里直接赋值可能不会触发onConfirmTask的逻辑,需要手动处理部分联动
402 - status: 'done',
403 - message: '已上传'
404 - }))
405 - }
406 524
407 - // 恢复选中的作业 525 + // 等待taskOptions加载完毕后再匹配text
408 - if (payload.selected_task_value && payload.selected_task_value.length > 0) { 526 + const matchOption = () => {
409 - selectedTaskValue.value = payload.selected_task_value 527 + const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0])
410 - // 触发联动逻辑 (如updateDynamicFormFields等),这部分在selectedTaskValue的watcher或onConfirmTask中处理 528 + if (option) {
411 - // 但这里直接赋值可能不会触发onConfirmTask的逻辑,需要手动处理部分联动 529 + selectedTaskText.value = option.text
412 - 530 + isMakeup.value = !!option.is_makeup
413 - // 等待taskOptions加载完毕后再匹配text 531 + personType.value = option.person_type
414 - const matchOption = () => { 532 + updateDynamicFormFields(option)
415 - const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0]) 533 + if (option.attachment_type) updateAttachmentTypeOptions(option.attachment_type)
416 - if (option) {
417 - selectedTaskText.value = option.text
418 - isMakeup.value = !!option.is_makeup
419 - personType.value = option.person_type
420 - updateDynamicFormFields(option)
421 - if (option.attachment_type) updateAttachmentTypeOptions(option.attachment_type)
422 - }
423 - }
424 -
425 - if (taskOptions.value.length > 0) {
426 - matchOption()
427 - } else {
428 - // 如果选项还没加载完,watch taskOptions
429 - const unwatch = watch(taskOptions, () => {
430 - matchOption()
431 - unwatch()
432 - })
433 - }
434 } 534 }
535 + }
536 +
537 + if (taskOptions.value.length > 0) {
538 + matchOption()
539 + } else {
540 + // 如果选项还没加载完,watch taskOptions
541 + const unwatch = watch(taskOptions, () => {
542 + matchOption()
543 + unwatch()
544 + })
545 + }
546 + }
435 547
436 - // 恢复计数 548 + // 恢复计数
437 - if (payload.count?.gratitude_count) { 549 + if (payload.count?.gratitude_count) {
438 - countValue.value = payload.count.gratitude_count 550 + countValue.value = payload.count.gratitude_count
439 - } 551 + }
440 552
441 - // 恢复感恩列表(计数对象) 553 + // 恢复感恩列表(计数对象)
442 - // 必须在恢复 selectedTaskValue 之后执行,因为 fetchTargetList 依赖 subtask_id 554 + // 必须在恢复 selectedTaskValue 之后执行,因为 fetchTargetList 依赖 subtask_id
443 - if (payload.count?.gratitude_form_list && Array.isArray(payload.count.gratitude_form_list) && payload.count.gratitude_form_list.length > 0) { 555 + if (
444 - const savedList = payload.count.gratitude_form_list 556 + payload.count?.gratitude_form_list &&
445 - 557 + Array.isArray(payload.count.gratitude_form_list) &&
446 - // 如果有作业ID,先获取基础列表 558 + payload.count.gratitude_form_list.length > 0
447 - if (selectedTaskValue.value && selectedTaskValue.value.length > 0) { 559 + ) {
448 - // 等待 fetchTargetList 完成,然后覆盖默认选中的项 560 + const savedList = payload.count.gratitude_form_list
449 - await fetchTargetList(selectedTaskValue.value[0]) 561 +
450 - 562 + // 如果有作业ID,先获取基础列表
451 - // 使用草稿中的列表覆盖 selectedTargets (注意去重或合并策略) 563 + if (selectedTaskValue.value && selectedTaskValue.value.length > 0) {
452 - // 这里选择:完全信任草稿中的选中状态。 564 + // 等待 fetchTargetList 完成,然后覆盖默认选中的项
453 - // 但需要注意:草稿中的对象可能只包含 id/name,而 targetList 中有完整信息。 565 + await fetchTargetList(selectedTaskValue.value[0])
454 - // 最好是基于 targetList 重新构建 selectedTargets,如果 targetList 中没有(比如新增的),则直接使用草稿中的。
455 -
456 - const restoredTargets = []
457 -
458 - savedList.forEach(savedItem => {
459 - // 尝试在 targetList 中找到对应项(获取最新状态/引用)
460 - const existingItem = targetList.value.find(t =>
461 - (savedItem.id && t.id && t.id == savedItem.id) ||
462 - (!savedItem.id && savedItem.name === t.name)
463 - )
464 -
465 - if (existingItem) {
466 - existingItem.has_confirmed = true // 既然都在草稿里了,肯定是确认过的
467 - restoredTargets.push(existingItem)
468 - } else {
469 - // 如果 targetList 里没有(可能是新增的,或者 targetList 变了),则直接使用草稿项
470 - restoredTargets.push({
471 - ...savedItem,
472 - has_confirmed: true
473 - })
474 - // 同时也加到 targetList 里显示出来(如果是新增的)
475 - targetList.value.push(restoredTargets[restoredTargets.length - 1])
476 - }
477 - })
478 -
479 - selectedTargets.value = restoredTargets
480 - } else {
481 - // 如果没有作业ID(理论上不应该,因为计数打卡必须选作业),直接恢复
482 - selectedTargets.value = savedList
483 - }
484 - }
485 566
486 - showToast('已恢复草稿') 567 + // 使用草稿中的列表覆盖 selectedTargets (注意去重或合并策略)
568 + // 这里选择:完全信任草稿中的选中状态。
569 + // 但需要注意:草稿中的对象可能只包含 id/name,而 targetList 中有完整信息。
570 + // 最好是基于 targetList 重新构建 selectedTargets,如果 targetList 中没有(比如新增的),则直接使用草稿中的。
571 +
572 + const restoredTargets = []
573 +
574 + savedList.forEach(savedItem => {
575 + // 尝试在 targetList 中找到对应项(获取最新状态/引用)
576 + const existingItem = targetList.value.find(
577 + t =>
578 + (savedItem.id && t.id && String(t.id) === String(savedItem.id)) ||
579 + (!savedItem.id && savedItem.name === t.name)
580 + )
581 +
582 + if (existingItem) {
583 + existingItem.has_confirmed = true // 既然都在草稿里了,肯定是确认过的
584 + restoredTargets.push(existingItem)
585 + } else {
586 + // 如果 targetList 里没有(可能是新增的,或者 targetList 变了),则直接使用草稿项
587 + restoredTargets.push({
588 + ...savedItem,
589 + has_confirmed: true,
590 + })
591 + // 同时也加到 targetList 里显示出来(如果是新增的)
592 + targetList.value.push(restoredTargets[restoredTargets.length - 1])
593 + }
594 + })
487 595
488 - } catch (e) { 596 + selectedTargets.value = restoredTargets
489 - // 取消恢复,清除草稿 597 + } else {
490 - if (e !== 'cancel') console.error(e) 598 + // 如果没有作业ID(理论上不应该,因为计数打卡必须选作业),直接恢复
491 - clearDraft(draftKey.value) 599 + selectedTargets.value = savedList
492 - showToast('已丢弃草稿') 600 + }
493 } 601 }
494 -}
495 -
496 602
497 -const beforeReadGuard = (file) => { 603 + showToast('已恢复草稿')
498 - const files = Array.isArray(file) ? file : [file] 604 + } catch (e) {
499 - if (activeType.value === 'video') { 605 + // 取消恢复,清除草稿
500 - const hasMov = files.some(item => { 606 + if (e !== 'cancel') console.error(e)
501 - const fileName = String(item?.name || '').toLowerCase() 607 + clearDraft(draftKey.value)
502 - const fileType = String(item?.type || '').toLowerCase() 608 + showToast('已丢弃草稿')
503 - return fileName.endsWith('.mov') || fileType.includes('quicktime') 609 + }
504 - }) 610 +}
505 611
506 - if (hasMov) { 612 +const beforeReadGuard = file => {
507 - showDialog({ 613 + const files = Array.isArray(file) ? file : [file]
508 - title: '不支持 MOV 格式', 614 + if (activeType.value === 'video') {
509 - message: 'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。', 615 + const hasMov = files.some(item => {
510 - confirmButtonText: '我知道了', 616 + const fileName = String(item?.name || '').toLowerCase()
511 - }) 617 + const fileType = String(item?.type || '').toLowerCase()
512 - return false 618 + return fileName.endsWith('.mov') || fileType.includes('quicktime')
513 - } 619 + })
514 620
515 - return beforeRead(file) 621 + if (hasMov) {
622 + showDialog({
623 + title: '不支持 MOV 格式',
624 + message:
625 + 'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。',
626 + confirmButtonText: '我知道了',
627 + })
628 + return false
516 } 629 }
517 630
518 - if (activeType.value === 'audio') { 631 + return beforeRead(file)
519 - const supportedExt = ['mp3', 'm4a', 'aac', 'wav'] 632 + }
520 - const unsupportedFiles = files.filter(item => { 633 +
521 - const fileName = String(item?.name || '').toLowerCase() 634 + if (activeType.value === 'audio') {
522 - const ext = fileName.includes('.') ? fileName.split('.').pop() : '' 635 + const supportedExt = ['mp3', 'm4a', 'aac', 'wav']
523 - if (supportedExt.includes(ext)) return false 636 + const unsupportedFiles = files.filter(item => {
524 - const fileType = String(item?.type || '').toLowerCase() 637 + const fileName = String(item?.name || '').toLowerCase()
525 - if (!fileType) return true 638 + const ext = fileName.includes('.') ? fileName.split('.').pop() : ''
526 - if (!fileType.startsWith('audio/')) return true 639 + if (supportedExt.includes(ext)) return false
527 - return true 640 + const fileType = String(item?.type || '').toLowerCase()
528 - }) 641 + if (!fileType) return true
529 - 642 + if (!fileType.startsWith('audio/')) return true
530 - if (unsupportedFiles.length > 0) { 643 + return true
531 - showDialog({ 644 + })
532 - title: '不支持的音频格式',
533 - message: '当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。',
534 - confirmButtonText: '我知道了',
535 - })
536 - return false
537 - }
538 645
539 - return beforeRead(file) 646 + if (unsupportedFiles.length > 0) {
647 + showDialog({
648 + title: '不支持的音频格式',
649 + message:
650 + '当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。',
651 + confirmButtonText: '我知道了',
652 + })
653 + return false
540 } 654 }
541 655
542 return beforeRead(file) 656 return beforeRead(file)
657 + }
658 +
659 + return beforeRead(file)
543 } 660 }
544 661
545 /** 662 /**
...@@ -547,20 +664,19 @@ const beforeReadGuard = (file) => { ...@@ -547,20 +664,19 @@ const beforeReadGuard = (file) => {
547 * @param {string} type - 文件类型 664 * @param {string} type - 文件类型
548 * @returns {number} 文件数量 665 * @returns {number} 文件数量
549 */ 666 */
550 -const getTypeCount = (type) => { 667 +const getTypeCount = type =>
551 - return fileList.value.filter(item => { 668 + fileList.value.filter(item => {
552 - if (item.file_type) { 669 + if (item.file_type) {
553 - return item.file_type === type 670 + return item.file_type === type
554 - } 671 + }
555 - // 处理刚选择但未上传完成的文件(尝试推断类型) 672 + // 处理刚选择但未上传完成的文件(尝试推断类型)
556 - if (item.file && item.file.type) { 673 + if (item.file && item.file.type) {
557 - if (type === 'image') return item.file.type.startsWith('image/') 674 + if (type === 'image') return item.file.type.startsWith('image/')
558 - if (type === 'video') return item.file.type.startsWith('video/') 675 + if (type === 'video') return item.file.type.startsWith('video/')
559 - if (type === 'audio') return item.file.type.startsWith('audio/') 676 + if (type === 'audio') return item.file.type.startsWith('audio/')
560 - } 677 + }
561 - return false 678 + return false
562 - }).length 679 + }).length
563 -}
564 680
565 /** 681 /**
566 * 当前显示的(经过类型过滤的)文件列表 682 * 当前显示的(经过类型过滤的)文件列表
...@@ -569,139 +685,130 @@ const getTypeCount = (type) => { ...@@ -569,139 +685,130 @@ const getTypeCount = (type) => {
569 * 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList 685 * 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList
570 */ 686 */
571 const displayFileList = computed({ 687 const displayFileList = computed({
572 - get: () => { 688 + get: () =>
573 - return fileList.value.filter(item => { 689 + fileList.value.filter(item => {
574 - if (item.file_type) { 690 + if (item.file_type) {
575 - return item.file_type === activeType.value 691 + return item.file_type === activeType.value
576 - } 692 + }
577 - if (item.file && item.file.type) { 693 + if (item.file && item.file.type) {
578 - if (activeType.value === 'image') return item.file.type.startsWith('image/') 694 + if (activeType.value === 'image') return item.file.type.startsWith('image/')
579 - if (activeType.value === 'video') return item.file.type.startsWith('video/') 695 + if (activeType.value === 'video') return item.file.type.startsWith('video/')
580 - if (activeType.value === 'audio') return item.file.type.startsWith('audio/') 696 + if (activeType.value === 'audio') return item.file.type.startsWith('audio/')
581 - } 697 + }
582 - return false 698 + return false
583 - }) 699 + }),
584 - }, 700 + set: val => {
585 - set: (val) => { 701 + // 找出不属于当前视图的其他文件(保留它们)
586 - // 找出不属于当前视图的其他文件(保留它们) 702 + const otherFiles = fileList.value.filter(item => {
587 - const otherFiles = fileList.value.filter(item => { 703 + if (item.file_type) {
588 - if (item.file_type) { 704 + return item.file_type !== activeType.value
589 - return item.file_type !== activeType.value 705 + }
590 - } 706 + if (item.file && item.file.type) {
591 - if (item.file && item.file.type) { 707 + if (activeType.value === 'image') return !item.file.type.startsWith('image/')
592 - if (activeType.value === 'image') return !item.file.type.startsWith('image/') 708 + if (activeType.value === 'video') return !item.file.type.startsWith('video/')
593 - if (activeType.value === 'video') return !item.file.type.startsWith('video/') 709 + if (activeType.value === 'audio') return !item.file.type.startsWith('audio/')
594 - if (activeType.value === 'audio') return !item.file.type.startsWith('audio/') 710 + }
595 - } 711 + // 如果无法判断类型,且 activeType 不是 text,保守起见保留它?
596 - // 如果无法判断类型,且 activeType 不是 text,保守起见保留它? 712 + // 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other
597 - // 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other 713 + return true
598 - return true 714 + })
599 - })
600 715
601 - // 合并其他文件和当前视图的新文件列表 716 + // 合并其他文件和当前视图的新文件列表
602 - fileList.value = [...otherFiles, ...val] 717 + fileList.value = [...otherFiles, ...val]
603 - } 718 + },
604 }) 719 })
605 720
606 -
607 -
608 -
609 -
610 const maxFileSizeBytes = computed(() => { 721 const maxFileSizeBytes = computed(() => {
611 - const size = Number(maxFileSizeMb.value || 0) 722 + const size = Number(maxFileSizeMb.value || 0)
612 - if (!Number.isFinite(size) || size <= 0) return 20 * 1024 * 1024 723 + if (!Number.isFinite(size) || size <= 0) return 20 * 1024 * 1024
613 - return Math.floor(size * 1024 * 1024) 724 + return Math.floor(size * 1024 * 1024)
614 }) 725 })
615 726
616 // 显示的作业描述 727 // 显示的作业描述
617 const displayTaskNote = computed(() => { 728 const displayTaskNote = computed(() => {
618 - const selected_subtask_id = selectedTaskValue.value?.[0] 729 + const selected_subtask_id = selectedTaskValue.value?.[0]
619 - if (selected_subtask_id) { 730 + if (selected_subtask_id) {
620 - const option = taskOptions.value.find(o => o.value === selected_subtask_id) 731 + const option = taskOptions.value.find(o => o.value === selected_subtask_id)
621 - return option?.note || taskDetail.value?.note || '' 732 + return option?.note || taskDetail.value?.note || ''
622 - } 733 + }
623 - return taskDetail.value?.note || '' 734 + return taskDetail.value?.note || ''
624 }) 735 })
625 736
626 // 打卡类型 737 // 打卡类型
627 const taskType = computed(() => route.query.task_type) 738 const taskType = computed(() => route.query.task_type)
628 739
629 - 740 +const fetchTargetList = async subtask_id => {
630 - 741 + const { code, data } = await reuseGratitudeFormAPI({ subtask_id })
631 -const fetchTargetList = async (subtask_id) => { 742 + if (code === 1) {
632 - const { code, data } = await reuseGratitudeFormAPI({ subtask_id }) 743 + targetList.value = data.gratitude_form_list || []
633 - if (code === 1) { 744 + lastUsedTargetList.value = data.last_used_list || []
634 - targetList.value = data.gratitude_form_list || [] 745 +
635 - lastUsedTargetList.value = data.last_used_list || [] 746 + // 自动选中上次使用的对象
636 - 747 + if (lastUsedTargetList.value.length > 0) {
637 - // 自动选中上次使用的对象 748 + // 找出 lastUsedTargetList 中存在于 targetList 的项(并获取 targetList 中的完整对象引用)
638 - if (lastUsedTargetList.value.length > 0) { 749 + const validTargets = []
639 - // 找出 lastUsedTargetList 中存在于 targetList 的项(并获取 targetList 中的完整对象引用) 750 +
640 - const validTargets = [] 751 + lastUsedTargetList.value.forEach(lastItem => {
641 - 752 + const targetItem = targetList.value.find(
642 - lastUsedTargetList.value.forEach(lastItem => { 753 + t =>
643 - const targetItem = targetList.value.find(t => 754 + (lastItem.id && t.id && String(t.id) === String(lastItem.id)) ||
644 - (lastItem.id && t.id && t.id == lastItem.id) || 755 + (!lastItem.id && lastItem.name === t.name)
645 - (!lastItem.id && lastItem.name === t.name) 756 + )
646 - ) 757 +
647 - 758 + if (targetItem) {
648 - if (targetItem) { 759 + // 标记为已确认,避免再次弹出确认框
649 - // 标记为已确认,避免再次弹出确认框 760 + targetItem.has_confirmed = true
650 - targetItem.has_confirmed = true 761 + validTargets.push(targetItem)
651 - validTargets.push(targetItem)
652 - }
653 - })
654 -
655 - // 将这些项加入 selectedTargets(去重)
656 - validTargets.forEach(item => {
657 - const exists = selectedTargets.value.some(t =>
658 - (item.id && t.id && t.id == item.id) ||
659 - (!item.id && t.name === item.name)
660 - )
661 - if (!exists) {
662 - selectedTargets.value.push(item)
663 - }
664 - })
665 } 762 }
763 + })
764 +
765 + // 将这些项加入 selectedTargets(去重)
766 + validTargets.forEach(item => {
767 + const exists = selectedTargets.value.some(
768 + t =>
769 + (item.id && t.id && String(t.id) === String(item.id)) ||
770 + (!item.id && t.name === item.name)
771 + )
772 + if (!exists) {
773 + selectedTargets.value.push(item)
774 + }
775 + })
666 } 776 }
777 + }
667 } 778 }
668 779
669 -
670 -
671 /** 780 /**
672 * 更新动态表单字段 781 * 更新动态表单字段
673 * @description 根据选中的作业选项更新动态表单字段配置 782 * @description 根据选中的作业选项更新动态表单字段配置
674 * @param {Object} option - 选中的作业选项 783 * @param {Object} option - 选中的作业选项
675 */ 784 */
676 -const updateDynamicFormFields = (option) => { 785 +const updateDynamicFormFields = option => {
677 - if (option.field_list && Array.isArray(option.field_list)) { 786 + if (option.field_list && Array.isArray(option.field_list)) {
678 - // 处理动态表单字段 787 + // 处理动态表单字段
679 - dynamicFormFields.value = option.field_list.map(field => { 788 + dynamicFormFields.value = option.field_list.map(field => ({
680 - return { 789 + id: field.field || field.field_name || field.name || field.id, // 兼容多种字段名
681 - id: field.field || field.field_name || field.name || field.id, // 兼容多种字段名 790 + label: field.label || '未命名',
682 - label: field.label || '未命名', 791 + type: field.type || 'text', // 默认类型,如果后端有类型字段可替换
683 - type: field.type || 'text', // 默认类型,如果后端有类型字段可替换 792 + required: true, // 默认必填,如果后端有必填字段可替换
684 - required: true // 默认必填,如果后端有必填字段可替换 793 + }))
685 - } 794 + // 确保如果有city字段,类型为textarea
686 - }) 795 + const cityField = dynamicFormFields.value.find(f => f.id === 'city')
687 - // 确保如果有city字段,类型为textarea 796 + if (cityField) {
688 - const cityField = dynamicFormFields.value.find(f => f.id === 'city') 797 + cityField.type = 'textarea'
689 - if (cityField) {
690 - cityField.type = 'textarea'
691 - }
692 - // 确保如果有unit字段,类型为textarea
693 - const unitField = dynamicFormFields.value.find(f => f.id === 'unit')
694 - if (unitField) {
695 - unitField.type = 'textarea'
696 - }
697 - } else {
698 - // 如果没有配置字段,使用默认字段
699 - dynamicFormFields.value = [
700 - { id: 'name', label: '姓名', type: 'text', required: true },
701 - { id: 'city', label: '城市', type: 'textarea', required: true },
702 - { id: 'unit', label: '单位', type: 'textarea', required: true },
703 - ]
704 } 798 }
799 + // 确保如果有unit字段,类型为textarea
800 + const unitField = dynamicFormFields.value.find(f => f.id === 'unit')
801 + if (unitField) {
802 + unitField.type = 'textarea'
803 + }
804 + } else {
805 + // 如果没有配置字段,使用默认字段
806 + dynamicFormFields.value = [
807 + { id: 'name', label: '姓名', type: 'text', required: true },
808 + { id: 'city', label: '城市', type: 'textarea', required: true },
809 + { id: 'unit', label: '单位', type: 'textarea', required: true },
810 + ]
811 + }
705 } 812 }
706 813
707 /** 814 /**
...@@ -711,30 +818,30 @@ const updateDynamicFormFields = (option) => { ...@@ -711,30 +818,30 @@ const updateDynamicFormFields = (option) => {
711 * @param {Array} param0.selectedOptions - 选中的选项数组 818 * @param {Array} param0.selectedOptions - 选中的选项数组
712 */ 819 */
713 const onConfirmTask = async ({ selectedOptions }) => { 820 const onConfirmTask = async ({ selectedOptions }) => {
714 - const option = selectedOptions[0] 821 + const option = selectedOptions[0]
715 - selectedTaskText.value = option.text 822 + selectedTaskText.value = option.text
716 - selectedTaskValue.value = [option.value] 823 + selectedTaskValue.value = [option.value]
717 - isMakeup.value = !!option.is_makeup 824 + isMakeup.value = !!option.is_makeup
718 - showTaskPicker.value = false 825 + showTaskPicker.value = false
719 - personType.value = option.person_type 826 + personType.value = option.person_type
720 - 827 +
721 - // 更新动态表单字段 828 + // 更新动态表单字段
722 - updateDynamicFormFields(option) 829 + updateDynamicFormFields(option)
723 - 830 +
724 - // 更新附件类型选项 831 + // 更新附件类型选项
725 - if (option.attachment_type) { 832 + if (option.attachment_type) {
726 - updateAttachmentTypeOptions(option.attachment_type) 833 + updateAttachmentTypeOptions(option.attachment_type)
727 - } else { 834 + } else {
728 - // 如果小作业没有配置附件类型,尝试使用大作业的默认配置 835 + // 如果小作业没有配置附件类型,尝试使用大作业的默认配置
729 - updateAttachmentTypeOptions(taskDetail.value.attachment_type) 836 + updateAttachmentTypeOptions(taskDetail.value.attachment_type)
730 - } 837 + }
731 - 838 +
732 - // 如果是计数打卡,根据选中的作业ID查询计数对象 839 + // 如果是计数打卡,根据选中的作业ID查询计数对象
733 - if (taskType.value === 'count') { 840 + if (taskType.value === 'count') {
734 - // 切换作业时,清空之前选中的对象,避免混淆 841 + // 切换作业时,清空之前选中的对象,避免混淆
735 - selectedTargets.value = [] 842 + selectedTargets.value = []
736 - await fetchTargetList(selectedTaskValue.value[0]) 843 + await fetchTargetList(selectedTaskValue.value[0])
737 - } 844 + }
738 } 845 }
739 846
740 // 监听作业选择变化, 当选中的作业ID变化时, 查询对应的计数对象 847 // 监听作业选择变化, 当选中的作业ID变化时, 查询对应的计数对象
...@@ -745,25 +852,25 @@ const onConfirmTask = async ({ selectedOptions }) => { ...@@ -745,25 +852,25 @@ const onConfirmTask = async ({ selectedOptions }) => {
745 // } 852 // }
746 // }) 853 // })
747 854
748 - 855 +const toggleTarget = item => {
749 - 856 + // 优先使用id匹配,如果id不存在,则使用name匹配
750 -const toggleTarget = (item) => { 857 + const index = selectedTargets.value.findIndex(t =>
751 - // 优先使用id匹配,如果id不存在,则使用name匹配 858 + item.id ? t.id === item.id : t.name === item.name
752 - const index = selectedTargets.value.findIndex(t => (item.id ? t.id === item.id : t.name === item.name)) 859 + )
753 - if (index > -1) { 860 + if (index > -1) {
754 - // 取消选中 861 + // 取消选中
755 - selectedTargets.value.splice(index, 1) 862 + selectedTargets.value.splice(index, 1)
863 + } else {
864 + // 选中逻辑:如果是第一次选中(未确认过),则弹出确认框
865 + if (!item.has_confirmed) {
866 + editingTarget.value = item
867 + isConfirmMode.value = true
868 + showAddTargetDialog.value = true
756 } else { 869 } else {
757 - // 选中逻辑:如果是第一次选中(未确认过),则弹出确认框 870 + // 已确认过,直接选中
758 - if (!item.has_confirmed) { 871 + selectedTargets.value.push(item)
759 - editingTarget.value = item
760 - isConfirmMode.value = true
761 - showAddTargetDialog.value = true
762 - } else {
763 - // 已确认过,直接选中
764 - selectedTargets.value.push(item)
765 - }
766 } 872 }
873 + }
767 } 874 }
768 875
769 /** 876 /**
...@@ -771,9 +878,9 @@ const toggleTarget = (item) => { ...@@ -771,9 +878,9 @@ const toggleTarget = (item) => {
771 * @description 重置编辑状态并显示弹窗 878 * @description 重置编辑状态并显示弹窗
772 */ 879 */
773 const openAddTargetDialog = () => { 880 const openAddTargetDialog = () => {
774 - editingTarget.value = null; // 重置编辑对象 881 + editingTarget.value = null // 重置编辑对象
775 - isConfirmMode.value = false; 882 + isConfirmMode.value = false
776 - showAddTargetDialog.value = true; 883 + showAddTargetDialog.value = true
777 } 884 }
778 885
779 /** 886 /**
...@@ -781,52 +888,53 @@ const openAddTargetDialog = () => { ...@@ -781,52 +888,53 @@ const openAddTargetDialog = () => {
781 * @description 处理弹窗确认事件,更新本地列表和选中状态 888 * @description 处理弹窗确认事件,更新本地列表和选中状态
782 * @param {Array} formFields - 表单字段数组,包含字段ID和值 889 * @param {Array} formFields - 表单字段数组,包含字段ID和值
783 */ 890 */
784 -const confirmAddTarget = async (formFields) => { 891 +const confirmAddTarget = formFields => {
785 - // 将表单字段数组转换为对象 892 + // 将表单字段数组转换为对象
786 - const formData = formFields.reduce((acc, field) => { 893 + const formData = formFields.reduce((acc, field) => {
787 - if (field.id) { 894 + if (field.id) {
788 - acc[field.id] = field.value 895 + acc[field.id] = field.value
789 - } 896 + }
790 - return acc 897 + return acc
791 - }, {}) 898 + }, {})
792 - 899 +
793 - if (editingTarget.value) { 900 + if (editingTarget.value) {
794 - // 编辑模式或确认模式 901 + // 编辑模式或确认模式
795 - const index = targetList.value.findIndex(t => t === editingTarget.value) 902 + const index = targetList.value.findIndex(t => t === editingTarget.value)
796 - if (index > -1) { 903 + if (index > -1) {
797 - // 更新对象 (使用 Object.assign 保持引用) 904 + // 更新对象 (使用 Object.assign 保持引用)
798 - Object.assign(targetList.value[index], formData) 905 + Object.assign(targetList.value[index], formData)
799 - 906 +
800 - if (isConfirmMode.value) { 907 + if (isConfirmMode.value) {
801 - targetList.value[index].has_confirmed = true // 标记为已确认 908 + targetList.value[index].has_confirmed = true // 标记为已确认
802 - } 909 + }
803 - 910 +
804 - // 检查是否在选中列表中 911 + // 检查是否在选中列表中
805 - const selectedIndex = selectedTargets.value.findIndex(t => 912 + const selectedIndex = selectedTargets.value.findIndex(
806 - (editingTarget.value.id && t.id && t.id == editingTarget.value.id) || 913 + t =>
807 - (!editingTarget.value.id && t.name === editingTarget.value.name) 914 + (editingTarget.value.id && t.id && String(t.id) === String(editingTarget.value.id)) ||
808 - ) 915 + (!editingTarget.value.id && t.name === editingTarget.value.name)
809 - 916 + )
810 - // 如果是确认模式,确认后自动加入选中列表 917 +
811 - if (isConfirmMode.value && selectedIndex === -1) { 918 + // 如果是确认模式,确认后自动加入选中列表
812 - selectedTargets.value.push(targetList.value[index]) 919 + if (isConfirmMode.value && selectedIndex === -1) {
813 - } 920 + selectedTargets.value.push(targetList.value[index])
814 - 921 + }
815 - showToast(isConfirmMode.value ? '确认成功' : '修改成功') 922 +
816 - } 923 + showToast(isConfirmMode.value ? '确认成功' : '修改成功')
817 - } else {
818 - // 新增成功,更新本地列表
819 - const newTarget = {
820 - ...formData,
821 - has_confirmed: true // 新增的对象默认已确认
822 - }
823 - targetList.value.push(newTarget)
824 - // 默认勾选新增的对象
825 - selectedTargets.value.push(newTarget)
826 - showToast('新增成功')
827 } 924 }
925 + } else {
926 + // 新增成功,更新本地列表
927 + const newTarget = {
928 + ...formData,
929 + has_confirmed: true, // 新增的对象默认已确认
930 + }
931 + targetList.value.push(newTarget)
932 + // 默认勾选新增的对象
933 + selectedTargets.value.push(newTarget)
934 + showToast('新增成功')
935 + }
828 936
829 - showAddTargetDialog.value = false; 937 + showAddTargetDialog.value = false
830 } 938 }
831 939
832 /** 940 /**
...@@ -834,10 +942,10 @@ const confirmAddTarget = async (formFields) => { ...@@ -834,10 +942,10 @@ const confirmAddTarget = async (formFields) => {
834 * @description 打开弹窗并填充当前对象数据进行编辑 942 * @description 打开弹窗并填充当前对象数据进行编辑
835 * @param {Object} item - 待编辑的计数对象 943 * @param {Object} item - 待编辑的计数对象
836 */ 944 */
837 -const handleTargetEdit = (item) => { 945 +const handleTargetEdit = item => {
838 - editingTarget.value = item 946 + editingTarget.value = item
839 - isConfirmMode.value = false // 明确设置为非确认模式 947 + isConfirmMode.value = false // 明确设置为非确认模式
840 - showAddTargetDialog.value = true 948 + showAddTargetDialog.value = true
841 } 949 }
842 950
843 /** 951 /**
...@@ -845,24 +953,22 @@ const handleTargetEdit = (item) => { ...@@ -845,24 +953,22 @@ const handleTargetEdit = (item) => {
845 * @description 从本地列表和选中列表中移除对象(暂未调用后端接口) 953 * @description 从本地列表和选中列表中移除对象(暂未调用后端接口)
846 * @param {Object} item - 待删除的计数对象 954 * @param {Object} item - 待删除的计数对象
847 */ 955 */
848 -const handleTargetDelete = async (item) => { 956 +const handleTargetDelete = async item => {
849 - // 屏蔽删除功能, 那个接口也是不存在的 957 + // 屏蔽删除功能, 那个接口也是不存在的
850 - // const { code } = await gratitudeDeleteAPI({ id: item.id }) 958 + // const { code } = await gratitudeDeleteAPI({ id: item.id })
851 - // if (code === 1) { 959 + // if (code === 1) {
852 - // // 删除成功,更新本地列表 960 + // // 删除成功,更新本地列表
853 - // const targetIndex = targetList.value.findIndex(t => t.id === item.id) 961 + // const targetIndex = targetList.value.findIndex(t => t.id === item.id)
854 - // if (targetIndex > -1) { 962 + // if (targetIndex > -1) {
855 - // targetList.value.splice(targetIndex, 1) 963 + // targetList.value.splice(targetIndex, 1)
856 - // } 964 + // }
857 - 965 + // // 从选中列表中也删除
858 - // // 从选中列表中也删除 966 + // const selectedIndex = selectedTargets.value.findIndex(t => t.id === item.id)
859 - // const selectedIndex = selectedTargets.value.findIndex(t => t.id === item.id) 967 + // if (selectedIndex > -1) {
860 - // if (selectedIndex > -1) { 968 + // selectedTargets.value.splice(selectedIndex, 1)
861 - // selectedTargets.value.splice(selectedIndex, 1) 969 + // }
862 - // } 970 + // showToast('删除成功')
863 - 971 + // }
864 - // showToast('删除成功')
865 - // }
866 } 972 }
867 973
868 /** 974 /**
...@@ -871,26 +977,25 @@ const handleTargetDelete = async (item) => { ...@@ -871,26 +977,25 @@ const handleTargetDelete = async (item) => {
871 * @returns {boolean} 977 * @returns {boolean}
872 */ 978 */
873 const isSubmitDisabled = computed(() => { 979 const isSubmitDisabled = computed(() => {
874 - // 1. 校验作业选择 980 + // 1. 校验作业选择
875 - if (!selectedTaskValue.value || selectedTaskValue.value.length === 0) return true 981 + if (!selectedTaskValue.value || selectedTaskValue.value.length === 0) return true
876 - 982 +
877 - // 2. 计数打卡特定校验 983 + // 2. 计数打卡特定校验
878 - if (taskType.value === 'count') { 984 + if (taskType.value === 'count') {
879 - // 必须选择至少一个对象 985 + // 必须选择至少一个对象
880 - if (selectedTargets.value.length === 0) return true 986 + if (selectedTargets.value.length === 0) return true
881 - // 次数必须大于0 987 + // 次数必须大于0
882 - if (!countValue.value || countValue.value <= 0) return true 988 + if (!countValue.value || countValue.value <= 0) return true
883 - return false 989 + return false
884 - } 990 + }
885 - 991 +
886 - // 3. 普通打卡校验 992 + // 3. 普通打卡校验
887 - if (activeType.value === 'text') { 993 + if (activeType.value === 'text') {
888 - // 文本打卡:必须填写内容且长度不少于10个字符 994 + // 文本打卡:必须填写内容且长度不少于10个字符
889 - return !message.value.trim() || message.value.trim().length < 10 995 + return !message.value.trim() || message.value.trim().length < 10
890 - } else { 996 + }
891 - // 其他类型:必须有文件 (如果是混合模式,只要有文件就行) 997 + // 其他类型:必须有文件 (如果是混合模式,只要有文件就行)
892 - return fileList.value.length === 0 998 + return fileList.value.length === 0
893 - }
894 }) 999 })
895 1000
896 /** 1001 /**
...@@ -899,52 +1004,49 @@ const isSubmitDisabled = computed(() => { ...@@ -899,52 +1004,49 @@ const isSubmitDisabled = computed(() => {
899 * @returns {Promise<void>} 1004 * @returns {Promise<void>}
900 */ 1005 */
901 const handleSubmit = async () => { 1006 const handleSubmit = async () => {
902 - // 计数打卡校验 1007 + // 计数打卡校验
903 - if (taskType.value === 'count') { 1008 + if (taskType.value === 'count') {
904 - if (selectedTaskValue.value.length === 0) { 1009 + if (selectedTaskValue.value.length === 0) {
905 - const taskText = taskOptions.value.find(t => t.value === selectedTaskValue.value[0])?.text || '作业' 1010 + const taskText =
906 - showToast(`请选择${taskText}`) 1011 + taskOptions.value.find(t => t.value === selectedTaskValue.value[0])?.text || '作业'
907 - return 1012 + showToast(`请选择${taskText}`)
908 - } 1013 + return
909 - if (selectedTargets.value.length === 0) {
910 - const targetText = dynamicFieldText.value || '对象'
911 - showToast(`请选择${targetText}`)
912 - return
913 - }
914 } 1014 }
915 - 1015 + if (selectedTargets.value.length === 0) {
916 - const extraData = { 1016 + const targetText = dynamicFieldText.value || '对象'
917 - subtask_id: selectedTaskValue.value.length > 0 ? selectedTaskValue.value[0] : '' 1017 + showToast(`请选择${targetText}`)
1018 + return
918 } 1019 }
919 - 1020 + }
920 - // 如果是计数打卡,添加选中的计数对象列表, 并添加次数 1021 +
921 - if (taskType.value === 'count') { 1022 + const extraData = {
922 - extraData.gratitude_form_list = selectedTargets.value 1023 + subtask_id: selectedTaskValue.value.length > 0 ? selectedTaskValue.value[0] : '',
923 - extraData.gratitude_count = countValue.value 1024 + }
1025 +
1026 + // 如果是计数打卡,添加选中的计数对象列表, 并添加次数
1027 + if (taskType.value === 'count') {
1028 + extraData.gratitude_form_list = selectedTargets.value
1029 + extraData.gratitude_count = countValue.value
1030 + }
1031 +
1032 + // 提交成功后的回调,清除草稿
1033 + const onSuccess = () => {
1034 + if (isDraftEnabled()) {
1035 + clearDraft(draftKey.value)
924 } 1036 }
1037 + }
925 1038
926 - // 提交成功后的回调,清除草稿 1039 + await onSubmit(extraData, onSuccess)
927 - const onSuccess = () => {
928 - if (isDraftEnabled()) {
929 - clearDraft(draftKey.value)
930 - }
931 - }
932 -
933 - await onSubmit(extraData, onSuccess)
934 } 1040 }
935 1041
936 -
937 -
938 // 是否为编辑模式 1042 // 是否为编辑模式
939 const isEditMode = computed(() => route.query.status === 'edit') 1043 const isEditMode = computed(() => route.query.status === 'edit')
940 1044
941 -
942 -
943 /** 1045 /**
944 * 返回上一页 1046 * 返回上一页
945 */ 1047 */
946 const onClickLeft = () => { 1048 const onClickLeft = () => {
947 - router.back() 1049 + router.back()
948 } 1050 }
949 1051
950 /** 1052 /**
...@@ -952,14 +1054,14 @@ const onClickLeft = () => { ...@@ -952,14 +1054,14 @@ const onClickLeft = () => {
952 * @param {string} type - 打卡类型 1054 * @param {string} type - 打卡类型
953 * @returns {string} 图标名称 1055 * @returns {string} 图标名称
954 */ 1056 */
955 -const getIconName = (type) => { 1057 +const getIconName = type => {
956 - const iconMap = { 1058 + const iconMap = {
957 - 'text': 'edit', 1059 + text: 'edit',
958 - 'image': 'photo', 1060 + image: 'photo',
959 - 'video': 'video', 1061 + video: 'video',
960 - 'audio': 'music' 1062 + audio: 'music',
961 - } 1063 + }
962 - return iconMap[type] || 'edit' 1064 + return iconMap[type] || 'edit'
963 } 1065 }
964 1066
965 /** 1067 /**
...@@ -967,12 +1069,12 @@ const getIconName = (type) => { ...@@ -967,12 +1069,12 @@ const getIconName = (type) => {
967 * @returns {string} 文件图标名称 1069 * @returns {string} 文件图标名称
968 */ 1070 */
969 const getFileIcon = () => { 1071 const getFileIcon = () => {
970 - const iconMap = { 1072 + const iconMap = {
971 - 'image': 'photo', 1073 + image: 'photo',
972 - 'video': 'video', 1074 + video: 'video',
973 - 'audio': 'music' 1075 + audio: 'music',
974 - } 1076 + }
975 - return iconMap[activeType.value] || 'description' 1077 + return iconMap[activeType.value] || 'description'
976 } 1078 }
977 1079
978 /** 1080 /**
...@@ -980,12 +1082,12 @@ const getFileIcon = () => { ...@@ -980,12 +1082,12 @@ const getFileIcon = () => {
980 * @returns {string} accept属性值 1082 * @returns {string} accept属性值
981 */ 1083 */
982 const getAcceptType = () => { 1084 const getAcceptType = () => {
983 - const acceptMap = { 1085 + const acceptMap = {
984 - 'image': 'image/*', 1086 + image: 'image/*',
985 - 'video': '.mp4,video/mp4', 1087 + video: '.mp4,video/mp4',
986 - 'audio': '.mp3,.m4a,.aac,.wav' 1088 + audio: '.mp3,.m4a,.aac,.wav',
987 - } 1089 + }
988 - return acceptMap[activeType.value] || '*' 1090 + return acceptMap[activeType.value] || '*'
989 } 1091 }
990 1092
991 /** 1093 /**
...@@ -993,50 +1095,52 @@ const getAcceptType = () => { ...@@ -993,50 +1095,52 @@ const getAcceptType = () => {
993 * @returns {string} 提示文本 1095 * @returns {string} 提示文本
994 */ 1096 */
995 const getUploadTips = () => { 1097 const getUploadTips = () => {
996 - const tipsMap = { 1098 + const tipsMap = {
997 - 'image': '支持格式:.jpg/.jpeg/.png', 1099 + image: '支持格式:.jpg/.jpeg/.png',
998 - 'video': '支持格式:.mp4(不支持 .mov)', 1100 + video: '支持格式:.mp4(不支持 .mov)',
999 - 'audio': '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)' 1101 + audio: '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)',
1000 - } 1102 + }
1001 - return tipsMap[activeType.value] || '' 1103 + return tipsMap[activeType.value] || ''
1002 } 1104 }
1003 1105
1004 /** 1106 /**
1005 * 获取任务详情 1107 * 获取任务详情
1006 * @param {string} month - 月份 1108 * @param {string} month - 月份
1007 */ 1109 */
1008 -const getTaskDetail = async (month) => { 1110 +const getTaskDetail = async month => {
1009 - const { code, data } = await getTaskDetailAPI({ i: route.query.task_id, month }) 1111 + const { code, data } = await getTaskDetailAPI({ i: route.query.task_id, month })
1010 - if (code === 1) { 1112 + if (code === 1) {
1011 - taskDetail.value = data 1113 + taskDetail.value = data
1012 - } 1114 + }
1013 } 1115 }
1014 1116
1015 /** 1117 /**
1016 * 更新附件类型选项 1118 * 更新附件类型选项
1017 * @param {Array|Object} attachmentType - 附件类型数据 1119 * @param {Array|Object} attachmentType - 附件类型数据
1018 */ 1120 */
1019 -const updateAttachmentTypeOptions = (attachmentType) => { 1121 +const updateAttachmentTypeOptions = attachmentType => {
1020 - const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig(attachmentType) 1122 + const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig(attachmentType)
1021 - attachmentTypeOptions.value = options 1123 + attachmentTypeOptions.value = options
1022 - 1124 +
1023 - // 设置最大文件大小映射 1125 + // 设置最大文件大小映射
1024 - if (upload_size_limit_mb_map) { 1126 + if (upload_size_limit_mb_map) {
1025 - setMaxFileSizeMbMap(upload_size_limit_mb_map) 1127 + setMaxFileSizeMbMap(upload_size_limit_mb_map)
1026 - } 1128 + }
1027 - 1129 +
1028 - // 如果是计数打卡(count),过滤掉文本(text)类型 1130 + // 如果是计数打卡(count),过滤掉文本(text)类型
1029 - if (taskType.value === 'count') { 1131 + if (taskType.value === 'count') {
1030 - attachmentTypeOptions.value = attachmentTypeOptions.value.filter(option => option.key !== 'text') 1132 + attachmentTypeOptions.value = attachmentTypeOptions.value.filter(
1031 - } 1133 + option => option.key !== 'text'
1032 - 1134 + )
1033 - // 设置默认选中类型(非计数打卡模式下) 1135 + }
1034 - if (taskType.value !== 'count' && attachmentTypeOptions.value.length > 0) { 1136 +
1035 - // 如果当前选中的类型不在新的选项中,则重置为第一个 1137 + // 设置默认选中类型(非计数打卡模式下)
1036 - if (!activeType.value || !attachmentTypeOptions.value.find(o => o.key === activeType.value)) { 1138 + if (taskType.value !== 'count' && attachmentTypeOptions.value.length > 0) {
1037 - activeType.value = attachmentTypeOptions.value[0].key 1139 + // 如果当前选中的类型不在新的选项中,则重置为第一个
1038 - } 1140 + if (!activeType.value || !attachmentTypeOptions.value.find(o => o.key === activeType.value)) {
1141 + activeType.value = attachmentTypeOptions.value[0].key
1039 } 1142 }
1143 + }
1040 } 1144 }
1041 1145
1042 /** 1146 /**
...@@ -1044,84 +1148,62 @@ const updateAttachmentTypeOptions = (attachmentType) => { ...@@ -1044,84 +1148,62 @@ const updateAttachmentTypeOptions = (attachmentType) => {
1044 * @param {Object} file - 文件对象 1148 * @param {Object} file - 文件对象
1045 * @param {Object} detail - 详细信息 1149 * @param {Object} detail - 详细信息
1046 */ 1150 */
1047 -const onClickPreview = (file, detail) => { 1151 +const onClickPreview = file => {
1048 - console.log('onClickPreview - file:', file) 1152 + const fileName = file.name || file.file?.name || ''
1049 - console.log('onClickPreview - detail:', detail) 1153 + let fileUrl = ''
1050 - console.log('file对象的所有属性:', Object.keys(file)) 1154 +
1051 - 1155 + if (file.url) {
1052 - const fileName = file.name || file.file?.name || '' 1156 + fileUrl = file.url
1053 - 1157 + } else if (file.content) {
1054 - // 尝试多种方式获取文件URL 1158 + fileUrl = file.content
1055 - let fileUrl = '' 1159 + } else if (file.objectURL) {
1056 - 1160 + fileUrl = file.objectURL
1057 - // 方式1: 直接从file对象获取 1161 + } else if (file.file) {
1058 - if (file.url) { 1162 + if (file.file.url) {
1059 - fileUrl = file.url 1163 + fileUrl = file.file.url
1060 - console.log('从file.url获取URL:', fileUrl) 1164 + } else {
1061 - } 1165 + try {
1062 - // 方式2: 从file.content获取 1166 + fileUrl = URL.createObjectURL(file.file)
1063 - else if (file.content) { 1167 + } catch (error) {
1064 - fileUrl = file.content 1168 + console.error('创建ObjectURL失败:', error)
1065 - console.log('从file.content获取URL:', fileUrl) 1169 + }
1066 - }
1067 - // 方式3: 从file.objectURL获取
1068 - else if (file.objectURL) {
1069 - fileUrl = file.objectURL
1070 - console.log('从file.objectURL获取URL:', fileUrl)
1071 - }
1072 - // 方式4: 从file.file获取
1073 - else if (file.file) {
1074 - if (file.file.url) {
1075 - fileUrl = file.file.url
1076 - console.log('从file.file.url获取URL:', fileUrl)
1077 - } else {
1078 - // 创建临时URL
1079 - try {
1080 - fileUrl = URL.createObjectURL(file.file)
1081 - console.log('通过URL.createObjectURL创建URL:', fileUrl)
1082 - } catch (error) {
1083 - console.error('创建ObjectURL失败:', error)
1084 - }
1085 - }
1086 - }
1087 - // 方式5: 检查是否有其他可能的URL字段
1088 - else {
1089 - const possibleUrlFields = ['src', 'path', 'value', 'href', 'link']
1090 - for (const field of possibleUrlFields) {
1091 - if (file[field]) {
1092 - fileUrl = file[field]
1093 - console.log(`从file.${field}获取URL:`, fileUrl)
1094 - break
1095 - }
1096 - }
1097 } 1170 }
1098 - 1171 + } else {
1099 - console.log('最终提取的文件名:', fileName) 1172 + const possibleUrlFields = ['src', 'path', 'value', 'href', 'link']
1100 - console.log('最终提取的文件URL:', fileUrl) 1173 + for (const field of possibleUrlFields) {
1101 - 1174 + if (file[field]) {
1102 - if (!fileUrl) { 1175 + fileUrl = file[field]
1103 - console.warn('文件URL不存在,文件对象完整结构:', JSON.stringify(file, null, 2)) 1176 + break
1104 - showToast('无法获取文件URL,请检查文件是否上传成功') 1177 + }
1105 - return
1106 } 1178 }
1107 - 1179 + }
1108 - // 根据打卡类型或文件扩展名判断文件类型 1180 +
1109 - const finalFileType = file.file_type || (isAudioFile(fileName) ? 'audio' : (isVideoFile(fileName) ? 'video' : 'image')) 1181 + if (!fileUrl) {
1110 - 1182 + console.warn('文件URL不存在,文件对象完整结构:', JSON.stringify(file, null, 2))
1111 - if (finalFileType === 'audio') { 1183 + showToast('无法获取文件URL,请检查文件是否上传成功')
1112 - console.log('准备播放音频:', fileName, fileUrl) 1184 + return
1113 - showAudio(fileName, fileUrl) 1185 + }
1114 - } else if (finalFileType === 'video') { 1186 +
1115 - console.log('准备播放视频:', fileName, fileUrl) 1187 + let finalFileType = file.file_type
1116 - showVideo(fileName, fileUrl) 1188 + if (!finalFileType) {
1117 - } else if (finalFileType === 'image') { 1189 + if (isAudioFile(fileName)) {
1118 - console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览') 1190 + finalFileType = 'audio'
1119 - // 图片预览由van-uploader的@click-preview事件处理,避免重复弹出 1191 + } else if (isVideoFile(fileName)) {
1120 - return 1192 + finalFileType = 'video'
1121 } else { 1193 } else {
1122 - console.log('该文件类型不支持预览,文件名:', fileName, '类型:', finalFileType) 1194 + finalFileType = 'image'
1123 - showToast('该文件类型不支持预览')
1124 } 1195 }
1196 + }
1197 +
1198 + if (finalFileType === 'audio') {
1199 + showAudio(fileName, fileUrl)
1200 + } else if (finalFileType === 'video') {
1201 + showVideo(fileName, fileUrl)
1202 + } else if (finalFileType === 'image') {
1203 + return
1204 + } else {
1205 + showToast('该文件类型不支持预览')
1206 + }
1125 } 1207 }
1126 1208
1127 /** 1209 /**
...@@ -1214,9 +1296,9 @@ const onClickPreview = (file, detail) => { ...@@ -1214,9 +1296,9 @@ const onClickPreview = (file, detail) => {
1214 * @param {string} fileName - 文件名 1296 * @param {string} fileName - 文件名
1215 * @returns {boolean} 1297 * @returns {boolean}
1216 */ 1298 */
1217 -const isAudioFile = (fileName) => { 1299 +const isAudioFile = fileName => {
1218 - const audioExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma'] 1300 + const audioExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma']
1219 - return audioExtensions.some(ext => fileName.toLowerCase().includes(ext)) 1301 + return audioExtensions.some(ext => fileName.toLowerCase().includes(ext))
1220 } 1302 }
1221 1303
1222 /** 1304 /**
...@@ -1224,9 +1306,9 @@ const isAudioFile = (fileName) => { ...@@ -1224,9 +1306,9 @@ const isAudioFile = (fileName) => {
1224 * @param {string} fileName - 文件名 1306 * @param {string} fileName - 文件名
1225 * @returns {boolean} 1307 * @returns {boolean}
1226 */ 1308 */
1227 -const isVideoFile = (fileName) => { 1309 +const isVideoFile = fileName => {
1228 - const videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv'] 1310 + const videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv']
1229 - return videoExtensions.some(ext => fileName.toLowerCase().includes(ext)) 1311 + return videoExtensions.some(ext => fileName.toLowerCase().includes(ext))
1230 } 1312 }
1231 1313
1232 /** 1314 /**
...@@ -1234,9 +1316,9 @@ const isVideoFile = (fileName) => { ...@@ -1234,9 +1316,9 @@ const isVideoFile = (fileName) => {
1234 * @param {string} fileName - 文件名 1316 * @param {string} fileName - 文件名
1235 * @returns {boolean} 1317 * @returns {boolean}
1236 */ 1318 */
1237 -const isImageFile = (fileName) => { 1319 +const isImageFile = fileName => {
1238 - const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'] 1320 + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
1239 - return imageExtensions.some(ext => fileName.toLowerCase().includes(ext)) 1321 + return imageExtensions.some(ext => fileName.toLowerCase().includes(ext))
1240 } 1322 }
1241 1323
1242 /** 1324 /**
...@@ -1245,9 +1327,9 @@ const isImageFile = (fileName) => { ...@@ -1245,9 +1327,9 @@ const isImageFile = (fileName) => {
1245 * @param {string} url - 音频URL 1327 * @param {string} url - 音频URL
1246 */ 1328 */
1247 const showAudio = (title, url) => { 1329 const showAudio = (title, url) => {
1248 - audioTitle.value = title 1330 + audioTitle.value = title
1249 - audioUrl.value = url 1331 + audioUrl.value = url
1250 - audioShow.value = true 1332 + audioShow.value = true
1251 } 1333 }
1252 1334
1253 /** 1335 /**
...@@ -1257,11 +1339,11 @@ const showAudio = (title, url) => { ...@@ -1257,11 +1339,11 @@ const showAudio = (title, url) => {
1257 * @param {string} cover - 视频封面URL(可选) 1339 * @param {string} cover - 视频封面URL(可选)
1258 */ 1340 */
1259 const showVideo = (title, url, cover = '') => { 1341 const showVideo = (title, url, cover = '') => {
1260 - videoTitle.value = title 1342 + videoTitle.value = title
1261 - videoUrl.value = url 1343 + videoUrl.value = url
1262 - videoCover.value = cover 1344 + videoCover.value = cover
1263 - videoShow.value = true 1345 + videoShow.value = true
1264 - isVideoPlaying.value = false // 重置播放状态 1346 + isVideoPlaying.value = false // 重置播放状态
1265 } 1347 }
1266 1348
1267 /** 1349 /**
...@@ -1270,528 +1352,531 @@ const showVideo = (title, url, cover = '') => { ...@@ -1270,528 +1352,531 @@ const showVideo = (title, url, cover = '') => {
1270 * @param {number} index - 图片索引(可选) 1352 * @param {number} index - 图片索引(可选)
1271 */ 1353 */
1272 const showImage = (url, index = 0) => { 1354 const showImage = (url, index = 0) => {
1273 - imageList.value = [url] 1355 + imageList.value = [url]
1274 - imageIndex.value = index 1356 + imageIndex.value = index
1275 - imageShow.value = true 1357 + imageShow.value = true
1276 } 1358 }
1277 1359
1278 /** 1360 /**
1279 * 开始播放视频 1361 * 开始播放视频
1280 */ 1362 */
1281 const startVideoPlay = async () => { 1363 const startVideoPlay = async () => {
1282 - isVideoPlaying.value = true 1364 + isVideoPlaying.value = true
1283 - await nextTick() 1365 + await nextTick()
1284 - if (videoPlayerRef.value) { 1366 + if (videoPlayerRef.value) {
1285 - videoPlayerRef.value.play() 1367 + videoPlayerRef.value.play()
1286 - } 1368 + }
1287 } 1369 }
1288 1370
1289 /** 1371 /**
1290 * 处理视频播放事件 1372 * 处理视频播放事件
1291 */ 1373 */
1292 const handleVideoPlay = () => { 1374 const handleVideoPlay = () => {
1293 - isVideoPlaying.value = true 1375 + isVideoPlaying.value = true
1294 } 1376 }
1295 1377
1296 /** 1378 /**
1297 * 处理视频暂停事件 1379 * 处理视频暂停事件
1298 */ 1380 */
1299 const handleVideoPause = () => { 1381 const handleVideoPause = () => {
1300 - // 保持视频播放器可见,只在初始状态显示封面 1382 + // 保持视频播放器可见,只在初始状态显示封面
1301 } 1383 }
1302 1384
1303 /** 1385 /**
1304 * 停止视频播放 1386 * 停止视频播放
1305 */ 1387 */
1306 const stopVideoPlay = () => { 1388 const stopVideoPlay = () => {
1307 - if (videoPlayerRef.value && typeof videoPlayerRef.value.pause === 'function') { 1389 + if (videoPlayerRef.value && typeof videoPlayerRef.value.pause === 'function') {
1308 - videoPlayerRef.value.pause() 1390 + videoPlayerRef.value.pause()
1309 - // 重置视频播放进度到开始位置 1391 + // 重置视频播放进度到开始位置
1310 - const player = videoPlayerRef.value.getPlayer() 1392 + const player = videoPlayerRef.value.getPlayer()
1311 - if (player && typeof player.currentTime === 'function') { 1393 + if (player && typeof player.currentTime === 'function') {
1312 - player.currentTime(0) 1394 + player.currentTime(0)
1313 - }
1314 } 1395 }
1315 - isVideoPlaying.value = false 1396 + }
1397 + isVideoPlaying.value = false
1316 } 1398 }
1317 1399
1318 /** 1400 /**
1319 * 页面挂载时的初始化逻辑 1401 * 页面挂载时的初始化逻辑
1320 */ 1402 */
1321 onMounted(async () => { 1403 onMounted(async () => {
1322 - // 获取任务详情 1404 + // 获取任务详情
1323 - const current_date = route.query.date; 1405 + const current_date = route.query.date
1324 - if (current_date) { 1406 + if (current_date) {
1325 - getTaskDetail(dayjs(current_date).format('YYYY-MM')); 1407 + getTaskDetail(dayjs(current_date).format('YYYY-MM'))
1326 - } else { 1408 + } else {
1327 - getTaskDetail(dayjs().format('YYYY-MM')); 1409 + getTaskDetail(dayjs().format('YYYY-MM'))
1328 - } 1410 + }
1329 - 1411 +
1330 - // 初始化选中的子任务ID 1412 + // 初始化选中的子任务ID
1331 - selectedTaskValue.value = route.query.subtask_id ? [+route.query.subtask_id] : [] 1413 + selectedTaskValue.value = route.query.subtask_id ? [+route.query.subtask_id] : []
1332 - 1414 +
1333 - // 获取小作业列表 1415 + // 获取小作业列表
1334 - const subtask_list = await getSubtaskListAPI({ task_id: route.query.task_id, date: current_date }) 1416 + const subtask_list = await getSubtaskListAPI({ task_id: route.query.task_id, date: current_date })
1335 - if (subtask_list.code === 1) { 1417 + if (subtask_list.code === 1) {
1336 - taskOptions.value = [...subtask_list.data.map(item => ({ 1418 + taskOptions.value = [
1337 - text: item.is_makeup ? '补卡:' + item.title : item.title, 1419 + ...subtask_list.data.map(item => ({
1338 - value: item.id, 1420 + text: item.is_makeup ? `补卡:${item.title}` : item.title,
1339 - note: item.note, // 作业描述 1421 + value: item.id,
1340 - is_makeup: item.is_makeup, // 是否为补录 1422 + note: item.note, // 作业描述
1341 - field_list: item.field_list, // 动态字段列表 1423 + is_makeup: item.is_makeup, // 是否为补录
1342 - person_type: item.person_type, // 打卡对象类型 1424 + field_list: item.field_list, // 动态字段列表
1343 - attachment_type: item.attachment_type, // 附件类型 1425 + person_type: item.person_type, // 打卡对象类型
1344 - })) 1426 + attachment_type: item.attachment_type, // 附件类型
1345 - ] 1427 + })),
1428 + ]
1429 + }
1430 +
1431 + // 如果有默认选中值,且非编辑模式(编辑模式下由initEditData统一处理,避免逻辑重复)
1432 + if (selectedTaskValue.value.length > 0 && !isEditMode.value) {
1433 + const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0])
1434 + if (option) {
1435 + selectedTaskText.value = option.text
1436 + isMakeup.value = !!option.is_makeup
1437 + personType.value = option.person_type
1438 + // 初始化动态表单字段
1439 + updateDynamicFormFields(option)
1440 + // 更新附件类型选项
1441 + if (option.attachment_type) {
1442 + updateAttachmentTypeOptions(option.attachment_type)
1443 + } else {
1444 + updateAttachmentTypeOptions(taskDetail.value.attachment_type)
1445 + }
1346 } 1446 }
1347 1447
1348 - // 如果有默认选中值,且非编辑模式(编辑模式下由initEditData统一处理,避免逻辑重复) 1448 + // 如果是计数打卡,根据选中的作业ID查询计数对象
1349 - if (selectedTaskValue.value.length > 0 && !isEditMode.value) { 1449 + if (taskType.value === 'count') {
1350 - const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0]) 1450 + await fetchTargetList(selectedTaskValue.value[0])
1351 - if (option) {
1352 - selectedTaskText.value = option.text
1353 - isMakeup.value = !!option.is_makeup
1354 - personType.value = option.person_type
1355 - // 初始化动态表单字段
1356 - updateDynamicFormFields(option)
1357 - // 更新附件类型选项
1358 - if (option.attachment_type) {
1359 - updateAttachmentTypeOptions(option.attachment_type)
1360 - } else {
1361 - updateAttachmentTypeOptions(taskDetail.value.attachment_type)
1362 - }
1363 - }
1364 -
1365 - // 如果是计数打卡,根据选中的作业ID查询计数对象
1366 - if (taskType.value === 'count') {
1367 - await fetchTargetList(selectedTaskValue.value[0])
1368 - }
1369 } 1451 }
1452 + }
1453 +
1454 + // 初始化编辑数据
1455 + await initEditData(taskOptions.value, {
1456 + onTaskFound: option => {
1457 + updateDynamicFormFields(option)
1458 + // 更新附件类型选项
1459 + if (option.attachment_type) {
1460 + updateAttachmentTypeOptions(option.attachment_type)
1461 + } else {
1462 + updateAttachmentTypeOptions(taskDetail.value.attachment_type)
1463 + }
1464 + },
1465 + ensureTargetList: async id => {
1466 + if (targetList.value.length === 0) {
1467 + await fetchTargetList(id)
1468 + }
1469 + },
1470 + // setTargets: (list) => {
1471 + // // 只有当 list 不为空时才覆盖,避免覆盖掉 fetchTargetList 中设置的默认选中项
1472 + // if (list && list.length > 0) {
1473 + // selectedTargets.value = list
1474 + // }
1475 + // },
1476 + setCount: val => {
1477 + countValue.value = val
1478 + },
1479 + })
1370 1480
1371 - // 初始化编辑数据 1481 + // 尝试恢复草稿 (非编辑模式)
1372 - await initEditData(taskOptions.value, { 1482 + if (!isEditMode.value) {
1373 - onTaskFound: (option) => { 1483 + await checkAndRestoreDraft()
1374 - updateDynamicFormFields(option) 1484 + }
1375 - // 更新附件类型选项
1376 - if (option.attachment_type) {
1377 - updateAttachmentTypeOptions(option.attachment_type)
1378 - } else {
1379 - updateAttachmentTypeOptions(taskDetail.value.attachment_type)
1380 - }
1381 - },
1382 - ensureTargetList: async (id) => {
1383 - if (targetList.value.length === 0) {
1384 - await fetchTargetList(id)
1385 - }
1386 - },
1387 - // setTargets: (list) => {
1388 - // // 只有当 list 不为空时才覆盖,避免覆盖掉 fetchTargetList 中设置的默认选中项
1389 - // if (list && list.length > 0) {
1390 - // selectedTargets.value = list
1391 - // }
1392 - // },
1393 - setCount: (val) => {
1394 - countValue.value = val
1395 - }
1396 - })
1397 -
1398 - // 尝试恢复草稿 (非编辑模式)
1399 - if (!isEditMode.value) {
1400 - await checkAndRestoreDraft()
1401 - }
1402 }) 1485 })
1403 </script> 1486 </script>
1404 1487
1405 <style lang="less" scoped> 1488 <style lang="less" scoped>
1406 .checkin-detail-page { 1489 .checkin-detail-page {
1407 - min-height: 100vh; 1490 + min-height: 100vh;
1408 - background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff); 1491 + background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff);
1409 - padding-bottom: 100px; 1492 + padding-bottom: 100px;
1410 } 1493 }
1411 1494
1412 .page-content { 1495 .page-content {
1413 - padding: 1rem; 1496 + padding: 1rem;
1414 } 1497 }
1415 1498
1416 .section-wrapper { 1499 .section-wrapper {
1417 - background-color: #fff; 1500 + background-color: #fff;
1418 - border-radius: 12px; 1501 + border-radius: 12px;
1419 - margin-bottom: 1rem; 1502 + margin-bottom: 1rem;
1420 - overflow: hidden; 1503 + overflow: hidden;
1421 - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); 1504 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
1422 } 1505 }
1423 1506
1424 .section-title { 1507 .section-title {
1425 - font-size: 1.1rem; 1508 + font-size: 1.1rem;
1426 - font-weight: 600; 1509 + font-weight: 600;
1427 - color: #4caf50; 1510 + color: #4caf50;
1428 - padding: 1rem 1rem 0.5rem; 1511 + padding: 1rem 1rem 0.5rem;
1429 - border-bottom: 1px solid #f0f0f0; 1512 + border-bottom: 1px solid #f0f0f0;
1430 } 1513 }
1431 1514
1432 .section-content { 1515 .section-content {
1433 - padding: 1rem; 1516 + padding: 1rem;
1434 - overflow: hidden; 1517 + overflow: hidden;
1435 } 1518 }
1436 1519
1437 .description-text { 1520 .description-text {
1438 - color: #666; 1521 + color: #666;
1439 - line-height: 1.6; 1522 + line-height: 1.6;
1440 - font-size: 0.95rem; 1523 + font-size: 0.95rem;
1524 + word-break: break-word;
1525 + overflow-wrap: anywhere;
1526 + width: 100%;
1527 + box-sizing: border-box;
1528 +
1529 + :deep(*) {
1530 + max-width: 100% !important;
1531 + box-sizing: border-box;
1532 + white-space: normal !important;
1533 + overflow-wrap: anywhere;
1441 word-break: break-word; 1534 word-break: break-word;
1442 - overflow-wrap: break-word; 1535 + }
1536 +
1537 + :deep(img) {
1538 + max-width: 100% !important;
1539 + height: auto;
1540 + display: block;
1541 + }
1542 +
1543 + :deep(p) {
1544 + margin: 0.5rem 0;
1545 + }
1546 +
1547 + :deep(table) {
1548 + max-width: 100% !important;
1549 + border-collapse: collapse;
1550 + overflow-x: auto;
1551 + display: table;
1443 width: 100%; 1552 width: 100%;
1444 - box-sizing: border-box; 1553 + }
1445 - overflow: hidden; 1554 +
1446 - 1555 + :deep(pre) {
1447 - :deep(*) { 1556 + max-width: 100% !important;
1448 - max-width: 100% !important; 1557 + overflow-x: auto;
1449 - box-sizing: border-box; 1558 + white-space: pre-wrap;
1450 - } 1559 + word-wrap: break-word;
1451 - 1560 + }
1452 - :deep(img) { 1561 +
1453 - max-width: 100% !important; 1562 + // 富文本内容样式
1454 - height: auto; 1563 + // :deep(.rich-content) {
1455 - display: block; 1564 + // h1, h2, h3, h4, h5, h6 {
1456 - } 1565 + // margin: 16px 0 12px 0;
1457 - 1566 + // font-weight: 600;
1458 - :deep(p) { 1567 + // line-height: 1.4;
1459 - margin: 0.5rem 0; 1568 + // }
1460 - } 1569 +
1461 - 1570 + // h2 {
1462 - :deep(table) { 1571 + // font-size: 20px;
1463 - max-width: 100% !important; 1572 + // color: #2563eb;
1464 - border-collapse: collapse; 1573 + // }
1465 - overflow-x: auto; 1574 +
1466 - display: table; 1575 + // h3 {
1467 - width: 100%; 1576 + // font-size: 18px;
1468 - } 1577 + // color: #059669;
1469 - 1578 + // }
1470 - :deep(pre) { 1579 +
1471 - max-width: 100% !important; 1580 + // h4 {
1472 - overflow-x: auto; 1581 + // font-size: 16px;
1473 - white-space: pre-wrap; 1582 + // }
1474 - word-wrap: break-word; 1583 +
1475 - } 1584 + // p {
1476 - 1585 + // margin: 12px 0;
1477 - // 富文本内容样式 1586 + // line-height: 1.6;
1478 - // :deep(.rich-content) { 1587 + // color: #374151;
1479 - // h1, h2, h3, h4, h5, h6 { 1588 + // }
1480 - // margin: 16px 0 12px 0; 1589 +
1481 - // font-weight: 600; 1590 + // strong {
1482 - // line-height: 1.4; 1591 + // font-weight: 600;
1483 - // } 1592 + // color: #dc2626;
1484 - 1593 + // }
1485 - // h2 { 1594 +
1486 - // font-size: 20px; 1595 + // code {
1487 - // color: #2563eb; 1596 + // background: #f3f4f6;
1488 - // } 1597 + // padding: 2px 6px;
1489 - 1598 + // border-radius: 4px;
1490 - // h3 { 1599 + // color: #dc2626;
1491 - // font-size: 18px; 1600 + // font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1492 - // color: #059669; 1601 + // font-size: 0.9em;
1493 - // } 1602 + // }
1494 - 1603 +
1495 - // h4 { 1604 + // ol, ul {
1496 - // font-size: 16px; 1605 + // margin: 16px 0;
1497 - // } 1606 + // padding-left: 24px;
1498 - 1607 +
1499 - // p { 1608 + // li {
1500 - // margin: 12px 0; 1609 + // margin-bottom: 8px;
1501 - // line-height: 1.6; 1610 + // line-height: 1.8;
1502 - // color: #374151; 1611 + // }
1503 - // } 1612 + // }
1504 - 1613 +
1505 - // strong { 1614 + // blockquote {
1506 - // font-weight: 600; 1615 + // border-left: 4px solid #10b981;
1507 - // color: #dc2626; 1616 + // background: #f0fdf4;
1508 - // } 1617 + // padding: 16px;
1509 - 1618 + // margin: 20px 0;
1510 - // code { 1619 + // border-radius: 0 8px 8px 0;
1511 - // background: #f3f4f6; 1620 +
1512 - // padding: 2px 6px; 1621 + // p {
1513 - // border-radius: 4px; 1622 + // margin: 0;
1514 - // color: #dc2626; 1623 + // color: #065f46;
1515 - // font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 1624 + // font-style: italic;
1516 - // font-size: 0.9em; 1625 + // }
1517 - // } 1626 + // }
1518 - 1627 +
1519 - // ol, ul { 1628 + // img {
1520 - // margin: 16px 0; 1629 + // max-width: 100%;
1521 - // padding-left: 24px; 1630 + // height: auto;
1522 - 1631 + // border-radius: 8px;
1523 - // li { 1632 + // margin: 12px 0;
1524 - // margin-bottom: 8px; 1633 + // display: block;
1525 - // line-height: 1.8; 1634 + // }
1526 - // } 1635 +
1527 - // } 1636 + // // 特殊样式容器
1528 - 1637 + // .warning-box {
1529 - // blockquote { 1638 + // background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
1530 - // border-left: 4px solid #10b981; 1639 + // padding: 16px;
1531 - // background: #f0fdf4; 1640 + // border-radius: 12px;
1532 - // padding: 16px; 1641 + // margin: 16px 0;
1533 - // margin: 20px 0; 1642 + // border-left: 4px solid #f59e0b;
1534 - // border-radius: 0 8px 8px 0; 1643 + // }
1535 - 1644 +
1536 - // p { 1645 + // .info-box {
1537 - // margin: 0; 1646 + // background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
1538 - // color: #065f46; 1647 + // padding: 16px;
1539 - // font-style: italic; 1648 + // border-radius: 12px;
1540 - // } 1649 + // margin: 20px 0;
1541 - // } 1650 + // }
1542 - 1651 +
1543 - // img { 1652 + // .deadline-box {
1544 - // max-width: 100%; 1653 + // background: #fef2f2;
1545 - // height: auto; 1654 + // border: 1px solid #fecaca;
1546 - // border-radius: 8px; 1655 + // border-radius: 8px;
1547 - // margin: 12px 0; 1656 + // padding: 16px;
1548 - // display: block; 1657 + // margin: 20px 0;
1549 - // } 1658 + // }
1550 - 1659 +
1551 - // // 特殊样式容器 1660 + // // 图片容器居中
1552 - // .warning-box { 1661 + // div[style*="text-align: center"] {
1553 - // background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); 1662 + // text-align: center;
1554 - // padding: 16px; 1663 +
1555 - // border-radius: 12px; 1664 + // img {
1556 - // margin: 16px 0; 1665 + // margin: 12px auto;
1557 - // border-left: 4px solid #f59e0b; 1666 + // }
1558 - // } 1667 +
1559 - 1668 + // p {
1560 - // .info-box { 1669 + // color: #6b7280;
1561 - // background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); 1670 + // font-size: 14px;
1562 - // padding: 16px; 1671 + // font-style: italic;
1563 - // border-radius: 12px; 1672 + // margin-top: 8px;
1564 - // margin: 20px 0; 1673 + // }
1565 - // } 1674 + // }
1566 - 1675 + // }
1567 - // .deadline-box {
1568 - // background: #fef2f2;
1569 - // border: 1px solid #fecaca;
1570 - // border-radius: 8px;
1571 - // padding: 16px;
1572 - // margin: 20px 0;
1573 - // }
1574 -
1575 - // // 图片容器居中
1576 - // div[style*="text-align: center"] {
1577 - // text-align: center;
1578 -
1579 - // img {
1580 - // margin: 12px auto;
1581 - // }
1582 -
1583 - // p {
1584 - // color: #6b7280;
1585 - // font-size: 14px;
1586 - // font-style: italic;
1587 - // margin-top: 8px;
1588 - // }
1589 - // }
1590 - // }
1591 } 1676 }
1592 1677
1593 .no-description { 1678 .no-description {
1594 - color: #999; 1679 + color: #999;
1595 - font-style: italic; 1680 + font-style: italic;
1596 - text-align: center; 1681 + text-align: center;
1597 - padding: 2rem 0; 1682 + padding: 2rem 0;
1598 } 1683 }
1599 1684
1600 .text-input-area { 1685 .text-input-area {
1601 - margin-bottom: 1.5rem; 1686 + margin-bottom: 1.5rem;
1602 1687
1603 - .van-field { 1688 + .van-field {
1604 - border-radius: 8px; 1689 + border-radius: 8px;
1605 - background-color: #f8f9fa; 1690 + background-color: #f8f9fa;
1606 - border: 1px solid #e9ecef; 1691 + border: 1px solid #e9ecef;
1607 - } 1692 + }
1608 } 1693 }
1609 1694
1610 .checkin-tabs { 1695 .checkin-tabs {
1611 - .tabs-header { 1696 + .tabs-header {
1612 - margin-bottom: 1rem; 1697 + margin-bottom: 1rem;
1613 - } 1698 + }
1614 1699
1615 - .tab-title { 1700 + .tab-title {
1616 - font-size: 1rem; 1701 + font-size: 1rem;
1617 - font-weight: 600; 1702 + font-weight: 600;
1618 - color: #333; 1703 + color: #333;
1619 - margin-bottom: 0.8rem; 1704 + margin-bottom: 0.8rem;
1620 - } 1705 + }
1621 1706
1622 - .tabs-nav { 1707 + .tabs-nav {
1623 - display: grid; 1708 + display: grid;
1624 - grid-template-columns: repeat(4, 1fr); 1709 + grid-template-columns: repeat(4, 1fr);
1625 - gap: 0.5rem; 1710 + gap: 0.5rem;
1626 - } 1711 + }
1627 1712
1628 - .tab-item { 1713 + .tab-item {
1629 - display: flex; 1714 + display: flex;
1630 - flex-direction: column; 1715 + flex-direction: column;
1631 - align-items: center; 1716 + align-items: center;
1632 - justify-content: center; 1717 + justify-content: center;
1633 - padding: 0.8rem 0.5rem; 1718 + padding: 0.8rem 0.5rem;
1634 - border: 2px solid #e8f5e8; 1719 + border: 2px solid #e8f5e8;
1635 - border-radius: 8px; 1720 + border-radius: 8px;
1636 - background-color: #fafffe; 1721 + background-color: #fafffe;
1637 - cursor: pointer; 1722 + cursor: pointer;
1638 - transition: all 0.3s ease; 1723 + transition: all 0.3s ease;
1639 1724
1640 - &:hover { 1725 + &:hover {
1641 - border-color: #4caf50; 1726 + border-color: #4caf50;
1642 - background-color: #f0fdf4; 1727 + background-color: #f0fdf4;
1643 - } 1728 + }
1644 1729
1645 - &.active { 1730 + &.active {
1646 - border-color: #4caf50; 1731 + border-color: #4caf50;
1647 - background-color: #f0fdf4; 1732 + background-color: #f0fdf4;
1648 1733
1649 - .van-icon { 1734 + .van-icon {
1650 - color: #4caf50; 1735 + color: #4caf50;
1651 - } 1736 + }
1652 1737
1653 - .tab-text { 1738 + .tab-text {
1654 - color: #4caf50; 1739 + color: #4caf50;
1655 - font-weight: 600; 1740 + font-weight: 600;
1656 - } 1741 + }
1657 - } 1742 + }
1658 1743
1659 - &.disabled { 1744 + &.disabled {
1660 - opacity: 0.5; 1745 + opacity: 0.5;
1661 - cursor: not-allowed; 1746 + cursor: not-allowed;
1662 1747
1663 - &:hover { 1748 + &:hover {
1664 - border-color: #e8f5e8; 1749 + border-color: #e8f5e8;
1665 - background-color: #fafffe; 1750 + background-color: #fafffe;
1666 - } 1751 + }
1667 - }
1668 } 1752 }
1753 + }
1669 1754
1670 - .tab-text { 1755 + .tab-text {
1671 - margin-top: 0.3rem; 1756 + margin-top: 0.3rem;
1672 - font-size: 0.8rem; 1757 + font-size: 0.8rem;
1673 - color: #666; 1758 + color: #666;
1674 - text-align: center; 1759 + text-align: center;
1675 - } 1760 + }
1676 } 1761 }
1677 1762
1678 .upload-area { 1763 .upload-area {
1679 - margin-top: 1rem; 1764 + margin-top: 1rem;
1680 1765
1681 - .van-uploader { 1766 + .van-uploader {
1682 - margin-bottom: 1rem; 1767 + margin-bottom: 1rem;
1683 - } 1768 + }
1684 } 1769 }
1685 1770
1686 .file-list { 1771 .file-list {
1687 - margin: 1rem 0; 1772 + margin: 1rem 0;
1688 } 1773 }
1689 1774
1690 .file-item { 1775 .file-item {
1776 + display: flex;
1777 + align-items: center;
1778 + justify-content: space-between;
1779 + padding: 0.8rem;
1780 + background-color: #f8f9fa;
1781 + border-radius: 8px;
1782 + margin-bottom: 0.5rem;
1783 +
1784 + .file-info {
1691 display: flex; 1785 display: flex;
1692 align-items: center; 1786 align-items: center;
1693 - justify-content: space-between; 1787 + flex: 1;
1694 - padding: 0.8rem; 1788 + gap: 0.5rem;
1695 - background-color: #f8f9fa; 1789 + cursor: pointer;
1696 - border-radius: 8px; 1790 + padding: 0.5rem;
1697 - margin-bottom: 0.5rem; 1791 + border-radius: 0.5rem;
1698 - 1792 + transition: background-color 0.2s;
1699 - .file-info { 1793 +
1700 - display: flex; 1794 + &:hover {
1701 - align-items: center; 1795 + background-color: #f5f5f5;
1702 - flex: 1;
1703 - gap: 0.5rem;
1704 - cursor: pointer;
1705 - padding: 0.5rem;
1706 - border-radius: 0.5rem;
1707 - transition: background-color 0.2s;
1708 -
1709 - &:hover {
1710 - background-color: #f5f5f5;
1711 - }
1712 } 1796 }
1797 + }
1713 1798
1714 - .file-name { 1799 + .file-name {
1715 - flex: 1; 1800 + flex: 1;
1716 - font-size: 0.9rem; 1801 + font-size: 0.9rem;
1717 - color: #333; 1802 + color: #333;
1718 - overflow: hidden; 1803 + overflow: hidden;
1719 - text-overflow: ellipsis; 1804 + text-overflow: ellipsis;
1720 - word-break: break-all; 1805 + word-break: break-all;
1721 - word-wrap: break-word; 1806 + word-wrap: break-word;
1722 - // white-space: nowrap; 1807 + // white-space: nowrap;
1808 + }
1809 +
1810 + .file-status {
1811 + font-size: 0.8rem;
1812 + padding: 0.2rem 0.5rem;
1813 + border-radius: 4px;
1814 +
1815 + &.uploading {
1816 + color: #1890ff;
1817 + background-color: #e6f7ff;
1723 } 1818 }
1724 1819
1725 - .file-status { 1820 + &.done {
1726 - font-size: 0.8rem; 1821 + color: #52c41a;
1727 - padding: 0.2rem 0.5rem; 1822 + background-color: #f6ffed;
1728 - border-radius: 4px; 1823 + }
1729 -
1730 - &.uploading {
1731 - color: #1890ff;
1732 - background-color: #e6f7ff;
1733 - }
1734 -
1735 - &.done {
1736 - color: #52c41a;
1737 - background-color: #f6ffed;
1738 - }
1739 1824
1740 - &.failed { 1825 + &.failed {
1741 - color: #ff4d4f; 1826 + color: #ff4d4f;
1742 - background-color: #fff2f0; 1827 + background-color: #fff2f0;
1743 - }
1744 } 1828 }
1829 + }
1745 1830
1746 - .delete-icon { 1831 + .delete-icon {
1747 - color: #999; 1832 + color: #999;
1748 - cursor: pointer; 1833 + cursor: pointer;
1749 1834
1750 - &:hover { 1835 + &:hover {
1751 - color: #ff4d4f; 1836 + color: #ff4d4f;
1752 - }
1753 } 1837 }
1838 + }
1754 } 1839 }
1755 1840
1756 .upload-tips { 1841 .upload-tips {
1757 - .tip-text { 1842 + .tip-text {
1758 - font-size: 0.8rem; 1843 + font-size: 0.8rem;
1759 - color: #999; 1844 + color: #999;
1760 - margin-bottom: 0.3rem; 1845 + margin-bottom: 0.3rem;
1761 - } 1846 + }
1762 } 1847 }
1763 1848
1764 .finished-notice { 1849 .finished-notice {
1765 - display: flex; 1850 + display: flex;
1766 - flex-direction: column; 1851 + flex-direction: column;
1767 - align-items: center; 1852 + align-items: center;
1768 - justify-content: center; 1853 + justify-content: center;
1769 - padding: 3rem 1rem; 1854 + padding: 3rem 1rem;
1770 - text-align: center; 1855 + text-align: center;
1771 } 1856 }
1772 1857
1773 .finished-text { 1858 .finished-text {
1774 - margin-top: 1rem; 1859 + margin-top: 1rem;
1775 - font-size: 1.1rem; 1860 + font-size: 1.1rem;
1776 - color: #4caf50; 1861 + color: #4caf50;
1777 - font-weight: 600; 1862 + font-weight: 600;
1778 } 1863 }
1779 1864
1780 .submit-area { 1865 .submit-area {
1781 - position: fixed; 1866 + position: fixed;
1782 - bottom: 0; 1867 + bottom: 0;
1783 - left: 0; 1868 + left: 0;
1784 - right: 0; 1869 + right: 0;
1785 - padding: 1rem; 1870 + padding: 1rem;
1786 - background-color: #fff; 1871 + background-color: #fff;
1787 - border-top: 1px solid #f0f0f0; 1872 + border-top: 1px solid #f0f0f0;
1788 - z-index: 100; 1873 + z-index: 100;
1789 } 1874 }
1790 1875
1791 .loading-wrapper { 1876 .loading-wrapper {
1792 - display: flex; 1877 + display: flex;
1793 - align-items: center; 1878 + align-items: center;
1794 - justify-content: center; 1879 + justify-content: center;
1795 - height: 100%; 1880 + height: 100%;
1796 } 1881 }
1797 </style> 1882 </style>
......