gaoluyang
89 分钟以前 293acfc337ba02749026ef0050c2f6d59f122d1e
feat(hrm): 排班格子修改后自动保存

- 选班次/选休息后立即保存该格,失败回滚并提示
- 自动排班生成后自动分批保存,去掉手动「保存排班」按钮
- 保存中的格子半透明显示

Co-Authored-By: Claude Code <noreply@anthropic.com>
已修改1个文件
168 ■■■■■ 文件已修改
src/views/hrm/schedule/index.vue 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/schedule/index.vue
@@ -78,7 +78,6 @@
  new Map<string, HrmEmployeeScheduleApi.ScheduleLeave>(),
);
const loading = ref(false);
const saving = ref(false);
/** 是否已执行过查询 */
const loaded = ref(false);
/** 最近一次「查询排班」对应的月份/部门,保存前校验,避免拿旧数据覆盖 */
@@ -130,6 +129,10 @@
const autoOpen = ref(false);
const autoShiftId = ref<number>();
const autoRestMode = ref<RestMode>(REST_MODE.DOUBLE);
/** 自动排班保存中 */
const autoSaving = ref(false);
/** 正在保存的格子 key:`${employeeId}_${date}` */
const cellSaving = ref(new Set<string>());
/** 自动排班班次选择选项 */
const autoShiftOptions = computed(() =>
@@ -139,14 +142,26 @@
  })),
);
/** 按休息模式为当前员工生成整月排班(休息日清除,工作日填班次) */
function applyAutoSchedule() {
/** 分批提交排班变更 */
async function persistItems(items: HrmEmployeeScheduleApi.ScheduleItem[]) {
  for (let i = 0; i < items.length; i += SAVE_BATCH_SIZE) {
    await saveEmployeeSchedule(items.slice(i, i + SAVE_BATCH_SIZE));
  }
}
/** 按休息模式为当前员工生成整月排班(休息日清除,工作日填班次),生成后自动保存 */
async function applyAutoSchedule() {
  if (!employees.value.length || !days.value.length) {
    message.warning('请先查询出员工');
    return;
  }
  if (autoShiftId.value == null) {
    message.warning('请选择排班班次');
    return;
  }
  // 未重新查询时网格里的空格不代表「休息」,自动排班会误清已有排班
  if (loadedMonth.value !== month.value || loadedDeptId.value !== deptId.value) {
    message.warning('月份或部门已变更,请先点击「查询排班」加载最新数据');
    return;
  }
  // 勾选了员工则只对勾选人生效,未勾选则作用于全部员工
@@ -162,6 +177,7 @@
    return;
  }
  const map = new Map(scheduleMap.value);
  const items: HrmEmployeeScheduleApi.ScheduleItem[] = [];
  for (const emp of targets) {
    if (emp.id == null) {
      continue;
@@ -187,12 +203,30 @@
      } else if (autoRestMode.value === REST_MODE.SINGLE) {
        rest = week === 0;
      }
      map.set(`${emp.id}_${date}`, rest ? null : autoShiftId.value);
      const key = `${emp.id}_${date}`;
      const next = rest ? null : autoShiftId.value!;
      if ((map.get(key) ?? null) !== next) {
        items.push({ employeeId: emp.id, date, shiftId: next });
      }
      map.set(key, next);
    }
  }
  scheduleMap.value = map;
  autoOpen.value = false;
  message.success('自动排班已生成,可手动微调后保存');
  if (!items.length) {
    message.info('自动排班结果与现有排班一致,无需保存');
    return;
  }
  autoSaving.value = true;
  try {
    await persistItems(items);
    message.success(`自动排班已生成并保存(${items.length} 个格子)`);
  } catch {
    message.error('自动排班保存失败,已重新加载服务端排班');
    await loadData();
  } finally {
    autoSaving.value = false;
  }
}
// ========== 按群体批量排班(一类人一次排完) ==========
@@ -533,12 +567,56 @@
  );
}
function pickShift(shiftId: number | null) {
  if (!pickerEmployee.value || !pickerDate.value) {
/** 某格是否正在保存 */
function isCellSaving(employeeId: number, date: string): boolean {
  return cellSaving.value.has(`${employeeId}_${date}`);
}
/**
 * 保存单个格子:先乐观更新网格,失败则回滚为原值
 */
async function saveCell(
  employeeId: number,
  date: string,
  next: number | null,
  prev: number | null,
) {
  const key = `${employeeId}_${date}`;
  cellSaving.value = new Set([...cellSaving.value, key]);
  try {
    await saveEmployeeSchedule([{ employeeId, date, shiftId: next }]);
  } catch {
    const map = new Map(scheduleMap.value);
    if (prev == null) {
      map.delete(key);
    } else {
      map.set(key, prev);
    }
    scheduleMap.value = map;
    message.error('保存失败,已恢复原排班,请重试');
  } finally {
    const saving = new Set(cellSaving.value);
    saving.delete(key);
    cellSaving.value = saving;
  }
}
/** 选班次后立即保存该格(选「休息」即清除该天排班) */
async function pickShift(shiftId: number | null) {
  const emp = pickerEmployee.value;
  const date = pickerDate.value;
  pickerOpen.value = false;
  if (emp?.id == null || !date) {
    return;
  }
  scheduleMap.value.set(`${pickerEmployee.value.id}_${pickerDate.value}`, shiftId);
  pickerOpen.value = false;
  const key = `${emp.id}_${date}`;
  const prev = scheduleMap.value.get(key) ?? null;
  const next = shiftId ?? null;
  if (prev === next) {
    return;
  }
  scheduleMap.value.set(key, next);
  await saveCell(emp.id, date, next, prev);
}
// ========== 加载 ==========
@@ -621,52 +699,6 @@
  await loadEmployeePage();
}
// ========== 保存(整月全量) ==========
async function handleSave() {
  if (!month.value || !employees.value.length) {
    return;
  }
  // 月份/部门改过但没有重新查询时,页面上的空格不代表「休息」,保存会把已有排班清掉
  if (loadedMonth.value !== month.value || loadedDeptId.value !== deptId.value) {
    message.warning(
      '月份或部门已变更,请先点击「查询排班」加载最新数据后再保存,避免误清已有排班',
    );
    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();
@@ -712,14 +744,7 @@
      <Button type="primary" ghost :disabled="!month" @click="openBatch">
        批量排班(按人群)
      </Button>
      <Button
        type="primary"
        :loading="saving"
        :disabled="!employees.length"
        @click="handleSave"
      >
        保存排班
      </Button>
      <span class="text-sm text-gray-400">修改格子后自动保存</span>
      <Segmented
        v-model:value="viewMode"
        :options="[
@@ -861,6 +886,7 @@
                vd.isWeekend ? 'bg-red-50/40' : '',
                holidayClass(vd.date),
                getLeaveByCell(emp.id!, vd.date) ? 'leave-cell' : '',
                isCellSaving(emp.id!, vd.date) ? 'cell-saving' : '',
              ]"
              @click="openPicker(emp, vd.date)"
            >
@@ -972,11 +998,17 @@
            checkedEmployeeIds.size > 0
              ? '(仅勾选的员工)'
              : '(未勾选时为当前页全部员工)'
          }},生成后可再手动调整。请假日(审批通过)会自动跳过、不覆盖。
          }},生成后立即保存。请假日(审批通过)会自动跳过、不覆盖。
        </div>
        <div class="flex justify-end gap-2">
          <Button @click="autoOpen = false">取消</Button>
          <Button type="primary" @click="applyAutoSchedule">生成排班</Button>
          <Button
            type="primary"
            :loading="autoSaving"
            @click="applyAutoSchedule"
          >
            生成并保存
          </Button>
        </div>
      </div>
    </Modal>
@@ -1129,6 +1161,12 @@
    }
  }
  /* 单元格保存中:半透明,提示正在落库 */
  .cell-saving {
    cursor: progress;
    opacity: 0.45;
  }
  /* 节假日标记:休息日橙色、调休工作日绿色,优先于周末底色 */
  .holiday-rest {
    background-color: #fff1e6 !important;