Files
xgy-oa/klp-ui/src/views/hrm/requests/index.vue
2025-12-23 10:37:00 +08:00

435 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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-button size="mini" type="primary" plain icon="el-icon-plus" @click="goCreate(item.key)">新增</el-button>
<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">
<file-upload
v-model="stampForm.targetFileOssId"
:limit="1"
:file-size="20"
:file-type="['pdf', 'png', 'jpg', 'jpeg', 'bmp', 'webp']"
:is-show-tip="false"
@success="handleTargetUploadSuccess"
/>
</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,
listTravelReq,
listSealReq,
approveSealReq,
rejectSealReq,
cancelSealReq,
stampSealJava,
stampSealPython
} from '@/api/hrm'
import FileUpload from '@/components/FileUpload'
export default {
name: 'HrmRequests',
dicts: ['hrm_stamp_image'],
components: { FileUpload },
data() {
return {
requestBlocks: [],
stampDialogVisible: false,
stampSubmitting: false,
stampForm: {
targetFileUrl: '',
targetFileOssId: '',
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()
},
computed: {
applicantDisplay() {
const user = this.$store?.state?.user || {}
const name = user.nickName || user.userName || ''
const id = user.userId || user.userId === 0 ? user.userId : ''
return name ? `${name}${id ? ` (${id})` : ''}` : id || '当前登录人'
}
},
methods: {
goCreate(key) {
const routeNameMap = {
leave: 'HrmLeaveRequest',
travel: 'HrmTravelRequest',
seal: 'HrmSealRequest'
}
const name = routeNameMap[key]
if (name) this.$router.push({ name })
},
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: '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
})
},
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
if (!this.stampForm.targetFileUrl) {
this.$message.warning('请先上传待盖章文件')
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
}
},
handleTargetUploadSuccess(fileList) {
const first = (fileList && fileList[0]) || {}
this.stampForm.targetFileUrl = first.url || ''
this.stampForm.targetFileOssId = first.ossId || ''
this.marker.visible = false
}
}
}
</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>