yxk-20260727

改善提案
重构 ImprovementProposalBPMForm.vue 为“基本信息”和“流程办理”两个区域。节点识别统一使用 formData.taskDefKey,不通过 PC 表单 URL 参数判断节点;URL 参数仅保留给将来固定开关类需求。
奖项字段统一按 金奖=0、银奖=1、铜奖=2、参与奖=3 存储,页面展示中文。need_dept_score、isneedvote 均使用字符串 1/0。
Key Changes
基本信息区域在所有流程节点统一展示,使用只读组件展示提案申请字段、改善前后说明及三个附件分组;不复用生成式编辑表单,也不允许流程办理人修改申请内容。

流程办理区域仅在运行中的待办显示:formBpm=true、PROCESS_TAB_TYPE='run' 且存在 taskId。提取统一 processInfo,包含 taskId、taskDefKey、processInstanceId、mainId。

继续使用 TaskHandleInnerContent 的 beforeHandle 钩子;按当前 taskDefKey 完成节点专属数据校验、保存和流程变量写入后,再调用通用提交办理。

Task_improve001:
提供初步评定奖项 initial_award 必填选择。
金奖、银奖自动保存 need_dept_score='1';铜奖、参与奖保存 '0'。
后端在同一节点保存接口中更新主表,并通过 Flowable runtimeService.setVariable 写入全局流程变量 need_dept_score。
BPMN 排他网关配置:${need_dept_score == '1'} 流向 Task_improve002,${need_dept_score == '0'} 流向 Task_improve003。

Task_improve002:
展示部门评分记录及“打分”入口,入口按当前登录评委定位其 improvement_dept_score 记录。
本轮仅建立评分弹窗/页面入口和子表查询展示;四维度录入、加权计算、全部评委完成校验、平均分和建议奖项计算留待下一轮实现。

Task_improve003:
只读展示初步奖项;存在评分结果时展示 dept_avg_score、suggested_award。
部门负责人必选 leader_final_award;金奖写入 isneedvote='1',其他奖项写入 '0',并同步为全局流程变量。
BPMN 排他网关配置:${isneedvote == '1'} 流向 Task_improve004,${isneedvote == '0'} 流向 Task_improve006。

Task_improve004:
本轮预留“所级评议人员”办理区域和投票名单展示,不开发专用选人组件及投票记录初始化。
后续选人功能负责创建每位评委一条 improvement_institute_vote 待投票记录,并设置 institute_total_voters;流程需同时配置所级投票任务的多实例人员集合。

Task_improve005:
提供“同意”“不同意”操作,按当前登录用户更新其已有的待投票记录,写入 vote_result、vote_status='voted'、vote_time 与当前 process_task_id。
不信任前端传入的投票人 ID;后端以登录用户定位记录,并禁止非名单人员、重复投票或已完成记录再次修改。
提交办理前校验当前评委已投票。投票统计和三分之二结果计算留待选人及多实例规则完成后统一实现。

Task_improve006:
展示部门负责人确认奖项;如经过所级投票,同时展示投票统计和投票结果。
经办人必须选择 final_award。若金奖所级投票未通过,同时将 manual_confirm_result 固定保存为 confirmed,表示已由经办人手动确认最终奖项。

后端新增流程节点专用 DTO 与接口,分别处理初审奖项、负责人奖项、归档最终奖项和个人投票;接口校验主表、流程实例、活动任务定义键及当前办理人,避免通过普通 /edit 篡改流程数据。

不使用生成代码的 updateMain 保存流程数据,避免删除并重建评分、投票子表,也避免草稿状态校验阻断流程办理。

补齐数据库定义:在 improvement_proposal 增加 isneedvote char(1),同步建表 SQL、数据库 Markdown 与实体注释;将 need_dept_score 的说明统一改为 1/0。为已建库环境提供单独的增量 ALTER TABLE 脚本。
This commit is contained in:
ye1023
2026-07-27 15:06:40 +08:00
parent 31d897e2a5
commit 37a23f156a
2 changed files with 340 additions and 53 deletions
@@ -15,6 +15,10 @@ enum Api {
improvementDeptScoreList = '/improvementproposal/improvementProposal/queryImprovementDeptScoreByMainId',
improvementInstituteVoteList = '/improvementproposal/improvementProposal/queryImprovementInstituteVoteByMainId',
startProcess = '/improvementproposal/improvementProposal/startProcess',
bpmInitialReview = '/improvementproposal/improvementProposal/bpm/initialReview',
bpmLeaderAward = '/improvementproposal/improvementProposal/bpm/leaderAward',
bpmArchiveAward = '/improvementproposal/improvementProposal/bpm/archiveAward',
bpmInstituteVote = '/improvementproposal/improvementProposal/bpm/instituteVote',
}
/**
* 导出api
@@ -88,3 +92,9 @@ export const queryDataById = (id) => defHttp.get({url: Api.queryDataById, params
/** 发起改善提案审批流程。 */
export const startProposalProcess = (id: string) => defHttp.post({ url: Api.startProcess, params: { id } });
/** 流程节点专用接口会在任务提交前保存业务字段和网关变量。 */
export const saveInitialReview = (params) => defHttp.post({ url: Api.bpmInitialReview, params });
export const saveLeaderAward = (params) => defHttp.post({ url: Api.bpmLeaderAward, params });
export const saveArchiveAward = (params) => defHttp.post({ url: Api.bpmArchiveAward, params });
export const castInstituteVote = (params) => defHttp.post({ url: Api.bpmInstituteVote, params });
@@ -1,76 +1,316 @@
<template>
<a-spin :spinning="loading">
<section class="improvement-proposal-bpm-form">
<a-descriptions title="改善提案" bordered size="small" :column="2">
<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="提出日期">{{ proposal.proposalDate || '-' }}</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="团队名称">{{ proposal.teamName || '-' }}</a-descriptions-item>
<a-descriptions-item label="最终奖项">{{ proposal.finalAward || '-' }}</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.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>
<div class="attachment-label">其他附件</div>
<SUploadFile :bussiness-id="`${proposal.id}:other`" :disabled="true" :multiple="true" />
</div>
</div>
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
<a-divider />
<TaskHandleInnerContent
:formData="formData"
:claim="claim"
@success="handleSuccess"
@claimSuccess="handleClaimSuccess"
/>
<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>
<a-radio-group v-model:value="initialAward">
<a-radio v-for="item in awardOptions" :key="item.value" :value="item.value">{{ item.label }}</a-radio>
</a-radio-group>
<div class="form-help">金奖银奖进入部门评议小组打分铜奖参与奖直接进入部门负责人审核</div>
</a-form-item>
<template v-if="isTask('Task_improve002')">
<a-form-item label="部门评分记录">
<a-table row-key="id" size="small" bordered :columns="scoreColumns" :data-source="scoreRows" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">{{ record.scoreStatus === 'completed' ? '已评分' : '待评分' }}</template>
</template>
</a-table>
</a-form-item>
<a-form-item label="评分操作">
<a-button type="primary" :disabled="!currentScore" @click="openScoreModal">打分</a-button>
<span v-if="!currentScore" class="form-help">当前用户不在部门评分名单中</span>
</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-model:value="leaderFinalAward">
<a-radio v-for="item in awardOptions" :key="item.value" :value="item.value">{{ item.label }}</a-radio>
</a-radio-group>
</a-form-item>
</template>
<template v-if="isTask('Task_improve004')">
<a-form-item label="所级评议人员">
<a-table row-key="id" size="small" bordered :columns="voteMemberColumns" :data-source="voteRows" :pagination="false" />
<div 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="primary" :disabled="currentVote.voteStatus === 'voted'" :loading="savingVote" @click="castVote('agree')">同意</a-button>
<a-button danger :disabled="currentVote.voteStatus === 'voted'" :loading="savingVote" @click="castVote('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
: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">
<a-descriptions v-if="currentScore" bordered size="small" :column="1">
<a-descriptions-item label="评委">{{ currentScore.scorerName || '-' }}</a-descriptions-item>
<a-descriptions-item label="评分状态">{{ currentScore.scoreStatus === 'completed' ? '已评分' : '待评分' }}</a-descriptions-item>
</a-descriptions>
<div class="form-help">四个评分维度录入加权计算和评分提交将在下一阶段开发</div>
</a-modal>
</section>
</a-spin>
</template>
<script lang="ts">
import { computed, defineComponent, ref, watch } from 'vue';
import { queryDataById } from '../ImprovementProposal.api';
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { useUserStore } from '/@/store/modules/user';
import SUploadFile from '/@/components/semri/fileComponent/SUploadFile.vue';
import TaskHandleInnerContent from '/@/views/super/bpm/process/personalOffice/myHandleTask/content/TaskHandleInnerContent.vue';
import {
castInstituteVote,
queryDataById,
queryImprovementDeptScoreListByMainId,
queryImprovementInstituteVoteListByMainId,
saveArchiveAward,
saveInitialReview,
saveLeaderAward,
} from '../ImprovementProposal.api';
export default defineComponent({
name: 'ImprovementProposalBPMForm',
components: { TaskHandleInnerContent },
props: {
formData: { type: Object, default: () => ({}) },
claim: { type: Boolean, default: false },
},
emits: ['success', 'claimSuccess'],
setup(props, { emit }) {
const loading = ref(false);
const proposal = ref<Recordable>({});
// processDataId 是引擎保存的原始业务键;兼容普通待办没有该字段时回退 dataId。
const proposalId = computed(() => props.formData?.processDataId || props.formData?.dataId);
async function loadProposal() {
if (!proposalId.value) {
proposal.value = {};
return;
}
loading.value = true;
try {
proposal.value = await queryDataById(proposalId.value);
} finally {
loading.value = false;
}
}
watch(proposalId, loadProposal, { immediate: true });
return {
loading,
proposal,
handleSuccess: () => emit('success'),
handleClaimSuccess: () => emit('claimSuccess'),
};
},
const props = defineProps({
formData: { type: Object, default: () => ({}) },
formBpm: { type: Boolean, default: true },
claim: { type: Boolean, default: false },
});
const emit = defineEmits(['success', 'claimSuccess']);
const { createMessage } = useMessage();
const userStore = useUserStore();
const loading = ref(false);
const savingVote = ref(false);
const scoreModalVisible = ref(false);
const proposal = ref<Recordable>({});
const scoreRows = ref<Recordable[]>([]);
const voteRows = ref<Recordable[]>([]);
const initialAward = ref('');
const leaderFinalAward = ref('');
const finalAward = ref('');
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: '参与奖' },
];
const scoreColumns = [
{ title: '评委', dataIndex: 'scorerName', width: 180 },
{ title: '状态', key: 'status', width: 120 },
{ title: '总分', dataIndex: 'totalScore', width: 120 },
];
const voteMemberColumns = [
{ title: '评委', dataIndex: 'voterName', width: 180 },
{ title: '状态', dataIndex: 'voteStatus', width: 120 },
];
// 流程引擎在运行待办中提供 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 currentScore = computed(() => scoreRows.value.find((item) => String(item.scorerId) === currentUserId.value));
const currentVote = computed(() => voteRows.value.find((item) => String(item.voterId) === currentUserId.value));
watch(
() => [processInfo.value.mainId, processInfo.value.processInstanceId],
() => loadProposal(),
{ immediate: true }
);
async function loadProposal() {
const mainId = processInfo.value.mainId;
if (!mainId) {
proposal.value = {};
scoreRows.value = [];
voteRows.value = [];
return;
}
loading.value = true;
try {
const [main, scores, votes] = await Promise.all([
queryDataById(mainId),
queryImprovementDeptScoreListByMainId(mainId),
queryImprovementInstituteVoteListByMainId(mainId),
]);
proposal.value = main || {};
scoreRows.value = scores || [];
voteRows.value = votes || [];
initialAward.value = proposal.value.initialAward || '';
leaderFinalAward.value = proposal.value.leaderFinalAward || '';
finalAward.value = proposal.value.finalAward || '';
} 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_improve003')) {
if (!leaderFinalAward.value) {
createMessage.warning('请选择负责人确认奖项');
return false;
}
await saveLeaderAward(actionPayload({ leaderFinalAward: leaderFinalAward.value }));
}
if (isTask('Task_improve005') && currentVote.value?.voteStatus !== 'voted') {
createMessage.warning('请先完成投票');
return false;
}
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;
}
}
async function castVote(voteResult: string) {
savingVote.value = true;
try {
await castInstituteVote(actionPayload({ voteResult }));
createMessage.success('投票成功');
await loadProposal();
} catch (error: any) {
createMessage.warning(error?.message || '投票失败');
} finally {
savingVote.value = false;
}
}
function openScoreModal() {
// 评分字段、权重计算和完成条件将由下一阶段单独实现,避免当前占位入口误写评分结果。
scoreModalVisible.value = true;
}
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 handleSuccess() {
emit('success');
}
function handleClaimSuccess() {
emit('claimSuccess');
}
</script>
<style lang="less" scoped>
@@ -81,4 +321,41 @@
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(3, 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;
}
@media (max-width: 768px) {
.attachment-grid {
grid-template-columns: 1fr;
}
}
</style>