From 4ffc61bbe8dd6ec89965a8e2448725f514075b53 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期三, 08 七月 2026 13:29:32 +0800
Subject: [PATCH] 银川 1.考勤管理页面开发、联调

---
 src/views/hrm/attendance/rule/modules/form.vue             |   96 +++
 src/api/hrm/attendance/holiday/index.ts                    |   52 ++
 src/views/hrm/attendance/holiday/index.vue                 |  147 +++++
 src/views/hrm/attendance/summary/index.vue                 |  106 ++++
 src/views/hrm/attendance/holiday/modules/form.vue          |   96 +++
 src/api/hrm/attendance/summary/index.ts                    |   57 ++
 src/views/hrm/attendance/summary/modules/generate-form.vue |   63 ++
 src/views/hrm/attendance/summary/data.ts                   |  163 ++++++
 src/views/hrm/attendance/record/index.vue                  |   19 
 src/api/hrm/attendance/exception/index.ts                  |   19 
 src/api/hrm/attendance/record/index.ts                     |   18 
 src/views/hrm/attendance/holiday/data.ts                   |  163 ++++++
 src/views/hrm/attendance/exception/index.vue               |   65 ++
 src/views/hrm/attendance/record/modules/clock-panel.vue    |   82 +++
 src/views/hrm/attendance/rule/data.ts                      |  206 +++++++
 src/views/hrm/attendance/rule/index.vue                    |  143 +++++
 src/packages/constants/src/dict-enum.ts                    |    2 
 src/api/hrm/attendance/rule/index.ts                       |   63 ++
 18 files changed, 1,553 insertions(+), 7 deletions(-)

diff --git a/src/api/hrm/attendance/exception/index.ts b/src/api/hrm/attendance/exception/index.ts
index 5bb1014..1e822e3 100644
--- a/src/api/hrm/attendance/exception/index.ts
+++ b/src/api/hrm/attendance/exception/index.ts
@@ -19,6 +19,12 @@
     remark?: string;
     createTime?: string;
   }
+
+  /** 瀹℃壒娴佺▼ */
+  export interface ApproveProcess {
+    key: string;
+    name: string;
+  }
 }
 
 /** 鏌ヨ鑰冨嫟寮傚父鐢宠鍒嗛〉鍒楄〃 */
@@ -44,6 +50,15 @@
 }
 
 /** 鎻愪氦鑰冨嫟寮傚父鐢宠 */
-export function submitAttendanceException(id: number) {
-  return requestClient.post(`/hrm/attendance/exception/submit?id=${id}`);
+export function submitAttendanceException(id: number, processDefinitionKey: string) {
+  return requestClient.post('/hrm/attendance/exception/submit', null, {
+    params: { id, processDefinitionKey },
+  });
+}
+
+/** 鑾峰彇鑰冨嫟寮傚父瀹℃壒娴佺▼鍒楄〃 */
+export function getAttendanceExceptionApproveProcessList() {
+  return requestClient.get<HrmAttendanceExceptionApi.ApproveProcess[]>(
+    '/hrm/attendance/exception/approve-process-list',
+  );
 }
\ No newline at end of file
diff --git a/src/api/hrm/attendance/holiday/index.ts b/src/api/hrm/attendance/holiday/index.ts
new file mode 100644
index 0000000..89da4ec
--- /dev/null
+++ b/src/api/hrm/attendance/holiday/index.ts
@@ -0,0 +1,52 @@
+import type { PageParam, PageResult } from '#/packages/effects/request/src';
+
+import { requestClient } from '#/api/request';
+
+export namespace HrmAttendanceHolidayApi {
+  /** 鑺傚亣鏃� */
+  export interface Holiday {
+    id?: number;
+    name?: string;
+    date?: string;
+    type?: number;
+    typeName?: string;
+    isLegal?: number;
+    remark?: string;
+    createTime?: string;
+  }
+}
+
+/** 鏌ヨ鑺傚亣鏃ュ垎椤靛垪琛� */
+export function getAttendanceHolidayPage(params: PageParam) {
+  return requestClient.get<PageResult<HrmAttendanceHolidayApi.Holiday>>(
+    '/hrm/attendance/holiday/page',
+    { params },
+  );
+}
+
+/** 鏌ヨ鑺傚亣鏃ヨ鎯� */
+export function getAttendanceHoliday(id: number) {
+  return requestClient.get<HrmAttendanceHolidayApi.Holiday>(
+    `/hrm/attendance/holiday/get?id=${id}`,
+  );
+}
+
+/** 鏂板鑺傚亣鏃� */
+export function createAttendanceHoliday(data: HrmAttendanceHolidayApi.Holiday) {
+  return requestClient.post('/hrm/attendance/holiday/create', data);
+}
+
+/** 鏇存柊鑺傚亣鏃� */
+export function updateAttendanceHoliday(data: HrmAttendanceHolidayApi.Holiday) {
+  return requestClient.put('/hrm/attendance/holiday/update', data);
+}
+
+/** 鍒犻櫎鑺傚亣鏃� */
+export function deleteAttendanceHoliday(id: number) {
+  return requestClient.delete(`/hrm/attendance/holiday/delete?id=${id}`);
+}
+
+/** 瀵煎嚭鑺傚亣鏃� Excel */
+export function exportAttendanceHoliday(params: any) {
+  return requestClient.download('/hrm/attendance/holiday/export-excel', { params });
+}
\ No newline at end of file
diff --git a/src/api/hrm/attendance/record/index.ts b/src/api/hrm/attendance/record/index.ts
index 7852fa8..584cb2e 100644
--- a/src/api/hrm/attendance/record/index.ts
+++ b/src/api/hrm/attendance/record/index.ts
@@ -77,4 +77,22 @@
 /** 瀵煎嚭鑰冨嫟璁板綍 */
 export function exportAttendanceRecord(params: any) {
   return requestClient.download('/hrm/attendance/record/export-excel', { params });
+}
+
+/** 涓婄彮鎵撳崱 */
+export function clockIn(params: { location: string }) {
+  return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>(
+    '/hrm/attendance/record/clock-in',
+    null,
+    { params },
+  );
+}
+
+/** 涓嬬彮鎵撳崱 */
+export function clockOut(params: { location: string }) {
+  return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>(
+    '/hrm/attendance/record/clock-out',
+    null,
+    { params },
+  );
 }
\ No newline at end of file
diff --git a/src/api/hrm/attendance/rule/index.ts b/src/api/hrm/attendance/rule/index.ts
new file mode 100644
index 0000000..2ba8920
--- /dev/null
+++ b/src/api/hrm/attendance/rule/index.ts
@@ -0,0 +1,63 @@
+import type { PageParam, PageResult } from '#/packages/effects/request/src';
+
+import { requestClient } from '#/api/request';
+
+export namespace HrmAttendanceRuleApi {
+  /** 鑰冨嫟瑙勫垯 */
+  export interface AttendanceRule {
+    id?: number;
+    name?: string;
+    deptId?: number;
+    deptName?: string;
+    workStartTime?: string;
+    workEndTime?: string;
+    lateAllowMinutes?: number;
+    earlyAllowMinutes?: number;
+    overtimeThreshold?: number;
+    status?: number;
+    remark?: string;
+    createTime?: string;
+  }
+}
+
+/** 鏌ヨ鑰冨嫟瑙勫垯鍒嗛〉鍒楄〃 */
+export function getAttendanceRulePage(params: PageParam) {
+  return requestClient.get<PageResult<HrmAttendanceRuleApi.AttendanceRule>>(
+    '/hrm/attendance/rule/page',
+    { params },
+  );
+}
+
+/** 鏌ヨ鑰冨嫟瑙勫垯鍒楄〃锛堝惎鐢ㄧ姸鎬侊級 */
+export function getAttendanceRuleList() {
+  return requestClient.get<HrmAttendanceRuleApi.AttendanceRule[]>(
+    '/hrm/attendance/rule/list',
+  );
+}
+
+/** 鏌ヨ鑰冨嫟瑙勫垯璇︽儏 */
+export function getAttendanceRule(id: number) {
+  return requestClient.get<HrmAttendanceRuleApi.AttendanceRule>(
+    `/hrm/attendance/rule/get?id=${id}`,
+  );
+}
+
+/** 鏂板鑰冨嫟瑙勫垯 */
+export function createAttendanceRule(data: HrmAttendanceRuleApi.AttendanceRule) {
+  return requestClient.post('/hrm/attendance/rule/create', data);
+}
+
+/** 鏇存柊鑰冨嫟瑙勫垯 */
+export function updateAttendanceRule(data: HrmAttendanceRuleApi.AttendanceRule) {
+  return requestClient.put('/hrm/attendance/rule/update', data);
+}
+
+/** 鍒犻櫎鑰冨嫟瑙勫垯 */
+export function deleteAttendanceRule(id: number) {
+  return requestClient.delete(`/hrm/attendance/rule/delete?id=${id}`);
+}
+
+/** 瀵煎嚭鑰冨嫟瑙勫垯 Excel */
+export function exportAttendanceRule(params: any) {
+  return requestClient.download('/hrm/attendance/rule/export-excel', { params });
+}
\ No newline at end of file
diff --git a/src/api/hrm/attendance/summary/index.ts b/src/api/hrm/attendance/summary/index.ts
new file mode 100644
index 0000000..ec25193
--- /dev/null
+++ b/src/api/hrm/attendance/summary/index.ts
@@ -0,0 +1,57 @@
+import type { PageParam, PageResult } from '#/packages/effects/request/src';
+
+import { requestClient } from '#/api/request';
+
+export namespace HrmAttendanceSummaryApi {
+  /** 鑰冨嫟缁熻 */
+  export interface AttendanceSummary {
+    id?: number;
+    userId?: number;
+    userName?: string;
+    deptId?: number;
+    deptName?: string;
+    yearMonth?: string;
+    workDays?: number;
+    actualDays?: number;
+    lateCount?: number;
+    earlyCount?: number;
+    absentCount?: number;
+    overtimeHours?: number;
+    leaveHours?: number;
+    remark?: string;
+    createTime?: string;
+  }
+
+  /** 鐢熸垚缁熻鍙傛暟 */
+  export interface GenerateParams {
+    yearMonth: string;
+    deptId?: number;
+    userId?: number;
+  }
+}
+
+/** 鏌ヨ鑰冨嫟缁熻鍒嗛〉鍒楄〃 */
+export function getAttendanceSummaryPage(params: PageParam) {
+  return requestClient.get<PageResult<HrmAttendanceSummaryApi.AttendanceSummary>>(
+    '/hrm/attendance/summary/page',
+    { params },
+  );
+}
+
+/** 鏌ヨ鍛樺伐鏈堝害姹囨�� */
+export function getAttendanceSummary(params: { userId: number; yearMonth: string }) {
+  return requestClient.get<HrmAttendanceSummaryApi.AttendanceSummary>(
+    '/hrm/attendance/summary/get',
+    { params },
+  );
+}
+
+/** 鐢熸垚鏈堝害鑰冨嫟姹囨�� */
+export function generateAttendanceSummary(params: HrmAttendanceSummaryApi.GenerateParams) {
+  return requestClient.post('/hrm/attendance/summary/generate', null, { params });
+}
+
+/** 瀵煎嚭鑰冨嫟缁熻 Excel */
+export function exportAttendanceSummary(params: any) {
+  return requestClient.download('/hrm/attendance/summary/export-excel', { params });
+}
\ No newline at end of file
diff --git a/src/packages/constants/src/dict-enum.ts b/src/packages/constants/src/dict-enum.ts
index 8162670..ea20442 100644
--- a/src/packages/constants/src/dict-enum.ts
+++ b/src/packages/constants/src/dict-enum.ts
@@ -304,6 +304,8 @@
   HRM_TRANSFER_TYPE: 'hrm_transfer_type', // HRM 璋冨矖绫诲瀷
   HRM_RESIGNATION_TYPE: 'hrm_resignation_type', // HRM 绂昏亴绫诲瀷
   HRM_CLOCK_TYPE: 'hrm_clock_type', // HRM 鎵撳崱绫诲瀷
+  HRM_HOLIDAY_TYPE: 'hrm_holiday_type', // HRM 鑺傚亣鏃ョ被鍨�
+  HRM_DATA_SOURCE: 'hrm_data_source', // HRM 鏁版嵁鏉ユ簮
   HRM_SALARY_STATUS: 'hrm_salary_status', // HRM 钖祫鐘舵��
   HRM_HANDOVER_STATUS: 'hrm_handover_status', // HRM 浜ゆ帴鐘舵��
   HRM_PAYMENT_METHOD: 'hrm_payment_method', // HRM 鍙戞斁鏂瑰紡
diff --git a/src/views/hrm/attendance/exception/index.vue b/src/views/hrm/attendance/exception/index.vue
index ea9bbee..acd5d7a 100644
--- a/src/views/hrm/attendance/exception/index.vue
+++ b/src/views/hrm/attendance/exception/index.vue
@@ -2,12 +2,18 @@
 import type { VxeTableGridOptions } from '#/adapter/vxe-table';
 import type { HrmAttendanceExceptionApi } from '#/api/hrm/attendance/exception';
 
+import { ref } from 'vue';
+
 import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
 
-import { message } from 'ant-design-vue';
+import { message, Modal, Select } from 'ant-design-vue';
 
 import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
-import { getAttendanceExceptionPage, submitAttendanceException } from '#/api/hrm/attendance/exception';
+import {
+  getAttendanceExceptionApproveProcessList,
+  getAttendanceExceptionPage,
+  submitAttendanceException,
+} from '#/api/hrm/attendance/exception';
 import { $t } from '#/locales';
 
 import { useGridColumns, useGridFormSchema } from './data';
@@ -33,11 +39,37 @@
   formModalApi.setData({ id: row.id, formType: 'detail' }).open();
 }
 
+/** 鎻愪氦瀹℃壒寮圭獥 */
+const submitModalVisible = ref(false);
+const currentRow = ref<HrmAttendanceExceptionApi.AttendanceException>();
+const processList = ref<HrmAttendanceExceptionApi.ApproveProcess[]>([]);
+const selectedProcessKey = ref<string>();
+const submitLoading = ref(false);
+
 /** 鎻愪氦鑰冨嫟寮傚父鐢宠 */
 async function handleSubmit(row: HrmAttendanceExceptionApi.AttendanceException) {
-  await submitAttendanceException(row.id!);
-  message.success('鎻愪氦鎴愬姛');
-  handleRefresh();
+  const res = await getAttendanceExceptionApproveProcessList();
+  processList.value = res;
+  currentRow.value = row;
+  selectedProcessKey.value = undefined;
+  submitModalVisible.value = true;
+}
+
+/** 纭鎻愪氦 */
+async function confirmSubmit() {
+  if (!selectedProcessKey.value) {
+    message.warning('璇烽�夋嫨瀹℃壒娴佺▼');
+    return;
+  }
+  submitLoading.value = true;
+  try {
+    await submitAttendanceException(currentRow.value!.id!, selectedProcessKey.value);
+    message.success('鎻愪氦瀹℃壒鎴愬姛');
+    submitModalVisible.value = false;
+    handleRefresh();
+  } finally {
+    submitLoading.value = false;
+  }
 }
 
 const [Grid, gridApi] = useVbenVxeGrid({
@@ -106,5 +138,28 @@
         />
       </template>
     </Grid>
+
+    <!-- 鎻愪氦瀹℃壒寮圭獥 -->
+    <Modal
+      v-model:open="submitModalVisible"
+      title="閫夋嫨瀹℃壒娴佺▼"
+      width="400px"
+      @ok="confirmSubmit"
+      :confirmLoading="submitLoading"
+    >
+      <Select
+        v-model:value="selectedProcessKey"
+        placeholder="璇烽�夋嫨瀹℃壒娴佺▼"
+        class="w-full"
+      >
+        <Select.Option
+          v-for="item in processList"
+          :key="item.key"
+          :value="item.key"
+        >
+          {{ item.name }}
+        </Select.Option>
+      </Select>
+    </Modal>
   </Page>
 </template>
\ No newline at end of file
diff --git a/src/views/hrm/attendance/holiday/data.ts b/src/views/hrm/attendance/holiday/data.ts
new file mode 100644
index 0000000..822451b
--- /dev/null
+++ b/src/views/hrm/attendance/holiday/data.ts
@@ -0,0 +1,163 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmAttendanceHolidayApi } from '#/api/hrm/attendance/holiday';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+/** 琛ㄥ崟绫诲瀷 */
+export type FormType = 'create' | 'update' | 'detail';
+
+/** 鏂板/缂栬緫鑺傚亣鏃ョ殑琛ㄥ崟 */
+export function useFormSchema(formType: FormType): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'id',
+      component: 'Input',
+      dependencies: {
+        triggerFields: [''],
+        show: () => false,
+      },
+    },
+    {
+      fieldName: 'name',
+      label: '鑺傚亣鏃ュ悕绉�',
+      component: 'Input',
+      componentProps: {
+        placeholder: '濡傦細鏄ヨ妭銆佸浗搴嗚妭',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'date',
+      label: '鏃ユ湡',
+      component: 'DatePicker',
+      componentProps: {
+        placeholder: '閫夋嫨鏃ユ湡',
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'type',
+      label: '绫诲瀷',
+      component: 'RadioGroup',
+      componentProps: {
+        options: getDictOptions(DICT_TYPE.HRM_HOLIDAY_TYPE, 'number'),
+      },
+      rules: 'selectRequired',
+    },
+    {
+      fieldName: 'isLegal',
+      label: '娉曞畾鑺傚亣鏃�',
+      component: 'Checkbox',
+      componentProps: {
+        // @ts-ignore
+        trueValue: 1,
+        falseValue: 0,
+      },
+      help: '鍕鹃�夎〃绀轰负娉曞畾鑺傚亣鏃�',
+    },
+    {
+      fieldName: 'remark',
+      label: '澶囨敞',
+      component: 'Textarea',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ娉�',
+        rows: 2,
+        maxlength: 500,
+        showCount: true,
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'name',
+      label: '鑺傚亣鏃ュ悕绉�',
+      component: 'Input',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇疯緭鍏ヨ妭鍋囨棩鍚嶇О',
+      },
+    },
+    {
+      fieldName: 'date',
+      label: '鏃ユ湡',
+      component: 'RangePicker',
+      componentProps: {
+        allowClear: true,
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'type',
+      label: '绫诲瀷',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: getDictOptions(DICT_TYPE.HRM_HOLIDAY_TYPE, 'number'),
+        placeholder: '璇烽�夋嫨绫诲瀷',
+      },
+    },
+    {
+      fieldName: 'isLegal',
+      label: '娉曞畾鑺傚亣鏃�',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: [
+          { label: '鏄�', value: 1 },
+          { label: '鍚�', value: 0 },
+        ],
+        placeholder: '璇烽�夋嫨',
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions<HrmAttendanceHolidayApi.Holiday>['columns'] {
+  return [
+    { field: 'name', title: '鑺傚亣鏃ュ悕绉�', minWidth: 150 },
+    { field: 'date', title: '鏃ユ湡', width: 120 },
+    {
+      field: 'type',
+      title: '绫诲瀷',
+      width: 100,
+      cellRender: {
+        name: 'CellDict',
+        props: { type: DICT_TYPE.HRM_HOLIDAY_TYPE },
+      },
+    },
+    {
+      field: 'isLegal',
+      title: '娉曞畾鑺傚亣鏃�',
+      width: 100,
+      slots: {
+        default: 'isLegal',
+      },
+    },
+    { field: 'remark', title: '澶囨敞', minWidth: 200 },
+    {
+      field: 'createTime',
+      title: '鍒涘缓鏃堕棿',
+      width: 180,
+      formatter: 'formatDateTime',
+    },
+    {
+      title: '鎿嶄綔',
+      width: 150,
+      fixed: 'right',
+      slots: {
+        default: 'actions',
+      },
+    },
+  ];
+}
\ No newline at end of file
diff --git a/src/views/hrm/attendance/holiday/index.vue b/src/views/hrm/attendance/holiday/index.vue
new file mode 100644
index 0000000..8a33871
--- /dev/null
+++ b/src/views/hrm/attendance/holiday/index.vue
@@ -0,0 +1,147 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmAttendanceHolidayApi } from '#/api/hrm/attendance/holiday';
+
+import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
+import { downloadFileFromBlobPart } from '#/packages/utils/src';
+
+import { message, Modal, Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteAttendanceHoliday, exportAttendanceHoliday, getAttendanceHolidayPage } from '#/api/hrm/attendance/holiday';
+import { $t } from '#/locales';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+  gridApi.query();
+}
+
+/** 鏂板鑺傚亣鏃� */
+function handleCreate() {
+  formModalApi.setData({ formType: 'create' }).open();
+}
+
+/** 缂栬緫鑺傚亣鏃� */
+function handleUpdate(row: HrmAttendanceHolidayApi.Holiday) {
+  formModalApi.setData({ id: row.id, formType: 'update' }).open();
+}
+
+/** 鏌ョ湅鑺傚亣鏃� */
+function handleDetail(row: HrmAttendanceHolidayApi.Holiday) {
+  formModalApi.setData({ id: row.id, formType: 'detail' }).open();
+}
+
+/** 鍒犻櫎鑺傚亣鏃� */
+async function handleDelete(id: number) {
+  Modal.confirm({
+    title: '纭鍒犻櫎',
+    content: '纭畾瑕佸垹闄よ鑺傚亣鏃ュ悧锛�',
+    okText: '纭畾',
+    cancelText: '鍙栨秷',
+    onOk: async () => {
+      await deleteAttendanceHoliday(id);
+      message.success('鍒犻櫎鎴愬姛');
+      handleRefresh();
+    },
+  });
+}
+
+/** 瀵煎嚭鑺傚亣鏃� */
+async function handleExport() {
+  const data = await exportAttendanceHoliday(await gridApi.formApi.getValues());
+  downloadFileFromBlobPart({ fileName: '鑺傚亣鏃ラ厤缃�.xls', source: data });
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useGridFormSchema(),
+  },
+  gridOptions: {
+    columns: useGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) =>
+          await getAttendanceHolidayPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          }),
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<HrmAttendanceHolidayApi.Holiday>,
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormModal @success="handleRefresh" />
+    <Grid table-title="鑺傚亣鏃ラ厤缃垪琛�">
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: $t('ui.actionTitle.create', ['鑺傚亣鏃�']),
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['hrm:attendance-holiday:create'],
+              onClick: handleCreate,
+            },
+            {
+              label: $t('ui.actionTitle.export'),
+              type: 'primary',
+              icon: ACTION_ICON.DOWNLOAD,
+              auth: ['hrm:attendance-holiday:export'],
+              onClick: handleExport,
+            },
+          ]"
+        />
+      </template>
+      <template #isLegal="{ row }">
+        <Tag v-if="row.isLegal === 1" color="red">娉曞畾</Tag>
+        <span v-else>-</span>
+      </template>
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '鏌ョ湅',
+              type: 'link',
+              onClick: handleDetail.bind(null, row),
+            },
+            {
+              label: $t('ui.actionTitle.edit'),
+              type: 'link',
+              auth: ['hrm:attendance-holiday:update'],
+              onClick: handleUpdate.bind(null, row),
+            },
+            {
+              label: $t('ui.actionTitle.delete'),
+              type: 'link',
+              danger: true,
+              auth: ['hrm:attendance-holiday:delete'],
+              onClick: handleDelete.bind(null, row.id!),
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
\ No newline at end of file
diff --git a/src/views/hrm/attendance/holiday/modules/form.vue b/src/views/hrm/attendance/holiday/modules/form.vue
new file mode 100644
index 0000000..2ae9c5d
--- /dev/null
+++ b/src/views/hrm/attendance/holiday/modules/form.vue
@@ -0,0 +1,96 @@
+<script lang="ts" setup>
+import type { FormType } from '../data';
+import type { HrmAttendanceHolidayApi } from '#/api/hrm/attendance/holiday';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { createAttendanceHoliday, getAttendanceHoliday, updateAttendanceHoliday } from '#/api/hrm/attendance/holiday';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formType = ref<FormType>('create');
+
+const isDetail = computed(() => formType.value === 'detail');
+const getTitle = computed(() => {
+  if (formType.value === 'detail') {
+    return '鏌ョ湅鑺傚亣鏃�';
+  }
+  if (formType.value === 'update') {
+    return '缂栬緫鑺傚亣鏃�';
+  }
+  return '鏂板鑺傚亣鏃�';
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    formItemClass: 'col-span-1',
+    labelWidth: 100,
+  },
+  wrapperClass: 'grid-cols-2',
+  layout: 'horizontal',
+  schema: [],
+  showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    if (isDetail.value) {
+      await modalApi.close();
+      return;
+    }
+    const { valid } = await formApi.validate();
+    if (!valid) {
+      return;
+    }
+    modalApi.lock();
+    const data = (await formApi.getValues()) as HrmAttendanceHolidayApi.Holiday;
+    try {
+      if (formType.value === 'update') {
+        await updateAttendanceHoliday(data);
+      } else {
+        await createAttendanceHoliday(data);
+      }
+      await modalApi.close();
+      emit('success');
+      message.success($t('ui.actionMessage.operationSuccess'));
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      return;
+    }
+    const data = modalApi.getData<{ formType: FormType; id?: number }>();
+    formType.value = data.formType;
+    formApi.setState({ schema: useFormSchema(data.formType) });
+    formApi.setDisabled(data.formType === 'detail');
+    modalApi.setState({ showConfirmButton: data.formType !== 'detail' });
+    if (!data?.id) {
+      return;
+    }
+    modalApi.lock();
+    try {
+      await formApi.setValues(await getAttendanceHoliday(data.id));
+    } finally {
+      modalApi.unlock();
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle" class="w-[600px]">
+    <Form class="mx-4" />
+  </Modal>
+</template>
\ No newline at end of file
diff --git a/src/views/hrm/attendance/record/index.vue b/src/views/hrm/attendance/record/index.vue
index 50f3b9e..6aabd6c 100644
--- a/src/views/hrm/attendance/record/index.vue
+++ b/src/views/hrm/attendance/record/index.vue
@@ -12,6 +12,7 @@
 import { $t } from '#/locales';
 
 import { useGridColumns, useGridFormSchema } from './data';
+import ClockPanel from './modules/clock-panel.vue';
 import ImportForm from './modules/import-form.vue';
 
 const [ImportModal, importModalApi] = useVbenModal({
@@ -19,9 +20,19 @@
   destroyOnClose: true,
 });
 
+const [ClockModal, clockModalApi] = useVbenModal({
+  connectedComponent: ClockPanel,
+  destroyOnClose: true,
+});
+
 /** 鍒锋柊琛ㄦ牸 */
 function handleRefresh() {
   gridApi.query();
+}
+
+/** 鎵撳崱 */
+function handleClock() {
+  clockModalApi.open();
 }
 
 /** 瀵煎叆鑰冨嫟璁板綍 */
@@ -68,11 +79,19 @@
 <template>
   <Page auto-content-height>
     <ImportModal @success="handleRefresh" />
+    <ClockModal @success="handleRefresh" />
     <Grid table-title="鑰冨嫟璁板綍鍒楄〃">
       <template #toolbar-tools>
         <TableAction
           :actions="[
             {
+              label: '鎵撳崱',
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['hrm:attendance-record:clock'],
+              onClick: handleClock,
+            },
+            {
               label: '瀵煎叆',
               type: 'primary',
               icon: ACTION_ICON.UPLOAD,
diff --git a/src/views/hrm/attendance/record/modules/clock-panel.vue b/src/views/hrm/attendance/record/modules/clock-panel.vue
new file mode 100644
index 0000000..7f41eed
--- /dev/null
+++ b/src/views/hrm/attendance/record/modules/clock-panel.vue
@@ -0,0 +1,82 @@
+<script lang="ts" setup>
+import { 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';
+
+const emit = defineEmits(['success']);
+
+const location = ref('鍏徃鍓嶅彴');
+const loading = ref(false);
+
+/** 涓婄彮鎵撳崱 */
+async function handleClockIn() {
+  loading.value = true;
+  try {
+    await clockIn({ location: location.value });
+    message.success('涓婄彮鎵撳崱鎴愬姛');
+    emit('success');
+  } catch (e: any) {
+    message.error(e.message || '鎵撳崱澶辫触');
+  } finally {
+    loading.value = false;
+  }
+}
+
+/** 涓嬬彮鎵撳崱 */
+async function handleClockOut() {
+  loading.value = true;
+  try {
+    await clockOut({ location: location.value });
+    message.success('涓嬬彮鎵撳崱鎴愬姛');
+    emit('success');
+  } catch (e: any) {
+    message.error(e.message || '鎵撳崱澶辫触');
+  } finally {
+    loading.value = false;
+  }
+}
+
+const [Modal, modalApi] = useVbenModal({
+  onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      location.value = '鍏徃鍓嶅彴';
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal title="鎵撳崱" class="w-[400px]">
+    <div class="p-4">
+      <div class="mb-4">
+        <label class="mr-2">鎵撳崱鍦扮偣锛�</label>
+        <a-input
+          v-model:value="location"
+          placeholder="璇疯緭鍏ユ墦鍗″湴鐐�"
+          style="width: 200px"
+        />
+      </div>
+      <div class="flex gap-4">
+        <a-button
+          type="primary"
+          size="large"
+          :loading="loading"
+          @click="handleClockIn"
+        >
+          涓婄彮鎵撳崱
+        </a-button>
+        <a-button
+          size="large"
+          :loading="loading"
+          @click="handleClockOut"
+        >
+          涓嬬彮鎵撳崱
+        </a-button>
+      </div>
+    </div>
+  </Modal>
+</template>
\ No newline at end of file
diff --git a/src/views/hrm/attendance/rule/data.ts b/src/views/hrm/attendance/rule/data.ts
new file mode 100644
index 0000000..50bca13
--- /dev/null
+++ b/src/views/hrm/attendance/rule/data.ts
@@ -0,0 +1,206 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { getDeptList } from '#/api/system/dept';
+import { handleTree } from '#/packages/utils/src';
+
+/** 琛ㄥ崟绫诲瀷 */
+export type FormType = 'create' | 'update' | 'detail';
+
+/** 鏂板/缂栬緫鑰冨嫟瑙勫垯鐨勮〃鍗� */
+export function useFormSchema(formType: FormType): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'id',
+      component: 'Input',
+      dependencies: {
+        triggerFields: [''],
+        show: () => false,
+      },
+    },
+    {
+      fieldName: 'name',
+      label: '瑙勫垯鍚嶇О',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ヨ鍒欏悕绉�',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'deptId',
+      label: '閫傜敤閮ㄩ棬',
+      component: 'ApiTreeSelect',
+      componentProps: {
+        api: async () => {
+          const data = await getDeptList();
+          return handleTree(data);
+        },
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+        allowClear: true,
+        placeholder: '璇烽�夋嫨閮ㄩ棬锛堜笉閫夎〃绀哄叏灞�锛�',
+        treeDefaultExpandAll: true,
+      },
+    },
+    {
+      fieldName: 'workStartTime',
+      label: '涓婄彮鏃堕棿',
+      component: 'TimePicker',
+      componentProps: {
+        placeholder: '閫夋嫨涓婄彮鏃堕棿',
+        valueFormat: 'HH:mm:ss',
+        format: 'HH:mm:ss',
+        style: { width: '100%' },
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'workEndTime',
+      label: '涓嬬彮鏃堕棿',
+      component: 'TimePicker',
+      componentProps: {
+        placeholder: '閫夋嫨涓嬬彮鏃堕棿',
+        valueFormat: 'HH:mm:ss',
+        format: 'HH:mm:ss',
+        style: { width: '100%' },
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'lateAllowMinutes',
+      label: '杩熷埌鍏佽(鍒嗛挓)',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '鍒嗛挓',
+        min: 0,
+        max: 60,
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'earlyAllowMinutes',
+      label: '鏃╅��鍏佽(鍒嗛挓)',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '鍒嗛挓',
+        min: 0,
+        max: 60,
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'overtimeThreshold',
+      label: '鍔犵彮璧风畻(灏忔椂)',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '灏忔椂',
+        min: 0,
+        precision: 1,
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'RadioGroup',
+      componentProps: {
+        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+      },
+    },
+    {
+      fieldName: 'remark',
+      label: '澶囨敞',
+      component: 'Textarea',
+      formItemClass: 'col-span-2',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ娉�',
+        rows: 2,
+        maxlength: 500,
+        showCount: true,
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'name',
+      label: '瑙勫垯鍚嶇О',
+      component: 'Input',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇疯緭鍏ヨ鍒欏悕绉�',
+      },
+    },
+    {
+      fieldName: 'deptId',
+      label: '閫傜敤閮ㄩ棬',
+      component: 'ApiTreeSelect',
+      componentProps: {
+        api: async () => {
+          const data = await getDeptList();
+          return handleTree(data);
+        },
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+        allowClear: true,
+        placeholder: '璇烽�夋嫨閮ㄩ棬',
+        treeDefaultExpandAll: true,
+      },
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+        placeholder: '璇烽�夋嫨鐘舵��',
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions<HrmAttendanceRuleApi.AttendanceRule>['columns'] {
+  return [
+    { field: 'name', title: '瑙勫垯鍚嶇О', minWidth: 150 },
+    {
+      field: 'deptName',
+      title: '閫傜敤閮ㄩ棬',
+      minWidth: 120,
+      formatter: ({ cellValue }) => cellValue || '鍏ㄥ眬',
+    },
+    { field: 'workStartTime', title: '涓婄彮鏃堕棿', width: 100 },
+    { field: 'workEndTime', title: '涓嬬彮鏃堕棿', width: 100 },
+    { field: 'lateAllowMinutes', title: '杩熷埌鍏佽(鍒嗛挓)', width: 120 },
+    { field: 'earlyAllowMinutes', title: '鏃╅��鍏佽(鍒嗛挓)', width: 120 },
+    { field: 'overtimeThreshold', title: '鍔犵彮璧风畻(灏忔椂)', width: 120 },
+    {
+      field: 'status',
+      title: '鐘舵��',
+      width: 80,
+      cellRender: {
+        name: 'CellDict',
+        props: { type: DICT_TYPE.COMMON_STATUS },
+      },
+    },
+    {
+      title: '鎿嶄綔',
+      width: 150,
+      fixed: 'right',
+      slots: {
+        default: 'actions',
+      },
+    },
+  ];
+}
\ No newline at end of file
diff --git a/src/views/hrm/attendance/rule/index.vue b/src/views/hrm/attendance/rule/index.vue
new file mode 100644
index 0000000..903d9fe
--- /dev/null
+++ b/src/views/hrm/attendance/rule/index.vue
@@ -0,0 +1,143 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule';
+
+import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
+import { downloadFileFromBlobPart } from '#/packages/utils/src';
+
+import { message, Modal } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteAttendanceRule, exportAttendanceRule, getAttendanceRulePage } from '#/api/hrm/attendance/rule';
+import { $t } from '#/locales';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+  gridApi.query();
+}
+
+/** 鏂板鑰冨嫟瑙勫垯 */
+function handleCreate() {
+  formModalApi.setData({ formType: 'create' }).open();
+}
+
+/** 缂栬緫鑰冨嫟瑙勫垯 */
+function handleUpdate(row: HrmAttendanceRuleApi.AttendanceRule) {
+  formModalApi.setData({ id: row.id, formType: 'update' }).open();
+}
+
+/** 鏌ョ湅鑰冨嫟瑙勫垯 */
+function handleDetail(row: HrmAttendanceRuleApi.AttendanceRule) {
+  formModalApi.setData({ id: row.id, formType: 'detail' }).open();
+}
+
+/** 鍒犻櫎鑰冨嫟瑙勫垯 */
+async function handleDelete(id: number) {
+  Modal.confirm({
+    title: '纭鍒犻櫎',
+    content: '纭畾瑕佸垹闄よ鑰冨嫟瑙勫垯鍚楋紵',
+    okText: '纭畾',
+    cancelText: '鍙栨秷',
+    onOk: async () => {
+      await deleteAttendanceRule(id);
+      message.success('鍒犻櫎鎴愬姛');
+      handleRefresh();
+    },
+  });
+}
+
+/** 瀵煎嚭鑰冨嫟瑙勫垯 */
+async function handleExport() {
+  const data = await exportAttendanceRule(await gridApi.formApi.getValues());
+  downloadFileFromBlobPart({ fileName: '鑰冨嫟瑙勫垯.xls', source: data });
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useGridFormSchema(),
+  },
+  gridOptions: {
+    columns: useGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) =>
+          await getAttendanceRulePage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          }),
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<HrmAttendanceRuleApi.AttendanceRule>,
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormModal @success="handleRefresh" />
+    <Grid table-title="鑰冨嫟瑙勫垯鍒楄〃">
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: $t('ui.actionTitle.create', ['鑰冨嫟瑙勫垯']),
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['hrm:attendance-rule:create'],
+              onClick: handleCreate,
+            },
+            {
+              label: $t('ui.actionTitle.export'),
+              type: 'primary',
+              icon: ACTION_ICON.DOWNLOAD,
+              auth: ['hrm:attendance-rule:export'],
+              onClick: handleExport,
+            },
+          ]"
+        />
+      </template>
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '鏌ョ湅',
+              type: 'link',
+              onClick: handleDetail.bind(null, row),
+            },
+            {
+              label: $t('ui.actionTitle.edit'),
+              type: 'link',
+              auth: ['hrm:attendance-rule:update'],
+              onClick: handleUpdate.bind(null, row),
+            },
+            {
+              label: $t('ui.actionTitle.delete'),
+              type: 'link',
+              danger: true,
+              auth: ['hrm:attendance-rule:delete'],
+              onClick: handleDelete.bind(null, row.id!),
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
\ No newline at end of file
diff --git a/src/views/hrm/attendance/rule/modules/form.vue b/src/views/hrm/attendance/rule/modules/form.vue
new file mode 100644
index 0000000..60bff96
--- /dev/null
+++ b/src/views/hrm/attendance/rule/modules/form.vue
@@ -0,0 +1,96 @@
+<script lang="ts" setup>
+import type { FormType } from '../data';
+import type { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { createAttendanceRule, getAttendanceRule, updateAttendanceRule } from '#/api/hrm/attendance/rule';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formType = ref<FormType>('create');
+
+const isDetail = computed(() => formType.value === 'detail');
+const getTitle = computed(() => {
+  if (formType.value === 'detail') {
+    return '鏌ョ湅鑰冨嫟瑙勫垯';
+  }
+  if (formType.value === 'update') {
+    return '缂栬緫鑰冨嫟瑙勫垯';
+  }
+  return '鏂板鑰冨嫟瑙勫垯';
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    formItemClass: 'col-span-1',
+    labelWidth: 120,
+  },
+  wrapperClass: 'grid-cols-2',
+  layout: 'horizontal',
+  schema: [],
+  showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    if (isDetail.value) {
+      await modalApi.close();
+      return;
+    }
+    const { valid } = await formApi.validate();
+    if (!valid) {
+      return;
+    }
+    modalApi.lock();
+    const data = (await formApi.getValues()) as HrmAttendanceRuleApi.AttendanceRule;
+    try {
+      if (formType.value === 'update') {
+        await updateAttendanceRule(data);
+      } else {
+        await createAttendanceRule(data);
+      }
+      await modalApi.close();
+      emit('success');
+      message.success($t('ui.actionMessage.operationSuccess'));
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      return;
+    }
+    const data = modalApi.getData<{ formType: FormType; id?: number }>();
+    formType.value = data.formType;
+    formApi.setState({ schema: useFormSchema(data.formType) });
+    formApi.setDisabled(data.formType === 'detail');
+    modalApi.setState({ showConfirmButton: data.formType !== 'detail' });
+    if (!data?.id) {
+      return;
+    }
+    modalApi.lock();
+    try {
+      await formApi.setValues(await getAttendanceRule(data.id));
+    } finally {
+      modalApi.unlock();
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle" class="w-[800px]">
+    <Form class="mx-4" />
+  </Modal>
+</template>
\ No newline at end of file
diff --git a/src/views/hrm/attendance/summary/data.ts b/src/views/hrm/attendance/summary/data.ts
new file mode 100644
index 0000000..02c53a2
--- /dev/null
+++ b/src/views/hrm/attendance/summary/data.ts
@@ -0,0 +1,163 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmAttendanceSummaryApi } from '#/api/hrm/attendance/summary';
+
+import { getDeptList } from '#/api/system/dept';
+import { handleTree } from '#/packages/utils/src';
+import { getEmployeeSimpleList } from '#/api/hrm/employee';
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'yearMonth',
+      label: '骞存湀',
+      component: 'DatePicker',
+      componentProps: {
+        picker: 'month',
+        allowClear: true,
+        valueFormat: 'YYYY-MM',
+        format: 'YYYY-MM',
+        style: { width: '100%' },
+        placeholder: '閫夋嫨骞存湀',
+      },
+    },
+    {
+      fieldName: 'deptId',
+      label: '閮ㄩ棬',
+      component: 'ApiTreeSelect',
+      componentProps: {
+        api: async () => {
+          const data = await getDeptList();
+          return handleTree(data);
+        },
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+        allowClear: true,
+        placeholder: '璇烽�夋嫨閮ㄩ棬',
+        treeDefaultExpandAll: true,
+      },
+    },
+    {
+      fieldName: 'userName',
+      label: '鍛樺伐濮撳悕',
+      component: 'Input',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇疯緭鍏ュ憳宸ュ鍚�',
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions<HrmAttendanceSummaryApi.AttendanceSummary>['columns'] {
+  return [
+    { field: 'userName', title: '鍛樺伐濮撳悕', minWidth: 100 },
+    { field: 'deptName', title: '閮ㄩ棬', minWidth: 120 },
+    { field: 'yearMonth', title: '骞存湀', width: 100 },
+    { field: 'workDays', title: '搴斿嚭鍕ゅぉ鏁�', width: 100 },
+    { field: 'actualDays', title: '瀹為檯鍑哄嫟澶╂暟', width: 110 },
+    {
+      field: 'lateCount',
+      title: '杩熷埌娆℃暟',
+      width: 90,
+      slots: {
+        default: 'lateCount',
+      },
+    },
+    {
+      field: 'earlyCount',
+      title: '鏃╅��娆℃暟',
+      width: 90,
+      slots: {
+        default: 'earlyCount',
+      },
+    },
+    {
+      field: 'absentCount',
+      title: '缂哄崱娆℃暟',
+      width: 90,
+      slots: {
+        default: 'absentCount',
+      },
+    },
+    { field: 'overtimeHours', title: '鍔犵彮鏃堕暱(h)', width: 100 },
+    { field: 'leaveHours', title: '璇峰亣鏃堕暱(h)', width: 100 },
+  ];
+}
+
+/** 鐢熸垚缁熻寮圭獥鐨勮〃鍗� */
+export function useGenerateFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'yearMonth',
+      label: '骞存湀',
+      component: 'DatePicker',
+      componentProps: {
+        picker: 'month',
+        valueFormat: 'YYYY-MM',
+        format: 'YYYY-MM',
+        style: { width: '100%' },
+        placeholder: '閫夋嫨骞存湀',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'rangeType',
+      label: '鐢熸垚鑼冨洿',
+      component: 'RadioGroup',
+      componentProps: {
+        options: [
+          { label: '鍏ㄥ叕鍙�', value: 'all' },
+          { label: '鎸囧畾閮ㄩ棬', value: 'dept' },
+          { label: '鎸囧畾鍛樺伐', value: 'user' },
+        ],
+      },
+      defaultValue: 'all',
+    },
+    {
+      fieldName: 'deptId',
+      label: '閮ㄩ棬',
+      component: 'ApiTreeSelect',
+      dependencies: {
+        triggerFields: ['rangeType'],
+        show: (values) => values.rangeType === 'dept',
+        required: (values) => values.rangeType === 'dept',
+      },
+      componentProps: {
+        api: async () => {
+          const data = await getDeptList();
+          return handleTree(data);
+        },
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+        placeholder: '閫夋嫨閮ㄩ棬',
+        treeDefaultExpandAll: true,
+      },
+    },
+    {
+      fieldName: 'userId',
+      label: '鍛樺伐',
+      component: 'ApiSelect',
+      dependencies: {
+        triggerFields: ['rangeType'],
+        show: (values) => values.rangeType === 'user',
+        required: (values) => values.rangeType === 'user',
+      },
+      componentProps: {
+        api: async () => {
+          const data = await getEmployeeSimpleList();
+          return data;
+        },
+        labelField: 'name',
+        valueField: 'id',
+        placeholder: '閫夋嫨鍛樺伐',
+        showSearch: true,
+        filterOption: true,
+      },
+    },
+  ];
+}
\ No newline at end of file
diff --git a/src/views/hrm/attendance/summary/index.vue b/src/views/hrm/attendance/summary/index.vue
new file mode 100644
index 0000000..f3a2552
--- /dev/null
+++ b/src/views/hrm/attendance/summary/index.vue
@@ -0,0 +1,106 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmAttendanceSummaryApi } from '#/api/hrm/attendance/summary';
+
+import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
+import { downloadFileFromBlobPart } from '#/packages/utils/src';
+
+import { Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { exportAttendanceSummary, getAttendanceSummaryPage } from '#/api/hrm/attendance/summary';
+import { $t } from '#/locales';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import GenerateForm from './modules/generate-form.vue';
+
+const [GenerateModal, generateModalApi] = useVbenModal({
+  connectedComponent: GenerateForm,
+  destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+  gridApi.query();
+}
+
+/** 鐢熸垚缁熻 */
+function handleGenerate() {
+  generateModalApi.open();
+}
+
+/** 瀵煎嚭鑰冨嫟缁熻 */
+async function handleExport() {
+  const data = await exportAttendanceSummary(await gridApi.formApi.getValues());
+  downloadFileFromBlobPart({ fileName: '鑰冨嫟缁熻.xls', source: data });
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useGridFormSchema(),
+  },
+  gridOptions: {
+    columns: useGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) =>
+          await getAttendanceSummaryPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          }),
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<HrmAttendanceSummaryApi.AttendanceSummary>,
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <GenerateModal @success="handleRefresh" />
+    <Grid table-title="鑰冨嫟缁熻鍒楄〃">
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: '鐢熸垚缁熻',
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['hrm:attendance-summary:generate'],
+              onClick: handleGenerate,
+            },
+            {
+              label: $t('ui.actionTitle.export'),
+              type: 'primary',
+              icon: ACTION_ICON.DOWNLOAD,
+              auth: ['hrm:attendance-summary:export'],
+              onClick: handleExport,
+            },
+          ]"
+        />
+      </template>
+      <template #lateCount="{ row }">
+        <Tag v-if="row.lateCount && row.lateCount > 0" color="warning">{{ row.lateCount }}</Tag>
+        <span v-else>{{ row.lateCount || 0 }}</span>
+      </template>
+      <template #earlyCount="{ row }">
+        <Tag v-if="row.earlyCount && row.earlyCount > 0" color="warning">{{ row.earlyCount }}</Tag>
+        <span v-else>{{ row.earlyCount || 0 }}</span>
+      </template>
+      <template #absentCount="{ row }">
+        <Tag v-if="row.absentCount && row.absentCount > 0" color="error">{{ row.absentCount }}</Tag>
+        <span v-else>{{ row.absentCount || 0 }}</span>
+      </template>
+    </Grid>
+  </Page>
+</template>
\ No newline at end of file
diff --git a/src/views/hrm/attendance/summary/modules/generate-form.vue b/src/views/hrm/attendance/summary/modules/generate-form.vue
new file mode 100644
index 0000000..b509a85
--- /dev/null
+++ b/src/views/hrm/attendance/summary/modules/generate-form.vue
@@ -0,0 +1,63 @@
+<script lang="ts" setup>
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { generateAttendanceSummary } from '#/api/hrm/attendance/summary';
+
+import { useGenerateFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    formItemClass: 'col-span-1',
+    labelWidth: 100,
+  },
+  layout: 'horizontal',
+  schema: useGenerateFormSchema(),
+  showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) {
+      return;
+    }
+    modalApi.lock();
+    const data = await formApi.getValues();
+    try {
+      const params: any = {
+        yearMonth: data.yearMonth,
+      };
+      if (data.rangeType === 'dept') {
+        params.deptId = data.deptId;
+      } else if (data.rangeType === 'user') {
+        params.userId = data.userId;
+      }
+      await generateAttendanceSummary(params);
+      await modalApi.close();
+      emit('success');
+      message.success('鐢熸垚鑰冨嫟缁熻鎴愬姛');
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      formApi.resetForm();
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal title="鐢熸垚鑰冨嫟缁熻" class="w-[500px]">
+    <Form class="mx-4" />
+  </Modal>
+</template>
\ No newline at end of file

--
Gitblit v1.9.3