专业间人员分配添加同步按钮

This commit is contained in:
lpd
2026-05-26 16:19:42 +08:00
parent 47f48aa09f
commit 0e8dcb1f71
2 changed files with 317 additions and 2 deletions

View File

@@ -0,0 +1,194 @@
<template>
<div ref="paneRef" class="tjt-split-pane" :class="{ 'is-resizing': isResizing }">
<div class="tjt-split-pane__panel tjt-split-pane__panel--left" :style="{ flexBasis: leftBasis }">
<slot name="left"></slot>
</div>
<div
aria-label="Resize panels"
class="tjt-split-pane__resize"
role="separator"
tabindex="0"
@keydown="handleKeydown"
@pointerdown="startResize"
>
<span class="tjt-split-pane__resize-handle"></span>
</div>
<div class="tjt-split-pane__panel tjt-split-pane__panel--right">
<slot name="right"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'TjtSplitPane' })
const props = withDefaults(
defineProps<{
defaultPercent?: number
minPanelWidth?: number
}>(),
{
defaultPercent: 50,
minPanelWidth: 320
}
)
const RESIZER_WIDTH = 16
const KEYBOARD_STEP = 2
const paneRef = ref<HTMLElement>()
const leftPercent = ref(props.defaultPercent)
const isResizing = ref(false)
const leftBasis = computed(() => `calc(${leftPercent.value}% - ${RESIZER_WIDTH / 2}px)`)
const getBounds = () => {
const rect = paneRef.value?.getBoundingClientRect()
if (!rect?.width) {
return
}
const minPercent = Math.min(
45,
((props.minPanelWidth + RESIZER_WIDTH / 2) / rect.width) * 100
)
return {
rect,
minPercent,
maxPercent: 100 - minPercent
}
}
const setLeftPercent = (percent: number) => {
const bounds = getBounds()
if (!bounds) {
leftPercent.value = percent
return
}
leftPercent.value = Math.min(bounds.maxPercent, Math.max(bounds.minPercent, percent))
}
const updateByPointer = (event: PointerEvent) => {
const bounds = getBounds()
if (!bounds) {
return
}
setLeftPercent(((event.clientX - bounds.rect.left) / bounds.rect.width) * 100)
}
const stopResize = () => {
if (!isResizing.value) {
return
}
isResizing.value = false
window.removeEventListener('pointermove', updateByPointer)
window.removeEventListener('pointerup', stopResize)
window.removeEventListener('pointercancel', stopResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
const startResize = (event: PointerEvent) => {
isResizing.value = true
event.preventDefault()
updateByPointer(event)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('pointermove', updateByPointer)
window.addEventListener('pointerup', stopResize)
window.addEventListener('pointercancel', stopResize)
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
setLeftPercent(leftPercent.value - KEYBOARD_STEP)
}
if (event.key === 'ArrowRight') {
event.preventDefault()
setLeftPercent(leftPercent.value + KEYBOARD_STEP)
}
}
onBeforeUnmount(() => {
stopResize()
})
</script>
<style scoped>
.tjt-split-pane {
display: flex;
align-items: stretch;
width: 100%;
min-width: 0;
}
.tjt-split-pane__panel {
min-width: 0;
}
.tjt-split-pane__panel--left {
flex: 0 0 50%;
}
.tjt-split-pane__panel--right {
flex: 1 1 0;
}
.tjt-split-pane__resize {
position: relative;
display: flex;
flex: 0 0 16px;
align-items: center;
justify-content: center;
cursor: col-resize;
outline: none;
touch-action: none;
user-select: none;
}
.tjt-split-pane__resize::before {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
content: '';
background: var(--el-border-color);
}
.tjt-split-pane__resize-handle {
position: sticky;
top: calc(50vh - 21px);
z-index: 1;
width: 6px;
height: 42px;
border-radius: 4px;
background: var(--el-border-color);
transition:
width 0.15s ease,
background-color 0.15s ease;
}
.tjt-split-pane__resize:hover .tjt-split-pane__resize-handle,
.tjt-split-pane__resize:focus-visible .tjt-split-pane__resize-handle,
.tjt-split-pane.is-resizing .tjt-split-pane__resize-handle {
width: 8px;
background: var(--el-color-primary);
}
@media (max-width: 900px) {
.tjt-split-pane {
flex-direction: column;
}
.tjt-split-pane__panel--left,
.tjt-split-pane__panel--right {
flex: 0 0 auto;
}
.tjt-split-pane__resize {
display: none;
}
}
</style>

View File

@@ -110,6 +110,16 @@
<div class="mb-16px flex items-center justify-between gap-12px">
<div class="text-16px font-600">{{ currentPlanning.planningContent }}</div>
<div class="flex items-center gap-12px">
<el-button
v-hasPermi="['tjt:specialty-role-split:update']"
:disabled="copyPlanningOptions.length === 0"
plain
type="primary"
@click="openCopyDialog"
>
<Icon class="mr-5px" icon="ep:copy-document" />
复制人员设置
</el-button>
<el-button
v-hasPermi="['tjt:specialty-role-split:update']"
plain
@@ -312,6 +322,31 @@
<el-button @click="dialogVisible = false">取消</el-button>
</template>
</Dialog>
<Dialog v-model="copyDialogVisible" title="复制其他任务包人员设置" width="560">
<el-form label-width="96px">
<el-form-item label="来源任务包">
<el-select
v-model="copySourcePlanningId"
class="!w-1/1"
filterable
placeholder="请选择任务包"
>
<el-option
v-for="item in copyPlanningOptions"
:key="item.id"
:label="buildPlanningOptionLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button :loading="copyLoading" type="primary" @click="handleCopyConfirm">确定复制</el-button>
<el-button @click="copyDialogVisible = false">取消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
@@ -356,6 +391,7 @@ const loading = ref(false)
const planningLoading = ref(false)
const roleLoading = ref(false)
const saveLoading = ref(false)
const copyLoading = ref(false)
const total = ref(0)
const projectList = ref<ProjectApi.ProjectVO[]>([])
const planningList = ref<PlanningApi.ProjectPlanningVO[]>([])
@@ -366,6 +402,8 @@ const editRoleList = ref<SpecialtyRoleSplitVO[]>([])
const selectedGroupCode = ref('')
const editGroupCode = ref('')
const dialogVisible = ref(false)
const copyDialogVisible = ref(false)
const copySourcePlanningId = ref<number>()
const employeeOptions = ref<EmployeeApi.EmployeeSimpleVO[]>([])
const employeeLoading = ref(false)
const queryFormRef = ref()
@@ -547,6 +585,12 @@ const currentEditGroup = computed(
() =>
editGroups.value.find((item) => item.specialtyCode === editGroupCode.value) || editGroups.value[0]
)
const copyPlanningOptions = computed<Array<PlanningApi.ProjectPlanningVO & { id: number }>>(() =>
planningList.value.filter(
(item): item is PlanningApi.ProjectPlanningVO & { id: number } =>
typeof item.id === 'number' && item.id !== currentPlanning.value?.id
)
)
const isProjectLeadRow = (row?: SpecialtyRoleSplitVO) => row?.specialtyCode === PROJECT_LEAD_GROUP_CODE
const isEngineeringPrincipalRole = (row?: SpecialtyRoleSplitVO) =>
row?.roleCode === ROLE_ENGINEERING_PRINCIPAL
@@ -715,9 +759,9 @@ const buildSavePersons = (
return { persons }
}
const buildSaveItems = (validate: boolean) => {
const buildSaveItems = (validate: boolean, sourceGroups = editGroups.value) => {
const items: SpecialtyRoleSplitApi.SpecialtyRoleSplitSaveItemVO[] = []
for (const group of editGroups.value) {
for (const group of sourceGroups) {
let roleTotal = 0
for (const row of group.rows) {
const result = buildSavePersons(row, validate)
@@ -857,6 +901,83 @@ const openEditDialog = () => {
dialogVisible.value = true
}
const buildPlanningOptionLabel = (item: PlanningApi.ProjectPlanningVO) => {
const parts = [item.planningContent]
if (item.planningStartYear) {
parts.push(String(item.planningStartYear))
}
return parts.filter(Boolean).join(' / ')
}
const openCopyDialog = () => {
if (!currentPlanning.value?.id) {
return
}
if (!copyPlanningOptions.value.length) {
message.warning('暂无可复制的其他任务包')
return
}
copySourcePlanningId.value = copyPlanningOptions.value[0]?.id
copyDialogVisible.value = true
}
const buildCopiedRoleRows = (sourceRows: SpecialtyRoleSplitVO[]) => {
const sourceMap = new Map(
sourceRows.map((item) => [`${item.specialtyCode}:${item.roleCode}`, item] as const)
)
const rows = roleList.value.map((targetRow) => {
const sourceRow = sourceMap.get(`${targetRow.specialtyCode}:${targetRow.roleCode}`)
return buildEditableRow({
...targetRow,
roleRatio: sourceRow?.roleRatio ?? targetRow.roleRatio,
persons: isProjectLeadRow(targetRow) ? targetRow.persons || [] : sourceRow?.persons || []
})
})
rows.forEach((row) => syncRoleRatiosForGroup(rows, row.specialtyCode))
syncAllDerivedValues(rows)
return rows
}
const handleCopyConfirm = async () => {
if (!currentPlanning.value?.id || !copySourcePlanningId.value) {
message.warning('请选择来源任务包')
return
}
try {
await message.confirm('确认用所选任务包的人员分配设置替换当前任务包吗?')
} catch {
return
}
copyLoading.value = true
try {
const sourceData = await SpecialtyRoleSplitApi.getSpecialtyRoleSplitListByPlanningId(
copySourcePlanningId.value
)
const sourceRows = cloneRoleRows(sourceData)
if (!sourceRows.length) {
message.warning('来源任务包暂无人员分配设置')
return
}
sourceRows.forEach((row) => syncRoleRatiosForGroup(sourceRows, row.specialtyCode))
syncAllDerivedValues(sourceRows)
const copiedRows = buildCopiedRoleRows(sourceRows)
const items = buildSaveItems(false, buildGroups(copiedRows))
if (!items) {
return
}
await SpecialtyRoleSplitApi.saveSpecialtyRoleSplitBatch({
planningId: currentPlanning.value.id,
items,
temporarySave: true
})
message.success('复制人员分配设置成功')
copyDialogVisible.value = false
await loadRoleList(currentPlanning.value.id)
} finally {
copyLoading.value = false
}
}
const saveRoleSplit = async (temporarySave: boolean) => {
if (!currentPlanning.value?.id) {
return