| | |
| | | <script setup lang="ts"> |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import { createLeave, getHrmDicts, getLeave, getLeaveProcesses, submitLeave, updateLeave, type ApproveProcess, type HrmAttachment, type Leave } from "../../../api/hrm"; |
| | | import { createLeave, getHrmDicts, getLeave, getLeaveProcesses, submitLeave, updateLeave, uploadHrmFile, type ApproveProcess, type HrmAttachment, type Leave } from "../../../api/hrm"; |
| | | import type { DictData } from "../../../api/crm"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | import { HRM_DICT, label, options, setDateTime } from "../shared"; |
| | | import HrmAttachmentField from "../HrmAttachmentField.vue"; |
| | | |
| | | const { hasPermission, state } = useAuth(); |
| | | const id = ref<number>(); |
| | |
| | | const submitting = ref(false); |
| | | const dicts = ref<DictData[]>([]); |
| | | const attachments = ref<HrmAttachment[]>([]); |
| | | const pendingImages = ref<string[]>([]); |
| | | const MAX_ATTACHMENTS = 10; |
| | | const IMAGE_PATTERN = /\.(png|jpe?g|gif|webp|bmp|heic)(\?.*)?$/i; |
| | | const uploading = computed(() => pendingImages.value.length > 0); |
| | | const form = reactive<Leave>({ userId: undefined, leaveType: undefined, startTime: "", endTime: "", duration: 0.5, reason: "", blobIds: [] }); |
| | | const readonly = computed(() => mode.value === "detail"); |
| | | const canSave = computed(() => !readonly.value && (id.value ? hasPermission("hrm:leave:update") : hasPermission("hrm:leave:create"))); |
| | | const canSubmit = computed(() => canSave.value && hasPermission("hrm:leave:submit")); |
| | | const typeOptions = computed(() => options(dicts.value, HRM_DICT.leaveType)); |
| | | const userText = computed(() => form.userName || state.user?.nickname || state.user?.username || "当前登录用户"); |
| | | |
| | | function isImage(file: HrmAttachment) { |
| | | return IMAGE_PATTERN.test(file.url || "") || IMAGE_PATTERN.test(file.name || ""); |
| | | } |
| | | |
| | | function chooseImages() { |
| | | if (!state.accessToken || uploading.value) return; |
| | | const remain = MAX_ATTACHMENTS - attachments.value.length - pendingImages.value.length; |
| | | if (remain <= 0) { |
| | | uni.showToast({ title: `最多上传${MAX_ATTACHMENTS}个附件`, icon: "none" }); |
| | | return; |
| | | } |
| | | uni.chooseImage({ |
| | | count: remain, |
| | | sourceType: ["album", "camera"], |
| | | success: async result => { |
| | | const paths = result.tempFilePaths || []; |
| | | if (!paths.length) return; |
| | | pendingImages.value.push(...paths); |
| | | try { |
| | | for (const path of paths) { |
| | | try { |
| | | const file = await uploadHrmFile(state.accessToken!, path); |
| | | attachments.value.push(file); |
| | | if (file.id && !form.blobIds?.includes(file.id)) { |
| | | form.blobIds = [...(form.blobIds || []), file.id]; |
| | | } |
| | | } catch (error) { |
| | | uni.showToast({ title: error instanceof Error ? error.message : "图片上传失败", icon: "none" }); |
| | | } |
| | | } |
| | | } finally { |
| | | pendingImages.value = pendingImages.value.filter(path => !paths.includes(path)); |
| | | } |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | function removeAttachment(index: number) { |
| | | const file = attachments.value[index]; |
| | | attachments.value.splice(index, 1); |
| | | if (file?.id !== undefined) { |
| | | form.blobIds = (form.blobIds || []).filter(blobId => blobId !== file.id); |
| | | } |
| | | } |
| | | |
| | | function previewAttachment(index: number) { |
| | | const file = attachments.value[index]; |
| | | if (!file?.url) { |
| | | uni.showToast({ title: "附件地址不可用", icon: "none" }); |
| | | return; |
| | | } |
| | | if (isImage(file)) { |
| | | const urls = attachments.value |
| | | .filter(item => isImage(item) && item.url) |
| | | .map(item => item.url as string); |
| | | uni.previewImage({ urls, current: file.url }); |
| | | return; |
| | | } |
| | | // #ifdef H5 |
| | | window.open(file.url, "_blank"); |
| | | // #endif |
| | | // #ifndef H5 |
| | | uni.showToast({ title: "非图片附件请在 Web 端查看", icon: "none" }); |
| | | // #endif |
| | | } |
| | | |
| | | function validateForm() { |
| | | if (!form.userId || form.leaveType === undefined || !form.startTime || !form.endTime || !form.duration || !form.reason?.trim()) { |
| | |
| | | |
| | | async function save() { |
| | | if (!validateForm() || !state.accessToken || saving.value || submitting.value) return; |
| | | if (uploading.value) { |
| | | uni.showToast({ title: "图片上传中,请稍候", icon: "none" }); |
| | | return; |
| | | } |
| | | saving.value = true; |
| | | try { |
| | | await persistDraft(); |
| | |
| | | |
| | | async function saveAndSubmit() { |
| | | if (!validateForm() || !state.accessToken || saving.value || submitting.value) return; |
| | | if (uploading.value) { |
| | | uni.showToast({ title: "图片上传中,请稍候", icon: "none" }); |
| | | return; |
| | | } |
| | | submitting.value = true; |
| | | let draftSaved = false; |
| | | try { |
| | |
| | | </view> |
| | | <view class="field"><text>请假时长 <text class="required">*</text></text><input v-model.number="form.duration" :disabled="readonly" type="digit" placeholder="单位:天" /></view> |
| | | <view class="field column"><text>请假原因 <text class="required">*</text></text><textarea v-model="form.reason" :disabled="readonly" maxlength="500" placeholder="请输入请假原因" /></view> |
| | | <HrmAttachmentField class="leave-attachment" v-model="form.blobIds" v-model:files="attachments" :token="state.accessToken" :readonly="readonly" /> |
| | | <view v-if="!readonly || attachments.length" class="field column attachment-field"> |
| | | <text>图片附件 <text class="optional">(选填,最多10张)</text></text> |
| | | <view class="attachment-grid"> |
| | | <view v-for="(file, index) in attachments" :key="file.id" class="attachment-cell"> |
| | | <image v-if="isImage(file)" :src="file.url" mode="aspectFill" @click="previewAttachment(index)" /> |
| | | <view v-else class="attachment-doc" @click="previewAttachment(index)"> |
| | | <text>{{ file.name || `附件${index + 1}` }}</text> |
| | | </view> |
| | | <text v-if="!readonly" class="attachment-del" @click="removeAttachment(index)">×</text> |
| | | </view> |
| | | <view v-for="(path, index) in pendingImages" :key="`pending-${index}`" class="attachment-cell"> |
| | | <image :src="path" mode="aspectFill" /> |
| | | <view class="attachment-mask"><text>上传中</text></view> |
| | | </view> |
| | | <view v-if="!readonly && attachments.length + pendingImages.length < MAX_ATTACHMENTS" |
| | | class="attachment-add" @click="chooseImages"> |
| | | <text class="plus">+</text> |
| | | <text>上传图片</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注" /></view> |
| | | </view> |
| | | <view v-if="canSave" class="form-actions"> |
| | |
| | | |
| | | <style lang="scss" scoped> |
| | | @import "../form.scss"; |
| | | .leave-attachment{box-sizing:border-box;padding-right:24rpx;padding-left:24rpx} |
| | | .attachment-field { |
| | | gap: 18rpx; |
| | | } |
| | | .optional { |
| | | color: #97a4b4; |
| | | font-size: 22rpx; |
| | | } |
| | | .attachment-grid { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 18rpx; |
| | | } |
| | | .attachment-cell { |
| | | position: relative; |
| | | width: 150rpx; |
| | | height: 150rpx; |
| | | } |
| | | .attachment-cell image { |
| | | width: 150rpx; |
| | | height: 150rpx; |
| | | background: #f2f4f7; |
| | | border-radius: 12rpx; |
| | | } |
| | | .attachment-doc { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | box-sizing: border-box; |
| | | width: 150rpx; |
| | | height: 150rpx; |
| | | padding: 12rpx; |
| | | background: #f2f6fc; |
| | | border: 1rpx solid #dbe5f2; |
| | | border-radius: 12rpx; |
| | | } |
| | | .attachment-doc text { |
| | | color: #526780; |
| | | font-size: 22rpx; |
| | | text-align: center; |
| | | word-break: break-all; |
| | | } |
| | | .attachment-del { |
| | | position: absolute; |
| | | top: -12rpx; |
| | | right: -12rpx; |
| | | width: 38rpx; |
| | | height: 38rpx; |
| | | color: #fff; |
| | | font-size: 28rpx; |
| | | line-height: 36rpx; |
| | | text-align: center; |
| | | background: rgba(0, 0, 0, 0.55); |
| | | border-radius: 50%; |
| | | } |
| | | .attachment-mask { |
| | | position: absolute; |
| | | top: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | left: 0; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | background: rgba(0, 0, 0, 0.4); |
| | | border-radius: 12rpx; |
| | | } |
| | | .attachment-mask text { |
| | | color: #fff; |
| | | font-size: 22rpx; |
| | | } |
| | | .attachment-add { |
| | | display: flex; |
| | | align-items: center; |
| | | flex-direction: column; |
| | | justify-content: center; |
| | | box-sizing: border-box; |
| | | width: 150rpx; |
| | | height: 150rpx; |
| | | gap: 8rpx; |
| | | color: #7c8aa0; |
| | | font-size: 22rpx; |
| | | background: #f7f9fc; |
| | | border: 1rpx dashed #c3cfdf; |
| | | border-radius: 12rpx; |
| | | } |
| | | .attachment-add .plus { |
| | | color: #93a3ba; |
| | | font-size: 44rpx; |
| | | line-height: 1; |
| | | } |
| | | .attachment-add:active { |
| | | background: #eef3fa; |
| | | } |
| | | </style> |