|
|
@@ -1,5 +1,5 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { reactive, ref, computed, onMounted } from 'vue'
|
|
|
+import { reactive, ref, computed, onMounted, watch } from 'vue'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import { Plus, EditPen, Upload, View, Close, Delete, Download, ZoomIn, Document } from '@element-plus/icons-vue'
|
|
|
import { reimburseApi, attachmentApi } from '../../api'
|
|
|
@@ -16,6 +16,8 @@ import BaseTag from '../../components/base/BaseTag.vue'
|
|
|
import BaseOpButton from '../../components/base/BaseOpButton.vue'
|
|
|
import ApprovalFlowView from '../../components/base/ApprovalFlowView.vue'
|
|
|
import BaseClampText from '../../components/base/BaseClampText.vue'
|
|
|
+import AttachmentUploadPanel from '../../components/base/AttachmentUploadPanel.vue'
|
|
|
+import { isImageFile, fileExt, type AttachmentFile } from '../../utils/attachment'
|
|
|
|
|
|
type DialogKey = 'add' | 'view' | 'viewFlow'
|
|
|
|
|
|
@@ -90,13 +92,23 @@ const addForm = reactive({
|
|
|
] as DetailLine[]
|
|
|
})
|
|
|
|
|
|
+function todayApplyDate(): string {
|
|
|
+ const d = new Date()
|
|
|
+ const y = d.getFullYear()
|
|
|
+ const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
|
+ const day = String(d.getDate()).padStart(2, '0')
|
|
|
+ return `${y}-${m}-${day}`
|
|
|
+}
|
|
|
+
|
|
|
+const addFormApplyDate = ref(todayApplyDate())
|
|
|
+const titleLoading = ref(false)
|
|
|
+
|
|
|
const viewData = ref<Record<string, any>>({})
|
|
|
const selectedRows = ref<any[]>([])
|
|
|
const flowNodes = ref<any[]>([])
|
|
|
const flowLoading = ref(false)
|
|
|
const viewFlowNodes = ref<any[]>([])
|
|
|
-const uploadedFiles = ref<Array<{ id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string }>>([])
|
|
|
-const uploadLoading = ref(false)
|
|
|
+const uploadedFiles = ref<AttachmentFile[]>([])
|
|
|
const previewVisible = ref(false)
|
|
|
const previewUrl = ref('')
|
|
|
const previewFileName = ref('')
|
|
|
@@ -108,17 +120,27 @@ const detailTotalAmount = computed(() => {
|
|
|
return addForm.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0)
|
|
|
})
|
|
|
|
|
|
+async function refreshGeneratedTitle() {
|
|
|
+ if (editingId.value) return
|
|
|
+ titleLoading.value = true
|
|
|
+ try {
|
|
|
+ const res: any = await reimburseApi.generateTitle(addFormApplyDate.value)
|
|
|
+ addForm.title = res.data || ''
|
|
|
+ } catch { /* fallback: user can type manually */ }
|
|
|
+ finally {
|
|
|
+ titleLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
async function openAddDialog() {
|
|
|
addForm.title = ''
|
|
|
addForm.remark = ''
|
|
|
addForm.details = [{ expenseItemId: '', amount: '', remark: '' }]
|
|
|
uploadedFiles.value = []
|
|
|
editingId.value = null
|
|
|
+ addFormApplyDate.value = todayApplyDate()
|
|
|
dialogs.add = true
|
|
|
- try {
|
|
|
- const res: any = await reimburseApi.generateTitle()
|
|
|
- addForm.title = res.data || ''
|
|
|
- } catch { /* fallback: user can type manually */ }
|
|
|
+ await refreshGeneratedTitle()
|
|
|
}
|
|
|
|
|
|
async function openEditDialog(row: any) {
|
|
|
@@ -176,6 +198,12 @@ onMounted(() => {
|
|
|
refreshListAndStats()
|
|
|
})
|
|
|
|
|
|
+watch(addFormApplyDate, () => {
|
|
|
+ if (dialogs.add && !editingId.value) {
|
|
|
+ refreshGeneratedTitle()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
function toggleMoreFilters() {
|
|
|
showMoreFilters.value = !showMoreFilters.value
|
|
|
}
|
|
|
@@ -242,6 +270,7 @@ async function submitAdd() {
|
|
|
} else {
|
|
|
const res: any = await reimburseApi.create({
|
|
|
title: addForm.title || undefined,
|
|
|
+ applyDate: addFormApplyDate.value,
|
|
|
remark: addForm.remark,
|
|
|
details: validDetails.map(d => ({
|
|
|
expenseItemId: Number(d.expenseItemId),
|
|
|
@@ -380,40 +409,22 @@ async function handleDelete(row: any) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async function handleUpload(options: any) {
|
|
|
- uploadLoading.value = true
|
|
|
- try {
|
|
|
- const file = options.file as File
|
|
|
- const res: any = await attachmentApi.upload(file, 'reimburse_apply', 0)
|
|
|
- const fileData: { id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string } = {
|
|
|
- id: res.data.id,
|
|
|
- fileName: res.data.fileName,
|
|
|
- fileType: res.data.fileType || '',
|
|
|
- fileSize: res.data.fileSize,
|
|
|
- previewUrl: '',
|
|
|
- }
|
|
|
- if (isImageFile(fileData.fileType)) {
|
|
|
- try {
|
|
|
- const previewRes: any = await attachmentApi.preview(fileData.id)
|
|
|
- fileData.previewUrl = previewRes.data.url
|
|
|
- } catch { /* preview not critical */ }
|
|
|
- }
|
|
|
- uploadedFiles.value.push(fileData)
|
|
|
- } catch (e: any) {
|
|
|
- ElMessage.error(e.message || '上传失败')
|
|
|
- } finally {
|
|
|
- uploadLoading.value = false
|
|
|
+async function uploadReimburseAttachment(file: File): Promise<AttachmentFile> {
|
|
|
+ const res: any = await attachmentApi.upload(file, 'reimburse_apply', 0)
|
|
|
+ const fileData: AttachmentFile = {
|
|
|
+ id: res.data.id,
|
|
|
+ fileName: res.data.fileName,
|
|
|
+ fileType: res.data.fileType || '',
|
|
|
+ fileSize: res.data.fileSize,
|
|
|
+ previewUrl: '',
|
|
|
}
|
|
|
-}
|
|
|
-
|
|
|
-async function handleRemoveAttachment(index: number) {
|
|
|
- const file = uploadedFiles.value[index]
|
|
|
- try {
|
|
|
- await ElMessageBox.confirm('确定删除该附件?', '提示', { type: 'warning' })
|
|
|
- await attachmentApi.delete(file.id)
|
|
|
- uploadedFiles.value.splice(index, 1)
|
|
|
- ElMessage.success('已删除')
|
|
|
- } catch { /* cancelled or already errored */ }
|
|
|
+ if (isImageFile(fileData.fileType)) {
|
|
|
+ try {
|
|
|
+ const previewRes: any = await attachmentApi.preview(fileData.id)
|
|
|
+ fileData.previewUrl = previewRes.data.url
|
|
|
+ } catch { /* preview not critical */ }
|
|
|
+ }
|
|
|
+ return fileData
|
|
|
}
|
|
|
|
|
|
async function handleDownload(file: { id: number; fileName: string }) {
|
|
|
@@ -453,15 +464,6 @@ function formatFileSize(bytes: number): string {
|
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
|
}
|
|
|
|
|
|
-function isImageFile(fileType: string): boolean {
|
|
|
- return fileType?.startsWith('image/')
|
|
|
-}
|
|
|
-
|
|
|
-function fileExt(name: string): string {
|
|
|
- const ext = name.split('.').pop()
|
|
|
- return ext ? ext.toUpperCase() : 'FILE'
|
|
|
-}
|
|
|
-
|
|
|
function summaryMethod(params: { data: any[] }) {
|
|
|
const data = params.data
|
|
|
const n = data.length
|
|
|
@@ -607,12 +609,28 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
|
|
|
|
|
|
<div class="form-section">
|
|
|
<h4>基础信息</h4>
|
|
|
- <div class="form-grid-2">
|
|
|
+ <div v-if="!editingId" class="form-grid-2">
|
|
|
<div class="field">
|
|
|
+ <label>申请时间</label>
|
|
|
+ <el-date-picker
|
|
|
+ v-model="addFormApplyDate"
|
|
|
+ type="date"
|
|
|
+ value-format="YYYY-MM-DD"
|
|
|
+ placeholder="选择申请日期"
|
|
|
+ class="bx-date-picker field-date-picker"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="field" v-loading="titleLoading">
|
|
|
<label>报销标题</label>
|
|
|
<BaseInput v-model="addForm.title" placeholder="不填则自动生成" />
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <div v-else class="form-grid-2">
|
|
|
+ <div class="field">
|
|
|
+ <label>报销标题</label>
|
|
|
+ <BaseInput v-model="addForm.title" disabled />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
|
|
|
<div class="form-section">
|
|
|
@@ -679,35 +697,7 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
|
|
|
|
|
|
<div class="form-section">
|
|
|
<h4>附件上传</h4>
|
|
|
- <div class="attachment-upload-area">
|
|
|
- <el-upload
|
|
|
- :http-request="handleUpload"
|
|
|
- :show-file-list="false"
|
|
|
- multiple
|
|
|
- :disabled="uploadLoading"
|
|
|
- >
|
|
|
- <div class="upload-trigger">
|
|
|
- <el-icon class="upload-icon"><Plus /></el-icon>
|
|
|
- <p>点击上传附件</p>
|
|
|
- </div>
|
|
|
- </el-upload>
|
|
|
-
|
|
|
- <div v-if="uploadedFiles.length" class="attachment-grid">
|
|
|
- <div v-for="(file, idx) in uploadedFiles" :key="file.id" class="attachment-card">
|
|
|
- <div class="card-preview" @click="isImageFile(file.fileType) && handlePreview(file)">
|
|
|
- <img v-if="isImageFile(file.fileType) && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
|
|
|
- <div v-else class="card-file-icon">
|
|
|
- <el-icon :size="32"><Document /></el-icon>
|
|
|
- <span class="card-ext">{{ fileExt(file.fileName) }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="card-footer">
|
|
|
- <el-button size="small" text @click="handleRemoveAttachment(idx)"><el-icon><Delete /></el-icon> 删除</el-button>
|
|
|
- <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ <AttachmentUploadPanel v-model="uploadedFiles" :upload="uploadReimburseAttachment" />
|
|
|
</div>
|
|
|
<div class="form-section form-section-last">
|
|
|
<h4>备注说明</h4>
|
|
|
@@ -959,36 +949,6 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
-.attachment-upload-area {
|
|
|
- margin-top: 4px;
|
|
|
-}
|
|
|
-
|
|
|
-.upload-trigger {
|
|
|
- width: 100%;
|
|
|
- border: 1px dashed var(--border);
|
|
|
- border-radius: var(--radius-md);
|
|
|
- padding: 20px;
|
|
|
- text-align: center;
|
|
|
- cursor: pointer;
|
|
|
- transition: border-color 0.2s;
|
|
|
- color: var(--text-muted);
|
|
|
-}
|
|
|
-
|
|
|
-.upload-trigger:hover {
|
|
|
- border-color: var(--primary);
|
|
|
- color: var(--primary-dark);
|
|
|
-}
|
|
|
-
|
|
|
-.upload-trigger .upload-icon {
|
|
|
- font-size: 24px;
|
|
|
- margin-bottom: 6px;
|
|
|
-}
|
|
|
-
|
|
|
-.upload-trigger p {
|
|
|
- margin: 0;
|
|
|
- font-size: 13px;
|
|
|
-}
|
|
|
-
|
|
|
.attachment-grid {
|
|
|
margin-top: 10px;
|
|
|
display: grid;
|