hrm前端一版

This commit is contained in:
2025-12-22 10:57:47 +08:00
parent 6858648b07
commit 40f96069ab
7 changed files with 1784 additions and 0 deletions

View File

@@ -0,0 +1,407 @@
<template>
<div class="hrm-page">
<section class="panel-grid quad">
<el-card v-for="item in requestBlocks" :key="item.key" class="metal-panel" shadow="hover">
<div slot="header" class="panel-header">
<span>{{ item.title }}</span>
<div class="actions-inline">
<el-select
v-if="item.statusField"
v-model="item.query.status"
size="mini"
placeholder="状态"
clearable
style="width: 120px"
@change="item.loader"
>
<el-option label="草稿" value="draft" />
<el-option label="审批中" value="pending" />
<el-option label="通过" value="approved" />
<el-option label="驳回" value="rejected" />
</el-select>
<el-button size="mini" icon="el-icon-refresh" @click="item.loader">刷新</el-button>
</div>
</div>
<el-table :data="item.list" v-loading="item.loading" height="300" stripe>
<el-table-column label="员工" prop="empId" min-width="100" />
<el-table-column label="类型/目的" :prop="item.typeField" min-width="120" />
<el-table-column label="开始" prop="startTime" min-width="140">
<template slot-scope="scope">{{ formatDate(scope.row.startTime) }}</template>
</el-table-column>
<el-table-column label="结束" prop="endTime" min-width="140">
<template slot-scope="scope">{{ formatDate(scope.row.endTime) }}</template>
</el-table-column>
<el-table-column label="状态" prop="status" min-width="110">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)">{{ scope.row.status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="附件" prop="accessoryApplyIds" min-width="150" show-overflow-tooltip />
<el-table-column
v-if="item.key === 'seal'"
label="操作"
min-width="220"
fixed="right"
>
<template slot-scope="scope">
<el-button size="mini" type="text" @click="approveSeal(scope.row)">通过</el-button>
<el-button size="mini" type="text" @click="rejectSeal(scope.row)">驳回</el-button>
<el-button size="mini" type="text" @click="cancelSeal(scope.row)">撤销</el-button>
<el-button size="mini" type="text" @click="openStamp(scope.row)">盖章</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</section>
<el-dialog
title="盖章"
:visible.sync="stampDialogVisible"
width="720px"
append-to-body
>
<el-row :gutter="12">
<el-col :span="12">
<el-form :model="stampForm" label-width="110px" size="small">
<el-form-item label="待盖章文件" prop="targetFileUrl">
<el-input v-model="stampForm.targetFileUrl" placeholder="PDF或图片 OSS 完整 URL" />
</el-form-item>
<el-form-item label="章图片" prop="stampImageUrl">
<el-select
v-model="stampForm.stampImageUrl"
filterable
clearable
placeholder="选择章图(字典配置)"
@change="preloadStampImage"
style="width: 100%"
>
<el-option
v-for="opt in dict.type.hrm_stamp_image || []"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="页码">
<el-input-number v-model="stampForm.pageNo" :min="1" />
</el-form-item>
<el-form-item label="坐标/尺寸">
<div class="readonly-row">
<span>X: {{ stampForm.xPx || '-' }} px</span>
<span>Y: {{ stampForm.yPx || '-' }} px</span>
</div>
<div class="readonly-row">
<span>: {{ stampForm.widthPx || stampImageNatural.width || '-' }} px</span>
<span>: {{ stampForm.heightPx || stampImageNatural.height || '-' }} px</span>
</div>
<div class="hint-text">点击右侧预览即可定位尺寸默认取章图原始大小</div>
</el-form-item>
</el-form>
</el-col>
<el-col :span="12">
<div class="preview-card">
<div class="preview-title">图形化定位点击预览设置坐标</div>
<div
class="preview-area"
ref="previewArea"
v-if="stampForm.targetFileUrl"
>
<img
:src="stampForm.targetFileUrl"
class="preview-img"
@load="handlePreviewLoad"
@click="handlePreviewClick"
alt="preview"
>
<div
v-if="marker.visible"
class="stamp-marker"
:style="markerStyle"
></div>
</div>
<div v-else class="preview-placeholder">请先填写待盖章文件URL建议提供图片预览</div>
</div>
</el-col>
</el-row>
<div slot="footer" class="dialog-footer">
<el-button @click="stampDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="stampSubmitting" @click="submitStamp">盖章</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listLeaveReq,
listOvertimeReq,
listTravelReq,
listSealReq,
approveSealReq,
rejectSealReq,
cancelSealReq,
stampSealJava,
stampSealPython
} from '@/api/hrm'
export default {
name: 'HrmRequests',
data() {
return {
requestBlocks: [],
stampDialogVisible: false,
stampSubmitting: false,
stampForm: {
targetFileUrl: '',
stampImageUrl: '',
pageNo: 1,
xPx: 0,
yPx: 0,
widthPx: undefined,
heightPx: undefined
},
currentSeal: null,
previewNatural: { width: 0, height: 0 },
marker: { visible: false, x: 0, y: 0, width: 0, height: 0 },
stampImageNatural: { width: 0, height: 0 }
}
},
created() {
this.initRequests()
},
methods: {
statusType(status) {
if (!status) return 'info'
const map = { pending: 'warning', draft: 'info', approved: 'success', rejected: 'danger' }
return map[status] || 'info'
},
formatDate(val) {
if (!val) return ''
const d = new Date(val)
const p = n => (n < 10 ? `0${n}` : n)
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
},
initRequests() {
this.requestBlocks = [
{ key: 'leave', title: '请假单', typeField: 'leaveType', statusField: 'status', query: { pageNum: 1, pageSize: 10, status: undefined }, list: [], loading: false, loader: this.loadLeaveReq },
{ key: 'overtime', title: '加班单', typeField: 'overtimeType', statusField: 'status', query: { pageNum: 1, pageSize: 10, status: undefined }, list: [], loading: false, loader: this.loadOvertimeReq },
{ key: 'travel', title: '出差单', typeField: 'travelType', statusField: 'status', query: { pageNum: 1, pageSize: 10, status: undefined }, list: [], loading: false, loader: this.loadTravelReq },
{ key: 'seal', title: '用印申请', typeField: 'sealType', statusField: 'status', query: { pageNum: 1, pageSize: 10, status: undefined }, list: [], loading: false, loader: this.loadSealReq }
]
this.requestBlocks.forEach(b => b.loader())
},
loadLeaveReq() {
const block = this.requestBlocks.find(i => i.key === 'leave')
block.loading = true
listLeaveReq(block.query)
.then(res => {
block.list = res.rows || []
})
.finally(() => {
block.loading = false
})
},
loadOvertimeReq() {
const block = this.requestBlocks.find(i => i.key === 'overtime')
block.loading = true
listOvertimeReq(block.query)
.then(res => {
block.list = res.rows || []
})
.finally(() => {
block.loading = false
})
},
loadTravelReq() {
const block = this.requestBlocks.find(i => i.key === 'travel')
block.loading = true
listTravelReq(block.query)
.then(res => {
block.list = res.rows || []
})
.finally(() => {
block.loading = false
})
},
loadSealReq() {
const block = this.requestBlocks.find(i => i.key === 'seal')
block.loading = true
listSealReq(block.query)
.then(res => {
block.list = res.rows || []
})
.finally(() => {
block.loading = false
})
},
approveSeal(row) {
approveSealReq(row.bizId).then(() => {
this.$message.success('已通过')
this.loadSealReq()
})
},
rejectSeal(row) {
rejectSealReq(row.bizId).then(() => {
this.$message.success('已驳回')
this.loadSealReq()
})
},
cancelSeal(row) {
cancelSealReq(row.bizId).then(() => {
this.$message.success('已撤销')
this.loadSealReq()
})
},
openStamp(row) {
this.currentSeal = row
this.stampDialogVisible = true
this.marker.visible = false
},
submitStamp() {
if (!this.currentSeal) return
this.stampSubmitting = true
stampSealJava(this.currentSeal.bizId, this.stampForm)
.then(() => {
this.$message.success('盖章指令已提交')
this.stampDialogVisible = false
this.loadSealReq()
})
.finally(() => {
this.stampSubmitting = false
})
},
handlePreviewLoad(e) {
const img = e.target
this.previewNatural = { width: img.naturalWidth, height: img.naturalHeight }
this.updateMarkerStyle()
},
handlePreviewClick(event) {
if (!this.previewNatural.width || !this.previewNatural.height) return
const rect = this.$refs.previewArea.getBoundingClientRect()
const displayWidth = rect.width
const displayHeight = rect.height
const clickX = event.clientX - rect.left
const clickY = event.clientY - rect.top
const xRatio = clickX / displayWidth
const yRatio = clickY / displayHeight
const xPx = Math.round(xRatio * this.previewNatural.width)
// 注意 PDF 坐标原点左下,这里预览原点左上,需要转换
const yPx = Math.round((1 - yRatio) * this.previewNatural.height)
this.stampForm.xPx = xPx
this.stampForm.yPx = yPx
// 默认尺寸取章图天然尺寸
if (this.stampImageNatural.width) {
this.stampForm.widthPx = this.stampForm.widthPx || this.stampImageNatural.width
this.stampForm.heightPx = this.stampForm.heightPx || this.stampImageNatural.height
}
this.updateMarkerStyle()
},
preloadStampImage() {
if (!this.stampForm.stampImageUrl) return
const img = new Image()
img.onload = () => {
this.stampImageNatural = { width: img.width, height: img.height }
}
img.src = this.stampForm.stampImageUrl
},
updateMarkerStyle() {
if (!this.previewNatural.width || !this.previewNatural.height) return
const rect = this.$refs.previewArea?.getBoundingClientRect?.()
if (!rect) return
const displayWidth = rect.width
const displayHeight = rect.height
const xRatio = this.stampForm.xPx / this.previewNatural.width
const yRatio = 1 - this.stampForm.yPx / this.previewNatural.height
const wRatio = (this.stampForm.widthPx || this.stampImageNatural.width || 0) / this.previewNatural.width
const hRatio = (this.stampForm.heightPx || this.stampImageNatural.height || 0) / this.previewNatural.height
this.marker = {
visible: true,
x: xRatio * displayWidth,
y: yRatio * displayHeight,
width: wRatio * displayWidth,
height: hRatio * displayHeight
}
}
}
}
</script>
<style lang="scss" scoped>
.hrm-page {
padding: 16px 20px 32px;
background: #f8f9fb;
}
.panel-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.metal-panel {
border: 1px solid #d7d9df;
border-radius: 10px;
background: #fff;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: #303133;
}
.actions-inline {
display: flex;
gap: 8px;
align-items: center;
}
.coord-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.preview-card {
border: 1px dashed #e6e8ed;
border-radius: 8px;
padding: 8px;
min-height: 340px;
}
.preview-title {
font-weight: 600;
margin-bottom: 8px;
}
.preview-area {
position: relative;
width: 100%;
height: 280px;
overflow: hidden;
background: #f5f7fa;
border-radius: 6px;
border: 1px solid #ebeef5;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
cursor: crosshair;
}
.preview-placeholder {
color: #a0a3ad;
font-size: 13px;
padding: 12px;
background: #fafafa;
border: 1px dashed #ebeef5;
border-radius: 6px;
}
.stamp-marker {
position: absolute;
border: 2px dashed #409eff;
background: rgba(64, 158, 255, 0.08);
pointer-events: none;
transform: translate(-50%, -50%);
}
@media (max-width: 1200px) {
.panel-grid {
grid-template-columns: 1fr;
}
}
</style>