先审后发
build / build (push) Successful in 5m1s

This commit is contained in:
chenxing
2026-09-15 22:09:27 +08:00
parent 1b39e0086d
commit c3ff58fd94
8 changed files with 355 additions and 15 deletions
+2
View File
@@ -6,6 +6,8 @@
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=
# 流程中心本地直连(详情页撤回/催办直调;生产同主域自动推导 /flow-center)
VITE_FLOW_HOST=http://localhost:8085
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
+2 -1
View File
@@ -78,7 +78,8 @@ onMounted(async () => {
<layout v-if="isLogin" :layoutData="layoutData" />
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
<div class="main-wrapper">
<router-view />
<!-- key=fullPath:详情页仅 query 变化(id/serid)时强制重渲染,避免显示上一条数据 -->
<router-view :key="$route.fullPath" />
</div>
</div>
</template>
+16
View File
@@ -15,6 +15,8 @@ import type {
NoticeRequest,
NoticeQuery,
DictOption,
FlowOption,
FlowTraceVo,
DeptManageItem,
ContactGroupPage,
ContactUserPage,
@@ -107,6 +109,20 @@ class BaseApi {
}
class NoticeApi {
// ========== 先审后发(流程中心托管) ==========
/** 审核流程下拉选项(流程中心未接入时返回空数组,页面隐藏选择器) */
getFlowOptions(schId?: number) {
return get<FlowOption[]>(`${NOTICE}/flow/Options`, schId ? { schId } : undefined);
}
/** 提交/重新提交审核(幂等:bizId=notice.id;审批中会被拒绝) */
trySubmitFlow(serid: number) {
return postForm<void>(`${NOTICE}/flow/TrySubmit`, { serid });
}
/** 审批进度(详情页进度卡片;未绑流程返回 bound=false) */
flowTrace(serid: number) {
return get<FlowTraceVo>(`${NOTICE}/flow/Trace`, { serid });
}
// ========== 收件箱 ==========
/** 已收通知列表 */
list(params: NoticeQuery) {
+4
View File
@@ -23,6 +23,9 @@ let conf: BaseConf = {
baseHost: origin,
ssoHost: "",
serviceHost: seg.length >= 2 ? `${origin}/${seg[0]}` : "",
// 流程中心基址(Notice 详情页撤回/催办直调 /api/flow/*):生产同主域 Caddy 路由 /flow-center/;
// 本地 dev 由 VITE_FLOW_HOST 指向 http://localhost:8085(flow-center CORS 已收 8077)
flowHost: seg.length >= 2 ? `${origin}/flow-center` : "",
relayConf: {
ziyuankuHost: "",
zhuanxiangHost: "",
@@ -46,6 +49,7 @@ applyConf(conf, {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_IS_MOCK === "true" ? "" : env.VITE_SERVICE_HOST,
flowHost: env.VITE_FLOW_HOST,
relayConf: {
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
+45
View File
@@ -68,6 +68,44 @@ export interface NoticeVo {
createTime: string | null;
updateBy: string | null;
updateTime: string | null;
/** 绑定的审核流程 GUID(null=直发未接流程中心) */
flowGuid?: string | null;
/** 审核状态:0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回(null=未绑流程) */
auditStatus?: number | null;
/** 审核状态文案 */
auditStatusDesc?: string | null;
/** 流程中心办件 id */
flowInstanceId?: number | null;
}
// ============================ 先审后发(流程中心) ============================
/** 审核流程下拉选项(对齐 FlowModels.FlowSummaryVo) */
export interface FlowOption {
flowGuid: string;
name: string;
version: number;
nodeSummary: string | null;
}
/** 审批进度(GET /api/notice/flow/Trace) */
export interface FlowTraceVo {
bound: boolean;
flowGuid: string | null;
flowInstanceId: number | null;
auditStatus: number | null;
auditStatusDesc: string | null;
status: number | null;
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
instanceStatus?: number | null;
version?: number | null;
nodeSummary?: string | null;
currentNodes?: string | null;
assigneeIds?: number[] | null;
submitTime?: string | null;
finishTime?: string | null;
/** 办理轨迹(traceJson 解析后的数组) */
trace?: any[] | null;
}
// ============================ 通知详情 ============================
@@ -76,6 +114,11 @@ export interface NoticeVo {
export interface NoticeDetailVo {
serid: number;
title: string;
flowGuid?: string | null;
auditStatus?: number | null;
auditStatusDesc?: string | null;
flowInstanceId?: number | null;
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
/** 通知内容(旧字段名 Contents) */
Contents: string;
/** 发布部门ID(旧字段名 DeptID) */
@@ -194,6 +237,8 @@ export interface NoticeRequest {
smsFlag?: number;
/** 转发源通知ID */
sourceId?: number;
/** 审核流程 GUID(非空=提交审核;空=直发) */
flowGuid?: string;
}
/** 通知列表查询参数(对齐 NoticeQuery.java + BasePageQuery.java) */
+100 -7
View File
@@ -2,11 +2,11 @@
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRequest } from "alova/client";
import { Message } from "view-ui-plus";
import { Message, Modal } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { noticeApi } from "@/api/module/notice";
import type { NoticeDetailVo, NoticeRequest, UpFileVo } from "@/types/notice";
import type { NoticeDetailVo, NoticeRequest, UpFileVo, FlowOption } from "@/types/notice";
import UserCheckInput from "@/components/UserCheckInput.vue";
import NoticeUpload from "@/components/NoticeUpload.vue";
import config from "@/assets/config/host.config";
@@ -26,6 +26,13 @@ const form = reactive({
content: "",
isShortMsg: false,
});
/* ============ 先审后发(审核流程绑定) ============ */
const flowOptions = ref<FlowOption[]>([]);
const flowGuid = ref(""); // 空=直发(原链路)
const auditStatus = ref<number | null>(null); // 编辑回显的审核态
const auditResult = ref<{ result?: string; opinion?: string; rejectNode?: string } | null>(null);
const flowEnabled = computed(() => flowOptions.value.length > 0); // 流程中心不可用→隐藏选择器走直发
const fileids = ref<number[]>([]);
const editFileList = ref<{ name: string; fileid: number }[]>([]);
const isSms = ref(false);
@@ -52,6 +59,20 @@ onMounted(async () => {
}
});
/* 审核流程下拉(流程中心不可用/未配置 → 空数组,选择器隐藏、提交按钮走"发送") */
onMounted(async () => {
try {
flowOptions.value = (await noticeApi.getFlowOptions().send()) || [];
} catch {
flowOptions.value = [];
}
});
/** 流程图:flow-center 示意图深链(Notice 指定到人场景用人员视图) */
function openFlowDiagram() {
window.open(`${config.flowHost}/#/diagram?flowGuid=${flowGuid.value}&mode=user`, "_blank");
}
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
const editorRef = shallowRef();
const editorLoading = ref(true);
@@ -134,6 +155,10 @@ onMounted(async () => {
form.dept = detail.DeptID || "";
form.rdoname = detail.tlist === "all" || !Array.isArray(detail.tlist) ? "all" : detail.tlist.join(",");
if (detail.DsfsTime) timeForm.date1 = detail.DsfsTime;
// 审核绑定回显(驳回/撤回态显示意见与重提按钮)
flowGuid.value = detail.flowGuid || "";
auditStatus.value = detail.auditStatus ?? null;
auditResult.value = detail.flowResult || null;
} else {
form.title = detail.title || "";
}
@@ -177,6 +202,7 @@ async function doAdd(_type?: string) {
deptId: form.dept,
fileIds: fileids.value.join(","), //附件
smsFlag: form.isShortMsg ? 1 : 0, //是否发送短信
flowGuid: flowGuid.value || undefined, //绑流程→提交审核;空→直发
};
if (isTime) {
//定时发送
@@ -211,14 +237,23 @@ async function doAdd(_type?: string) {
try {
if (!isEdit.value) {
await sendCreate(data);
Message.success("发送成功");
Message.success(flowGuid.value ? "已提交审核,审核通过后自动发布" : "发送成功");
router.push("/list");
} else {
data.sourceId = serid.value;
if (data.title.indexOf("[修订稿]") == -1) data.title = "[修订稿]" + data.title;
await sendEdit(data);
Message.success("保存成功");
router.push("/list");
// §3.6:已发布(audit=20)编辑告知——修改后重新走审批,期间对用户不可见
if (flowGuid.value && auditStatus.value === 20) {
Modal.confirm({
title: "重新送审确认",
content: "该通知已发布,修改后将重新走审批流程,期间对用户不可见,确认继续?",
onOk: async () => {
await saveEditAndLeave(data);
},
});
return;
}
await saveEditAndLeave(data);
}
} catch (e: any) {
Message.error(e.message || "保存失败");
@@ -226,6 +261,28 @@ async function doAdd(_type?: string) {
isLoading.value = false;
}
}
/* 编辑保存 + 离开(§3.6 抽出:audit=20 先 confirm 再走这里) */
async function saveEditAndLeave(data: NoticeRequest) {
await sendEdit(data);
// 编辑驳回/撤回态:后端 edit 内已自动重提(复用同办件 R4);待提交态由重提按钮处理
Message.success(flowGuid.value ? "已保存并提交审核" : "保存成功");
router.push("/list");
}
/* 单独重提审核(待提交/驳回/撤回态;审批中按钮隐藏) */
async function doResubmit() {
isLoading.value = true;
try {
await noticeApi.trySubmitFlow(serid.value).send();
Message.success("已提交审核");
router.push("/list");
} catch (e: any) {
Message.error(e.message || "提交审核失败");
} finally {
isLoading.value = false;
}
}
</script>
<template>
@@ -247,8 +304,26 @@ async function doAdd(_type?: string) {
<FormItem label="添加附件:">
<NoticeUpload v-model:fileids="fileids" :defaultList="editFileList"></NoticeUpload>
</FormItem>
<FormItem v-if="flowEnabled" label="审核流程:">
<div class="flow-select">
<Select v-model="flowGuid" clearable placeholder="不审核,直接发布">
<Option value="">不审核,直接发布</Option>
<Option v-for="f in flowOptions" :key="f.flowGuid" :value="f.flowGuid">
{{ f.name }}({{ f.nodeSummary || "—" }})
</Option>
</Select>
<Button v-if="flowGuid" size="small" @click="openFlowDiagram">流程图</Button>
</div>
</FormItem>
</Form>
<!-- 驳回/撤回意见提示(编辑驳回/撤回态) -->
<Alert v-if="isEdit && (auditStatus === 30 || auditStatus === 40)" type="warning" show-icon class="audit-alert">
审核{{ auditStatus === 30 ? "已驳回" : "已撤回" }}
<template v-if="auditResult?.opinion"> 办理意见:{{ auditResult.opinion }}<template v-if="auditResult?.rejectNode">({{ auditResult.rejectNode }})</template></template>
 修改保存后将自动重新提交审核
</Alert>
<!-- 富文本编辑器 -->
<div class="editor-wrap">
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
@@ -262,7 +337,17 @@ async function doAdd(_type?: string) {
</div>
<div class="bottom-btn">
<Button type="primary" :loading="isLoading" @click="doAdd()">{{ isEdit ? "保存" : "发送" }}</Button>
<Button type="primary" :loading="isLoading" @click="doAdd()">
{{ isEdit ? (flowGuid ? "保存并重新提交审核" : "保存") : flowGuid ? "提交审核" : "发送" }}
</Button>
<!-- 待提交态单独重提(提交流程中心失败后的幂等重推入口) -->
<Button
v-if="isEdit && flowGuid && auditStatus === 0"
style="margin-left: 10px"
:loading="isLoading"
@click="doResubmit"
>重新提交审核</Button
>
<Button v-if="!isEdit" style="margin-left: 10px" @click="isModal.timiSend = true">定时发送</Button>
</div>
@@ -302,6 +387,14 @@ async function doAdd(_type?: string) {
</template>
<style lang="scss" scoped>
.audit-alert {
margin-bottom: 12px;
}
.flow-select {
display: flex;
gap: 8px;
align-items: center;
}
/* 页面超高时的滚动由 App.vue 的 .main-wrapper 统一承接 */
.myform {
:deep(.ivu-form-item) {
+167 -3
View File
@@ -3,9 +3,10 @@ import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { noticeApi } from "@/api/module/notice";
import type { NoticeDetailVo, NextPrevVo, ReadGroup } from "@/types/notice";
import type { NoticeDetailVo, NextPrevVo, ReadGroup, FlowTraceVo } from "@/types/notice";
import FilePreviewModal from "@/components/FilePreviewModal.vue";
import Utils from "@/utils";
import config from "@/assets/config/host.config";
const route = useRoute();
const router = useRouter();
@@ -21,6 +22,50 @@ const navType = computed(() => (viewType.value === 0 ? "0" : "1"));
const detail = ref<NoticeDetailVo | null>(null);
const loading = ref(false);
/* ============ 先审后发:审批进度卡片 + 撤回/催办 ============ */
const flowTrace = ref<FlowTraceVo | null>(null);
const flowActing = ref(false);
async function loadTrace() {
if (!detail.value?.flowGuid) return;
try {
flowTrace.value = await noticeApi.flowTrace(serid.value).send();
} catch {
flowTrace.value = null; // 进度不可用时只展示本地镜像
}
}
/** 撤回/催办直调 flow-center 用户面(同主域 CAS 免二次登录;本地 dev 走 flowHost CORS) */
async function flowAction(action: "withdraw" | "urge") {
const instanceId = detail.value?.flowInstanceId;
if (!instanceId || !config.flowHost) return;
flowActing.value = true;
try {
const resp = await fetch(`${config.flowHost}/api/flow/instance/${instanceId}/${action}`, {
method: "POST",
credentials: "include",
});
const res = await resp.json().catch(() => null);
if (resp.ok && res && res.code === 0) {
Message.success(action === "withdraw" ? "已撤回审核,可编辑后重新提交" : "已发送催办提醒");
load(); // 撤回后状态刷新
loadTrace();
} else {
Message.error(res?.msg || (action === "withdraw" ? "撤回失败(已有人办理时不可撤回)" : "催办失败"));
}
} catch (e: any) {
Message.error("流程中心不可达:" + (e.message || e));
} finally {
flowActing.value = false;
}
}
/** 查看流程图:flow-center 示意图深链(绑流程时;Notice 指定到人场景用人员视图) */
function openFlowDiagram() {
if (!detail.value?.flowGuid || !config.flowHost) return;
window.open(`${config.flowHost}/#/diagram?flowGuid=${detail.value.flowGuid}&mode=user`, "_blank");
}
const readGroups = ref<ReadGroup[]>([]);
const showRead = ref(false);
const prevId = ref(0);
@@ -28,6 +73,7 @@ const nextId = ref(0);
onMounted(async () => {
await load();
loadTrace(); // 详情加载后并行取审批进度(未绑流程接口自身返回 bound=false)
});
async function load() {
@@ -38,6 +84,7 @@ async function load() {
const nav: NextPrevVo = await noticeApi.getNextPrevNotice(serid.value, navType.value).send();
prevId.value = nav.prevId || 0;
nextId.value = nav.nextId || 0;
loadTrace();
} catch (e: any) {
Message.error(e.message || "加载通知失败");
} finally {
@@ -97,6 +144,15 @@ function onPrint() {
win.print();
}
/** 审核状态 Tag 颜色 */
function auditColor(auditStatus?: number | null) {
if (auditStatus === 20) return "green";
if (auditStatus === 30) return "red";
if (auditStatus === 40) return "orange";
if (auditStatus === 0) return "default";
return "blue"; // 审批中
}
function downFile(id: number) {
if (!id) return;
window.location.href = noticeApi.fileDownUrl(id);
@@ -176,6 +232,49 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
<!-- 查看视角:普通标题 -->
<h2 v-else class="notice-title2">{{ detail.title }}</h2>
<!-- 审批进度卡片(绑流程通知;提交人/管理员/办理人可见) -->
<div v-if="flowTrace && flowTrace.bound" class="flow-card">
<div class="flow-card-head">
<span class="flow-title">审批进度</span>
<Tag :color="auditColor(flowTrace.auditStatus)">{{ flowTrace.auditStatusDesc || "—" }}</Tag>
<span v-if="flowTrace.nodeSummary" class="flow-nodes">流程:{{ flowTrace.nodeSummary }}</span>
<span v-if="flowTrace.currentNodes" class="flow-nodes">当前节点:{{ flowTrace.currentNodes }}</span>
<div class="flow-ops">
<Button v-if="detail.flowGuid" size="small" @click="openFlowDiagram">查看流程图</Button>
<Button
v-if="detail.flowInstanceId && flowTrace.auditStatus === 10"
size="small"
:loading="flowActing"
style="margin-left: 8px"
@click="flowAction('withdraw')"
>撤回审核</Button
>
<Button
v-if="detail.flowInstanceId && flowTrace.auditStatus === 10"
size="small"
:loading="flowActing"
style="margin-left: 8px"
@click="flowAction('urge')"
>催办</Button
>
</div>
</div>
<!-- 驳回/撤回意见 -->
<div v-if="flowTrace.flowResult && flowTrace.flowResult.opinion" class="flow-opinion">
办理意见:{{ flowTrace.flowResult.opinion }}
<template v-if="flowTrace.flowResult.rejectNode">({{ flowTrace.flowResult.rejectNode }})</template>
</div>
<!-- 节点时间线(traceJson 解析) -->
<div v-if="flowTrace.trace && flowTrace.trace.length" class="flow-timeline">
<div v-for="(t, i) in flowTrace.trace" :key="i" class="flow-step">
<span class="flow-step-node">{{ t.nodeName || t.node || t.taskName || `节点${i + 1}` }}</span>
<span class="flow-step-user">{{ t.userName || t.assigneeName || t.handlerName || "" }}</span>
<span class="flow-step-time">{{ t.handleTime || t.time || t.createTime || "" }}</span>
<span class="flow-step-op" v-if="t.opinion">{{ t.opinion }}</span>
</div>
</div>
</div>
<div v-if="viewType !== 0" class="meta">
<span>发文单位:{{ detail.DeptName || "—" }}</span>
<span>发布人:{{ detail.TeaName || "—" }}</span>
@@ -196,9 +295,12 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
</div>
</div>
<!-- 管理视角底部操作(同旧版:编辑 / 打印) -->
<!-- 管理视角底部操作(同旧版:编辑 / 打印;审批中隐藏编辑——后端 403 死按钮,BP-R5-1) -->
<div v-if="viewType === 0" class="bottom-btns">
<Button @click="onEdit">编辑</Button>
<Button v-if="detail.auditStatus !== 10" @click="onEdit">编辑</Button>
<Tooltip v-else content="审批中,请先撤回再编辑" placement="top" transfer>
<Button disabled>编辑</Button>
</Tooltip>
<Button style="margin-left: 10px" @click="onPrint">打印</Button>
</div>
@@ -246,6 +348,68 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
font-size: 20px;
margin: 0 0 12px;
}
.flow-card {
border: 1px solid #e8eaec;
border-radius: 4px;
padding: 10px 14px;
margin-bottom: 14px;
background: #f8f8f9;
}
.flow-card-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
}
.flow-title {
font-weight: 600;
color: #17233d;
}
.flow-nodes {
color: #808695;
}
.flow-ops {
margin-left: auto;
}
.flow-opinion {
margin-top: 8px;
padding: 6px 10px;
background: #fff;
border-radius: 4px;
color: #ed4014;
font-size: 13px;
}
.flow-timeline {
margin-top: 8px;
background: #fff;
border-radius: 4px;
padding: 6px 10px;
}
.flow-step {
display: flex;
gap: 12px;
font-size: 13px;
color: #515a6e;
padding: 3px 0;
border-bottom: 1px dashed #f0f0f0;
}
.flow-step:last-child {
border-bottom: none;
}
.flow-step-node {
min-width: 120px;
}
.flow-step-user {
min-width: 90px;
color: #2d8cf0;
}
.flow-step-time {
color: #999;
}
.flow-step-op {
color: #ed4014;
}
.meta {
display: flex;
flex-wrap: wrap;
+19 -4
View File
@@ -151,6 +151,17 @@ const columns = [
align: "left",
render: (h: any, p: any) => h("span", {}, String(p.row.createTime || "").slice(0, 16)),
},
{
title: "审核状态",
width: 100,
align: "center",
render: (h: any, p: any) => {
const st = p.row.auditStatus;
if (st === null || st === undefined) return h("span", { style: "color:#999;" }, "—");
const colors: Record<number, string> = { 0: "default", 10: "blue", 20: "green", 30: "red", 40: "orange" };
return h("Tag", { color: colors[st] || "default" }, () => p.row.auditStatusDesc || "—");
},
},
{ title: "已读人数", key: "readCount", width: 110, align: "left" },
{
title: "未读人数",
@@ -172,10 +183,14 @@ const columns = [
width: 180,
align: "center",
fixed: "right",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row)),
Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row)),
],
render: (h: any, p: any) => {
// BP-R5-1:审批中(audit=10)禁止删除(后端 403)——不再渲染死按钮,审批通过/驳回/撤回后恢复
const ops = [Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row))];
if (p.row.auditStatus !== 10) {
ops.push(Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row)));
}
return ops;
},
},
];