feat(embed): 新增办件进度嵌入页与无鉴权旁路
This commit is contained in:
+21
-6
@@ -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 v-if="isLogin" :layoutData="layoutData" />
|
||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view />
|
||||
<!-- 嵌入页:裸渲染,不套 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>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
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预处理器
|
||||
|
||||
Reference in New Issue
Block a user