更新若干事项

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

@@ -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>