|
|
@@ -0,0 +1,760 @@
|
|
|
+<template>
|
|
|
+ <el-dialog
|
|
|
+ :visible.sync="show"
|
|
|
+ :title="config ? config.title : '下钻明细'"
|
|
|
+ custom-class="metric-drill-dialog"
|
|
|
+ append-to-body
|
|
|
+ width="1100px"
|
|
|
+ top="6vh"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ >
|
|
|
+ <div v-if="config" class="drill-body" v-loading="loading">
|
|
|
+ <!-- 口径行:与卡片同源的统计口径 + 数量 -->
|
|
|
+ <div class="drill-meta">
|
|
|
+ <span>口径:{{ caliberText }}</span>
|
|
|
+ <span class="drill-meta__divider">|</span>
|
|
|
+ <span>共 <b>{{ totalText }}</b> {{ config.countUnit }}</span>
|
|
|
+ <div class="drill-actions">
|
|
|
+ <button type="button" class="drill-btn drill-btn--primary" :disabled="exporting" @click="exportRows(true)">
|
|
|
+ <i class="el-icon-download"></i>导出 Excel
|
|
|
+ </button>
|
|
|
+ <button type="button" class="drill-btn" @click="show = false">
|
|
|
+ <i class="el-icon-close"></i>关闭
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 明细表 -->
|
|
|
+ <el-table :data="rows" stripe class="drill-table" empty-text="暂无数据">
|
|
|
+ <el-table-column
|
|
|
+ v-for="column in config.columns"
|
|
|
+ :key="column.prop"
|
|
|
+ :prop="column.prop"
|
|
|
+ :label="column.label"
|
|
|
+ :width="column.width"
|
|
|
+ :min-width="column.minWidth"
|
|
|
+ :fixed="column.fixed"
|
|
|
+ align="left"
|
|
|
+ show-overflow-tooltip
|
|
|
+ >
|
|
|
+ <template v-if="column.slot === 'status'" v-slot="{ row }">
|
|
|
+ <el-tag size="mini" :type="statusMeta(row).type">{{ statusMeta(row).label }}</el-tag>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="column.slot === 'days'" v-slot="{ row }">
|
|
|
+ <el-tag size="mini" type="danger">{{ row[column.prop] }} 天</el-tag>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="column.slot === 'action'" v-slot="{ row }">
|
|
|
+ <el-link
|
|
|
+ v-for="action in config.actionTexts"
|
|
|
+ :key="action"
|
|
|
+ type="primary"
|
|
|
+ :underline="false"
|
|
|
+ class="drill-action"
|
|
|
+ @click="handleAction(action, row)"
|
|
|
+ >
|
|
|
+ {{ action }}
|
|
|
+ </el-link>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <!-- 底部:数量 + 分页 + 导出本页 -->
|
|
|
+ <div class="drill-footer">
|
|
|
+ <span class="drill-footer__total">共 <b>{{ totalText }}</b> 条</span>
|
|
|
+ <el-pagination
|
|
|
+ v-if="config.paginated !== false"
|
|
|
+ layout="prev, pager, next"
|
|
|
+ small
|
|
|
+ :total="total"
|
|
|
+ :page-size="size"
|
|
|
+ :current-page="page"
|
|
|
+ :pager-count="5"
|
|
|
+ class="drill-pager"
|
|
|
+ @current-change="handlePageChange"
|
|
|
+ />
|
|
|
+ <button type="button" class="drill-btn drill-btn--primary" :disabled="exporting" @click="exportRows(false)">
|
|
|
+ <i class="el-icon-download"></i>导出本页
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import * as XLSX from "xlsx";
|
|
|
+import {
|
|
|
+ getStructureDetails,
|
|
|
+ exportStructureDetails,
|
|
|
+} from "@/api/hr/personnelStructure";
|
|
|
+import {
|
|
|
+ getAttendanceExceptionReport,
|
|
|
+ exceptionLabel,
|
|
|
+} from "@/api/hr/attendance";
|
|
|
+import {
|
|
|
+ getContractUnrenewedReport,
|
|
|
+ contractTypeLabel,
|
|
|
+} from "@/api/hr/index";
|
|
|
+/* 考勤异常明细只回 userId,姓名/工号/部门走主数据批量接口补齐(带缓存) */
|
|
|
+import { loadEmployeeBriefs } from "@/api/organization";
|
|
|
+
|
|
|
+function pad2(value) {
|
|
|
+ return String(value).padStart(2, "0");
|
|
|
+}
|
|
|
+
|
|
|
+function formatDate(date) {
|
|
|
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
|
+}
|
|
|
+
|
|
|
+function sliceDate(value) {
|
|
|
+ return String(value || "").slice(0, 10);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 考勤异常处理状态 → 文案 / 颜色。
|
|
|
+ * 实测后端返回 OPEN(未闭环);其余枚举未定稿,认得的翻译、不认识的原样展示。
|
|
|
+ */
|
|
|
+function attendanceStatusMeta(value) {
|
|
|
+ const labelMap = {
|
|
|
+ OPEN: "待处理",
|
|
|
+ PENDING: "待处理",
|
|
|
+ UNHANDLED: "待处理",
|
|
|
+ UNPROCESSED: "待处理",
|
|
|
+ CLOSED: "已处理",
|
|
|
+ HANDLED: "已处理",
|
|
|
+ PROCESSED: "已处理",
|
|
|
+ DONE: "已处理",
|
|
|
+ };
|
|
|
+ const label = labelMap[String(value || "").toUpperCase()] || String(value || "") || "待处理";
|
|
|
+ return { label, type: /已处理/.test(label) ? "success" : "warning" };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 顶部卡片编码 → 下钻弹窗配置。
|
|
|
+ * load(ctx) 返回 { list, total };exportAll(ctx) 有则「导出 Excel」优先走服务端
|
|
|
+ * (返回 rows 数组则继续走本地 xlsx 导出,返回空值表示已由服务端处理)。
|
|
|
+ */
|
|
|
+export const DRILL_DOWN_CONFIGS = {
|
|
|
+ ON_JOB_TOTAL: {
|
|
|
+ title: "总员工数 · 下钻明细",
|
|
|
+ countUnit: "人",
|
|
|
+ caliber({ statisticsDate }) {
|
|
|
+ return `状态=在职且入职日期≤统计日${statisticsDate ? `(${statisticsDate})` : ""}`;
|
|
|
+ },
|
|
|
+ columns: [
|
|
|
+ { prop: "jobNumber", label: "工号", width: 110 },
|
|
|
+ { prop: "name", label: "姓名", width: 100 },
|
|
|
+ { prop: "deptName", label: "部门", minWidth: 130 },
|
|
|
+ { prop: "position", label: "岗位", minWidth: 140 },
|
|
|
+ { prop: "hireDate", label: "入职日期", width: 110 },
|
|
|
+ { prop: "status", label: "员工状态", width: 100, slot: "status" },
|
|
|
+ { prop: "action", label: "操作", width: 90, fixed: "right", slot: "action" },
|
|
|
+ ],
|
|
|
+ actionTexts: ["查看档案"],
|
|
|
+ statusMeta(value) {
|
|
|
+ return value === "leave"
|
|
|
+ ? { label: "离职", type: "info" }
|
|
|
+ : { label: "在职", type: "success" };
|
|
|
+ },
|
|
|
+ async load({ organizationId, reportMonth, page, size }) {
|
|
|
+ const data = await getStructureDetails(
|
|
|
+ { pageNum: page, size, flowType: "ON_JOB" },
|
|
|
+ { organizationId, month: reportMonth },
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ total: data.count,
|
|
|
+ list: data.list.map((item) => ({
|
|
|
+ jobNumber: item.employeeNo,
|
|
|
+ name: item.name,
|
|
|
+ deptName: item.department,
|
|
|
+ position: item.position,
|
|
|
+ hireDate: item.joinDate,
|
|
|
+ status: item.status === "离职" ? "leave" : "active",
|
|
|
+ })),
|
|
|
+ };
|
|
|
+ },
|
|
|
+ async exportAll({ organizationId, reportMonth }) {
|
|
|
+ await exportStructureDetails(
|
|
|
+ { flowType: "ON_JOB" },
|
|
|
+ { organizationId, month: reportMonth },
|
|
|
+ );
|
|
|
+ return null;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ MONTH_ONBOARD: {
|
|
|
+ title: "本月入职 · 下钻明细",
|
|
|
+ countUnit: "人",
|
|
|
+ caliber({ monthRange }) {
|
|
|
+ return monthRange.start
|
|
|
+ ? `入职日期 ∈ [${monthRange.start}, ${monthRange.end}]`
|
|
|
+ : "入职日期在本统计月内";
|
|
|
+ },
|
|
|
+ columns: [
|
|
|
+ { prop: "jobNumber", label: "工号", width: 110 },
|
|
|
+ { prop: "name", label: "姓名", width: 100 },
|
|
|
+ { prop: "deptName", label: "部门", minWidth: 110 },
|
|
|
+ { prop: "position", label: "岗位", minWidth: 120 },
|
|
|
+ { prop: "hireDate", label: "入职日期", width: 110 },
|
|
|
+ { prop: "source", label: "入职来源", minWidth: 100 },
|
|
|
+ { prop: "handler", label: "经办人", width: 100 },
|
|
|
+ { prop: "action", label: "操作", width: 100, fixed: "right", slot: "action" },
|
|
|
+ ],
|
|
|
+ actionTexts: ["查看入岗单"],
|
|
|
+ async load({ organizationId, reportMonth, page, size }) {
|
|
|
+ const data = await getStructureDetails(
|
|
|
+ { pageNum: page, size, flowType: "ONBOARD" },
|
|
|
+ { organizationId, month: reportMonth },
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ total: data.count,
|
|
|
+ list: data.list.map((item) => ({
|
|
|
+ jobNumber: item.employeeNo,
|
|
|
+ name: item.name,
|
|
|
+ deptName: item.department,
|
|
|
+ position: item.position,
|
|
|
+ hireDate: item.joinDate,
|
|
|
+ // 明细模型暂无入职来源 / 经办人字段,后端补充后自动展示
|
|
|
+ source: "",
|
|
|
+ handler: "",
|
|
|
+ })),
|
|
|
+ };
|
|
|
+ },
|
|
|
+ async exportAll({ organizationId, reportMonth }) {
|
|
|
+ await exportStructureDetails(
|
|
|
+ { flowType: "ONBOARD" },
|
|
|
+ { organizationId, month: reportMonth },
|
|
|
+ );
|
|
|
+ return null;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ATTENDANCE_ABNORMAL_DEPT: {
|
|
|
+ title: "考勤异常 · 待处理明细",
|
|
|
+ countUnit: "条",
|
|
|
+ caliber({ monthRange }) {
|
|
|
+ return monthRange.start
|
|
|
+ ? `统计月(${monthRange.start} ~ ${monthRange.end})内未关账考勤异常`
|
|
|
+ : "统计月内未关账考勤异常";
|
|
|
+ },
|
|
|
+ columns: [
|
|
|
+ { prop: "jobNumber", label: "工号", width: 110 },
|
|
|
+ { prop: "name", label: "姓名", width: 100 },
|
|
|
+ { prop: "deptName", label: "部门", minWidth: 120 },
|
|
|
+ { prop: "type", label: "异常类型", minWidth: 120 },
|
|
|
+ { prop: "date", label: "异常日期", width: 110 },
|
|
|
+ { prop: "status", label: "处理状态", width: 100, slot: "status" },
|
|
|
+ { prop: "action", label: "操作", width: 90, fixed: "right", slot: "action" },
|
|
|
+ ],
|
|
|
+ actionTexts: ["去处理"],
|
|
|
+ statusMeta: attendanceStatusMeta,
|
|
|
+ async load({ organizationId, monthRange, page, size }) {
|
|
|
+ // ★ 实测:dateStart/dateEnd 是必填口径,缺一个就返回 {code:'-1', message:''} 的静默失败
|
|
|
+ const data = await getAttendanceExceptionReport({
|
|
|
+ pageNum: page,
|
|
|
+ size,
|
|
|
+ deptId: organizationId || undefined,
|
|
|
+ dateStart: monthRange.start || undefined,
|
|
|
+ dateEnd: monthRange.end || undefined,
|
|
|
+ });
|
|
|
+ return { total: data.count, list: await adaptAttendanceRows(data.list) };
|
|
|
+ },
|
|
|
+ /** 无导出接口:分页拉全量后本地导出(单页上限 200,最多 50 页) */
|
|
|
+ async exportAll(ctx) {
|
|
|
+ const size = 200;
|
|
|
+ const base = {
|
|
|
+ size,
|
|
|
+ deptId: ctx.organizationId || undefined,
|
|
|
+ dateStart: ctx.monthRange.start || undefined,
|
|
|
+ dateEnd: ctx.monthRange.end || undefined,
|
|
|
+ };
|
|
|
+ const first = await getAttendanceExceptionReport({ ...base, pageNum: 1 });
|
|
|
+ const rows = [...first.list];
|
|
|
+ const total = Number(first.count) || rows.length;
|
|
|
+ for (let pageNum = 2; rows.length < total && pageNum <= 50; pageNum += 1) {
|
|
|
+ const next = await getAttendanceExceptionReport({ ...base, pageNum });
|
|
|
+ if (!next.list?.length) break;
|
|
|
+ rows.push(...next.list);
|
|
|
+ }
|
|
|
+ return adaptAttendanceRows(rows);
|
|
|
+ },
|
|
|
+ },
|
|
|
+ CONTRACT_EXPIRE: {
|
|
|
+ title: "合同到期 · 下钻明细",
|
|
|
+ countUnit: "条",
|
|
|
+ caliber({ statisticsDate, warnEndDate }) {
|
|
|
+ return `合同到期日 ∈ [统计日 ${statisticsDate || "—"}, 统计日+预警期30天 ${warnEndDate || "—"}] 且续签状态≠已续签`;
|
|
|
+ },
|
|
|
+ columns: [
|
|
|
+ { prop: "jobNumber", label: "工号", width: 100 },
|
|
|
+ { prop: "name", label: "姓名", width: 90 },
|
|
|
+ { prop: "deptName", label: "部门", minWidth: 100 },
|
|
|
+ { prop: "position", label: "岗位", minWidth: 110 },
|
|
|
+ { prop: "contractNo", label: "合同编号", width: 120 },
|
|
|
+ { prop: "contractType", label: "合同类型", minWidth: 110 },
|
|
|
+ { prop: "startDate", label: "合同起始日", width: 110 },
|
|
|
+ { prop: "endDate", label: "合同到期日", width: 110 },
|
|
|
+ { prop: "remainDays", label: "剩余天数", width: 90, slot: "days" },
|
|
|
+ { prop: "status", label: "续签状态", width: 90, slot: "status" },
|
|
|
+ { prop: "action", label: "操作", width: 130, fixed: "right", slot: "action" },
|
|
|
+ ],
|
|
|
+ actionTexts: ["办理续签", "查看合同"],
|
|
|
+ paginated: false,
|
|
|
+ statusMeta(value) {
|
|
|
+ return value === "signed"
|
|
|
+ ? { label: "已续签", type: "success" }
|
|
|
+ : { label: "未续签", type: "warning" };
|
|
|
+ },
|
|
|
+ async load() {
|
|
|
+ // 非分页接口(后端口径:到期未续签合同全量,days=30 预警期)
|
|
|
+ const list = await getContractUnrenewedReport({ days: 30 });
|
|
|
+ const rows = (Array.isArray(list) ? list : []).map(adaptContractRow);
|
|
|
+ return { list: rows, total: rows.length };
|
|
|
+ },
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 考勤异常行适配:实测返回模型只有 userId / attendanceDate / exceptionType / status,
|
|
|
+ * 姓名、工号、部门用主数据批量接口按 userId 补齐(带缓存、自动分片);
|
|
|
+ * 补齐失败直接抛错,不静默降级成空列。
|
|
|
+ */
|
|
|
+async function adaptAttendanceRows(items = []) {
|
|
|
+ const source = Array.isArray(items) ? items : [];
|
|
|
+ const rows = source.map((item) => ({
|
|
|
+ jobNumber: item.employeeNo || item.jobNumber || "",
|
|
|
+ name: item.employeeName || item.userName || "",
|
|
|
+ deptName: item.departmentName || item.deptName || "",
|
|
|
+ type: exceptionLabel(item.exceptionType),
|
|
|
+ date: sliceDate(item.attendanceDate),
|
|
|
+ status: item.status || "",
|
|
|
+ }));
|
|
|
+ const ids = source.map((item) => item.userId).filter(Boolean);
|
|
|
+ if (!ids.length) return rows;
|
|
|
+ const briefs = await loadEmployeeBriefs(ids);
|
|
|
+ return rows.map((row, index) => {
|
|
|
+ const brief = briefs.get(String(source[index].userId || ""));
|
|
|
+ if (!brief) return row;
|
|
|
+ return {
|
|
|
+ ...row,
|
|
|
+ jobNumber: brief.jobNumber || row.jobNumber,
|
|
|
+ name: brief.name || row.name,
|
|
|
+ deptName: brief.deptName || row.deptName,
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/** 合同到期行适配 */
|
|
|
+function adaptContractRow(item = {}) {
|
|
|
+ return {
|
|
|
+ jobNumber: item.employeeCode || "",
|
|
|
+ name: item.employeeName || "",
|
|
|
+ deptName: item.departmentName || "",
|
|
|
+ position: item.positionName || "",
|
|
|
+ contractNo: item.contractNo || "",
|
|
|
+ contractType: contractTypeLabel(item.contractType),
|
|
|
+ startDate: sliceDate(item.startDate),
|
|
|
+ endDate: sliceDate(item.endDate),
|
|
|
+ remainDays: item.remainingDays ?? "",
|
|
|
+ status: "unsigned",
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: "MetricDrillDownDialog",
|
|
|
+ props: {
|
|
|
+ visible: Boolean,
|
|
|
+ /** 卡片编码:ON_JOB_TOTAL / MONTH_ONBOARD / ATTENDANCE_ABNORMAL_DEPT / CONTRACT_EXPIRE */
|
|
|
+ code: { type: String, default: "" },
|
|
|
+ /** 统计月份(yyyy-MM),口径区间与明细查询共用 */
|
|
|
+ reportMonth: { type: String, default: "" },
|
|
|
+ /** 统计日(yyyy-MM-dd),口径展示用 */
|
|
|
+ statisticsDate: { type: String, default: "" },
|
|
|
+ /** 所属机构(首页机构选择联动) */
|
|
|
+ organizationId: { type: String, default: "" },
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ rows: [],
|
|
|
+ total: 0,
|
|
|
+ page: 1,
|
|
|
+ size: 20,
|
|
|
+ loading: false,
|
|
|
+ exporting: false,
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ show: {
|
|
|
+ get() {
|
|
|
+ return this.visible;
|
|
|
+ },
|
|
|
+ set(value) {
|
|
|
+ this.$emit("update:visible", value);
|
|
|
+ },
|
|
|
+ },
|
|
|
+ config() {
|
|
|
+ return DRILL_DOWN_CONFIGS[this.code] || null;
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 统计月份起止(yyyy-MM-dd)。reportMonth 缺失时回落当前自然月:
|
|
|
+ * ★ 实测考勤异常接口 dateStart/dateEnd 必填,不传会返回 {code:'-1', message:''} 的静默失败,
|
|
|
+ * 且口径行展示的就是实际查询区间,不做无区间的隐式查询。
|
|
|
+ */
|
|
|
+ monthRange() {
|
|
|
+ const now = new Date();
|
|
|
+ const fallback = { year: now.getFullYear(), month: now.getMonth() + 1 };
|
|
|
+ const match = String(this.reportMonth || "").match(/^(\d{4})-(\d{1,2})$/);
|
|
|
+ const year = match ? Number(match[1]) : fallback.year;
|
|
|
+ const month = match ? Number(match[2]) : fallback.month;
|
|
|
+ const lastDay = new Date(year, month, 0).getDate();
|
|
|
+ return {
|
|
|
+ start: `${year}-${pad2(month)}-01`,
|
|
|
+ end: `${year}-${pad2(month)}-${pad2(lastDay)}`,
|
|
|
+ };
|
|
|
+ },
|
|
|
+ /** 统计日 + 预警期 30 天 */
|
|
|
+ warnEndDate() {
|
|
|
+ const date = new Date(this.statisticsDate);
|
|
|
+ if (Number.isNaN(date.getTime())) return "";
|
|
|
+ date.setDate(date.getDate() + 30);
|
|
|
+ return formatDate(date);
|
|
|
+ },
|
|
|
+ caliberText() {
|
|
|
+ return this.config ? this.config.caliber(this) : "";
|
|
|
+ },
|
|
|
+ totalText() {
|
|
|
+ return Number(this.total || 0).toLocaleString();
|
|
|
+ },
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ visible(value) {
|
|
|
+ if (value && this.code) this.reload();
|
|
|
+ },
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ reload() {
|
|
|
+ this.page = 1;
|
|
|
+ this.load();
|
|
|
+ },
|
|
|
+ handlePageChange(page) {
|
|
|
+ this.page = page;
|
|
|
+ this.load();
|
|
|
+ },
|
|
|
+ async load() {
|
|
|
+ if (!this.config?.load) return;
|
|
|
+ // 请求序号防竞态:快速翻页 / 换卡片时丢弃过期响应
|
|
|
+ const seq = (this._loadSeq = (this._loadSeq || 0) + 1);
|
|
|
+ this.loading = true;
|
|
|
+ try {
|
|
|
+ const result = await this.config.load(this.loadContext());
|
|
|
+ if (seq !== this._loadSeq) return;
|
|
|
+ this.rows = result.list || [];
|
|
|
+ this.total = Number(result.total) || 0;
|
|
|
+ } catch (error) {
|
|
|
+ if (seq !== this._loadSeq) return;
|
|
|
+ this.rows = [];
|
|
|
+ this.total = 0;
|
|
|
+ this.$message.error(error?.message || "明细数据加载失败");
|
|
|
+ } finally {
|
|
|
+ if (seq === this._loadSeq) this.loading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ loadContext() {
|
|
|
+ return {
|
|
|
+ organizationId: this.organizationId,
|
|
|
+ reportMonth: this.reportMonth,
|
|
|
+ monthRange: this.monthRange,
|
|
|
+ statisticsDate: this.statisticsDate,
|
|
|
+ page: this.page,
|
|
|
+ size: this.size,
|
|
|
+ };
|
|
|
+ },
|
|
|
+ statusMeta(row) {
|
|
|
+ return (
|
|
|
+ this.config?.statusMeta?.(row?.status) || { label: "--", type: "info" }
|
|
|
+ );
|
|
|
+ },
|
|
|
+ cellText(row, column) {
|
|
|
+ if (column.slot === "status") return this.statusMeta(row).label;
|
|
|
+ const value = row[column.prop];
|
|
|
+ return value === null || value === undefined ? "" : String(value);
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 导出:all=true 优先走配置的 exportAll(服务端导出或全量拉取后本地导出),
|
|
|
+ * all=false 直接导当前页 rows。
|
|
|
+ */
|
|
|
+ async exportRows(all) {
|
|
|
+ if (!this.config || this.exporting) return;
|
|
|
+ this.exporting = true;
|
|
|
+ try {
|
|
|
+ let rows = this.rows;
|
|
|
+ if (all && this.config.exportAll) {
|
|
|
+ const result = await this.config.exportAll(this.loadContext());
|
|
|
+ // 返回空值 = 服务端已处理下载,前端不再落文件
|
|
|
+ if (!result) {
|
|
|
+ this.$message.success("导出任务已开始下载");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ rows = result;
|
|
|
+ }
|
|
|
+ if (!rows.length) {
|
|
|
+ this.$message.warning("暂无数据,已导出表头");
|
|
|
+ }
|
|
|
+ this.writeLocalExcel(rows);
|
|
|
+ this.$message.success(`已导出 ${rows.length} 条`);
|
|
|
+ } catch (error) {
|
|
|
+ this.$message.error(error?.message || "导出失败");
|
|
|
+ } finally {
|
|
|
+ this.exporting = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ writeLocalExcel(rows) {
|
|
|
+ const columns = this.config.columns.filter(
|
|
|
+ (column) => column.slot !== "action",
|
|
|
+ );
|
|
|
+ const header = columns.map((column) => column.label);
|
|
|
+ const body = rows.map((row) =>
|
|
|
+ columns.map((column) => this.cellText(row, column)),
|
|
|
+ );
|
|
|
+ const sheet = XLSX.utils.aoa_to_sheet([header, ...body]);
|
|
|
+ sheet["!cols"] = columns.map((column) => ({
|
|
|
+ wch: Math.max(8, column.label.length * 2),
|
|
|
+ }));
|
|
|
+ const book = XLSX.utils.book_new();
|
|
|
+ XLSX.utils.book_append_sheet(book, sheet, "明细");
|
|
|
+ XLSX.writeFile(book, `${this.config.title}_${formatDate(new Date())}.xlsx`);
|
|
|
+ },
|
|
|
+ handleAction(action) {
|
|
|
+ this.$message.info(`「${action}」功能入口暂未开放`);
|
|
|
+ },
|
|
|
+ },
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+/* append-to-body 弹窗:样式必须非 scoped。element-ui 的 custom-class 直接挂在 .el-dialog 上,故本类即弹窗根元素 */
|
|
|
+.metric-drill-dialog {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ max-height: 86vh;
|
|
|
+ overflow: hidden;
|
|
|
+ background:
|
|
|
+ linear-gradient(165deg, rgba(0, 50, 85, 0.92), rgba(2, 16, 32, 0.96) 42%, rgba(0, 28, 50, 0.92));
|
|
|
+ border: 1px solid rgba(0, 200, 255, 0.32);
|
|
|
+ box-shadow:
|
|
|
+ 0 18px 48px rgba(0, 0, 0, 0.5),
|
|
|
+ 0 0 28px rgba(0, 160, 255, 0.12) inset;
|
|
|
+
|
|
|
+ .el-dialog__header {
|
|
|
+ padding: 14px 20px 12px;
|
|
|
+ border-bottom: 1px solid rgba(0, 200, 255, 0.2);
|
|
|
+ background: linear-gradient(90deg, rgba(0, 120, 200, 0.16), transparent 60%);
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-dialog__title {
|
|
|
+ color: #eaf9ff;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 650;
|
|
|
+ letter-spacing: 0.08em;
|
|
|
+ text-shadow: 0 0 12px rgba(0, 200, 255, 0.35);
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-dialog__headerbtn .el-dialog__close {
|
|
|
+ color: #6f97b0;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: #00eaff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-dialog__body {
|
|
|
+ flex: 1 1 auto;
|
|
|
+ min-height: 0;
|
|
|
+ padding: 12px 20px 16px;
|
|
|
+ overflow: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .drill-meta {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 4px 8px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ color: #9fc9e2;
|
|
|
+ font-size: 12px;
|
|
|
+
|
|
|
+ b {
|
|
|
+ color: #7af6ff;
|
|
|
+ font-weight: 650;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__divider {
|
|
|
+ color: rgba(0, 180, 255, 0.35);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__muted {
|
|
|
+ color: #5d82a0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .drill-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ margin-left: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .drill-btn {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ height: 28px;
|
|
|
+ padding: 0 14px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #bfe6ff;
|
|
|
+ cursor: pointer;
|
|
|
+ background: rgba(0, 60, 100, 0.35);
|
|
|
+ border: 1px solid rgba(0, 200, 255, 0.35);
|
|
|
+ border-radius: 3px;
|
|
|
+ transition: all 0.2s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: #fff;
|
|
|
+ border-color: rgba(0, 230, 255, 0.7);
|
|
|
+ box-shadow: 0 0 12px rgba(0, 200, 255, 0.35);
|
|
|
+ }
|
|
|
+
|
|
|
+ &:disabled {
|
|
|
+ opacity: 0.6;
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
+
|
|
|
+ &--primary {
|
|
|
+ color: #001018;
|
|
|
+ font-weight: 600;
|
|
|
+ background: linear-gradient(135deg, #7af6ff, #1ec8ff);
|
|
|
+ border-color: transparent;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: #001018;
|
|
|
+ box-shadow: 0 0 14px rgba(0, 220, 255, 0.55);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* el-table 深色皮肤 */
|
|
|
+ .el-table {
|
|
|
+ flex: 1 1 auto;
|
|
|
+ min-height: 200px;
|
|
|
+ color: #d8effc;
|
|
|
+ background: transparent;
|
|
|
+
|
|
|
+ &::before,
|
|
|
+ &::after {
|
|
|
+ background-color: rgba(0, 140, 220, 0.2);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 项目全局 hr-list-standard.scss 有 `.el-table th.el-table__cell > .cell { color:#303133 !important }`,
|
|
|
+ 深色弹窗要白字必须同样带 !important,否则被全局规则盖掉 */
|
|
|
+ th,
|
|
|
+ th.el-table__cell {
|
|
|
+ color: #fff !important;
|
|
|
+ font-weight: 600;
|
|
|
+ background: rgba(0, 55, 95, 0.45) !important;
|
|
|
+ border-bottom: 1px solid rgba(0, 200, 255, 0.25);
|
|
|
+
|
|
|
+ > .cell {
|
|
|
+ color: #fff !important;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ td {
|
|
|
+ border-bottom: 1px solid rgba(0, 140, 220, 0.14);
|
|
|
+ }
|
|
|
+
|
|
|
+ tr {
|
|
|
+ background: transparent;
|
|
|
+
|
|
|
+ &:hover > td {
|
|
|
+ background: rgba(0, 120, 200, 0.14) !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &--striped .el-table__body tr.el-table__row--striped td {
|
|
|
+ background: rgba(0, 40, 70, 0.3);
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-table__empty-block {
|
|
|
+ min-height: 160px;
|
|
|
+ color: #6f97b0;
|
|
|
+ background: rgba(0, 25, 48, 0.3);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .drill-action {
|
|
|
+ margin-right: 12px;
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ margin-right: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .drill-footer {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 14px;
|
|
|
+ margin-top: 12px;
|
|
|
+ color: #9fc9e2;
|
|
|
+ font-size: 12px;
|
|
|
+
|
|
|
+ b {
|
|
|
+ color: #7af6ff;
|
|
|
+ font-weight: 650;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__total {
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .drill-pager {
|
|
|
+ margin-left: auto;
|
|
|
+ padding: 0;
|
|
|
+ color: #9fc9e2;
|
|
|
+
|
|
|
+ button {
|
|
|
+ padding: 0 6px;
|
|
|
+ min-width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ line-height: 26px;
|
|
|
+ color: #bfe6ff;
|
|
|
+ background: rgba(0, 60, 100, 0.35);
|
|
|
+
|
|
|
+ &:disabled {
|
|
|
+ color: #4a6a85;
|
|
|
+ background: rgba(0, 40, 70, 0.35);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-pager li {
|
|
|
+ min-width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ line-height: 26px;
|
|
|
+ color: #bfe6ff;
|
|
|
+ background: rgba(0, 60, 100, 0.35);
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ color: #001018;
|
|
|
+ font-weight: 600;
|
|
|
+ background: linear-gradient(135deg, #7af6ff, #1ec8ff);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .drill-btn {
|
|
|
+ margin-left: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 无分页(合同到期)时导出按钮靠右 */
|
|
|
+ .drill-pager + .drill-btn {
|
|
|
+ margin-left: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|