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