Преглед изворни кода

考勤明细导入改弹框交互:模板下载移入弹框,上传结果弹窗反馈

lihaiting пре 5 часа
родитељ
комит
9fb522056d

+ 0 - 1
src/api/hr/attendance.js

@@ -2010,4 +2010,3 @@ export const getDailyAttendanceDetailPage = async (params = {}) => {
   return pageResult(await unwrap(await request.get('/hr/attendance/daily', { params: query })));
 };
 
-

+ 156 - 0
src/views/attendance/daily/components/AttendanceImportDialog.vue

@@ -0,0 +1,156 @@
+<template>
+  <!--
+    考勤明细导入弹框:版式复制自 kd-aiot-frontend 的 components/upload/import-dialogNew.vue(2026-11-03 用户要求)。
+    - 上传:POST /hr/attendance/daily/import/excel(formData 字段名 file,单文件),
+      结果反馈(总/成功/失败 + 逐行错误 + warnings)用 $alert 展示(逻辑取自远程内联版 70915e4)
+    - 模板下载:弹框内「下载模板」链接,js 用 downloadDailyImportTemplate(GET /hr/attendance/daily/import/template)
+  -->
+  <ele-modal
+    title="导入文件上传"
+    :visible.sync="visible"
+    width="560px"
+    append-to-body
+    custom-class="attendance-import-dialog"
+  >
+    <el-form label-width="90px">
+      <el-form-item label="选择文件">
+        <el-upload
+          action="#"
+          accept=".xls,.xlsx"
+          :show-file-list="false"
+          :http-request="handlePick"
+          :multiple="false"
+        >
+          <el-button icon="el-icon-plus" size="small" type="primary" :disabled="importing">
+            文件上传
+          </el-button>
+          <div slot="tip" class="el-upload__tip">
+            只能上传excel文件,点击
+            <el-link type="primary" :underline="false" :disabled="importing" @click="downloadTemplate">
+              下载模板
+            </el-link>
+          </div>
+        </el-upload>
+      </el-form-item>
+      <el-form-item label="上传列表">
+        <div class="imgs-box">
+          <p v-for="(item, index) in files" :key="index" class="imgs-p">
+            <span>{{ item.name }}</span>
+            <el-link type="primary" :underline="false" :disabled="importing" @click="removeFile(index)">
+              删除
+            </el-link>
+          </p>
+        </div>
+      </el-form-item>
+    </el-form>
+
+    <span slot="footer">
+      <el-button size="small" @click="visible = false">关 闭</el-button>
+      <el-button size="small" type="primary" :loading="importing" @click="upload">上 传</el-button>
+    </span>
+  </ele-modal>
+</template>
+
+<script>
+import {
+  importDailyAttendance,
+  downloadDailyImportTemplate,
+} from '@/api/hr/attendance';
+
+export default {
+  name: 'AttendanceImportDialog',
+  data() {
+    return {
+      visible: false,
+      importing: false,
+      /* 后端接口一次只收一个 file,选择新文件会替换旧文件 */
+      files: [],
+    };
+  },
+  methods: {
+    open() {
+      this.files = [];
+      this.visible = true;
+    },
+    removeFile(index) {
+      this.files.splice(index, 1);
+    },
+    /* 选择文件:只收 excel,且后端单文件接口一次只传一个 → 新选的替换旧的 */
+    handlePick({ file }) {
+      if (!/\.(xlsx|xls)$/i.test(String(file?.name || ''))) {
+        this.$message.warning('只能上传 excel 文件(.xls / .xlsx)');
+        return;
+      }
+      this.files = [file];
+    },
+    async upload() {
+      if (!this.files.length) {
+        this.$message.warning('请先选择文件');
+        return;
+      }
+      this.importing = true;
+      try {
+        /* 返回 { totalCount, successCount, failedCount, errors: [{ rowNumber, employeeCode, employeeName, message }], warnings } */
+        const result = await importDailyAttendance(this.files[0]);
+        const errors = (result.errors || [])
+          .slice(0, 3)
+          .map(
+            (item) =>
+              `第${item.rowNumber}行${item.employeeName ? `(${item.employeeName})` : ''}:${
+                item.message || '导入失败'
+              }`,
+          )
+          .join(';');
+        const warnings = (result.warnings || []).slice(0, 3).join(';');
+        this.$alert(
+          `总计 ${result.totalCount || 0} 条,成功 ${result.successCount || 0} 条,失败 ${
+            result.failedCount || 0
+          } 条${errors ? `;${errors}` : ''}${warnings ? `;提示:${warnings}` : ''}`,
+          '考勤明细导入结果',
+          { type: result.failedCount || warnings ? 'warning' : 'success' },
+        );
+        this.visible = false;
+        this.$emit('success');
+      } catch (error) {
+        /* request 层已弹过 toast 的错误不重复提示 */
+        if (!error?.__toastShown) {
+          this.$message.error(error?.message || '导入失败');
+        }
+      } finally {
+        this.importing = false;
+      }
+    },
+    /* 模板下载 js 用远程 70915e4 的 downloadDailyImportTemplate:
+       API 内部 downloadBlobResponse 落盘(decodeURIComponent 文件名 + 拦「HTTP 200 + JSON 业务错误」) */
+    async downloadTemplate() {
+      try {
+        await downloadDailyImportTemplate();
+      } catch (error) {
+        if (!error?.__toastShown) {
+          this.$message.error(error?.message || '下载模板失败');
+        }
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+/* append-to-body 后 scoped 失效 → 挂 custom-class 写非 scoped 样式(项目约定) */
+.attendance-import-dialog {
+  .imgs-box .imgs-p {
+    min-height: 30px;
+    background: #f0f3f3;
+    line-height: 30px;
+    width: 100%;
+    margin-bottom: 5px;
+    padding: 0 10px;
+    display: flex;
+    justify-content: space-between;
+    box-sizing: border-box;
+  }
+  .el-upload__tip {
+    line-height: 24px;
+  }
+}
+</style>

+ 35 - 153
src/views/attendance/daily/index.vue

@@ -4,60 +4,24 @@
       <!-- 筛选区:所属机构 / 所属部门 / 姓名 / 日期 -->
       <el-form inline :model="filter" class="daily-filter" @submit.native.prevent>
         <el-form-item label="所属机构">
-          <el-select
-            v-model="filter.orgId"
-            placeholder="请选择"
-            clearable
-            filterable
-            style="width: 220px"
-            @change="onOrgChange"
-          >
-            <el-option
-              v-for="item in filterOrgOptions"
-              :key="item.value"
-              :label="item.label"
-              :value="item.value"
-            />
+          <el-select v-model="filter.orgId" placeholder="请选择" clearable filterable style="width: 220px"
+            @change="onOrgChange">
+            <el-option v-for="item in filterOrgOptions" :key="item.value" :label="item.label" :value="item.value" />
           </el-select>
         </el-form-item>
         <el-form-item label="所属部门">
-          <el-select
-            v-model="filter.deptId"
-            placeholder="全部部门"
-            clearable
-            filterable
-            style="width: 220px"
-            @change="search"
-          >
-            <el-option
-              v-for="item in filterDeptOptions"
-              :key="item.value"
-              :label="item.label"
-              :value="item.value"
-            />
+          <el-select v-model="filter.deptId" placeholder="全部部门" clearable filterable style="width: 220px"
+            @change="search">
+            <el-option v-for="item in filterDeptOptions" :key="item.value" :label="item.label" :value="item.value" />
           </el-select>
         </el-form-item>
         <el-form-item label="姓名">
-          <el-input
-            v-model="filter.employeeKeyword"
-            placeholder="姓名模糊/工号精确"
-            clearable
-            style="width: 200px"
-            @keyup.enter.native="search"
-            @clear="search"
-          />
+          <el-input v-model="filter.employeeKeyword" placeholder="姓名模糊/工号精确" clearable style="width: 200px"
+            @keyup.enter.native="search" @clear="search" />
         </el-form-item>
         <el-form-item label="日期">
-          <el-date-picker
-            v-model="filter.dateRange"
-            type="daterange"
-            value-format="yyyy-MM-dd"
-            range-separator="-"
-            start-placeholder="开始日期"
-            end-placeholder="结束日期"
-            style="width: 260px"
-            @change="search"
-          />
+          <el-date-picker v-model="filter.dateRange" type="daterange" value-format="yyyy-MM-dd" range-separator="-"
+            start-placeholder="开始日期" end-placeholder="结束日期" style="width: 260px" @change="search" />
         </el-form-item>
         <el-form-item>
           <el-button type="primary" icon="el-icon-search" @click="search">查询</el-button>
@@ -65,32 +29,20 @@
         </el-form-item>
       </el-form>
 
-      <ele-pro-table
-        ref="table"
-        row-key="id"
-        :columns="columns"
-        :datasource="fetchPage"
-        :need-page="true"
-        :page-size="20"
-        :page-sizes="[10, 20, 50]"
-        :cache-key="$tableCacheKey('hr-attendance-daily-v9')"
-      >
+      <ele-pro-table ref="table" row-key="id" :columns="columns" :datasource="fetchPage" :need-page="true"
+        :page-size="20" :page-sizes="[10, 20, 50]" :cache-key="$tableCacheKey('hr-attendance-daily-v9')">
         <template v-slot:toolbar>
-          <!-- 导入模板列与列表一致(必填列带*),导入直接覆盖每日结果、后端不重算 -->
-          <el-button icon="el-icon-document" @click="downloadTemplate">下载模板</el-button>
-          <el-button icon="el-icon-upload2" :loading="importing" @click="onImport">导入excel</el-button>
+          <el-button icon="el-icon-upload2" @click="onImport">导入excel</el-button>
           <el-button type="primary" icon="el-icon-download" @click="onExport">excel导出</el-button>
         </template>
 
         <template slot="name" slot-scope="{ row }">
-          <!-- 点姓名看详情(替代原操作列「查看」入口) -->
           <el-link type="primary" :underline="false" @click="viewRow(row)">
             {{ row.name || '—' }}
           </el-link>
         </template>
 
         <template slot="statusNameList" slot-scope="{ row }">
-          <!-- 出勤情况:当日状态名称(如 正常/迟到/旷工),tag 一行一个、左对齐(2026-11-03 用户要求排列整齐) -->
           <div v-for="(name, index) in row.statusNameList" :key="index" class="status-tag-line">
             <el-tag size="mini" :type="attendanceStatusTagType(name)" class="punch-tag">
               {{ name }}
@@ -99,72 +51,54 @@
         </template>
 
         <template slot="inStatus" slot-scope="{ row }">
-          <!-- 上班·打卡情况:punchRecords 中 punchType=IN 记录的 statusName(后端中文直取) -->
           <template v-if="row.inPunches.length">
-            <el-tag
-              v-for="(item, index) in row.inPunches"
-              :key="index"
-              size="mini"
-              :type="punchStatusTagType(item)"
-              class="punch-tag"
-            >
+            <el-tag v-for="(item, index) in row.inPunches" :key="index" size="mini" :type="punchStatusTagType(item)"
+              class="punch-tag">
               {{ item.statusText }}
             </el-tag>
           </template>
         </template>
 
         <template slot="inType" slot-scope="{ row }">
-          <!-- 上班·打卡类型:记录的 sourceTypeName(移动端/考勤设备/补卡),纯文本逐行(2026-11-03 用户要求不用标签) -->
           <div v-for="(item, index) in row.inPunches" :key="index" class="punch-time-line">
             {{ item.sourceType }}
           </div>
         </template>
 
         <template slot="inTime" slot-scope="{ row }">
-          <!-- 上班·打卡时间:多条记录逐行展示,接口未返回则留空 -->
           <div v-for="(item, index) in row.inPunches" :key="index" class="punch-time-line">
             {{ item.punchTime }}
           </div>
         </template>
 
         <template slot="inAddress" slot-scope="{ row }">
-          <!-- 上班·打卡地点:punchRecords 中 punchType=IN 记录的 address,逐行展示,未返回则留空 -->
           <div v-for="(item, index) in row.inPunches" :key="index" class="punch-time-line">
             {{ item.address }}
           </div>
         </template>
 
         <template slot="outStatus" slot-scope="{ row }">
-          <!-- 下班·打卡情况:punchRecords 中 punchType=OUT 记录的 statusName -->
           <template v-if="row.outPunches.length">
-            <el-tag
-              v-for="(item, index) in row.outPunches"
-              :key="index"
-              size="mini"
-              :type="punchStatusTagType(item)"
-              class="punch-tag"
-            >
+            <el-tag v-for="(item, index) in row.outPunches" :key="index" size="mini" :type="punchStatusTagType(item)"
+              class="punch-tag">
               {{ item.statusText }}
             </el-tag>
           </template>
         </template>
 
         <template slot="outType" slot-scope="{ row }">
-          <!-- 下班·打卡类型:记录的 sourceTypeName,纯文本逐行(2026-11-03 用户要求不用标签) -->
           <div v-for="(item, index) in row.outPunches" :key="index" class="punch-time-line">
             {{ item.sourceType }}
           </div>
         </template>
 
         <template slot="outTime" slot-scope="{ row }">
-          <!-- 下班·打卡时间:多条记录逐行展示,接口未返回则留空 -->
           <div v-for="(item, index) in row.outPunches" :key="index" class="punch-time-line">
             {{ item.punchTime }}
           </div>
         </template>
 
         <template slot="outAddress" slot-scope="{ row }">
-          <!-- 下班·打卡地点:punchRecords 中 punchType=OUT 记录的 address,逐行展示,未返回则留空 -->
           <div v-for="(item, index) in row.outPunches" :key="index" class="punch-time-line">
             {{ item.address }}
           </div>
@@ -176,13 +110,10 @@
       </ele-pro-table>
     </el-card>
 
+    <attendance-import-dialog ref="importDialog" @success="search" />
+
     <!-- 查看详情弹框 -->
-    <ele-modal
-      title="考勤明细详情"
-      :visible.sync="detailVisible"
-      width="720px"
-      append-to-body
-    >
+    <ele-modal title="考勤明细详情" :visible.sync="detailVisible" width="720px" append-to-body>
       <el-descriptions v-if="detailRow" :column="2" border size="small">
         <el-descriptions-item label="姓名">{{ detailRow.name }}</el-descriptions-item>
         <el-descriptions-item label="岗位">{{ detailRow.postName }}</el-descriptions-item>
@@ -241,9 +172,8 @@ import {
 import {
   getDailyAttendanceDetailPage,
   adaptDailyAttendance,
-  downloadDailyImportTemplate,
-  importDailyAttendance,
 } from '@/api/hr/attendance';
+import AttendanceImportDialog from './components/AttendanceImportDialog.vue';
 
 /* 日期筛选默认当月:本月 1 号 ~ 今天(2026-10-30 用户指定默认查当月) */
 function currentMonthRange() {
@@ -256,6 +186,9 @@ function currentMonthRange() {
 
 export default {
   name: 'AttendanceDaily',
+  components: {
+    AttendanceImportDialog,
+  },
   data() {
     return {
       filter: {
@@ -270,13 +203,11 @@ export default {
       orgTreeNodes: [],
       columns: [
         { type: 'index', label: '序号', width: 60, align: 'center', headerAlign: 'center', fixed: 'left' },
-        /* 日期列不排序:后端 /hr/attendance/daily 无排序参数,服务端分页下前端排序只能排当前页,会误导 */
         { prop: 'attendanceDate', label: '日期', minWidth: 110, fixed: 'left' },
         { prop: 'name', label: '姓名', minWidth: 100, slot: 'name', fixed: 'left' },
         { prop: 'postName', label: '岗位', minWidth: 130 },
         { prop: 'groupName', label: '所属机构', minWidth: 220 },
         { prop: 'deptName', label: '所属部门', minWidth: 140 },
-        /* 出勤情况:当日状态名称集合,后端 statusNameList 直取(2026-11-02 用户指定字段);tag 逐行左对齐 */
         { prop: 'statusNameList', label: '出勤情况', minWidth: 130, align: 'left', headerAlign: 'left', slot: 'statusNameList' },
         {
           label: '上班',
@@ -284,9 +215,8 @@ export default {
           children: [
             { prop: 'inStatus', label: '打卡状态', minWidth: 100, slot: 'inStatus' },
             { prop: 'inType', label: '打卡类型', minWidth: 110, slot: 'inType' },
-            { prop: 'inTime', label: '打卡时间', minWidth:90, slot: 'inTime' },
-            /* 打卡地点:punchRecords 内 address 直取(2026-11-03 用户指定),分组各自取 IN/OUT 记录 */
-            { prop: 'inAddress', label: '打卡地点', minWidth:90, slot: 'inAddress' },
+            { prop: 'inTime', label: '打卡时间', minWidth: 90, slot: 'inTime' },
+            { prop: 'inAddress', label: '打卡地点', minWidth: 90, slot: 'inAddress' },
           ],
         },
         {
@@ -296,16 +226,14 @@ export default {
             { prop: 'outStatus', label: '打卡状态', minWidth: 100, slot: 'outStatus' },
             { prop: 'outType', label: '打卡类型', minWidth: 110, slot: 'outType' },
             { prop: 'outTime', label: '打卡时间', minWidth: 90, slot: 'outTime' },
-            { prop: 'outAddress', label: '打卡地点', minWidth:90, slot: 'outAddress' },
+            { prop: 'outAddress', label: '打卡地点', minWidth: 90, slot: 'outAddress' },
           ],
         },
         { prop: 'workAddress', label: '工作地址', minWidth: 140 },
       ],
-      /* 当前页数据快照:本地 excel 导出用(后端暂无导出接口) */
       currentRows: [],
       detailVisible: false,
       detailRow: null,
-      importing: false,
     };
   },
   created() {
@@ -414,61 +342,10 @@ export default {
       this.detailRow = row;
       this.detailVisible = true;
     },
-    /* 下载导入模板:表头与列表一致,必填列带*(日期/姓名/所属机构/所属部门/出勤情况/工作地址) */
-    async downloadTemplate() {
-      try {
-        await downloadDailyImportTemplate();
-      } catch (error) {
-        this.$message.error(error.message || '模板下载失败');
-      }
-    },
-    /* 导入考勤明细:后端直接覆盖每日结果(含锁定与已调整数据)并打导入标识,导入后不重算 */
+    /* 导入考勤明细:后端直接覆盖每日结果(含锁定与已调整数据)并打导入标识,导入后不重算。
+       上传/模板下载/结果反馈都在 AttendanceImportDialog 内完成 */
     onImport() {
-      const input = document.createElement('input');
-      input.type = 'file';
-      input.accept = '.xlsx,.xls';
-      const cleanup = () => {
-        if (input.parentNode) input.parentNode.removeChild(input);
-      };
-      //挂到 body 再点击,兼容部分浏览器对游离 input 的 click 限制
-      document.body.appendChild(input);
-      input.onchange = async () => {
-        const file = input.files?.[0];
-        if (!file) {
-          cleanup();
-          return;
-        }
-        this.importing = true;
-        try {
-          const result = await importDailyAttendance(file);
-          const errors = (result.errors || [])
-            .slice(0, 3)
-            .map(
-              (item) =>
-                `第${item.rowNumber}行${item.employeeName ? `(${item.employeeName})` : ''}:${
-                  item.message || '导入失败'
-                }`,
-            )
-            .join(';');
-          const warnings = (result.warnings || []).slice(0, 3).join(';');
-          this.$alert(
-            `总计 ${result.totalCount || 0} 条,成功 ${result.successCount || 0} 条,失败 ${
-              result.failedCount || 0
-            } 条${errors ? `;${errors}` : ''}${warnings ? `;提示:${warnings}` : ''}`,
-            '考勤明细导入结果',
-            { type: result.failedCount || warnings ? 'warning' : 'success' },
-          );
-          this.search();
-        } catch (error) {
-          this.$message.error(error.message || '考勤明细导入失败');
-        } finally {
-          this.importing = false;
-          cleanup();
-        }
-      };
-      //用户取消选择时 onchange 不触发,避免 DOM 上残留隐藏 input
-      input.oncancel = cleanup;
-      input.click();
+      this.$refs.importDialog.open();
     },
     /* 后端暂无导出接口:先导出当前页已加载数据(xlsx,SheetJS 本地生成,与员工档案导入模板同款);
        全量导出待后端补接口。注意 SheetJS 社区版只支持写 .xlsx,写不了老格式 .xls(BIFF8)。
@@ -532,21 +409,26 @@ export default {
     margin-bottom: 4px;
   }
 }
+
 .punch-tag {
   margin-right: 6px;
   margin-bottom: 2px;
 }
+
 /* 上下班分组「打卡时间」列:多条打卡记录逐行展示 */
 .punch-time-line {
   line-height: 20px;
 }
+
 /* 出勤情况列:tag 一行一个、强制左对齐(防全局/表头样式带偏) */
 .status-tag-line {
   text-align: left;
   line-height: 26px;
 }
+
 .punch-records {
   margin-top: 16px;
+
   h4 {
     margin: 0 0 8px;
     font-size: 14px;