“出国申请审批流程已实现10.29”

This commit is contained in:
weizhong committed 2025-10-29 20:02:33 +08:00
1 parent 32b9822146
commit 4277e2fca8
15 files changed
+1040 -321

No files matched your search

+53
View File
@@ -157,6 +157,7 @@ export interface ApprovalRequest {
applicationId: number | string;
approvalStatus: string;
approvalOpinion?: string;
approvalSignature?: string; // 审批签字(base64图片)
taskId?: string; // 🔴 新增:工作流任务ID,支持标准工作流模式
}
@@ -885,6 +886,7 @@ export interface ItineraryChangeApprovalRequest {
changeId: number | string;
approved: boolean; // true=通过,false=拒绝
opinion?: string; // 审批意见
signature?: string; // 审批签字(base64图片)
}
// 查询行程变更列表
@@ -1030,3 +1032,54 @@ export function getHrApprovers(applicantId: string | number): Promise<{ data: nu
method: 'get'
});
}
// ==================== 出国申请进度查询(监控)相关API ====================
// 审批监控查询参数类型
export interface OverseasMonitorQuery {
pageNum: number;
pageSize: number;
applicantName?: string;
department?: string;
destination?: string;
approvalLevel?: string;
currentApprover?: string;
status?: string;
applyTimeStart?: string;
applyTimeEnd?: string;
}
// 查询待审批列表(监控)
export function getPendingList(query: OverseasMonitorQuery) {
return request({
url: '/rs/overseas/monitor/pending/list',
method: 'get',
params: query
});
}
// 查询已完成审批列表(监控)
export function getCompletedList(query: OverseasMonitorQuery) {
return request({
url: '/rs/overseas/monitor/completed/list',
method: 'get',
params: query
});
}
// 查询申请详情(监控)
export function getDetail(applicationId: string | number) {
return request({
url: `/rs/overseas/monitor/detail/${applicationId}`,
method: 'get'
});
}
// 下载审批表PDF
export function downloadApprovalPdf(applicationId: string | number) {
return request({
url: `/rs/overseas/monitor/downloadApprovalPdf/${applicationId}`,
method: 'get',
responseType: 'blob'
});
}
+196 -227
View File
@@ -1,278 +1,247 @@
<template>
<div class="signature-pad-wrapper">
<div class="signature-header">
<span class="signature-title">手写签名</span>
<el-button v-if="hasSignature" size="small" type="danger" :icon="Delete" @click="handleClear">清空</el-button>
</div>
<div class="signature-canvas-container">
<div class="signature-pad-container">
<div class="signature-canvas-wrapper">
<canvas
ref="canvasRef"
class="signature-canvas"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
></canvas>
<div v-if="!hasSignature" class="signature-placeholder">请在此处签名</div>
:width="width"
:height="height"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="handleTouch"
@touchmove="handleTouch"
@touchend="stopDrawing"
/>
</div>
<div class="signature-buttons">
<el-button size="small" @click="clearSignature">清除</el-button>
<el-button v-if="showSaveButton" size="small" type="primary" @click="saveSignature">保存</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { ref, onMounted, watch } from 'vue';
import { ElMessage } from 'element-plus';
// Props
interface Props {
modelValue?: string; // Base64签名数据
modelValue?: string; // base64图片数据
width?: number;
height?: number;
lineWidth?: number;
lineColor?: string;
showSaveButton?: boolean; // 是否显示保存按钮
autoSave?: boolean; // 是否自动保存(绘制时实时保存)
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
width: 500,
height: 200,
lineWidth: 2,
lineColor: '#000000'
width: 400,
height: 150,
showSaveButton: true,
autoSave: true
});
// Emits
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
'update:modelValue': [value: string];
'save': [value: string];
}>();
// Refs
const canvasRef = ref<HTMLCanvasElement>();
const hasSignature = ref(false);
const isDrawing = ref(false);
let ctx: CanvasRenderingContext2D | null = null;
let resizeObserver: ResizeObserver | null = null;
const lastPoint = ref({ x: 0, y: 0 });
onMounted(() => {
initCanvas();
// 如果有初始值,加载到画布上
if (props.modelValue) {
loadSignature(props.modelValue);
// Methods
const startDrawing = (event: MouseEvent) => {
if (!canvasRef.value) return;
isDrawing.value = true;
const rect = canvasRef.value.getBoundingClientRect();
lastPoint.value = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
};
const draw = (event: MouseEvent) => {
if (!isDrawing.value || !canvasRef.value) return;
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
const rect = canvasRef.value.getBoundingClientRect();
const currentPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
// 绘制线条
ctx.beginPath();
ctx.moveTo(lastPoint.value.x, lastPoint.value.y);
ctx.lineTo(currentPoint.x, currentPoint.y);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
lastPoint.value = currentPoint;
// 自动保存
if (props.autoSave) {
saveToModel();
}
};
// 监听容器大小变化
if (canvasRef.value?.parentElement) {
resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(canvasRef.value.parentElement);
const stopDrawing = () => {
isDrawing.value = false;
// 如果不是自动保存,在停止绘制时保存
if (!props.autoSave) {
saveToModel();
}
});
};
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
// 触摸事件处理
const handleTouch = (event: TouchEvent) => {
event.preventDefault();
const touch = event.touches[0];
if (!touch || !canvasRef.value) return;
const rect = canvasRef.value.getBoundingClientRect();
const mouseEvent = new MouseEvent(
event.type === 'touchstart' ? 'mousedown' : 'mousemove',
{
clientX: touch.clientX,
clientY: touch.clientY
}
);
if (event.type === 'touchstart') {
startDrawing(mouseEvent);
} else if (event.type === 'touchmove') {
draw(mouseEvent);
}
});
};
// 监听外部值变化
// 清除签名
const clearSignature = () => {
if (!canvasRef.value) return;
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
emit('update:modelValue', '');
};
// 保存到 v-model
const saveToModel = () => {
if (!canvasRef.value) return;
try {
const dataURL = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', dataURL);
} catch (error) {
console.error('保存签名失败:', error);
}
};
// 手动保存(触发save事件)
const saveSignature = () => {
if (!canvasRef.value) return;
try {
const dataURL = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', dataURL);
emit('save', dataURL);
ElMessage.success('签名已保存');
} catch (error) {
console.error('保存签名失败:', error);
ElMessage.error('保存签名失败');
}
};
// 从base64恢复签名
const restoreSignature = (base64Image: string) => {
if (!canvasRef.value || !base64Image) return;
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
const img = new Image();
img.onload = () => {
if (!canvasRef.value) return;
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
ctx.drawImage(img, 0, 0);
};
img.src = base64Image;
};
// 监听 modelValue 变化,恢复签名
watch(
() => props.modelValue,
(newVal) => {
if (newVal && !hasSignature.value) {
loadSignature(newVal);
(newValue) => {
if (newValue) {
restoreSignature(newValue);
} else {
clearSignature();
}
}
);
const initCanvas = () => {
if (!canvasRef.value) return;
const canvas = canvasRef.value;
const container = canvas.parentElement;
// 获取容器的实际宽度
const containerWidth = container?.clientWidth || props.width;
// 设置画布的物理尺寸(实际像素)
canvas.width = containerWidth;
canvas.height = props.height;
// 同时设置CSS样式,确保显示尺寸与物理尺寸一致
canvas.style.width = `${containerWidth}px`;
canvas.style.height = `${props.height}px`;
ctx = canvas.getContext('2d');
if (ctx) {
ctx.strokeStyle = props.lineColor;
ctx.lineWidth = props.lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 组件挂载时恢复签名
onMounted(() => {
if (props.modelValue) {
restoreSignature(props.modelValue);
}
};
});
// 处理容器大小变化
const handleResize = () => {
if (!canvasRef.value) return;
// 保存当前签名
const currentSignature = hasSignature.value ? canvasRef.value.toDataURL('image/png') : '';
// 重新初始化画布
initCanvas();
// 恢复签名
if (currentSignature && hasSignature.value) {
nextTick(() => {
loadSignature(currentSignature);
});
}
};
const startDrawing = (x: number, y: number) => {
if (!ctx || !canvasRef.value) return;
isDrawing.value = true;
const rect = canvasRef.value.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(x - rect.left, y - rect.top);
};
const draw = (x: number, y: number) => {
if (!isDrawing.value || !ctx || !canvasRef.value) return;
const rect = canvasRef.value.getBoundingClientRect();
ctx.lineTo(x - rect.left, y - rect.top);
ctx.stroke();
hasSignature.value = true;
};
const stopDrawing = () => {
if (!isDrawing.value) return;
isDrawing.value = false;
if (ctx) {
ctx.closePath();
}
// 导出签名数据
if (hasSignature.value && canvasRef.value) {
const signatureData = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', signatureData);
}
};
// 鼠标事件处理
const handleMouseDown = (e: MouseEvent) => {
startDrawing(e.clientX, e.clientY);
};
const handleMouseMove = (e: MouseEvent) => {
draw(e.clientX, e.clientY);
};
const handleMouseUp = () => {
stopDrawing();
};
// 触摸事件处理(移动端支持)
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
startDrawing(touch.clientX, touch.clientY);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
draw(touch.clientX, touch.clientY);
};
const handleTouchEnd = (e: TouchEvent) => {
e.preventDefault();
stopDrawing();
};
// 清空签名
const handleClear = () => {
if (!canvasRef.value || !ctx) return;
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
hasSignature.value = false;
emit('update:modelValue', '');
};
// 加载签名
const loadSignature = (base64Data: string) => {
if (!canvasRef.value || !ctx) return;
const img = new Image();
img.onload = () => {
ctx!.clearRect(0, 0, canvasRef.value!.width, canvasRef.value!.height);
ctx!.drawImage(img, 0, 0);
hasSignature.value = true;
};
img.src = base64Data;
};
// 获取签名数据
const getSignature = (): string => {
if (!canvasRef.value || !hasSignature.value) return '';
return canvasRef.value.toDataURL('image/png');
};
// 对外暴露方法
// 暴露方法供父组件调用
defineExpose({
clear: handleClear,
getSignature,
hasSignature: () => hasSignature.value
clearSignature,
saveSignature
});
</script>
<style scoped lang="scss">
.signature-pad-wrapper {
.signature-pad-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.signature-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.signature-canvas-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #fafafa;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px;
}
.signature-title {
font-size: 14px;
color: #606266;
font-weight: 500;
}
}
.signature-canvas {
border: 1px dashed #dcdfe6;
border-radius: 4px;
cursor: crosshair;
background-color: #fff;
touch-action: none; /* 防止触摸时页面滚动 */
}
.signature-canvas-container {
position: relative;
width: 100%;
border: 1px solid #dcdfe6;
border-radius: 4px;
background-color: #ffffff;
overflow: hidden;
.signature-canvas:hover {
border-color: #409eff;
}
&:hover {
border-color: #c0c4cc;
}
.signature-canvas {
display: block;
width: 100%;
cursor: crosshair;
touch-action: none;
}
.signature-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #c0c4cc;
font-size: 14px;
pointer-events: none;
user-select: none;
}
}
.signature-buttons {
display: flex;
gap: 8px;
justify-content: center;
}
</style>
+28 -3
View File
@@ -325,11 +325,23 @@
</el-dialog>
<!-- 审批对话框 -->
<el-dialog :title="approvalDialog.title" v-model="approvalDialog.visible" width="600px" append-to-body>
<el-dialog :title="approvalDialog.title" v-model="approvalDialog.visible" width="650px" append-to-body>
<el-form ref="approvalFormRef" :model="approvalForm" :rules="approvalRules" label-width="100px">
<el-form-item label="审批意见" prop="approvalOpinion">
<el-input v-model="approvalForm.approvalOpinion" type="textarea" :rows="4" placeholder="请输入审批意见" />
</el-form-item>
<!-- 手写签字 -->
<el-form-item label="审批签字" prop="approvalSignature">
<SignaturePad
v-model="approvalForm.approvalSignature"
:width="450"
:height="120"
:auto-save="true"
:show-save-button="false"
/>
<div class="signature-tip">请在上方签字板中手写签名</div>
</el-form-item>
</el-form>
<template #footer>
@@ -360,6 +372,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { Document, Warning, Paperclip, View, Download } from '@element-plus/icons-vue';
import animate from '@/animate';
import request from '@/utils/request';
import SignaturePad from '@/components/SignaturePad/index.vue';
import { getToken } from '@/utils/auth';
// 响应式数据
@@ -400,7 +413,8 @@ const state = reactive({
approvalForm: {
applicationId: '',
approvalStatus: '1',
approvalOpinion: ''
approvalOpinion: '',
approvalSignature: '' // 审批签字
} as ApprovalRequest,
approvalRules: {
approvalOpinion: [{ required: true, message: '请输入审批意见', trigger: 'blur' }]
@@ -550,6 +564,7 @@ const handleApprovalPass = (row: OverseasApplicationVO) => {
state.approvalForm.applicationId = row.applicationId;
state.approvalForm.approvalStatus = '1';
state.approvalForm.approvalOpinion = '';
state.approvalForm.approvalSignature = ''; // 清空签字
state.approvalDialog.title = '通过';
state.approvalDialog.visible = true;
};
@@ -559,6 +574,7 @@ const handleApprovalReject = (row: OverseasApplicationVO) => {
state.approvalForm.applicationId = row.applicationId;
state.approvalForm.approvalStatus = '2';
state.approvalForm.approvalOpinion = '';
state.approvalForm.approvalSignature = ''; // 清空签字
state.approvalDialog.title = '拒绝';
state.approvalDialog.visible = true;
};
@@ -586,7 +602,8 @@ const cancelApproval = () => {
state.approvalForm = {
applicationId: '',
approvalStatus: '1',
approvalOpinion: ''
approvalOpinion: '',
approvalSignature: ''
};
};
@@ -913,4 +930,12 @@ const handlePreviewStampedFile = async (data: OverseasApplicationVO) => {
font-size: 12px;
}
}
/* 签字样式 */
.signature-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
text-align: center;
}
</style>
@@ -257,7 +257,7 @@
</el-dialog>
<!-- 审批对话框 -->
<el-dialog :title="approvalDialog.title" v-model="approvalDialog.visible" width="600px" append-to-body :close-on-click-modal="false">
<el-dialog :title="approvalDialog.title" v-model="approvalDialog.visible" width="650px" append-to-body :close-on-click-modal="false">
<el-form ref="approvalFormRef" :model="approvalForm" :rules="approvalRules" label-width="100px">
<el-form-item label="审批结果" prop="approved">
<el-radio-group v-model="approvalForm.approved">
@@ -268,6 +268,18 @@
<el-form-item label="审批意见" prop="opinion">
<el-input v-model="approvalForm.opinion" type="textarea" :rows="4" placeholder="请输入审批意见" maxlength="500" show-word-limit />
</el-form-item>
<!-- 手写签字 -->
<el-form-item label="审批签字">
<SignaturePad
v-model="approvalForm.signature"
:width="450"
:height="120"
:auto-save="true"
:show-save-button="false"
/>
<div class="signature-tip">请在上方签字板中手写签名</div>
</el-form-item>
</el-form>
<template #footer>
@@ -295,6 +307,7 @@ import {
type ItineraryChangeVO,
type ItineraryChangeApprovalRequest
} from '@/api/rs/overseas';
import SignaturePad from '@/components/SignaturePad/index.vue';
// ==================== 响应式数据 ====================
@@ -346,7 +359,8 @@ const state = reactive({
approvalForm: {
changeId: 0,
approved: true, // true=通过,false=拒绝
opinion: ''
opinion: '',
signature: '' // 审批签字
} as ItineraryChangeApprovalRequest,
approvalLoading: false,
@@ -549,4 +563,12 @@ const getStatusTagType = (status: string): 'primary' | 'success' | 'info' | 'war
.tab-content {
min-height: 400px;
}
/* 签字样式 */
.signature-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
text-align: center;
}
</style>