更新若干事项

This commit is contained in:
hero920103 committed 2026-10-26 08:54:34 +08:00
1 parent 68cdaf38fa
commit 9898962836
30 files changed
+7715

No files matched your search

+2
View File
@@ -31,6 +31,7 @@ declare module 'vue' {
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
@@ -79,6 +80,7 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
+100
View File
@@ -0,0 +1,100 @@
import request from '@/utils/request'
// OA 工作排班:班次 / 排班方案 / 排班看板 / 临时调配
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
/* ------------------------------- 班次 ------------------------------- */
export function getShiftList() {
return request({ url: '/backend/oa/scheduling/shifts', method: 'get' })
}
export function createShift(data) {
return request({ url: '/backend/oa/scheduling/shifts', method: 'post', data })
}
export function updateShift(id, data) {
return request({ url: `/backend/oa/scheduling/shifts/${id}`, method: 'post', data })
}
export function deleteShift(id) {
return request({ url: `/backend/oa/scheduling/shifts/${id}`, method: 'delete' })
}
/* ------------------------------- 员工 ------------------------------- */
// 排班可选员工:org_id 为空表示全部,include_child=1 含下级组织
export function getSchedulingEmployees(params) {
return request({ url: '/backend/oa/scheduling/employees', method: 'get', params })
}
/* ------------------------------- 看板 ------------------------------- */
export function getScheduleBoard(params) {
return request({ url: '/backend/oa/scheduling/board', method: 'get', params })
}
/* ----------------------------- 排班模板 ----------------------------- */
// 平台预置 + 自定义模板(只读);租户调用后规则会复制到自己的方案
export function getSchedTemplates(params) {
return request({ url: '/backend/oa/scheduling/templates', method: 'get', params })
}
// 调用模板:自动匹配 / 创建租户自己的班次,返回可直接用于方案的 pattern
export function applySchedTemplate(templateId) {
return request({ url: '/backend/oa/scheduling/templates/apply', method: 'post', data: { template_id: templateId } })
}
/* ------------------------------- 方案 ------------------------------- */
export function getSchedulePlans(params) {
return request({ url: '/backend/oa/scheduling/plans', method: 'get', params })
}
export function getSchedulePlanDetail(id) {
return request({ url: `/backend/oa/scheduling/plans/detail/${id}`, method: 'get' })
}
// 预览方案覆盖人数与生成条数,不写入数据
export function previewSchedulePlan(data) {
return request({ url: '/backend/oa/scheduling/plans/preview', method: 'post', data })
}
export function createSchedulePlan(data) {
return request({ url: '/backend/oa/scheduling/plans/create', method: 'post', data })
}
export function updateSchedulePlan(id, data) {
return request({ url: `/backend/oa/scheduling/plans/update/${id}`, method: 'post', data })
}
// overwrite=true 覆盖同一员工同一天已有排班,默认保留
export function publishSchedulePlan(id, data) {
return request({ url: `/backend/oa/scheduling/plans/publish/${id}`, method: 'post', data })
}
// delete_entries=0 时仅删除方案,保留已生成的排班
export function deleteSchedulePlan(id, params) {
return request({ url: `/backend/oa/scheduling/plans/delete/${id}`, method: 'delete', params })
}
/* ----------------------------- 明细与调配 ----------------------------- */
// 临时排班:employee_ids + start_date/end_date + weekdays + shift_id
// shift_id=0 表示清除这些日期的排班
export function saveScheduleEntries(data) {
return request({ url: '/backend/oa/scheduling/entries/save', method: 'post', data })
}
export function swapScheduleEntry(id, data) {
return request({ url: `/backend/oa/scheduling/entries/swap/${id}`, method: 'post', data })
}
export function clearScheduleEntries(data) {
return request({ url: '/backend/oa/scheduling/entries/clear', method: 'post', data })
}
export function getScheduleChanges(params) {
return request({ url: '/backend/oa/scheduling/changes', method: 'get', params })
}
+7
View File
@@ -41,6 +41,13 @@ const staticMainChildren = [
component: () => import("@/views/apps/oa/approvalcenter/settings.vue"),
meta: { requiresAuth: true, title: "审批设置", modulePath: "/apps/oa" }
},
// OA 工作排班(静态注册兜底:三级菜单在动态路由重建前/菜单缓存未刷新时直链会落到 404)
{
path: "/apps/oa/scheduling/board",
name: "OaSchedulingBoard",
component: () => import("@/views/apps/oa/scheduling/index.vue"),
meta: { requiresAuth: true, title: "工作排班", modulePath: "/apps/oa" }
},
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
// 统一结构:index 为列表页,type 为分类页。
{
@@ -0,0 +1,266 @@
<template>
<el-dialog
:model-value="modelValue"
title="临时排班 / 调配"
width="620px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@open="handleOpen"
>
<el-alert type="info" :closable="false" show-icon class="entry-tip">
针对单个员工或一批员工做临时安排;同一员工同一天已有排班时会被覆盖,并在变更记录中留痕。
</el-alert>
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px" class="entry-form">
<el-form-item label="员工范围">
<div class="scope-row">
<el-tree-select
v-model="filterOrgID"
:data="orgTree"
:props="{ label: 'org_name', value: 'id', children: 'children' }"
node-key="id"
check-strictly
filterable
clearable
placeholder="按公司/部门/小组筛选员工(可选)"
style="flex: 1"
@change="loadEmployees"
/>
<el-button link type="primary" @click="selectAll">全选</el-button>
<el-button link @click="clearSelected">清空</el-button>
</div>
</el-form-item>
<el-form-item label="员工" prop="employee_ids">
<el-select
v-model="form.employee_ids"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="可选择一个或多个员工"
style="width: 100%"
>
<el-option v-for="employee in employees" :key="employee.id" :label="employee.label" :value="employee.id" />
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="日期范围" prop="date_range">
<el-date-picker
v-model="form.date_range"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
:clearable="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="生效星期">
<el-checkbox-group v-model="form.weekdays">
<el-checkbox v-for="day in WEEKDAYS" :key="day.value" :value="day.value">{{ day.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
</div>
<el-form-item label="班次" prop="shift_id">
<el-select v-model="form.shift_id" placeholder="选择班次" style="width: 100%">
<el-option v-for="shift in shifts" :key="shift.id" :label="`${shift.name}(${shift.work_start}-${shift.work_end})`" :value="shift.id" />
</el-select>
</el-form-item>
<el-form-item label="备注 / 原因">
<el-input v-model="form.note" maxlength="255" placeholder="如:项目上线临时换班" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" plain :loading="clearing" @click="submitClear">清除这些日期排班</el-button>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitSave">保存排班</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { oaOrganizationApi } from '@/api/organization'
import { buildOrgTree } from '@/views/apps/organization/composables'
import { getSchedulingEmployees, getShiftList, saveScheduleEntries } from '@/api/oaScheduling'
const props = defineProps({
modelValue: Boolean,
presetEmployeeIds: { type: Array, default: () => [] },
presetDate: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'changed'])
const WEEKDAYS = [
{ value: 1, label: '一' },
{ value: 2, label: '二' },
{ value: 3, label: '三' },
{ value: 4, label: '四' },
{ value: 5, label: '五' },
{ value: 6, label: '六' },
{ value: 7, label: '日' }
]
function unwrap(res) {
if (res && typeof res === 'object' && 'code' in res) {
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
return res.data
}
return res
}
const saving = ref(false)
const clearing = ref(false)
const employees = ref([])
const shifts = ref([])
const orgTree = ref([])
const filterOrgID = ref(null)
const formRef = ref(null)
const form = reactive({
employee_ids: [],
date_range: [],
weekdays: [1, 2, 3, 4, 5, 6, 7],
shift_id: null,
note: ''
})
const rules = {
employee_ids: [{ required: true, message: '请选择员工', trigger: 'change' }],
date_range: [{ required: true, message: '请选择日期范围', trigger: 'change' }],
shift_id: [{ required: true, message: '请选择班次', trigger: 'change' }]
}
async function handleOpen() {
form.employee_ids = [...(props.presetEmployeeIds || [])]
form.date_range = props.presetDate ? [props.presetDate, props.presetDate] : []
form.weekdays = [1, 2, 3, 4, 5, 6, 7]
form.note = ''
form.shift_id = null
filterOrgID.value = null
await Promise.all([loadOrgs(), loadEmployees(), loadShifts()])
}
async function loadOrgs() {
try {
const data = unwrap(await oaOrganizationApi.getOrganizationList())
orgTree.value = buildOrgTree(Array.isArray(data) ? data : data?.list || [])
} catch {
orgTree.value = []
}
}
// 按当前组织筛选候选项;已选中的员工不受影响
async function loadEmployees() {
try {
const data = unwrap(await getSchedulingEmployees({
org_id: filterOrgID.value || undefined,
include_child: 1
})) || {}
employees.value = (data.list || []).map((item) => ({
...item,
label: `${item.name}${item.department_name ? `(${item.department_name})` : ''}`
}))
} catch {
employees.value = []
}
}
function selectAll() {
const merged = new Set(form.employee_ids)
employees.value.forEach((item) => merged.add(item.id))
form.employee_ids = [...merged]
}
function clearSelected() {
form.employee_ids = []
}
async function loadShifts() {
try {
const data = unwrap(await getShiftList()) || {}
shifts.value = (data.list || []).filter((item) => item.status === 1)
if (!form.shift_id) {
form.shift_id = data.default_id || shifts.value[0]?.id || null
}
} catch {
shifts.value = []
}
}
function buildPayload() {
return {
employee_ids: form.employee_ids,
start_date: form.date_range?.[0],
end_date: form.date_range?.[1],
weekdays: form.weekdays,
note: form.note
}
}
async function submitSave() {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const stats = unwrap(await saveScheduleEntries({ ...buildPayload(), shift_id: form.shift_id })) || {}
ElMessage.success(
`排班完成:新增 ${stats.created || 0} 条,调整 ${stats.updated || 0} 条,跳过 ${stats.skipped || 0} 条`
)
emit('update:modelValue', false)
emit('changed')
} catch (error) {
ElMessage.error(error.message || '保存排班失败')
} finally {
saving.value = false
}
}
async function submitClear() {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
try {
await ElMessageBox.confirm('确认清除所选员工在这些日期的排班?', '清除排班', { type: 'warning' })
} catch {
return
}
clearing.value = true
try {
const stats = unwrap(await saveScheduleEntries({ ...buildPayload(), shift_id: 0 })) || {}
ElMessage.success(`已清除 ${stats.cleared || 0} 条排班`)
emit('update:modelValue', false)
emit('changed')
} catch (error) {
ElMessage.error(error.message || '清除失败')
} finally {
clearing.value = false
}
}
</script>
<style scoped lang="less">
.entry-tip {
margin-bottom: 14px;
}
.entry-form {
padding-right: 8px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr;
}
.scope-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
</style>
@@ -0,0 +1,821 @@
<template>
<el-dialog
:model-value="modelValue"
title="排班方案"
width="980px"
top="5vh"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@open="handleOpen"
>
<!-- 列表视图 -->
<template v-if="!formVisible">
<div class="plan-toolbar">
<div class="plan-tip">方案按公司、部门(小组)或指定员工下发排班,发布后可在看板逐日调整。</div>
<div class="plan-toolbar-actions">
<el-input
v-model="keyword"
placeholder="搜索方案名称"
clearable
size="small"
style="width: 180px"
@keyup.enter="loadPlans"
/>
<el-button size="small" @click="loadPlans">查询</el-button>
<el-button type="primary" size="small" :icon="Plus" @click="openCreate">新建方案</el-button>
</div>
</div>
<el-table :data="plans" v-loading="loading" size="small" max-height="430">
<el-table-column prop="plan_name" label="方案名称" min-width="150" show-overflow-tooltip />
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.plan_type === 2 ? 'warning' : ''" size="small">
{{ row.plan_type === 2 ? "临时" : "常规" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="org_name" label="作用范围" min-width="150" show-overflow-tooltip />
<el-table-column label="周期" width="210">
<template #default="{ row }">{{ row.start_date }} ~ {{ row.end_date }}</template>
</el-table-column>
<el-table-column label="周期规则" width="150">
<template #default="{ row }">{{ ruleText(row) }}</template>
</el-table-column>
<el-table-column label="模板 / 班次" width="160">
<template #default="{ row }">
<el-tag v-if="row.align_mode" size="small" type="warning">
{{ row.template_name || '模板规则' }}
</el-tag>
<span v-else>
<span class="shift-dot" :style="{ background: row.color }" />{{ row.shift_name }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.publish_status === 1 ? 'success' : 'info'" size="small">
{{ row.publish_status === 1 ? "已发布" : "未发布" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="publishPlan(row, false)">发布</el-button>
<el-dropdown trigger="click" @command="(command) => handlePublishCommand(row, command)">
<el-button link type="primary">更多<el-icon class="ml2"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="overwrite">覆盖发布</el-dropdown-item>
<el-dropdown-item command="edit">编辑方案</el-dropdown-item>
<el-dropdown-item command="delete" divided>删除方案</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
<template #empty><el-empty description="暂无排班方案" :image-size="60" /></template>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadPlans"
/>
</div>
</template>
<!-- 新建 / 编辑表单 -->
<template v-else>
<div class="plan-form-head">
<span class="form-title">{{ editingID ? "编辑排班方案" : "新建排班方案" }}</span>
<el-button link @click="formVisible = false">返回方案列表</el-button>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="92px">
<div class="form-grid">
<el-form-item label="方案名称" prop="plan_name">
<el-input v-model="form.plan_name" maxlength="100" placeholder="如:技术部 11 月早班" />
</el-form-item>
<el-form-item label="方案类型">
<el-radio-group v-model="form.plan_type">
<el-radio :value="1">常规排班</el-radio>
<el-radio :value="2">临时方案</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排班方式">
<el-radio-group v-model="form.rule_mode" @change="handleRuleModeChange">
<el-radio value="template">使用排班模板</el-radio>
<el-radio value="weekday">按星期排班</el-radio>
</el-radio-group>
</el-form-item>
</div>
<!-- 模板模式:套用平台模板的周期规则,规则会复制到本方案,可继续微调 -->
<template v-if="form.rule_mode === 'template'">
<div class="form-grid template-row">
<el-form-item label="排班模板" prop="template_id">
<el-select
v-model="form.template_id"
placeholder="选择模板"
style="width: 100%"
@change="handleTemplateChange"
>
<el-option
v-for="tpl in templates"
:key="tpl.id"
:value="tpl.id"
:label="`${tpl.name}(${tpl.template_type_text})`"
/>
</el-select>
</el-form-item>
<el-form-item label="模板规则">
<span class="template-summary">
{{ currentTemplate?.summary || '选择模板后自动带入规则' }}
</span>
</el-form-item>
</div>
<el-table
v-if="form.pattern.length"
:data="form.pattern"
size="small"
border
max-height="220"
class="pattern-table"
>
<el-table-column label="周期" width="110">
<template #default="{ $index }">{{ patternLabel($index) }}</template>
</el-table-column>
<el-table-column label="上班 / 休息" width="140">
<template #default="{ row }">
<el-radio-group v-model="row.day_type" size="small">
<el-radio-button :value="1">上班</el-radio-button>
<el-radio-button :value="2">休息</el-radio-button>
</el-radio-group>
</template>
</el-table-column>
<el-table-column label="班次" min-width="160">
<template #default="{ row }">
<el-select
v-if="row.day_type === 1"
v-model="row.shift_id"
size="small"
placeholder="选择班次"
style="width: 100%"
@change="applyShiftToPattern(row)"
>
<el-option
v-for="shift in shifts"
:key="shift.id"
:value="shift.id"
:label="`${shift.name}(${shift.work_start}-${shift.work_end})`"
/>
</el-select>
<span v-else class="rest-text">休息</span>
</template>
</el-table-column>
<el-table-column label="工作时间" width="180">
<template #default="{ row }">
<span v-if="row.day_type === 1">{{ row.work_start }} - {{ row.work_end }}</span>
<span v-else class="rest-text">-</span>
</template>
</el-table-column>
</el-table>
<div v-if="form.pattern.length" class="pattern-tip">
模板规则已复制到本方案,上面的调整只影响当前方案,不会修改平台模板
</div>
</template>
<!-- 按星期模式:一人一天一个固定班次 -->
<el-form-item v-else label="班次" prop="shift_id">
<el-select v-model="form.shift_id" placeholder="选择班次" style="width: 100%">
<el-option v-for="shift in shifts" :key="shift.id" :label="`${shift.name}(${shift.work_start}-${shift.work_end})`" :value="shift.id" />
</el-select>
</el-form-item>
<el-form-item label="排班范围">
<el-radio-group v-model="form.scope_type">
<el-radio-button :value="1">整个公司</el-radio-button>
<el-radio-button :value="2">部门 / 小组</el-radio-button>
<el-radio-button :value="3">指定员工</el-radio-button>
</el-radio-group>
</el-form-item>
<div v-if="form.scope_type === 2" class="form-grid scope-row">
<el-form-item label="选择组织" prop="org_id">
<el-tree-select
v-model="form.org_id"
:data="orgTree"
:props="{ label: 'org_name', value: 'id', children: 'children' }"
node-key="id"
check-strictly
filterable
clearable
placeholder="选择公司、部门或小组"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="包含下级">
<el-switch v-model="form.include_child" active-text="含全部下级组织" />
</el-form-item>
</div>
<el-form-item v-if="form.scope_type === 3" label="选择员工" prop="employee_ids">
<el-select
v-model="form.employee_ids"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="可选择一个或多个员工"
style="width: 100%"
>
<el-option
v-for="employee in employees"
:key="employee.id"
:label="employee.label"
:value="employee.id"
/>
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="日期范围" prop="date_range">
<el-date-picker
v-model="form.date_range"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
:clearable="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item v-if="form.rule_mode === 'weekday'" label="生效星期">
<el-checkbox-group v-model="form.weekdays">
<el-checkbox v-for="day in WEEKDAYS" :key="day.value" :value="day.value">{{ day.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
</div>
<el-form-item label="备注">
<el-input v-model="form.note" maxlength="500" placeholder="选填,会带到生成的排班备注里" />
</el-form-item>
</el-form>
<div v-if="preview" class="preview-box">
<el-alert v-if="!preview.employee_count" type="warning" :closable="false" show-icon
title="该范围内没有可排班的员工,请检查所选组织或员工状态" />
<el-alert v-else type="success" :closable="false" show-icon>
<template #title>
将覆盖 <strong>{{ preview.employee_count }}</strong> 名员工 ×
<strong>{{ preview.workday_count }}</strong> 个工作日,共
<strong>{{ preview.entry_count }}</strong> 条排班
</template>
</el-alert>
<div v-if="preview.employee_list?.length" class="preview-employees">
<el-tag v-for="item in preview.employee_list.slice(0, 30)" :key="item.id" size="small" type="info">
{{ item.name }}<span v-if="item.department_name"> · {{ item.department_name }}</span>
</el-tag>
<span v-if="preview.employee_list.length > 30" class="more-tip">
等 {{ preview.employee_list.length }} 人
</span>
</div>
</div>
<div class="form-actions">
<el-button :loading="previewing" @click="runPreview">预览覆盖范围</el-button>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="savePlan">保存方案</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ArrowDown, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { oaOrganizationApi } from '@/api/organization'
import { buildOrgTree } from '@/views/apps/organization/composables'
import { getSchedulingEmployees, getShiftList } from '@/api/oaScheduling'
import {
applySchedTemplate,
createSchedulePlan,
deleteSchedulePlan,
getSchedulePlanDetail,
getSchedTemplates,
getSchedulePlans,
previewSchedulePlan,
publishSchedulePlan,
updateSchedulePlan
} from '@/api/oaScheduling'
defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue', 'changed'])
const WEEKDAYS = [
{ value: 1, label: '周一' },
{ value: 2, label: '周二' },
{ value: 3, label: '周三' },
{ value: 4, label: '周四' },
{ value: 5, label: '周五' },
{ value: 6, label: '周六' },
{ value: 7, label: '周日' }
]
function unwrap(res) {
if (res && typeof res === 'object' && 'code' in res) {
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
return res.data
}
return res
}
const loading = ref(false)
const saving = ref(false)
const previewing = ref(false)
const plans = ref([])
const shifts = ref([])
const templates = ref([])
const currentTemplate = ref(null)
const employees = ref([])
const orgTree = ref([])
const keyword = ref('')
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const formVisible = ref(false)
const editingID = ref(0)
const preview = ref(null)
const formRef = ref(null)
const form = reactive({
plan_name: '',
plan_type: 1,
rule_mode: 'weekday',
shift_id: null,
template_id: null,
template_code: '',
template_name: '',
align_mode: 0,
cycle_days: 0,
week_offset: 0,
month_rest_days: 0,
month_rest_weekday: 0,
pattern: [],
scope_type: 2,
org_id: null,
include_child: true,
employee_ids: [],
date_range: [],
weekdays: [1, 2, 3, 4, 5],
note: ''
})
const rules = {
plan_name: [{ required: true, message: '请输入方案名称', trigger: 'blur' }],
org_id: [{ required: true, message: '请选择组织', trigger: 'change' }],
employee_ids: [{ required: true, message: '请选择员工', trigger: 'change' }],
date_range: [{ required: true, message: '请选择日期范围', trigger: 'change' }]
}
async function handleOpen() {
formVisible.value = false
preview.value = null
await Promise.all([loadPlans(), loadShifts(), loadTemplates(), loadEmployees(), loadOrgs()])
}
async function loadPlans() {
loading.value = true
try {
const data = unwrap(await getSchedulePlans({
page: pagination.page,
page_size: pagination.pageSize,
keyword: keyword.value || undefined
})) || {}
plans.value = data.list || []
pagination.total = data.total || 0
} catch (error) {
ElMessage.error(error.message || '加载方案失败')
} finally {
loading.value = false
}
}
async function loadShifts() {
try {
const data = unwrap(await getShiftList()) || {}
shifts.value = (data.list || []).filter((item) => item.status === 1)
} catch {
shifts.value = []
}
}
async function loadTemplates() {
try {
const data = unwrap(await getSchedTemplates({})) || {}
templates.value = data.list || []
} catch {
templates.value = []
}
}
async function loadEmployees() {
try {
const data = unwrap(await getSchedulingEmployees({})) || {}
employees.value = (data.list || []).map((item) => ({
...item,
label: `${item.name}${item.department_name ? `(${item.department_name})` : ''}`
}))
} catch {
employees.value = []
}
}
async function loadOrgs() {
try {
const data = unwrap(await oaOrganizationApi.getOrganizationList())
orgTree.value = buildOrgTree(Array.isArray(data) ? data : data?.list || [])
} catch {
orgTree.value = []
}
}
function openCreate() {
editingID.value = 0
preview.value = null
currentTemplate.value = null
Object.assign(form, {
plan_name: '',
plan_type: 1,
rule_mode: 'weekday',
shift_id: shifts.value.find((item) => item.is_default === 1)?.id || shifts.value[0]?.id || null,
template_id: null,
template_code: '',
template_name: '',
align_mode: 0,
cycle_days: 0,
week_offset: 0,
month_rest_days: 0,
month_rest_weekday: 0,
pattern: [],
scope_type: 2,
org_id: null,
include_child: true,
employee_ids: [],
date_range: [],
weekdays: [1, 2, 3, 4, 5],
note: ''
})
formVisible.value = true
}
async function openEdit(row) {
try {
const detail = unwrap(await getSchedulePlanDetail(row.id)) || {}
editingID.value = detail.id
preview.value = null
const isTemplate = Number(detail.align_mode) > 0 && Array.isArray(detail.pattern) && detail.pattern.length > 0
Object.assign(form, {
plan_name: detail.plan_name,
plan_type: detail.plan_type,
rule_mode: isTemplate ? 'template' : 'weekday',
shift_id: detail.shift_id,
template_id: detail.template_id || null,
template_code: detail.template_code || '',
template_name: detail.template_name || '',
align_mode: detail.align_mode || 0,
cycle_days: detail.cycle_days || 0,
week_offset: detail.week_offset || 0,
month_rest_days: detail.month_rest_days || 0,
month_rest_weekday: detail.month_rest_weekday || 0,
pattern: isTemplate ? detail.pattern.map((item) => ({ ...item })) : [],
scope_type: detail.scope_type,
org_id: detail.org_id || null,
include_child: detail.include_child === 1,
employee_ids: detail.employee_ids || [],
date_range: [detail.start_date, detail.end_date],
weekdays: detail.weekdays || [],
note: detail.note || ''
})
currentTemplate.value = isTemplate
? {
id: detail.template_id,
name: detail.template_name,
summary: '当前方案沿用调用时的模板规则(已复制到本方案,可继续调整)'
}
: null
formVisible.value = true
} catch (error) {
ElMessage.error(error.message || '加载方案失败')
}
}
const weekdayLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
function patternLabel(index) {
if (form.align_mode === 3) return '上班日'
if (form.align_mode === 2) return `第 ${index + 1} 天`
const week = Math.floor(index / 7) + 1
const name = weekdayLabels[index % 7]
return form.cycle_days > 7 ? `第${week}周 ${name}` : name
}
function handleRuleModeChange(mode) {
preview.value = null
if (mode === 'weekday' && !form.shift_id) {
form.shift_id = shifts.value.find((item) => item.is_default === 1)?.id || shifts.value[0]?.id || null
}
}
// 选择模板:调用后端把模板规则复制成方案 pattern,同时自动匹配 / 创建班次
async function handleTemplateChange(templateId) {
preview.value = null
if (!templateId) {
form.pattern = []
currentTemplate.value = null
return
}
try {
const data = unwrap(await applySchedTemplate(templateId)) || {}
const info = data.template || templates.value.find((item) => item.id === templateId) || {}
form.template_code = info.code || ''
form.template_name = info.name || ''
form.align_mode = info.align_mode || 0
form.cycle_days = info.cycle_days || 0
form.week_offset = info.week_offset || 0
form.month_rest_days = info.month_rest_days || 0
form.month_rest_weekday = info.month_rest_weekday || 0
form.pattern = (data.pattern || data.days || []).map((item) => ({ ...item }))
currentTemplate.value = { ...info, summary: info.summary || '' }
if (data.created_shift) {
ElMessage.success(`已套用模板,并自动创建 ${data.created_shift} 个班次`)
await loadShifts()
} else {
ElMessage.success('已套用模板规则')
}
} catch (error) {
ElMessage.error(error.message || '套用模板失败')
}
}
// 微调某天的班次:把班次快照带进 pattern,保存后与模板无关
function applyShiftToPattern(row) {
const shift = shifts.value.find((item) => item.id === row.shift_id)
if (!shift) return
row.shift_name = shift.name
row.short_name = shift.short_name
row.color = shift.color
row.work_start = shift.work_start
row.work_end = shift.work_end
row.rest_start = shift.rest_start || ''
row.rest_end = shift.rest_end || ''
row.work_hours = shift.work_hours
}
function buildPayload() {
const templateMode = form.rule_mode === 'template'
return {
plan_name: form.plan_name,
plan_type: form.plan_type,
shift_id: templateMode ? 0 : form.shift_id,
template_id: templateMode ? form.template_id : 0,
template_code: templateMode ? form.template_code : '',
template_name: templateMode ? form.template_name : '',
align_mode: templateMode ? form.align_mode : 0,
cycle_days: templateMode ? form.cycle_days : 0,
week_offset: templateMode ? form.week_offset : 0,
month_rest_days: templateMode ? form.month_rest_days : 0,
month_rest_weekday: templateMode ? form.month_rest_weekday : 0,
pattern: templateMode ? form.pattern : [],
scope_type: form.scope_type,
org_id: form.scope_type === 2 ? form.org_id : 0,
include_child: form.include_child,
employee_ids: form.scope_type === 3 ? form.employee_ids : [],
start_date: form.date_range?.[0],
end_date: form.date_range?.[1],
weekdays: form.weekdays,
note: form.note
}
}
async function validateForm() {
if (!formRef.value) return false
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return false
if (form.rule_mode === 'template') {
if (!form.template_id || form.pattern.length === 0) {
ElMessage.warning('请选择排班模板')
return false
}
if (!form.pattern.some((item) => item.day_type === 1)) {
ElMessage.warning('模板规则至少需要一天上班')
return false
}
} else if (!form.shift_id) {
ElMessage.warning('请选择班次')
return false
}
return true
}
async function runPreview() {
const valid = await validateForm()
if (!valid) return
previewing.value = true
try {
preview.value = unwrap(await previewSchedulePlan(buildPayload())) || {}
} catch (error) {
preview.value = null
ElMessage.error(error.message || '预览失败')
} finally {
previewing.value = false
}
}
async function savePlan() {
const valid = await validateForm()
if (!valid) return
saving.value = true
try {
if (editingID.value) {
unwrap(await updateSchedulePlan(editingID.value, buildPayload()))
ElMessage.success('方案已更新,重新发布后新规则生效')
} else {
unwrap(await createSchedulePlan(buildPayload()))
ElMessage.success('方案已保存,发布后生效')
}
formVisible.value = false
await loadPlans()
emit('changed')
} catch (error) {
ElMessage.error(error.message || '保存方案失败')
} finally {
saving.value = false
}
}
async function handlePublishCommand(row, command) {
if (command === 'edit') return openEdit(row)
if (command === 'delete') return removePlan(row)
if (command === 'overwrite') return publishPlan(row, true)
}
async function publishPlan(row, overwrite) {
try {
await ElMessageBox.confirm(
overwrite
? `将用方案「${row.plan_name}」覆盖范围内员工同一天的已有排班,是否继续?`
: `将按方案「${row.plan_name}」生成排班,已有排班的日期会被保留(不覆盖)。`,
overwrite ? '覆盖发布' : '发布排班',
{ type: overwrite ? 'warning' : 'info', confirmButtonText: '发布' }
)
} catch {
return
}
try {
const data = unwrap(await publishSchedulePlan(row.id, { overwrite })) || {}
const stats = data.publish || {}
ElMessage.success(
`发布完成:新增 ${stats.created || 0} 条,覆盖 ${stats.updated || 0} 条,跳过 ${stats.skipped || 0} 条`
)
await loadPlans()
emit('changed')
} catch (error) {
ElMessage.error(error.message || '发布失败')
}
}
async function removePlan(row) {
try {
await ElMessageBox.confirm(
`删除方案「${row.plan_name}」会同时清除它生成且尚未被调整的排班,是否继续?`,
'删除方案',
{ type: 'warning' }
)
} catch {
return
}
try {
unwrap(await deleteSchedulePlan(row.id))
ElMessage.success('方案已删除')
await loadPlans()
emit('changed')
} catch (error) {
ElMessage.error(error.message || '删除失败')
}
}
function weekdayText(list) {
if (!Array.isArray(list) || !list.length) return '每天'
if (list.length === 7) return '每天'
return list.map((value) => WEEKDAYS.find((day) => day.value === value)?.label || value).join(' ')
}
function ruleText(row) {
if (row.align_mode === 3) return `每月休 ${row.month_rest_days || 0} 天`
if (row.align_mode === 2) return `每 ${row.cycle_days || 0} 天循环`
if (row.align_mode === 1) {
const weeks = Math.max(1, Math.round((row.cycle_days || 7) / 7))
return weeks > 1 ? `${weeks} 周循环` : '每周循环'
}
return weekdayText(row.weekdays)
}
defineExpose({ loadPlans })
</script>
<style scoped lang="less">
.plan-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.plan-tip {
color: #909399;
font-size: 13px;
}
.plan-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.shift-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.ml2 {
margin-left: 2px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.plan-form-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.form-title {
font-weight: 600;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 16px;
}
.scope-row {
grid-template-columns: 2fr 1fr;
}
.preview-box {
margin-top: 4px;
}
.preview-employees {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.more-tip {
color: #909399;
font-size: 12px;
line-height: 24px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.template-row {
grid-template-columns: 1fr 1fr;
}
.template-summary {
color: #909399;
font-size: 13px;
line-height: 32px;
}
.pattern-table {
margin-top: 4px;
}
.pattern-tip {
margin-top: 6px;
color: #909399;
font-size: 12px;
}
.rest-text {
color: #c0c4cc;
font-size: 12px;
}
</style>
@@ -0,0 +1,314 @@
<template>
<el-dialog
:model-value="modelValue"
title="班次管理"
width="900px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@open="loadShifts"
>
<div class="shift-toolbar">
<div class="shift-tip">班次用于定义上下班时间与休息时段,可被排班方案和临时调配复用。</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新增班次</el-button>
</div>
<el-table :data="shifts" v-loading="loading" size="small" max-height="320">
<el-table-column label="班次" min-width="150">
<template #default="{ row }">
<span class="shift-dot" :style="{ background: row.color }" />
<span>{{ row.name }}</span>
<el-tag v-if="row.is_default === 1" size="small" type="success" class="ml6">默认</el-tag>
</template>
</el-table-column>
<el-table-column prop="short_name" label="简称" width="90" />
<el-table-column label="上班时间" width="150">
<template #default="{ row }">{{ row.work_start }} - {{ row.work_end }}</template>
</el-table-column>
<el-table-column label="休息时段" width="140">
<template #default="{ row }">
{{ row.rest_start ? `${row.rest_start} - ${row.rest_end}` : "无" }}
</template>
</el-table-column>
<el-table-column label="工时" width="80">
<template #default="{ row }">{{ row.work_hours }}h</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeShift(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="还没有班次,请先新增一个" :image-size="60" /></template>
</el-table>
<div v-if="formVisible" class="shift-form">
<div class="form-title">{{ editingID ? "编辑班次" : "新增班次" }}</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="92px">
<div class="form-grid">
<el-form-item label="班次名称" prop="name">
<el-input v-model="form.name" placeholder="如:早班 / 晚班 / 行政班" maxlength="50" />
</el-form-item>
<el-form-item label="简称">
<el-input v-model="form.short_name" placeholder="看板色块上的短文本" maxlength="20" />
</el-form-item>
<el-form-item label="上班时间" prop="work_start">
<el-time-picker v-model="form.work_start" value-format="HH:mm" format="HH:mm" placeholder="上班" style="width: 100%" />
</el-form-item>
<el-form-item label="下班时间" prop="work_end">
<el-time-picker v-model="form.work_end" value-format="HH:mm" format="HH:mm" placeholder="下班" style="width: 100%" />
</el-form-item>
<el-form-item label="休息开始">
<el-time-picker v-model="form.rest_start" value-format="HH:mm" format="HH:mm" placeholder="选填" clearable style="width: 100%" />
</el-form-item>
<el-form-item label="休息结束">
<el-time-picker v-model="form.rest_end" value-format="HH:mm" format="HH:mm" placeholder="选填" clearable style="width: 100%" />
</el-form-item>
<el-form-item label="标识颜色">
<el-color-picker v-model="form.color" :predefine="COLORS" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="默认班次">
<el-switch v-model="form.is_default" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" class="span-2">
<el-input v-model="form.remark" maxlength="255" placeholder="选填" />
</el-form-item>
</div>
<div class="form-actions">
<span class="hours-tip">自动计算工时:{{ computedHours }} 小时</span>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveShift">保存班次</el-button>
</div>
</el-form>
</div>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createShift, deleteShift, getShiftList, updateShift } from '@/api/oaScheduling'
defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue', 'changed'])
const COLORS = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#9254DE', '#13C2C2']
const loading = ref(false)
const saving = ref(false)
const shifts = ref([])
const formVisible = ref(false)
const editingID = ref(0)
const formRef = ref(null)
const form = reactive({
name: '',
short_name: '',
color: '#409EFF',
work_start: '09:00',
work_end: '18:00',
rest_start: '12:00',
rest_end: '13:00',
is_default: false,
status: 1,
sort: 0,
remark: ''
})
const rules = {
name: [{ required: true, message: '请输入班次名称', trigger: 'blur' }],
work_start: [{ required: true, message: '请选择上班时间', trigger: 'change' }],
work_end: [{ required: true, message: '请选择下班时间', trigger: 'change' }]
}
function unwrap(res) {
if (res && typeof res === 'object' && 'code' in res) {
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
return res.data
}
return res
}
const computedHours = computed(() => {
const toMinutes = (value) => {
if (!value) return null
const [hour, minute] = String(value).split(':').map(Number)
if (Number.isNaN(hour) || Number.isNaN(minute)) return null
return hour * 60 + minute
}
let start = toMinutes(form.work_start)
let end = toMinutes(form.work_end)
if (start === null || end === null) return 0
if (end <= start) end += 24 * 60
let span = end - start
let restStart = toMinutes(form.rest_start)
let restEnd = toMinutes(form.rest_end)
if (restStart !== null && restEnd !== null) {
if (restEnd <= restStart) restEnd += 24 * 60
const overlap = Math.max(0, Math.min(restEnd, end) - Math.max(restStart, start))
span -= overlap
}
return Math.max(0, Math.round((span / 60) * 100) / 100)
})
async function loadShifts() {
loading.value = true
try {
const data = unwrap(await getShiftList()) || {}
shifts.value = data.list || []
} catch (error) {
ElMessage.error(error.message || '加载班次失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingID.value = 0
Object.assign(form, {
name: '', short_name: '', color: '#409EFF',
work_start: '09:00', work_end: '18:00', rest_start: '12:00', rest_end: '13:00',
is_default: false, status: 1, sort: shifts.value.length, remark: ''
})
formVisible.value = true
}
function openEdit(row) {
editingID.value = row.id
Object.assign(form, {
name: row.name,
short_name: row.short_name || '',
color: row.color || '#409EFF',
work_start: row.work_start,
work_end: row.work_end,
rest_start: row.rest_start || '',
rest_end: row.rest_end || '',
is_default: row.is_default === 1,
status: row.status,
sort: row.sort || 0,
remark: row.remark || ''
})
formVisible.value = true
}
async function saveShift() {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
if (form.rest_start && !form.rest_end) {
ElMessage.warning('请选择休息结束时间')
return
}
if (!form.rest_start && form.rest_end) {
ElMessage.warning('请选择休息开始时间')
return
}
saving.value = true
try {
const payload = { ...form, work_hours: computedHours.value }
if (editingID.value) {
unwrap(await updateShift(editingID.value, payload))
ElMessage.success('班次已更新')
} else {
unwrap(await createShift(payload))
ElMessage.success('班次已创建')
}
formVisible.value = false
await loadShifts()
emit('changed')
} catch (error) {
ElMessage.error(error.message || '保存班次失败')
} finally {
saving.value = false
}
}
async function removeShift(row) {
try {
await ElMessageBox.confirm(`确认删除班次「${row.name}」?已被排班使用的班次不能删除。`, '删除班次', {
type: 'warning'
})
} catch {
return
}
try {
unwrap(await deleteShift(row.id))
ElMessage.success('班次已删除')
await loadShifts()
emit('changed')
} catch (error) {
ElMessage.error(error.message || '删除失败')
}
}
defineExpose({ loadShifts })
</script>
<style scoped lang="less">
.shift-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.shift-tip {
color: #909399;
font-size: 13px;
}
.shift-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.ml6 {
margin-left: 6px;
}
.shift-form {
margin-top: 16px;
padding: 16px;
border-radius: 8px;
background: #f7f9fc;
border: 1px solid #ebeef5;
}
.form-title {
font-weight: 600;
margin-bottom: 12px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 16px;
}
.span-2 {
grid-column: span 3;
}
.form-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.hours-tip {
margin-right: auto;
color: #909399;
font-size: 13px;
}
</style>
@@ -0,0 +1,192 @@
<template>
<el-dialog
:model-value="modelValue"
:title="entry ? '调整排班' : '补排班次'"
width="480px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@open="handleOpen"
>
<div class="swap-head">
<div class="swap-person">
<strong>{{ employeeName }}</strong>
<span>{{ dateText }}</span>
</div>
<div class="swap-current">
<template v-if="entry">
<span class="shift-dot" :style="{ background: entry.color }" />
<span>{{ entry.shift_name }}</span>
<span class="time">{{ entry.work_start }}-{{ entry.work_end }}</span>
</template>
<span v-else class="empty-tip">当天未排班</span>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="82px">
<el-form-item :label="entry ? '调换班次' : '选择班次'" prop="shift_id">
<el-select v-model="form.shift_id" placeholder="选择新班次" style="width: 100%">
<el-option
v-for="shift in options"
:key="shift.id"
:label="`${shift.name}(${shift.work_start}-${shift.work_end})`"
:value="shift.id"
/>
</el-select>
</el-form-item>
<el-form-item label="原因">
<el-input v-model="form.reason" maxlength="255" placeholder="如:临时换班 / 请假顶班" />
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="entry" type="danger" plain :loading="clearing" @click="submitClear">清除排班</el-button>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitSave">{{ entry ? "保存调班" : "保存排班" }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { clearScheduleEntries, getShiftList, saveScheduleEntries, swapScheduleEntry } from '@/api/oaScheduling'
const props = defineProps({
modelValue: Boolean,
entry: { type: Object, default: null },
employeeId: { type: Number, default: 0 },
employeeName: { type: String, default: '' },
scheduleDate: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'changed'])
function unwrap(res) {
if (res && typeof res === 'object' && 'code' in res) {
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
return res.data
}
return res
}
const saving = ref(false)
const clearing = ref(false)
const shifts = ref([])
const formRef = ref(null)
const form = reactive({ shift_id: null, reason: '' })
const rules = {
shift_id: [{ required: true, message: '请选择班次', trigger: 'change' }]
}
const dateText = computed(() => {
if (!props.scheduleDate) return ''
const parsed = new Date(`${props.scheduleDate}T00:00:00`)
const labels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${props.scheduleDate} ${labels[parsed.getDay()] || ''}`
})
// 调班时把自己排除,避免选到当前班次
const options = computed(() => shifts.value.filter((item) => item.id !== props.entry?.shift_id))
async function handleOpen() {
form.reason = ''
form.shift_id = null
try {
const data = unwrap(await getShiftList()) || {}
shifts.value = (data.list || []).filter((item) => item.status === 1)
if (!props.entry) {
form.shift_id = data.default_id || shifts.value[0]?.id || null
}
} catch {
shifts.value = []
}
}
async function submitSave() {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (props.entry) {
unwrap(await swapScheduleEntry(props.entry.id, { shift_id: form.shift_id, reason: form.reason }))
ElMessage.success('调班已保存')
} else {
unwrap(await saveScheduleEntries({
employee_ids: [props.employeeId],
start_date: props.scheduleDate,
end_date: props.scheduleDate,
weekdays: [],
shift_id: form.shift_id,
note: form.reason
}))
ElMessage.success('排班已保存')
}
emit('update:modelValue', false)
emit('changed')
} catch (error) {
ElMessage.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
async function submitClear() {
if (!props.entry) return
try {
await ElMessageBox.confirm('确认清除这条排班?', '清除排班', { type: 'warning' })
} catch {
return
}
clearing.value = true
try {
unwrap(await clearScheduleEntries({ entry_ids: [props.entry.id], reason: form.reason }))
ElMessage.success('排班已清除')
emit('update:modelValue', false)
emit('changed')
} catch (error) {
ElMessage.error(error.message || '清除失败')
} finally {
clearing.value = false
}
}
</script>
<style scoped lang="less">
.swap-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
margin-bottom: 14px;
background: #f7f9fc;
border-radius: 8px;
}
.swap-person {
display: flex;
flex-direction: column;
gap: 2px;
span {
color: #909399;
font-size: 12px;
}
}
.swap-current {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.shift-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.time {
color: #909399;
font-size: 12px;
}
.empty-tip {
color: #c0c4cc;
}
</style>
@@ -0,0 +1,900 @@
<template>
<div class="scheduling-page">
<div class="page-header">
<div>
<h2>工作排班</h2>
<p>按公司、部门或小组批量排班,支持单人或多人临时调配,逐日可调</p>
</div>
<div class="header-actions">
<el-button :icon="Clock" @click="openChanges">变更记录</el-button>
<el-button :icon="Setting" @click="shiftVisible = true">班次管理</el-button>
<el-button :icon="Tickets" @click="planVisible = true">排班方案</el-button>
<el-button type="primary" :icon="Plus" @click="openTempEntry">临时排班</el-button>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">覆盖员工</div>
<div class="stat-value">{{ summary.employee_count || 0 }}</div>
<div class="stat-sub">当前筛选范围内员工</div>
</div>
<div class="stat-card purple">
<div class="stat-label">排班人次</div>
<div class="stat-value">{{ summary.entry_count || 0 }}</div>
<div class="stat-sub">{{ rangeText }}</div>
</div>
<div class="stat-card orange">
<div class="stat-label">临时调配</div>
<div class="stat-value">{{ summary.temp_count || 0 }}</div>
<div class="stat-sub">非方案生成、手工调整的班次</div>
</div>
<div class="stat-card green">
<div class="stat-label">今日在岗</div>
<div class="stat-value">{{ summary.today_on_duty || 0 }}</div>
<div class="stat-sub">未排班员工 {{ summary.unassigned || 0 }} 人</div>
</div>
</div>
<div class="table-card">
<div class="filter-bar">
<el-radio-group v-model="viewMode" @change="handleViewModeChange">
<el-radio-button value="week">按周</el-radio-button>
<el-radio-button value="month">按月</el-radio-button>
</el-radio-group>
<el-button-group class="week-switch">
<el-button :icon="ArrowLeft" @click="shiftRange(-1)" />
<el-button @click="goCurrentRange">{{ isMonth ? "本月" : "本周" }}</el-button>
<el-button :icon="ArrowRight" @click="shiftRange(1)" />
</el-button-group>
<el-date-picker
v-model="anchorDate"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
placeholder="选择日期"
style="width: 150px"
@change="loadBoard"
/>
<el-tree-select
v-model="filters.org_id"
:data="orgTree"
:props="{ label: 'org_name', value: 'id', children: 'children' }"
node-key="id"
check-strictly
filterable
clearable
placeholder="全部组织"
style="width: 200px"
@change="loadBoard"
/>
<el-checkbox v-model="filters.include_child" @change="loadBoard">含下级组织</el-checkbox>
<el-input
v-model="filters.keyword"
placeholder="搜索员工姓名 / 账号"
clearable
:prefix-icon="Search"
style="width: 190px"
/>
<el-checkbox v-model="filters.only_scheduled">只看有排班</el-checkbox>
<el-button :icon="Refresh" @click="loadBoard">刷新</el-button>
<div class="legend">
<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>
</div>
</div>
<div class="board-wrap" :class="{ 'is-month': isMonth }" v-loading="loading">
<table v-if="displayEmployees.length" class="board-table">
<thead>
<tr>
<th class="sticky-col">员工</th>
<th
v-for="day in dateCells"
:key="day.date"
:class="{
'is-today': day.is_today,
'is-holiday': holidayOf(day.date)?.type === 1,
'is-makeup': holidayOf(day.date)?.type === 0
}"
>
<div class="date-main">{{ day.date.slice(5) }}</div>
<div class="date-sub">{{ weekdayLabel(day.weekday) }}</div>
<div
v-if="holidayOf(day.date)"
class="date-holiday"
:title="holidayOf(day.date).name"
>
<span
class="holiday-tag"
:class="holidayOf(day.date).type === 1 ? 'is-off' : 'is-work'"
>
{{ holidayOf(day.date).type === 1 ? "休" : "班" }}
</span>
<span v-if="!isMonth" class="holiday-name">{{ holidayOf(day.date).name }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="employee in displayEmployees" :key="employee.id">
<td class="sticky-col employee-cell">
<div class="emp-name">{{ employee.name }}</div>
<div class="emp-meta">{{ employee.org_name || "未分配部门" }}</div>
</td>
<td
v-for="day in dateCells"
:key="day.date"
class="shift-cell"
:class="{ 'is-today': day.is_today }"
@click="openCell(employee, day.date)"
>
<div
v-if="employee.days[day.date]"
class="shift-block"
:style="shiftStyle(employee.days[day.date])"
:title="cellTitle(employee.days[day.date])"
>
<span v-if="employee.days[day.date].source === 2" class="temp-flag">临</span>
<span class="shift-title">{{ employee.days[day.date].short_name || employee.days[day.date].shift_name }}</span>
<span v-if="!isMonth" class="shift-time">{{ employee.days[day.date].work_start }}-{{ employee.days[day.date].work_end }}</span>
</div>
<div v-else class="empty-cell">+</div>
</td>
</tr>
</tbody>
</table>
<el-empty
v-else
:description="loading ? '加载中…' : '当前范围内没有员工或排班,请调整筛选或先创建排班方案'"
/>
</div>
<div v-if="truncated" class="truncated-tip">
员工较多,仅展示前 500 人,请通过组织筛选缩小范围。
</div>
</div>
<shift-dialog v-model="shiftVisible" @changed="loadBoard" />
<plan-dialog v-model="planVisible" @changed="loadBoard" />
<entry-dialog
v-model="entryVisible"
:preset-employee-ids="entryPreset.employeeIds"
:preset-date="entryPreset.date"
@changed="handleEntryChanged"
/>
<swap-dialog
v-model="swapVisible"
:entry="selectedCell.entry"
:employee-id="selectedCell.employeeId"
:employee-name="selectedCell.employeeName"
:schedule-date="selectedCell.scheduleDate"
@changed="handleEntryChanged"
/>
<el-drawer v-model="changesVisible" title="排班变更记录" size="620px" @open="loadChanges">
<el-table :data="changes" v-loading="changesLoading" size="small">
<el-table-column prop="scheduleDateText" label="日期" width="100" />
<el-table-column prop="employee_name" label="员工" width="90" />
<el-table-column label="变更" min-width="190">
<template #default="{ row }">
<span v-if="row.change_type === 2" class="change-clear">清除 {{ row.old_shift_name }}</span>
<span v-else-if="row.change_type === 3" class="change-new">新增 {{ row.new_shift_name }}</span>
<span v-else>
{{ row.old_shift_name || "未排班" }}
<el-icon class="change-arrow"><Right /></el-icon>
{{ row.new_shift_name }}
</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="120" show-overflow-tooltip />
<el-table-column prop="operator_name" label="操作人" width="90" />
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ (row.create_time || "").replace("T", " ").slice(0, 16) }}</template>
</el-table-column>
<template #empty><el-empty description="本周暂无调配记录" :image-size="55" /></template>
</el-table>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ArrowLeft, ArrowRight, Clock, Plus, Refresh, Right, Search, Setting, Tickets } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { oaOrganizationApi } from '@/api/organization'
import { buildOrgTree } from '@/views/apps/organization/composables'
import { getScheduleBoard, getScheduleChanges, getShiftList } from '@/api/oaScheduling'
import { getScheduleHolidays } from '@/api/oaSchedule'
import ShiftDialog from './components/shiftDialog.vue'
import PlanDialog from './components/planDialog.vue'
import EntryDialog from './components/entryDialog.vue'
import SwapDialog from './components/swapDialog.vue'
const WEEK_LABELS = { 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日' }
function unwrap(res) {
if (res && typeof res === 'object' && 'code' in res) {
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
return res.data
}
return res
}
function formatDate(date) {
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function mondayOf(value) {
const date = new Date(`${value}T00:00:00`)
const weekday = date.getDay() || 7
date.setDate(date.getDate() - (weekday - 1))
return date
}
const anchorDate = ref(formatDate(new Date()))
// 默认按月看(一月一屏);可切回按周
const viewMode = ref('month')
const isMonth = computed(() => viewMode.value === 'month')
const loading = ref(false)
const truncated = ref(false)
const board = ref({ employees: [], dates: [], summary: {}, shifts: [] })
const shifts = ref([])
const orgTree = ref([])
const filters = reactive({ org_id: null, include_child: true, keyword: '', only_scheduled: false })
const shiftVisible = ref(false)
const planVisible = ref(false)
const entryVisible = ref(false)
const swapVisible = ref(false)
const changesVisible = ref(false)
const changes = ref([])
const changesLoading = ref(false)
const entryPreset = reactive({ employeeIds: [], date: '' })
const selectedCell = reactive({ entry: null, employeeId: 0, employeeName: '', scheduleDate: '' })
const summary = computed(() => board.value.summary || {})
const dateCells = computed(() => board.value.dates || [])
const rangeText = computed(() => {
if (!dateCells.value.length) return ''
return `${dateCells.value[0].date} ~ ${dateCells.value[dateCells.value.length - 1].date}`
})
const displayEmployees = computed(() => {
const keyword = filters.keyword.trim()
return (board.value.employees || [])
.filter((row) => !keyword || row.name.includes(keyword) || (row.account || '').includes(keyword))
.filter((row) => !filters.only_scheduled || Object.keys(row.days || {}).length > 0)
})
function weekdayLabel(value) {
return WEEK_LABELS[value] || ''
}
function cellTitle(entry) {
const parts = [entry.shift_name || '排班']
if (entry.work_start || entry.work_end) parts.push(`${entry.work_start}-${entry.work_end}`)
if (entry.source === 2) parts.push('临时调配')
return parts.join(' ')
}
function shiftStyle(entry) {
return {
borderLeftColor: entry.color || '#409EFF',
background: hexToRgba(entry.color || '#409EFF', 0.1),
color: '#303133'
}
}
function hexToRgba(hex, alpha) {
const value = String(hex || '').replace('#', '')
if (value.length !== 6) return `rgba(64, 158, 255, ${alpha})`
const r = parseInt(value.slice(0, 2), 16)
const g = parseInt(value.slice(2, 4), 16)
const b = parseInt(value.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
async function loadOrgTree() {
try {
const data = unwrap(await oaOrganizationApi.getOrganizationList())
orgTree.value = buildOrgTree(Array.isArray(data) ? data : data?.list || [])
} catch {
orgTree.value = []
}
}
async function loadShifts() {
try {
const data = unwrap(await getShiftList()) || {}
shifts.value = (data.list || []).filter((item) => item.status === 1)
} catch {
shifts.value = []
}
}
// ---------- 中国法定节假日 / 调休 ----------
// 复用工作日程的节假日接口:type 1=法定放假(休),0=调休上班(班)。
// 整年拉取并缓存,翻月/翻周命中缓存时不再请求。
const holidayMap = ref({})
const loadedHolidayYears = new Set()
const loadingHolidayYears = new Set()
async function loadHolidays(start, end) {
const years = [start.slice(0, 4), end.slice(0, 4)].filter((y, i, arr) => arr.indexOf(y) === i)
const pending = years.filter((y) => !loadedHolidayYears.has(y) && !loadingHolidayYears.has(y))
if (!pending.length) return
pending.forEach((y) => loadingHolidayYears.add(y))
try {
const results = await Promise.all(pending.map((y) => getScheduleHolidays({ year: y })))
const map = { ...holidayMap.value }
const okYears = []
for (let i = 0; i < results.length; i += 1) {
const res = results[i]
if (res?.code !== 200) continue
okYears.push(pending[i])
for (const item of res.data?.list || []) {
map[item.date] = { name: item.name, type: item.type, wage: item.wage }
}
}
if (okYears.length) {
holidayMap.value = map
okYears.forEach((y) => loadedHolidayYears.add(y))
}
} catch {
// 节假日属辅助信息,接口失败时静默降级,看板照常可用
} finally {
pending.forEach((y) => loadingHolidayYears.delete(y))
}
}
function holidayOf(date) {
return holidayMap.value[date] || null
}
async function loadBoard() {
loading.value = true
try {
const range = currentRange()
const data = unwrap(await getScheduleBoard({
start_date: range.start,
end_date: range.end,
org_id: filters.org_id || undefined,
include_child: filters.include_child ? 1 : 0,
only_scheduled: filters.only_scheduled ? 1 : 0
})) || {}
board.value = {
employees: data.employees || [],
dates: data.dates || [],
summary: data.summary || {},
shifts: data.shifts || []
}
truncated.value = Boolean(data.truncated)
// 节假日不阻塞表格渲染,拿到后补上「休 / 班」角标
loadHolidays(range.start, range.end)
if (data.shifts?.length) {
shifts.value = data.shifts.filter((item) => item.status === 1)
}
if (changesVisible.value) loadChanges()
} catch (error) {
ElMessage.error(error.message || '加载排班失败')
} finally {
loading.value = false
}
}
// 当前视图的日期区间:按月=自然月 1 号至月末,按周=周一至周日
function currentRange() {
if (isMonth.value) {
const date = new Date(`${anchorDate.value}T00:00:00`)
const first = new Date(date.getFullYear(), date.getMonth(), 1)
const last = new Date(date.getFullYear(), date.getMonth() + 1, 0)
return { start: formatDate(first), end: formatDate(last) }
}
const monday = mondayOf(anchorDate.value)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
return { start: formatDate(monday), end: formatDate(sunday) }
}
function shiftRange(offset) {
const date = new Date(`${anchorDate.value}T00:00:00`)
if (isMonth.value) {
// 以当月 1 号为基准换月,避免 31 号在短月份溢出到下下个月
date.setDate(1)
date.setMonth(date.getMonth() + offset)
} else {
date.setDate(date.getDate() + offset * 7)
}
anchorDate.value = formatDate(date)
loadBoard()
}
function goCurrentRange() {
anchorDate.value = formatDate(new Date())
loadBoard()
}
function handleViewModeChange() {
loadBoard()
}
function openCell(employee, date) {
const entry = employee.days?.[date] || null
Object.assign(selectedCell, {
entry,
employeeId: employee.id,
employeeName: employee.name,
scheduleDate: date
})
swapVisible.value = true
}
function openTempEntry() {
entryPreset.employeeIds = []
entryPreset.date = ''
entryVisible.value = true
}
function handleEntryChanged() {
loadBoard()
}
async function openChanges() {
changesVisible.value = true
await loadChanges()
}
async function loadChanges() {
changesLoading.value = true
try {
const range = currentRange()
const data = unwrap(await getScheduleChanges({
start_date: range.start,
end_date: range.end
})) || {}
changes.value = (data.list || []).map((row) => ({
...row,
scheduleDateText: (row.schedule_date || '').slice(0, 10)
}))
} catch (error) {
ElMessage.error(error.message || '加载变更记录失败')
} finally {
changesLoading.value = false
}
}
onMounted(async () => {
await Promise.all([loadOrgTree(), loadShifts()])
await loadBoard()
})
</script>
<style scoped lang="less">
.scheduling-page {
padding: 16px 20px 28px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h2 {
margin: 0 0 4px;
font-size: 20px;
}
p {
margin: 0;
color: #909399;
font-size: 13px;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
padding: 15px 18px;
background: #fff;
border: 1px solid #ebeef5;
border-top: 3px solid #409eff;
border-radius: 8px;
&.purple {
border-top-color: #8b5cf6;
}
&.green {
border-top-color: #67c23a;
}
&.orange {
border-top-color: #e6a23c;
}
}
.stat-label,
.stat-sub {
color: #909399;
font-size: 13px;
}
.stat-value {
margin: 7px 0 4px;
font-size: 25px;
font-weight: 600;
color: #303133;
}
.stat-sub {
font-size: 12px;
}
.table-card {
padding: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.filter-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-left: auto;
color: #606266;
font-size: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.board-wrap {
overflow: auto;
max-height: 640px;
border: 1px solid #ebeef5;
border-radius: 6px;
}
.board-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
min-width: 900px;
th,
td {
border-bottom: 1px solid #ebeef5;
border-right: 1px solid #f2f3f5;
padding: 0;
text-align: center;
font-weight: normal;
}
thead th {
position: sticky;
top: 0;
z-index: 2;
background: #f5f7fa;
padding: 8px 4px;
}
thead th.is-today {
background: #ecf5ff;
}
.date-main {
font-size: 13px;
color: #303133;
font-weight: 600;
}
.date-sub {
font-size: 12px;
color: #909399;
}
// 法定假日表头:浅红底 + 「休」角标;调休上班日标「班」
thead th.is-holiday:not(.is-today) {
background: #fffafa;
}
.date-holiday {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
margin-top: 2px;
overflow: hidden;
}
.holiday-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 15px;
height: 15px;
padding: 0 3px;
border-radius: 3px;
font-size: 10px;
line-height: 1;
flex-shrink: 0;
&.is-off {
background: #fef0f0;
color: #f56c6c;
border: 1px solid #fde2e2;
}
&.is-work {
background: #f4f4f5;
color: #909399;
border: 1px solid #e9e9eb;
}
}
.holiday-name {
font-size: 11px;
color: #f56c6c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sticky-col {
position: sticky;
left: 0;
z-index: 1;
width: 150px;
min-width: 150px;
background: #fff;
text-align: left;
padding: 8px 12px;
border-right: 1px solid #ebeef5;
}
thead .sticky-col {
z-index: 3;
background: #f5f7fa;
}
.employee-cell {
.emp-name {
font-size: 13px;
font-weight: 600;
color: #303133;
}
.emp-meta {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
}
.shift-cell {
width: 118px;
min-width: 118px;
height: 56px;
cursor: pointer;
padding: 4px;
&.is-today {
background: #f7fbff;
}
&:hover {
background: #f5f7fa;
}
}
.shift-block {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 5px 7px;
border-left: 3px solid #409eff;
border-radius: 4px;
text-align: left;
min-height: 38px;
}
.shift-title {
font-size: 12px;
font-weight: 600;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shift-time {
font-size: 11px;
color: #606266;
}
.temp-flag {
position: absolute;
right: 4px;
top: 3px;
font-size: 10px;
line-height: 14px;
padding: 0 3px;
border-radius: 3px;
color: #e6a23c;
background: #fdf6ec;
}
.empty-cell {
color: #dcdfe6;
font-size: 16px;
line-height: 38px;
}
}
// 月视图:整月同屏,列宽收紧,只显示班次简称(完整信息看悬浮提示)
.board-wrap.is-month {
.board-table {
min-width: 0;
thead th {
padding: 6px 2px;
}
.sticky-col {
width: 130px;
min-width: 130px;
padding: 6px 10px;
}
.shift-cell {
width: 54px;
min-width: 54px;
height: 42px;
padding: 2px;
}
.shift-block {
gap: 1px;
padding: 3px 4px;
min-height: 30px;
border-left-width: 2px;
}
.shift-title {
font-size: 11px;
}
.date-main {
font-size: 12px;
}
.date-sub {
font-size: 11px;
}
.date-holiday {
gap: 0;
margin-top: 1px;
}
.holiday-tag {
min-width: 12px;
height: 12px;
padding: 0 2px;
font-size: 9px;
}
.temp-flag {
top: 1px;
right: 2px;
font-size: 9px;
line-height: 12px;
padding: 0 2px;
}
.empty-cell {
font-size: 13px;
line-height: 30px;
}
}
}
.truncated-tip {
margin-top: 10px;
color: #e6a23c;
font-size: 12px;
}
.change-arrow {
margin: 0 4px;
vertical-align: -2px;
}
.change-clear {
color: #f56c6c;
}
.change-new {
color: #67c23a;
}
@media (max-width: 1000px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
.legend {
margin-left: 0;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.page-header p {
color: var(--el-text-color-secondary);
}
.stat-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
.stat-label,
.stat-sub {
color: var(--el-text-color-secondary);
}
.stat-value {
color: var(--el-text-color-primary);
}
}
.table-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.board-wrap {
border-color: var(--el-border-color-lighter);
}
.board-table {
th,
td {
border-bottom-color: var(--el-border-color-lighter);
border-right-color: var(--el-border-color-lighter);
}
thead th {
background: var(--el-fill-color-light);
}
thead th.is-today {
background: var(--el-color-primary-light-9);
}
thead th.is-holiday:not(.is-today) {
background: rgba(245, 108, 108, 0.08);
}
.holiday-tag {
&.is-off {
background: rgba(245, 108, 108, 0.16);
color: #f89898;
border-color: rgba(245, 108, 108, 0.3);
}
&.is-work {
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
border-color: var(--el-border-color-lighter);
}
}
.holiday-name {
color: #f89898;
}
.sticky-col {
background: var(--el-bg-color);
border-right-color: var(--el-border-color-lighter);
}
thead .sticky-col {
background: var(--el-fill-color-light);
}
.employee-cell {
.emp-name {
color: var(--el-text-color-primary);
}
.emp-meta {
color: var(--el-text-color-secondary);
}
}
.shift-cell {
&.is-today {
background: var(--el-color-primary-light-9);
}
&:hover {
background: var(--el-fill-color-light);
}
}
.shift-time {
color: var(--el-text-color-regular);
}
}
.legend {
color: var(--el-text-color-regular);
}
}
</style>
+540
View File
@@ -0,0 +1,540 @@
package controllers
import (
"encoding/json"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/identity"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendOaSchedulingController OA 工作排班(班次 / 排班方案 / 排班看板 / 临时调配)。
//
// 排班的作用范围直接复用租户的组织架构(yz_backend_organization):
// 公司、部门、小组是同一棵树上的不同层级,选中某个节点并勾选“含下级”
// 即可把排班下发给该节点下的全部员工;也可以跳过组织,直接挑选
// 一个或多个员工做临时安排。所有数据按 JWT 中的 tid 做租户隔离。
type BackendOaSchedulingController struct {
beego.Controller
}
// ---------------------------------------------------------------------------
// 认证与响应
// ---------------------------------------------------------------------------
func (c *BackendOaSchedulingController) scClaims() (*jwtutil.Claims, bool) {
claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
if err != nil || claims.TenantId <= 0 {
return nil, false
}
return claims, true
}
// scOperator 返回当前操作人信息:tid、uid、用户名。
func (c *BackendOaSchedulingController) scOperator() (uint64, uint64, string, bool) {
claims, ok := c.scClaims()
if !ok {
c.scFail(401, 401, "未登录或登录已过期")
return 0, 0, "", false
}
return uint64(claims.TenantId), uint64(claims.UserID), claims.Username, true
}
func (c *BackendOaSchedulingController) scOK(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
func (c *BackendOaSchedulingController) scFail(httpStatus, bizCode int, msg string) {
if httpStatus > 0 {
c.Ctx.Output.SetStatus(httpStatus)
}
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *BackendOaSchedulingController) scBody() map[string]interface{} {
body := map[string]interface{}{}
if len(c.Ctx.Input.RequestBody) == 0 {
return body
}
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &body)
return body
}
func (c *BackendOaSchedulingController) scPathID(param string) (uint64, bool) {
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(param)), 10, 64)
return id, err == nil && id > 0
}
// ---------------------------------------------------------------------------
// 参数解析辅助
// ---------------------------------------------------------------------------
func scStringValue(body map[string]interface{}, keys ...string) string {
for _, key := range keys {
raw, ok := body[key]
if !ok || raw == nil {
continue
}
switch value := raw.(type) {
case string:
return strings.TrimSpace(value)
case float64:
return strconv.FormatFloat(value, 'f', -1, 64)
case bool:
return strconv.FormatBool(value)
}
}
return ""
}
func scIntValue(body map[string]interface{}, keys ...string) (int, bool) {
raw := scStringValue(body, keys...)
if raw == "" {
return 0, false
}
value, err := strconv.Atoi(raw)
return value, err == nil
}
func scUintValue(body map[string]interface{}, keys ...string) uint64 {
raw := scStringValue(body, keys...)
if raw == "" {
return 0
}
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0
}
return value
}
// scUintSlice 解析 [] 形式的 ID 列表,兼容数字与字符串两种元素。
func scUintSlice(body map[string]interface{}, key string) []uint64 {
raw, ok := body[key]
if !ok || raw == nil {
return nil
}
list, ok := raw.([]interface{})
if !ok {
return nil
}
ids := make([]uint64, 0, len(list))
seen := map[uint64]bool{}
for _, item := range list {
var value uint64
switch v := item.(type) {
case float64:
value = uint64(v)
case string:
value, _ = strconv.ParseUint(strings.TrimSpace(v), 10, 64)
}
if value > 0 && !seen[value] {
seen[value] = true
ids = append(ids, value)
}
}
return ids
}
func scBoolValue(body map[string]interface{}, keys ...string) bool {
for _, key := range keys {
raw, ok := body[key]
if !ok || raw == nil {
continue
}
switch value := raw.(type) {
case bool:
return value
case float64:
return value != 0
case string:
return value == "1" || strings.EqualFold(value, "true")
}
}
return false
}
// scWeekdaySet 解析星期筛选,1=周一 ... 7=周日。
// 支持 [1,3,5] 数组与 "1,3,5" 字符串;空值表示每天。
func scWeekdaySet(body map[string]interface{}) map[int]bool {
result := map[int]bool{}
raw, ok := body["weekdays"]
if !ok || raw == nil {
return result
}
add := func(value int) {
if value >= 1 && value <= 7 {
result[value] = true
}
}
switch list := raw.(type) {
case []interface{}:
for _, item := range list {
switch v := item.(type) {
case float64:
add(int(v))
case string:
n, _ := strconv.Atoi(strings.TrimSpace(v))
add(n)
}
}
case string:
for _, part := range strings.Split(list, ",") {
n, _ := strconv.Atoi(strings.TrimSpace(part))
add(n)
}
}
return result
}
// scParseDate 解析 "YYYY-MM-DD" 并返回时间对象。
func scParseDate(value string) (time.Time, bool) {
if !isValidOaDate(value) {
return time.Time{}, false
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
if err != nil {
return time.Time{}, false
}
return parsed, true
}
// scDateSpan 展开 [start, end] 之间的全部日期字符串;区间上限 92 天,
// 避免一次请求把看板拖垮。
func scDateSpan(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, "结束日期不能早于开始日期"
}
if endDate.Sub(startDate) > 92*24*time.Hour {
return nil, "日期区间不能超过 92 天"
}
dates := make([]string, 0, 32)
for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) {
dates = append(dates, cursor.Format("2006-01-02"))
}
return dates, ""
}
// scMinutes 把 "HH:mm" 转为当日分钟数。
func scMinutes(value string) (int, bool) {
if value == "" {
return 0, false
}
parsed, err := time.ParseInLocation("15:04", value, time.Local)
if err != nil {
return 0, false
}
return parsed.Hour()*60 + parsed.Minute(), true
}
// scCalcWorkHours 计算班次工时(小时):上下班跨度扣除休息时段。
// 下班时间早于上班时间视为跨天班次,自动加 24 小时。
func scCalcWorkHours(workStart, workEnd, restStart, restEnd string) float64 {
start, okStart := scMinutes(workStart)
end, okEnd := scMinutes(workEnd)
if !okStart || !okEnd {
return 0
}
if end <= start {
end += 24 * 60
}
span := end - start
if restStart != "" && restEnd != "" {
restFrom, okFrom := scMinutes(restStart)
restTo, okTo := scMinutes(restEnd)
if okFrom && okTo {
if restTo <= restFrom {
restTo += 24 * 60
}
if restTo > start && restFrom < end {
overlap := restTo - restFrom
if restFrom < start {
overlap -= start - restFrom
}
if restTo > end {
overlap -= restTo - end
}
if overlap > 0 {
span -= overlap
}
}
}
}
if span < 0 {
return 0
}
return float64(span) / 60
}
// ---------------------------------------------------------------------------
// 班次管理
// ---------------------------------------------------------------------------
func (c *BackendOaSchedulingController) shiftQuery(tid uint64) orm.QuerySeter {
return models.Orm.QueryTable(new(models.OaShift)).
Filter("tid", tid).
Filter("is_deleted", 0)
}
func (c *BackendOaSchedulingController) shiftByID(tid, id uint64) (*models.OaShift, bool) {
var row models.OaShift
if err := c.shiftQuery(tid).Filter("id", id).One(&row); err != nil {
return nil, false
}
return &row, true
}
// ShiftList 班次列表。
// GET /backend/oa/scheduling/shifts
func (c *BackendOaSchedulingController) ShiftList() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
var rows []models.OaShift
if _, err := c.shiftQuery(tid).OrderBy("sort", "id").All(&rows); err != nil {
c.scFail(500, 500, "查询班次失败: "+err.Error())
return
}
if rows == nil {
rows = []models.OaShift{}
}
var defaultID uint64
for _, row := range rows {
if row.IsDefault == 1 {
defaultID = row.ID
break
}
}
c.scOK(map[string]interface{}{
"list": rows,
"default_id": defaultID,
"total": len(rows),
})
}
// CreateShift 新增班次。
// POST /backend/oa/scheduling/shifts
func (c *BackendOaSchedulingController) CreateShift() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
body := c.scBody()
row, msg := c.buildShiftFromBody(body)
if msg != "" {
c.scFail(400, 400, msg)
return
}
row.Tid = int(tid)
if row.IsDefault == 1 {
c.clearDefaultShift(tid, 0)
}
if _, err := models.Orm.Insert(row); err != nil {
c.scFail(500, 500, "新增班次失败: "+err.Error())
return
}
c.scOK(row)
}
// UpdateShift 更新班次。
// POST /backend/oa/scheduling/shifts/:id
func (c *BackendOaSchedulingController) UpdateShift() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
id, valid := c.scPathID(":id")
if !valid {
c.scFail(400, 400, "无效的班次ID")
return
}
existing, found := c.shiftByID(tid, id)
if !found {
c.scFail(404, 404, "班次不存在")
return
}
body := c.scBody()
row, msg := c.buildShiftFromBody(body)
if msg != "" {
c.scFail(400, 400, msg)
return
}
existing.Name = row.Name
existing.ShortName = row.ShortName
existing.Color = row.Color
existing.WorkStart = row.WorkStart
existing.WorkEnd = row.WorkEnd
existing.RestStart = row.RestStart
existing.RestEnd = row.RestEnd
existing.WorkHours = row.WorkHours
existing.IsDefault = row.IsDefault
existing.Status = row.Status
existing.Sort = row.Sort
existing.Remark = row.Remark
if existing.IsDefault == 1 {
c.clearDefaultShift(tid, existing.ID)
}
if _, err := models.Orm.Update(existing); err != nil {
c.scFail(500, 500, "更新班次失败: "+err.Error())
return
}
// 班次名称/时间变化时同步已排班的快照,避免看板显示旧时间。
c.syncShiftSnapshot(tid, existing)
c.scOK(existing)
}
// DeleteShift 删除班次(软删除)。已被排班使用时拒绝删除,建议改为停用。
// DELETE /backend/oa/scheduling/shifts/:id
func (c *BackendOaSchedulingController) DeleteShift() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
id, valid := c.scPathID(":id")
if !valid {
c.scFail(400, 400, "无效的班次ID")
return
}
row, found := c.shiftByID(tid, id)
if !found {
c.scFail(404, 404, "班次不存在")
return
}
used, _ := models.Orm.QueryTable(new(models.OaScheduleEntry)).
Filter("tid", tid).
Filter("shift_id", id).
Filter("is_deleted", 0).
Count()
if used > 0 {
c.scFail(400, 400, "该班次已被排班使用,无法删除,请改为停用")
return
}
now := time.Now()
row.IsDeleted = 1
row.DeleteTime = &now
if _, err := models.Orm.Update(row, "is_deleted", "delete_time"); err != nil {
c.scFail(500, 500, "删除班次失败: "+err.Error())
return
}
c.scOK(map[string]interface{}{"id": id})
}
// buildShiftFromBody 校验并构造班次对象;返回非空 msg 表示参数错误。
func (c *BackendOaSchedulingController) buildShiftFromBody(body map[string]interface{}) (*models.OaShift, string) {
name := scStringValue(body, "name")
if name == "" {
return nil, "班次名称不能为空"
}
if len([]rune(name)) > 50 {
return nil, "班次名称不能超过 50 个字符"
}
workStart := scStringValue(body, "work_start")
workEnd := scStringValue(body, "work_end")
if !isValidOaTime(workStart) || !isValidOaTime(workEnd) {
return nil, "上下班时间格式应为 HH:mm"
}
if workStart == workEnd {
return nil, "上下班时间不能相同"
}
restStart := scStringValue(body, "rest_start")
restEnd := scStringValue(body, "rest_end")
if (restStart == "") != (restEnd == "") {
return nil, "休息开始与结束时间需要同时填写"
}
if restStart != "" && (!isValidOaTime(restStart) || !isValidOaTime(restEnd)) {
return nil, "休息时间格式应为 HH:mm"
}
shortName := scStringValue(body, "short_name")
if shortName == "" {
shortName = name
}
color := scStringValue(body, "color")
if color == "" {
color = "#409EFF"
}
status := int8(1)
if value, has := scIntValue(body, "status"); has {
status = int8(value)
}
sortValue, _ := scIntValue(body, "sort")
isDefault := int8(0)
if scBoolValue(body, "is_default") {
isDefault = 1
}
remark := scStringValue(body, "remark")
row := &models.OaShift{
Name: name,
ShortName: shortName,
Color: color,
WorkStart: workStart,
WorkEnd: workEnd,
RestStart: restStart,
RestEnd: restEnd,
WorkHours: scCalcWorkHours(workStart, workEnd, restStart, restEnd),
IsDefault: isDefault,
Status: status,
Sort: sortValue,
}
if remark != "" {
row.Remark = &remark
}
return row, ""
}
// clearDefaultShift 把同租户下其它班次的默认标记清掉,保证只有一个默认班次。
func (c *BackendOaSchedulingController) clearDefaultShift(tid, excludeID uint64) {
qs := c.shiftQuery(tid).Filter("is_default", 1)
if excludeID > 0 {
qs = qs.Exclude("id", excludeID)
}
var rows []models.OaShift
if _, err := qs.All(&rows); err != nil {
return
}
for i := range rows {
rows[i].IsDefault = 0
_, _ = models.Orm.Update(&rows[i], "is_default")
}
}
// syncShiftSnapshot 班次变更后,把新快照写回已生成的排班明细。
func (c *BackendOaSchedulingController) syncShiftSnapshot(tid uint64, shift *models.OaShift) {
_, _ = models.Orm.Raw(
"UPDATE yz_backend_oa_schedule_entry SET shift_name=?, short_name=?, color=?, work_start=?, work_end=? "+
"WHERE tid=? AND shift_id=? AND is_deleted=0",
shift.Name, shift.ShortName, shift.Color, shift.WorkStart, shift.WorkEnd, tid, shift.ID,
).Exec()
}
@@ -0,0 +1,647 @@
package controllers
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
const scBoardMaxDays = 62
// scEntryView 看板单元格里的排班信息。
type scEntryView struct {
ID uint64 `json:"id"`
PlanID uint64 `json:"plan_id"`
ShiftID uint64 `json:"shift_id"`
ShiftName string `json:"shift_name"`
ShortName string `json:"short_name"`
Color string `json:"color"`
WorkStart string `json:"work_start"`
WorkEnd string `json:"work_end"`
Source int8 `json:"source"`
Note string `json:"note"`
}
type scBoardRow struct {
ID uint `json:"id"`
Name string `json:"name"`
Account string `json:"account"`
OrgID uint64 `json:"org_id"`
OrgName string `json:"org_name"`
Position string `json:"position"`
Days map[string]scEntryView `json:"days"`
}
type scDateCell struct {
Date string `json:"date"`
Weekday int `json:"weekday"`
IsToday bool `json:"is_today"`
}
func (c *BackendOaSchedulingController) scEntryQuery(tid uint64) orm.QuerySeter {
return models.Orm.QueryTable(new(models.OaScheduleEntry)).
Filter("tid", tid).
Filter("is_deleted", 0)
}
// Board 排班看板:按员工 × 日期返回区间内的排班矩阵。
// GET /backend/oa/scheduling/board?start_date=&end_date=&org_id=&include_child=1&keyword=
func (c *BackendOaSchedulingController) Board() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
startDate := strings.TrimSpace(c.GetString("start_date"))
endDate := strings.TrimSpace(c.GetString("end_date"))
if startDate == "" || endDate == "" {
// 默认展示本周(周一至周日)
now := time.Now()
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
monday := now.AddDate(0, 0, -(weekday - 1))
startDate = monday.Format("2006-01-02")
endDate = monday.AddDate(0, 0, 6).Format("2006-01-02")
}
dates, msg := scDateSpan(startDate, endDate)
if msg != "" {
c.scFail(400, 400, msg)
return
}
if len(dates) > scBoardMaxDays {
c.scFail(400, 400, fmt.Sprintf("看板一次最多展示 %d 天", scBoardMaxDays))
return
}
orgID := uint64(0)
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
if parsed, err := strconvParseUint(raw); err == nil {
orgID = parsed
}
}
includeChild := c.GetString("include_child") != "0"
keyword := strings.TrimSpace(c.GetString("keyword"))
onlyScheduled := c.GetString("only_scheduled") == "1"
shiftFilter := uint64(0)
if raw := strings.TrimSpace(c.GetString("shift_id")); raw != "" {
if parsed, err := strconvParseUint(raw); err == nil {
shiftFilter = parsed
}
}
employees, err := c.scEmployeesByOrg(tid, orgID, includeChild)
if err != nil {
c.scFail(500, 500, "查询员工失败: "+err.Error())
return
}
index := c.scLoadOrgIndex(tid)
rows := make([]scBoardRow, 0, len(employees))
employeeIDs := make([]uint64, 0, len(employees))
for _, employee := range employees {
if keyword != "" && !strings.Contains(employee.Name, keyword) && !strings.Contains(employee.Account, keyword) {
continue
}
orgID := scEmployeeDepartmentID(employee)
position := ""
if employee.Position != nil {
position = *employee.Position
}
rows = append(rows, scBoardRow{
ID: employee.ID,
Name: employee.Name,
Account: employee.Account,
OrgID: orgID,
OrgName: index.name(orgID),
Position: position,
Days: map[string]scEntryView{},
})
employeeIDs = append(employeeIDs, uint64(employee.ID))
}
truncated := false
const boardMaxEmployees = 500
if len(rows) > boardMaxEmployees {
rows = rows[:boardMaxEmployees]
employeeIDs = employeeIDs[:boardMaxEmployees]
truncated = true
}
if len(employeeIDs) > 0 {
var entries []models.OaScheduleEntry
_, err = c.scEntryQuery(tid).
Filter("employee_id__in", employeeIDs).
Filter("schedule_date__gte", dates[0]).
Filter("schedule_date__lte", dates[len(dates)-1]).
All(&entries)
if err != nil && err != orm.ErrNoRows {
c.scFail(500, 500, "查询排班失败: "+err.Error())
return
}
rowIndex := map[uint]*scBoardRow{}
for i := range rows {
rowIndex[rows[i].ID] = &rows[i]
}
for _, entry := range entries {
if shiftFilter > 0 && entry.ShiftID != shiftFilter {
continue
}
row, hit := rowIndex[entry.EmployeeID]
if !hit {
continue
}
row.Days[normalizeOaDate(entry.ScheduleDate)] = scEntryView{
ID: entry.ID,
PlanID: entry.PlanID,
ShiftID: entry.ShiftID,
ShiftName: entry.ShiftName,
ShortName: entry.ShortName,
Color: entry.Color,
WorkStart: entry.WorkStart,
WorkEnd: entry.WorkEnd,
Source: entry.Source,
Note: entry.Note,
}
}
}
if onlyScheduled {
filtered := rows[:0]
for _, row := range rows {
if len(row.Days) > 0 {
filtered = append(filtered, row)
}
}
rows = filtered
}
today := time.Now().Format("2006-01-02")
dateCells := make([]scDateCell, 0, len(dates))
for _, date := range dates {
parsed, _ := scParseDate(date)
weekday := int(parsed.Weekday())
if weekday == 0 {
weekday = 7
}
dateCells = append(dateCells, scDateCell{Date: date, Weekday: weekday, IsToday: date == today})
}
// 汇总:排班人次、临时调配数、未排班人数、今日在岗、班次分布
entryCount, tempCount, unassigned := 0, 0, 0
todayOnDuty := 0
shiftCounter := map[string]int{}
shiftColor := map[string]string{}
for _, row := range rows {
if len(row.Days) == 0 {
unassigned++
}
for date, entry := range row.Days {
entryCount++
if entry.Source == models.OaEntrySourceTemp {
tempCount++
}
if date == today {
todayOnDuty++
}
name := entry.ShiftName
if name == "" {
name = "未命名班次"
}
shiftCounter[name]++
shiftColor[name] = entry.Color
}
}
shiftDist := make([]map[string]interface{}, 0, len(shiftCounter))
for name, count := range shiftCounter {
shiftDist = append(shiftDist, map[string]interface{}{
"name": name,
"count": count,
"color": shiftColor[name],
})
}
sort.Slice(shiftDist, func(i, j int) bool {
return shiftDist[i]["count"].(int) > shiftDist[j]["count"].(int)
})
var shifts []models.OaShift
_, _ = c.shiftQuery(tid).Filter("status", 1).OrderBy("sort", "id").All(&shifts)
if shifts == nil {
shifts = []models.OaShift{}
}
c.scOK(map[string]interface{}{
"start_date": startDate,
"end_date": endDate,
"dates": dateCells,
"employees": rows,
"shifts": shifts,
"truncated": truncated,
"summary": map[string]interface{}{
"employee_count": len(rows),
"entry_count": entryCount,
"temp_count": tempCount,
"unassigned": unassigned,
"today_on_duty": todayOnDuty,
"shift_dist": shiftDist,
},
})
}
func strconvParseUint(value string) (uint64, error) {
return strconv.ParseUint(strings.TrimSpace(value), 10, 64)
}
// scEntryTargets 解析临时排班/批量清除的目标员工与日期。
func (c *BackendOaSchedulingController) scEntryTargets(tid uint64, body map[string]interface{}) ([]models.BackendEmployee, []string, string) {
employeeIDs := scUintSlice(body, "employee_ids")
if len(employeeIDs) == 0 {
return nil, nil, "请选择员工"
}
if len(employeeIDs) > scPlanMaxEmployees {
return nil, nil, fmt.Sprintf("单次最多操作 %d 名员工", scPlanMaxEmployees)
}
startDate := scStringValue(body, "start_date", "schedule_date", "date")
endDate := scStringValue(body, "end_date")
if endDate == "" {
endDate = startDate
}
dates, msg := scDateSpan(startDate, endDate)
if msg != "" {
return nil, nil, msg
}
workdays := scWorkdays(dates, scWeekdaySet(body))
if len(workdays) == 0 {
return nil, nil, "所选日期区间内没有符合条件的日期"
}
var employees []models.BackendEmployee
_, err := c.scBaseEmployeeQuery(tid).Filter("id__in", employeeIDs).All(&employees)
if err != nil && err != orm.ErrNoRows {
return nil, nil, "查询员工失败: " + err.Error()
}
if len(employees) == 0 {
return nil, nil, "员工不存在或已离职"
}
if len(employees)*len(workdays) > scPlanMaxEntries {
return nil, nil, fmt.Sprintf("本次将产生 %d 条排班,超过单次 %d 条上限", len(employees)*len(workdays), scPlanMaxEntries)
}
return employees, workdays, ""
}
// SaveEntries 临时排班:给一个或多个员工在指定日期/星期安排班次。
// shift_id=0 时表示清除这些日期的排班。
// POST /backend/oa/scheduling/entries/save
func (c *BackendOaSchedulingController) SaveEntries() {
tid, uid, username, ok := c.scOperator()
if !ok {
return
}
body := c.scBody()
employees, workdays, msg := c.scEntryTargets(tid, body)
if msg != "" {
c.scFail(400, 400, msg)
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
}
note := scStringValue(body, "note")
overwrite := true
if _, has := body["overwrite"]; has {
overwrite = scBoolValue(body, "overwrite")
}
empIDs := make([]uint64, 0, len(employees))
for _, employee := range employees {
empIDs = append(empIDs, uint64(employee.ID))
}
var existing []models.OaScheduleEntry
_, err := c.scEntryQuery(tid).
Filter("employee_id__in", empIDs).
Filter("schedule_date__gte", workdays[0]).
Filter("schedule_date__lte", workdays[len(workdays)-1]).
All(&existing)
if 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)
stats := map[string]int{"created": 0, "updated": 0, "cleared": 0, "skipped": 0}
now := time.Now()
for _, employee := range employees {
orgID := scEmployeeDepartmentID(employee)
position := ""
if employee.Position != nil {
position = *employee.Position
}
for _, date := range workdays {
key := fmt.Sprintf("%d|%s", employee.ID, date)
row := existingMap[key]
// shift_id=0:清除排班
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, date,
models.OaChangeClear, oldShiftID, oldShiftName, 0, "", note, uid, username)
delete(existingMap, key)
stats["cleared"]++
continue
}
if row != nil {
if !overwrite {
stats["skipped"]++
continue
}
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 = models.OaEntrySourceTemp
row.Note = note
row.CreatedBy = uid
row.CreatedByName = username
if _, updateErr := models.Orm.Update(row); updateErr != nil {
c.scFail(500, 500, "调整排班失败: "+updateErr.Error())
return
}
c.scWriteChange(models.Orm, tid, row.ID, row.EmployeeID, row.EmployeeName, 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: date,
ShiftID: shift.ID,
ShiftName: shift.Name,
ShortName: shift.ShortName,
Color: shift.Color,
WorkStart: shift.WorkStart,
WorkEnd: shift.WorkEnd,
Source: models.OaEntrySourceTemp,
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, date,
models.OaChangeManual, 0, "", shift.ID, shift.Name, note, uid, username)
stats["created"]++
}
}
stats["entry_count"] = stats["created"] + stats["updated"] + stats["cleared"]
c.scOK(stats)
}
// SwapEntry 单条调班:把某个员工某天的班次换成另一个班次。
// POST /backend/oa/scheduling/entries/swap/:id
func (c *BackendOaSchedulingController) SwapEntry() {
tid, uid, username, ok := c.scOperator()
if !ok {
return
}
id, valid := c.scPathID(":id")
if !valid {
c.scFail(400, 400, "无效的排班ID")
return
}
body := c.scBody()
shiftID := scUintValue(body, "shift_id")
if shiftID == 0 {
c.scFail(400, 400, "请选择调换后的班次")
return
}
shift, found := c.scLoadShift(tid, shiftID)
if !found {
c.scFail(400, 400, "班次不存在或已停用")
return
}
var entry models.OaScheduleEntry
if err := c.scEntryQuery(tid).Filter("id", id).One(&entry); err != nil {
c.scFail(404, 404, "排班记录不存在")
return
}
if entry.ShiftID == shift.ID {
c.scFail(400, 400, "该排班已经是目标班次")
return
}
reason := scStringValue(body, "reason")
oldShiftID, oldShiftName := entry.ShiftID, entry.ShiftName
entry.ShiftID = shift.ID
entry.ShiftName = shift.Name
entry.ShortName = shift.ShortName
entry.Color = shift.Color
entry.WorkStart = shift.WorkStart
entry.WorkEnd = shift.WorkEnd
entry.Source = models.OaEntrySourceTemp
if reason != "" {
entry.Note = reason
}
if _, err := models.Orm.Update(&entry); err != nil {
c.scFail(500, 500, "调班失败: "+err.Error())
return
}
c.scWriteChange(models.Orm, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, normalizeOaDate(entry.ScheduleDate),
models.OaChangeShift, oldShiftID, oldShiftName, shift.ID, shift.Name, reason, uid, username)
c.scOK(map[string]interface{}{"entry": entry, "old_shift_name": oldShiftName})
}
// ClearEntries 批量清除排班:按明细 ID,或按员工 + 日期区间。
// POST /backend/oa/scheduling/entries/clear
func (c *BackendOaSchedulingController) ClearEntries() {
tid, uid, username, ok := c.scOperator()
if !ok {
return
}
body := c.scBody()
entryIDs := scUintSlice(body, "entry_ids")
var entries []models.OaScheduleEntry
if len(entryIDs) > 0 {
_, err := c.scEntryQuery(tid).Filter("id__in", entryIDs).All(&entries)
if err != nil && err != orm.ErrNoRows {
c.scFail(500, 500, "查询排班失败: "+err.Error())
return
}
} else {
employees, workdays, msg := c.scEntryTargets(tid, body)
if msg != "" {
c.scFail(400, 400, msg)
return
}
empIDs := make([]uint64, 0, len(employees))
for _, employee := range employees {
empIDs = append(empIDs, uint64(employee.ID))
}
_, err := c.scEntryQuery(tid).
Filter("employee_id__in", empIDs).
Filter("schedule_date__in", workdays).
All(&entries)
if err != nil && err != orm.ErrNoRows {
c.scFail(500, 500, "查询排班失败: "+err.Error())
return
}
}
if len(entries) == 0 {
c.scOK(map[string]interface{}{"cleared": 0})
return
}
if len(entries) > scPlanMaxEntries {
c.scFail(400, 400, fmt.Sprintf("单次最多清除 %d 条排班", scPlanMaxEntries))
return
}
reason := scStringValue(body, "reason")
now := time.Now()
tx, err := orm.NewOrm().Begin()
if err != nil {
c.scFail(500, 500, "清除排班失败: "+err.Error())
return
}
for i := range entries {
entry := &entries[i]
oldShiftID, oldShiftName := entry.ShiftID, entry.ShiftName
entry.IsDeleted = entry.ID
entry.DeleteTime = &now
if _, err := tx.Update(entry, "is_deleted", "delete_time"); err != nil {
_ = tx.Rollback()
c.scFail(500, 500, "清除排班失败: "+err.Error())
return
}
c.scWriteChange(tx, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, normalizeOaDate(entry.ScheduleDate),
models.OaChangeClear, oldShiftID, oldShiftName, 0, "", reason, uid, username)
}
if err := tx.Commit(); err != nil {
c.scFail(500, 500, "清除排班失败: "+err.Error())
return
}
c.scOK(map[string]interface{}{"cleared": len(entries)})
}
// ChangeList 排班变更记录(最近 100 条),用于追溯临时调配。
// GET /backend/oa/scheduling/changes?start_date=&end_date=&employee_id=
func (c *BackendOaSchedulingController) ChangeList() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
qs := models.Orm.QueryTable(new(models.OaScheduleChange)).Filter("tid", tid)
if start := strings.TrimSpace(c.GetString("start_date")); isValidOaDate(start) {
qs = qs.Filter("schedule_date__gte", start)
}
if end := strings.TrimSpace(c.GetString("end_date")); isValidOaDate(end) {
qs = qs.Filter("schedule_date__lte", end)
}
if raw := strings.TrimSpace(c.GetString("employee_id")); raw != "" {
if employeeID, err := strconvParseUint(raw); err == nil {
qs = qs.Filter("employee_id", employeeID)
}
}
var rows []models.OaScheduleChange
_, err := qs.OrderBy("-id").Limit(100).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.scFail(500, 500, "查询变更记录失败: "+err.Error())
return
}
if rows == nil {
rows = []models.OaScheduleChange{}
}
for i := range rows {
rows[i].ScheduleDate = normalizeOaDate(rows[i].ScheduleDate)
}
c.scOK(map[string]interface{}{"list": rows})
}
// ScheduleEmployeeOptions 排班可选员工(供“指定员工”范围选择)。
// GET /backend/oa/scheduling/employees?org_id=&include_child=1&keyword=
func (c *BackendOaSchedulingController) ScheduleEmployeeOptions() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
orgID := uint64(0)
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
if parsed, err := strconvParseUint(raw); err == nil {
orgID = parsed
}
}
includeChild := c.GetString("include_child") != "0"
keyword := strings.TrimSpace(c.GetString("keyword"))
employees, err := c.scEmployeesByOrg(tid, orgID, includeChild)
if err != nil {
c.scFail(500, 500, "查询员工失败: "+err.Error())
return
}
list := c.scEmployeeDTOs(tid, employees)
if keyword != "" {
filtered := make([]schedulingEmployee, 0, len(list))
for _, item := range list {
if strings.Contains(item.Name, keyword) || strings.Contains(item.Account, keyword) {
filtered = append(filtered, item)
}
}
list = filtered
}
if list == nil {
list = []schedulingEmployee{}
}
c.scOK(map[string]interface{}{"list": list, "total": len(list)})
}
@@ -0,0 +1,353 @@
package controllers
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"server/models"
)
// scPatternDay 排班方案周期规则的每日快照。
//
// AlignMode=1/2/3 时,Plan.Pattern 保存该结构的 JSON 数组:模板的规则在
// 「调用模板」时复制进方案,之后平台模板怎么改都不影响已保存的方案。
// 发布方案时按自然周 / 开始日滚动 / 自然月推算每天命中哪一天规则。
type scPatternDay struct {
DayIndex int `json:"day_index"`
DayType int8 `json:"day_type"`
ShiftID uint64 `json:"shift_id"`
ShiftName string `json:"shift_name"`
ShortName string `json:"short_name"`
Color string `json:"color"`
WorkStart string `json:"work_start"`
WorkEnd string `json:"work_end"`
RestStart string `json:"rest_start"`
RestEnd string `json:"rest_end"`
WorkHours float64 `json:"work_hours"`
Remark string `json:"remark"`
}
// scPatternFromBody 解析前端提交的 pattern;兼容数组与 JSON 字符串两种形式。
func scPatternFromBody(body map[string]interface{}) []scPatternDay {
raw, ok := body["pattern"]
if !ok || raw == nil {
return nil
}
list := []scPatternDay{}
switch value := raw.(type) {
case string:
if strings.TrimSpace(value) == "" {
return nil
}
if err := json.Unmarshal([]byte(value), &list); err != nil {
return nil
}
case []interface{}:
buf, err := json.Marshal(value)
if err != nil {
return nil
}
if err := json.Unmarshal(buf, &list); err != nil {
return nil
}
default:
return nil
}
return list
}
func scPatternToJSON(days []scPatternDay) string {
if len(days) == 0 {
return ""
}
buf, err := json.Marshal(days)
if err != nil {
return ""
}
return string(buf)
}
func scPatternFromJSON(raw string) []scPatternDay {
days := []scPatternDay{}
if strings.TrimSpace(raw) == "" {
return days
}
if err := json.Unmarshal([]byte(raw), &days); err != nil {
return []scPatternDay{}
}
return days
}
// scValidatePattern 校验周期规则,返回补齐默认值并按 day_index 排序后的规则。
func scValidatePattern(days []scPatternDay, alignMode int8, cycleDays int) ([]scPatternDay, string) {
if alignMode < models.SchedTplAlignWeek || alignMode > models.SchedTplAlignMonth {
return nil, "排班方式不合法"
}
if len(days) == 0 {
return nil, "缺少排班规则,请重新选择模板或班次"
}
if alignMode == models.SchedTplAlignMonth {
// 月休按自然月计算,周期天数无意义
cycleDays = 0
} else {
if cycleDays < 2 || cycleDays > 31 {
return nil, "周期天数需在 2~31 天之间"
}
if alignMode == models.SchedTplAlignWeek && cycleDays%7 != 0 {
return nil, "按自然周排班时,周期天数必须是 7 的倍数(7 / 14 / 21 / 28)"
}
if len(days) != cycleDays {
return nil, fmt.Sprintf("周期为 %d 天,需要配置 %d 天的规则", cycleDays, cycleDays)
}
}
seen := map[int]bool{}
workCount := 0
result := make([]scPatternDay, 0, len(days))
for _, item := range days {
index := item.DayIndex
if alignMode == models.SchedTplAlignMonth {
index = len(result)
}
if index < 0 || (alignMode != models.SchedTplAlignMonth && index >= cycleDays) {
return nil, "规则的天序号不合法"
}
if seen[index] {
return nil, "规则的天序号重复"
}
seen[index] = true
if item.DayType != models.SchedTplDayWork && item.DayType != models.SchedTplDayRest {
return nil, "规则类型不合法(1=上班 2=休息)"
}
row := scPatternDay{DayIndex: index, DayType: item.DayType, Remark: strings.TrimSpace(item.Remark)}
if item.DayType == models.SchedTplDayWork {
workCount++
row.ShiftID = item.ShiftID
row.ShiftName = strings.TrimSpace(item.ShiftName)
row.ShortName = strings.TrimSpace(item.ShortName)
row.Color = strings.TrimSpace(item.Color)
row.WorkStart = strings.TrimSpace(item.WorkStart)
row.WorkEnd = strings.TrimSpace(item.WorkEnd)
row.RestStart = strings.TrimSpace(item.RestStart)
row.RestEnd = strings.TrimSpace(item.RestEnd)
row.WorkHours = item.WorkHours
if row.ShiftName == "" {
return nil, fmt.Sprintf("第 %d 天选择上班,班次不能为空", index+1)
}
if row.ShortName == "" {
row.ShortName = row.ShiftName
}
if row.Color == "" {
row.Color = "#409EFF"
}
if !isValidOaTime(row.WorkStart) || !isValidOaTime(row.WorkEnd) {
return nil, fmt.Sprintf("第 %d 天的上下班时间格式应为 HH:mm", index+1)
}
if row.WorkStart == row.WorkEnd {
return nil, fmt.Sprintf("第 %d 天的上下班时间不能相同", index+1)
}
if (row.RestStart == "") != (row.RestEnd == "") {
return nil, fmt.Sprintf("第 %d 天的休息开始与结束时间需要同时填写", index+1)
}
if row.RestStart != "" && (!isValidOaTime(row.RestStart) || !isValidOaTime(row.RestEnd)) {
return nil, fmt.Sprintf("第 %d 天的休息时间格式应为 HH:mm", index+1)
}
if row.WorkHours <= 0 {
row.WorkHours = scCalcWorkHours(row.WorkStart, row.WorkEnd, row.RestStart, row.RestEnd)
}
}
result = append(result, row)
}
if workCount == 0 {
return nil, "排班规则至少需要一天上班"
}
sort.Slice(result, func(i, j int) bool { return result[i].DayIndex < result[j].DayIndex })
return result, ""
}
// scPrimaryPatternShift 取规则里第一个上班日,用于方案列表展示班次名 / 颜色。
func scPrimaryPatternShift(days []scPatternDay) (scPatternDay, bool) {
for _, item := range days {
if item.DayType == models.SchedTplDayWork {
return item, true
}
}
return scPatternDay{}, false
}
// scPlanPatternDays 取方案中保存的周期规则。
func scPlanPatternDays(plan *models.OaSchedulePlan) []scPatternDay {
return scPatternFromJSON(plan.Pattern)
}
// scPlanWorkRules 计算方案在日期区间内「需要上班」的日期与对应的班次规则。
//
// fallback 是按星期模式(AlignMode=0)使用的固定班次;模板模式下可为 nil。
// 返回的 map 以日期字符串为键,workdays 为升序的上班日期列表。
func scPlanWorkRules(plan *models.OaSchedulePlan, fallback *models.OaShift, dates []string) (map[string]scPatternDay, []string, string) {
pattern := scPlanPatternDays(plan)
if len(pattern) == 0 && fallback == nil {
return nil, nil, "排班规则为空,请重新选择模板或班次"
}
start, ok := scParseDate(normalizeOaDate(plan.StartDate))
if !ok {
return nil, nil, "开始日期格式错误"
}
rules := make(map[int]scPatternDay, len(pattern))
for _, item := range pattern {
rules[item.DayIndex] = item
}
weekdays := map[int]bool{}
if strings.TrimSpace(plan.Weekdays) != "" {
list := []int{}
if err := json.Unmarshal([]byte(plan.Weekdays), &list); err == nil {
for _, day := range list {
weekdays[day] = true
}
}
}
result := make(map[string]scPatternDay, len(dates))
workdays := make([]string, 0, len(dates))
for _, date := range dates {
day, valid := scParseDate(date)
if !valid {
continue
}
rule, work := scRuleForDate(plan, rules, fallback, start, day, weekdays)
if !work {
continue
}
result[date] = rule
workdays = append(workdays, date)
}
return result, workdays, ""
}
// scRuleForDate 推算某一天命中的规则;第二个返回值表示当天是否上班。
func scRuleForDate(plan *models.OaSchedulePlan, rules map[int]scPatternDay, fallback *models.OaShift,
start, day time.Time, weekdays map[int]bool) (scPatternDay, bool) {
switch plan.AlignMode {
case models.SchedTplAlignWeek:
weeks := plan.CycleDays / 7
if weeks < 1 {
weeks = 1
}
startMonday := start.AddDate(0, 0, -scWeekdayOffset(start))
curMonday := day.AddDate(0, 0, -scWeekdayOffset(day))
diffWeeks := int(curMonday.Sub(startMonday).Hours()/24) / 7
weekIndex := scMod(diffWeeks+int(plan.WeekOffset), weeks)
return scRuleOfDay(rules, weekIndex*7+scWeekdayOffset(day))
case models.SchedTplAlignRoll:
cycle := plan.CycleDays
if cycle < 1 {
cycle = 1
}
diffDays := int(day.Sub(start).Hours() / 24)
return scRuleOfDay(rules, scMod(diffDays, cycle))
case models.SchedTplAlignMonth:
if scMonthRestDay(plan, day) {
return scPatternDay{}, false
}
index := 0
if rule, ok := rules[0]; ok && rule.DayType != models.SchedTplDayWork {
for _, item := range rules {
if item.DayType == models.SchedTplDayWork {
index = item.DayIndex
break
}
}
}
return scRuleOfDay(rules, index)
}
// 按星期模式(AlignMode=0):weekdays 为空表示每天都上班
weekday := int(day.Weekday())
if weekday == 0 {
weekday = 7
}
if len(weekdays) > 0 && !weekdays[weekday] {
return scPatternDay{}, false
}
if fallback == nil {
return scPatternDay{}, false
}
return scPatternDay{
DayType: models.SchedTplDayWork,
ShiftID: fallback.ID,
ShiftName: fallback.Name,
ShortName: fallback.ShortName,
Color: fallback.Color,
WorkStart: fallback.WorkStart,
WorkEnd: fallback.WorkEnd,
RestStart: fallback.RestStart,
RestEnd: fallback.RestEnd,
WorkHours: fallback.WorkHours,
}, true
}
func scRuleOfDay(rules map[int]scPatternDay, index int) (scPatternDay, bool) {
rule, ok := rules[index]
if !ok || rule.DayType != models.SchedTplDayWork {
return scPatternDay{}, false
}
return rule, true
}
// scWeekdayOffset 返回相对本周周一的天数(周一=0 ... 周日=6)。
func scWeekdayOffset(day time.Time) int {
return (int(day.Weekday()) + 6) % 7
}
func scMod(value, base int) int {
if base <= 0 {
return 0
}
result := value % base
if result < 0 {
result += base
}
return result
}
// scMonthRestDay 判断自然月排班中某天是否休息。
//
// 指定休息周几时,先取当月该周几的前 N 天;不足 N 天时从月末向前补齐。
// 未指定周几(0)时直接取月末最后 N 天。
func scMonthRestDay(plan *models.OaSchedulePlan, day time.Time) bool {
restDays := int(plan.MonthRestDays)
if restDays <= 0 {
return false
}
year, month := day.Year(), day.Month()
first := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
daysInMonth := first.AddDate(0, 1, -1).Day()
if restDays >= daysInMonth {
return true
}
restSet := map[int]bool{}
weekday := int(plan.MonthRestWeekday)
if weekday >= 1 && weekday <= 7 {
for d := 1; d <= daysInMonth && len(restSet) < restDays; d++ {
w := int(time.Date(year, month, d, 0, 0, 0, 0, time.Local).Weekday())
if w == 0 {
w = 7
}
if w == weekday {
restSet[d] = true
}
}
}
for d := daysInMonth; d >= 1 && len(restSet) < restDays; d-- {
restSet[d] = true
}
return restSet[day.Day()]
}
@@ -0,0 +1,872 @@
package controllers
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// scPlanMaxEmployees / scPlanMaxEntries 限制单次方案规模,避免误选整个公司
// 再叠加长周期导致一次写入数万条明细。
const (
scPlanMaxEmployees = 500
scPlanMaxEntries = 10000
)
// scPlanInput 排班方案的入参(新建 / 编辑 / 预览共用)。
//
// 两种排班方式:
// - 按星期:Weekdays + ShiftID,每天固定一个班次;
// - 按模板:Pattern + AlignMode,规则来自平台模板(可在租户端微调),
// 此时 ShiftID 允许为 0。
type scPlanInput struct {
PlanName string
PlanType int8
ScopeType int8
OrgID uint64
IncludeChild bool
EmployeeIDs []uint64
StartDate string
EndDate string
Weekdays map[int]bool
ShiftID uint64
Note string
TemplateID uint64
TemplateCode string
TemplateName string
AlignMode int8
CycleDays int
WeekOffset int8
MonthRestDays int8
MonthRestWeekday int8
Pattern []scPatternDay
}
func (c *BackendOaSchedulingController) scPlanInputFromBody(body map[string]interface{}, requireName bool) (*scPlanInput, string) {
input := &scPlanInput{
PlanName: scStringValue(body, "plan_name"),
ScopeType: models.OaScopeOrg,
IncludeChild: true,
StartDate: scStringValue(body, "start_date"),
EndDate: scStringValue(body, "end_date"),
Weekdays: scWeekdaySet(body),
Note: scStringValue(body, "note"),
ShiftID: scUintValue(body, "shift_id"),
EmployeeIDs: scUintSlice(body, "employee_ids"),
TemplateID: scUintValue(body, "template_id"),
TemplateCode: scStringValue(body, "template_code"),
TemplateName: scStringValue(body, "template_name"),
Pattern: scPatternFromBody(body),
}
if requireName && input.PlanName == "" {
return nil, "方案名称不能为空"
}
if len([]rune(input.PlanName)) > 100 {
return nil, "方案名称不能超过 100 个字符"
}
if value, ok := scIntValue(body, "plan_type"); ok {
input.PlanType = int8(value)
} else {
input.PlanType = models.OaPlanTypeRegular
}
if input.PlanType != models.OaPlanTypeRegular && input.PlanType != models.OaPlanTypeTemp {
return nil, "方案类型不合法"
}
if value, ok := scIntValue(body, "scope_type"); ok {
input.ScopeType = int8(value)
}
if input.ScopeType < models.OaScopeCompany || input.ScopeType > models.OaScopeEmployees {
return nil, "排班范围不合法"
}
input.OrgID = scUintValue(body, "org_id")
if input.ScopeType == models.OaScopeOrg && input.OrgID == 0 {
return nil, "请选择要排班的部门或小组"
}
if input.ScopeType == models.OaScopeEmployees && len(input.EmployeeIDs) == 0 {
return nil, "请选择需要排班的员工"
}
if _, has := body["include_child"]; has {
input.IncludeChild = scBoolValue(body, "include_child")
}
if _, msg := scDateSpan(input.StartDate, input.EndDate); msg != "" {
return nil, msg
}
if len(input.Pattern) > 0 {
// 模板模式:校验周期规则,班次信息随规则一起保存
if value, ok := scIntValue(body, "align_mode"); ok {
input.AlignMode = int8(value)
}
if value, ok := scIntValue(body, "cycle_days"); ok {
input.CycleDays = value
}
if value, ok := scIntValue(body, "week_offset"); ok {
input.WeekOffset = int8(value)
}
if value, ok := scIntValue(body, "month_rest_days"); ok {
input.MonthRestDays = int8(value)
}
if value, ok := scIntValue(body, "month_rest_weekday"); ok {
input.MonthRestWeekday = int8(value)
}
days, msg := scValidatePattern(input.Pattern, input.AlignMode, input.CycleDays)
if msg != "" {
return nil, msg
}
input.Pattern = days
if input.TemplateName == "" {
input.TemplateName = "自定义规则"
}
} else {
if input.ShiftID == 0 {
return nil, "请选择班次"
}
input.AlignMode = 0
}
if len(input.EmployeeIDs) > scPlanMaxEmployees {
return nil, fmt.Sprintf("单次最多为 %d 名员工排班,请缩小范围", scPlanMaxEmployees)
}
return input, ""
}
func (c *BackendOaSchedulingController) scLoadShift(tid, id uint64) (*models.OaShift, bool) {
shift, ok := c.shiftByID(tid, id)
if !ok || shift.Status != 1 {
return nil, false
}
return shift, true
}
func scWeekdayList(set map[int]bool) []int {
list := make([]int, 0, 7)
for day := 1; day <= 7; day++ {
if set[day] {
list = append(list, day)
}
}
return list
}
// scPlanView 把方案转成前端友好的结构:employee_ids / weekdays 直接给数组,
// 避免前端再解析 JSON 字符串。
func scPlanView(plan *models.OaSchedulePlan) map[string]interface{} {
employeeIDs := []uint64{}
if strings.TrimSpace(plan.EmployeeIDs) != "" {
_ = json.Unmarshal([]byte(plan.EmployeeIDs), &employeeIDs)
}
weekdays := []int{}
if strings.TrimSpace(plan.Weekdays) != "" {
_ = json.Unmarshal([]byte(plan.Weekdays), &weekdays)
}
return map[string]interface{}{
"id": plan.ID,
"plan_name": plan.PlanName,
"plan_type": plan.PlanType,
"scope_type": plan.ScopeType,
"org_id": plan.OrgID,
"org_name": plan.OrgName,
"include_child": plan.IncludeChild,
"employee_ids": employeeIDs,
"employee_names": plan.EmployeeNames,
"start_date": normalizeOaDate(plan.StartDate),
"end_date": normalizeOaDate(plan.EndDate),
"weekdays": weekdays,
"shift_id": plan.ShiftID,
"shift_name": plan.ShiftName,
"color": plan.Color,
"template_id": plan.TemplateID,
"template_code": plan.TemplateCode,
"template_name": plan.TemplateName,
"align_mode": plan.AlignMode,
"cycle_days": plan.CycleDays,
"week_offset": plan.WeekOffset,
"month_rest_days": plan.MonthRestDays,
"month_rest_weekday": plan.MonthRestWeekday,
"pattern": scPlanPatternDays(plan),
"note": plan.Note,
"status": plan.Status,
"publish_status": plan.PublishStatus,
"entry_count": plan.EntryCount,
"created_by": plan.CreatedBy,
"created_by_name": plan.CreatedByName,
"create_time": plan.CreateTime.Format("2006-01-02 15:04:05"),
"update_time": plan.UpdateTime,
}
}
func (c *BackendOaSchedulingController) scPlanQuery(tid uint64) orm.QuerySeter {
return models.Orm.QueryTable(new(models.OaSchedulePlan)).
Filter("tid", tid).
Filter("is_deleted", 0)
}
// PlanList 排班方案列表。
// GET /backend/oa/scheduling/plans
func (c *BackendOaSchedulingController) PlanList() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("page_size", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 20
}
qs := c.scPlanQuery(tid)
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
qs = qs.Filter("plan_name__icontains", keyword)
}
if raw := strings.TrimSpace(c.GetString("status")); raw != "" {
if value, err := strconv.Atoi(raw); err == nil {
qs = qs.Filter("status", value)
}
}
if raw := strings.TrimSpace(c.GetString("plan_type")); raw != "" {
if value, err := strconv.Atoi(raw); err == nil {
qs = qs.Filter("plan_type", value)
}
}
total, _ := qs.Count()
var rows []models.OaSchedulePlan
_, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.scFail(500, 500, "查询排班方案失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, scPlanView(&rows[i]))
}
c.scOK(map[string]interface{}{
"list": list,
"total": total,
"page": page,
})
}
// PlanDetail 排班方案详情。
// GET /backend/oa/scheduling/plans/detail/:id
func (c *BackendOaSchedulingController) PlanDetail() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
id, valid := c.scPathID(":id")
if !valid {
c.scFail(400, 400, "无效的方案ID")
return
}
var plan models.OaSchedulePlan
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
c.scFail(404, 404, "排班方案不存在")
return
}
c.scOK(scPlanView(&plan))
}
// PlanPreview 预览方案覆盖的员工与班次数,不写入任何数据。
// POST /backend/oa/scheduling/plans/preview
func (c *BackendOaSchedulingController) PlanPreview() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
body := c.scBody()
input, msg := c.scPlanInputFromBody(body, false)
if msg != "" {
c.scFail(400, 400, msg)
return
}
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
var shift *models.OaShift
if len(input.Pattern) == 0 {
loaded, found := c.scLoadShift(tid, input.ShiftID)
if !found {
c.scFail(400, 400, "班次不存在或已停用")
return
}
shift = loaded
}
employees, err := c.scResolveEmployees(tid, input.ScopeType, input.OrgID, input.IncludeChild, input.EmployeeIDs)
if err != nil {
c.scFail(500, 500, "展开排班范围失败: "+err.Error())
return
}
if len(employees) == 0 {
c.scOK(map[string]interface{}{
"employee_count": 0,
"employee_list": []schedulingEmployee{},
"dates": []string{},
"entry_count": 0,
"rest_count": 0,
"shift": shift,
"scope_name": c.scScopeName(tid, input),
"template_name": input.TemplateName,
"pattern": input.Pattern,
})
return
}
if len(employees) > scPlanMaxEmployees {
c.scFail(400, 400, fmt.Sprintf("范围覆盖 %d 名员工,超过单次 %d 人上限,请缩小范围", len(employees), scPlanMaxEmployees))
return
}
dates, msg := scDateSpan(input.StartDate, input.EndDate)
if msg != "" {
c.scFail(400, 400, msg)
return
}
previewPlan := &models.OaSchedulePlan{
StartDate: input.StartDate,
EndDate: input.EndDate,
Weekdays: scWeekdaysJSON(input.Weekdays),
AlignMode: input.AlignMode,
CycleDays: input.CycleDays,
WeekOffset: input.WeekOffset,
MonthRestDays: input.MonthRestDays,
MonthRestWeekday: input.MonthRestWeekday,
Pattern: scPatternToJSON(input.Pattern),
}
_, workdays, msg := scPlanWorkRules(previewPlan, shift, dates)
if msg != "" {
c.scFail(400, 400, msg)
return
}
if len(workdays) == 0 {
c.scFail(400, 400, "所选日期区间内没有需要上班的日期")
return
}
entryCount := len(employees) * len(workdays)
if entryCount > scPlanMaxEntries {
c.scFail(400, 400, fmt.Sprintf("本次将生成 %d 条排班,超过单次 %d 条上限,请缩短日期或缩小范围", entryCount, scPlanMaxEntries))
return
}
list := c.scEmployeeDTOs(tid, employees)
c.scOK(map[string]interface{}{
"employee_count": len(employees),
"employee_list": list,
"dates": workdays,
"workday_count": len(workdays),
"rest_count": len(dates) - len(workdays),
"entry_count": entryCount,
"shift": shift,
"scope_name": c.scScopeName(tid, input),
"template_name": input.TemplateName,
"pattern": input.Pattern,
})
}
// scWeekdaysJSON 把星期集合转成与方案存储一致的 JSON 数组。
func scWeekdaysJSON(set map[int]bool) string {
list := scWeekdayList(set)
if len(list) == 0 {
return ""
}
buf, err := json.Marshal(list)
if err != nil {
return ""
}
return string(buf)
}
// scScopeName 给方案生成可读的范围描述。
func (c *BackendOaSchedulingController) scScopeName(tid uint64, input *scPlanInput) string {
switch input.ScopeType {
case models.OaScopeCompany:
return "全公司"
case models.OaScopeEmployees:
employees, err := c.scEmployeesByIDs(tid, input.EmployeeIDs)
if err != nil || len(employees) == 0 {
return fmt.Sprintf("指定员工(%d 人)", len(input.EmployeeIDs))
}
names := make([]string, 0, len(employees))
for _, employee := range employees {
names = append(names, employee.Name)
}
if len(names) > 3 {
return fmt.Sprintf("%s 等 %d 人", strings.Join(names[:3], "、"), len(names))
}
return strings.Join(names, "、")
default:
name := c.scLoadOrgIndex(tid).name(input.OrgID)
if name == "" {
name = "部门"
}
if input.IncludeChild {
return name + "(含下级)"
}
return name
}
}
// PlanCreate 新建排班方案。
// POST /backend/oa/scheduling/plans/create
func (c *BackendOaSchedulingController) PlanCreate() {
tid, uid, username, ok := c.scOperator()
if !ok {
return
}
body := c.scBody()
input, msg := c.scPlanInputFromBody(body, true)
if msg != "" {
c.scFail(400, 400, msg)
return
}
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
var shift *models.OaShift
if len(input.Pattern) == 0 {
loaded, found := c.scLoadShift(tid, input.ShiftID)
if !found {
c.scFail(400, 400, "班次不存在或已停用")
return
}
shift = loaded
}
weekdays := scWeekdayList(input.Weekdays)
weekdayJSON, _ := json.Marshal(weekdays)
employeeJSON, _ := json.Marshal(input.EmployeeIDs)
plan := &models.OaSchedulePlan{
Tid: int(tid),
PlanName: input.PlanName,
PlanType: input.PlanType,
ScopeType: input.ScopeType,
OrgID: input.OrgID,
OrgName: c.scScopeName(tid, input),
IncludeChild: scBoolToInt8(input.IncludeChild),
EmployeeIDs: string(employeeJSON),
EmployeeNames: c.scEmployeeNames(tid, input.EmployeeIDs),
StartDate: input.StartDate,
EndDate: input.EndDate,
Weekdays: string(weekdayJSON),
Note: input.Note,
Status: 1,
PublishStatus: 0,
CreatedBy: uid,
CreatedByName: username,
}
scApplyPlanRules(plan, input, shift)
if _, err := models.Orm.Insert(plan); err != nil {
c.scFail(500, 500, "保存排班方案失败: "+err.Error())
return
}
// publish_now=true 时创建后立即下发,减少“建完再点发布”的一步操作。
if scBoolValue(body, "publish_now") {
stats, publishErr := c.scPublishPlan(tid, uid, username, plan, true)
if publishErr != nil {
c.scFail(500, 500, "方案已保存,但发布失败: "+publishErr.Error())
return
}
c.scOK(map[string]interface{}{"plan": scPlanView(plan), "publish": stats})
return
}
c.scOK(map[string]interface{}{"plan": scPlanView(plan)})
}
func scBoolToInt8(value bool) int8 {
if value {
return 1
}
return 0
}
// scApplyPlanRules 把入参的排班规则写入方案:
// - 带 pattern:保存模板快照与周期参数,方案的展示班次取第一个上班日;
// - 不带 pattern:回到按星期模式,清空模板字段。
func scApplyPlanRules(plan *models.OaSchedulePlan, input *scPlanInput, shift *models.OaShift) {
if len(input.Pattern) > 0 {
plan.TemplateID = input.TemplateID
plan.TemplateCode = input.TemplateCode
plan.TemplateName = input.TemplateName
plan.AlignMode = input.AlignMode
plan.CycleDays = input.CycleDays
if plan.AlignMode == models.SchedTplAlignMonth {
plan.CycleDays = 0
}
plan.WeekOffset = input.WeekOffset
plan.MonthRestDays = input.MonthRestDays
plan.MonthRestWeekday = input.MonthRestWeekday
plan.Pattern = scPatternToJSON(input.Pattern)
if primary, ok := scPrimaryPatternShift(input.Pattern); ok {
plan.ShiftID = primary.ShiftID
plan.ShiftName = primary.ShiftName
plan.Color = primary.Color
}
return
}
plan.TemplateID = 0
plan.TemplateCode = ""
plan.TemplateName = ""
plan.AlignMode = 0
plan.CycleDays = 0
plan.WeekOffset = 0
plan.MonthRestDays = 0
plan.MonthRestWeekday = 0
plan.Pattern = ""
if shift != nil {
plan.ShiftID = shift.ID
plan.ShiftName = shift.Name
plan.Color = shift.Color
}
}
func (c *BackendOaSchedulingController) scEmployeeNames(tid uint64, ids []uint64) string {
if len(ids) == 0 {
return ""
}
employees, err := c.scEmployeesByIDs(tid, ids)
if err != nil {
return ""
}
names := make([]string, 0, len(employees))
for _, employee := range employees {
names = append(names, employee.Name)
}
return strings.Join(names, "、")
}
// PlanUpdate 更新排班方案。已发布的方案会回到“未发布”状态,
// 需要重新发布后新规则才会写入明细(已有明细不会被自动清除)。
// POST /backend/oa/scheduling/plans/update/:id
func (c *BackendOaSchedulingController) PlanUpdate() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
id, valid := c.scPathID(":id")
if !valid {
c.scFail(400, 400, "无效的方案ID")
return
}
var plan models.OaSchedulePlan
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
c.scFail(404, 404, "排班方案不存在")
return
}
body := c.scBody()
input, msg := c.scPlanInputFromBody(body, true)
if msg != "" {
c.scFail(400, 400, msg)
return
}
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
var shift *models.OaShift
if len(input.Pattern) == 0 {
loaded, found := c.scLoadShift(tid, input.ShiftID)
if !found {
c.scFail(400, 400, "班次不存在或已停用")
return
}
shift = loaded
}
weekdays := scWeekdayList(input.Weekdays)
weekdayJSON, _ := json.Marshal(weekdays)
employeeJSON, _ := json.Marshal(input.EmployeeIDs)
plan.PlanName = input.PlanName
plan.PlanType = input.PlanType
plan.ScopeType = input.ScopeType
plan.OrgID = input.OrgID
plan.OrgName = c.scScopeName(tid, input)
plan.IncludeChild = scBoolToInt8(input.IncludeChild)
plan.EmployeeIDs = string(employeeJSON)
plan.EmployeeNames = c.scEmployeeNames(tid, input.EmployeeIDs)
plan.StartDate = input.StartDate
plan.EndDate = input.EndDate
plan.Weekdays = string(weekdayJSON)
plan.Note = input.Note
plan.Status = 1
plan.PublishStatus = 0
scApplyPlanRules(&plan, input, shift)
if _, err := models.Orm.Update(&plan); err != nil {
c.scFail(500, 500, "更新排班方案失败: "+err.Error())
return
}
c.scOK(map[string]interface{}{"plan": scPlanView(&plan)})
}
// PlanPublish 发布方案:把规则展开成每人每天的排班明细。
// POST /backend/oa/scheduling/plans/publish/:id
// body: { overwrite: true } 覆盖同一员工同一天已有排班,默认保留原排班。
func (c *BackendOaSchedulingController) PlanPublish() {
tid, uid, username, ok := c.scOperator()
if !ok {
return
}
id, valid := c.scPathID(":id")
if !valid {
c.scFail(400, 400, "无效的方案ID")
return
}
var plan models.OaSchedulePlan
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
c.scFail(404, 404, "排班方案不存在")
return
}
if plan.Status != 1 {
c.scFail(400, 400, "方案已停用,请先启用")
return
}
body := c.scBody()
stats, err := c.scPublishPlan(tid, uid, username, &plan, scBoolValue(body, "overwrite"))
if err != nil {
c.scFail(500, 500, "发布排班失败: "+err.Error())
return
}
c.scOK(map[string]interface{}{"plan": scPlanView(&plan), "publish": stats})
}
// scPublishPlan 方案展开与写入。同一员工同一天只保留一条有效明细:
// - 不存在 -> 新增
// - 已存在且 overwrite=false -> 跳过(保留手工调整结果)
// - 已存在且 overwrite=true -> 覆盖为新班次并记录变更
func (c *BackendOaSchedulingController) scPublishPlan(tid, uid uint64, username string, plan *models.OaSchedulePlan, overwrite bool) (map[string]int, error) {
// 模板模式(AlignMode=1/2/3)由 pattern 携带每天班次;按星期模式回退到方案绑定的班次
var fallback *models.OaShift
if plan.AlignMode == 0 {
loaded, found := c.scLoadShift(tid, plan.ShiftID)
if !found {
return nil, errors.New("方案绑定的班次不存在或已停用")
}
fallback = loaded
}
employeeIDs := []uint64{}
if strings.TrimSpace(plan.EmployeeIDs) != "" {
_ = json.Unmarshal([]byte(plan.EmployeeIDs), &employeeIDs)
}
employees, err := c.scResolveEmployees(tid, plan.ScopeType, plan.OrgID, plan.IncludeChild == 1, employeeIDs)
if err != nil {
return nil, err
}
if len(employees) == 0 {
return nil, errors.New("方案范围内没有可排班的员工")
}
if len(employees) > scPlanMaxEmployees {
return nil, fmt.Errorf("范围覆盖 %d 名员工,超过单次 %d 人上限", len(employees), scPlanMaxEmployees)
}
dates, msg := scDateSpan(normalizeOaDate(plan.StartDate), normalizeOaDate(plan.EndDate))
if msg != "" {
return nil, errors.New(msg)
}
rules, workdays, msg := scPlanWorkRules(plan, fallback, dates)
if msg != "" {
return nil, errors.New(msg)
}
if len(workdays) == 0 {
return nil, errors.New("方案日期区间内没有需要上班的日期")
}
if len(employees)*len(workdays) > scPlanMaxEntries {
return nil, fmt.Errorf("本次将生成 %d 条排班,超过单次 %d 条上限", len(employees)*len(workdays), scPlanMaxEntries)
}
index := c.scLoadOrgIndex(tid)
empIDs := make([]uint64, 0, len(employees))
for _, employee := range employees {
empIDs = append(empIDs, uint64(employee.ID))
}
var existing []models.OaScheduleEntry
_, err = models.Orm.QueryTable(new(models.OaScheduleEntry)).
Filter("tid", tid).
Filter("is_deleted", 0).
Filter("employee_id__in", empIDs).
Filter("schedule_date__gte", workdays[0]).
Filter("schedule_date__lte", workdays[len(workdays)-1]).
All(&existing)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
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]
}
stats := map[string]int{"created": 0, "updated": 0, "skipped": 0}
tx, err := orm.NewOrm().Begin()
if err != nil {
return nil, err
}
for _, employee := range employees {
orgID := scEmployeeDepartmentID(employee)
position := ""
if employee.Position != nil {
position = *employee.Position
}
for _, date := range workdays {
rule := rules[date]
key := fmt.Sprintf("%d|%s", employee.ID, date)
if row, hit := existingMap[key]; hit {
if !overwrite {
stats["skipped"]++
continue
}
if row.ShiftID == rule.ShiftID && row.ShiftName == rule.ShiftName && row.Source == models.OaEntrySourcePlan && row.PlanID == plan.ID {
stats["skipped"]++
continue
}
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
row.PlanID = plan.ID
row.ShiftID = rule.ShiftID
row.ShiftName = rule.ShiftName
row.ShortName = rule.ShortName
row.Color = rule.Color
row.WorkStart = rule.WorkStart
row.WorkEnd = rule.WorkEnd
row.Source = models.OaEntrySourcePlan
if _, updateErr := tx.Update(row); updateErr != nil {
_ = tx.Rollback()
return nil, updateErr
}
c.scWriteChange(tx, tid, row.ID, row.EmployeeID, row.EmployeeName, date,
models.OaChangePublish, oldShiftID, oldShiftName, rule.ShiftID, rule.ShiftName, "方案重新发布覆盖", uid, username)
stats["updated"]++
continue
}
entry := &models.OaScheduleEntry{
Tid: int(tid),
PlanID: plan.ID,
EmployeeID: employee.ID,
EmployeeName: employee.Name,
EmployeeAccount: employee.Account,
OrgID: orgID,
OrgName: index.name(orgID),
Position: position,
ScheduleDate: date,
ShiftID: rule.ShiftID,
ShiftName: rule.ShiftName,
ShortName: rule.ShortName,
Color: rule.Color,
WorkStart: rule.WorkStart,
WorkEnd: rule.WorkEnd,
Source: models.OaEntrySourcePlan,
Note: plan.Note,
CreatedBy: uid,
CreatedByName: username,
}
if _, insertErr := tx.Insert(entry); insertErr != nil {
_ = tx.Rollback()
return nil, insertErr
}
existingMap[key] = entry
stats["created"]++
}
}
now := time.Now()
plan.PublishStatus = 1
plan.EntryCount = stats["created"] + stats["updated"]
plan.UpdateTime = &now
if _, err := tx.Update(plan, "publish_status", "entry_count", "update_time"); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
stats["employee_count"] = len(employees)
stats["workday_count"] = len(workdays)
return stats, nil
}
// scChangeInserter 抽取 Insert,使普通 Ormer 与事务 TxOrmer 都能写变更记录。
type scChangeInserter interface {
Insert(interface{}) (int64, error)
}
// scWriteChange 写一条排班变更记录;失败不阻断主流程。
func (c *BackendOaSchedulingController) scWriteChange(tx scChangeInserter, tid uint64, entryID uint64, employeeID uint, employeeName, date string,
changeType int8, oldShiftID uint64, oldShiftName string, newShiftID uint64, newShiftName, reason string, operatorID uint64, operatorName string) {
if tx == nil || (oldShiftID == newShiftID && oldShiftName == newShiftName) {
return
}
_, _ = tx.Insert(&models.OaScheduleChange{
Tid: int(tid),
EntryID: entryID,
EmployeeID: employeeID,
EmployeeName: employeeName,
ScheduleDate: date,
ChangeType: changeType,
OldShiftID: oldShiftID,
OldShiftName: oldShiftName,
NewShiftID: newShiftID,
NewShiftName: newShiftName,
Reason: reason,
OperatorID: operatorID,
OperatorName: operatorName,
})
}
// PlanDelete 删除排班方案;默认同时清除该方案生成且仍未被调整的排班明细
// (source=1)。被临时调配过的明细 source 已变为 2,会保留下来,
// 避免把人工调整过的排班一起删掉。
// DELETE /backend/oa/scheduling/plans/delete/:id?delete_entries=1
func (c *BackendOaSchedulingController) PlanDelete() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
id, valid := c.scPathID(":id")
if !valid {
c.scFail(400, 400, "无效的方案ID")
return
}
var plan models.OaSchedulePlan
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
c.scFail(404, 404, "排班方案不存在")
return
}
now := time.Now()
plan.IsDeleted = 1
plan.DeleteTime = &now
if _, err := models.Orm.Update(&plan, "is_deleted", "delete_time"); err != nil {
c.scFail(500, 500, "删除方案失败: "+err.Error())
return
}
removed := int64(0)
deleteEntries := c.GetString("delete_entries") != "0"
if deleteEntries {
result, err := models.Orm.Raw(
"UPDATE yz_backend_oa_schedule_entry SET is_deleted=id, delete_time=? "+
"WHERE tid=? AND plan_id=? AND source=1 AND is_deleted=0", now, tid, plan.ID,
).Exec()
if err == nil {
removed, _ = result.RowsAffected()
}
}
c.scOK(map[string]interface{}{"id": plan.ID, "deleted_entries": removed})
}
@@ -0,0 +1,196 @@
package controllers
import (
"strconv"
"strings"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// schedulingEmployee 排班看板/方案预览里的员工视图。
// Department 保留组织 ID 字符串,DepartmentName 供前端直接展示。
type schedulingEmployee struct {
ID uint `json:"id"`
Name string `json:"name"`
Account string `json:"account"`
Department string `json:"department"`
DepartmentName string `json:"department_name"`
Position string `json:"position"`
Phone string `json:"phone"`
AccountStatus int8 `json:"account_status"`
}
// scOrgIndex 租户组织架构的内存索引,用于把“部门/小组”展开成部门子树,
// 以及给员工补齐部门名称。一次请求只加载一次,避免 N+1 查询。
type scOrgIndex struct {
rows map[uint64]models.BackendOrganization
children map[uint64][]uint64
names map[uint64]string
}
func (c *BackendOaSchedulingController) scLoadOrgIndex(tid uint64) *scOrgIndex {
index := &scOrgIndex{
rows: map[uint64]models.BackendOrganization{},
children: map[uint64][]uint64{},
names: map[uint64]string{},
}
var rows []models.BackendOrganization
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
All(&rows)
if err != nil {
return index
}
for _, row := range rows {
index.rows[row.ID] = row
index.names[row.ID] = row.OrgName
index.children[row.ParentID] = append(index.children[row.ParentID], row.ID)
}
return index
}
// subtreeIDs 返回节点自身及其全部下级组织 ID。
func (index *scOrgIndex) subtreeIDs(root uint64) map[uint64]bool {
result := map[uint64]bool{}
if root == 0 {
return result
}
queue := []uint64{root}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if result[current] {
continue
}
result[current] = true
queue = append(queue, index.children[current]...)
}
return result
}
func (index *scOrgIndex) name(id uint64) string {
if name, ok := index.names[id]; ok {
return name
}
return ""
}
// scBaseEmployeeQuery 排班可选员工:未删除、非离职。
func (c *BackendOaSchedulingController) scBaseEmployeeQuery(tid uint64) orm.QuerySeter {
return models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
Exclude("account_status", 2)
}
func scEmployeeDepartmentID(row models.BackendEmployee) uint64 {
if row.Department == nil {
return 0
}
id, err := strconv.ParseUint(strings.TrimSpace(*row.Department), 10, 64)
if err != nil {
return 0
}
return id
}
func (c *BackendOaSchedulingController) scEmployeeDTOs(tid uint64, rows []models.BackendEmployee) []schedulingEmployee {
index := c.scLoadOrgIndex(tid)
result := make([]schedulingEmployee, 0, len(rows))
for _, row := range rows {
orgID := scEmployeeDepartmentID(row)
position := ""
if row.Position != nil {
position = *row.Position
}
phone := ""
if row.Phone != nil {
phone = *row.Phone
}
result = append(result, schedulingEmployee{
ID: row.ID,
Name: row.Name,
Account: row.Account,
Department: strconv.FormatUint(orgID, 10),
DepartmentName: index.name(orgID),
Position: position,
Phone: phone,
AccountStatus: row.AccountStatus,
})
}
return result
}
// scEmployeesByOrg 按组织节点取员工;includeChild=true 时包含全部下级组织,
// 这样选中“公司/部门/小组”任意层级都能覆盖到该范围内的所有人。
func (c *BackendOaSchedulingController) scEmployeesByOrg(tid, orgID uint64, includeChild bool) ([]models.BackendEmployee, error) {
qs := c.scBaseEmployeeQuery(tid)
if orgID > 0 {
allowed := map[uint64]bool{orgID: true}
if includeChild {
allowed = c.scLoadOrgIndex(tid).subtreeIDs(orgID)
}
values := make([]string, 0, len(allowed))
for id := range allowed {
values = append(values, strconv.FormatUint(id, 10))
}
qs = qs.Filter("department__in", values)
}
var rows []models.BackendEmployee
_, err := qs.OrderBy("department", "id").All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
return rows, nil
}
// scEmployeesByIDs 按员工 ID 取员工,保持传入顺序由前端自行处理。
func (c *BackendOaSchedulingController) scEmployeesByIDs(tid uint64, ids []uint64) ([]models.BackendEmployee, error) {
if len(ids) == 0 {
return nil, nil
}
var rows []models.BackendEmployee
_, err := c.scBaseEmployeeQuery(tid).Filter("id__in", ids).All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
return rows, nil
}
// scResolveEmployees 把方案范围(公司 / 部门小组 / 指定员工)展开成员工列表。
func (c *BackendOaSchedulingController) scResolveEmployees(tid uint64, scopeType int8, orgID uint64, includeChild bool, employeeIDs []uint64) ([]models.BackendEmployee, error) {
switch scopeType {
case models.OaScopeEmployees:
return c.scEmployeesByIDs(tid, employeeIDs)
case models.OaScopeCompany:
return c.scEmployeesByOrg(tid, 0, false)
default:
return c.scEmployeesByOrg(tid, orgID, includeChild)
}
}
// scWorkdays 从日期区间中筛出命中的星期(1=周一 ... 7=周日)。
// 未选择任何星期表示每天。
func scWorkdays(dates []string, weekdays map[int]bool) []string {
if len(weekdays) == 0 {
return dates
}
result := make([]string, 0, len(dates))
for _, date := range dates {
parsed, ok := scParseDate(date)
if !ok {
continue
}
weekday := int(parsed.Weekday())
if weekday == 0 {
weekday = 7
}
if weekdays[weekday] {
result = append(result, date)
}
}
return result
}
@@ -0,0 +1,206 @@
package controllers
import (
"strconv"
"strings"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// 租户端排班模板:只读调用,不允许修改平台模板。
//
// 调用模板时会把模板的每日规则转成方案周期的 pattern 快照,并把模板里的
// 班次按「名称 + 上下班时间」匹配到租户自己的班次;匹配不到则自动创建,
// 这样租户拿到方案后就能像普通方案一样自由修改。
func schedTplTypeLabel(value int8) string {
return schedTplTypeText(value)
}
func (c *BackendOaSchedulingController) schedTplQuery() orm.QuerySeter {
return models.Orm.QueryTable(new(models.PlatformSchedTpl)).
Filter("delete_time__isnull", true)
}
func (c *BackendOaSchedulingController) schedTplDays(templateID uint64) []models.PlatformSchedTplDay {
rows := []models.PlatformSchedTplDay{}
_, err := models.Orm.QueryTable(new(models.PlatformSchedTplDay)).
Filter("template_id", templateID).
OrderBy("day_index").All(&rows)
if err != nil && err != orm.ErrNoRows {
return []models.PlatformSchedTplDay{}
}
return rows
}
func (c *BackendOaSchedulingController) schedTplByID(id uint64) (*models.PlatformSchedTpl, bool) {
var row models.PlatformSchedTpl
if err := c.schedTplQuery().Filter("id", id).One(&row); err != nil {
return nil, false
}
return &row, true
}
func schedTplView(row *models.PlatformSchedTpl, days []models.PlatformSchedTplDay) map[string]interface{} {
view := map[string]interface{}{
"id": row.ID,
"code": row.Code,
"name": row.Name,
"template_type": row.TemplateType,
"template_type_text": schedTplTypeLabel(row.TemplateType),
"align_mode": row.AlignMode,
"cycle_days": row.CycleDays,
"week_offset": row.WeekOffset,
"month_rest_days": row.MonthRestDays,
"month_rest_weekday": row.MonthRestWeekday,
"summary": row.Summary,
}
if days != nil {
view["days"] = days
}
return view
}
// TemplateList 租户端可调用的排班模板(只返回启用的模板)。
// GET /backend/oa/scheduling/templates?keyword=
func (c *BackendOaSchedulingController) TemplateList() {
if _, _, _, ok := c.scOperator(); !ok {
return
}
qs := c.schedTplQuery().Filter("status", 1)
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
qs = qs.Filter("name__icontains", keyword)
}
var rows []models.PlatformSchedTpl
if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil && err != orm.ErrNoRows {
c.scFail(500, 500, "查询排班模板失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, schedTplView(&rows[i], c.schedTplDays(rows[i].ID)))
}
c.scOK(map[string]interface{}{"list": list, "total": len(list)})
}
// TemplateApply 调用模板:返回可直接用于方案的周期规则,并把模板班次
// 匹配到租户班次(不存在时自动创建)。
// POST /backend/oa/scheduling/templates/apply body: {"template_id": 1}
func (c *BackendOaSchedulingController) TemplateApply() {
tid, _, _, ok := c.scOperator()
if !ok {
return
}
id := scUintValue(c.scBody(), "template_id")
if id == 0 {
if raw := strings.TrimSpace(c.GetString("template_id")); raw != "" {
id, _ = strconv.ParseUint(raw, 10, 64)
}
}
if id == 0 {
c.scFail(400, 400, "请选择排班模板")
return
}
tpl, found := c.schedTplByID(id)
if !found {
c.scFail(404, 404, "排班模板不存在")
return
}
if tpl.Status != 1 {
c.scFail(400, 400, "该模板已停用")
return
}
var shifts []models.OaShift
if _, err := c.shiftQuery(tid).OrderBy("sort", "id").All(&shifts); err != nil && err != orm.ErrNoRows {
c.scFail(500, 500, "查询班次失败: "+err.Error())
return
}
shiftMap := make(map[string]*models.OaShift, len(shifts))
maxSort := 0
for i := range shifts {
shift := &shifts[i]
shiftMap[schedShiftKey(shift.Name, shift.WorkStart, shift.WorkEnd)] = shift
if shift.Sort > maxSort {
maxSort = shift.Sort
}
}
patterns := []scPatternDay{}
createdShifts := 0
matchedShifts := 0
for _, day := range c.schedTplDays(tpl.ID) {
if day.DayType != models.SchedTplDayWork {
patterns = append(patterns, scPatternDay{DayIndex: day.DayIndex, DayType: models.SchedTplDayRest})
continue
}
key := schedShiftKey(day.ShiftName, day.WorkStart, day.WorkEnd)
shift, hit := shiftMap[key]
if hit {
matchedShifts++
} else {
maxSort += 10
shift = &models.OaShift{
Tid: int(tid),
Name: day.ShiftName,
ShortName: day.ShiftShort,
Color: day.Color,
WorkStart: day.WorkStart,
WorkEnd: day.WorkEnd,
RestStart: day.RestStart,
RestEnd: day.RestEnd,
WorkHours: day.WorkHours,
IsDefault: 0,
Status: 1,
Sort: maxSort,
IsDeleted: 0,
}
if shift.ShortName == "" {
shift.ShortName = shift.Name
}
if shift.Color == "" {
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 {
c.scFail(400, 400, "该模板没有配置排班规则")
return
}
c.scOK(map[string]interface{}{
"template": schedTplView(tpl, nil),
"pattern": patterns,
"days": patterns,
"created_shift": createdShifts,
"matched_shift": matchedShifts,
})
}
func schedShiftKey(name, workStart, workEnd string) string {
return strings.TrimSpace(name) + "|" + strings.TrimSpace(workStart) + "|" + strings.TrimSpace(workEnd)
}
@@ -0,0 +1,635 @@
package controllers
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/identity"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformSchedTemplateController 平台端排班模板管理(全局模板,只有平台可维护)。
//
// 表:yz_platform_sched_tpl / yz_platform_sched_tpl_day
// 建表与预置数据:docs/sql/create_platform_sched_template.sql
//
// 规则:
// - 系统预置模板(is_system=1)由平台锁定:禁止编辑、删除、停用;
// - 平台可新建「自定义模板」,并编辑 / 停用 / 删除自定义模板;
// - 租户端只读取启用模板(/backend/oa/scheduling/templates),
// 调用后把规则快照进自己的排班方案,模板改动不影响已保存方案。
type PlatformSchedTemplateController struct {
beego.Controller
}
func (c *PlatformSchedTemplateController) tplClaims() (*jwtutil.Claims, error) {
return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndPlatform)
}
func (c *PlatformSchedTemplateController) tplErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformSchedTemplateController) tplOK(data interface{}, msg string) {
if msg == "" {
msg = "success"
}
resp := map[string]interface{}{"code": 200, "msg": msg}
if data != nil {
resp["data"] = data
}
c.Data["json"] = resp
_ = c.ServeJSON()
}
// ---------------------------------------------------------------------------
// 参数结构
// ---------------------------------------------------------------------------
type schedTplDayPayload struct {
DayIndex int `json:"day_index"`
DayType int8 `json:"day_type"`
ShiftName string `json:"shift_name"`
ShiftShort string `json:"shift_short"`
Color string `json:"color"`
WorkStart string `json:"work_start"`
WorkEnd string `json:"work_end"`
RestStart string `json:"rest_start"`
RestEnd string `json:"rest_end"`
WorkHours float64 `json:"work_hours"`
Remark string `json:"remark"`
}
type schedTplPayload struct {
Name string `json:"name"`
Code string `json:"code"`
TemplateType int8 `json:"template_type"`
AlignMode int8 `json:"align_mode"`
CycleDays int `json:"cycle_days"`
WeekOffset int8 `json:"week_offset"`
MonthRestDays int8 `json:"month_rest_days"`
MonthRestWeekday int8 `json:"month_rest_weekday"`
Summary string `json:"summary"`
Status int8 `json:"status"`
Sort int `json:"sort"`
Days []schedTplDayPayload `json:"days"`
}
// ---------------------------------------------------------------------------
// 列表 / 详情
// ---------------------------------------------------------------------------
func (c *PlatformSchedTemplateController) tplQuery() orm.QuerySeter {
return models.Orm.QueryTable(new(models.PlatformSchedTpl)).Filter("delete_time__isnull", true)
}
func (c *PlatformSchedTemplateController) tplByID(id uint64) (*models.PlatformSchedTpl, bool) {
var row models.PlatformSchedTpl
if err := c.tplQuery().Filter("id", id).One(&row); err != nil {
return nil, false
}
return &row, true
}
func (c *PlatformSchedTemplateController) tplDays(templateID uint64) []models.PlatformSchedTplDay {
rows := []models.PlatformSchedTplDay{}
_, err := models.Orm.QueryTable(new(models.PlatformSchedTplDay)).
Filter("template_id", templateID).
OrderBy("day_index").All(&rows)
if err != nil && err != orm.ErrNoRows {
return []models.PlatformSchedTplDay{}
}
return rows
}
func (c *PlatformSchedTemplateController) tplView(row *models.PlatformSchedTpl, withDays bool) map[string]interface{} {
view := map[string]interface{}{
"id": row.ID,
"code": row.Code,
"name": row.Name,
"template_type": row.TemplateType,
"template_type_text": schedTplTypeText(row.TemplateType),
"align_mode": row.AlignMode,
"cycle_days": row.CycleDays,
"week_offset": row.WeekOffset,
"month_rest_days": row.MonthRestDays,
"month_rest_weekday": row.MonthRestWeekday,
"summary": row.Summary,
"is_system": row.IsSystem,
"editable": row.IsSystem != 1,
"status": row.Status,
"sort": row.Sort,
"creater": row.Creater,
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
}
if row.UpdateTime != nil {
view["update_time"] = row.UpdateTime.Format("2006-01-02 15:04:05")
}
if withDays {
view["days"] = c.tplDays(row.ID)
}
return view
}
func schedTplTypeText(value int8) string {
switch value {
case models.SchedTplTypeDoubleWeekend:
return "双休"
case models.SchedTplTypeSingleWeekend:
return "单休"
case models.SchedTplTypeBigSmallWeek:
return "大小周"
case models.SchedTplTypeMonthRest:
return "月休"
case models.SchedTplTypeThreeShift:
return "三班倒"
case models.SchedTplTypeWorkRestOne:
return "做一休一"
default:
return "自定义"
}
}
// List 模板列表。
// GET /platform/schedTemplates?page=1&pageSize=20&keyword=&status=&template_type=
func (c *PlatformSchedTemplateController) List() {
if _, err := c.tplClaims(); err != nil {
c.tplErr(401, 401, "未登录或登录已过期")
return
}
page, pageSize, offset := dictPageSize(&c.Controller)
qs := c.tplQuery()
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
qs = qs.Filter("name__icontains", keyword)
}
if raw := strings.TrimSpace(c.GetString("status")); raw != "" {
if value, err := strconv.Atoi(raw); err == nil {
qs = qs.Filter("status", value)
}
}
if raw := strings.TrimSpace(c.GetString("template_type")); raw != "" {
if value, err := strconv.Atoi(raw); err == nil {
qs = qs.Filter("template_type", value)
}
}
total, _ := qs.Count()
var rows []models.PlatformSchedTpl
if _, err := qs.OrderBy("sort", "id").Limit(pageSize).Offset(offset).All(&rows); err != nil && err != orm.ErrNoRows {
c.tplErr(500, 500, "查询模板失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, c.tplView(&rows[i], false))
}
c.tplOK(map[string]interface{}{
"list": list,
"total": total,
"page": page,
"page_size": pageSize,
}, "")
}
// Detail 模板详情(含周期每日规则)。
// GET /platform/schedTemplates/:id
func (c *PlatformSchedTemplateController) Detail() {
if _, err := c.tplClaims(); err != nil {
c.tplErr(401, 401, "未登录或登录已过期")
return
}
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
if err != nil || id == 0 {
c.tplErr(400, 400, "无效的模板ID")
return
}
row, found := c.tplByID(id)
if !found {
c.tplErr(404, 404, "模板不存在")
return
}
c.tplOK(c.tplView(row, true), "")
}
// ---------------------------------------------------------------------------
// 新建 / 编辑 / 删除 / 启停
// ---------------------------------------------------------------------------
func (c *PlatformSchedTemplateController) parsePayload() (*schedTplPayload, bool) {
var payload schedTplPayload
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
c.tplErr(400, 400, "参数错误")
return nil, false
}
return &payload, true
}
// buildDays 校验并构造周期每日规则;返回非空 msg 表示参数错误。
func buildSchedTplDays(payload *schedTplPayload) ([]models.PlatformSchedTplDay, string) {
if payload.AlignMode < models.SchedTplAlignWeek || payload.AlignMode > models.SchedTplAlignMonth {
return nil, "排班方式不合法"
}
if len(payload.Days) == 0 {
return nil, "请至少配置一天的规则"
}
cycle := payload.CycleDays
if payload.AlignMode == models.SchedTplAlignMonth {
// 月休按自然月计算,周期天数无意义
cycle = 0
if payload.MonthRestDays < 1 || payload.MonthRestDays > 15 {
return nil, "月休天数需在 1~15 天之间"
}
if payload.MonthRestWeekday < 0 || payload.MonthRestWeekday > 7 {
return nil, "月休休息周几不合法"
}
} else {
if cycle < 2 || cycle > 31 {
return nil, "周期天数需在 2~31 天之间"
}
if payload.AlignMode == models.SchedTplAlignWeek && cycle%7 != 0 {
return nil, "按自然周排班时,周期天数必须是 7 的倍数(7 / 14 / 21 / 28)"
}
if len(payload.Days) != cycle {
return nil, fmt.Sprintf("周期为 %d 天,需要配置 %d 天的规则", cycle, cycle)
}
}
seen := map[int]bool{}
workCount := 0
days := make([]models.PlatformSchedTplDay, 0, len(payload.Days))
for _, item := range payload.Days {
index := item.DayIndex
if payload.AlignMode == models.SchedTplAlignMonth {
index = len(days)
}
if index < 0 || (payload.AlignMode != models.SchedTplAlignMonth && index >= cycle) {
return nil, "每日规则的天序号不合法"
}
if seen[index] {
return nil, "每日规则的天序号重复"
}
seen[index] = true
dayType := item.DayType
if dayType != models.SchedTplDayWork && dayType != models.SchedTplDayRest {
return nil, "每日规则类型不合法(1=上班 2=休息)"
}
row := models.PlatformSchedTplDay{
DayIndex: index,
DayType: dayType,
Remark: strings.TrimSpace(item.Remark),
}
if dayType == models.SchedTplDayWork {
workCount++
row.ShiftName = strings.TrimSpace(item.ShiftName)
if row.ShiftName == "" {
return nil, fmt.Sprintf("第 %d 天选择上班,需填写班次名称", index+1)
}
if len([]rune(row.ShiftName)) > 50 {
return nil, "班次名称不能超过 50 个字符"
}
row.WorkStart = strings.TrimSpace(item.WorkStart)
row.WorkEnd = strings.TrimSpace(item.WorkEnd)
if !isValidOaTime(row.WorkStart) || !isValidOaTime(row.WorkEnd) {
return nil, fmt.Sprintf("第 %d 天的上下班时间格式应为 HH:mm", index+1)
}
if row.WorkStart == row.WorkEnd {
return nil, fmt.Sprintf("第 %d 天的上下班时间不能相同", index+1)
}
row.RestStart = strings.TrimSpace(item.RestStart)
row.RestEnd = strings.TrimSpace(item.RestEnd)
if (row.RestStart == "") != (row.RestEnd == "") {
return nil, fmt.Sprintf("第 %d 天的休息开始与结束时间需要同时填写", index+1)
}
if row.RestStart != "" && (!isValidOaTime(row.RestStart) || !isValidOaTime(row.RestEnd)) {
return nil, fmt.Sprintf("第 %d 天的休息时间格式应为 HH:mm", index+1)
}
row.ShiftShort = strings.TrimSpace(item.ShiftShort)
if row.ShiftShort == "" {
row.ShiftShort = row.ShiftName
}
row.Color = strings.TrimSpace(item.Color)
if row.Color == "" {
row.Color = "#409EFF"
}
row.WorkHours = scCalcWorkHours(row.WorkStart, row.WorkEnd, row.RestStart, row.RestEnd)
}
days = append(days, row)
}
if workCount == 0 {
return nil, "排班模板至少需要一天上班"
}
// 按天序号排序,避免前端乱序提交
for i := 0; i < len(days); i++ {
for j := i + 1; j < len(days); j++ {
if days[j].DayIndex < days[i].DayIndex {
days[i], days[j] = days[j], days[i]
}
}
}
return days, ""
}
// autoSummary 生成规则摘要(前端未填写时使用)。
func autoSummary(payload *schedTplPayload, days []models.PlatformSchedTplDay) string {
work, rest := 0, 0
shiftNames := []string{}
seenShift := map[string]bool{}
for _, day := range days {
if day.DayType == models.SchedTplDayWork {
work++
if !seenShift[day.ShiftName] {
seenShift[day.ShiftName] = true
shiftNames = append(shiftNames, fmt.Sprintf("%s %s-%s", day.ShiftName, day.WorkStart, day.WorkEnd))
}
} else {
rest++
}
}
switch payload.AlignMode {
case models.SchedTplAlignWeek:
weeks := payload.CycleDays / 7
if weeks <= 1 {
return fmt.Sprintf("每周上班 %d 天、休息 %d 天;%s", work, rest, strings.Join(shiftNames, "、"))
}
return fmt.Sprintf("%d 周循环,共上班 %d 天、休息 %d 天;%s", weeks, work, rest, strings.Join(shiftNames, "、"))
case models.SchedTplAlignMonth:
return fmt.Sprintf("每月固定休 %d 天;上班日 %s", payload.MonthRestDays, strings.Join(shiftNames, "、"))
default:
return fmt.Sprintf("每 %d 天循环,上班 %d 天、休息 %d 天;%s", payload.CycleDays, work, rest, strings.Join(shiftNames, "、"))
}
}
// Create 新建自定义模板。
// POST /platform/schedTemplates
func (c *PlatformSchedTemplateController) Create() {
claims, err := c.tplClaims()
if err != nil {
c.tplErr(401, 401, "未登录或登录已过期")
return
}
payload, ok := c.parsePayload()
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
if payload.Name == "" {
c.tplErr(400, 400, "模板名称不能为空")
return
}
if len([]rune(payload.Name)) > 50 {
c.tplErr(400, 400, "模板名称不能超过 50 个字符")
return
}
if payload.TemplateType <= 0 || (payload.TemplateType > 6 && payload.TemplateType != models.SchedTplTypeCustom) {
payload.TemplateType = models.SchedTplTypeCustom
}
days, msg := buildSchedTplDays(payload)
if msg != "" {
c.tplErr(400, 400, msg)
return
}
if payload.Status != 0 {
payload.Status = 1
}
code := strings.TrimSpace(payload.Code)
if code == "" {
code = fmt.Sprintf("custom_%d", time.Now().UnixNano()/1e6)
}
exist, _ := c.tplQuery().Filter("code", code).Count()
if exist > 0 {
c.tplErr(400, 400, "模板编码已存在")
return
}
summary := strings.TrimSpace(payload.Summary)
if summary == "" {
summary = autoSummary(payload, days)
}
row := &models.PlatformSchedTpl{
Code: code,
Name: payload.Name,
TemplateType: payload.TemplateType,
AlignMode: payload.AlignMode,
CycleDays: payload.CycleDays,
WeekOffset: payload.WeekOffset,
MonthRestDays: payload.MonthRestDays,
MonthRestWeekday: payload.MonthRestWeekday,
Summary: summary,
IsSystem: 0,
Status: payload.Status,
Sort: payload.Sort,
Creater: claims.Username,
}
if row.AlignMode == models.SchedTplAlignMonth {
row.CycleDays = 0
}
tx, beginErr := orm.NewOrm().Begin()
if beginErr != nil {
c.tplErr(500, 500, "保存模板失败: "+beginErr.Error())
return
}
id, insertErr := tx.Insert(row)
if insertErr != nil {
_ = tx.Rollback()
c.tplErr(500, 500, "保存模板失败: "+insertErr.Error())
return
}
for i := range days {
days[i].TemplateID = row.ID
if _, err := tx.Insert(&days[i]); err != nil {
_ = tx.Rollback()
c.tplErr(500, 500, "保存模板规则失败: "+err.Error())
return
}
}
if err := tx.Commit(); err != nil {
c.tplErr(500, 500, "保存模板失败: "+err.Error())
return
}
_ = id
c.tplOK(c.tplView(row, true), "模板已创建")
}
// Update 编辑自定义模板;系统预置模板只读。
// POST /platform/schedTemplates/:id
func (c *PlatformSchedTemplateController) Update() {
if _, err := c.tplClaims(); err != nil {
c.tplErr(401, 401, "未登录或登录已过期")
return
}
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
if err != nil || id == 0 {
c.tplErr(400, 400, "无效的模板ID")
return
}
row, found := c.tplByID(id)
if !found {
c.tplErr(404, 404, "模板不存在")
return
}
if row.IsSystem == 1 {
c.tplErr(400, 400, "系统预置模板只读,不能编辑")
return
}
payload, ok := c.parsePayload()
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
if payload.Name == "" {
c.tplErr(400, 400, "模板名称不能为空")
return
}
if len([]rune(payload.Name)) > 50 {
c.tplErr(400, 400, "模板名称不能超过 50 个字符")
return
}
if payload.TemplateType <= 0 || (payload.TemplateType > 6 && payload.TemplateType != models.SchedTplTypeCustom) {
payload.TemplateType = models.SchedTplTypeCustom
}
days, msg := buildSchedTplDays(payload)
if msg != "" {
c.tplErr(400, 400, msg)
return
}
if payload.Status != 0 {
payload.Status = 1
}
summary := strings.TrimSpace(payload.Summary)
if summary == "" {
summary = autoSummary(payload, days)
}
row.Name = payload.Name
row.TemplateType = payload.TemplateType
row.AlignMode = payload.AlignMode
row.CycleDays = payload.CycleDays
if row.AlignMode == models.SchedTplAlignMonth {
row.CycleDays = 0
}
row.WeekOffset = payload.WeekOffset
row.MonthRestDays = payload.MonthRestDays
row.MonthRestWeekday = payload.MonthRestWeekday
row.Summary = summary
row.Status = payload.Status
row.Sort = payload.Sort
tx, beginErr := orm.NewOrm().Begin()
if beginErr != nil {
c.tplErr(500, 500, "保存模板失败: "+beginErr.Error())
return
}
if _, err := tx.Update(row); err != nil {
_ = tx.Rollback()
c.tplErr(500, 500, "保存模板失败: "+err.Error())
return
}
// 规则整体替换:先清空旧规则再写入新规则,避免残留天数
if _, err := tx.QueryTable(new(models.PlatformSchedTplDay)).Filter("template_id", row.ID).Delete(); err != nil {
_ = tx.Rollback()
c.tplErr(500, 500, "保存模板规则失败: "+err.Error())
return
}
for i := range days {
days[i].TemplateID = row.ID
if _, err := tx.Insert(&days[i]); err != nil {
_ = tx.Rollback()
c.tplErr(500, 500, "保存模板规则失败: "+err.Error())
return
}
}
if err := tx.Commit(); err != nil {
c.tplErr(500, 500, "保存模板失败: "+err.Error())
return
}
c.tplOK(c.tplView(row, true), "模板已更新")
}
// Delete 删除自定义模板(软删除);系统预置模板禁止删除。
// DELETE /platform/schedTemplates/:id
func (c *PlatformSchedTemplateController) Delete() {
if _, err := c.tplClaims(); err != nil {
c.tplErr(401, 401, "未登录或登录已过期")
return
}
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
if err != nil || id == 0 {
c.tplErr(400, 400, "无效的模板ID")
return
}
row, found := c.tplByID(id)
if !found {
c.tplErr(404, 404, "模板不存在")
return
}
if row.IsSystem == 1 {
c.tplErr(400, 400, "系统预置模板不能删除")
return
}
now := time.Now()
row.DeleteTime = &now
if _, err := models.Orm.Update(row, "delete_time"); err != nil {
c.tplErr(500, 500, "删除模板失败: "+err.Error())
return
}
c.tplOK(map[string]interface{}{"id": row.ID}, "模板已删除")
}
// ToggleStatus 启用 / 停用自定义模板;系统预置模板不可停用。
// POST /platform/schedTemplates/:id/status
func (c *PlatformSchedTemplateController) ToggleStatus() {
if _, err := c.tplClaims(); err != nil {
c.tplErr(401, 401, "未登录或登录已过期")
return
}
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
if err != nil || id == 0 {
c.tplErr(400, 400, "无效的模板ID")
return
}
row, found := c.tplByID(id)
if !found {
c.tplErr(404, 404, "模板不存在")
return
}
if row.IsSystem == 1 {
c.tplErr(400, 400, "系统预置模板固定启用,不能停用")
return
}
status, _ := c.GetInt("status", -1)
if status != 0 && status != 1 {
var body struct {
Status int8 `json:"status"`
}
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &body)
if body.Status != 0 && body.Status != 1 {
c.tplErr(400, 400, "状态只能是 0 或 1")
return
}
status = int(body.Status)
}
row.Status = int8(status)
if _, err := models.Orm.Update(row, "status"); err != nil {
c.tplErr(500, 500, "更新状态失败: "+err.Error())
return
}
c.tplOK(map[string]interface{}{"id": row.ID, "status": row.Status}, "状态已更新")
}
@@ -0,0 +1,71 @@
-- ============================================================
-- OA 工作排班:排班方案支持「平台模板 + 周期规则」
--
-- 适用:已执行过 create_oa_scheduling.sql 的环境(表已存在)。
-- 执行:在业务库(go-platform)下整段执行即可。
--
-- 幂等:逐列判断 information_schema,只添加缺失的列;
-- 9 列全部存在时自动跳过一次无害查询,不会报 1060。
--
-- 字段说明:
-- template_id / template_code / template_name
-- 方案来源模板的快照。租户调用模板后,模板规则会复制进本方案,
-- 之后模板调整或停用都不影响已保存方案(platform 模板只读,租户可改自己的)。
-- align_mode
-- 0 = 未使用模板(沿用 weekdays + shift_id 的按星期排班)
-- 1 = 按自然周(双休 / 单休 / 大小周)
-- 2 = 从 start_date 起滚动(三班倒 / 做一休一)
-- 3 = 按自然月(月休)
-- cycle_days 周期天数(align_mode=1/2 时等于 pattern 长度)
-- week_offset 大小周起始周偏移:0=开始日期所在周为周期第 1 周,1=第 2 周
-- month_rest_days 月休:每月休息天数
-- month_rest_weekday 月休固定休息周几(1-7),0 表示不固定
-- pattern 周期规则 JSON 快照,元素形如
-- {"day_index":0,"day_type":1,"shift_id":12,"shift_name":"白班",
-- "short_name":"白","color":"#409EFF","work_start":"09:00","work_end":"18:00"}
-- ============================================================
-- 执行前自查:existing_cols = 9 表示字段已齐全,执行到下面会自动跳过
SELECT COUNT(*) AS existing_cols
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'yz_backend_oa_schedule_plan'
AND COLUMN_NAME IN ('template_id','template_code','template_name','align_mode','cycle_days','week_offset','month_rest_days','month_rest_weekday','pattern');
SET @sql := CONCAT('ALTER TABLE `yz_backend_oa_schedule_plan` ',
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_id') = 0,
'ADD COLUMN `template_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''来源模板ID(0=未使用模板)'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_code') = 0,
'ADD COLUMN `template_code` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''来源模板编码快照'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_name') = 0,
'ADD COLUMN `template_name` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''来源模板名称快照'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'align_mode') = 0,
'ADD COLUMN `align_mode` TINYINT NOT NULL DEFAULT 0 COMMENT ''0不使用模板 1按自然周 2按开始日滚动 3按自然月'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'cycle_days') = 0,
'ADD COLUMN `cycle_days` SMALLINT NOT NULL DEFAULT 0 COMMENT ''周期天数'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'week_offset') = 0,
'ADD COLUMN `week_offset` TINYINT NOT NULL DEFAULT 0 COMMENT ''大小周起始周偏移'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'month_rest_days') = 0,
'ADD COLUMN `month_rest_days` TINYINT NOT NULL DEFAULT 0 COMMENT ''月休天数'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'month_rest_weekday') = 0,
'ADD COLUMN `month_rest_weekday` TINYINT NOT NULL DEFAULT 0 COMMENT ''月休固定周几(1-7,0不固定)'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'pattern') = 0,
'ADD COLUMN `pattern` TEXT NULL COMMENT ''周期规则JSON快照'', ', '')
);
-- 去掉结尾多余的 ", "(两个字符),否则 ALTER 语句会因尾逗号报 1064
SET @sql := IF(RIGHT(@sql, 2) = ', ', LEFT(@sql, CHAR_LENGTH(@sql) - 2), @sql);
-- 没有任何缺失列时,@sql 只剩表名,替换为一条无害语句
SET @sql := IF(RIGHT(@sql, 6) = 'TABLE ', 'SELECT 1 AS skipped_all_columns_exist', @sql);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 核对结果:应返回 9 行
SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'yz_backend_oa_schedule_plan'
AND COLUMN_NAME IN ('template_id','template_code','template_name','align_mode','cycle_days','week_offset','month_rest_days','month_rest_weekday','pattern')
ORDER BY ORDINAL_POSITION;
+151
View File
@@ -0,0 +1,151 @@
-- =============================================================
-- OA 工作排班 —— 表结构
--
-- 对应代码:
-- models/oa_scheduling.go
-- controllers/backend_oa_scheduling.go (班次管理)
-- controllers/backend_oa_scheduling_scope.go (组织/员工范围展开)
-- controllers/backend_oa_scheduling_plan.go (排班方案与发布)
-- controllers/backend_oa_scheduling_board.go (看板、临时调配、变更记录)
--
-- 执行顺序:
-- 1) 本脚本:创建 4 张排班表
-- 2) oa_scheduling_menus.sql:注册「OA → 排班考勤 → 工作排班」菜单并授权角色
--
-- 设计要点:
-- 1. 排班对象直接复用租户组织架构(yz_backend_organization)与员工表
-- (yz_backend_employee),不另建“小组”表:公司、部门、小组都是组织
-- 树上的节点,方案通过 scope_type + org_id + include_child 决定范围。
-- 2. 方案(plan)只保存规则;发布后展开成明细(entry),一人一天一条。
-- 看板与临时调配只操作明细,方案可重复发布(可选覆盖)。
-- 3. 明细软删除:is_deleted=0 表示有效,删除时写入记录 ID,
-- 使唯一键 (tid, employee_id, schedule_date, is_deleted) 在删除后
-- 同一天重新排班时不会与历史记录冲突。
-- 4. 变更留痕(change)只记录人、日期与新旧班次,方便追溯临时调配。
-- =============================================================
SET NAMES utf8mb4;
-- ---------------- 1. 班次定义 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_shift` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称,如早班/晚班',
`short_name` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '看板色块上的简称',
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '标识颜色',
`work_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '上班时间 HH:mm',
`work_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '下班时间 HH:mm(早于上班时间视为跨天)',
`rest_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '休息开始 HH:mm,空表示无',
`rest_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '休息结束 HH:mm',
`work_hours` DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '工时(已扣除休息)',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '1=默认班次',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
`remark` VARCHAR(255) DEFAULT NULL,
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid_status` (`tid`, `status`),
KEY `idx_tid_deleted` (`tid`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班班次';
-- ---------------- 2. 排班方案 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_plan` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`plan_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '方案名称',
`plan_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=常规排班 2=临时方案',
`scope_type` TINYINT NOT NULL DEFAULT 2 COMMENT '1=整个公司 2=部门/小组 3=指定员工',
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '范围组织ID(yz_backend_organization.id)',
`org_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '范围名称快照',
`include_child` TINYINT NOT NULL DEFAULT 1 COMMENT '1=包含全部下级组织',
`employee_ids` TEXT COMMENT '指定员工ID JSON 数组:[1,2,3]',
`employee_names` TEXT COMMENT '指定员工姓名快照',
`start_date` DATE NOT NULL COMMENT '开始日期',
`end_date` DATE NOT NULL COMMENT '结束日期',
`weekdays` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '生效星期 JSON:[1,2,3,4,5];空=每天',
`shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '班次ID',
`shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称快照',
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色快照',
`note` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
`publish_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未发布 1=已发布',
`entry_count` INT NOT NULL DEFAULT 0 COMMENT '最近一次发布生成/覆盖的条数',
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人 uid',
`created_by_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人名称快照',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid_deleted` (`tid`, `is_deleted`),
KEY `idx_tid_scope` (`tid`, `scope_type`, `org_id`),
KEY `idx_tid_dates` (`tid`, `start_date`, `end_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班方案';
-- ---------------- 3. 排班明细 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_entry` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`plan_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '来源方案ID,0=纯手工排班',
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID(yz_backend_employee.id)',
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
`employee_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工账号快照',
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属组织ID',
`org_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '所属组织名称快照',
`position` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '职位快照',
`schedule_date` DATE NOT NULL COMMENT '排班日期',
`shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '班次ID',
`shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称快照',
`short_name` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '班次简称快照',
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色快照',
`work_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '上班时间快照',
`work_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '下班时间快照',
`source` TINYINT NOT NULL DEFAULT 1 COMMENT '1=方案生成 2=临时调配',
`note` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注/调配原因',
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人 uid',
`created_by_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称快照',
`is_deleted` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除标记:0=有效;删除时写入记录ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tid_emp_date` (`tid`, `employee_id`, `schedule_date`, `is_deleted`),
KEY `idx_tid_date` (`tid`, `schedule_date`),
KEY `idx_tid_plan` (`tid`, `plan_id`),
KEY `idx_tid_emp` (`tid`, `employee_id`),
KEY `idx_tid_org_date` (`tid`, `org_id`, `schedule_date`),
KEY `idx_tid_shift` (`tid`, `shift_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班明细';
-- ---------------- 4. 排班变更记录 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_change` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`entry_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '排班明细ID',
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID',
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
`schedule_date` DATE NOT NULL COMMENT '排班日期',
`change_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=调班 2=清除 3=手工新增 4=方案覆盖',
`old_shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '原班次ID',
`old_shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '原班次名称',
`new_shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '新班次ID',
`new_shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '新班次名称',
`reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '调整原因',
`operator_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人 uid',
`operator_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称快照',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tid_date` (`tid`, `schedule_date`),
KEY `idx_tid_emp` (`tid`, `employee_id`),
KEY `idx_tid_entry` (`tid`, `entry_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班变更记录';
-- ---------------- 校验 ----------------
SELECT TABLE_NAME, TABLE_COMMENT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE 'yz_backend_oa_schedule_%'
OR TABLE_NAME = 'yz_backend_oa_shift';
@@ -0,0 +1,219 @@
-- ============================================================
-- 平台端排班模板(全局预制模板 + 自定义模板)
--
-- 执行顺序:
-- 1) 本脚本:创建 2 张模板表并写入 6 个系统预置模板
-- 2) alter_oa_schedule_plan_template.sql:租户排班方案增加模板/周期字段
-- 3) platform_sched_template_menu.sql:注册平台端「排班模板」菜单
-- 4) create_oa_scheduling.sql / oa_scheduling_menus.sql(若尚未执行)
--
-- 说明:
-- 1. 模板是全局数据(不按租户隔离),只有平台端可维护;
-- 系统预置模板(is_system=1)由平台锁定:不可编辑、不可删除、不可停用。
-- 2. 租户端只能「调用」模板:调用时把模板规则快照到自己的排班方案
-- (yz_backend_oa_schedule_plan.pattern),之后在租户端随意修改,
-- 模板本身的后续调整不会再影响已保存的方案。
-- 3. align_mode:1=按自然周(双休/单休/大小周) 2=从开始日滚动(三班倒/做一休一)
-- 3=按自然月(月休,按月计算休息日)
-- 4. day_index 表示周期内第几天,从 0 开始;按自然周时 0=周一、6=周日。
-- 5. 本脚本不使用会话变量 / PREPARE,可整段执行也可逐条执行,重复执行安全。
-- ============================================================
CREATE TABLE IF NOT EXISTS `yz_platform_sched_tpl` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` varchar(50) NOT NULL DEFAULT '' COMMENT '模板编码(系统模板固定)',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '模板名称',
`template_type` tinyint NOT NULL DEFAULT 9 COMMENT '1双休 2单休 3大小周 4月休 5三班倒 6做一休一 9自定义',
`align_mode` tinyint NOT NULL DEFAULT 1 COMMENT '1按自然周 2按开始日滚动 3按自然月',
`cycle_days` smallint NOT NULL DEFAULT 7 COMMENT '周期天数(按自然月时为0)',
`week_offset` tinyint NOT NULL DEFAULT 0 COMMENT '大小周起始周偏移:0=开始周为第1周 1=第2周',
`month_rest_days` tinyint NOT NULL DEFAULT 0 COMMENT '月休:每月休息天数',
`month_rest_weekday` tinyint NOT NULL DEFAULT 0 COMMENT '月休固定休息周几(1-7,0=不固定)',
`summary` varchar(255) NOT NULL DEFAULT '' COMMENT '规则摘要(列表展示)',
`is_system` tinyint NOT NULL DEFAULT 0 COMMENT '1=系统预置(只读,禁止编辑/删除)',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`creater` varchar(50) NOT NULL DEFAULT '' COMMENT '创建人',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
KEY `idx_code` (`code`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台端排班模板(全局)';
CREATE TABLE IF NOT EXISTS `yz_platform_sched_tpl_day` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`template_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '模板ID',
`day_index` smallint NOT NULL DEFAULT 0 COMMENT '周期内第几天,从0开始',
`day_type` tinyint NOT NULL DEFAULT 1 COMMENT '1上班 2休息',
`shift_name` varchar(50) NOT NULL DEFAULT '' COMMENT '班次名称',
`shift_short` varchar(20) NOT NULL DEFAULT '' COMMENT '班次简称',
`color` varchar(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色',
`work_start` varchar(8) NOT NULL DEFAULT '' COMMENT '上班时间 HH:mm',
`work_end` varchar(8) NOT NULL DEFAULT '' COMMENT '下班时间 HH:mm',
`rest_start` varchar(8) NOT NULL DEFAULT '' COMMENT '休息开始 HH:mm',
`rest_end` varchar(8) NOT NULL DEFAULT '' COMMENT '休息结束 HH:mm',
`work_hours` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '工时(小时)',
`remark` varchar(100) NOT NULL DEFAULT '' COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tpl_day` (`template_id`,`day_index`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台端排班模板-周期每日规则';
-- ============================================================
-- 系统预置模板(6 个)
-- ============================================================
-- 1. 双休:周一至周五上班,周六日休息
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'double_weekend', '双休', 1, 1, 7, 0, 0, 0, '周一至周五上班 09:00-18:00,周六日休息', 1, 1, 10, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'double_weekend' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 5, 2, '', '', '', '', '', '', '', 0.00
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'double_weekend' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 2. 单休:周一至周六上班,周日休息
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'single_weekend', '单休', 2, 1, 7, 0, 0, 0, '周一至周六上班 09:00-18:00,周日休息', 1, 1, 20, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'single_weekend' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 5, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'single_weekend' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 3. 大小周:第 1 周双休,第 2 周单休(周六上班),14 天一个周期
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'big_small_week', '大小周', 3, 1, 14, 0, 0, 0, '第 1 周双休、第 2 周单休(周六上班),14 天循环', 1, 1, 30, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'big_small_week' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 5, 2, '', '', '', '', '', '', '', 0.00
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
UNION ALL SELECT 7, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 8, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 9, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 10, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 11, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 12, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 13, 2, '', '', '', '', '', '', '', 0.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'big_small_week' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 4. 月休 4 天:每月固定休 4 天,优先安排在周日(周末不足时从月末补齐)
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'month_4_rest', '月休 4 天', 4, 3, 0, 0, 4, 7, '每月固定休 4 天,优先安排周日,遇 5 个周日只休前 4 个', 1, 1, 40, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'month_4_rest' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'month_4_rest' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 5. 三班倒:早班 -> 中班 -> 夜班,各 1 天循环
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'three_shift', '三班倒', 5, 2, 3, 0, 0, 0, '早班 08:00-16:00 → 中班 16:00-24:00 → 夜班 00:00-08:00,3 天循环', 1, 1, 50, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'three_shift' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '早班' AS shift_name, '早' AS shift_short, '#67C23A' AS color, '08:00' AS work_start, '16:00' AS work_end, '' AS rest_start, '' AS rest_end, 8.00 AS work_hours
UNION ALL SELECT 1, 1, '中班', '中', '#E6A23C', '16:00', '24:00', '', '', 8.00
UNION ALL SELECT 2, 1, '夜班', '夜', '#909399', '00:00', '08:00', '', '', 8.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'three_shift' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 6. 做一休一:上 1 天休 1 天
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'work_one_rest_one', '做一休一', 6, 2, 2, 0, 0, 0, '上 1 天休 1 天循环(09:00-18:00)', 1, 1, 60, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'work_one_rest_one' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
UNION ALL SELECT 1, 2, '', '', '', '', '', '', '', 0.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'work_one_rest_one' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- ============================================================
-- 结果校验:6 行,day_count 分别为 7 / 7 / 14 / 1 / 3 / 2
-- ============================================================
SELECT t.id, t.code, t.name, t.template_type, t.align_mode, t.cycle_days,
t.month_rest_days, t.month_rest_weekday, t.is_system, t.status, t.sort,
COUNT(d.id) AS day_count
FROM yz_platform_sched_tpl t
LEFT JOIN yz_platform_sched_tpl_day d ON d.template_id = t.id
WHERE t.delete_time IS NULL
GROUP BY t.id
ORDER BY t.sort;
+94
View File
@@ -0,0 +1,94 @@
-- ============================================================
-- OA 工作排班 —— 菜单配置(yz_system_menu)
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_scheduling_menus.sql
--
-- 挂载位置:OA 应用根(path = '/apps/oa')
-- OA
-- └─ 排班考勤(目录,/apps/oa/scheduling)
-- └─ 工作排班(页面,/apps/oa/scheduling/board)
--
-- 对应页面:backend/src/views/apps/oa/scheduling/index.vue
-- 同一页面内完成:班次管理、排班方案(公司/部门/小组/指定员工)、
-- 周看板、单个或批量员工的临时调配、变更记录查看。
--
-- 说明:
-- 1. 排班看板是 Web 管理端功能,platforms 显式为 ["web"],
-- 移动端菜单不出现(后续若开放员工查看排班,再补 APP 端页面)。
-- 2. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
-- rights 过滤。只插菜单不授权,会出现「目录可见但页面被过滤」,
-- 因此本脚本同时把两个菜单 ID 追加到角色 rights。
-- 目录与页面都必须授权,缺一不可。
-- ============================================================
SET @oa_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 排班考勤(目录) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @oa_id, '排班考勤', '/apps/oa/scheduling', NULL,
'Calendar', 88, 1, 1, '[2]', '["web"]', 1, 'system',
'按公司、部门、小组或指定员工制定排班方案,支持临时调配'
FROM DUAL
WHERE @oa_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL) x);
SET @scheduling_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 工作排班(页面) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @scheduling_id, '工作排班', '/apps/oa/scheduling/board', '/apps/oa/scheduling/index.vue',
'Clock', 1, 1, 1, '[2]', '["web"]', 2, 'system',
'排班看板:按周查看排班,支持方案发布、单条调班与批量临时排班'
FROM DUAL
WHERE @scheduling_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL) x);
SET @board_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @scheduling_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL
AND TRIM(rights) <> ''
AND JSON_VALID(rights)
AND @scheduling_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON));
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL
AND TRIM(rights) <> ''
AND JSON_VALID(rights)
AND @board_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON));
-- ---------- 结果校验 ----------
SELECT id, pid, title, path, component_path, platforms, type, sort
FROM yz_system_menu
WHERE delete_time IS NULL AND path LIKE '/apps/oa/scheduling%'
ORDER BY pid, sort;
SELECT id, name, tenant_id,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON)) AS has_dir,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON)) AS has_board
FROM yz_system_admin_role
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
ORDER BY tenant_id, id;
@@ -0,0 +1,42 @@
-- ============================================================
-- 排班模板 —— 平台端菜单(yz_system_menu)
--
-- 挂载位置:平台端「基础设置」(path = /basicSettings)下,与「字典管理」并列。
-- 基础设置
-- └─ 排班模板(页面,/basicSettings/schedTemplate)
--
-- 说明:
-- 1. views = [1]:只在平台端出现;租户端通过 /backend/oa/scheduling/templates
-- 读取模板(不在租户菜单里暴露模板管理入口)。
-- 2. 平台端菜单接口(/platform/menu/:id)按 views 过滤,不按角色 rights 过滤,
-- 因此本脚本无需追加角色授权。
-- 3. 幂等:按 path 判重,重复执行不会产生重复菜单。
-- 4. 本脚本不使用会话变量 / PREPARE,可整段执行也可逐条执行。
-- ============================================================
-- 1) 有「基础设置」目录时,挂到该目录下
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
SELECT
(SELECT id FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL ORDER BY id LIMIT 1) b),
'排班模板', '/basicSettings/schedTemplate', '/system/schedTemplate/index.vue',
'Calendar', 90, 1, 1, '[1]', 2, 'system',
'全局排班模板:预置双休/单休/大小周/月休/三班倒/做一休一,租户调用后存入自己的方案'
FROM DUAL
WHERE EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL) b)
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings/schedTemplate' AND delete_time IS NULL) e);
-- 2) 未找到「基础设置」目录时,退化为平台端顶级菜单(pid=0)
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
SELECT 0, '排班模板', '/basicSettings/schedTemplate', '/system/schedTemplate/index.vue',
'Calendar', 92, 1, 1, '[1]', 2, 'system',
'全局排班模板:预置双休/单休/大小周/月休/三班倒/做一休一,租户调用后存入自己的方案'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL) b)
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings/schedTemplate' AND delete_time IS NULL) e);
-- 结果校验:应返回 1 行
SELECT id, pid, title, path, component_path, views, type, is_visible, status
FROM yz_system_menu
WHERE delete_time IS NULL AND path = '/basicSettings/schedTemplate';
+8
View File
@@ -190,6 +190,14 @@ func Init(_ string) {
new(BackendScheduleReminderSendLog),
new(OaSchedule),
// 平台端排班模板(docs/sql/create_platform_sched_template.sql)
new(PlatformSchedTpl),
new(PlatformSchedTplDay),
// 工作排班(docs/sql/create_oa_scheduling.sql)
new(OaShift),
new(OaSchedulePlan),
new(OaScheduleEntry),
new(OaScheduleChange),
new(OaHoliday),
new(OaNotice),
// 工作日报(docs/sql/create_oa_dailyreport.sql)
+176
View File
@@ -0,0 +1,176 @@
package models
import "time"
// OaShift OA 工作排班-班次定义表 yz_backend_oa_shift
//
// 班次是排班的最小单位,描述一段可被复用的工作时间(如早班 09:00-18:00)。
// 字段中的时间均为 "HH:mm" 字符串,休息时段可为空表示不区分午休。
type OaShift struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
Name string `orm:"column(name);size(50)" json:"name"`
ShortName string `orm:"column(short_name);size(20);default()" json:"short_name"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
RestStart string `orm:"column(rest_start);size(8);default()" json:"rest_start"`
RestEnd string `orm:"column(rest_end);size(8);default()" json:"rest_end"`
WorkHours float64 `orm:"column(work_hours);type(decimal);digits(5);decimals(2);default(0)" json:"work_hours"`
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
Remark *string `orm:"column(remark);size(255);null" json:"remark"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaShift) TableName() string {
return "yz_backend_oa_shift"
}
// 排班方案类型(plan_type)
const (
OaPlanTypeRegular int8 = 1 // 常规排班:按组织范围周期性下发
OaPlanTypeTemp int8 = 2 // 临时方案:针对单个/多个员工的短期安排
)
// 排班作用范围(scope_type)
const (
OaScopeCompany int8 = 1 // 整个公司
OaScopeOrg int8 = 2 // 部门 / 小组(可含下级组织)
OaScopeEmployees int8 = 3 // 指定员工(一人或多人)
)
// OaSchedulePlan 排班方案 yz_backend_oa_schedule_plan
//
// 方案只描述“谁、在什么时间、上什么班”的规则,发布后由后端展开成
// yz_backend_oa_schedule_entry 明细(每人每天一条),这样看板查询与
// 临时调配都只操作明细表,方案本身可以随时停用而不影响已下发的排班。
//
// 两种排班方式:
// - 按星期(AlignMode=0):Weekdays + ShiftID,一人一天一个固定班次;
// - 按模板(AlignMode=1/2/3):Pattern 存周期的每日规则快照,
// 发布时按自然周 / 开始日滚动 / 自然月推算每天是否上班、上哪个班次。
// 模板来自平台端(yz_platform_sched_tpl),调用后即固化到本方案,
// 之后模板调整与方案无关。
type OaSchedulePlan struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
PlanName string `orm:"column(plan_name);size(100)" json:"plan_name"`
PlanType int8 `orm:"column(plan_type);default(1)" json:"plan_type"`
ScopeType int8 `orm:"column(scope_type);default(2)" json:"scope_type"`
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
OrgName string `orm:"column(org_name);size(128);default()" json:"org_name"`
IncludeChild int8 `orm:"column(include_child);default(1)" json:"include_child"`
EmployeeIDs string `orm:"column(employee_ids);type(text);null" json:"employee_ids"`
EmployeeNames string `orm:"column(employee_names);type(text);null" json:"employee_names"`
StartDate string `orm:"column(start_date);type(date)" json:"start_date"`
EndDate string `orm:"column(end_date);type(date)" json:"end_date"`
Weekdays string `orm:"column(weekdays);size(32);default()" json:"weekdays"`
ShiftID uint64 `orm:"column(shift_id);default(0)" json:"shift_id"`
// 模板快照与周期规则(平台模板调用后固化到方案,见 models/sched_template.go)
TemplateID uint64 `orm:"column(template_id);default(0)" json:"template_id"`
TemplateCode string `orm:"column(template_code);size(50);default()" json:"template_code"`
TemplateName string `orm:"column(template_name);size(50);default()" json:"template_name"`
AlignMode int8 `orm:"column(align_mode);default(0)" json:"align_mode"`
CycleDays int `orm:"column(cycle_days);default(0)" json:"cycle_days"`
WeekOffset int8 `orm:"column(week_offset);default(0)" json:"week_offset"`
MonthRestDays int8 `orm:"column(month_rest_days);default(0)" json:"month_rest_days"`
MonthRestWeekday int8 `orm:"column(month_rest_weekday);default(0)" json:"month_rest_weekday"`
Pattern string `orm:"column(pattern);type(text);null" json:"pattern"`
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
Note string `orm:"column(note);size(500);default()" json:"note"`
Status int8 `orm:"column(status);default(1)" json:"status"`
PublishStatus int8 `orm:"column(publish_status);default(0)" json:"publish_status"`
EntryCount int `orm:"column(entry_count);default(0)" json:"entry_count"`
CreatedBy uint64 `orm:"column(created_by);default(0)" json:"created_by"`
CreatedByName string `orm:"column(created_by_name);size(64);default()" json:"created_by_name"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaSchedulePlan) TableName() string {
return "yz_backend_oa_schedule_plan"
}
// 排班明细来源(source)
const (
OaEntrySourcePlan int8 = 1 // 由排班方案发布生成
OaEntrySourceTemp int8 = 2 // 临时调配 / 单人多人手工安排
)
// OaScheduleEntry 排班明细 yz_backend_oa_schedule_entry
//
// 一人一天一条有效记录:唯一键 (tid, employee_id, schedule_date, is_deleted)。
// 删除时把 is_deleted 写成记录 ID,既保留历史痕迹,又避免同一天重新排班
// 与软删记录冲突(与 OA 工作日报的处理方式一致)。
type OaScheduleEntry struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
PlanID uint64 `orm:"column(plan_id);default(0)" json:"plan_id"`
EmployeeID uint `orm:"column(employee_id)" json:"employee_id"`
EmployeeName string `orm:"column(employee_name);size(50);default()" json:"employee_name"`
EmployeeAccount string `orm:"column(employee_account);size(50);default()" json:"employee_account"`
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
OrgName string `orm:"column(org_name);size(128);default()" json:"org_name"`
Position string `orm:"column(position);size(50);default()" json:"position"`
ScheduleDate string `orm:"column(schedule_date);type(date)" json:"schedule_date"`
ShiftID uint64 `orm:"column(shift_id);default(0)" json:"shift_id"`
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
ShortName string `orm:"column(short_name);size(20);default()" json:"short_name"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
Source int8 `orm:"column(source);default(1)" json:"source"`
Note string `orm:"column(note);size(255);default()" json:"note"`
CreatedBy uint64 `orm:"column(created_by);default(0)" json:"created_by"`
CreatedByName string `orm:"column(created_by_name);size(64);default()" json:"created_by_name"`
IsDeleted uint64 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaScheduleEntry) TableName() string {
return "yz_backend_oa_schedule_entry"
}
// 排班变更类型(change_type)
const (
OaChangeShift int8 = 1 // 调班:换班次
OaChangeClear int8 = 2 // 清除排班
OaChangeManual int8 = 3 // 手工新增排班
OaChangePublish int8 = 4 // 方案发布覆盖
)
// OaScheduleChange 排班变更留痕 yz_backend_oa_schedule_change
//
// 只记录“人 + 日期 + 原班次 -> 新班次”的关键信息,用于回答
// “这天谁被临时调班了、为什么调”,不做完整审计日志。
type OaScheduleChange struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
EntryID uint64 `orm:"column(entry_id);default(0)" json:"entry_id"`
EmployeeID uint `orm:"column(employee_id)" json:"employee_id"`
EmployeeName string `orm:"column(employee_name);size(50);default()" json:"employee_name"`
ScheduleDate string `orm:"column(schedule_date);type(date)" json:"schedule_date"`
ChangeType int8 `orm:"column(change_type);default(1)" json:"change_type"`
OldShiftID uint64 `orm:"column(old_shift_id);default(0)" json:"old_shift_id"`
OldShiftName string `orm:"column(old_shift_name);size(50);default()" json:"old_shift_name"`
NewShiftID uint64 `orm:"column(new_shift_id);default(0)" json:"new_shift_id"`
NewShiftName string `orm:"column(new_shift_name);size(50);default()" json:"new_shift_name"`
Reason string `orm:"column(reason);size(255);default()" json:"reason"`
OperatorID uint64 `orm:"column(operator_id);default(0)" json:"operator_id"`
OperatorName string `orm:"column(operator_name);size(64);default()" json:"operator_name"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
}
func (m *OaScheduleChange) TableName() string {
return "yz_backend_oa_schedule_change"
}
+84
View File
@@ -0,0 +1,84 @@
package models
import "time"
// 排班模板对齐方式(align_mode)
//
// 1 按自然周:双休 / 单休 / 大小周,day_index 0=周一 ... 6=周日
// 2 按开始日滚动:三班倒 / 做一休一,从方案开始日期起按周期顺延
// 3 按自然月:月休,按月计算休息日
const (
SchedTplAlignWeek int8 = 1
SchedTplAlignRoll int8 = 2
SchedTplAlignMonth int8 = 3
)
// 排班模板每天类型(day_type)
const (
SchedTplDayWork int8 = 1 // 上班
SchedTplDayRest int8 = 2 // 休息
)
// 排班模板类型(template_type)
const (
SchedTplTypeDoubleWeekend int8 = 1 // 双休
SchedTplTypeSingleWeekend int8 = 2 // 单休
SchedTplTypeBigSmallWeek int8 = 3 // 大小周
SchedTplTypeMonthRest int8 = 4 // 月休
SchedTplTypeThreeShift int8 = 5 // 三班倒
SchedTplTypeWorkRestOne int8 = 6 // 做一休一
SchedTplTypeCustom int8 = 9 // 自定义
)
// PlatformSchedTpl 平台端排班模板 yz_platform_sched_tpl
//
// 模板是全局数据(不按租户隔离),只有平台端可维护;系统预置模板
// (is_system=1)由平台锁定,禁止编辑 / 删除 / 停用。租户端调用模板时
// 会把规则快照进自己的排班方案(OaSchedulePlan.Pattern),之后互不影响。
type PlatformSchedTpl struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Code string `orm:"column(code);size(50);default()" json:"code"`
Name string `orm:"column(name);size(50)" json:"name"`
TemplateType int8 `orm:"column(template_type);default(9)" json:"template_type"`
AlignMode int8 `orm:"column(align_mode);default(1)" json:"align_mode"`
CycleDays int `orm:"column(cycle_days);default(7)" json:"cycle_days"`
WeekOffset int8 `orm:"column(week_offset);default(0)" json:"week_offset"`
MonthRestDays int8 `orm:"column(month_rest_days);default(0)" json:"month_rest_days"`
MonthRestWeekday int8 `orm:"column(month_rest_weekday);default(0)" json:"month_rest_weekday"`
Summary string `orm:"column(summary);size(255);default()" json:"summary"`
IsSystem int8 `orm:"column(is_system);default(0)" json:"is_system"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
Creater string `orm:"column(creater);size(50);default()" json:"creater"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformSchedTpl) TableName() string {
return "yz_platform_sched_tpl"
}
// PlatformSchedTplDay 平台端排班模板的周期每日规则 yz_platform_sched_tpl_day
//
// day_index 为周期内第几天(从 0 开始)。day_type=2 表示休息,
// 此时班次字段为空;day_type=1 时填写班次名称与上下班时间。
type PlatformSchedTplDay struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TemplateID uint64 `orm:"column(template_id);default(0)" json:"template_id"`
DayIndex int `orm:"column(day_index);default(0)" json:"day_index"`
DayType int8 `orm:"column(day_type);default(1)" json:"day_type"`
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
ShiftShort string `orm:"column(shift_short);size(20);default()" json:"shift_short"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
RestStart string `orm:"column(rest_start);size(8);default()" json:"rest_start"`
RestEnd string `orm:"column(rest_end);size(8);default()" json:"rest_end"`
WorkHours float64 `orm:"column(work_hours);type(decimal);digits(5);decimals(2);default(0)" json:"work_hours"`
Remark string `orm:"column(remark);size(100);default()" json:"remark"`
}
func (m *PlatformSchedTplDay) TableName() string {
return "yz_platform_sched_tpl_day"
}
+19
View File
@@ -334,6 +334,25 @@ func RegisterAuthRoutes() {
beego.Router("/backend/oa/compensation/payrolls/:id", &controllers.BackendOaCompensationController{}, "get:Detail;post:Update;delete:Delete")
beego.Router("/backend/oa/compensation/payrolls/:id/status", &controllers.BackendOaCompensationController{}, "post:UpdateStatus")
// OA工作排班(班次 / 排班方案 / 排班看板 / 临时调配)
beego.Router("/backend/oa/scheduling/shifts", &controllers.BackendOaSchedulingController{}, "get:ShiftList;post:CreateShift")
beego.Router("/backend/oa/scheduling/shifts/:id", &controllers.BackendOaSchedulingController{}, "post:UpdateShift;delete:DeleteShift")
beego.Router("/backend/oa/scheduling/employees", &controllers.BackendOaSchedulingController{}, "get:ScheduleEmployeeOptions")
beego.Router("/backend/oa/scheduling/board", &controllers.BackendOaSchedulingController{}, "get:Board")
beego.Router("/backend/oa/scheduling/plans", &controllers.BackendOaSchedulingController{}, "get:PlanList")
beego.Router("/backend/oa/scheduling/templates", &controllers.BackendOaSchedulingController{}, "get:TemplateList")
beego.Router("/backend/oa/scheduling/templates/apply", &controllers.BackendOaSchedulingController{}, "post:TemplateApply")
beego.Router("/backend/oa/scheduling/plans/create", &controllers.BackendOaSchedulingController{}, "post:PlanCreate")
beego.Router("/backend/oa/scheduling/plans/preview", &controllers.BackendOaSchedulingController{}, "post:PlanPreview")
beego.Router("/backend/oa/scheduling/plans/detail/:id", &controllers.BackendOaSchedulingController{}, "get:PlanDetail")
beego.Router("/backend/oa/scheduling/plans/update/:id", &controllers.BackendOaSchedulingController{}, "post:PlanUpdate")
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/entries/save", &controllers.BackendOaSchedulingController{}, "post:SaveEntries")
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/changes", &controllers.BackendOaSchedulingController{}, "get:ChangeList")
// OA日程管理(日历待办)
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
+6
View File
@@ -404,6 +404,12 @@ func Register() {
beego.Router("/platform/loginBrand", &controllers.PlatformWebsiteSettingController{}, "get:LoginBrandInfo")
beego.Router("/platform/saveLoginBrand", &controllers.PlatformWebsiteSettingController{}, "post:SaveLoginBrand")
// 平台端排班模板(全局模板;系统预置只读,只有平台可维护)
// 注意:带 /status 的固定路径先于 /:id 注册
beego.Router("/platform/schedTemplates", &controllers.PlatformSchedTemplateController{}, "get:List;post:Create")
beego.Router("/platform/schedTemplates/:id/status", &controllers.PlatformSchedTemplateController{}, "post:ToggleStatus")
beego.Router("/platform/schedTemplates/:id", &controllers.PlatformSchedTemplateController{}, "get:Detail;post:Update;delete:Delete")
// ===== 平台官网管理(docs/sql/create_platform_website.sql)=====
// 文章管理 yz_platform_website_article
beego.Router("/platform/website/article/list", &controllers.PlatformWebsiteArticleController{}, "get:List")
+1
View File
@@ -28,6 +28,7 @@ declare module 'vue' {
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+51
View File
@@ -0,0 +1,51 @@
import request from "@/utils/request";
// 平台端排班模板管理(全局模板,只有平台可维护)
// 系统预置模板只读:后端会拒绝对 is_system=1 的编辑 / 删除 / 停用
const PREFIX = "/platform/schedTemplates";
export function getSchedTemplateList(params) {
return request({
url: PREFIX,
method: "get",
params,
});
}
export function getSchedTemplateDetail(id) {
return request({
url: `${PREFIX}/${id}`,
method: "get",
});
}
export function createSchedTemplate(data) {
return request({
url: PREFIX,
method: "post",
data,
});
}
export function updateSchedTemplate(id, data) {
return request({
url: `${PREFIX}/${id}`,
method: "post",
data,
});
}
export function deleteSchedTemplate(id) {
return request({
url: `${PREFIX}/${id}`,
method: "delete",
});
}
export function toggleSchedTemplateStatus(id, status) {
return request({
url: `${PREFIX}/${id}/status`,
method: "post",
data: { status },
});
}
+7
View File
@@ -73,6 +73,13 @@ const staticMainChildren = [
component: () => import("@/views/template/tags.vue"),
meta: { requiresAuth: true, title: "标签调用说明" }
},
// 排班模板(全局模板管理,只有平台可维护)
{
path: "/basicSettings/schedTemplate",
name: "PlatformSchedTemplate",
component: () => import("@/views/system/schedTemplate/index.vue"),
meta: { requiresAuth: true, title: "排班模板" }
},
// 兼容拼写错误的路径重定向
{
path: "/apps/erp/dashborad",
@@ -0,0 +1,523 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="960px"
top="6vh"
destroy-on-close
@closed="handleClosed"
>
<div v-loading="loading">
<el-form :model="form" label-width="110px" :disabled="readonly">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="模板名称" required>
<el-input v-model="form.name" maxlength="50" placeholder="如:项目部大小周" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="模板类型">
<el-select v-model="form.template_type" style="width: 100%">
<el-option
v-for="item in typeOptions"
:key="item.value"
:value="item.value"
:label="item.label"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排班方式" required>
<el-radio-group v-model="form.align_mode">
<el-radio :value="1">按自然周</el-radio>
<el-radio :value="2">按开始日滚动</el-radio>
<el-radio :value="3">按自然月</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
<span class="tip">数字越小越靠前</span>
</el-form-item>
</el-col>
<el-col v-if="form.align_mode !== 3" :span="12">
<el-form-item label="周期天数" required>
<el-select v-if="form.align_mode === 1" v-model="form.cycle_days" style="width: 100%">
<el-option :value="7" label="1 周(7 天)" />
<el-option :value="14" label="2 周(14 天,大小周常用)" />
<el-option :value="21" label="3 周(21 天)" />
<el-option :value="28" label="4 周(28 天)" />
</el-select>
<el-input-number
v-else
v-model="form.cycle_days"
:min="2"
:max="31"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col v-if="form.align_mode === 1" :span="12">
<el-form-item label="起始周">
<el-select v-model="form.week_offset" style="width: 100%">
<el-option :value="0" label="方案开始周按第 1 周算" />
<el-option v-if="form.cycle_days > 7" :value="1" label="方案开始周按第 2 周算" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="form.align_mode === 3" :span="12">
<el-form-item label="每月休息天数" required>
<el-input-number
v-model="form.month_rest_days"
:min="1"
:max="15"
controls-position="right"
/>
<span class="tip">天</span>
</el-form-item>
</el-col>
<el-col v-if="form.align_mode === 3" :span="12">
<el-form-item label="休息日安排">
<el-select v-model="form.month_rest_weekday" style="width: 100%">
<el-option :value="0" label="不固定(从月末往前排)" />
<el-option v-for="item in weekdayOptions" :key="item.value" :value="item.value" :label="`优先休${item.label}`" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="规则摘要">
<el-input
v-model="form.summary"
maxlength="255"
placeholder="留空由系统自动生成,用于租户端列表展示"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="rules-head">
<div class="label">
{{ form.align_mode === 3 ? "上班日规则" : "周期每日规则" }}
</div>
<div class="hint" v-if="form.align_mode === 1">
序号按自然周固定:周一 → 周日;多周循环时第 2 周从第 8 天开始
</div>
<div class="hint" v-else-if="form.align_mode === 2">
从方案开始日期起,按顺序循环使用下面 {{ form.cycle_days }} 天的规则
</div>
<div class="hint" v-else>
除每月固定休息日外,其余日期都按下面这一天的班次上班
</div>
</div>
<el-table :data="form.days" border size="small" :disabled="readonly">
<el-table-column label="天数" width="120">
<template #default="{ row, $index }">{{ dayLabel($index, row) }}</template>
</el-table-column>
<el-table-column label="类型" width="130">
<template #default="{ row }">
<el-radio-group v-model="row.day_type" size="small" :disabled="readonly">
<el-radio-button :value="1">上班</el-radio-button>
<el-radio-button :value="2">休息</el-radio-button>
</el-radio-group>
</template>
</el-table-column>
<el-table-column label="班次名称" width="130">
<template #default="{ row }">
<el-input
v-model="row.shift_name"
size="small"
maxlength="50"
:disabled="readonly || row.day_type === 2"
placeholder="如:白班"
/>
</template>
</el-table-column>
<el-table-column label="简称" width="80">
<template #default="{ row }">
<el-input
v-model="row.shift_short"
size="small"
maxlength="20"
:disabled="readonly || row.day_type === 2"
placeholder="白"
/>
</template>
</el-table-column>
<el-table-column label="上班时间" width="100">
<template #default="{ row }">
<el-input
v-model="row.work_start"
size="small"
placeholder="09:00"
:disabled="readonly || row.day_type === 2"
/>
</template>
</el-table-column>
<el-table-column label="下班时间" width="100">
<template #default="{ row }">
<el-input
v-model="row.work_end"
size="small"
placeholder="18:00"
:disabled="readonly || row.day_type === 2"
/>
</template>
</el-table-column>
<el-table-column label="休息开始" width="100">
<template #default="{ row }">
<el-input
v-model="row.rest_start"
size="small"
placeholder="12:00"
:disabled="readonly || row.day_type === 2"
/>
</template>
</el-table-column>
<el-table-column label="休息结束" width="100">
<template #default="{ row }">
<el-input
v-model="row.rest_end"
size="small"
placeholder="13:30"
:disabled="readonly || row.day_type === 2"
/>
</template>
</el-table-column>
<el-table-column label="颜色" width="70" align="center">
<template #default="{ row }">
<el-color-picker
v-model="row.color"
size="small"
:disabled="readonly || row.day_type === 2"
/>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">{{ readonly ? "关闭" : "取消" }}</el-button>
<el-button v-if="!readonly" type="primary" :loading="submitting" @click="submit">
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick } from "vue";
import {
getSchedTemplateDetail,
createSchedTemplate,
updateSchedTemplate,
} from "@/api/schedTemplate";
const props = defineProps({
modelValue: { type: Boolean, default: false },
templateId: { type: Number, default: 0 },
readonly: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue", "saved"]);
const visible = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const typeOptions = [
{ value: 1, label: "双休" },
{ value: 2, label: "单休" },
{ value: 3, label: "大小周" },
{ value: 4, label: "月休" },
{ value: 5, label: "三班倒" },
{ value: 6, label: "做一休一" },
{ value: 9, label: "自定义" },
];
const weekdayOptions = [
{ value: 1, label: "周一" },
{ value: 2, label: "周二" },
{ value: 3, label: "周三" },
{ value: 4, label: "周四" },
{ value: 5, label: "周五" },
{ value: 6, label: "周六" },
{ value: 7, label: "周日" },
];
const weekdayNames = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const loading = ref(false);
const submitting = ref(false);
// 回填详情时避免 watch 把已加载的规则重新重建
let skipSync = false;
function blankDay(index) {
return {
day_index: index,
day_type: 1,
shift_name: "白班",
shift_short: "白",
color: "#409EFF",
work_start: "09:00",
work_end: "18:00",
rest_start: "12:00",
rest_end: "13:30",
work_hours: 7.5,
remark: "",
};
}
function restDay(index) {
return {
day_index: index,
day_type: 2,
shift_name: "",
shift_short: "",
color: "",
work_start: "",
work_end: "",
rest_start: "",
rest_end: "",
work_hours: 0,
remark: "",
};
}
function defaultForm() {
const days = [];
for (let i = 0; i < 7; i += 1) {
days.push(i >= 5 ? restDay(i) : blankDay(i));
}
return {
id: 0,
name: "",
code: "",
template_type: 9,
align_mode: 1,
cycle_days: 7,
week_offset: 0,
month_rest_days: 4,
month_rest_weekday: 7,
summary: "",
status: 1,
sort: 0,
days,
};
}
const form = reactive(defaultForm());
const dialogTitle = computed(() => {
if (props.readonly) return "查看排班模板";
return props.templateId ? "编辑排班模板" : "新建排班模板";
});
function dayLabel(index, row) {
if (form.align_mode === 3) return "上班日";
if (form.align_mode === 2) return `第 ${index + 1} 天`;
const week = Math.floor(index / 7) + 1;
const name = weekdayNames[index % 7] || row.day_index;
return form.cycle_days > 7 ? `第 ${week} 周 ${name}` : name;
}
// 周期天数或排班方式变化时,保留已填规则并补齐/裁剪天数
function syncDays() {
const cycle = form.align_mode === 3 ? 1 : Number(form.cycle_days) || 7;
const next = [];
for (let i = 0; i < cycle; i += 1) {
const current = form.days[i];
if (current) {
next.push({ ...current, day_index: i });
} else {
next.push(blankDay(i));
}
}
form.days = next;
}
watch(
() => form.cycle_days,
() => {
if (!props.readonly && !skipSync) syncDays();
}
);
watch(
() => form.align_mode,
(value) => {
if (props.readonly || skipSync) return;
if (value === 3) {
form.month_rest_days = form.month_rest_days || 4;
} else if (value === 1 && form.cycle_days % 7 !== 0) {
form.cycle_days = 7;
}
syncDays();
}
);
// 月休的休息日安排由后端按月推算,这里只要保证至少有一天的上班规则
watch(
() => form.modelValue,
(open) => {
if (open) loadDetail();
}
);
async function loadDetail() {
if (!props.templateId) {
skipSync = true;
Object.assign(form, defaultForm());
await nextTick();
skipSync = false;
return;
}
loading.value = true;
try {
const res = await getSchedTemplateDetail(props.templateId);
const data = res?.data || {};
skipSync = true;
Object.assign(form, {
id: data.id || 0,
name: data.name || "",
code: data.code || "",
template_type: data.template_type || 9,
align_mode: data.align_mode || 1,
cycle_days: data.cycle_days || 7,
week_offset: data.week_offset || 0,
month_rest_days: data.month_rest_days || 0,
month_rest_weekday: data.month_rest_weekday || 0,
summary: data.summary || "",
status: data.status === 0 ? 0 : 1,
sort: data.sort || 0,
});
form.days = (data.days || []).map((item) => ({
day_index: item.day_index,
day_type: item.day_type,
shift_name: item.shift_name || "",
shift_short: item.shift_short || "",
color: item.color || "#409EFF",
work_start: item.work_start || "",
work_end: item.work_end || "",
rest_start: item.rest_start || "",
rest_end: item.rest_end || "",
work_hours: item.work_hours || 0,
remark: item.remark || "",
}));
if (form.days.length === 0) syncDays();
await nextTick();
skipSync = false;
} catch (e) {
skipSync = false;
ElMessage.error(e?.message || "加载模板详情失败");
} finally {
loading.value = false;
}
}
function validate() {
if (!form.name.trim()) return "请填写模板名称";
if (form.align_mode !== 3) {
if (!form.cycle_days || form.cycle_days < 2) return "周期天数至少 2 天";
if (form.align_mode === 1 && form.cycle_days % 7 !== 0) {
return "按自然周排班时,周期天数必须是 7 的倍数";
}
if (form.days.length !== form.cycle_days) return "每日规则数量与周期天数不一致";
} else if (!form.month_rest_days || form.month_rest_days < 1) {
return "请填写每月休息天数";
}
const workDays = form.days.filter((item) => item.day_type === 1);
if (workDays.length === 0) return "至少需要一天上班";
for (const item of workDays) {
if (!String(item.shift_name || "").trim()) return "上班日需要填写班次名称";
if (!/^\d{1,2}:\d{2}$/.test(item.work_start) || !/^\d{1,2}:\d{2}$/.test(item.work_end)) {
return "上下班时间格式应为 HH:mm";
}
}
return "";
}
async function submit() {
const message = validate();
if (message) {
ElMessage.warning(message);
return;
}
const payload = {
name: form.name.trim(),
code: form.code || "",
template_type: form.template_type,
align_mode: form.align_mode,
cycle_days: form.align_mode === 3 ? 0 : form.cycle_days,
week_offset: form.week_offset,
month_rest_days: form.align_mode === 3 ? form.month_rest_days : 0,
month_rest_weekday: form.align_mode === 3 ? form.month_rest_weekday : 0,
summary: form.summary,
status: form.status,
sort: form.sort,
days: form.days.map((item, index) => ({
day_index: index,
day_type: item.day_type,
shift_name: item.day_type === 1 ? item.shift_name : "",
shift_short: item.day_type === 1 ? item.shift_short : "",
color: item.day_type === 1 ? item.color || "#409EFF" : "",
work_start: item.day_type === 1 ? item.work_start : "",
work_end: item.day_type === 1 ? item.work_end : "",
rest_start: item.day_type === 1 ? item.rest_start : "",
rest_end: item.day_type === 1 ? item.rest_end : "",
work_hours: item.day_type === 1 ? item.work_hours : 0,
remark: item.remark || "",
})),
};
submitting.value = true;
try {
if (props.templateId) {
await updateSchedTemplate(props.templateId, payload);
ElMessage.success("模板已更新");
} else {
await createSchedTemplate(payload);
ElMessage.success("模板已创建");
}
visible.value = false;
emit("saved");
} catch (e) {
ElMessage.error(e?.message || "保存失败");
} finally {
submitting.value = false;
}
}
function handleClosed() {
skipSync = true;
Object.assign(form, defaultForm());
nextTick(() => {
skipSync = false;
});
}
</script>
<style scoped>
.rules-head {
display: flex;
align-items: baseline;
gap: 12px;
margin: 4px 0 8px;
}
.rules-head .label {
font-size: 14px;
font-weight: 600;
}
.rules-head .hint {
color: #909399;
font-size: 12px;
}
.tip {
margin-left: 8px;
color: #909399;
font-size: 12px;
}
</style>
@@ -0,0 +1,212 @@
<template>
<div class="sched-template-page">
<div class="page-header">
<div class="title">排班模板</div>
<div class="actions">
<el-input
v-model="keyword"
placeholder="搜索模板名称"
clearable
style="width: 220px"
@keyup.enter="loadList"
@clear="loadList"
/>
<el-button @click="loadList">查询</el-button>
<el-button type="primary" @click="openCreate">新建模板</el-button>
</div>
</div>
<el-alert
type="info"
:closable="false"
show-icon
title="系统预置模板全局生效、只读不可改;租户只能调用,调用后规则会复制到租户自己的方案里,可自由调整"
style="margin-bottom: 12px"
/>
<el-table :data="list" v-loading="loading" border>
<el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column label="模板名称" min-width="150">
<template #default="{ row }">
<span>{{ row.name }}</span>
<el-tag v-if="row.is_system === 1" size="small" type="warning" style="margin-left: 6px">
预置
</el-tag>
</template>
</el-table-column>
<el-table-column prop="template_type_text" label="类型" width="100" />
<el-table-column label="排班方式" width="190">
<template #default="{ row }">{{ alignText(row) }}</template>
</el-table-column>
<el-table-column prop="summary" label="规则摘要" min-width="280" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="creater" label="创建人" width="100" />
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openView(row)">查看</el-button>
<el-button v-if="row.editable" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="row.editable"
link
:type="row.status === 1 ? 'warning' : 'success'"
@click="toggleStatus(row)"
>
{{ row.status === 1 ? "停用" : "启用" }}
</el-button>
<el-button v-if="row.editable" link type="danger" @click="removeRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
@current-change="loadList"
@size-change="loadList"
/>
</div>
<TemplateEdit
v-model="dialogVisible"
:template-id="currentId"
:readonly="readonly"
@saved="loadList"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import TemplateEdit from "./components/TemplateEdit.vue";
import {
getSchedTemplateList,
deleteSchedTemplate,
toggleSchedTemplateStatus,
} from "@/api/schedTemplate";
const keyword = ref("");
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const dialogVisible = ref(false);
const currentId = ref(0);
const readonly = ref(false);
function alignText(row) {
if (row.align_mode === 3) {
return `按自然月 · 每月休 ${row.month_rest_days || 0} 天`;
}
if (row.align_mode === 2) {
return `每 ${row.cycle_days || 0} 天滚动循环`;
}
const weeks = Math.max(1, Math.round((row.cycle_days || 7) / 7));
return weeks > 1 ? `按自然周 · ${weeks} 周循环` : "按自然周";
}
async function loadList() {
loading.value = true;
try {
const res = await getSchedTemplateList({
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value,
});
const data = res?.data || {};
list.value = data.list || [];
total.value = data.total || 0;
} catch (e) {
ElMessage.error(e?.message || "加载排班模板失败");
} finally {
loading.value = false;
}
}
function openCreate() {
currentId.value = 0;
readonly.value = false;
dialogVisible.value = true;
}
function openEdit(row) {
currentId.value = row.id;
readonly.value = false;
dialogVisible.value = true;
}
function openView(row) {
currentId.value = row.id;
readonly.value = true;
dialogVisible.value = true;
}
async function toggleStatus(row) {
const next = row.status === 1 ? 0 : 1;
try {
await toggleSchedTemplateStatus(row.id, next);
ElMessage.success(next === 1 ? "模板已启用" : "模板已停用");
loadList();
} catch (e) {
ElMessage.error(e?.message || "更新状态失败");
}
}
async function removeRow(row) {
try {
await ElMessageBox.confirm(
`确认删除模板「${row.name}」?删除后租户将无法再调用该模板,已生成的方案不受影响。`,
"删除确认",
{ type: "warning" }
);
} catch (e) {
return;
}
try {
await deleteSchedTemplate(row.id);
ElMessage.success("模板已删除");
loadList();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
}
onMounted(loadList);
</script>
<style scoped>
.sched-template-page {
padding: 16px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.page-header .title {
font-size: 18px;
font-weight: 600;
}
.page-header .actions {
display: flex;
gap: 8px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>