feat(embed): 新增办件进度嵌入页与无鉴权旁路
This commit is contained in:
+16
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from "vue";
|
import { ref, reactive, computed, onMounted } from "vue";
|
||||||
import layout from "./components/layout.vue";
|
import layout from "./components/layout.vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
@@ -9,6 +9,9 @@ import { Message } from "view-ui-plus";
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isLogin = ref(false);
|
const isLogin = ref(false);
|
||||||
|
// 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话,
|
||||||
|
// 也不该出现菜单壳。判定用路由前缀而非 query 标记——刷新/直链/分享均稳定
|
||||||
|
const isEmbed = computed(() => router.currentRoute.value.path.startsWith("/embed/"));
|
||||||
const layoutData = reactive({
|
const layoutData = reactive({
|
||||||
appName: "流程中心",
|
appName: "流程中心",
|
||||||
baseHost: config.baseHost,
|
baseHost: config.baseHost,
|
||||||
@@ -20,6 +23,14 @@ const layoutData = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
|
||||||
|
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
|
||||||
|
await router.isReady();
|
||||||
|
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)
|
||||||
|
if (isEmbed.value) {
|
||||||
|
isLogin.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 1. 获取当前用户信息
|
// 1. 获取当前用户信息
|
||||||
const user: any = await baseApi.getUserInfo().send();
|
const user: any = await baseApi.getUserInfo().send();
|
||||||
@@ -75,6 +86,9 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动) -->
|
||||||
|
<router-view v-if="isEmbed" />
|
||||||
|
<template v-else>
|
||||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
<layout v-if="isLogin" :layoutData="layoutData" />
|
||||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||||
<div class="main-wrapper">
|
<div class="main-wrapper">
|
||||||
@@ -82,6 +96,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/flow/admin/Routers.vue"),
|
component: () => import("@/views/flow/admin/Routers.vue"),
|
||||||
meta: { title: "条件路由", modId: 106 },
|
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" },
|
{ path: "/", redirect: "/workbench" },
|
||||||
// 兜底:UserMsg jumpPath / bizUrl 等外部深链若落到未登记路由,避免 no-match 静默空白页
|
// 兜底:UserMsg jumpPath / bizUrl 等外部深链若落到未登记路由,避免 no-match 静默空白页
|
||||||
{ path: "/:pathMatch(.*)*", redirect: "/workbench" },
|
{ path: "/:pathMatch(.*)*", redirect: "/workbench" },
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -106,6 +106,13 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
target: "http://localhost:8085",
|
target: "http://localhost:8085",
|
||||||
changeOrigin: true,
|
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预处理器
|
// css预处理器
|
||||||
|
|||||||
Reference in New Issue
Block a user