[v2.1] A 去壳任务办理:App 增 bare 任务形态(/task/*?embed=1 保 CAS 去布局),TaskHandle 办理成功/查看业务/高度变化 postMessage 回父页,bare 不跳工作台
build / build (push) Successful in 2m30s

This commit is contained in:
chenxing
2026-10-19 16:50:49 +08:00
parent 04fd54e945
commit ba448fc7a0
3 changed files with 181 additions and 21 deletions
+21 -10
View File
@@ -5,22 +5,29 @@ import { useRouter } from "vue-router";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
import { baseApi } from "@/api/module/base"; import { baseApi } from "@/api/module/base";
import { resolveMenuPage } from "@/utils/menuRoute"; import { resolveMenuPage } from "@/utils/menuRoute";
import { isBareTaskRoute } from "@/utils/taskEmbed";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
const router = useRouter(); const router = useRouter();
const isLogin = ref(false); const isLogin = ref(false);
// 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话, // 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话,
// 也不该出现菜单壳。判定用路由前缀而非 query 标记——刷新/直链/分享均稳定 // 也不该出现菜单壳。判定用路由前缀而非 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): // 嵌入页必须旁路全局 body 的 min-width(assets/styles/base/_base.scss 的 1200px):
// 上面 isEmbed 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版, // 上面 isShellless 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
// 表现为整体右偏 + 右侧被裁。用内联样式覆盖(优先级高于样式表),非嵌入态移除内联属性回落到原值 // 表现为整体右偏 + 右侧被裁。用内联样式覆盖(优先级高于样式表),非嵌入态移除内联属性回落到原值
function syncEmbedLayout(embed: boolean) { function syncEmbedLayout(embed: boolean) {
if (embed) document.body.style.minWidth = "0"; if (embed) document.body.style.minWidth = "0";
else document.body.style.removeProperty("min-width"); else document.body.style.removeProperty("min-width");
} }
watch(isEmbed, syncEmbedLayout, { immediate: true }); watch(isShellless, syncEmbedLayout, { immediate: true });
onBeforeUnmount(() => syncEmbedLayout(false)); onBeforeUnmount(() => syncEmbedLayout(false));
const layoutData = reactive({ const layoutData = reactive({
@@ -37,8 +44,9 @@ onMounted(async () => {
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀, // 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求 // 否则直链 /#/embed/* 会在旁路前误发鉴权请求
await router.isReady(); await router.isReady();
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受) // 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)。
if (isEmbed.value) { // 去壳任务页不走这个分支——它是写面,getUserInfo/getUserMod 正是触发 CAS 的地方
if (isTokenEmbed.value) {
isLogin.value = true; isLogin.value = true;
return; return;
} }
@@ -97,11 +105,14 @@ onMounted(async () => {
</script> </script>
<template> <template>
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动) --> <!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动)。
<router-view v-if="isEmbed" /> token 页维持"不等鉴权先渲染"(它本来就不发鉴权请求);去壳任务页要等 CAS 那一路走完,
<template v-else> 否则未登录时 iframe 里先闪一个能点的办理面板 -->
<layout v-if="isLogin" :layoutData="layoutData" /> <router-view v-if="isTokenEmbed" />
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }"> <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"> <div class="main-wrapper">
<router-view /> <router-view />
</div> </div>
+70
View File
@@ -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;
}
+90 -11
View File
@@ -2,16 +2,26 @@
// 办理页(概览 §9.4):biz_snapshot 业务摘要区块 + formData 业务表单数据只读渲染(§3.4, // 办理页(概览 §9.4):biz_snapshot 业务摘要区块 + formData 业务表单数据只读渲染(§3.4,
// 非表单引擎——只读展示,原文留在业务方)+ "查看业务详情"跳 biz_url + 历史意见 + 同意/拒绝。 // 非表单引擎——只读展示,原文留在业务方)+ "查看业务详情"跳 biz_url + 历史意见 + 同意/拒绝。
// 一切审批入口的收敛点都是后端同一套 /api/flow/task API。 // 一切审批入口的收敛点都是后端同一套 /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 { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus"; import { Message, Modal } from "view-ui-plus";
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow"; import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue"; import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
import { isBareTaskRoute, postToParent, type FlowEmbedAction } from "@/utils/taskEmbed";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const taskId = computed(() => String(route.params.taskId || "")); 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=按人员(不传=人员),与办件详情页、图示页同参数 */ /** 轨迹显示口径:dept=按科室 / user=按人员(不传=人员),与办件详情页、图示页同参数 */
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user"); const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
@@ -91,9 +101,37 @@ async function load() {
} }
function openBizUrl() { function openBizUrl() {
if (detail.value?.bizUrl) { const url = detail.value?.bizUrl;
window.open(detail.value.bizUrl, "_blank"); 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) { 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(); await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send();
Message.success("已同意"); Message.success("已同意");
await afterHandled("approve", "已同意,本任务办理完成");
} else { } else {
await flowApi.reject(myTaskId.value, opinion.value.trim()).send(); await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
Message.success("已拒绝"); Message.success("已拒绝");
await afterHandled("reject", "已拒绝,本任务办理完成");
} }
router.push("/workbench");
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -148,10 +187,10 @@ function doReturn() {
targets[targets.length - 1] || null; targets[targets.length - 1] || null;
flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined, opinion.value.trim() || undefined) flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined, opinion.value.trim() || undefined)
.send() .send()
.then(() => { .then(async () => {
Message.success("已退回"); Message.success("已退回");
showReturn.value = false; showReturn.value = false;
router.push("/workbench"); await afterHandled("return", "已退回,目标节点重新生成待办");
}) })
.finally(() => { submitting.value = false; }); .finally(() => { submitting.value = false; });
} }
@@ -169,17 +208,55 @@ async function doTransfer() {
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send(); await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send();
Message.success("已移交"); Message.success("已移交");
showTransfer.value = false; showTransfer.value = false;
router.push("/workbench"); await afterHandled("transfer", "已移交,本任务由接手人办理");
} finally { } finally {
submitting.value = false; 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> </script>
<template> <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"> <div class="head">
<span class="title">{{ detail.bizTitle }}</span> <span class="title">{{ detail.bizTitle }}</span>
<Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'"> <Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'">
@@ -247,8 +324,7 @@ onMounted(load);
</RadioGroup> </RadioGroup>
</template> </template>
<TraceTimeline :trace="detail.trace || []" :mode="mode" /> <TraceTimeline :trace="detail.trace || []" :mode="mode" />
<Button size="small" ghost type="primary" <Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
@click="router.push(`/diagram?instanceId=${detail.instanceId}&mode=${mode}`)">查看流程图</Button>
</Card> </Card>
<!-- 退回弹窗 --> <!-- 退回弹窗 -->
@@ -287,6 +363,9 @@ onMounted(load);
<style scoped> <style scoped>
.page { padding: 12px 16px; } .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; } .head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.title { font-size: 16px; font-weight: 600; } .title { font-size: 16px; font-weight: 600; }
.block { margin-bottom: 12px; } .block { margin-bottom: 12px; }