[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 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>
|
||||||
|
|||||||
@@ -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,
|
// 办理页(概览 §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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user