更新若干事项
This commit is contained in:
1 parent
68cdaf38fa
commit
9898962836
30 files changed
+7715
No files matched your search
Vendored
+2
@@ -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']
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user