liu
2026-10-30 a385917c1f82edbdf1063ac1505bc9b57c6c1108
feat(hrm,bi): 移植长辉电力的考勤请假展示与 BI 大屏

- 打卡面板展示当天请假类型、请假工时,请假当天仍允许打卡
- 考勤记录接口补充 AttendanceMyStatus 与请假字段
- BI 大屏布局自适应(4K/宽屏/平板/手机),修正 12 列栅格下的卡片宽度
- 新增员工选择下拉组件

Co-Authored-By: Claude <noreply@anthropic.com>
已添加1个文件
已修改7个文件
610 ■■■■■ 文件已修改
src/api/hrm/attendance/record/index.ts 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/index.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | 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 234 ●●●●● 补丁 | 查看 | 原始文档 | 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
@@ -10,6 +10,7 @@
    userName?: string;
    deptId?: number;
    deptName?: string;
    phone?: string;
    date?: string;
    clockInTime?: string;
    clockOutTime?: string;
@@ -20,10 +21,87 @@
    workHours?: number;
    overtimeHours?: number;
    location?: string;
    /** ä¸Šç­æ‰“卡经度(WGS84) */
    clockInLongitude?: number;
    /** ä¸Šç­æ‰“卡纬度(WGS84) */
    clockInLatitude?: number;
    /** ä¸Šç­å®šä½ç²¾åº¦ï¼Œç±³ */
    clockInAccuracy?: number;
    /** ä¸Šç­è·æ‰“卡地点距离,米 */
    clockInDistance?: number;
    /** ä¸Šç­æ‰“卡地点地址快照 */
    clockInAddress?: string;
    /** ä¸‹ç­æ‰“卡经度(WGS84) */
    clockOutLongitude?: number;
    /** ä¸‹ç­æ‰“卡纬度(WGS84) */
    clockOutLatitude?: number;
    /** ä¸‹ç­å®šä½ç²¾åº¦ï¼Œç±³ */
    clockOutAccuracy?: number;
    /** ä¸‹ç­è·æ‰“卡地点距离,米 */
    clockOutDistance?: number;
    /** ä¸‹ç­æ‰“卡地点地址快照 */
    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;
    };
  }
  /** è€ƒå‹¤ç»Ÿè®¡ */
@@ -41,11 +119,40 @@
  }
}
/** å®šä½æ‰“卡坐标,后端仅接受 WGS84 */
export interface AttendanceClockLocation {
  longitude: number;
  latitude: number;
  accuracy: number;
  coordinateSystem: 'WGS84';
}
/** å®šä½æ‰“卡结果;未启用定位范围校验时,距离与半径为空 */
export interface AttendanceClockResponse {
  record?: HrmAttendanceRecordApi.AttendanceRecord;
  locationName?: string;
  distanceMeters?: number;
  radiusMeters?: number;
  withinRange?: boolean;
}
/** æŸ¥è¯¢è€ƒå‹¤è®°å½•分页列表 */
export function getAttendanceRecordPage(params: PageParam) {
  return requestClient.get<PageResult<HrmAttendanceRecordApi.AttendanceRecord>>(
    '/hrm/attendance/record/page',
    { params },
  );
}
/**
 * æŸ¥è¯¢å½“前登录人的考勤状态
 *
 * @param date è€ƒå‹¤æ—¥æœŸï¼Œæ ¼å¼ yyyy-MM-dd,不传取服务器当天
 */
export function getAttendanceMyStatus(date?: string) {
  return requestClient.get<HrmAttendanceRecordApi.AttendanceMyStatus>(
    '/hrm/attendance/record/my-status',
    date ? { params: { date } } : undefined,
  );
}
@@ -79,25 +186,32 @@
  return requestClient.download('/hrm/attendance/record/export-excel', { params });
}
export interface AttendanceClockLocation {
  longitude: number;
  latitude: number;
  accuracy: number;
  coordinateSystem: 'WGS84';
}
/** ä¸Šç­æ‰“卡 */
export function clockIn(data: AttendanceClockLocation) {
  return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>(
/**
 * ä¸Šç­æ‰“卡
 *
 * åŒæ—¶æäº¤å®šä½ï¼ˆJSON body)与旧版地点文本(query å‚数):
 * - æ–°åŽç«¯æŒ‰å®šä½æ ¡éªŒæ‰“卡范围,未启用定位时忽略定位;
 * - æ—§åŽç«¯åªè¯»å– location å‚数,保证升级前后都能正常打卡。
 */
export function clockIn(
  data?: AttendanceClockLocation | null,
  location?: string,
) {
  return requestClient.post<AttendanceClockResponse>(
    '/hrm/attendance/record/clock-in',
    data,
    data ?? null,
    location ? { params: { location } } : undefined,
  );
}
/** ä¸‹ç­æ‰“卡 */
export function clockOut(data: AttendanceClockLocation) {
  return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>(
/** ä¸‹ç­æ‰“卡,参数说明同 clockIn */
export function clockOut(
  data?: AttendanceClockLocation | null,
  location?: string,
) {
  return requestClient.post<AttendanceClockResponse>(
    '/hrm/attendance/record/clock-out',
    data,
    data ?? null,
    location ? { params: { location } } : undefined,
  );
}
src/views/bi/dashboard/index.vue
@@ -80,6 +80,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);
}
@@ -100,11 +125,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 æ•°å­—动画 ========
@@ -667,7 +692,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 {
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: '请选择是否请假',
      },
    },
  ];
}
@@ -96,6 +143,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,79 +1,235 @@
<script lang="ts" setup>
import { ref } from 'vue';
import type { AttendanceClockLocation, AttendanceClockResponse, HrmAttendanceRecordApi } from '#/api/hrm/attendance/record';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
import type { AttendanceClockLocation } from '#/api/hrm/attendance/record';
import { clockIn, clockOut } from '#/api/hrm/attendance/record';
import { clockIn, clockOut, getAttendanceMyStatus } from '#/api/hrm/attendance/record';
const emit = defineEmits(['success']);
const loading = ref(false);
const DEFAULT_LOCATION = '公司前台';
function getCurrentLocation(): Promise<AttendanceClockLocation> {
  if (!navigator.geolocation) {
    return Promise.reject(new Error('当前浏览器不支持定位,请使用支持 GPS çš„æµè§ˆå™¨'));
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 new Promise((resolve, reject) => {
  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 äº¤ç»™åŽç«¯æ ¡éªŒã€‚
 * å®šä½ä¸å¯ç”¨ï¼ˆä¸æ”¯æŒã€æ‹’绝授权、非 HTTPS)时返回 null,
 * ç”±åŽç«¯æŒ‰è€ƒå‹¤è§„则决定是否允许无定位打卡。
 */
function getCurrentLocation(): Promise<AttendanceClockLocation | null> {
  if (!navigator.geolocation) {
    return Promise.resolve(null);
  }
  return new Promise((resolve) => {
    navigator.geolocation.getCurrentPosition(
      (position) => resolve({
        longitude: position.coords.longitude,
        latitude: position.coords.latitude,
        accuracy: position.coords.accuracy,
        coordinateSystem: 'WGS84',
      }),
      () => reject(new Error('定位失败,请开启定位权限后重试(非 localhost éœ€ä½¿ç”¨ HTTPS)')),
      (position) =>
        resolve({
          longitude: Number(position.coords.longitude.toFixed(7)),
          latitude: Number(position.coords.latitude.toFixed(7)),
          accuracy: Number(position.coords.accuracy.toFixed(2)),
          coordinateSystem: 'WGS84',
        }),
      () => resolve(null),
      { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 },
    );
  });
}
/** ä¸Šç­æ‰“卡 */
async function handleClockIn() {
/** æœªå¯ç”¨å®šä½èŒƒå›´æ ¡éªŒæ—¶åŽç«¯ä¸è¿”回距离,直接提示成功 */
function buildSuccessMessage(
  action: string,
  result?: AttendanceClockResponse,
) {
  if (result?.distanceMeters === null || result?.distanceMeters === undefined) {
    return `${action}成功`;
  }
  const place = result.locationName ? `距${result.locationName}` : '距打卡地点';
  return `${action}成功,${place} ${Number(result.distanceMeters).toFixed(1)} ç±³`;
}
async function submitClock(action: 'in' | 'out') {
  if (loading.value) {
    return;
  }
  loading.value = true;
  try {
    await clockIn(await getCurrentLocation());
    message.success('上班打卡成功');
    locating.value = true;
    const position = await getCurrentLocation();
    locating.value = false;
    if (!position) {
      message.warning(
        '未获取到定位,将按打卡地点文本提交;若考勤规则要求定位,请开启定位权限后重试(非 HTTPS çŽ¯å¢ƒæµè§ˆå™¨ä¼šç¦ç”¨å®šä½ï¼‰',
      );
    }
    const locationText = location.value.trim() || undefined;
    const result =
      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 (e: any) {
    message.error(e.message || '打卡失败');
  } catch (error: any) {
    // æŽ¥å£é”™è¯¯å·²ç”±è¯·æ±‚拦截器统一提示,这里只兜底本地异常,避免重复弹出
    const handledByInterceptor = Boolean(
      error?.response || error?.data || error?.isAxiosError || error?.code,
    );
    if (!handledByInterceptor) {
      message.error(error?.message || '打卡失败');
    }
  } finally {
    locating.value = false;
    loading.value = false;
  }
}
/** ä¸Šç­æ‰“卡 */
function handleClockIn() {
  return submitClock('in');
}
/** ä¸‹ç­æ‰“卡 */
async function handleClockOut() {
  loading.value = true;
  try {
    await clockOut(await getCurrentLocation());
    message.success('下班打卡成功');
    emit('success');
  } catch (e: any) {
    message.error(e.message || '打卡失败');
  } finally {
    loading.value = false;
  }
function handleClockOut() {
  return submitClock('out');
}
const [Modal, modalApi] = useVbenModal({
  onOpenChange() {},
const [Modal] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (isOpen) {
      // æ¯æ¬¡æ‰“开都拉一次今日状态,避免展示过期的请假信息
      myStatus.value = undefined;
      lastClockRecord.value = undefined;
      void loadMyStatus();
    } else {
      location.value = DEFAULT_LOCATION;
    }
  },
});
</script>
<template>
  <Modal title="打卡" class="w-[400px]">
  <Modal title="打卡" class="w-[420px]">
    <div class="p-4">
      <div class="mb-4 text-sm text-gray-500">
        ç‚¹å‡»æ‰“卡后将获取当前 GPS ä½ç½®ï¼Œå¹¶æŒ‰è€ƒå‹¤è§„则中的经纬度和打卡半径校验。
      <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
          v-model:value="location"
          placeholder="请输入打卡地点"
          style="width: 220px"
        />
      </div>
      <div class="mb-4 text-xs text-gray-500">
        ç‚¹å‡»æ‰“卡后会自动获取当前 GPS ä½ç½®ï¼Œå¹¶æŒ‰è€ƒå‹¤è§„则中的地点与允许半径校验;浏览器定位不可用时,将按上方地点文本提交。
      </div>
      <div class="flex gap-4">
        <a-button
          type="primary"
          size="large"
          :loading="loading"
          :disabled="loading"
          @click="handleClockIn"
        >
          ä¸Šç­æ‰“卡
@@ -81,11 +237,15 @@
        <a-button
          size="large"
          :loading="loading"
          :disabled="loading"
          @click="handleClockOut"
        >
          ä¸‹ç­æ‰“卡
        </a-button>
      </div>
      <div v-if="locating" class="mt-2 text-xs text-gray-400">
        æ­£åœ¨èŽ·å–å®šä½â€¦
      </div>
    </div>
  </Modal>
</template>
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',