Files
supervision-test-frontend-repo/src/views/gh/improvementProposal/components/ImprovementProposalBPMForm.vue
T
ye1023 c4fc82ef48 yxk-20260729
改善提案
Task_improve005 不显示 TaskHandleInnerContent。评委选择“投票通过”或“投票反对”后弹出确认框;确认即锁定个人投票并直接完成当前流程任务。
2026-07-29 10:59:04 +08:00

555 lines
23 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>
<a-spin :spinning="loading">
<section class="improvement-proposal-bpm-form">
<div class="section-header">
<span>基本信息</span>
</div>
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="提案编号">{{ proposal.proposalNo || '-' }}</a-descriptions-item>
<a-descriptions-item label="提案名称">{{ proposal.proposalName || '-' }}</a-descriptions-item>
<a-descriptions-item label="提出日期">{{ formatDate(proposal.proposalDate) }}</a-descriptions-item>
<a-descriptions-item label="提案类别">{{ proposal.proposalCategory || '-' }}</a-descriptions-item>
<a-descriptions-item label="提案部门">{{ proposal.proposalDeptName || '-' }}</a-descriptions-item>
<a-descriptions-item label="申请人">{{ proposal.applicantName || '-' }}</a-descriptions-item>
<a-descriptions-item label="团队名称" :span="2">{{ proposal.teamName || '-' }}</a-descriptions-item>
<a-descriptions-item label="问题描述" :span="2">{{ proposal.problemDesc || '-' }}</a-descriptions-item>
<a-descriptions-item label="改善方法及措施" :span="2">{{ proposal.improvementMethod || '-' }}</a-descriptions-item>
<a-descriptions-item label="改善前情况" :span="2">{{ proposal.beforeDesc || '-' }}</a-descriptions-item>
<a-descriptions-item label="改善后情况" :span="2">{{ proposal.afterDesc || '-' }}</a-descriptions-item>
<a-descriptions-item label="改善效果" :span="2">{{ proposal.improvementEffect || '-' }}</a-descriptions-item>
<a-descriptions-item label="附件" :span="2">
<div v-if="proposal.id" class="attachment-grid">
<div>
<div class="attachment-label">改善前附件</div>
<SUploadFile :bussiness-id="`${proposal.id}:before`" :disabled="true" :multiple="true" />
</div>
<div>
<div class="attachment-label">改善后附件</div>
<SUploadFile :bussiness-id="`${proposal.id}:after`" :disabled="true" :multiple="true" />
</div>
</div>
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
<template v-if="showProcessHandle">
<a-divider />
<div class="section-header">
<span>流程办理</span>
</div>
<a-form class="process-form" :label-col="labelCol" :wrapper-col="wrapperCol">
<a-form-item v-if="isTask('Task_improve001')" label="初步评定奖项" required>
<JDictSelectTag v-model:value="initialAward" dictCode="proposal_prize" type="radio" />
<div class="form-help">金奖银奖进入部门评议小组打分铜奖参与奖直接进入部门负责人审核</div>
</a-form-item>
<template v-if="isTask('Task_improve002')">
<a-form-item label="我的评分">
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="评分状态">{{ scoreStatusLabel }}</a-descriptions-item>
<a-descriptions-item label="加权总分">{{ currentScore?.totalScore ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="评分时间" :span="2">{{ formatDateTime(currentScore?.scoreTime) }}</a-descriptions-item>
</a-descriptions>
</a-form-item>
<a-form-item label="评分操作">
<a-button type="primary" :disabled="scoreLocked" @click="openScoreModal">{{ scoreLocked ? '评分已锁定' : '打分' }}</a-button>
</a-form-item>
</template>
<template v-if="isTask('Task_improve003')">
<a-form-item label="初步评定奖项">{{ awardLabel(proposal.initialAward) }}</a-form-item>
<a-form-item v-if="proposal.needDeptScore === '1'" label="部门评议小组打分结果">
平均分{{ proposal.deptAvgScore ?? '-' }}建议奖项{{ awardLabel(proposal.suggestedAward) }}
</a-form-item>
<a-form-item label="负责人确认奖项" required>
<a-radio-group v-if="leaderAwardBase" v-model:value="leaderFinalAward">
<a-radio v-for="item in leaderAwardOptions" :key="item.value" :value="item.value">{{ item.label }}</a-radio>
</a-radio-group>
<div v-if="leaderAwardBase" class="form-help">评定基准{{ awardLabel(leaderAwardBase) }}仅可选择当前档位或降档奖项</div>
<div v-else class="form-help">部门评议建议奖项尚未生成暂不能确认负责人奖项</div>
</a-form-item>
</template>
<template v-if="isTask('Task_improve004')">
<a-form-item label="负责人确认奖项">{{ awardLabel(proposal.leaderFinalAward) }}</a-form-item>
<a-form-item label="所级评议人员" required>
<a-checkbox-group v-model:value="selectedInstituteVoterIds" class="vote-member-grid">
<a-checkbox v-for="member in instituteVoteCandidates" :key="member.id" :value="member.id" class="vote-member-option">
<span class="vote-member-name">{{ member.realname || member.username }}</span>
<span class="vote-member-username">{{ member.username }}</span>
</a-checkbox>
</a-checkbox-group>
<div v-if="!instituteVoteCandidates.length" class="form-help">未查询到所级评议小组人员</div>
<div v-else class="form-help">请选择本次所级评议投票人员提交办理后将生成对应投票待办</div>
</a-form-item>
</template>
<template v-if="isTask('Task_improve005')">
<a-form-item label="我的投票">
<a-space v-if="currentVote">
<a-button :type="currentVote.voteResult === 'agree' ? 'primary' : 'default'" :loading="savingVote" @click="confirmInstituteVote('agree')">投票通过</a-button>
<a-button danger :type="currentVote.voteResult === 'disagree' ? 'primary' : 'default'" :loading="savingVote" @click="confirmInstituteVote('disagree')">投票反对</a-button>
<span>{{ voteResultLabel(currentVote) }}</span>
</a-space>
<span v-else class="form-help">当前用户不在所级评议投票名单中</span>
</a-form-item>
</template>
<template v-if="isTask('Task_improve006')">
<a-form-item label="负责人确认奖项">{{ awardLabel(proposal.leaderFinalAward) }}</a-form-item>
<a-form-item v-if="proposal.isneedvote === '1'" label="所级评议结果">
同意票{{ proposal.instituteAgreeCount ?? '-' }}不同意票{{ proposal.instituteDisagreeCount ?? '-' }}结果{{ proposal.instituteVoteResult || '-' }}
</a-form-item>
<a-form-item label="最终奖项" required>
<a-radio-group v-model:value="finalAward">
<a-radio v-for="item in awardOptions" :key="item.value" :value="item.value">{{ item.label }}</a-radio>
</a-radio-group>
</a-form-item>
</template>
</a-form>
<!-- 所级投票以投票即办理完成避免通用同意/退回与投票结果产生歧义 -->
<TaskHandleInnerContent
v-if="!isTask('Task_improve005')"
:form-data="formData"
:claim="claim"
:before-handle="saveNodeAction"
@success="handleSuccess"
@claimSuccess="handleClaimSuccess"
/>
</template>
<a-modal v-model:open="scoreModalVisible" title="部门评议打分" :footer="null" :mask-closable="false" :body-style="{ padding: '24px 28px 20px' }">
<a-form layout="vertical">
<a-alert class="score-range-alert" type="info" show-icon message="所有评分项均须填写 0-100 分,最多可保留两位小数。" />
<a-row :gutter="12">
<a-col :span="24">
<a-form-item label="改善效果(权重 50%" required>
<a-row :gutter="12">
<a-col :xs="24" :sm="12" :lg="6">
<a-form-item label="安全" required><a-input-number v-model:value="scoreForm.safetyScore" :min="0" :max="100" :precision="2" :disabled="scoreLocked" style="width: 100%" /></a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-form-item label="质量" required><a-input-number v-model:value="scoreForm.qualityScore" :min="0" :max="100" :precision="2" :disabled="scoreLocked" style="width: 100%" /></a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-form-item label="效率" required><a-input-number v-model:value="scoreForm.efficiencyScore" :min="0" :max="100" :precision="2" :disabled="scoreLocked" style="width: 100%" /></a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-form-item label="成本" required><a-input-number v-model:value="scoreForm.costScore" :min="0" :max="100" :precision="2" :disabled="scoreLocked" style="width: 100%" /></a-form-item>
</a-col>
</a-row>
<div class="form-help">改善效果得分按安全质量效率成本四项的平均分自动计算{{ effectAveragePreview }}</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="可推广度(权重 20%" required>
<a-input-number v-model:value="scoreForm.promotionScore" :min="0" :max="100" :precision="2" :disabled="scoreLocked" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="独创性(权重 20%" required>
<a-input-number v-model:value="scoreForm.originalityScore" :min="0" :max="100" :precision="2" :disabled="scoreLocked" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="努力度(权重 10%" required>
<a-input-number v-model:value="scoreForm.effortScore" :min="0" :max="100" :precision="2" :disabled="scoreLocked" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="评分意见">
<a-textarea v-model:value="scoreForm.scoreOpinion" :rows="3" :disabled="scoreLocked" />
</a-form-item>
<a-space>
<a-button v-if="!scoreLocked" type="primary" :loading="savingScore" @click="saveScoreDraft">保存评分</a-button>
<span v-else>评分已锁定请重新提交流程办理</span>
</a-space>
</a-form>
</a-modal>
</section>
</a-spin>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { useUserStore } from '/@/store/modules/user';
import JDictSelectTag from '/@/components/Form/src/jeecg/components/JDictSelectTag.vue';
import SUploadFile from '/@/components/semri/fileComponent/SUploadFile.vue';
import TaskHandleInnerContent from '/@/views/super/bpm/process/personalOffice/myHandleTask/content/TaskHandleInnerContent.vue';
import {
queryDataById,
queryInstituteVoteCandidates,
queryImprovementInstituteVoteListByMainId,
queryMyDeptScore,
saveArchiveAward,
saveDeptScore,
saveInitialReview,
saveInstituteVoteMembers,
saveLeaderAward,
submitInstituteVote,
submitDeptScore,
} from '../ImprovementProposal.api';
const props = defineProps({
formData: { type: Object, default: () => ({}) },
formBpm: { type: Boolean, default: true },
claim: { type: Boolean, default: false },
});
const emit = defineEmits(['success', 'claimSuccess']);
const { createMessage, createConfirm } = useMessage();
const userStore = useUserStore();
const loading = ref(false);
const savingScore = ref(false);
const savingVote = ref(false);
const scoreModalVisible = ref(false);
const proposal = ref<Recordable>({});
const currentScore = ref<Recordable | null>(null);
const voteRows = ref<Recordable[]>([]);
const instituteVoteCandidates = ref<Recordable[]>([]);
const selectedInstituteVoterIds = ref<string[]>([]);
const initialAward = ref('');
const leaderFinalAward = ref('');
const finalAward = ref('');
const scoreForm = ref<Recordable>(createEmptyScoreForm());
const labelCol = { xs: { span: 24 }, sm: { span: 5 } };
const wrapperCol = { xs: { span: 24 }, sm: { span: 19 } };
const awardOptions = [
{ value: '0', label: '金奖' },
{ value: '1', label: '银奖' },
{ value: '2', label: '铜奖' },
{ value: '3', label: '参与奖' },
];
// 流程引擎在运行待办中提供 taskDefKey,节点功能只依赖该稳定标识而非表单 URL 参数。
const processInfo = computed(() => ({
mainId: props.formData?.processDataId || props.formData?.dataId,
taskId: props.formData?.taskId,
taskDefKey: props.formData?.taskDefKey,
processInstanceId:
props.formData?.procInsId ||
props.formData?.procInstId ||
props.formData?.processInstanceId ||
props.formData?.vars?.BPM_INST_ID ||
props.formData?.vars?.JG_LOCAL_PROCESS_ID ||
props.formData?.vars?.processInstanceId,
}));
const showProcessHandle = computed(() => props.formBpm && props.formData?.PROCESS_TAB_TYPE === 'run' && !!processInfo.value.taskId);
const currentUserId = computed(() => String(userStore.getUserInfo?.id || ''));
const scoreLocked = computed(() => currentScore.value?.scoreStatus === 'completed');
const scoreStatusLabel = computed(() => {
if (!currentScore.value) return '未保存';
return scoreLocked.value ? '评分已锁定' : '已保存,待流程提交';
});
const effectAveragePreview = computed(() => {
const values = ['safetyScore', 'qualityScore', 'efficiencyScore', 'costScore'].map((key) => scoreForm.value[key]);
if (values.some((value) => value === undefined || value === null)) return '-';
return (values.reduce((sum, value) => sum + Number(value), 0) / values.length).toFixed(2);
});
// 部门评分完成后必须以系统建议奖项为基准,不能回退到初审奖项。
const leaderAwardBase = computed(() => {
const award = proposal.value.needDeptScore === '1' ? proposal.value.suggestedAward : proposal.value.initialAward;
return awardOptions.some((item) => item.value === String(award)) ? String(award) : '';
});
const leaderAwardOptions = computed(() => {
if (!leaderAwardBase.value) return [];
return awardOptions.filter((item) => Number(item.value) >= Number(leaderAwardBase.value));
});
const currentVote = computed(() => voteRows.value.find((item) => String(item.voterId) === currentUserId.value));
watch(
() => [processInfo.value.mainId, processInfo.value.processInstanceId, processInfo.value.taskId, processInfo.value.taskDefKey],
() => loadProposal(),
{ immediate: true }
);
async function loadProposal() {
const mainId = processInfo.value.mainId;
if (!mainId) {
proposal.value = {};
currentScore.value = null;
voteRows.value = [];
instituteVoteCandidates.value = [];
selectedInstituteVoterIds.value = [];
return;
}
loading.value = true;
try {
const [main, votes, myScore] = await Promise.all([
queryDataById(mainId),
queryImprovementInstituteVoteListByMainId(mainId),
isTask('Task_improve002') && processInfo.value.processInstanceId && processInfo.value.taskId
? queryMyDeptScore(processInfo.value)
: Promise.resolve(null),
]);
proposal.value = main || {};
currentScore.value = myScore || null;
voteRows.value = votes || [];
initialAward.value = proposal.value.initialAward || '';
leaderFinalAward.value = proposal.value.leaderFinalAward || '';
finalAward.value = proposal.value.finalAward || '';
if (isTask('Task_improve004') && processInfo.value.processInstanceId && processInfo.value.taskId) {
instituteVoteCandidates.value = await queryInstituteVoteCandidates(processInfo.value);
const candidateIds = new Set(instituteVoteCandidates.value.map((item) => String(item.id)));
// 流程提交失败后重新打开 Task4 时,回显本次尚未流转的有效名单。
selectedInstituteVoterIds.value = voteRows.value
.map((item) => String(item.voterId))
.filter((voterId) => candidateIds.has(voterId));
} else {
instituteVoteCandidates.value = [];
selectedInstituteVoterIds.value = [];
}
} catch (error) {
createMessage.warning('提案数据加载失败');
} finally {
loading.value = false;
}
}
function isTask(taskDefKey: string) {
return processInfo.value.taskDefKey === taskDefKey;
}
function actionPayload(extra: Recordable = {}) {
return { ...processInfo.value, ...extra };
}
async function saveNodeAction() {
try {
if (isTask('Task_improve001')) {
if (!initialAward.value) {
createMessage.warning('请选择初步评定奖项');
return false;
}
await saveInitialReview(actionPayload({ initialAward: initialAward.value }));
}
if (isTask('Task_improve002')) {
// 弹窗仅保存草稿;通用流程办理前才锁定评分,确保评委在此之前仍可修改。
await submitDeptScore(actionPayload());
}
if (isTask('Task_improve003')) {
if (!leaderAwardBase.value) {
createMessage.warning('部门评议建议奖项尚未生成,不能确认负责人奖项');
return false;
}
if (!leaderFinalAward.value) {
createMessage.warning('请选择负责人确认奖项');
return false;
}
if (!leaderAwardOptions.value.some((item) => item.value === leaderFinalAward.value)) {
createMessage.warning('负责人确认奖项只能选择当前档位或降档奖项');
return false;
}
await saveLeaderAward(actionPayload({ leaderFinalAward: leaderFinalAward.value }));
}
if (isTask('Task_improve004')) {
if (!selectedInstituteVoterIds.value.length) {
createMessage.warning('请至少选择一名所级评议人员');
return false;
}
await saveInstituteVoteMembers(actionPayload({ voterIds: selectedInstituteVoterIds.value }));
}
if (isTask('Task_improve006')) {
if (!finalAward.value) {
createMessage.warning('请选择最终奖项');
return false;
}
await saveArchiveAward(actionPayload({ finalAward: finalAward.value }));
}
return true;
} catch (error: any) {
createMessage.warning(error?.message || '流程办理数据保存失败');
return false;
}
}
function confirmInstituteVote(voteResult: 'agree' | 'disagree') {
const voteLabel = voteResult === 'agree' ? '通过' : '反对';
createConfirm({
iconType: 'warning',
title: '确认投票',
content: `确认提交投票${voteLabel}吗?`,
okText: '确认提交',
cancelText: '取消',
onOk: () => submitInstituteVoteAndComplete(voteResult),
});
}
async function submitInstituteVoteAndComplete(voteResult: 'agree' | 'disagree') {
savingVote.value = true;
try {
// 后端在锁定投票后复用通用流程办理服务完成当前 Task5 任务。
await submitInstituteVote(actionPayload({ voteResult }));
createMessage.success('投票提交成功');
emit('success');
} catch (error: any) {
createMessage.warning(error?.message || '投票提交失败');
} finally {
savingVote.value = false;
}
}
function openScoreModal() {
const score = currentScore.value;
scoreForm.value = {
safetyScore: score?.safetyScore ?? undefined,
qualityScore: score?.qualityScore ?? undefined,
efficiencyScore: score?.efficiencyScore ?? undefined,
costScore: score?.costScore ?? undefined,
promotionScore: score?.promotionScore ?? undefined,
originalityScore: score?.originalityScore ?? undefined,
effortScore: score?.effortScore ?? undefined,
scoreOpinion: score?.scoreOpinion || '',
};
scoreModalVisible.value = true;
}
async function saveScoreDraft() {
if (!hasValidScoreValues()) {
createMessage.warning('请完整填写 0 到 100 分之间的七项评分');
return;
}
savingScore.value = true;
try {
await saveDeptScore(actionPayload(scoreForm.value));
createMessage.success('评分保存成功,可在流程提交前继续修改');
scoreModalVisible.value = false;
await loadProposal();
} catch (error: any) {
createMessage.warning(error?.message || '评分保存失败');
} finally {
savingScore.value = false;
}
}
function createEmptyScoreForm() {
return {
safetyScore: undefined,
qualityScore: undefined,
efficiencyScore: undefined,
costScore: undefined,
promotionScore: undefined,
originalityScore: undefined,
effortScore: undefined,
scoreOpinion: '',
};
}
function hasValidScoreValues() {
return ['safetyScore', 'qualityScore', 'efficiencyScore', 'costScore', 'promotionScore', 'originalityScore', 'effortScore'].every((key) => {
const value = scoreForm.value[key];
return value !== undefined && value !== null && Number(value) >= 0 && Number(value) <= 100;
});
}
function awardLabel(value: string) {
return awardOptions.find((item) => item.value === String(value))?.label || '-';
}
function voteResultLabel(vote: Recordable) {
if (vote.voteStatus !== 'voted') {
return '待投票';
}
return vote.voteResult === 'agree' ? '已投同意票' : '已投不同意票';
}
function formatDate(value: string) {
return value ? String(value).slice(0, 10) : '-';
}
function formatDateTime(value?: string) {
return value ? String(value) : '-';
}
function handleSuccess() {
emit('success');
}
function handleClaimSuccess() {
emit('claimSuccess');
}
</script>
<style lang="less" scoped>
.improvement-proposal-bpm-form {
padding: 8px 12px 16px;
:deep(.ant-descriptions-item-content) {
white-space: pre-wrap;
}
}
.section-header {
margin-bottom: 10px;
padding: 7px 10px;
border-left: 3px solid #1677ff;
background: #f6fbff;
color: #1f2937;
font-weight: 600;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.attachment-label {
margin-bottom: 6px;
color: #475569;
font-weight: 500;
}
.process-form {
margin-bottom: 12px;
}
.form-help {
margin-top: 6px;
color: #64748b;
font-size: 12px;
}
.score-range-alert {
margin-bottom: 16px;
}
.vote-member-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px 12px;
width: 100%;
}
.vote-member-option {
display: flex;
align-items: center;
min-height: 42px;
margin-inline-start: 0;
padding: 8px 10px;
border: 1px solid #d9e2ec;
border-radius: 4px;
background: #fff;
}
.vote-member-name {
margin-left: 6px;
color: #1f2937;
font-weight: 500;
}
.vote-member-username {
margin-left: 6px;
color: #64748b;
font-size: 12px;
}
@media (max-width: 768px) {
.attachment-grid {
grid-template-columns: 1fr;
}
}
</style>