feat(embed): 新增办件进度嵌入页与无鉴权旁路

This commit is contained in:
chenxing
2026-10-03 16:51:29 +08:00
parent 79866a05e1
commit d3a2b08ba2
4 changed files with 205 additions and 6 deletions
+16 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted } from "vue";
import layout from "./components/layout.vue";
import { useRouter } from "vue-router";
import config from "@/assets/config/host.config";
@@ -9,6 +9,9 @@ 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 layoutData = reactive({
appName: "流程中心",
baseHost: config.baseHost,
@@ -20,6 +23,14 @@ const layoutData = reactive({
});
onMounted(async () => {
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
await router.isReady();
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)
if (isEmbed.value) {
isLogin.value = true;
return;
}
try {
// 1. 获取当前用户信息
const user: any = await baseApi.getUserInfo().send();
@@ -75,12 +86,16 @@ 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 }">
<div class="main-wrapper">
<router-view />
</div>
</div>
</template>
</template>
<style lang="scss">
+8
View File
@@ -84,6 +84,14 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/flow/admin/Routers.vue"),
meta: { title: "条件路由", modId: 106 },
},
{
// 办件进度嵌入页(iframe 免签嵌入,App.vue 对 /embed/* 前缀旁路 CAS 与 layout):
// 不挂菜单、无 parentId(子应用 Drawer 内打开,非管理面导航);query.token 必填
path: "/embed/instance",
name: "flow_embed_instance",
component: () => import("@/views/flow/EmbedInstance.vue"),
meta: { title: "办件进度" },
},
{ path: "/", redirect: "/workbench" },
// 兜底:UserMsg jumpPath / bizUrl 等外部深链若落到未登记路由,避免 no-match 静默空白页
{ path: "/:pathMatch(.*)*", redirect: "/workbench" },
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
// 办件进度嵌入页(iframe 免签嵌入,2026-10-03 方案):子应用 Drawer 里以 iframe 打开本页,
// 展示"进度 + 流程示意图",零样式复制、单点维护。全程零 CAS 交互——数据走 /open/flow/embed/data
// 免签端点(只认 token),示意图 token 两用(/open/flow/diagram/img 同一 token 出图)。
// 本页只读(方案 §9):不做任何审批动作;办理入口仍是待办站内信 → /task/:taskId 既有链路。
import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
import config from "@/assets/config/host.config";
const route = useRoute();
const detail = ref<any>(null);
const loading = ref(true);
/** 兜底空态:缺 token / 过期 / 加载失败,全页同一文案,不留白 */
const expired = ref(false);
/** 显示口径:dept=按科室 / user=按人员(缺省)。纯前端本地状态——嵌入页 URL 归子应用管,
* 改 query 会破坏 token 语义,不落 URL(与管理面 InstanceDetail 写 query 的动机不同) */
const traceMode = ref<string>("user");
function switchMode(val: string) {
traceMode.value = val;
}
const token = computed(() => String(route.query.token || ""));
/** 示意图直嵌:同一 token 第二处消费(换图端点零改动即认领嵌入 token) */
const diagramSrc = computed(
() => `${config.serviceHost}/open/flow/diagram/img?token=${token.value}`,
);
const snapshotRows = computed(() => {
const raw = detail.value?.bizSnapshot;
if (!raw) return [];
let obj: any = null;
try {
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [{ k: "摘要", v: String(raw) }];
}
if (obj && typeof obj === "object") {
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
}
return [];
});
/** 业务表单数据键值平铺(同 InstanceDetail 现状,不做精细表单渲染) */
const formRows = computed(() => {
const raw = detail.value?.formDataJson;
if (raw === null || raw === undefined || raw === "") return [];
let obj: any = null;
try {
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [{ k: "表单原文", v: String(raw) }];
}
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
return Object.entries(obj).map(([k, v]) => ({
k,
v: v == null ? "-"
: Array.isArray(v)
? (v.map((item: any) => item && typeof item === "object" ? item.fileName || JSON.stringify(item) : String(item)).join("、") || "-")
: (typeof v === "object" ? JSON.stringify(v) : String(v)),
}));
}
return [{ k: "表单原文", v: String(raw) }];
});
function statusName(s: number) {
return INSTANCE_STATUS[s] || s;
}
function statusColor(s: number) {
return INSTANCE_STATUS_COLOR[s] || "gray";
}
// 免签端点不带任何会话,alova 拦截器(token 头/信封解析)一概用不上,原生 fetch 直取
onMounted(async () => {
if (!token.value) {
expired.value = true;
loading.value = false;
return;
}
try {
const res = await fetch(`${config.serviceHost}/open/flow/embed/data?token=${token.value}`);
if (!res.ok) {
// 404=过期/伪造,与缺 token 同空态(不区分,不诱导探测)
expired.value = true;
return;
}
detail.value = await res.json();
} catch {
expired.value = true;
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="embed-page" v-if="detail">
<div class="head">
<span class="title">{{ detail.bizTitle }}</span>
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
</div>
<Card class="block" :bordered="false" dis-hover>
<template #title>基本信息</template>
<div class="grid">
<div class="row"><span class="k">流程</span><span>{{ detail.defCode }} v{{ detail.defVersion }}</span></div>
<div class="row"><span class="k">提交人</span><span>{{ detail.submitterName }}</span></div>
<div class="row"><span class="k">提交时间</span><span>{{ (detail.submitTime || "").replace("T", " ") }}</span></div>
<div class="row"><span class="k">办结时间</span><span>{{ (detail.finishTime || "").replace("T", " ") || "-" }}</span></div>
</div>
<template v-if="snapshotRows.length">
<Divider size="small" />
<div class="snapshot">
<div v-for="row in snapshotRows" :key="row.k" class="row">
<span class="k">{{ row.k }}</span>
<span class="v">{{ row.v }}</span>
</div>
</div>
</template>
</Card>
<Card class="block" :bordered="false" dis-hover>
<template #title>流程示意图</template>
<img class="diagram-img" :src="diagramSrc" alt="流程示意图" />
</Card>
<Card class="block" :bordered="false" dis-hover>
<template #title>
流程轨迹
<RadioGroup class="trace-mode" :model-value="traceMode" size="small" type="button" @on-change="switchMode">
<Radio label="user">按人员</Radio>
<Radio label="dept">按科室</Radio>
</RadioGroup>
</template>
<TraceTimeline :trace="detail.trace || []" :mode="traceMode" />
</Card>
<Card class="block" :bordered="false" dis-hover v-if="formRows.length">
<template #title>业务表单数据</template>
<div class="snapshot">
<div v-for="row in formRows" :key="row.k" class="row">
<span class="k">{{ row.k }}</span>
<span class="v">{{ row.v }}</span>
</div>
</div>
</Card>
</div>
<div v-else-if="loading" class="embed-page muted">加载中…</div>
<div v-else class="embed-page muted expired">链接已过期,请回到来源应用重新打开</div>
</template>
<style scoped>
/* 根类不用 .page:UnoCSS 原子类同名会以更高优先级盖掉 scoped 的 padding(裸渲染无 layout 壳时暴露) */
.embed-page { padding: 12px 16px; }
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.title { font-size: 16px; font-weight: 600; }
.block { margin-bottom: 12px; }
.grid .row { display: flex; padding: 3px 0; }
.k { width: 120px; color: #888; flex-shrink: 0; }
.v { white-space: pre-wrap; word-break: break-all; }
.muted { color: #999; }
.expired { padding: 48px 16px; text-align: center; }
.diagram-img { max-width: 100%; }
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
</style>
+7
View File
@@ -106,6 +106,13 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
target: "http://localhost:8085",
changeOrigin: true,
},
// 免签面(嵌入页数据 /open/flow/embed/data、示意图 /open/flow/diagram/img):
// 生产前后端同域(Caddy /flow-center 下同源相对路径即达),本地 dev 前后端分端口,
// 同理需代理——serviceHost 为空时浏览器请求落在 5173/8088 上无此路由
"/open": {
target: "http://localhost:8085",
changeOrigin: true,
},
},
},
// css预处理器