liu
9 天以前 5e7ec2238e3cdc66e440d7f839aaa2fe56d61f89
feat(hrm,bi): 移植长辉电力的考勤请假展示与 BI 大屏

参考 dev_pro_长辉电力 分支移植:
- 考勤记录页展示请假状态、请假工时;打卡面板提示当天已请假
- 新增员工下拉模糊选择器 searchEmployeeDropdown
- BI 大屏支持新图表(出勤指标、库存采购)与全屏自适应

文件:
- src/api/hrm/attendance/record/index.ts:新增请假/我的状态相关接口
- src/views/hrm/attendance/record/{data.ts,index.vue,modules/clock-panel.vue}
- src/views/hrm/employee/components/select-dropdown.vue(新增)
- src/views/bi/dashboard/index.vue:图表渲染与自适应
- src/views/hrm/{resignation/data.ts,salary/calculation/data.ts}:日期展示

Co-Authored-By: Claude <noreply@anthropic.com>
已添加1个文件
已修改8个文件
467 ■■■■■ 文件已修改
src/api/hrm/attendance/record/index.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/index.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/data.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/index.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/modules/clock-panel.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/location-map-picker.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/components/select-dropdown.vue 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/resignation/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/calculation/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/record/index.ts
@@ -43,8 +43,65 @@
    clockOutAddress?: string;
    dataSource?: number;
    dataSourceName?: string;
    /** å½“天是否请假(存在审批通过且与工作时段重叠的请假单) */
    leaveFlag?: boolean;
    /** å½“天请假工时(小时),未请假为 0 */
    leaveHours?: number;
    /** å½“天请假类型,逗号分隔的 HrmLeaveTypeEnum.type,如 "1,2" */
    leaveTypes?: string;
    remark?: string;
    createTime?: string;
  }
  /** æˆ‘的考勤状态(/hrm/attendance/record/my-status) */
  export interface AttendanceMyStatus {
    /** å‘˜å·¥å§“名 */
    employeeName?: string;
    userName?: string;
    deptName?: string;
    /** è€ƒå‹¤æ—¥æœŸ */
    date?: string;
    /** æœåŠ¡å™¨æ—¶é—´ */
    serverTime?: string;
    /** æ˜¯å¦å·¥ä½œæ—¥ */
    workDay?: boolean;
    /** ä¼‘息日原因,如:国庆节、周六 */
    restDayReason?: string;
    /** æ‰“卡地点名称 */
    locationName?: string;
    locationAddress?: string;
    /** æ˜¯å¦å¯ç”¨å®šä½èŒƒå›´æ ¡éªŒ */
    locationRequired?: boolean;
    /** å…è®¸æ‰“卡半径,单位米 */
    radiusMeters?: number;
    /** å½“日考勤记录,未打卡时为 null */
    record?: AttendanceRecord;
    /** ä»Šæ—¥è®°å½•ID */
    recordId?: number;
    clockInTime?: string;
    clockOutTime?: string;
    clockInType?: number;
    clockOutType?: number;
    clockInDone?: boolean;
    clockOutDone?: boolean;
    workHours?: number;
    overtimeHours?: number;
    location?: string;
    /** å½“天是否请假 */
    onLeave?: boolean;
    /** å½“天请假工时(小时),未请假为 0 */
    leaveHours?: number;
    /** å½“天请假类型名称,多段用顿号分隔,如 "事假、病假" */
    leaveTypeNames?: string;
    /** å½“前可打卡状态 */
    clockable?: {
      clockIn?: boolean;
      clockOut?: boolean;
      /** ä¸‹ä¸€æ­¥åŠ¨ä½œï¼šin-上班打卡,out-下班打卡,none-无需打卡 */
      nextAction?: 'in' | 'none' | 'out' | string;
      /** æç¤ºæ–‡æ¡ˆ */
      message?: string;
    };
  }
  /** è€ƒå‹¤ç»Ÿè®¡ */
@@ -87,6 +144,18 @@
  );
}
/**
 * æŸ¥è¯¢å½“前登录人的考勤状态
 *
 * @param date è€ƒå‹¤æ—¥æœŸï¼Œæ ¼å¼ yyyy-MM-dd,不传取服务器当天
 */
export function getAttendanceMyStatus(date?: string) {
  return requestClient.get<HrmAttendanceRecordApi.AttendanceMyStatus>(
    '/hrm/attendance/record/my-status',
    date ? { params: { date } } : undefined,
  );
}
/** æŸ¥è¯¢è€ƒå‹¤ç»Ÿè®¡ï¼ˆæŒ‰æœˆï¼‰ */
export function getAttendanceStatistics(params: {
  userId?: number;
src/views/bi/dashboard/index.vue
@@ -49,6 +49,7 @@
      loading.value = false;
      await nextTick();
      if (!disposed && sequence === requestSequence) {
        layoutFill();
        resizeAllCharts();
        if (!background) {
          const intervals = charts.value.map((chart) => chart.refreshInterval).filter((interval) => interval > 0);
@@ -88,6 +89,31 @@
const dashboardRef = ref<HTMLElement>();
let resizeObserver: ResizeObserver | null = null;
/**
 * ç²¾ç¡®è®¡ç®—图表区可用高度,写入 CSS å˜é‡ --fill-h
 *
 * ä»…靠 flex + 1fr åœ¨éƒ¨åˆ†åœºæ™¯ä¸ç”Ÿæ•ˆï¼Œè¿™é‡Œç›´æŽ¥é‡æµ‹å¤´éƒ¨ã€KPI è¡Œã€
 * è¡¨æ ¼å¡ç‰‡å ç”¨é«˜åº¦ï¼ŒæŠŠå‰©ä½™é«˜åº¦ç®—出来给图表区。
 */
function layoutFill() {
  const el = dashboardRef.value;
  if (!el) return;
  if (!isFullscreen.value) {
    el.style.removeProperty('--fill-h');
    return;
  }
  const header = el.querySelector('.dash-header') as HTMLElement | null;
  const kpi = el.querySelector('.kpi-row') as HTMLElement | null;
  const tables = el.querySelectorAll<HTMLElement>('.table-card');
  const cs = getComputedStyle(el);
  let used = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
  if (header) used += header.offsetHeight + 24;
  if (kpi) used += kpi.offsetHeight + 16;
  tables.forEach((t) => { used += t.offsetHeight + 16; });
  const avail = Math.max(240, window.innerHeight - used);
  el.style.setProperty('--fill-h', `${avail}px`);
}
function resizeAllCharts() {
  setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
}
@@ -108,11 +134,11 @@
    await document.exitFullscreen();
    isFullscreen.value = false;
  }
  setTimeout(resizeAllCharts, 400);
  setTimeout(() => { layoutFill(); resizeAllCharts(); }, 400);
}
function onFullscreenChange() {
  isFullscreen.value = !!document.fullscreenElement;
  setTimeout(resizeAllCharts, 300);
  setTimeout(() => { layoutFill(); resizeAllCharts(); }, 300);
}
// ======== KPI æ•°å­—动画 ========
@@ -210,6 +236,7 @@
onMounted(() => {
  document.addEventListener('fullscreenchange', onFullscreenChange);
  setupResizeObserver();
  window.addEventListener('resize', layoutFill);
  loadData();
});
onBeforeUnmount(() => {
@@ -217,6 +244,7 @@
  requestSequence++;
  if (dataRefreshTimer) clearInterval(dataRefreshTimer);
  document.removeEventListener('fullscreenchange', onFullscreenChange);
  window.removeEventListener('resize', layoutFill);
  resizeObserver?.disconnect();
});
</script>
@@ -479,7 +507,7 @@
.kpi-row {
  position: relative; z-index: 1;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr));
  gap: 12px; margin-bottom: 16px;
}
@@ -548,7 +576,7 @@
.chart-row {
  position: relative; z-index: 1;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
  gap: 12px;
  margin-bottom: 16px;
}
@@ -617,7 +645,32 @@
/* ======== å…¨å± ======== */
.is-fullscreen {
  max-height: none; min-height: 100vh; padding: 24px 28px; border-radius: 0; overflow-y: auto;
  height: 100vh;
  min-height: 100vh;
  max-height: 100vh;
  padding: 24px 28px;
  border-radius: 0;
  overflow-y: auto;
}
/* å¤´éƒ¨ä¸Ž KPI è¡Œä¿æŒè‡ªç„¶é«˜åº¦ */
.is-fullscreen > .dash-header,
.is-fullscreen > .kpi-row {
  flex: 0 0 auto;
}
/* å›¾è¡¨åŒºåƒæŽ‰å‰©ä½™é«˜åº¦ï¼Œè¡Œé«˜å‡åˆ†ï¼Œé¿å…åº•部大片留白 */
.is-fullscreen > .chart-row {
  flex: 0 0 auto;
  /* é«˜åº¦ç”± layoutFill() é‡æµ‹åŽå†™å…¥ï¼Œä¿è¯æ­£å¥½å¡«æ»¡è§†å£ */
  height: var(--fill-h, auto);
  grid-auto-rows: minmax(200px, 1fr);
  align-content: stretch;
}
/* è¡¨æ ¼å¡ç‰‡ä¸æŠ¢é«˜åº¦ï¼Œå†…容超出时容器滚动 */
.is-fullscreen > .table-card {
  flex: 0 1 auto;
}
.is-fullscreen::before,
.is-fullscreen::after {
@@ -633,13 +686,54 @@
:deep(.ant-empty-description) { color: var(--text-muted); }
/* ======== å“åº”式 ======== */
/* å¤§å± / 4K:放大间距与字号,图表列宽加大 */
@media (min-width: 1921px) {
  .bi-dashboard { padding: 24px 32px; }
  .kpi-row { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
  .chart-row { grid-template-columns: repeat(auto-fit, minmax(460px, 1fr)); }
  .kpi-value { font-size: 28px; }
}
/* å®½å± */
@media (min-width: 1600px) and (max-width: 1920px) {
  .bi-dashboard { padding: 20px 26px; }
}
/* ç¬”记本 / å¹³æ¿æ¨ªå±ï¼šå¤´éƒ¨ç©ºé—´ç´§å¼ æ—¶åŽ‹ç¼© */
@media (max-width: 1280px) {
  .dash-status-tags { display: none; }
  .dash-title-group p { display: none; }
}
/* å¹³æ¿ç«–屏 */
@media (max-width: 1024px) {
  .bi-dashboard { padding: 14px; }
  .dash-header { flex-wrap: wrap; gap: 10px; }
  .dash-header-divider { display: none; }
  .kpi-row { grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); }
  .chart-row { grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); }
}
/* æ‰‹æœº */
@media (max-width: 768px) {
  .bi-dashboard { padding: 10px; }
  .dash-header { flex-wrap: wrap; }
  .dash-header { flex-wrap: wrap; gap: 8px; }
  .dash-header-divider { display: none; }
  .kpi-row { grid-template-columns: 1fr 1fr; }
  .chart-row { grid-template-columns: 1fr; }
  .dash-header-right { width: 100%; justify-content: space-between; }
  .dash-live-badge { display: none; }
  .kpi-row { grid-template-columns: 1fr 1fr; gap: 8px; }
  .kpi-card { padding: 10px 12px; gap: 8px; }
  .kpi-icon { width: 32px; height: 32px; font-size: 15px; }
  .kpi-value { font-size: 18px; }
  .kpi-label { font-size: 10px; }
  .chart-row { grid-template-columns: 1fr; gap: 10px; }
  .chart-row > div { grid-column: 1 / -1 !important; }
  .kpi-value { font-size: 20px; }
}
/* è¶…窄屏(小屏手机) */
@media (max-width: 400px) {
  .kpi-row { grid-template-columns: 1fr; }
}
</style>
src/views/hrm/attendance/record/data.ts
@@ -3,10 +3,44 @@
import type { HrmAttendanceRecordApi } from '#/api/hrm/attendance/record';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictOptions } from '#/packages/effects/hooks/src';
import { getDictLabel, getDictOptions } from '#/packages/effects/hooks/src';
import { getDeptList } from '#/api/system/dept';
import { handleTree } from '#/packages/utils/src';
/** è¯·å‡ç±»åž‹çš„兜底名称,与后端 HrmLeaveTypeEnum ä¿æŒä¸€è‡´ */
const LEAVE_TYPE_FALLBACK_LABELS: Record<number, string> = {
  1: '事假',
  2: '病假',
  3: '年假',
  4: '婚假',
  5: '产假',
  6: '丧假',
};
/**
 * è¯·å‡ç±»åž‹çš„字典标签
 *
 * å­—å…¸ hrm_leave_type æœªåŒæ­¥æ—¶è¿”回空串,由调用方决定兜底,
 * é¿å…è¿™ç±»çŽ¯å¢ƒæŠŠè¯·å‡ç±»åž‹æ˜¾ç¤ºæˆ "-" è€Œè®©äººè¯¯ä»¥ä¸ºæ²¡æœ‰è¯·å‡ã€‚
 */
export function getLeaveTypeLabel(type: number | string) {
  const label = getDictLabel(DICT_TYPE.HRM_LEAVE_TYPE, String(type));
  return label || LEAVE_TYPE_FALLBACK_LABELS[Number(type)] || '';
}
/** æŠŠ leaveTypes(如 "1,2")翻译成请假类型名称,多段用顿号分隔 */
export function formatLeaveTypeNames(leaveTypes?: string) {
  if (!leaveTypes) {
    return '';
  }
  return leaveTypes
    .split(',')
    .map((type) => type.trim())
    .filter(Boolean)
    .map((type) => getLeaveTypeLabel(type) || type)
    .join('、');
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
@@ -67,6 +101,19 @@
        placeholder: '请选择下班打卡类型',
      },
    },
    {
      fieldName: 'leaveFlag',
      label: '是否请假',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: [
          { label: '已请假', value: true },
          { label: '未请假', value: false },
        ],
        placeholder: '请选择是否请假',
      },
    },
  ];
}
@@ -97,6 +144,14 @@
        props: { type: DICT_TYPE.HRM_CLOCK_TYPE },
      },
    },
    {
      field: 'leaveFlag',
      title: '请假',
      width: 160,
      slots: {
        default: 'leave',
      },
    },
    { field: 'workHours', title: '工作时长(h)', width: 100 },
    { field: 'overtimeHours', title: '加班时长(h)', width: 100 },
    { field: 'location', title: '打卡地点', minWidth: 150 },
src/views/hrm/attendance/record/index.vue
@@ -5,13 +5,13 @@
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message } from 'ant-design-vue';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportAttendanceRecord, getAttendanceRecordPage } from '#/api/hrm/attendance/record';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import { formatLeaveTypeNames, useGridColumns, useGridFormSchema } from './data';
import ClockPanel from './modules/clock-panel.vue';
import ImportForm from './modules/import-form.vue';
@@ -108,6 +108,15 @@
          ]"
        />
      </template>
      <template #leave="{ row }">
        <Tag v-if="row.leaveFlag" color="blue">
          {{ formatLeaveTypeNames(row.leaveTypes) || '请假' }}
          <span v-if="Number(row.leaveHours) > 0">
            {{ Number(row.leaveHours).toFixed(2) }}h
          </span>
        </Tag>
        <span v-else>-</span>
      </template>
    </Grid>
  </Page>
</template>
src/views/hrm/attendance/record/modules/clock-panel.vue
@@ -1,13 +1,13 @@
<script lang="ts" setup>
import type { AttendanceClockLocation, AttendanceClockResponse } from '#/api/hrm/attendance/record';
import type { AttendanceClockLocation, AttendanceClockResponse, HrmAttendanceRecordApi } from '#/api/hrm/attendance/record';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
import { clockIn, clockOut } from '#/api/hrm/attendance/record';
import { clockIn, clockOut, getAttendanceMyStatus } from '#/api/hrm/attendance/record';
const emit = defineEmits(['success']);
@@ -16,6 +16,77 @@
const location = ref(DEFAULT_LOCATION);
const loading = ref(false);
const locating = ref(false);
/** ä»Šæ—¥è€ƒå‹¤çŠ¶æ€ï¼ˆå«è¯·å‡ä¿¡æ¯ï¼‰ï¼ŒåŠ è½½å¤±è´¥æ—¶ä¸º undefined */
const myStatus = ref<HrmAttendanceRecordApi.AttendanceMyStatus>();
const statusLoading = ref(false);
/** å½“前打卡返回的当天记录,用于打卡后即时刷新请假信息 */
const lastClockRecord = ref<HrmAttendanceRecordApi.AttendanceRecord>();
/** å½“天是否请假:优先用 my-status çš„ onLeave,其次用当天记录上的 leaveFlag */
const onLeave = computed(
  () =>
    myStatus.value?.onLeave === true ||
    myStatus.value?.record?.leaveFlag === true ||
    lastClockRecord.value?.leaveFlag === true,
);
/** è¯·å‡ç±»åž‹åç§°ï¼Œå¦‚ "事假、病假" */
const leaveTypeNames = computed(
  () =>
    myStatus.value?.leaveTypeNames ||
    formatLeaveTypes(
      myStatus.value?.record?.leaveTypes ||
        lastClockRecord.value?.leaveTypes,
    ),
);
/** è¯·å‡å·¥æ—¶ï¼ˆå°æ—¶ï¼‰ */
const leaveHours = computed(
  () =>
    Number(
      myStatus.value?.leaveHours ??
        myStatus.value?.record?.leaveHours ??
        lastClockRecord.value?.leaveHours ??
        0,
    ) || 0,
);
/** è¯·å‡ç±»åž‹å…œåº•名称,与后端 HrmLeaveTypeEnum ä¿æŒä¸€è‡´ */
const LEAVE_TYPE_NAMES: Record<number, string> = {
  1: '事假',
  2: '病假',
  3: '年假',
  4: '婚假',
  5: '产假',
  6: '丧假',
};
/** æŠŠ "1,2" ç¿»è¯‘成 "事假、病假" */
function formatLeaveTypes(leaveTypes?: string) {
  if (!leaveTypes) {
    return '';
  }
  return leaveTypes
    .split(',')
    .map((type) => type.trim())
    .filter(Boolean)
    .map((type) => LEAVE_TYPE_NAMES[Number(type)] ?? '')
    .filter(Boolean)
    .join('、');
}
/** åŠ è½½å½“å‰ç™»å½•äººçš„è€ƒå‹¤çŠ¶æ€ï¼›å¤±è´¥æ—¶ä¸é˜»æ–­æ‰“å¡ */
async function loadMyStatus() {
  statusLoading.value = true;
  try {
    myStatus.value = await getAttendanceMyStatus();
  } catch {
    // æŽ¥å£é”™è¯¯å·²ç”±è¯·æ±‚拦截器统一提示,这里静默降级,不影响打卡主流程
    myStatus.value = undefined;
  } finally {
    statusLoading.value = false;
  }
}
/**
 * èŽ·å–æµè§ˆå™¨å®šä½å¹¶ç»Ÿä¸€ä¸º WGS84 äº¤ç»™åŽç«¯æ ¡éªŒã€‚
@@ -72,9 +143,12 @@
      action === 'in'
        ? await clockIn(position, locationText)
        : await clockOut(position, locationText);
    lastClockRecord.value = result?.record;
    message.success(
      buildSuccessMessage(action === 'in' ? '上班打卡' : '下班打卡', result),
    );
    // æ‰“卡后刷新今日状态(含请假信息),保证面板上的提示与后端一致
    await loadMyStatus();
    emit('success');
  } catch (error: any) {
    // æŽ¥å£é”™è¯¯å·²ç”±è¯·æ±‚拦截器统一提示,这里只兜底本地异常,避免重复弹出
@@ -102,7 +176,12 @@
const [Modal] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
    if (isOpen) {
      // æ¯æ¬¡æ‰“开都拉一次今日状态,避免展示过期的请假信息
      myStatus.value = undefined;
      lastClockRecord.value = undefined;
      void loadMyStatus();
    } else {
      location.value = DEFAULT_LOCATION;
    }
  },
@@ -112,6 +191,28 @@
<template>
  <Modal title="打卡" class="w-[420px]">
    <div class="p-4">
      <a-alert
        v-if="onLeave"
        class="mb-4"
        type="info"
        show-icon
        message="今日已请假,如需上班可正常打卡"
      >
        <template #description>
          <div class="text-xs">
            <span v-if="leaveTypeNames">请假类型:{{ leaveTypeNames }}</span>
            <span v-if="leaveHours > 0" class="ml-2">
              è¯·å‡ {{ leaveHours.toFixed(2) }} å°æ—¶
            </span>
            <div class="mt-1 text-gray-500">
              è¯·å‡å½“天打卡不计迟到/早退,也不计缺卡。
            </div>
          </div>
        </template>
      </a-alert>
      <div v-else-if="statusLoading" class="mb-4 text-xs text-gray-400">
        æ­£åœ¨åŠ è½½ä»Šæ—¥è€ƒå‹¤çŠ¶æ€â€¦
      </div>
      <div class="mb-4">
        <label class="mr-2">打卡地点:</label>
        <a-input
src/views/hrm/attendance/rule/modules/location-map-picker.vue
@@ -359,6 +359,7 @@
        @press-enter="handleSearch"
      />
      <Button :loading="loading" @click="handleSearch">搜索地址</Button>
      <Button @click="handleCurrentLocation">定位到当前位置</Button>
    </div>
    <div
      ref="mapContainer"
src/views/hrm/employee/components/select-dropdown.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
<script lang="ts" setup>
import type { HrmEmployeeApi } from '#/api/hrm/employee';
import { computed, onMounted, ref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { getEmployeeSimpleList } from '#/api/hrm/employee';
defineOptions({ name: 'EmployeeSelect', inheritAttrs: false });
const props = withDefaults(
  defineProps<{
    allowClear?: boolean;
    disabled?: boolean;
    /** éƒ¨é—¨ ID,传入则只显示该部门的员工 */
    deptId?: number;
    /** å ä½æ–‡æ¡ˆ */
    placeholder?: string;
    modelValue?: number;
  }>(),
  {
    allowClear: true,
    disabled: false,
    deptId: undefined,
    placeholder: '输入姓名或工号搜索',
    modelValue: undefined,
  },
);
const emit = defineEmits<{
  change: [item: HrmEmployeeApi.EmployeeSimple | undefined];
  'update:modelValue': [value: number | undefined];
}>();
const loading = ref(false);
const allOptions = ref<HrmEmployeeApi.EmployeeSimple[]>([]);
/** ä¸‹æ‹‰é€‰é¡¹ï¼šå§“名(部门) */
const options = computed(() =>
  allOptions.value
    .filter((e) => !props.deptId || (e as any).deptId === props.deptId)
    .map((e) => ({
      label: e.deptName ? `${e.name}(${e.deptName})` : e.name,
      value: e.id,
      raw: e,
    })),
);
onMounted(async () => {
  loading.value = true;
  try {
    allOptions.value = (await getEmployeeSimpleList()) || [];
  } finally {
    loading.value = false;
  }
});
/** æ¨¡ç³ŠåŒ¹é…ï¼šå§“名 / å·¥å· / éƒ¨é—¨ */
function filterOption(input: string, option: any) {
  const kw = String(input ?? '').toLowerCase().trim();
  if (!kw) {
    return true;
  }
  const raw = option?.raw || {};
  const hay = [raw.name, raw.employeeNo, raw.deptName, option?.label]
    .filter(Boolean)
    .join(' ')
    .toLowerCase();
  return hay.includes(kw);
}
function handleChange(val: any) {
  const id = typeof val === 'number' ? val : undefined;
  emit('update:modelValue', id);
  emit('change', allOptions.value.find((e) => e.id === id));
}
watch(
  () => props.modelValue,
  () => {
    // ä»…用于触发视图更新
  },
);
</script>
<template>
  <Select
    v-bind="$attrs"
    :allow-clear="allowClear"
    :disabled="disabled"
    :filter-option="filterOption"
    :loading="loading"
    :options="options"
    :placeholder="placeholder"
    :value="modelValue"
    class="w-full"
    option-filter-prop="label"
    show-search
    @change="handleChange"
  />
</template>
src/views/hrm/resignation/data.ts
@@ -48,6 +48,8 @@
      label: '交接人',
      component: 'ApiSelect',
      componentProps: {
        showSearch: true,
        optionFilterProp: 'label',
        api: getEmployeeSimpleList,
        labelField: 'name',
        valueField: 'id',
src/views/hrm/salary/calculation/data.ts
@@ -30,6 +30,8 @@
      label: '员工',
      component: 'ApiSelect',
      componentProps: {
        showSearch: true,
        optionFilterProp: 'label',
        api: getEmployeeSimpleList,
        labelField: 'name',
        valueField: 'id',