[v2.1] A 去壳任务办理:App 增 bare 任务形态(/task/*?embed=1 保 CAS 去布局),TaskHandle 办理成功/查看业务/高度变化 postMessage 回父页,bare 不跳工作台
build / build (push) Successful in 2m30s
build / build (push) Successful in 2m30s
This commit is contained in:
+21
-10
@@ -5,22 +5,29 @@ import { useRouter } from "vue-router";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { baseApi } from "@/api/module/base";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { isBareTaskRoute } from "@/utils/taskEmbed";
|
||||
import { Message } from "view-ui-plus";
|
||||
|
||||
const router = useRouter();
|
||||
const isLogin = ref(false);
|
||||
// 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话,
|
||||
// 也不该出现菜单壳。判定用路由前缀而非 query 标记——刷新/直链/分享均稳定
|
||||
const isEmbed = computed(() => router.currentRoute.value.path.startsWith("/embed/"));
|
||||
const isTokenEmbed = computed(() => router.currentRoute.value.path.startsWith("/embed/"));
|
||||
// 去壳任务页(/task/{taskId}?embed=1,v2.1 完整办理内嵌):只去菜单壳,CAS 一律保留——
|
||||
// 办理是写操作,旁路鉴权等于把审批动作挂在免签 URL 上。与 token 页共享"无壳"渲染,
|
||||
// 但鉴权分支只走 isTokenEmbed
|
||||
const isBareTask = computed(() =>
|
||||
isBareTaskRoute(router.currentRoute.value.path, router.currentRoute.value.query.embed));
|
||||
const isShellless = computed(() => isTokenEmbed.value || isBareTask.value);
|
||||
|
||||
// 嵌入页必须旁路全局 body 的 min-width(assets/styles/base/_base.scss 的 1200px):
|
||||
// 上面 isEmbed 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
|
||||
// 上面 isShellless 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
|
||||
// 表现为整体右偏 + 右侧被裁。用内联样式覆盖(优先级高于样式表),非嵌入态移除内联属性回落到原值
|
||||
function syncEmbedLayout(embed: boolean) {
|
||||
if (embed) document.body.style.minWidth = "0";
|
||||
else document.body.style.removeProperty("min-width");
|
||||
}
|
||||
watch(isEmbed, syncEmbedLayout, { immediate: true });
|
||||
watch(isShellless, syncEmbedLayout, { immediate: true });
|
||||
onBeforeUnmount(() => syncEmbedLayout(false));
|
||||
|
||||
const layoutData = reactive({
|
||||
@@ -37,8 +44,9 @@ onMounted(async () => {
|
||||
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
|
||||
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
|
||||
await router.isReady();
|
||||
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)
|
||||
if (isEmbed.value) {
|
||||
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)。
|
||||
// 去壳任务页不走这个分支——它是写面,getUserInfo/getUserMod 正是触发 CAS 的地方
|
||||
if (isTokenEmbed.value) {
|
||||
isLogin.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -97,11 +105,14 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动) -->
|
||||
<router-view v-if="isEmbed" />
|
||||
<template v-else>
|
||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动)。
|
||||
token 页维持"不等鉴权先渲染"(它本来就不发鉴权请求);去壳任务页要等 CAS 那一路走完,
|
||||
否则未登录时 iframe 里先闪一个能点的办理面板 -->
|
||||
<router-view v-if="isTokenEmbed" />
|
||||
<router-view v-else-if="isBareTask && isLogin" />
|
||||
<template v-else-if="isLogin">
|
||||
<layout :layoutData="layoutData" />
|
||||
<div class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 去壳任务页(/task/{taskId}?embed=1)与父应用之间的 postMessage 协议(v2.1 应用内审批·完整办理)。
|
||||
*
|
||||
* 协议常量集中在此,父侧(Notice/svc-hall 的 FlowTaskEmbed)按同样字面量校验;跨应用不发包,
|
||||
* 改动须双端同步。
|
||||
*
|
||||
* origin 纪律:子页只向 document.referrer 的 origin 发(父文档地址),拿不到就不发——
|
||||
* 禁止 `*`,否则任意宿主页面嵌我们任务页都能收到办理结果。父侧必须同样校验 event.origin。
|
||||
*/
|
||||
|
||||
export const FLOW_EMBED_PROTO = "flow";
|
||||
export const FLOW_EMBED_VERSION = 1;
|
||||
|
||||
export type FlowEmbedAction = "approve" | "reject" | "return" | "transfer";
|
||||
|
||||
export interface FlowEmbedMessage {
|
||||
proto: typeof FLOW_EMBED_PROTO;
|
||||
v: typeof FLOW_EMBED_VERSION;
|
||||
type: "handled" | "open-biz" | "height";
|
||||
taskId?: string;
|
||||
action?: FlowEmbedAction;
|
||||
bizId?: string | null;
|
||||
bizUrl?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** 本是否作为"去壳任务页"运行:路径在 /task/ 下且 embed=1(与 App.vue 的壳判定同一条规则) */
|
||||
export function isBareTaskRoute(path: string, embed: unknown): boolean {
|
||||
return path.startsWith("/task/") && String(embed ?? "") === "1";
|
||||
}
|
||||
|
||||
/** 当前是否运行在 iframe 内(顶层 window 与 parent 不同) */
|
||||
export function isInIframe(): boolean {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch {
|
||||
// 跨域访问 window.top 本身会抛——抛了说明就在 iframe 里
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 父页面 origin:iframe 的 document.referrer 即宿主文档地址,且在整个 iframe 生命周期内不变
|
||||
* (SPA 内部路由不影响它)。直接输地址进 iframe(无 referrer)时返回 null——宁可不发,
|
||||
* 也不猜一个 targetOrigin。
|
||||
*/
|
||||
export function parentOrigin(): string | null {
|
||||
const ref = document.referrer;
|
||||
if (!ref) return null;
|
||||
try {
|
||||
const origin = new URL(ref).origin;
|
||||
return origin && origin !== "null" ? origin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 向父页面发一条协议消息;非 iframe 或父 origin 未知时静默跳过(独立直开任务页即现状行为) */
|
||||
export function postToParent(msg: Omit<FlowEmbedMessage, "proto" | "v">): void {
|
||||
if (!isInIframe()) return;
|
||||
const target = parentOrigin();
|
||||
if (!target) return;
|
||||
window.parent.postMessage({ proto: FLOW_EMBED_PROTO, v: FLOW_EMBED_VERSION, ...msg }, target);
|
||||
}
|
||||
|
||||
/** 父侧校验:只认本协议的消息体(origin 白名单由调用方自己按 iframe 地址判,这里管信封) */
|
||||
export function isFlowEmbedMessage(data: unknown): data is FlowEmbedMessage {
|
||||
const d = data as FlowEmbedMessage | null;
|
||||
return !!d && d.proto === FLOW_EMBED_PROTO && d.v === FLOW_EMBED_VERSION && !!d.type;
|
||||
}
|
||||
@@ -2,16 +2,26 @@
|
||||
// 办理页(概览 §9.4):biz_snapshot 业务摘要区块 + formData 业务表单数据只读渲染(§3.4,
|
||||
// 非表单引擎——只读展示,原文留在业务方)+ "查看业务详情"跳 biz_url + 历史意见 + 同意/拒绝。
|
||||
// 一切审批入口的收敛点都是后端同一套 /api/flow/task API。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import { isBareTaskRoute, postToParent, type FlowEmbedAction } from "@/utils/taskEmbed";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = computed(() => String(route.params.taskId || ""));
|
||||
|
||||
/**
|
||||
* 去壳态(embed=1,v2.1 完整办理内嵌):同一个 /task/:taskId 组件、同一套 CAS 用户面接口,
|
||||
* 只把"办完后跳工作台""跳流程图""看业务详情"这三个会离开当前壳的动作为父页面接管——
|
||||
* iframe 里跳出工作台等于把 flow-center 菜单塞进子应用弹窗。
|
||||
*/
|
||||
const isBare = computed(() => isBareTaskRoute(route.path, route.query.embed));
|
||||
/** bare 下办理成功后的结果条文案(不跳走,父页面自己决定关不关) */
|
||||
const doneTip = ref("");
|
||||
|
||||
/** 轨迹显示口径:dept=按科室 / user=按人员(不传=人员),与办件详情页、图示页同参数 */
|
||||
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
|
||||
|
||||
@@ -91,9 +101,37 @@ async function load() {
|
||||
}
|
||||
|
||||
function openBizUrl() {
|
||||
if (detail.value?.bizUrl) {
|
||||
window.open(detail.value.bizUrl, "_blank");
|
||||
const url = detail.value?.bizUrl;
|
||||
if (!url) return;
|
||||
// bare:由父页面决定怎么开(它才知道自家业务详情的路由),iframe 里 window.open 会白跳一个中心站地址
|
||||
if (isBare.value) {
|
||||
postToParent({ type: "open-biz", bizId: detail.value?.bizId ?? null, bizUrl: url });
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
/** 流程图仍要能看,但 bare 下不能用 router.push——/diagram 不是 /task/ 前缀,会在 iframe 里长出菜单壳 */
|
||||
function openDiagram() {
|
||||
const href = router.resolve(`/diagram?instanceId=${detail.value.instanceId}&mode=${mode.value}`).href;
|
||||
if (isBare.value) window.open(href, "_blank");
|
||||
else router.push(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* 办理成功后的收尾:bare 回 postMessage(handled) + 结果条 + 重新拉详情(本任务转为不可办理态,
|
||||
* 避免"再点一次"),非 bare 保持原样跳工作台。
|
||||
*/
|
||||
async function afterHandled(action: FlowEmbedAction, tip: string) {
|
||||
if (!isBare.value) {
|
||||
router.push("/workbench");
|
||||
return;
|
||||
}
|
||||
postToParent({ type: "handled", taskId: taskId.value, action });
|
||||
doneTip.value = tip;
|
||||
opinion.value = "";
|
||||
chosenEdge.value = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
async function submit(approved: boolean) {
|
||||
@@ -113,11 +151,12 @@ async function submit(approved: boolean) {
|
||||
}
|
||||
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send();
|
||||
Message.success("已同意");
|
||||
await afterHandled("approve", "已同意,本任务办理完成");
|
||||
} else {
|
||||
await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
|
||||
Message.success("已拒绝");
|
||||
await afterHandled("reject", "已拒绝,本任务办理完成");
|
||||
}
|
||||
router.push("/workbench");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -148,10 +187,10 @@ function doReturn() {
|
||||
targets[targets.length - 1] || null;
|
||||
flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined, opinion.value.trim() || undefined)
|
||||
.send()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
Message.success("已退回");
|
||||
showReturn.value = false;
|
||||
router.push("/workbench");
|
||||
await afterHandled("return", "已退回,目标节点重新生成待办");
|
||||
})
|
||||
.finally(() => { submitting.value = false; });
|
||||
}
|
||||
@@ -169,17 +208,55 @@ async function doTransfer() {
|
||||
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send();
|
||||
Message.success("已移交");
|
||||
showTransfer.value = false;
|
||||
router.push("/workbench");
|
||||
await afterHandled("transfer", "已移交,本任务由接手人办理");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
// ===== 去壳态高度上报(父页面 iframe 自适应,避免固定高度里内滚两层) =====
|
||||
const pageEl = ref<HTMLElement | null>(null);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let lastReportedHeight = 0;
|
||||
|
||||
/** 内容变化(详情加载完、弹窗开合)都会触发;8px 死区防止亚像素抖动刷屏 */
|
||||
function reportHeight() {
|
||||
const el = pageEl.value;
|
||||
if (!el) return;
|
||||
const height = el.scrollHeight;
|
||||
if (!height || Math.abs(height - lastReportedHeight) < 8) return;
|
||||
lastReportedHeight = height;
|
||||
postToParent({ type: "height", height });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => detail.value,
|
||||
async () => {
|
||||
if (!isBare.value || !detail.value) return;
|
||||
await nextTick();
|
||||
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => reportHeight());
|
||||
}
|
||||
if (resizeObserver && pageEl.value) resizeObserver.observe(pageEl.value);
|
||||
reportHeight();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
window.addEventListener("resize", reportHeight);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", reportHeight);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" v-if="detail">
|
||||
<div ref="pageEl" class="page" :class="{ bare: isBare }" v-if="detail">
|
||||
<Alert v-if="doneTip" type="success" show-icon class="done-tip">{{ doneTip }}</Alert>
|
||||
<div class="head">
|
||||
<span class="title">{{ detail.bizTitle }}</span>
|
||||
<Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'">
|
||||
@@ -247,8 +324,7 @@ onMounted(load);
|
||||
</RadioGroup>
|
||||
</template>
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||
<Button size="small" ghost type="primary"
|
||||
@click="router.push(`/diagram?instanceId=${detail.instanceId}&mode=${mode}`)">查看流程图</Button>
|
||||
<Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
|
||||
</Card>
|
||||
|
||||
<!-- 退回弹窗 -->
|
||||
@@ -287,6 +363,9 @@ onMounted(load);
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
/* 去壳态:iframe 里父页面已给了标题与外边距,这里只留最小呼吸(也稳住高度上报的抖动) */
|
||||
.page.bare { padding: 8px 10px; }
|
||||
.done-tip { margin-bottom: 10px; }
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.title { font-size: 16px; font-weight: 600; }
|
||||
.block { margin-bottom: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user