Cursor快速实现上传文件功能 目录1、页面主体src\views\AttendanceWorkHours.vue2、列表 / 上传 / 删除 / 下载 / 预览src\api\hrReport.ts3、上传文件类型与名称校验src\utils\hrFileValidate.ts一、页面主体src\views\ReportAutoProcess.vue二、Mock 接口src\api\reportProcess.ts页面布局与交互Mock 接口优化完整版本:src\views\ReportAutoProcess.vuesrc\api\reportProcess.tssrc\views\Report.vue背景:帮我实现一个需求功能:关于XXXXXX。项目使用的是 elementplus,刚开始接触使用 Vue3,不需要拆分的太细了,新生成一个路由页面以便我测试。1、页面主体src\views\AttendanceWorkHours.vueVue3 + Element Plustemplate div header h1工时与考勤管理/h1 router-link to="/survey-builder"返回其他演示/router-link /header !-- Tab:考勤 / 工时 -- el-tabs v-model="activeTab" @tab-change="onTabChange" el-tab-pane label="考勤" / el-tab-pane label="工时" / /el-tabs !-- 第一行:搜索 -- el-form :inline="true" @submit.prevent="handleSearch" el-form-item label="文件名" el-input v-model="searchForm.fileName" clearable placeholder="模糊搜索报表名称" style="width: 180px" / /el-form-item el-form-item label="姓名" el-input v-model="searchForm.operatorName" clearable placeholder="模糊搜索操作人" style="width: 140px" / /el-form-item el-form-item v-if="activeTab === 'attendance'" label="年-月" el-date-picker v-model="searchForm.yearMonth" type="month" placeholder="选择年月" value-format="YYYY-MM" clearable style="width: 150px" / /el-form-item el-form-item v-else label="年份" el-date-picker v-model="searchForm.year" type="year" placeholder="选择年份" value-format="YYYY" clearable style="width: 120px" / /el-form-item el-form-item el-button @click="handleReset"重置/el-button el-button type="primary" :loading="tableLoading" @click="handleSearch" 搜索 /el-button /el-form-item /el-form !-- 第二行:上传 / 批量删除 -- div el-button type="primary" :icon="Upload" @click="openUploadDialog" 上传 Excel /el-button el-button type="danger" :icon="Delete" :disabled="!selectedIds.length" @click="handleBatchDelete" 批量删除 /el-button span v-if="selectedIds.length" 已选 { { selectedIds.length }} 项 /span /div !-- 列表 -- el-table v-loading="tableLoading" :data="tableData" border stripe row-key="id" @selection-change="onSelectionChange" el-table-column type="selection" align="center" / el-table-column prop="reportName" label="汇总报表名称" min-width="240" show-overflow-tooltip / el-table-column prop="operator" label="操作人" / el-table-column prop="operateTime" label="操作时间" / el-table-column label="操作" fixed="right" align="center" template #default="{ row }" el-button type="primary" link @click="handlePreview(row)" 预览 /el-button el-button type="primary" link @click="handleDownload(row)" 下载 /el-button el-button type="danger" link @click="handleDelete(row)" 删除 /el-button /template /el-table-column /el-table !-- 分页 -- div el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" background @size-change="loadList" @current-change="loadList" / /div !-- 上传弹窗 -- el-dialog v-model="uploadVisible" :title="uploadDialogTitle" destroy-on-close @closed="resetUploadForm" el-form label-width="100px" el-form-item v-if="activeTab === 'attendance'" label="考勤月份" required el-date-picker v-model="uploadForm.yearMonth" type="month" placeholder="选择上传数据所属月份" value-format="YYYY-MM" style="width: 100%" / /el-form-item el-form-item v-else label="工时年份" required el-date-picker v-model="uploadForm.year" type="year" placeholder="选择工时统计年份" value-format="YYYY" style="width: 100%" / /el-form-item el-form-item label="上传文件" required el-upload ref="uploadRef" drag multiple :auto-upload="false" accept=".xls,.xlsx,.zip" :file-list="uploadFileList" :on-change="onUploadFileChange" :on-remove="onUploadFileRemove" :before-upload="() = false" el-iconUploadFilled //el-icon div 将文件拖到此处,或 em点击选择/em /div template #tip div template v-if="activeTab === 'attendance'" 支持多个 Excel(文件名需含:加班列表 / 请假列表 / 记录报表 / 考勤)或一个 .zip 压缩包 /template template v-else 支持多个 Excel(文件名需含「工时」)或一个 .zip 压缩包 /template /div /template /el-upload /el-form-item /el-form template #footer el-button @click="uploadVisible = false"取消/el-button el-button type="primary" :loading="uploading" @click="submitUpload" 确认上传 /el-button /template /el-dialog !-- 预览弹窗 -- el-dialog v-model="previewVisible" :title="previewTitle" destroy-on-close el-table v-loading="previewLoading" :data="previewRows" border stripe el-table-column prop="dept" label="部门" / el-table-column prop="name" label="姓名" / el-table-column prop="detail" label="汇总数据" min-width="160" / el-table-column prop="remark" label="备注" min-width="120" / /el-table template #footer el-button type="primary" @click="previewVisible = false" 关闭 /el-button /template /el-dialog /div /template script setup lang="ts" import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import type { UploadFile, UploadInstance, UploadUserFile } from 'element-plus' import { Delete, Upload, UploadFilled } from '@element-plus/icons-vue' import { deleteReports, downloadReport, fetchReportList, previewReport, uploadReports, type ReportRecord, type ReportType, } from '@/api/hrReport' import { validateUploadFiles } from '@/utils/hrFileValidate' // ---------- 当前 Tab ---------- const activeTab = refReportType('attendance') // ---------- 搜索表单 ---------- const searchForm = reactive({ fileName: '', operatorName: '', yearMonth: '' as string, year: '' as string, }) // ---------- 表格 ---------- const tableLoading = ref(false) const tableData = refReportRecord[]([]) const selectedIds = refstring[]([]) const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) // ---------- 上传 ---------- const uploadVisible = ref(false) const uploading = ref(false) const uploadRef = refUploadInstance() const uploadFileList = refUploadUserFile[]([]) const uploadForm = reactive({ yearMonth: '', year: '', }) const uploadDialogTitle = computed(() = activeTab.value === 'attendance' ? '上传考勤数据' : '上传工时统计表', ) // ---------- 预览 ---------- const previewVisible = ref(false) const previewLoading = ref(false) const previewTitle = ref('') const previewRows = ref { dept: string; name: string; detail: string; remark: string }[] ([]) /** 加载列表 */ async function loadList() { tableLoading.value = true try { const q: Parameterstypeof fetchReportList[0] = { type: activeTab.value, page: pagination.page, pageSize: pagination.pageSize, fileName: searchForm.fileName || undefined, operatorName: searchForm.operatorName || undefined, } if (activeTab.value === 'attendance' searchForm.yearMonth) { const [y, m] = searchForm.yearMonth.split('-') q.year = Number(y) q.month = Number(m) } if (activeTab.value === 'workhours' searchForm.year) { q.year = Number(searchForm.year) } const res = await fetchReportList(q) tableData.value = res.list pagination.total = res.total } finally { tableLoading.value = false } } function onTabChange() { selectedIds.value = [] pagination.page = 1 handleReset() } function handleSearch() { pagination.page = 1 loadList() } function handleReset() { searchForm.fileName = '' searchForm.operatorName = '' searchForm.yearMonth = '' searchForm.year = '' pagination.page = 1 loadList() } function onSelectionChange(rows: ReportRecord[]) { selectedIds.value = rows.map((r) = r.id) } // ---------- 上传相关 ---------- function openUploadDialog() { uploadVisible.value = true } function resetUploadForm() { uploadForm.yearMonth = '' uploadForm.year = '' uploadFileList.value = [] uploadRef.value?.clearFiles() } function onUploadFileChange(_file: UploadFile, fileList: UploadUserFile[]) { uploadFileList.value = fileList } function onUploadFileRemove(_file: UploadFile, fileList: UploadUserFile[]) { uploadFileList.value = fileList } function getUploadRawFiles(): File[] { const files: File[] = [] for (const item of uploadFileList.value) { if (item.raw instanceof File) files.push(item.raw) } return files } async function submitUpload() { const files = getUploadRawFiles() const validate = validateUploadFiles(files, activeTab.value) if (!validate.ok) { ElMessage.warning(validate.message) return } let year = 0 let month: number | undefined if (activeTab.value === 'attendance') { if (!uploadForm.yearMonth) { ElMessage.warning('请选择考勤月份') return } const [y, m] = uploadForm.yearMonth.split('-') year = Number(y) month = Number(m) } else { if (!uploadForm.year) { ElMessage.warning('请选择工时年份') return } year = Number(uploadForm.year) } uploading.value = true try { await uploadReports({ type: activeTab.value, files, year, month, operator: '当前用户', }) ElMessage.success('上传成功,后端已合并汇总') uploadVisible.value = false pagination.page = 1 await loadList() } catch { ElMessage.error('上传失败,请稍后重试') } finally { uploading.value = false } } // ---------- 预览 / 下载 / 删除 ---------- async function handlePreview(row: ReportRecord) { previewTitle.value = `预览:${row.reportName}` previewVisible.value = true previewLoading.value = true previewRows.value = [] try { previewRows.value = await previewReport(activeTab.value, row.id) } finally { previewLoading.value = false } } async function handleDownload(row: ReportRecord) { try { const blob = await downloadReport(activeTab.value, row) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = row.reportName a.click() URL.revokeObjectURL(url) ElMessage.success('下载已开始') } catch { ElMessage.error('下载失败') } } async function handleDelete(row: ReportRecord) { await ElMessageBox.confirm(`确定删除「${row.reportName}」吗?`, '提示', { type: 'warning', }) await deleteReports(activeTab.value, [row.id]) ElMessage.success('删除成功') if (tableData.value.length === 1 pagination.page 1) { pagination.page -= 1 } await loadList() } async function handleBatchDelete() { if (!selectedIds.value.length) return await ElMessageBox.confirm( `确定删除选中的 ${selectedIds.value.length} 条记录吗?`, '批量删除', { type: 'warning' }, ) await deleteReports(activeTab.value, [...selectedIds.value]) selectedIds.value = [] ElMessage.success('批量删除成功') pagination.page = 1 await loadList() } onMounted(() = { loadList() }) /script style scoped .hr-page { position: relative; left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw; width: 100vw; box-sizing: border-box; padding: 20px 24px 32px; background: #f5f7fa; min-height: calc(100vh - 32px); } .hr-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .hr-title { margin: 0; font-size: 20px; font-weight: 600; color: #303133; } .hr-link { font-size: 13px; color: #409eff; } .hr-tabs { background: #fff; padding: 0 16px; border-radius: 8px 8px 0 0; } .search-form { background: #fff; padding: 16px 16px 4px; border-radius: 0; } .search-actions { float: right; margin-right: 0 !important; } .toolbar { background: #fff; padding: 0 16px 16px; display: flex; align-items: center; gap: 12px; border-radius: 0 0 8px 8px; margin-bottom: 16px; } .selected-tip { font-size: 13px; color: #909399; } .el-table { border-radius: 8px; overflow: hidden; } .pagination-wrap { margin-top: 16px; display: flex; justify-content: flex-end; } .upload-icon { font-size: 48px; color: #c0c4cc; margin-bottom: 8px; } .upload-tip { font-size: 12px; color: #909399; line-height: 1.6; margin-top: 8px; } /style2、列表 / 上传 / 删除 / 下载 / 预览src\api\hrReport.ts当前为 Mock/** * 工时 / 考勤报表 API * 当前为 Mock 实现,对接后端时替换 fetch 地址与响应解析即可。 */ export type ReportType = 'attendance' | 'workhours' export interface ReportRecord { id: string reportName: string operator: string operateTime: string year: number month?: number } export interface ListQuery { type: ReportType page: number pageSize: number fileName?: string operatorName?: string year?: number month?: number } export interface ListResult { list: ReportRecord[] total: number } export interface PreviewRow { dept: string name: string detail: string remark: string } // ---------- Mock 内存数据(刷新页面会重置) ---------- const mockDb: RecordReportType, ReportRecord[] = { attendance: [ { id: 'a1', reportName: 'KQHZ报表_2024年01月.xlsx', operator: '李四', operateTime: '2024-02-01 10:20:00', year: 2024, month: 1, }, { id: 'a2', reportName: 'KQHZ报表_2024年02月.xlsx', operator: '王五', operateTime: '2024-03-02 14:35:00', year: 2024, month: 2, }, ], workhours: [ { id: 'w1', reportName: 'BMGS汇总_2023.xlsx', operator: '张三', operateTime: '2024-01-15 09:10:00', year: 2023, }, { id: 'w2', reportName: 'BMGS汇总_2024.xlsx', operator: '李四', operateTime: '2024-06-20 16:40:00', year: 2024, }, ], } function uid(): string { return crypto.randomUUID() } function nowStr(): string { const d = new Date() const pad = (n: number) = String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } function filterList(type: ReportType, q: ListQuery): ReportRecord[] { let rows = [...mockDb[type]] if (q.fileName?.trim()) { const kw = q.fileName.trim().toLowerCase() rows = rows.filter((r) = r.reportName.toLowerCase().includes(kw)) } if (q.operatorName?.trim()) { const kw = q.operatorName.trim() rows = rows.filter((r) = r.operator.includes(kw)) } if (q.year) { rows = rows.filter((r) = r.year === q.year) } if (type === 'attendance' q.month) { rows = rows.filter((r) = r.month === q.month) } return rows } /** 列表(分页 + 筛选) */ export async function fetchReportList(q: ListQuery): PromiseListResult { await delay(300) const all = filterList(q.type, q) const start = (q.page - 1) * q.pageSize return { list: all.slice(start, start + q.pageSize), total: all.length, } } export interface UploadParams { type: ReportType files: File[] year: number month?: number operator?: string } /** 上传并汇总(Mock:模拟后端合并) */ export async function uploadReports(p: UploadParams): PromiseReportRecord { await delay(1200) const operator = p.operator || '当前用户' let reportName: string if (p.type === 'attendance' p.month) { reportName = `KQHZ报表_${p.year}年${String(p.month).padStart(2, '0')}月.xlsx` }