优化排班功能
This commit is contained in:
1 parent
8acbe732ff
commit
8ba15d87b3
13 files changed
+2380
-482
No files matched your search
@@ -34,6 +34,12 @@ export function getScheduleBoard(params) {
|
|||||||
return request({ url: '/backend/oa/scheduling/board', method: 'get', params })
|
return request({ url: '/backend/oa/scheduling/board', method: 'get', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量按格子编辑排班:cells 为排班板多选的「员工 × 日期」任意组合
|
||||||
|
// shift_id>0 设为该班次(有排班=调班,无排班=新增);shift_id=0 清除(休息/请假)
|
||||||
|
export function saveScheduleEntryCells(data) {
|
||||||
|
return request({ url: '/backend/oa/scheduling/entries/save-cells', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------- 排班模板 ----------------------------- */
|
/* ----------------------------- 排班模板 ----------------------------- */
|
||||||
|
|
||||||
// 平台预置 + 自定义模板(只读);租户调用后规则会复制到自己的方案
|
// 平台预置 + 自定义模板(只读);租户调用后规则会复制到自己的方案
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="批量编辑所选格子"
|
||||||
|
width="420px"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<div class="batch-summary">
|
||||||
|
已选 <b>{{ cells.length }}</b> 个格子(<b>{{ employeeCount }}</b> 人 · <b>{{ dateCount }}</b> 天),
|
||||||
|
选了哪些格子就改哪些格子。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form label-width="82px" @submit.prevent>
|
||||||
|
<el-form-item label="操作">
|
||||||
|
<el-radio-group v-model="action">
|
||||||
|
<el-radio value="shift">设置班次(上班/调班)</el-radio>
|
||||||
|
<el-radio value="clear">清除排班(休息/请假)</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="action === 'shift'" label="班次">
|
||||||
|
<el-select v-model="shiftId" placeholder="选择班次" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="item in shifts"
|
||||||
|
:key="item.id"
|
||||||
|
:value="item.id"
|
||||||
|
:label="optionLabel(item)"
|
||||||
|
>
|
||||||
|
<span class="shift-option">
|
||||||
|
<span class="shift-dot" :style="{ background: item.color }" />
|
||||||
|
<span>{{ optionLabel(item) }}</span>
|
||||||
|
</span>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
<div v-if="shiftHint" class="shift-hint">{{ shiftHint }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="action === 'shift'" label="类型">
|
||||||
|
<el-radio-group v-model="source">
|
||||||
|
<el-radio value="temp">临时调整(显示「临」标记)</el-radio>
|
||||||
|
<el-radio value="normal">正常排班(不显示标记)</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input
|
||||||
|
v-model="note"
|
||||||
|
:placeholder="action === 'clear' ? '如:请假、休息' : '如:加班支援(可选)'"
|
||||||
|
maxlength="60"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||||
|
{{ action === 'clear' ? '清除所选' : '应用到所选' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getShiftList, saveScheduleEntryCells } from '@/api/oaScheduling'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: Boolean,
|
||||||
|
// 排班板多选的格子:[{ employee_id, employee_name, date }]
|
||||||
|
cells: { type: Array, default: () => [] }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'changed'])
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const action = ref('shift')
|
||||||
|
const shiftId = ref(null)
|
||||||
|
const source = ref('temp')
|
||||||
|
const note = ref('')
|
||||||
|
const shifts = ref([])
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const employeeCount = computed(() => new Set(props.cells.map((item) => item.employee_id)).size)
|
||||||
|
const dateCount = computed(() => new Set(props.cells.map((item) => item.date)).size)
|
||||||
|
|
||||||
|
function optionLabel(item) {
|
||||||
|
const remark = item.remark ? ` · ${item.remark}` : ''
|
||||||
|
return `${item.name}(${item.work_start.slice(0, 5)}-${item.work_end.slice(0, 5)}${remark})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftHint = computed(() => {
|
||||||
|
const found = shifts.value.find((item) => item.id === shiftId.value)
|
||||||
|
return found ? `${found.name}:${found.work_start.slice(0, 5)} - ${found.work_end.slice(0, 5)}` : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleOpen() {
|
||||||
|
action.value = 'shift'
|
||||||
|
shiftId.value = null
|
||||||
|
source.value = 'temp'
|
||||||
|
note.value = ''
|
||||||
|
if (!shifts.value.length) {
|
||||||
|
const res = await getShiftList()
|
||||||
|
shifts.value = res.data?.list || []
|
||||||
|
}
|
||||||
|
if (shifts.value.length && !shiftId.value) {
|
||||||
|
shiftId.value = shifts.value[0].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!props.cells.length) {
|
||||||
|
ElMessage.warning('没有选中的格子')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.value === 'shift' && !shiftId.value) {
|
||||||
|
ElMessage.warning('请选择班次')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await saveScheduleEntryCells({
|
||||||
|
cells: props.cells.map((item) => ({ employee_id: item.employee_id, schedule_date: item.date })),
|
||||||
|
shift_id: action.value === 'shift' ? shiftId.value : 0,
|
||||||
|
source: source.value,
|
||||||
|
note: note.value
|
||||||
|
})
|
||||||
|
const stats = res.data || {}
|
||||||
|
const parts = []
|
||||||
|
if (stats.created) parts.push(`新增 ${stats.created}`)
|
||||||
|
if (stats.updated) parts.push(`调整 ${stats.updated}`)
|
||||||
|
if (stats.cleared) parts.push(`清除 ${stats.cleared}`)
|
||||||
|
if (stats.skipped) parts.push(`跳过 ${stats.skipped}`)
|
||||||
|
ElMessage.success(`批量编辑完成:${parts.join('、') || '无变更'}`)
|
||||||
|
visible.value = false
|
||||||
|
emit('changed')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error.message || '批量编辑失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.batch-summary {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 18px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -58,17 +58,23 @@
|
|||||||
/>
|
/>
|
||||||
<el-tree-select
|
<el-tree-select
|
||||||
v-model="filters.org_id"
|
v-model="filters.org_id"
|
||||||
:data="orgTree"
|
:data="orgFilterTree"
|
||||||
:props="{ label: 'org_name', value: 'id', children: 'children' }"
|
:props="{ label: 'org_name', value: 'id', children: 'children' }"
|
||||||
node-key="id"
|
node-key="id"
|
||||||
check-strictly
|
check-strictly
|
||||||
|
:render-after-expand="false"
|
||||||
|
default-expand-all
|
||||||
filterable
|
filterable
|
||||||
clearable
|
:clearable="false"
|
||||||
placeholder="全部组织"
|
placeholder="全部"
|
||||||
style="width: 200px"
|
style="width: 200px"
|
||||||
@change="loadBoard"
|
@change="loadBoard"
|
||||||
/>
|
/>
|
||||||
<el-checkbox v-model="filters.include_child" @change="loadBoard">含下级组织</el-checkbox>
|
<el-checkbox
|
||||||
|
v-model="filters.include_child"
|
||||||
|
:disabled="!filters.org_id"
|
||||||
|
@change="loadBoard"
|
||||||
|
>含下级组织</el-checkbox>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
placeholder="搜索员工姓名 / 账号"
|
placeholder="搜索员工姓名 / 账号"
|
||||||
@@ -78,6 +84,13 @@
|
|||||||
/>
|
/>
|
||||||
<el-checkbox v-model="filters.only_scheduled">只看有排班</el-checkbox>
|
<el-checkbox v-model="filters.only_scheduled">只看有排班</el-checkbox>
|
||||||
<el-button :icon="Refresh" @click="loadBoard">刷新</el-button>
|
<el-button :icon="Refresh" @click="loadBoard">刷新</el-button>
|
||||||
|
<el-button
|
||||||
|
:type="batchMode ? 'warning' : 'default'"
|
||||||
|
@click="toggleBatchMode"
|
||||||
|
>{{ batchMode ? "退出批量" : "批量编辑" }}</el-button>
|
||||||
|
<el-button :icon="Download" :disabled="!displayEmployees.length" @click="exportBoard">
|
||||||
|
导出{{ exportCount ? ` ${exportCount} 人` : "全部" }}
|
||||||
|
</el-button>
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span v-for="shift in shifts" :key="shift.id" class="legend-item">
|
<span v-for="shift in shifts" :key="shift.id" class="legend-item">
|
||||||
<span class="legend-dot" :style="{ background: shift.color }" />{{ shift.short_name || shift.name }}
|
<span class="legend-dot" :style="{ background: shift.color }" />{{ shift.short_name || shift.name }}
|
||||||
@@ -85,41 +98,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="board-wrap" :class="{ 'is-month': isMonth }" v-loading="loading">
|
<!-- 批量选择操作条 -->
|
||||||
|
<div v-if="batchMode" class="batch-bar">
|
||||||
|
<span class="batch-tip">按住拖动连续选中,单击切换选中(员工 × 日期格子)</span>
|
||||||
|
<span v-if="selectedCount" class="batch-count">
|
||||||
|
已选 <b>{{ selectedCount }}</b> 格 · <b>{{ selectedEmployees.length }}</b> 人 · <b>{{ selectedDates.length }}</b> 天
|
||||||
|
</span>
|
||||||
|
<span v-else class="batch-count muted">尚未选择</span>
|
||||||
|
<el-button size="small" type="primary" :disabled="!selectedCount" @click="openBatchEntry">批量编辑所选</el-button>
|
||||||
|
<el-button size="small" :disabled="!selectedCount" @click="clearBatchSelection">清空选择</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="board-wrap" :class="{ 'is-month': isMonth, 'batch-mode': batchMode }" v-loading="loading">
|
||||||
<table v-if="displayEmployees.length" class="board-table">
|
<table v-if="displayEmployees.length" class="board-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sticky-col">员工</th>
|
<th class="sticky-col check-col">
|
||||||
|
<label class="export-all" title="全选 / 清空;导出时不勾选默认导出全部">
|
||||||
|
<input type="checkbox" :checked="exportAllChecked" @change="toggleExportAll" />
|
||||||
|
</label>
|
||||||
|
</th>
|
||||||
|
<th class="sticky-col emp-col">员工</th>
|
||||||
<th
|
<th
|
||||||
v-for="day in dateCells"
|
v-for="day in dateCells"
|
||||||
:key="day.date"
|
:key="day.date"
|
||||||
:class="{
|
:class="{
|
||||||
'is-today': day.is_today,
|
'is-today': day.is_today,
|
||||||
'is-holiday': holidayOf(day.date)?.type === 1,
|
'is-holiday-off': holidayOf(day.date)?.type === 1,
|
||||||
'is-makeup': holidayOf(day.date)?.type === 0
|
'is-holiday-work': holidayOf(day.date)?.type === 0,
|
||||||
|
'is-weekend': isWeekendDay(day) && !holidayOf(day.date)
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="date-main">{{ day.date.slice(5) }}</div>
|
<div class="date-main">{{ day.date.slice(5) }}</div>
|
||||||
<div class="date-sub">{{ weekdayLabel(day.weekday) }}</div>
|
<div class="date-sub">{{ weekdayLabel(day.weekday) }}</div>
|
||||||
<div
|
<div
|
||||||
v-if="holidayOf(day.date)"
|
v-if="cellDayLabel(day)"
|
||||||
class="date-holiday"
|
class="date-holiday"
|
||||||
:title="holidayOf(day.date).name"
|
:title="cellDayLabel(day).name"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="holiday-tag"
|
class="holiday-tag"
|
||||||
:class="holidayOf(day.date).type === 1 ? 'is-off' : 'is-work'"
|
:class="cellDayLabel(day).cls"
|
||||||
>
|
>
|
||||||
{{ holidayOf(day.date).type === 1 ? "休" : "班" }}
|
{{ cellDayLabel(day).tag }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!isMonth" class="holiday-name">{{ holidayOf(day.date).name }}</span>
|
<span v-if="!isMonth" class="holiday-name">{{ cellDayLabel(day).name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="employee in displayEmployees" :key="employee.id">
|
<tr v-for="employee in displayEmployees" :key="employee.id">
|
||||||
<td class="sticky-col employee-cell">
|
<td class="sticky-col check-col">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="emp-check"
|
||||||
|
:checked="!!exportSel[employee.id]"
|
||||||
|
@click.stop
|
||||||
|
@change="exportSel[employee.id] = $event.target.checked"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="sticky-col emp-col employee-cell">
|
||||||
<div class="emp-name">{{ employee.name }}</div>
|
<div class="emp-name">{{ employee.name }}</div>
|
||||||
<div class="emp-meta">{{ employee.org_name || "未分配部门" }}</div>
|
<div class="emp-meta">{{ employee.org_name || "未分配部门" }}</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -127,13 +166,21 @@
|
|||||||
v-for="day in dateCells"
|
v-for="day in dateCells"
|
||||||
:key="day.date"
|
:key="day.date"
|
||||||
class="shift-cell"
|
class="shift-cell"
|
||||||
:class="{ 'is-today': day.is_today }"
|
:class="{
|
||||||
@click="openCell(employee, day.date)"
|
'is-today': day.is_today,
|
||||||
|
'is-holiday-off': holidayOf(day.date)?.type === 1,
|
||||||
|
'is-holiday-work': holidayOf(day.date)?.type === 0,
|
||||||
|
'is-weekend': isWeekendDay(day) && !holidayOf(day.date),
|
||||||
|
'is-selected': batchMode && selection[`${employee.id}|${day.date}`]
|
||||||
|
}"
|
||||||
|
@mousedown="batchMode && batchMouseDown($event)"
|
||||||
|
@mouseover="batchMode && batchMouseOver(employee.id, day.date)"
|
||||||
|
@click="batchMode ? batchClick(employee.id, day.date) : openCell(employee, day.date)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="employee.days[day.date]"
|
v-if="employee.days[day.date]"
|
||||||
class="shift-block"
|
class="shift-block"
|
||||||
:style="shiftStyle(employee.days[day.date])"
|
:style="shiftStyle(employee.days[day.date], day.is_today)"
|
||||||
:title="cellTitle(employee.days[day.date])"
|
:title="cellTitle(employee.days[day.date])"
|
||||||
>
|
>
|
||||||
<span v-if="employee.days[day.date].source === 2" class="temp-flag">临</span>
|
<span v-if="employee.days[day.date].source === 2" class="temp-flag">临</span>
|
||||||
@@ -155,14 +202,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<shift-dialog v-model="shiftVisible" @changed="loadBoard" />
|
<shift-dialog v-model="shiftVisible" @changed="handleShiftChanged" />
|
||||||
<plan-dialog v-model="planVisible" @changed="loadBoard" />
|
<plan-dialog
|
||||||
|
ref="planDialogRef"
|
||||||
|
v-model="planVisible"
|
||||||
|
@changed="loadBoard"
|
||||||
|
@open-shifts="openShiftDialog"
|
||||||
|
/>
|
||||||
<entry-dialog
|
<entry-dialog
|
||||||
v-model="entryVisible"
|
v-model="entryVisible"
|
||||||
:preset-employee-ids="entryPreset.employeeIds"
|
:preset-employee-ids="entryPreset.employeeIds"
|
||||||
:preset-date="entryPreset.date"
|
:preset-date="entryPreset.date"
|
||||||
@changed="handleEntryChanged"
|
@changed="handleEntryChanged"
|
||||||
/>
|
/>
|
||||||
|
<batch-dialog
|
||||||
|
v-model="batchVisible"
|
||||||
|
:cells="batchCells"
|
||||||
|
@changed="handleBatchChanged"
|
||||||
|
/>
|
||||||
<swap-dialog
|
<swap-dialog
|
||||||
v-model="swapVisible"
|
v-model="swapVisible"
|
||||||
:entry="selectedCell.entry"
|
:entry="selectedCell.entry"
|
||||||
@@ -199,8 +256,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { ArrowLeft, ArrowRight, Clock, Plus, Refresh, Right, Search, Setting, Tickets } from '@element-plus/icons-vue'
|
import { ArrowLeft, ArrowRight, Clock, Download, Plus, Refresh, Right, Search, Setting, Tickets } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { oaOrganizationApi } from '@/api/organization'
|
import { oaOrganizationApi } from '@/api/organization'
|
||||||
import { buildOrgTree } from '@/views/apps/organization/composables'
|
import { buildOrgTree } from '@/views/apps/organization/composables'
|
||||||
@@ -210,6 +267,7 @@ import ShiftDialog from './components/shiftDialog.vue'
|
|||||||
import PlanDialog from './components/planDialog.vue'
|
import PlanDialog from './components/planDialog.vue'
|
||||||
import EntryDialog from './components/entryDialog.vue'
|
import EntryDialog from './components/entryDialog.vue'
|
||||||
import SwapDialog from './components/swapDialog.vue'
|
import SwapDialog from './components/swapDialog.vue'
|
||||||
|
import BatchDialog from './components/batchDialog.vue'
|
||||||
|
|
||||||
const WEEK_LABELS = { 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日' }
|
const WEEK_LABELS = { 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日' }
|
||||||
|
|
||||||
@@ -244,9 +302,14 @@ const truncated = ref(false)
|
|||||||
const board = ref({ employees: [], dates: [], summary: {}, shifts: [] })
|
const board = ref({ employees: [], dates: [], summary: {}, shifts: [] })
|
||||||
const shifts = ref([])
|
const shifts = ref([])
|
||||||
const orgTree = ref([])
|
const orgTree = ref([])
|
||||||
const filters = reactive({ org_id: null, include_child: true, keyword: '', only_scheduled: false })
|
// 组织筛选树:「全部」根节点 + 完整组织架构,默认显示全部
|
||||||
|
const orgFilterTree = computed(() => [
|
||||||
|
{ id: 0, org_name: '全部', children: orgTree.value }
|
||||||
|
])
|
||||||
|
const filters = reactive({ org_id: 0, include_child: true, keyword: '', only_scheduled: false })
|
||||||
|
|
||||||
const shiftVisible = ref(false)
|
const shiftVisible = ref(false)
|
||||||
|
const planDialogRef = ref(null)
|
||||||
const planVisible = ref(false)
|
const planVisible = ref(false)
|
||||||
const entryVisible = ref(false)
|
const entryVisible = ref(false)
|
||||||
const swapVisible = ref(false)
|
const swapVisible = ref(false)
|
||||||
@@ -256,6 +319,136 @@ const changesLoading = ref(false)
|
|||||||
const entryPreset = reactive({ employeeIds: [], date: '' })
|
const entryPreset = reactive({ employeeIds: [], date: '' })
|
||||||
const selectedCell = reactive({ entry: null, employeeId: 0, employeeName: '', scheduleDate: '' })
|
const selectedCell = reactive({ entry: null, employeeId: 0, employeeName: '', scheduleDate: '' })
|
||||||
|
|
||||||
|
// ===== 批量编辑:员工 × 日期格子的多选 =====
|
||||||
|
const batchMode = ref(false)
|
||||||
|
const selection = reactive({})
|
||||||
|
const batchDragging = ref(false)
|
||||||
|
const batchDragMoved = ref(false)
|
||||||
|
const batchVisible = ref(false)
|
||||||
|
|
||||||
|
const selectedKeys = computed(() => Object.keys(selection).filter((key) => selection[key]))
|
||||||
|
const selectedCount = computed(() => selectedKeys.value.length)
|
||||||
|
const selectedEmployees = computed(() => [...new Set(selectedKeys.value.map((key) => Number(key.split('|')[0])))])
|
||||||
|
const selectedDates = computed(() => [...new Set(selectedKeys.value.map((key) => key.split('|')[1]))].sort())
|
||||||
|
|
||||||
|
function toggleBatchMode() {
|
||||||
|
batchMode.value = !batchMode.value
|
||||||
|
if (batchMode.value) {
|
||||||
|
ElMessage.info('批量模式:按住拖动连续选中格子,单击切换选中,选完后点「批量编辑所选」')
|
||||||
|
} else {
|
||||||
|
clearBatchSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBatchSelection() {
|
||||||
|
Object.keys(selection).forEach((key) => {
|
||||||
|
delete selection[key]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 导出:员工行勾选(不勾选 = 导出全部)=====
|
||||||
|
const exportSel = reactive({})
|
||||||
|
const exportCount = computed(() => displayEmployees.value.filter((e) => exportSel[e.id]).length)
|
||||||
|
const exportAllChecked = computed(
|
||||||
|
() => displayEmployees.value.length > 0 && exportCount.value === displayEmployees.value.length
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleExportAll() {
|
||||||
|
const all = displayEmployees.value
|
||||||
|
if (exportAllChecked.value) {
|
||||||
|
all.forEach((e) => {
|
||||||
|
delete exportSel[e.id]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
all.forEach((e) => {
|
||||||
|
exportSel[e.id] = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportBoard() {
|
||||||
|
const all = displayEmployees.value
|
||||||
|
if (!all.length) {
|
||||||
|
ElMessage.warning('当前没有可导出的排班数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = exportCount.value ? all.filter((e) => exportSel[e.id]) : all
|
||||||
|
const days = dateCells.value
|
||||||
|
if (!days.length) {
|
||||||
|
ElMessage.warning('当前没有日期范围')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const XLSX = await import('xlsx')
|
||||||
|
const header = [
|
||||||
|
'员工',
|
||||||
|
'部门',
|
||||||
|
...days.map((d) => `${d.date.slice(5).replace('-', '/')} ${weekdayLabel(d.weekday)}`)
|
||||||
|
]
|
||||||
|
const rows = list.map((emp) => [
|
||||||
|
emp.name,
|
||||||
|
emp.org_name || '',
|
||||||
|
...days.map((d) => {
|
||||||
|
const entry = emp.days[d.date]
|
||||||
|
if (!entry) return ''
|
||||||
|
const name = entry.short_name || entry.shift_name || ''
|
||||||
|
return entry.source === 2 ? `${name}(临)` : name
|
||||||
|
})
|
||||||
|
])
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([header, ...rows])
|
||||||
|
ws['!cols'] = [{ wch: 10 }, { wch: 18 }, ...days.map(() => ({ wch: 8 }))]
|
||||||
|
const wb = XLSX.utils.book_new()
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, '排班表')
|
||||||
|
const range = `${days[0].date}_${days[days.length - 1].date}`
|
||||||
|
XLSX.writeFile(wb, `排班表_${range}.xlsx`)
|
||||||
|
ElMessage.success(`已导出 ${list.length} 名员工、${days.length} 天的排班`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchMouseDown(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
batchDragging.value = true
|
||||||
|
batchDragMoved.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchMouseOver(employeeId, date) {
|
||||||
|
if (!batchDragging.value) return
|
||||||
|
batchDragMoved.value = true
|
||||||
|
selection[`${employeeId}|${date}`] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchClick(employeeId, date) {
|
||||||
|
// 拖动结束的 click 不做切换,只有真正的单击才切换选中
|
||||||
|
if (batchDragMoved.value) return
|
||||||
|
const key = `${employeeId}|${date}`
|
||||||
|
if (selection[key]) delete selection[key]
|
||||||
|
else selection[key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowMouseUp() {
|
||||||
|
batchDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中的格子集合:[{ employee_id, employee_name, date }]
|
||||||
|
const batchCells = computed(() => {
|
||||||
|
const names = {}
|
||||||
|
board.value.employees.forEach((item) => {
|
||||||
|
names[item.id] = item.name
|
||||||
|
})
|
||||||
|
return selectedKeys.value.map((key) => {
|
||||||
|
const [id, date] = key.split('|')
|
||||||
|
return { employee_id: Number(id), employee_name: names[Number(id)] || '', date }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function openBatchEntry() {
|
||||||
|
if (!selectedCount.value) return
|
||||||
|
batchVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchChanged() {
|
||||||
|
clearBatchSelection()
|
||||||
|
loadBoard()
|
||||||
|
}
|
||||||
|
|
||||||
const summary = computed(() => board.value.summary || {})
|
const summary = computed(() => board.value.summary || {})
|
||||||
const dateCells = computed(() => board.value.dates || [])
|
const dateCells = computed(() => board.value.dates || [])
|
||||||
const rangeText = computed(() => {
|
const rangeText = computed(() => {
|
||||||
@@ -280,21 +473,27 @@ function cellTitle(entry) {
|
|||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftStyle(entry) {
|
function shiftStyle(entry, isToday) {
|
||||||
|
// 按当前班次定义统一取色,避免不同时期生成的记录颜色快照不一致
|
||||||
|
const def = shifts.value.find((s) => s.id === entry.shift_id)
|
||||||
|
const color = (def && def.color) || entry.color || '#409EFF'
|
||||||
return {
|
return {
|
||||||
borderLeftColor: entry.color || '#409EFF',
|
borderLeftColor: color,
|
||||||
background: hexToRgba(entry.color || '#409EFF', 0.1),
|
// 不透明预混色:今天列混绿底,其余混白底,避免半透明叠加周末/节假日底色混色
|
||||||
|
background: mixColor(color, 0.1, isToday ? '#e4f5d2' : '#ffffff'),
|
||||||
color: '#303133'
|
color: '#303133'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgba(hex, alpha) {
|
// hex 颜色按比例混入基色,返回不透明色
|
||||||
const value = String(hex || '').replace('#', '')
|
function mixColor(hex, ratio, base) {
|
||||||
if (value.length !== 6) return `rgba(64, 158, 255, ${alpha})`
|
const v = String(hex || '').replace('#', '')
|
||||||
const r = parseInt(value.slice(0, 2), 16)
|
const b = String(base || '#ffffff').replace('#', '')
|
||||||
const g = parseInt(value.slice(2, 4), 16)
|
if (v.length !== 6 || b.length !== 6) return '#ecf5ff'
|
||||||
const b = parseInt(value.slice(4, 6), 16)
|
const ch = (s, i) => parseInt(s.slice(i, i + 2), 16)
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
const mix = (i) => Math.round(ratio * ch(v, i) + (1 - ratio) * ch(b, i))
|
||||||
|
const to = (n) => n.toString(16).padStart(2, '0')
|
||||||
|
return `#${to(mix(0))}${to(mix(2))}${to(mix(4))}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrgTree() {
|
async function loadOrgTree() {
|
||||||
@@ -354,6 +553,26 @@ function holidayOf(date) {
|
|||||||
return holidayMap.value[date] || null
|
return holidayMap.value[date] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWeekendDay(day) {
|
||||||
|
// 法定节假日/调休优先(holidayMap 已覆盖),未标记时按周六=6、周日=7
|
||||||
|
if (!day) return false
|
||||||
|
return day.weekday === 6 || day.weekday === 7
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellHolidayType(date) {
|
||||||
|
// 返回 1=法定放假(休) 0=调休上班(班) null=无
|
||||||
|
const h = holidayOf(date)
|
||||||
|
return h ? Number(h.type) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellDayLabel(day) {
|
||||||
|
if (!day) return null
|
||||||
|
const h = holidayOf(day.date)
|
||||||
|
if (h) return h.type === 1 ? { tag: '休', cls: 'is-off', name: h.name } : { tag: '班', cls: 'is-work', name: h.name }
|
||||||
|
if (isWeekendDay(day)) return { tag: '休', cls: 'is-off-weekend', name: day.weekday === 6 ? '周六' : '周日' }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBoard() {
|
async function loadBoard() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -442,6 +661,17 @@ function handleEntryChanged() {
|
|||||||
loadBoard()
|
loadBoard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排班方案里提示「没有班次」时,直接打开班次管理弹窗
|
||||||
|
function openShiftDialog() {
|
||||||
|
shiftVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班次管理变更后,同步刷新方案弹窗的班次列表
|
||||||
|
function handleShiftChanged() {
|
||||||
|
loadBoard()
|
||||||
|
planDialogRef.value?.reloadShifts?.()
|
||||||
|
}
|
||||||
|
|
||||||
async function openChanges() {
|
async function openChanges() {
|
||||||
changesVisible.value = true
|
changesVisible.value = true
|
||||||
await loadChanges()
|
await loadChanges()
|
||||||
@@ -467,9 +697,14 @@ async function loadChanges() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('mouseup', onWindowMouseUp)
|
||||||
await Promise.all([loadOrgTree(), loadShifts()])
|
await Promise.all([loadOrgTree(), loadShifts()])
|
||||||
await loadBoard()
|
await loadBoard()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('mouseup', onWindowMouseUp)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
@@ -505,20 +740,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: 15px 18px;
|
padding: 15px 18px;
|
||||||
background: #fff;
|
background: #f7f8fa;
|
||||||
border: 1px solid #ebeef5;
|
|
||||||
border-top: 3px solid #409eff;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
&.purple {
|
|
||||||
border-top-color: #8b5cf6;
|
|
||||||
}
|
|
||||||
&.green {
|
|
||||||
border-top-color: #67c23a;
|
|
||||||
}
|
|
||||||
&.orange {
|
|
||||||
border-top-color: #e6a23c;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.stat-label,
|
.stat-label,
|
||||||
.stat-sub {
|
.stat-sub {
|
||||||
@@ -571,6 +794,37 @@ onMounted(async () => {
|
|||||||
max-height: 640px;
|
max-height: 640px;
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid #ebeef5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
|
// 批量模式:格子进入可选状态,禁止拖蓝
|
||||||
|
&.batch-mode .shift-cell {
|
||||||
|
cursor: cell;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.batch-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #fdf6ec;
|
||||||
|
border: 1px solid #f3d19e;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b88230;
|
||||||
|
|
||||||
|
.batch-tip {
|
||||||
|
color: #b88230;
|
||||||
|
}
|
||||||
|
.batch-count {
|
||||||
|
b {
|
||||||
|
color: #c05621;
|
||||||
|
}
|
||||||
|
&.muted {
|
||||||
|
color: #d0a96a;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.board-table {
|
.board-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -594,7 +848,10 @@ onMounted(async () => {
|
|||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
}
|
}
|
||||||
thead th.is-today {
|
thead th.is-today {
|
||||||
background: #ecf5ff;
|
background: #d9f0c7;
|
||||||
|
.date-main {
|
||||||
|
color: #3d8b1e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.date-main {
|
.date-main {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -605,10 +862,16 @@ onMounted(async () => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
// 法定假日表头:浅红底 + 「休」角标;调休上班日标「班」
|
// 法定假日表头:浅红底;调休上班:灰底;周末:淡橙底(周末节假日优先用节假日样式)
|
||||||
thead th.is-holiday:not(.is-today) {
|
thead th.is-holiday-off:not(.is-today) {
|
||||||
background: #fffafa;
|
background: #fffafa;
|
||||||
}
|
}
|
||||||
|
thead th.is-holiday-work:not(.is-today) {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
thead th.is-weekend:not(.is-today) {
|
||||||
|
background: #fdf6ec;
|
||||||
|
}
|
||||||
.date-holiday {
|
.date-holiday {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -639,6 +902,11 @@ onMounted(async () => {
|
|||||||
color: #909399;
|
color: #909399;
|
||||||
border: 1px solid #e9e9eb;
|
border: 1px solid #e9e9eb;
|
||||||
}
|
}
|
||||||
|
&.is-off-weekend {
|
||||||
|
background: #fdf6ec;
|
||||||
|
color: #e6a23c;
|
||||||
|
border: 1px solid #faecd8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.holiday-name {
|
.holiday-name {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -646,21 +914,55 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.is-off-weekend ~ &,
|
||||||
|
&.is-off-weekend-name {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sticky-col {
|
.sticky-col {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #ebeef5;
|
||||||
|
|
||||||
|
&.check-col {
|
||||||
|
left: 0;
|
||||||
|
width: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
padding: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.emp-check {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.emp-col {
|
||||||
|
left: 42px;
|
||||||
width: 150px;
|
width: 150px;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
background: #fff;
|
text-align: center;
|
||||||
text-align: left;
|
padding: 8px 10px;
|
||||||
padding: 8px 12px;
|
}
|
||||||
border-right: 1px solid #ebeef5;
|
|
||||||
}
|
}
|
||||||
thead .sticky-col {
|
thead .sticky-col {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
|
|
||||||
|
.export-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
input {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.employee-cell {
|
.employee-cell {
|
||||||
.emp-name {
|
.emp-name {
|
||||||
@@ -679,25 +981,42 @@ onMounted(async () => {
|
|||||||
min-width: 118px;
|
min-width: 118px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 0 3px;
|
||||||
|
|
||||||
&.is-today {
|
&.is-today {
|
||||||
background: #f7fbff;
|
background: #e4f5d2;
|
||||||
|
}
|
||||||
|
&.is-holiday-off:not(.is-today) {
|
||||||
|
background: #fffafa;
|
||||||
|
}
|
||||||
|
&.is-holiday-work:not(.is-today) {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
&.is-weekend:not(.is-today) {
|
||||||
|
background: #fdf6ec;
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
}
|
}
|
||||||
|
// 批量模式选中格
|
||||||
|
&.is-selected {
|
||||||
|
background: #ecf5ff;
|
||||||
|
outline: 2px solid #409eff;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.shift-block {
|
.shift-block {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 5px 7px;
|
padding: 2px 6px;
|
||||||
border-left: 3px solid #409eff;
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
.shift-title {
|
.shift-title {
|
||||||
@@ -726,7 +1045,10 @@ onMounted(async () => {
|
|||||||
.empty-cell {
|
.empty-cell {
|
||||||
color: #dcdfe6;
|
color: #dcdfe6;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 38px;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,22 +1060,21 @@ onMounted(async () => {
|
|||||||
thead th {
|
thead th {
|
||||||
padding: 6px 2px;
|
padding: 6px 2px;
|
||||||
}
|
}
|
||||||
.sticky-col {
|
.sticky-col.emp-col {
|
||||||
width: 130px;
|
width: 130px;
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
padding: 6px 10px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
.shift-cell {
|
.shift-cell {
|
||||||
width: 54px;
|
width: 54px;
|
||||||
min-width: 54px;
|
min-width: 54px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
padding: 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
.shift-block {
|
.shift-block {
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
padding: 3px 4px;
|
padding: 2px 4px;
|
||||||
min-height: 30px;
|
min-height: 100%;
|
||||||
border-left-width: 2px;
|
|
||||||
}
|
}
|
||||||
.shift-title {
|
.shift-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -818,8 +1139,7 @@ html.dark & {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--el-bg-color);
|
background: var(--el-fill-color-light);
|
||||||
border-color: var(--el-border-color-lighter);
|
|
||||||
|
|
||||||
.stat-label,
|
.stat-label,
|
||||||
.stat-sub {
|
.stat-sub {
|
||||||
@@ -846,11 +1166,17 @@ html.dark & {
|
|||||||
background: var(--el-fill-color-light);
|
background: var(--el-fill-color-light);
|
||||||
}
|
}
|
||||||
thead th.is-today {
|
thead th.is-today {
|
||||||
background: var(--el-color-primary-light-9);
|
background: rgba(103, 194, 58, 0.26);
|
||||||
}
|
}
|
||||||
thead th.is-holiday:not(.is-today) {
|
thead th.is-holiday-off:not(.is-today) {
|
||||||
background: rgba(245, 108, 108, 0.08);
|
background: rgba(245, 108, 108, 0.08);
|
||||||
}
|
}
|
||||||
|
thead th.is-holiday-work:not(.is-today) {
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
}
|
||||||
|
thead th.is-weekend:not(.is-today) {
|
||||||
|
background: rgba(230, 162, 60, 0.08);
|
||||||
|
}
|
||||||
.holiday-tag {
|
.holiday-tag {
|
||||||
&.is-off {
|
&.is-off {
|
||||||
background: rgba(245, 108, 108, 0.16);
|
background: rgba(245, 108, 108, 0.16);
|
||||||
@@ -862,6 +1188,11 @@ html.dark & {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
border-color: var(--el-border-color-lighter);
|
border-color: var(--el-border-color-lighter);
|
||||||
}
|
}
|
||||||
|
&.is-off-weekend {
|
||||||
|
background: rgba(230, 162, 60, 0.18);
|
||||||
|
color: #ebb563;
|
||||||
|
border-color: rgba(230, 162, 60, 0.35);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.holiday-name {
|
.holiday-name {
|
||||||
color: #f89898;
|
color: #f89898;
|
||||||
@@ -883,7 +1214,16 @@ html.dark & {
|
|||||||
}
|
}
|
||||||
.shift-cell {
|
.shift-cell {
|
||||||
&.is-today {
|
&.is-today {
|
||||||
background: var(--el-color-primary-light-9);
|
background: rgba(103, 194, 58, 0.16);
|
||||||
|
}
|
||||||
|
&.is-holiday-off:not(.is-today) {
|
||||||
|
background: rgba(245, 108, 108, 0.06);
|
||||||
|
}
|
||||||
|
&.is-holiday-work:not(.is-today) {
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
}
|
||||||
|
&.is-weekend:not(.is-today) {
|
||||||
|
background: rgba(230, 162, 60, 0.08);
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--el-fill-color-light);
|
background: var(--el-fill-color-light);
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="platform" label="平台名称" min-width="160">
|
<el-table-column prop="platform" label="平台名称" min-width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="platform-name-cell">
|
<div class="platform-name-cell">
|
||||||
<el-icon class="platform-icon"><Key /></el-icon>
|
<el-icon class="platform-icon"><Key /></el-icon>
|
||||||
|
|||||||
@@ -231,6 +231,60 @@ func scDateSpan(start, end string) ([]string, string) {
|
|||||||
return dates, ""
|
return dates, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scPlanNoEndDate 无期限方案的结束日期哨兵:数据库日期列不支持空串,
|
||||||
|
// 统一存 2099-12-31,前端按 no_end 标记展示为「无期限」。
|
||||||
|
const scPlanNoEndDate = "2099-12-31"
|
||||||
|
|
||||||
|
// scDateSpanUnbounded 展开任意长度的日期区间,供无期限方案的
|
||||||
|
// 「当年窗口」使用,不受 scDateSpan 的 92 天上限约束。
|
||||||
|
func scDateSpanUnbounded(start, end string) ([]string, string) {
|
||||||
|
startDate, ok := scParseDate(start)
|
||||||
|
if !ok {
|
||||||
|
return nil, "开始日期格式错误"
|
||||||
|
}
|
||||||
|
endDate, ok := scParseDate(end)
|
||||||
|
if !ok {
|
||||||
|
return nil, "结束日期格式错误"
|
||||||
|
}
|
||||||
|
if endDate.Before(startDate) {
|
||||||
|
return nil, "结束日期不能早于开始日期"
|
||||||
|
}
|
||||||
|
total := int(endDate.Sub(startDate).Hours()/24) + 1
|
||||||
|
dates := make([]string, 0, total)
|
||||||
|
for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) {
|
||||||
|
dates = append(dates, cursor.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
return dates, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// scIsNoEnd 判断结束日期是否表示无期限(空串或哨兵值)。
|
||||||
|
func scIsNoEnd(end string) bool {
|
||||||
|
return end == "" || end >= scPlanNoEndDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// scPlanGenWindow 计算一次预览 / 发布实际展开的日期窗口。
|
||||||
|
// 普通方案:[start, end] 原样展开;无期限方案:从 max(start, 今天) 生成到
|
||||||
|
// 当年 12 月 31 日,跨年不自动延续,新的一年需要重新排班。
|
||||||
|
func scPlanGenWindow(startText, endText string) ([]string, string) {
|
||||||
|
if !scIsNoEnd(endText) {
|
||||||
|
return scDateSpan(startText, endText)
|
||||||
|
}
|
||||||
|
startDate, ok := scParseDate(startText)
|
||||||
|
if !ok {
|
||||||
|
return nil, "开始日期格式错误"
|
||||||
|
}
|
||||||
|
windowStart := startDate
|
||||||
|
today := time.Now()
|
||||||
|
if today.After(startDate) {
|
||||||
|
windowStart = today
|
||||||
|
}
|
||||||
|
windowEnd := time.Date(today.Year(), 12, 31, 0, 0, 0, 0, today.Location())
|
||||||
|
if windowEnd.Before(windowStart) {
|
||||||
|
windowEnd = windowStart
|
||||||
|
}
|
||||||
|
return scDateSpanUnbounded(windowStart.Format("2006-01-02"), windowEnd.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
// scMinutes 把 "HH:mm" 转为当日分钟数。
|
// scMinutes 把 "HH:mm" 转为当日分钟数。
|
||||||
func scMinutes(value string) (int, bool) {
|
func scMinutes(value string) (int, bool) {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@@ -400,7 +400,11 @@ func (c *BackendOaSchedulingController) SaveEntries() {
|
|||||||
row.Note = note
|
row.Note = note
|
||||||
row.CreatedBy = uid
|
row.CreatedBy = uid
|
||||||
row.CreatedByName = username
|
row.CreatedByName = username
|
||||||
if _, updateErr := models.Orm.Update(row); updateErr != nil {
|
// 指定字段更新:DATE 列从库里读回 string 会带上
|
||||||
|
// "00:00:00 +0800 CST" 后缀,全字段 Update 会写回导致 MySQL 1292
|
||||||
|
if _, updateErr := models.Orm.Update(row,
|
||||||
|
"shift_id", "shift_name", "short_name", "color", "work_start", "work_end",
|
||||||
|
"source", "note", "created_by", "created_by_name"); updateErr != nil {
|
||||||
c.scFail(500, 500, "调整排班失败: "+updateErr.Error())
|
c.scFail(500, 500, "调整排班失败: "+updateErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -445,6 +449,205 @@ func (c *BackendOaSchedulingController) SaveEntries() {
|
|||||||
c.scOK(stats)
|
c.scOK(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveEntryCells 批量按格子编辑排班:cells 为任意「员工 × 日期」组合(排班板多选)。
|
||||||
|
// shift_id>0 时把这些格子设为该班次(已有排班视为调班,无排班则新增临时排班);
|
||||||
|
// shift_id=0 时清除这些格子的排班(休息 / 请假)。
|
||||||
|
// POST /backend/oa/scheduling/entries/save-cells
|
||||||
|
func (c *BackendOaSchedulingController) SaveEntryCells() {
|
||||||
|
tid, uid, username, ok := c.scOperator()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := c.scBody()
|
||||||
|
|
||||||
|
type scCellTarget struct {
|
||||||
|
employeeID uint
|
||||||
|
date string
|
||||||
|
}
|
||||||
|
rawCells, _ := body["cells"].([]interface{})
|
||||||
|
seen := map[string]bool{}
|
||||||
|
cells := make([]scCellTarget, 0, len(rawCells))
|
||||||
|
for _, raw := range rawCells {
|
||||||
|
item, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
employeeID := uint(scUintValue(item, "employee_id"))
|
||||||
|
date := normalizeOaDate(scStringValue(item, "schedule_date", "date"))
|
||||||
|
if employeeID == 0 || date == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("2006-01-02", date); err != nil {
|
||||||
|
c.scFail(400, 400, "存在无效的日期: "+date)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d|%s", employeeID, date)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
cells = append(cells, scCellTarget{employeeID: employeeID, date: date})
|
||||||
|
}
|
||||||
|
if len(cells) == 0 {
|
||||||
|
c.scFail(400, 400, "请先选择要编辑的格子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cells) > scPlanMaxEntries {
|
||||||
|
c.scFail(400, 400, fmt.Sprintf("单次最多编辑 %d 个格子", scPlanMaxEntries))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shiftID := scUintValue(body, "shift_id")
|
||||||
|
var shift *models.OaShift
|
||||||
|
if shiftID > 0 {
|
||||||
|
loaded, found := c.scLoadShift(tid, shiftID)
|
||||||
|
if !found {
|
||||||
|
c.scFail(400, 400, "班次不存在或已停用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shift = loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排班来源:normal=正常排班(不显示临时标记),temp=临时调整(默认,显示「临」角标)
|
||||||
|
entrySource := models.OaEntrySourceTemp
|
||||||
|
if scStringValue(body, "source") == "normal" {
|
||||||
|
entrySource = models.OaEntrySourcePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
empIDSet := map[uint]bool{}
|
||||||
|
empIDs := make([]uint64, 0, len(cells))
|
||||||
|
dates := make([]string, 0, len(cells))
|
||||||
|
for _, cell := range cells {
|
||||||
|
if !empIDSet[cell.employeeID] {
|
||||||
|
empIDSet[cell.employeeID] = true
|
||||||
|
empIDs = append(empIDs, uint64(cell.employeeID))
|
||||||
|
}
|
||||||
|
dates = append(dates, cell.date)
|
||||||
|
}
|
||||||
|
var employees []models.BackendEmployee
|
||||||
|
if _, err := c.scBaseEmployeeQuery(tid).Filter("id__in", empIDs).All(&employees); err != nil && err != orm.ErrNoRows {
|
||||||
|
c.scFail(500, 500, "查询员工失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
employeeMap := map[uint]*models.BackendEmployee{}
|
||||||
|
for i := range employees {
|
||||||
|
employeeMap[employees[i].ID] = &employees[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing []models.OaScheduleEntry
|
||||||
|
if _, err := c.scEntryQuery(tid).
|
||||||
|
Filter("employee_id__in", empIDs).
|
||||||
|
Filter("schedule_date__in", dates).
|
||||||
|
All(&existing); err != nil && err != orm.ErrNoRows {
|
||||||
|
c.scFail(500, 500, "查询已有排班失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingMap := map[string]*models.OaScheduleEntry{}
|
||||||
|
for i := range existing {
|
||||||
|
key := fmt.Sprintf("%d|%s", existing[i].EmployeeID, normalizeOaDate(existing[i].ScheduleDate))
|
||||||
|
existingMap[key] = &existing[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
index := c.scLoadOrgIndex(tid)
|
||||||
|
note := scStringValue(body, "note")
|
||||||
|
stats := map[string]int{"created": 0, "updated": 0, "cleared": 0, "skipped": 0}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, cell := range cells {
|
||||||
|
employee, found := employeeMap[cell.employeeID]
|
||||||
|
if !found {
|
||||||
|
stats["skipped"]++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d|%s", cell.employeeID, cell.date)
|
||||||
|
row := existingMap[key]
|
||||||
|
orgID := scEmployeeDepartmentID(*employee)
|
||||||
|
position := ""
|
||||||
|
if employee.Position != nil {
|
||||||
|
position = *employee.Position
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除排班(休息 / 请假):只处理已有排班的格子
|
||||||
|
if shift == nil {
|
||||||
|
if row == nil {
|
||||||
|
stats["skipped"]++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
|
||||||
|
row.IsDeleted = row.ID
|
||||||
|
row.DeleteTime = &now
|
||||||
|
if _, updateErr := models.Orm.Update(row, "is_deleted", "delete_time"); updateErr != nil {
|
||||||
|
c.scFail(500, 500, "清除排班失败: "+updateErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.scWriteChange(models.Orm, tid, row.ID, row.EmployeeID, row.EmployeeName, cell.date,
|
||||||
|
models.OaChangeClear, oldShiftID, oldShiftName, 0, "", note, uid, username)
|
||||||
|
stats["cleared"]++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已有排班:调整为目标班次(调班)
|
||||||
|
if row != nil {
|
||||||
|
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
|
||||||
|
row.ShiftID = shift.ID
|
||||||
|
row.ShiftName = shift.Name
|
||||||
|
row.ShortName = shift.ShortName
|
||||||
|
row.Color = shift.Color
|
||||||
|
row.WorkStart = shift.WorkStart
|
||||||
|
row.WorkEnd = shift.WorkEnd
|
||||||
|
row.Source = entrySource
|
||||||
|
row.Note = note
|
||||||
|
row.CreatedBy = uid
|
||||||
|
row.CreatedByName = username
|
||||||
|
// 指定字段更新:DATE 列从库里读回 string 会带上
|
||||||
|
// "00:00:00 +0800 CST" 后缀,全字段 Update 会写回导致 MySQL 1292
|
||||||
|
if _, updateErr := models.Orm.Update(row,
|
||||||
|
"shift_id", "shift_name", "short_name", "color", "work_start", "work_end",
|
||||||
|
"source", "note", "created_by", "created_by_name"); updateErr != nil {
|
||||||
|
c.scFail(500, 500, "调整排班失败: "+updateErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.scWriteChange(models.Orm, tid, row.ID, row.EmployeeID, row.EmployeeName, cell.date,
|
||||||
|
models.OaChangeShift, oldShiftID, oldShiftName, shift.ID, shift.Name, note, uid, username)
|
||||||
|
stats["updated"]++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无排班:新增临时排班(上班)
|
||||||
|
entry := &models.OaScheduleEntry{
|
||||||
|
Tid: int(tid),
|
||||||
|
EmployeeID: employee.ID,
|
||||||
|
EmployeeName: employee.Name,
|
||||||
|
EmployeeAccount: employee.Account,
|
||||||
|
OrgID: orgID,
|
||||||
|
OrgName: index.name(orgID),
|
||||||
|
Position: position,
|
||||||
|
ScheduleDate: cell.date,
|
||||||
|
ShiftID: shift.ID,
|
||||||
|
ShiftName: shift.Name,
|
||||||
|
ShortName: shift.ShortName,
|
||||||
|
Color: shift.Color,
|
||||||
|
WorkStart: shift.WorkStart,
|
||||||
|
WorkEnd: shift.WorkEnd,
|
||||||
|
Source: entrySource,
|
||||||
|
Note: note,
|
||||||
|
CreatedBy: uid,
|
||||||
|
CreatedByName: username,
|
||||||
|
}
|
||||||
|
if _, insertErr := models.Orm.Insert(entry); insertErr != nil {
|
||||||
|
c.scFail(500, 500, "新增排班失败: "+insertErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingMap[key] = entry
|
||||||
|
c.scWriteChange(models.Orm, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, cell.date,
|
||||||
|
models.OaChangeManual, 0, "", shift.ID, shift.Name, note, uid, username)
|
||||||
|
stats["created"]++
|
||||||
|
}
|
||||||
|
|
||||||
|
stats["cell_count"] = len(cells)
|
||||||
|
c.scOK(stats)
|
||||||
|
}
|
||||||
|
|
||||||
// SwapEntry 单条调班:把某个员工某天的班次换成另一个班次。
|
// SwapEntry 单条调班:把某个员工某天的班次换成另一个班次。
|
||||||
// POST /backend/oa/scheduling/entries/swap/:id
|
// POST /backend/oa/scheduling/entries/swap/:id
|
||||||
func (c *BackendOaSchedulingController) SwapEntry() {
|
func (c *BackendOaSchedulingController) SwapEntry() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
|
"server/pkg/holiday"
|
||||||
)
|
)
|
||||||
|
|
||||||
// scPatternDay 排班方案周期规则的每日快照。
|
// scPatternDay 排班方案周期规则的每日快照。
|
||||||
@@ -212,6 +213,12 @@ func scPlanWorkRules(plan *models.OaSchedulePlan, fallback *models.OaShift, date
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 周期类方案:构造基准周期之外的顺延上下文(节假日 / 调休修正 + 星期节奏)
|
||||||
|
var rollExt *scRollExt
|
||||||
|
if plan.AlignMode == models.SchedTplAlignRoll || plan.AlignMode == models.SchedTplAlignWeek {
|
||||||
|
rollExt = scBuildRollExt(pattern, start, dates)
|
||||||
|
}
|
||||||
|
|
||||||
result := make(map[string]scPatternDay, len(dates))
|
result := make(map[string]scPatternDay, len(dates))
|
||||||
workdays := make([]string, 0, len(dates))
|
workdays := make([]string, 0, len(dates))
|
||||||
for _, date := range dates {
|
for _, date := range dates {
|
||||||
@@ -219,7 +226,7 @@ func scPlanWorkRules(plan *models.OaSchedulePlan, fallback *models.OaShift, date
|
|||||||
if !valid {
|
if !valid {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rule, work := scRuleForDate(plan, rules, fallback, start, day, weekdays)
|
rule, work := scRuleForDate(plan, rules, fallback, start, day, weekdays, rollExt)
|
||||||
if !work {
|
if !work {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -229,9 +236,109 @@ func scPlanWorkRules(plan *models.OaSchedulePlan, fallback *models.OaShift, date
|
|||||||
return result, workdays, ""
|
return result, workdays, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scRollExt 周期滚动方案(AlignMode=2,按月涂排)在基准周期之外的顺延上下文。
|
||||||
|
//
|
||||||
|
// 涂刷基准月里的节假日安排属于「基准月的特殊规则」,不应该跟着日序号平移到
|
||||||
|
// 后续月份——否则 10 月的国庆假期会错位落到 11 月的普通工作日上。周期之外的
|
||||||
|
// 推算规则:
|
||||||
|
// 1. 目标日期是法定节假日 → 休息;
|
||||||
|
// 2. 目标日期是调休上班日 → 上班(取涂刷里的班次);
|
||||||
|
// 3. 该星期在涂刷周期内呈现严格 休/班 交替(如大小周的周六)→ 按周序奇偶交替顺延;
|
||||||
|
// 4. 其余按涂刷月的星期节奏顺延(同星期几在周期内的多数状态)。
|
||||||
|
type scRollExt struct {
|
||||||
|
weekdayRules [8]scPatternDay // 下标 1-7(周一~周日):该星期的顺延规则
|
||||||
|
altRules [8]bool // 该星期是否隔周交替(严格 休/班 交替)
|
||||||
|
altEven [8]scPatternDay // 交替星期:周序为偶数时的规则
|
||||||
|
altOdd [8]scPatternDay // 交替星期:周序为奇数时的规则
|
||||||
|
defaultWork scPatternDay // 涂刷里的首个上班规则,供调休上班日使用
|
||||||
|
holidays map[string]int8 // 目标日期 → holiday.Type(0 调休上班 / 1 法定放假)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scBuildRollExt 从涂刷周期构造顺延上下文。星期节奏取多数状态(work 数 - rest 数
|
||||||
|
// ≥ 0 视为上班,班次取该星期首个上班规则);若某星期在周期内严格 休/班 交替,
|
||||||
|
// 记录交替规则,顺延按周序奇偶保持交替(周序锚点 = start 所在周的周一)。
|
||||||
|
// 节假日数据按目标区间涉及的年份拉取,拉取失败只是少了节假日修正,不阻断发布。
|
||||||
|
func scBuildRollExt(pattern []scPatternDay, start time.Time, dates []string) *scRollExt {
|
||||||
|
ext := &scRollExt{holidays: map[string]int8{}}
|
||||||
|
workCount := [8]int{}
|
||||||
|
shiftBy := [8]scPatternDay{}
|
||||||
|
type weekSeqItem struct {
|
||||||
|
week int
|
||||||
|
work bool
|
||||||
|
item scPatternDay
|
||||||
|
}
|
||||||
|
byWeekday := [8][]weekSeqItem{}
|
||||||
|
startMonday := start.AddDate(0, 0, -scWeekdayOffset(start))
|
||||||
|
for _, item := range pattern {
|
||||||
|
day := start.AddDate(0, 0, item.DayIndex)
|
||||||
|
w := scWeekdayOffset(day) + 1
|
||||||
|
if item.DayType == models.SchedTplDayWork {
|
||||||
|
workCount[w]++
|
||||||
|
if shiftBy[w].ShiftID == 0 {
|
||||||
|
shiftBy[w] = item
|
||||||
|
}
|
||||||
|
if ext.defaultWork.ShiftID == 0 {
|
||||||
|
ext.defaultWork = item
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
workCount[w]--
|
||||||
|
}
|
||||||
|
weekSeq := int(day.AddDate(0, 0, -scWeekdayOffset(day)).Sub(startMonday).Hours()/24) / 7
|
||||||
|
byWeekday[w] = append(byWeekday[w], weekSeqItem{week: weekSeq, work: item.DayType == models.SchedTplDayWork, item: item})
|
||||||
|
}
|
||||||
|
for w := 1; w <= 7; w++ {
|
||||||
|
if workCount[w] >= 0 && shiftBy[w].ShiftID != 0 {
|
||||||
|
ext.weekdayRules[w] = shiftBy[w]
|
||||||
|
} else {
|
||||||
|
ext.weekdayRules[w] = scPatternDay{DayIndex: w - 1, DayType: models.SchedTplDayRest}
|
||||||
|
}
|
||||||
|
// 隔周交替检测:该星期在涂刷周期内严格 休/班 交替(如大小周的周六,
|
||||||
|
// 一周上班一周休息),顺延时按周序奇偶保持交替,而不是取多数
|
||||||
|
list := byWeekday[w]
|
||||||
|
if len(list) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alternating := true
|
||||||
|
for i := 1; i < len(list); i++ {
|
||||||
|
if list[i].work == list[i-1].work {
|
||||||
|
alternating = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !alternating {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext.altRules[w] = true
|
||||||
|
if list[0].week%2 == 0 {
|
||||||
|
ext.altEven[w] = list[0].item
|
||||||
|
ext.altOdd[w] = list[1].item
|
||||||
|
} else {
|
||||||
|
ext.altOdd[w] = list[0].item
|
||||||
|
ext.altEven[w] = list[1].item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
years := map[int]bool{}
|
||||||
|
for _, date := range dates {
|
||||||
|
if y := holidayYear(date); y > 0 {
|
||||||
|
years[y] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := range years {
|
||||||
|
items, _, err := holiday.FetchYear(y)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range items {
|
||||||
|
ext.holidays[d.Date] = d.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
// scRuleForDate 推算某一天命中的规则;第二个返回值表示当天是否上班。
|
// scRuleForDate 推算某一天命中的规则;第二个返回值表示当天是否上班。
|
||||||
|
// ext 是周期滚动方案(AlignMode=2)在基准周期之外的顺延上下文,可为 nil。
|
||||||
func scRuleForDate(plan *models.OaSchedulePlan, rules map[int]scPatternDay, fallback *models.OaShift,
|
func scRuleForDate(plan *models.OaSchedulePlan, rules map[int]scPatternDay, fallback *models.OaShift,
|
||||||
start, day time.Time, weekdays map[int]bool) (scPatternDay, bool) {
|
start, day time.Time, weekdays map[int]bool, ext *scRollExt) (scPatternDay, bool) {
|
||||||
switch plan.AlignMode {
|
switch plan.AlignMode {
|
||||||
case models.SchedTplAlignWeek:
|
case models.SchedTplAlignWeek:
|
||||||
weeks := plan.CycleDays / 7
|
weeks := plan.CycleDays / 7
|
||||||
@@ -241,6 +348,18 @@ func scRuleForDate(plan *models.OaSchedulePlan, rules map[int]scPatternDay, fall
|
|||||||
startMonday := start.AddDate(0, 0, -scWeekdayOffset(start))
|
startMonday := start.AddDate(0, 0, -scWeekdayOffset(start))
|
||||||
curMonday := day.AddDate(0, 0, -scWeekdayOffset(day))
|
curMonday := day.AddDate(0, 0, -scWeekdayOffset(day))
|
||||||
diffWeeks := int(curMonday.Sub(startMonday).Hours()/24) / 7
|
diffWeeks := int(curMonday.Sub(startMonday).Hours()/24) / 7
|
||||||
|
// 首个周期内原样回放;周期之外目标月的节假日 / 调休优先修正
|
||||||
|
//(大小周等模板按自然周循环,周序永远交替不串位)
|
||||||
|
if ext != nil && diffWeeks >= weeks {
|
||||||
|
if hType, ok := ext.holidays[day.Format("2006-01-02")]; ok {
|
||||||
|
if hType == holiday.TypeHoliday {
|
||||||
|
return scPatternDay{}, false
|
||||||
|
}
|
||||||
|
if hType == holiday.TypeWorkday && ext.defaultWork.ShiftID != 0 {
|
||||||
|
return ext.defaultWork, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
weekIndex := scMod(diffWeeks+int(plan.WeekOffset), weeks)
|
weekIndex := scMod(diffWeeks+int(plan.WeekOffset), weeks)
|
||||||
return scRuleOfDay(rules, weekIndex*7+scWeekdayOffset(day))
|
return scRuleOfDay(rules, weekIndex*7+scWeekdayOffset(day))
|
||||||
|
|
||||||
@@ -250,6 +369,41 @@ func scRuleForDate(plan *models.OaSchedulePlan, rules map[int]scPatternDay, fall
|
|||||||
cycle = 1
|
cycle = 1
|
||||||
}
|
}
|
||||||
diffDays := int(day.Sub(start).Hours() / 24)
|
diffDays := int(day.Sub(start).Hours() / 24)
|
||||||
|
// 基准周期内:按涂刷结果原样回放(涂刷月自己的节假日 / 周末已在保存时处理)
|
||||||
|
if diffDays < cycle || ext == nil {
|
||||||
|
return scRuleOfDay(rules, scMod(diffDays, cycle))
|
||||||
|
}
|
||||||
|
// 周期之外:先看目标日的节假日 / 调休特殊规则
|
||||||
|
if hType, ok := ext.holidays[day.Format("2006-01-02")]; ok {
|
||||||
|
if hType == holiday.TypeHoliday {
|
||||||
|
return scPatternDay{}, false
|
||||||
|
}
|
||||||
|
if hType == holiday.TypeWorkday && ext.defaultWork.ShiftID != 0 {
|
||||||
|
return ext.defaultWork, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 整月涂刷方案(周期=开始月天数):按星期节奏顺延,规避节假日 / 周末错位;
|
||||||
|
// 其他滚动周期(三班倒 / 做一休一等模板原生周期):保持相位纯滚动
|
||||||
|
daysInStartMonth := time.Date(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, start.Location()).Day()
|
||||||
|
if cycle == daysInStartMonth {
|
||||||
|
w := scWeekdayOffset(day) + 1
|
||||||
|
rule := ext.weekdayRules[w]
|
||||||
|
// 隔周交替的星期(如大小周的周六):按周序奇偶保持交替
|
||||||
|
if ext.altRules[w] {
|
||||||
|
startMonday := start.AddDate(0, 0, -scWeekdayOffset(start))
|
||||||
|
curMonday := day.AddDate(0, 0, -scWeekdayOffset(day))
|
||||||
|
weekSeq := int(curMonday.Sub(startMonday).Hours()/24) / 7
|
||||||
|
if weekSeq%2 == 0 {
|
||||||
|
rule = ext.altEven[w]
|
||||||
|
} else {
|
||||||
|
rule = ext.altOdd[w]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rule.DayType != models.SchedTplDayWork {
|
||||||
|
return scPatternDay{}, false
|
||||||
|
}
|
||||||
|
return rule, true
|
||||||
|
}
|
||||||
return scRuleOfDay(rules, scMod(diffDays, cycle))
|
return scRuleOfDay(rules, scMod(diffDays, cycle))
|
||||||
|
|
||||||
case models.SchedTplAlignMonth:
|
case models.SchedTplAlignMonth:
|
||||||
|
|||||||
@@ -98,7 +98,13 @@ func (c *BackendOaSchedulingController) scPlanInputFromBody(body map[string]inte
|
|||||||
input.IncludeChild = scBoolValue(body, "include_child")
|
input.IncludeChild = scBoolValue(body, "include_child")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, msg := scDateSpan(input.StartDate, input.EndDate); msg != "" {
|
// 无期限方案:结束日期允许为空,落库时存哨兵值,发布时按滚动窗口分批生成
|
||||||
|
if scIsNoEnd(input.EndDate) {
|
||||||
|
if _, ok := scParseDate(input.StartDate); !ok {
|
||||||
|
return nil, "开始日期格式错误"
|
||||||
|
}
|
||||||
|
input.EndDate = scPlanNoEndDate
|
||||||
|
} else if _, msg := scDateSpan(input.StartDate, input.EndDate); msg != "" {
|
||||||
return nil, msg
|
return nil, msg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +187,7 @@ func scPlanView(plan *models.OaSchedulePlan) map[string]interface{} {
|
|||||||
"employee_names": plan.EmployeeNames,
|
"employee_names": plan.EmployeeNames,
|
||||||
"start_date": normalizeOaDate(plan.StartDate),
|
"start_date": normalizeOaDate(plan.StartDate),
|
||||||
"end_date": normalizeOaDate(plan.EndDate),
|
"end_date": normalizeOaDate(plan.EndDate),
|
||||||
|
"no_end": scIsNoEnd(plan.EndDate),
|
||||||
"weekdays": weekdays,
|
"weekdays": weekdays,
|
||||||
"shift_id": plan.ShiftID,
|
"shift_id": plan.ShiftID,
|
||||||
"shift_name": plan.ShiftName,
|
"shift_name": plan.ShiftName,
|
||||||
@@ -330,7 +337,7 @@ func (c *BackendOaSchedulingController) PlanPreview() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dates, msg := scDateSpan(input.StartDate, input.EndDate)
|
dates, msg := scPlanGenWindow(input.StartDate, input.EndDate)
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
c.scFail(400, 400, msg)
|
c.scFail(400, 400, msg)
|
||||||
return
|
return
|
||||||
@@ -369,6 +376,9 @@ func (c *BackendOaSchedulingController) PlanPreview() {
|
|||||||
"workday_count": len(workdays),
|
"workday_count": len(workdays),
|
||||||
"rest_count": len(dates) - len(workdays),
|
"rest_count": len(dates) - len(workdays),
|
||||||
"entry_count": entryCount,
|
"entry_count": entryCount,
|
||||||
|
"no_end": scIsNoEnd(input.EndDate),
|
||||||
|
"gen_start": dates[0],
|
||||||
|
"gen_end": dates[len(dates)-1],
|
||||||
"shift": shift,
|
"shift": shift,
|
||||||
"scope_name": c.scScopeName(tid, input),
|
"scope_name": c.scScopeName(tid, input),
|
||||||
"template_name": input.TemplateName,
|
"template_name": input.TemplateName,
|
||||||
@@ -673,7 +683,7 @@ func (c *BackendOaSchedulingController) scPublishPlan(tid, uid uint64, username
|
|||||||
return nil, fmt.Errorf("范围覆盖 %d 名员工,超过单次 %d 人上限", len(employees), scPlanMaxEmployees)
|
return nil, fmt.Errorf("范围覆盖 %d 名员工,超过单次 %d 人上限", len(employees), scPlanMaxEmployees)
|
||||||
}
|
}
|
||||||
|
|
||||||
dates, msg := scDateSpan(normalizeOaDate(plan.StartDate), normalizeOaDate(plan.EndDate))
|
dates, msg := scPlanGenWindow(normalizeOaDate(plan.StartDate), normalizeOaDate(plan.EndDate))
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
return nil, errors.New(msg)
|
return nil, errors.New(msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,33 +121,43 @@ func (c *BackendOaSchedulingController) TemplateApply() {
|
|||||||
c.scFail(500, 500, "查询班次失败: "+err.Error())
|
c.scFail(500, 500, "查询班次失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 班次匹配索引:精确(名称+时间)→ 名称 → 兜底(默认班次 / 第一个可用班次)。
|
||||||
|
// 不强制时间一致,也不自动创建班次,由用户在方案里自行判断调整
|
||||||
shiftMap := make(map[string]*models.OaShift, len(shifts))
|
shiftMap := make(map[string]*models.OaShift, len(shifts))
|
||||||
maxSort := 0
|
shiftNameMap := make(map[string]*models.OaShift, len(shifts))
|
||||||
|
var fallbackShift *models.OaShift
|
||||||
for i := range shifts {
|
for i := range shifts {
|
||||||
shift := &shifts[i]
|
shift := &shifts[i]
|
||||||
shiftMap[schedShiftKey(shift.Name, shift.WorkStart, shift.WorkEnd)] = shift
|
shiftMap[schedShiftKey(shift.Name, shift.WorkStart, shift.WorkEnd)] = shift
|
||||||
if shift.Sort > maxSort {
|
if _, ok := shiftNameMap[shift.Name]; !ok {
|
||||||
maxSort = shift.Sort
|
shiftNameMap[shift.Name] = shift
|
||||||
|
}
|
||||||
|
if fallbackShift == nil || (shift.IsDefault == 1 && fallbackShift.IsDefault != 1) {
|
||||||
|
fallbackShift = shift
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
patterns := []scPatternDay{}
|
patterns := []scPatternDay{}
|
||||||
createdShifts := 0
|
|
||||||
matchedShifts := 0
|
matchedShifts := 0
|
||||||
for _, day := range c.schedTplDays(tpl.ID) {
|
for _, day := range c.schedTplDays(tpl.ID) {
|
||||||
if day.DayType != models.SchedTplDayWork {
|
if day.DayType != models.SchedTplDayWork {
|
||||||
patterns = append(patterns, scPatternDay{DayIndex: day.DayIndex, DayType: models.SchedTplDayRest})
|
patterns = append(patterns, scPatternDay{DayIndex: day.DayIndex, DayType: models.SchedTplDayRest})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := schedShiftKey(day.ShiftName, day.WorkStart, day.WorkEnd)
|
shift := shiftMap[schedShiftKey(day.ShiftName, day.WorkStart, day.WorkEnd)]
|
||||||
shift, hit := shiftMap[key]
|
if shift == nil {
|
||||||
if hit {
|
shift = shiftNameMap[strings.TrimSpace(day.ShiftName)]
|
||||||
|
}
|
||||||
|
if shift == nil {
|
||||||
|
shift = fallbackShift
|
||||||
|
}
|
||||||
|
if shift != nil {
|
||||||
matchedShifts++
|
matchedShifts++
|
||||||
} else {
|
}
|
||||||
maxSort += 10
|
item := scPatternDay{
|
||||||
shift = &models.OaShift{
|
DayIndex: day.DayIndex,
|
||||||
Tid: int(tid),
|
DayType: models.SchedTplDayWork,
|
||||||
Name: day.ShiftName,
|
ShiftName: day.ShiftName,
|
||||||
ShortName: day.ShiftShort,
|
ShortName: day.ShiftShort,
|
||||||
Color: day.Color,
|
Color: day.Color,
|
||||||
WorkStart: day.WorkStart,
|
WorkStart: day.WorkStart,
|
||||||
@@ -155,37 +165,19 @@ func (c *BackendOaSchedulingController) TemplateApply() {
|
|||||||
RestStart: day.RestStart,
|
RestStart: day.RestStart,
|
||||||
RestEnd: day.RestEnd,
|
RestEnd: day.RestEnd,
|
||||||
WorkHours: day.WorkHours,
|
WorkHours: day.WorkHours,
|
||||||
IsDefault: 0,
|
|
||||||
Status: 1,
|
|
||||||
Sort: maxSort,
|
|
||||||
IsDeleted: 0,
|
|
||||||
}
|
}
|
||||||
if shift.ShortName == "" {
|
if shift != nil {
|
||||||
shift.ShortName = shift.Name
|
item.ShiftID = shift.ID
|
||||||
|
item.ShiftName = shift.Name
|
||||||
|
item.ShortName = shift.ShortName
|
||||||
|
item.Color = shift.Color
|
||||||
|
item.WorkStart = shift.WorkStart
|
||||||
|
item.WorkEnd = shift.WorkEnd
|
||||||
|
item.RestStart = shift.RestStart
|
||||||
|
item.RestEnd = shift.RestEnd
|
||||||
|
item.WorkHours = shift.WorkHours
|
||||||
}
|
}
|
||||||
if shift.Color == "" {
|
patterns = append(patterns, item)
|
||||||
shift.Color = "#409EFF"
|
|
||||||
}
|
|
||||||
if _, err := models.Orm.Insert(shift); err != nil {
|
|
||||||
c.scFail(500, 500, "创建班次失败: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shiftMap[key] = shift
|
|
||||||
createdShifts++
|
|
||||||
}
|
|
||||||
patterns = append(patterns, scPatternDay{
|
|
||||||
DayIndex: day.DayIndex,
|
|
||||||
DayType: models.SchedTplDayWork,
|
|
||||||
ShiftID: shift.ID,
|
|
||||||
ShiftName: shift.Name,
|
|
||||||
ShortName: shift.ShortName,
|
|
||||||
Color: shift.Color,
|
|
||||||
WorkStart: shift.WorkStart,
|
|
||||||
WorkEnd: shift.WorkEnd,
|
|
||||||
RestStart: shift.RestStart,
|
|
||||||
RestEnd: shift.RestEnd,
|
|
||||||
WorkHours: shift.WorkHours,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if len(patterns) == 0 {
|
if len(patterns) == 0 {
|
||||||
c.scFail(400, 400, "该模板没有配置排班规则")
|
c.scFail(400, 400, "该模板没有配置排班规则")
|
||||||
@@ -196,7 +188,6 @@ func (c *BackendOaSchedulingController) TemplateApply() {
|
|||||||
"template": schedTplView(tpl, nil),
|
"template": schedTplView(tpl, nil),
|
||||||
"pattern": patterns,
|
"pattern": patterns,
|
||||||
"days": patterns,
|
"days": patterns,
|
||||||
"created_shift": createdShifts,
|
|
||||||
"matched_shift": matchedShifts,
|
"matched_shift": matchedShifts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ func RegisterAuthRoutes() {
|
|||||||
beego.Router("/backend/oa/scheduling/plans/publish/:id", &controllers.BackendOaSchedulingController{}, "post:PlanPublish")
|
beego.Router("/backend/oa/scheduling/plans/publish/:id", &controllers.BackendOaSchedulingController{}, "post:PlanPublish")
|
||||||
beego.Router("/backend/oa/scheduling/plans/delete/:id", &controllers.BackendOaSchedulingController{}, "delete:PlanDelete")
|
beego.Router("/backend/oa/scheduling/plans/delete/:id", &controllers.BackendOaSchedulingController{}, "delete:PlanDelete")
|
||||||
beego.Router("/backend/oa/scheduling/entries/save", &controllers.BackendOaSchedulingController{}, "post:SaveEntries")
|
beego.Router("/backend/oa/scheduling/entries/save", &controllers.BackendOaSchedulingController{}, "post:SaveEntries")
|
||||||
|
beego.Router("/backend/oa/scheduling/entries/save-cells", &controllers.BackendOaSchedulingController{}, "post:SaveEntryCells")
|
||||||
beego.Router("/backend/oa/scheduling/entries/swap/:id", &controllers.BackendOaSchedulingController{}, "post:SwapEntry")
|
beego.Router("/backend/oa/scheduling/entries/swap/:id", &controllers.BackendOaSchedulingController{}, "post:SwapEntry")
|
||||||
beego.Router("/backend/oa/scheduling/entries/clear", &controllers.BackendOaSchedulingController{}, "post:ClearEntries")
|
beego.Router("/backend/oa/scheduling/entries/clear", &controllers.BackendOaSchedulingController{}, "post:ClearEntries")
|
||||||
beego.Router("/backend/oa/scheduling/changes", &controllers.BackendOaSchedulingController{}, "get:ChangeList")
|
beego.Router("/backend/oa/scheduling/changes", &controllers.BackendOaSchedulingController{}, "get:ChangeList")
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="platform" label="平台名称" min-width="160">
|
<el-table-column prop="platform" label="平台名称" min-width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="platform-name-cell">
|
<div class="platform-name-cell">
|
||||||
<el-icon class="platform-icon"><Key /></el-icon>
|
<el-icon class="platform-icon"><Key /></el-icon>
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="账号预览" min-width="220" show-overflow-tooltip>
|
<el-table-column label="账号预览" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.accounts && row.accounts.length > 0" class="account-preview-tags">
|
<div v-if="row.accounts && row.accounts.length > 0" class="account-preview-tags">
|
||||||
<el-tag
|
<el-tag
|
||||||
@@ -177,6 +177,7 @@
|
|||||||
size="small"
|
size="small"
|
||||||
class="acc-preview-tag"
|
class="acc-preview-tag"
|
||||||
type="info"
|
type="info"
|
||||||
|
:title="acc.username || '(无账号名)'"
|
||||||
>
|
>
|
||||||
{{ acc.username || '(无账号名)' }}
|
{{ acc.username || '(无账号名)' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@@ -924,10 +925,19 @@ onMounted(load)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.acc-preview-tag {
|
.acc-preview-tag {
|
||||||
max-width: 110px;
|
max-width: 140px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* el-tag 文字在内层 .el-tag__content 中,需用 :deep 才能让省略号生效 */
|
||||||
|
.acc-preview-tag :deep(.el-tag__content) {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-tag {
|
.more-tag {
|
||||||
|
|||||||
Reference in new issue
Block a user