From f7e06aaee32674791f63d6e5111c0d0a3f4de4a3 Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期五, 28 八月 2026 15:13:13 +0800
Subject: [PATCH] feat(hrm): 新增班次/排班页面,优化薪酬核算

---
 src/api/hrm/schedule/index.ts              |   43 +
 src/views/hrm/schedule/index.vue           |  743 ++++++++++++++++++++++++++++++
 src/api/hrm/salary/calculation/index.ts    |    2 
 src/views/hrm/schedule/data.ts             |   46 +
 src/views/hrm/salary/calculation/data.ts   |    2 
 src/views/hrm/shift/modules/form.vue       |   96 ++++
 src/views/hrm/shift/data.ts                |  186 +++++++
 src/api/hrm/shift/index.ts                 |   62 ++
 src/views/hrm/salary/calculation/index.vue |   84 +++
 src/views/hrm/shift/index.vue              |  162 ++++++
 10 files changed, 1,423 insertions(+), 3 deletions(-)

diff --git a/src/api/hrm/salary/calculation/index.ts b/src/api/hrm/salary/calculation/index.ts
index 4aa6f4c..76d4fdd 100644
--- a/src/api/hrm/salary/calculation/index.ts
+++ b/src/api/hrm/salary/calculation/index.ts
@@ -26,6 +26,8 @@
     actualSalary?: number;
     workDays?: number;
     overtimeHours?: number;
+    /** 鎺掔彮鎬诲伐鏃讹紙灏忔椂锛� */
+    totalWorkHours?: number;
     status?: number;
     statusName?: string;
     remark?: string;
diff --git a/src/api/hrm/schedule/index.ts b/src/api/hrm/schedule/index.ts
new file mode 100644
index 0000000..100b8b5
--- /dev/null
+++ b/src/api/hrm/schedule/index.ts
@@ -0,0 +1,43 @@
+import { requestClient } from '#/api/request';
+
+export namespace HrmEmployeeScheduleApi {
+  /** 鎺掔彮璁板綍 */
+  export interface EmployeeSchedule {
+    id?: number;
+    employeeId?: number;
+    employeeName?: string;
+    employeeNo?: string;
+    /** 鎺掔彮鏃ユ湡 yyyy-MM-dd */
+    date?: string;
+    shiftId?: number;
+    shiftName?: string;
+    shiftColor?: string;
+    remark?: string;
+  }
+
+  /** 淇濆瓨椤癸紙鏁存湀鍏ㄩ噺鎻愪氦锛宻hiftId 涓虹┖琛ㄧず浼戞伅/娓呴櫎锛� */
+  export interface ScheduleItem {
+    employeeId: number;
+    /** 鎺掔彮鏃ユ湡 yyyy-MM-dd */
+    date: string;
+    shiftId?: number | null;
+    remark?: string;
+  }
+}
+
+/** 鏌ヨ鏌愭湀鎺掔彮鍒楄〃 */
+export function getEmployeeScheduleList(params: {
+  yearMonth: string;
+  deptId?: number;
+  employeeId?: number;
+}) {
+  return requestClient.get<HrmEmployeeScheduleApi.EmployeeSchedule[]>(
+    '/hrm/employee-schedule/list',
+    { params },
+  );
+}
+
+/** 鎵归噺淇濆瓨鎺掔彮锛堟暣鏈堣鐩栧紡锛氭湁鐝 upsert锛屾棤鐝鍒犻櫎锛� */
+export function saveEmployeeSchedule(data: HrmEmployeeScheduleApi.ScheduleItem[]) {
+  return requestClient.post('/hrm/employee-schedule/save', data);
+}
diff --git a/src/api/hrm/shift/index.ts b/src/api/hrm/shift/index.ts
new file mode 100644
index 0000000..5988198
--- /dev/null
+++ b/src/api/hrm/shift/index.ts
@@ -0,0 +1,62 @@
+import type { PageParam, PageResult } from '#/packages/effects/request/src';
+
+import { requestClient } from '#/api/request';
+
+export namespace HrmShiftApi {
+  /** 鐝瀹氫箟 */
+  export interface Shift {
+    id?: number;
+    name?: string;
+    /** 涓婄彮鏃堕棿 HH:mm:ss */
+    startTime?: string;
+    /** 涓嬬彮鏃堕棿 HH:mm:ss */
+    endTime?: string;
+    /** 鏍囧噯宸ユ椂锛堝皬鏃讹級 */
+    workHours?: number;
+    /** 鏄惁璺ㄥぉ */
+    crossDay?: boolean;
+    /** 鏍囩棰滆壊锛堝崄鍏繘鍒讹級 */
+    color?: string;
+    /** 鐘舵�侊細0-鍚敤锛�1-绂佺敤 */
+    status?: number;
+    remark?: string;
+    createTime?: string;
+  }
+}
+
+/** 鏌ヨ鐝鍒嗛〉鍒楄〃 */
+export function getShiftPage(params: PageParam) {
+  return requestClient.get<PageResult<HrmShiftApi.Shift>>('/hrm/shift/page', {
+    params,
+  });
+}
+
+/** 鏌ヨ鐝鍒楄〃锛堝惎鐢ㄧ姸鎬侊紝渚涙帓鐝〉閫夋嫨锛� */
+export function getShiftList() {
+  return requestClient.get<HrmShiftApi.Shift[]>('/hrm/shift/list');
+}
+
+/** 鏌ヨ鐝璇︽儏 */
+export function getShift(id: number) {
+  return requestClient.get<HrmShiftApi.Shift>(`/hrm/shift/get?id=${id}`);
+}
+
+/** 鏂板鐝 */
+export function createShift(data: HrmShiftApi.Shift) {
+  return requestClient.post('/hrm/shift/create', data);
+}
+
+/** 鏇存柊鐝 */
+export function updateShift(data: HrmShiftApi.Shift) {
+  return requestClient.put('/hrm/shift/update', data);
+}
+
+/** 鍒犻櫎鐝 */
+export function deleteShift(id: number) {
+  return requestClient.delete(`/hrm/shift/delete?id=${id}`);
+}
+
+/** 瀵煎嚭鐝 Excel */
+export function exportShift(params: any) {
+  return requestClient.download('/hrm/shift/export-excel', { params });
+}
diff --git a/src/views/hrm/salary/calculation/data.ts b/src/views/hrm/salary/calculation/data.ts
index 17860e1..bd96225 100644
--- a/src/views/hrm/salary/calculation/data.ts
+++ b/src/views/hrm/salary/calculation/data.ts
@@ -89,6 +89,7 @@
     { field: 'actualSalary', title: '瀹炲彂宸ヨ祫', width: 100, formatter: 'formatAmount2' },
     { field: 'workDays', title: '鍑哄嫟澶╂暟', width: 80 },
     { field: 'overtimeHours', title: '鍔犵彮鏃堕暱(h)', width: 100 },
+    { field: 'totalWorkHours', title: '鎺掔彮宸ユ椂(h)', width: 100 },
     {
       field: 'status',
       title: '鐘舵��',
@@ -127,6 +128,7 @@
     { title: '璇峰亣鎵f', dataIndex: 'leaveDeduction', width: 110 },
     { title: '鍑哄嫟澶╂暟', dataIndex: 'workDays', width: 90 },
     { title: '鍔犵彮鏃堕暱', dataIndex: 'overtimeHours', width: 100 },
+    { title: '鎺掔彮宸ユ椂(h)', dataIndex: 'totalWorkHours', width: 100 },
     { title: '瀹炲彂宸ヨ祫', dataIndex: 'actualSalary', width: 100, fixed: 'right' },
   ];
 }
diff --git a/src/views/hrm/salary/calculation/index.vue b/src/views/hrm/salary/calculation/index.vue
index 8536a41..33a1f36 100644
--- a/src/views/hrm/salary/calculation/index.vue
+++ b/src/views/hrm/salary/calculation/index.vue
@@ -16,9 +16,11 @@
 import {
   calculateSalary,
   confirmSalary,
+  deleteSalaryCalculation,
   exportSalaryCalculation,
   getSalaryCalculationPage,
   previewSalaryCalculation,
+  revokeSalaryCalculation,
   saveSalaryCalculation,
 } from '#/api/hrm/salary/calculation';
 import { getDeptList } from '#/api/system/dept';
@@ -153,7 +155,7 @@
   handleRefresh();
 }
 
-/** 鎵归噺纭 */
+/** 鎵归噺纭閫変腑鐨勮褰� */
 async function handleBatchConfirm() {
   const records = gridApi.grid.getCheckboxRecords() as HrmSalaryCalculationApi.SalaryCalculation[];
   if (records.length === 0) {
@@ -164,6 +166,58 @@
   await confirmSalary(ids);
   message.success('纭鎴愬姛');
   handleRefresh();
+}
+
+/** 鎵归噺淇濆瓨閫変腑鐨勬牳绠楄褰曪紙浠呭緟纭鐘舵�侊級 */
+async function handleBatchSave() {
+  const records = gridApi.grid.getCheckboxRecords() as HrmSalaryCalculationApi.SalaryCalculation[];
+  if (records.length === 0) {
+    message.warning('璇烽�夋嫨瑕佷繚瀛樼殑璁板綍');
+    return;
+  }
+  const pending = records.filter((r) => r.status === 0);
+  if (pending.length === 0) {
+    message.warning('閫変腑鐨勮褰曞潎闈炲緟纭鐘舵�侊紝鏃犻渶淇濆瓨');
+    return;
+  }
+  const first = pending[0];
+  if (!first?.period) {
+    message.warning('缂哄皯鏍哥畻鍛ㄦ湡');
+    return;
+  }
+  await saveSalaryCalculation({ period: first.period, list: pending });
+  message.success('鎵归噺淇濆瓨鎴愬姛');
+  handleRefresh();
+}
+
+/** 鎾ら攢纭锛堝凡纭 鈫� 寰呯‘璁わ紝鍙噸鏂版牳绠楋級 */
+async function handleRevoke(row: HrmSalaryCalculationApi.SalaryCalculation) {
+  Modal.confirm({
+    title: '鎾ら攢纭',
+    content: `纭畾鎾ら攢銆�${row.no}銆戠殑纭鍚楋紵鎾ら攢鍚庡彲閲嶆柊鏍哥畻銆俙,
+    okText: '纭畾',
+    cancelText: '鍙栨秷',
+    onOk: async () => {
+      await revokeSalaryCalculation([row.id!]);
+      message.success('鎾ら攢鎴愬姛');
+      handleRefresh();
+    },
+  });
+}
+
+/** 鍒犻櫎寰呯‘璁ょ殑鏍哥畻璁板綍 */
+async function handleDelete(row: HrmSalaryCalculationApi.SalaryCalculation) {
+  Modal.confirm({
+    title: '纭鍒犻櫎',
+    content: `纭畾鍒犻櫎銆�${row.no}銆戠殑鏍哥畻璁板綍鍚楋紵鍒犻櫎鍚庨渶閲嶆柊鏍哥畻銆俙,
+    okText: '纭畾',
+    cancelText: '鍙栨秷',
+    onOk: async () => {
+      await deleteSalaryCalculation(row.id!);
+      message.success('鍒犻櫎鎴愬姛');
+      handleRefresh();
+    },
+  });
 }
 
 /** 瀵煎嚭钖叕鏍哥畻 */
@@ -182,7 +236,6 @@
     keepSource: true,
     checkboxConfig: {
       highlight: true,
-      reserve: true,
     },
     proxyConfig: {
       ajax: {
@@ -193,6 +246,10 @@
             ...formValues,
           }),
       },
+    },
+    pagingConfig: {
+      pageSize: 20,
+      pageSizes: [20, 50, 100],
     },
     rowConfig: {
       keyField: 'id',
@@ -261,7 +318,7 @@
         v-if="previewData.length > 0"
         :columns="usePreviewColumns()"
         :data-source="previewData"
-        :pagination="false"
+        :pagination="{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `鍏� ${t} 浜篳 }"
         :scroll="{ x: 1800, y: 400 }"
         size="small"
         row-key="userId"
@@ -430,6 +487,12 @@
               onClick: handleBatchConfirm,
             },
             {
+              label: '鎵归噺淇濆瓨',
+              type: 'primary',
+              auth: ['hrm:salary-calculation:save'],
+              onClick: handleBatchSave,
+            },
+            {
               label: $t('ui.actionTitle.export'),
               type: 'primary',
               icon: ACTION_ICON.DOWNLOAD,
@@ -454,6 +517,21 @@
               ifShow: row.status === 0,
               onClick: handleConfirm.bind(null, row),
             },
+            {
+              label: '鍒犻櫎',
+              type: 'link',
+              danger: true,
+              auth: ['hrm:salary-calculation:delete'],
+              ifShow: row.status === 0,
+              onClick: handleDelete.bind(null, row),
+            },
+            {
+              label: '鎾ら攢纭',
+              type: 'link',
+              auth: ['hrm:salary-calculation:confirm'],
+              ifShow: row.status === 10,
+              onClick: handleRevoke.bind(null, row),
+            },
           ]"
         />
       </template>
diff --git a/src/views/hrm/schedule/data.ts b/src/views/hrm/schedule/data.ts
new file mode 100644
index 0000000..821428e
--- /dev/null
+++ b/src/views/hrm/schedule/data.ts
@@ -0,0 +1,46 @@
+/** 鎺掔彮缃戞牸鐨勬棩鏈熷伐鍏� */
+
+const WEEK_LABELS = ['鏃�', '涓�', '浜�', '涓�', '鍥�', '浜�', '鍏�'];
+
+/** 杩斿洖鏌愭湀澶╂暟鏁扮粍锛屽 2026-08 鈫� [1..31] */
+export function getDaysOfMonth(yearMonth: string): number[] {
+  const [yearStr, monthStr] = yearMonth.split('-');
+  const days = new Date(Number(yearStr), Number(monthStr), 0).getDate();
+  return Array.from({ length: days }, (_, i) => i + 1);
+}
+
+/** 杩斿洖鏌愭棩鐨勬槦鏈熶腑鏂囨爣绛撅紙鏃�/涓�/浜屸�︼級 */
+export function getWeekLabel(yearMonth: string, day: number): string {
+  const date = new Date(`${yearMonth}-${String(day).padStart(2, '0')}`);
+  return WEEK_LABELS[date.getDay()] ?? '';
+}
+
+/** 鍒ゆ柇鏌愭棩鏄惁涓哄懆鏈� */
+export function isWeekend(yearMonth: string, day: number): boolean {
+  const date = new Date(`${yearMonth}-${String(day).padStart(2, '0')}`);
+  const week = date.getDay();
+  return week === 0 || week === 6;
+}
+
+/** 鐢熸垚鏌愭湀鏌愭棩鐨勬棩鏈熷瓧绗︿覆 yyyy-MM-dd */
+export function formatDate(yearMonth: string, day: number): string {
+  return `${yearMonth}-${String(day).padStart(2, '0')}`;
+}
+
+/** 鑷姩鎺掔彮浼戞伅妯″紡 */
+export const REST_MODE = {
+  /** 鍙屼紤锛氬懆鍏�佸懆鏃ヤ紤鎭� */
+  DOUBLE: 'double',
+  /** 鍗曚紤锛氬懆鏃ヤ紤鎭� */
+  SINGLE: 'single',
+  /** 涓嶄紤鎭細姣忓ぉ鎺掔彮 */
+  NONE: 'none',
+} as const;
+
+export type RestMode = (typeof REST_MODE)[keyof typeof REST_MODE];
+
+export const REST_MODE_OPTIONS: { label: string; value: RestMode }[] = [
+  { label: '鍙屼紤锛堝叚鏃ヤ紤锛�', value: REST_MODE.DOUBLE },
+  { label: '鍗曚紤锛堝懆鏃ヤ紤锛�', value: REST_MODE.SINGLE },
+  { label: '涓嶄紤鎭�', value: REST_MODE.NONE },
+];
diff --git a/src/views/hrm/schedule/index.vue b/src/views/hrm/schedule/index.vue
new file mode 100644
index 0000000..6f4c443
--- /dev/null
+++ b/src/views/hrm/schedule/index.vue
@@ -0,0 +1,743 @@
+<script lang="ts" setup>
+import type { HrmEmployeeApi } from '#/api/hrm/employee';
+import type { HrmEmployeeScheduleApi } from '#/api/hrm/schedule';
+import type { HrmShiftApi } from '#/api/hrm/shift';
+
+import { computed, onMounted, ref, watch } from 'vue';
+
+import dayjs from 'dayjs';
+
+import {
+  Button,
+  Checkbox,
+  DatePicker,
+  Empty,
+  Input,
+  message,
+  Modal,
+  Pagination,
+  Radio,
+  Segmented,
+  Select,
+  TreeSelect,
+} from 'ant-design-vue';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { getEmployeePage } from '#/api/hrm/employee';
+import { getShiftList } from '#/api/hrm/shift';
+import { getEmployeeScheduleList, saveEmployeeSchedule } from '#/api/hrm/schedule';
+import { getDeptList } from '#/api/system/dept';
+import { handleTree } from '#/packages/utils/src';
+
+import {
+  REST_MODE,
+  REST_MODE_OPTIONS,
+  formatDate,
+  getDaysOfMonth,
+} from './data';
+import type { RestMode } from './data';
+
+defineOptions({ name: 'HrmSchedule' });
+
+// ========== 鏌ヨ鏉′欢 ==========
+const month = ref<string>('');
+const deptId = ref<number>();
+const deptTree = ref<any[]>([]);
+
+/** 鍛樺伐鍒嗛〉锛堟帓鐝綉鏍兼瘡娆″彧鏄剧ず涓�椤靛憳宸ワ級 */
+const employeePageNo = ref(1);
+const employeePageSize = ref(20);
+const employeeTotal = ref(0);
+
+/** 鎸夊憳宸ョ紪鍙�/濮撳悕/鎵嬫満鍙锋悳绱㈠崟涓憳宸� */
+const keyword = ref('');
+
+/** 淇濆瓨鏃跺崟娆¤姹傜殑鎺掔彮鏉$洰鏁帮紙闃叉澶ф壒閲忚姹傝繃澶э級 */
+const SAVE_BATCH_SIZE = 1000;
+
+// ========== 鏁版嵁 ==========
+const employees = ref<HrmEmployeeApi.Employee[]>([]);
+const shiftList = ref<HrmShiftApi.Shift[]>([]);
+const loading = ref(false);
+const saving = ref(false);
+/** 鏄惁宸叉墽琛岃繃鏌ヨ */
+const loaded = ref(false);
+
+// ========== 鍛樺伐鍕鹃�� ==========
+/** Checkbox 鍙樻洿浜嬩欢锛堜粎鍙� checked 瀛楁锛� */
+interface CheckChangeEvent {
+  target: { checked: boolean };
+}
+
+/** 鍕鹃�夌殑鍛樺伐ID闆嗗悎锛屼负绌烘椂鑷姩鎺掔彮浣滅敤浜庡叏閮ㄥ憳宸� */
+const checkedEmployeeIds = ref<Set<number>>(new Set());
+
+const isAllChecked = computed(
+  () =>
+    employees.value.length > 0 &&
+    checkedEmployeeIds.value.size === employees.value.length,
+);
+
+function isChecked(id: number): boolean {
+  return checkedEmployeeIds.value.has(id);
+}
+
+function handleCheck(id: number, e: CheckChangeEvent) {
+  const set = new Set(checkedEmployeeIds.value);
+  if (e.target.checked) {
+    set.add(id);
+  } else {
+    set.delete(id);
+  }
+  checkedEmployeeIds.value = set;
+}
+
+function handleCheckAll(e: CheckChangeEvent) {
+  if (e.target.checked) {
+    checkedEmployeeIds.value = new Set(
+      employees.value
+        .map((item) => item.id)
+        .filter((id): id is number => id != null),
+    );
+  } else {
+    checkedEmployeeIds.value = new Set();
+  }
+}
+
+// ========== 鑷姩鎺掔彮 ==========
+const autoOpen = ref(false);
+const autoShiftId = ref<number>();
+const autoRestMode = ref<RestMode>(REST_MODE.DOUBLE);
+
+/** 鑷姩鎺掔彮鐝閫夋嫨閫夐」 */
+const autoShiftOptions = computed(() =>
+  shiftList.value.map((shift) => ({
+    label: `${shift.name} (${shift.startTime}~${shift.endTime})`,
+    value: shift.id,
+  })),
+);
+
+/** 鎸変紤鎭ā寮忎负褰撳墠鍛樺伐鐢熸垚鏁存湀鎺掔彮锛堜紤鎭棩娓呴櫎锛屽伐浣滄棩濉彮娆★級 */
+function applyAutoSchedule() {
+  if (!employees.value.length || !days.value.length) {
+    message.warning('璇峰厛鏌ヨ鍑哄憳宸�');
+    return;
+  }
+  if (autoShiftId.value == null) {
+    message.warning('璇烽�夋嫨鎺掔彮鐝');
+    return;
+  }
+  // 鍕鹃�変簡鍛樺伐鍒欏彧瀵瑰嬀閫変汉鐢熸晥锛屾湭鍕鹃�夊垯浣滅敤浜庡叏閮ㄥ憳宸�
+  const targets =
+    checkedEmployeeIds.value.size > 0
+      ? employees.value.filter(
+          (item) =>
+            item.id != null && checkedEmployeeIds.value.has(item.id),
+        )
+      : employees.value;
+  if (!targets.length) {
+    message.warning('璇峰厛鍕鹃�夊憳宸�');
+    return;
+  }
+  const map = new Map(scheduleMap.value);
+  for (const emp of targets) {
+    if (emp.id == null) {
+      continue;
+    }
+    for (const day of days.value) {
+      const date = formatDate(month.value, day);
+      const week = new Date(
+        `${month.value}-${String(day).padStart(2, '0')}`,
+      ).getDay();
+      let rest = false;
+      if (autoRestMode.value === REST_MODE.DOUBLE) {
+        rest = week === 0 || week === 6;
+      } else if (autoRestMode.value === REST_MODE.SINGLE) {
+        rest = week === 0;
+      }
+      map.set(`${emp.id}_${date}`, rest ? null : autoShiftId.value);
+    }
+  }
+  scheduleMap.value = map;
+  autoOpen.value = false;
+  message.success('鑷姩鎺掔彮宸茬敓鎴愶紝鍙墜鍔ㄥ井璋冨悗淇濆瓨');
+}
+
+/** 鐝 id 鈫� 鐝 鏄犲皠 */
+const shiftMap = computed(() => {
+  const map = new Map<number, HrmShiftApi.Shift>();
+  for (const shift of shiftList.value) {
+    if (shift.id != null) {
+      map.set(shift.id, shift);
+    }
+  }
+  return map;
+});
+
+/**
+ * 鎺掔彮鏁版嵁銆俴ey = `${employeeId}_${date}`锛寁alue = shiftId锛坣ull 琛ㄧず浼戞伅锛�
+ */
+const scheduleMap = ref(new Map<string, number | null>());
+
+/** 褰撴湀澶╂暟鏁扮粍 */
+const days = computed(() => (month.value ? getDaysOfMonth(month.value) : []));
+
+// ========== 瑙嗗浘鍒囨崲锛堝懆/鏈堬級==========
+const WEEK_LABELS = ['鏃�', '涓�', '浜�', '涓�', '鍥�', '浜�', '鍏�'];
+
+interface ViewDay {
+  day: number;
+  date: string;
+  weekLabel: string;
+  isWeekend: boolean;
+}
+
+const viewMode = ref<'week' | 'month'>('week');
+/** 鏈堣鍥炬槸鍚︽樉绀哄懆鏈垪锛堥粯璁ら殣钘忥紝鍑忓垪缂撹В鎷ユ尋锛� */
+const showWeekend = ref(false);
+/** 鍛ㄨ鍥鹃敋鐐规棩鏈� */
+const viewAnchor = ref<Date>(new Date());
+
+/** 褰撳墠瑙嗗浘瑕佸睍绀虹殑鏃ユ湡鍒楄〃 */
+const viewDays = computed<ViewDay[]>(() => {
+  if (viewMode.value === 'week') {
+    // 浠ュ懆涓�涓轰竴鍛ㄨ捣鐐�
+    const base = dayjs(viewAnchor.value).day(1);
+    return Array.from({ length: 7 }, (_, i) => {
+      const date = base.add(i, 'day');
+      const week = date.day();
+      return {
+        day: date.date(),
+        date: date.format('YYYY-MM-DD'),
+        weekLabel: WEEK_LABELS[week] ?? '',
+        isWeekend: week === 0 || week === 6,
+      };
+    });
+  }
+  return days.value
+    .filter((day) => {
+      if (showWeekend.value) {
+        return true;
+      }
+      const week = new Date(
+        `${month.value}-${String(day).padStart(2, '0')}`,
+      ).getDay();
+      return week !== 0 && week !== 6;
+    })
+    .map((day) => {
+      const week = new Date(
+        `${month.value}-${String(day).padStart(2, '0')}`,
+      ).getDay();
+      return {
+        day,
+        date: formatDate(month.value, day),
+        weekLabel: WEEK_LABELS[week] ?? '',
+        isWeekend: week === 0 || week === 6,
+      };
+    });
+});
+
+/** 鍛ㄨ鍥惧綋鍓嶆樉绀鸿寖鍥存爣绛� */
+const weekRangeLabel = computed(() => {
+  const list = viewDays.value;
+  const first = list[0];
+  const last = list[list.length - 1];
+  if (!first || !last) {
+    return '';
+  }
+  return `${first.date} ~ ${last.date}`;
+});
+
+function shiftWeek(delta: number) {
+  viewAnchor.value = dayjs(viewAnchor.value).add(delta, 'week').toDate();
+}
+
+// 鏈堜唤鍙樺寲鏃讹紝鍛ㄨ鍥鹃敋鐐硅惤鍒拌鏈� 1 鍙锋墍鍦ㄥ懆
+watch(month, (value) => {
+  if (value) {
+    viewAnchor.value = new Date(`${value}-01`);
+  }
+});
+
+function getShiftByCell(employeeId: number, date: string): HrmShiftApi.Shift | undefined {
+  const shiftId = scheduleMap.value.get(`${employeeId}_${date}`);
+  if (shiftId == null) {
+    return undefined;
+  }
+  return shiftMap.value.get(shiftId);
+}
+
+// ========== 鐝閫夋嫨寮圭獥 ==========
+const pickerOpen = ref(false);
+const pickerEmployee = ref<HrmEmployeeApi.Employee>();
+const pickerDate = ref<string>('');
+
+const pickerTitle = computed(() => {
+  if (!pickerEmployee.value || !pickerDate.value) {
+    return '閫夋嫨鐝';
+  }
+  return `閫夋嫨鐝 路 ${pickerEmployee.value.name} 路 ${pickerDate.value}`;
+});
+
+function openPicker(emp: HrmEmployeeApi.Employee, date: string) {
+  pickerEmployee.value = emp;
+  pickerDate.value = date;
+  pickerOpen.value = true;
+}
+
+/** 褰撳墠寮圭獥鏍煎瓙鏄惁宸查�夎鐝 */
+function isCurrentPick(shiftId: number): boolean {
+  if (!pickerEmployee.value || !pickerDate.value) {
+    return false;
+  }
+  return (
+    scheduleMap.value.get(`${pickerEmployee.value.id}_${pickerDate.value}`) ===
+    shiftId
+  );
+}
+
+function pickShift(shiftId: number | null) {
+  if (!pickerEmployee.value || !pickerDate.value) {
+    return;
+  }
+  scheduleMap.value.set(`${pickerEmployee.value.id}_${pickerDate.value}`, shiftId);
+  pickerOpen.value = false;
+}
+
+// ========== 鍔犺浇 ==========
+async function loadDeptTree() {
+  const data = await getDeptList();
+  deptTree.value = handleTree(data);
+}
+
+/**
+ * 鍒嗛〉鎷夊彇閮ㄩ棬涓嬬殑鍏ㄩ儴鍛樺伐锛堝崟椤典笂闄� 200锛屽惊鐜嫾鍏級锛屼緵鎺掔彮缃戞牸鏁磋〃灞曠ず
+ */
+/** 鍔犺浇褰撳墠椤靛憳宸ワ紙缈婚〉鏃跺彧閲嶆柊鎷夊憳宸ワ紝鎺掔彮鏁版嵁淇濈暀锛� */
+async function loadEmployeePage() {
+  loading.value = true;
+  try {
+    const res = await getEmployeePage({
+      pageNo: employeePageNo.value,
+      pageSize: employeePageSize.value,
+      deptId: deptId.value,
+      keyword: keyword.value || undefined,
+    });
+    employees.value = res.list ?? [];
+    employeeTotal.value = res.total ?? 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+/** 鍛樺伐缈婚〉 */
+function handlePageChange(page: number, pageSize: number) {
+  if (pageSize !== employeePageSize.value) {
+    employeePageSize.value = pageSize;
+    employeePageNo.value = 1;
+  } else {
+    employeePageNo.value = page;
+  }
+  loadEmployeePage();
+}
+
+/** 鎸夊叧閿瘝鎼滅储鍛樺伐 */
+function handleSearch() {
+  employeePageNo.value = 1;
+  loadEmployeePage();
+}
+
+async function loadData() {
+  if (!month.value) {
+    message.warning('璇烽�夋嫨鏈堜唤');
+    return;
+  }
+  if (!deptId.value) {
+    message.warning('璇峰厛閫夋嫨閮ㄩ棬');
+    return;
+  }
+  loading.value = true;
+  try {
+    const [shifts, schedules] = await Promise.all([
+      getShiftList(),
+      getEmployeeScheduleList({
+        yearMonth: month.value,
+        deptId: deptId.value,
+      }),
+    ]);
+    shiftList.value = shifts;
+    const map = new Map<string, number | null>();
+    for (const item of schedules) {
+      if (item.employeeId != null && item.date) {
+        map.set(`${item.employeeId}_${item.date}`, item.shiftId ?? null);
+      }
+    }
+    scheduleMap.value = map;
+    loaded.value = true;
+  } finally {
+    loading.value = false;
+  }
+  // 鍔犺浇绗竴椤靛憳宸�
+  employeePageNo.value = 1;
+  await loadEmployeePage();
+}
+
+// ========== 淇濆瓨锛堟暣鏈堝叏閲忥級 ==========
+async function handleSave() {
+  if (!month.value || !employees.value.length) {
+    return;
+  }
+  Modal.confirm({
+    title: '纭淇濆瓨',
+    content: `灏嗕繚瀛樺綋鍓嶉〉 ${employees.value.length} 鍚嶅憳宸ュ湪 ${month.value} 鐨勫叏閮ㄦ帓鐝紝纭畾缁х画鍚楋紵`,
+    okText: '纭畾',
+    cancelText: '鍙栨秷',
+    onOk: async () => {
+      saving.value = true;
+      try {
+        const items: HrmEmployeeScheduleApi.ScheduleItem[] = [];
+        for (const emp of employees.value) {
+          if (emp.id == null) {
+            continue;
+          }
+          for (const day of days.value) {
+            const date = formatDate(month.value, day);
+            items.push({
+              employeeId: emp.id,
+              date,
+              shiftId: scheduleMap.value.get(`${emp.id}_${date}`) ?? null,
+            });
+          }
+        }
+        // 澶ф壒閲忔椂鍒嗘壒鎻愪氦锛岄伩鍏嶅崟娆¤姹傝繃澶�
+        for (let i = 0; i < items.length; i += SAVE_BATCH_SIZE) {
+          await saveEmployeeSchedule(items.slice(i, i + SAVE_BATCH_SIZE));
+        }
+        message.success('鎺掔彮淇濆瓨鎴愬姛');
+      } finally {
+        saving.value = false;
+      }
+    },
+  });
+}
+
+onMounted(() => {
+  month.value = dayjs().format('YYYY-MM');
+  loadDeptTree();
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <!-- 鏌ヨ宸ュ叿鏍� -->
+    <div
+      class="mb-4 flex flex-wrap items-center gap-3 rounded-lg bg-white p-4"
+    >
+      <DatePicker
+        v-model:value="month"
+        picker="month"
+        :allow-clear="false"
+        value-format="YYYY-MM"
+        format="YYYY-MM"
+        style="width: 140px"
+      />
+      <TreeSelect
+        v-model:value="deptId"
+        :tree-data="deptTree"
+        :field-names="{ label: 'name', value: 'id', children: 'children' }"
+        allow-clear
+        placeholder="璇烽�夋嫨閮ㄩ棬"
+        tree-default-expand-all
+        style="width: 200px"
+      />
+      <Input
+        v-model:value="keyword"
+        allow-clear
+        placeholder="鍛樺伐缂栧彿/濮撳悕/鎵嬫満鍙�"
+        style="width: 200px"
+        @pressEnter="handleSearch"
+      />
+      <Button type="primary" :loading="loading" @click="loadData">
+        鏌ヨ鎺掔彮
+      </Button>
+      <Button :disabled="!employees.length" @click="autoOpen = true">
+        鑷姩鎺掔彮
+      </Button>
+      <Button
+        type="primary"
+        :loading="saving"
+        :disabled="!employees.length"
+        @click="handleSave"
+      >
+        淇濆瓨鎺掔彮
+      </Button>
+      <Segmented
+        v-model:value="viewMode"
+        :options="[
+          { label: '鍛ㄨ鍥�', value: 'week' },
+          { label: '鏈堣鍥�', value: 'month' },
+        ]"
+      />
+      <template v-if="viewMode === 'week'">
+        <Button @click="shiftWeek(-1)">涓婁竴鍛�</Button>
+        <span class="text-sm text-gray-500">{{ weekRangeLabel }}</span>
+        <Button @click="shiftWeek(1)">涓嬩竴鍛�</Button>
+      </template>
+      <template v-else>
+        <Checkbox v-model:checked="showWeekend">鏄剧ず鍛ㄦ湯</Checkbox>
+      </template>
+    </div>
+
+    <!-- 鐝鍥句緥 -->
+    <div
+      v-if="shiftList.length"
+      class="mb-4 flex flex-wrap items-center gap-4 rounded-lg bg-white px-4 py-3"
+    >
+      <span class="text-sm text-gray-500">鐝鍥句緥锛�</span>
+      <span
+        v-for="shift in shiftList"
+        :key="shift.id"
+        class="inline-flex items-center gap-1.5 text-sm"
+      >
+        <span
+          class="inline-block h-3 w-3 rounded"
+          :style="{ backgroundColor: shift.color || '#1677ff' }"
+        />
+        <span>{{ shift.name }}</span>
+        <span class="text-xs text-gray-400"
+          >{{ shift.startTime }}~{{ shift.endTime }}</span
+        >
+      </span>
+    </div>
+
+    <!-- 鎺掔彮缃戞牸 -->
+    <div class="overflow-auto rounded-lg bg-white">
+      <Empty
+        v-if="!employees.length && !loading"
+        :description="
+          loaded
+            ? '璇ラ儴闂ㄦ殏鏃犵鍚堟潯浠剁殑鍛樺伐锛岃璋冩暣閮ㄩ棬鎴栧憳宸ョ姸鎬�'
+            : '璇烽�夋嫨鏈堜唤銆侀儴闂ㄥ悗锛岀偣鍑汇�屾煡璇㈡帓鐝��'
+        "
+        class="py-20"
+      />
+      <table v-else class="schedule-table w-full text-sm">
+        <thead>
+          <tr>
+            <th class="schedule-check-col bg-gray-100">
+              <Checkbox :checked="isAllChecked" @change="handleCheckAll" />
+            </th>
+            <th class="schedule-name-col bg-gray-100 text-left">鍛樺伐</th>
+            <th
+              v-for="vd in viewDays"
+              :key="vd.date"
+              :class="[
+                'schedule-date-col text-center',
+                vd.isWeekend ? 'bg-red-50' : 'bg-gray-50',
+              ]"
+            >
+              <div class="font-medium">{{ vd.day }}</div>
+              <div class="text-xs text-gray-400">{{ vd.weekLabel }}</div>
+            </th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="emp in employees" :key="emp.id">
+            <td class="schedule-check-col" @click.stop>
+              <Checkbox
+                :checked="isChecked(emp.id!)"
+                @change="handleCheck(emp.id!, $event)"
+              />
+            </td>
+            <td class="schedule-name-col">
+              <div class="font-medium">{{ emp.name }}</div>
+              <div class="text-xs text-gray-400">{{ emp.employeeNo }}</div>
+            </td>
+            <td
+              v-for="vd in viewDays"
+              :key="vd.date"
+              class="schedule-cell"
+              :class="[vd.isWeekend ? 'bg-red-50/40' : '']"
+              @click="openPicker(emp, vd.date)"
+            >
+              <span
+                v-if="getShiftByCell(emp.id!, vd.date)"
+                class="shift-chip"
+                :title="getShiftByCell(emp.id!, vd.date)!.name"
+                :style="{
+                  backgroundColor:
+                    getShiftByCell(emp.id!, vd.date)!.color || '#1677ff',
+                }"
+              >
+                {{ getShiftByCell(emp.id!, vd.date)!.name }}
+              </span>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 鍛樺伐鍒嗛〉 -->
+    <div
+      v-if="loaded && employees.length"
+      class="mt-4 flex items-center justify-end rounded-lg bg-white p-3"
+    >
+      <Pagination
+        :current="employeePageNo"
+        :page-size="employeePageSize"
+        :total="employeeTotal"
+        show-size-changer
+        show-quick-jumper
+        :show-total="(total: number) => `鍏� ${total} 浜篳"
+        @change="handlePageChange"
+      />
+    </div>
+
+    <!-- 鐝閫夋嫨寮圭獥 -->
+    <Modal
+      v-model:open="pickerOpen"
+      :title="pickerTitle"
+      :footer="null"
+      :width="440"
+    >
+      <div class="grid grid-cols-2 gap-3">
+        <button
+          v-for="shift in shiftList"
+          :key="shift.id"
+          type="button"
+          class="flex cursor-pointer flex-col items-center gap-1 rounded-lg border px-2 py-3 transition-colors hover:border-blue-500"
+          :class="[
+            isCurrentPick(shift.id!)
+              ? 'border-blue-500 bg-blue-50'
+              : 'border-gray-200',
+          ]"
+          @click="pickShift(shift.id!)"
+        >
+          <span class="inline-flex items-center gap-2 text-sm">
+            <span
+              class="inline-block h-3 w-3 rounded"
+              :style="{ backgroundColor: shift.color || '#1677ff' }"
+            />
+            <span class="font-medium">{{ shift.name }}</span>
+          </span>
+          <span class="text-xs text-gray-400"
+            >{{ shift.startTime }} ~ {{ shift.endTime }}</span
+          >
+        </button>
+      </div>
+      <div class="mt-4 flex items-center justify-between">
+        <Button danger @click="pickShift(null)">娓呴櫎鎺掔彮锛堜紤鎭級</Button>
+        <Button @click="pickerOpen = false">鍙栨秷</Button>
+      </div>
+    </Modal>
+
+    <!-- 鑷姩鎺掔彮寮圭獥 -->
+    <Modal v-model:open="autoOpen" title="鑷姩鎺掔彮" :footer="null" :width="440">
+      <div class="space-y-4">
+        <div>
+          <div class="mb-1.5 text-sm text-gray-500">鎺掔彮鐝</div>
+          <Select
+            v-model:value="autoShiftId"
+            :options="autoShiftOptions"
+            placeholder="璇烽�夋嫨鎺掔彮鐝"
+            style="width: 100%"
+          />
+        </div>
+        <div>
+          <div class="mb-1.5 text-sm text-gray-500">浼戞伅妯″紡</div>
+          <Radio.Group
+            v-model:value="autoRestMode"
+            :options="REST_MODE_OPTIONS"
+            option-type="button"
+          />
+        </div>
+        <div class="text-xs text-gray-400">
+          灏嗘寜鎵�閫夋ā寮忎负{{
+            checkedEmployeeIds.size > 0
+              ? checkedEmployeeIds.size
+              : employees.length
+          }}
+          鍚嶅憳宸ョ敓鎴愭暣鏈堟帓鐝瓄{
+            checkedEmployeeIds.size > 0
+              ? '锛堜粎鍕鹃�夌殑鍛樺伐锛�'
+              : '锛堟湭鍕鹃�夋椂涓哄綋鍓嶉〉鍏ㄩ儴鍛樺伐锛�'
+          }}锛岀敓鎴愬悗鍙啀鎵嬪姩璋冩暣銆�
+        </div>
+        <div class="flex justify-end gap-2">
+          <Button @click="autoOpen = false">鍙栨秷</Button>
+          <Button type="primary" @click="applyAutoSchedule">鐢熸垚鎺掔彮</Button>
+        </div>
+      </div>
+    </Modal>
+  </Page>
+</template>
+
+<style lang="scss" scoped>
+.schedule-table {
+  width: 100%;
+  min-width: max-content;
+  border-collapse: separate;
+  border-spacing: 0;
+
+  th,
+  td {
+    height: 44px;
+    border: 1px solid #f0f0f0;
+  }
+
+  .schedule-date-col {
+    width: 52px;
+    min-width: 52px;
+  }
+
+  .schedule-check-col,
+  .schedule-name-col {
+    position: sticky;
+    z-index: 10;
+    background-color: #fafafa;
+  }
+
+  .schedule-check-col {
+    left: 0;
+    width: 40px;
+    min-width: 40px;
+    padding: 0 8px;
+    text-align: center;
+  }
+
+  .schedule-name-col {
+    left: 40px;
+    min-width: 180px;
+    padding: 0 12px;
+    line-height: 1.3;
+  }
+
+  thead .schedule-check-col,
+  thead .schedule-name-col {
+    z-index: 20;
+  }
+
+  .schedule-cell {
+    padding: 2px;
+    cursor: pointer;
+    text-align: center;
+
+    &:hover {
+      background-color: #e6f4ff;
+    }
+  }
+
+  .shift-chip {
+    display: inline-block;
+    max-width: 100%;
+    padding: 1px 6px;
+    overflow: hidden;
+    color: #fff;
+    font-size: 11px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    border-radius: 3px;
+  }
+}
+</style>
diff --git a/src/views/hrm/shift/data.ts b/src/views/hrm/shift/data.ts
new file mode 100644
index 0000000..123b748
--- /dev/null
+++ b/src/views/hrm/shift/data.ts
@@ -0,0 +1,186 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmShiftApi } from '#/api/hrm/shift';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+/** 琛ㄥ崟绫诲瀷 */
+export type FormType = 'create' | 'update' | 'detail';
+
+/** 鐝鏍囩棰滆壊棰勮 */
+export const SHIFT_COLORS = [
+  { label: '钃濊壊', value: '#1677ff' },
+  { label: '缁胯壊', value: '#52c41a' },
+  { label: '姗欒壊', value: '#fa8c16' },
+  { label: '绱壊', value: '#722ed1' },
+  { label: '绮夎壊', value: '#eb2f96' },
+  { label: '榛勮壊', value: '#faad14' },
+  { label: '闈掕壊', value: '#13c2c2' },
+  { label: '鐏拌壊', value: '#8c8c8c' },
+];
+
+/** 鏂板/缂栬緫/鏌ョ湅鐝鐨勮〃鍗� */
+export function useFormSchema(formType: FormType): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'id',
+      component: 'Input',
+      dependencies: {
+        triggerFields: [''],
+        show: () => false,
+      },
+    },
+    {
+      fieldName: 'name',
+      label: '鐝鍚嶇О',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ョ彮娆″悕绉帮紝濡傦細鐧界彮銆佸鐝�',
+        maxlength: 50,
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'startTime',
+      label: '涓婄彮鏃堕棿',
+      component: 'TimePicker',
+      componentProps: {
+        placeholder: '閫夋嫨涓婄彮鏃堕棿',
+        valueFormat: 'HH:mm:ss',
+        format: 'HH:mm:ss',
+        style: { width: '100%' },
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'endTime',
+      label: '涓嬬彮鏃堕棿',
+      component: 'TimePicker',
+      componentProps: {
+        placeholder: '閫夋嫨涓嬬彮鏃堕棿',
+        valueFormat: 'HH:mm:ss',
+        format: 'HH:mm:ss',
+        style: { width: '100%' },
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'workHours',
+      label: '鏍囧噯宸ユ椂(灏忔椂)',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '濡� 8銆�8.5',
+        min: 0.5,
+        max: 24,
+        precision: 2,
+        style: { width: '100%' },
+      },
+    },
+    {
+      fieldName: 'crossDay',
+      label: '鏄惁璺ㄥぉ',
+      component: 'Switch',
+      componentProps: {
+        checkedValue: true,
+        unCheckedValue: false,
+      },
+    },
+    {
+      fieldName: 'color',
+      label: '鏍囩棰滆壊',
+      component: 'Select',
+      componentProps: {
+        options: SHIFT_COLORS,
+        placeholder: '璇烽�夋嫨棰滆壊',
+        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: 'status',
+      label: '鐘舵��',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+        placeholder: '璇烽�夋嫨鐘舵��',
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions<HrmShiftApi.Shift>['columns'] {
+  return [
+    { field: 'name', title: '鐝鍚嶇О', minWidth: 120 },
+    { field: 'startTime', title: '涓婄彮鏃堕棿', width: 100 },
+    { field: 'endTime', title: '涓嬬彮鏃堕棿', width: 100 },
+    { field: 'workHours', title: '鏍囧噯宸ユ椂(h)', width: 110 },
+    {
+      field: 'crossDay',
+      title: '璺ㄥぉ',
+      width: 80,
+      formatter: ({ cellValue }) => (cellValue ? '鏄�' : '鍚�'),
+    },
+    {
+      field: 'color',
+      title: '鏍囩棰滆壊',
+      width: 110,
+      slots: {
+        default: 'color',
+      },
+    },
+    {
+      field: 'status',
+      title: '鐘舵��',
+      width: 90,
+      cellRender: {
+        name: 'CellDict',
+        props: { type: DICT_TYPE.COMMON_STATUS },
+      },
+    },
+    {
+      title: '鎿嶄綔',
+      width: 150,
+      fixed: 'right',
+      slots: {
+        default: 'actions',
+      },
+    },
+  ];
+}
diff --git a/src/views/hrm/shift/index.vue b/src/views/hrm/shift/index.vue
new file mode 100644
index 0000000..1e98746
--- /dev/null
+++ b/src/views/hrm/shift/index.vue
@@ -0,0 +1,162 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { HrmShiftApi } from '#/api/hrm/shift';
+
+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 {
+  deleteShift,
+  exportShift,
+  getShiftPage,
+} from '#/api/hrm/shift';
+import { $t } from '#/locales';
+
+import { SHIFT_COLORS, 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: HrmShiftApi.Shift) {
+  formModalApi.setData({ id: row.id, formType: 'update' }).open();
+}
+
+/** 鏌ョ湅鐝 */
+function handleDetail(row: HrmShiftApi.Shift) {
+  formModalApi.setData({ id: row.id, formType: 'detail' }).open();
+}
+
+/** 鍒犻櫎鐝 */
+async function handleDelete(id: number) {
+  Modal.confirm({
+    title: '纭鍒犻櫎',
+    content: '纭畾瑕佸垹闄よ鐝鍚楋紵鍒犻櫎鍚庡凡鏈夋帓鐝皢澶卞幓鐝淇℃伅銆�',
+    okText: '纭畾',
+    cancelText: '鍙栨秷',
+    onOk: async () => {
+      await deleteShift(id);
+      message.success('鍒犻櫎鎴愬姛');
+      handleRefresh();
+    },
+  });
+}
+
+/** 瀵煎嚭鐝 */
+async function handleExport() {
+  const data = await exportShift(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 getShiftPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          }),
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<HrmShiftApi.Shift>,
+});
+</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:shift:create'],
+              onClick: handleCreate,
+            },
+            {
+              label: $t('ui.actionTitle.export'),
+              type: 'primary',
+              icon: ACTION_ICON.DOWNLOAD,
+              auth: ['hrm:shift:export'],
+              onClick: handleExport,
+            },
+          ]"
+        />
+      </template>
+      <template #color="{ row }">
+        <div class="flex items-center gap-2">
+          <span
+            class="inline-block h-4 w-4 rounded"
+            :style="{ backgroundColor: row.color || '#8c8c8c' }"
+          />
+          <span>
+            {{
+              SHIFT_COLORS.find((item) => item.value === row.color)?.label ||
+              row.color ||
+              '-'
+            }}
+          </span>
+        </div>
+      </template>
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '鏌ョ湅',
+              type: 'link',
+              onClick: handleDetail.bind(null, row),
+            },
+            {
+              label: $t('ui.actionTitle.edit'),
+              type: 'link',
+              auth: ['hrm:shift:update'],
+              onClick: handleUpdate.bind(null, row),
+            },
+            {
+              label: $t('ui.actionTitle.delete'),
+              type: 'link',
+              danger: true,
+              auth: ['hrm:shift:delete'],
+              onClick: handleDelete.bind(null, row.id!),
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
diff --git a/src/views/hrm/shift/modules/form.vue b/src/views/hrm/shift/modules/form.vue
new file mode 100644
index 0000000..7c69b65
--- /dev/null
+++ b/src/views/hrm/shift/modules/form.vue
@@ -0,0 +1,96 @@
+<script lang="ts" setup>
+import type { FormType } from '../data';
+import type { HrmShiftApi } from '#/api/hrm/shift';
+
+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 { createShift, getShift, updateShift } from '#/api/hrm/shift';
+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 HrmShiftApi.Shift;
+    try {
+      if (formType.value === 'update') {
+        await updateShift(data);
+      } else {
+        await createShift(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 getShift(data.id));
+    } finally {
+      modalApi.unlock();
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle" class="w-[720px]">
+    <Form class="mx-4" />
+  </Modal>
+</template>

--
Gitblit v1.9.3