Files
flow-center-front/src/components/layout.vue
T
chenxing c93313cdb7
build / build (push) Successful in 1m28s
feat(flow): 流程图入口/失败提示/指派预览/线级停发闭环/配置分层/选人宿主
- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
2026-10-28 03:18:54 +08:00

318 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onBeforeMount, onBeforeUnmount, onMounted, watch, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute";
import defaultLogo from "@/assets/image/yunhai-logo.svg";
import UserAvatar from "@/components/UserAvatar.vue";
const props = defineProps<{ layoutData: Record<string, any> }>();
const route = useRoute();
const router = useRouter();
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
const logoUrl = ref<string>(defaultLogo);
const readLogoUrl = () => {
let v: string | null = null;
try {
v = localStorage.getItem("yh-logo-url");
} catch {
v = null;
}
logoUrl.value = v || defaultLogo;
};
const onLogoError = () => {
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
};
onMounted(() => {
readLogoUrl();
document.addEventListener("yh-appearance-updated", readLogoUrl);
});
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
const isPanel = ref(false);
const currPath = ref<string>("");
const navPath = ref<any[]>([]);
const baseHost = computed(() => props.layoutData?.baseHost || "");
const ssoHost = computed(() => props.layoutData?.ssoHost || "");
const appName = computed(() => props.layoutData?.appName || "通知公告");
const appList = computed(() => props.layoutData?.appList || []);
const userInfo = computed(() => props.layoutData?.userInfo || { name: "管理员", avatar: "" });
const isAlone = computed(() => props.layoutData?.isAlone ?? false);
const modList = computed(() => props.layoutData?.modList || []);
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
onBeforeMount(() => {
routeChange(route);
});
watch(
() => route.fullPath,
() => routeChange(route)
);
const flaten = (tree: any[], pid = 0): any[] => {
const arr: any[] = [];
tree.forEach((element: any) => {
const { id, name, modId } = element;
// 平台配置的 path 格式不可控,统一解析为内部路由 path(名称/modId 兜底)
const resolved = resolveMenuPage(element.page, element.modId ?? element.id, element.name);
arr.push({ id, name, modId, page: resolved.path, external: resolved.external, pid });
if (element.children && element.children.length) {
arr.push(...flaten(element.children, id));
}
});
return arr;
};
const menuArr = computed(() => flaten(modList.value));
const routeChange = (to: any) => {
const meta: any = to.meta || {};
const { modId, title } = meta;
const navArr: any[] = [];
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
// 匹配不到再退回 modId 对照。
// modId 为空时必须短路:深链页(/diagram、/assign-preview、/task/*)不带 modId,
// 原先 `x.modId === undefined` 会命中第一条同样没有 modId 的菜单,面包屑于是显示成
// 不相干的「流程编辑器」(验收报告 P3)。
const find: any =
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
(modId !== undefined && modId !== null
? menuArr.value.find((x: any) => x.modId === modId)
: null);
if (find) {
currPath.value = find.page || "";
if (find.pid) {
const pfind = menuArr.value.find((x: any) => x.id === find.pid);
if (pfind) navArr.push({ name: pfind.name });
}
navArr.push({ name: find.name || title, page: find.page });
navPath.value = navArr;
return;
}
// 详情页父级(左侧高亮 + 面包屑"父页>本页"):路由 meta.parentId 静态默认,
// 可被来源列表跳转时的 ?pid=<父菜单modId> 动态覆盖(同一详情页多个入口,UserBase 同款范式)
const parentId = Number(to.query?.pid) || meta.parentId;
if (parentId) {
const parent = menuArr.value.find((x: any) => x.modId === parentId);
if (parent) {
currPath.value = parent.page || "";
if (parent.pid) {
const gp = menuArr.value.find((x: any) => x.id === parent.pid);
if (gp) navArr.push({ name: gp.name });
}
navArr.push({ name: parent.name, page: parent.page });
navArr.push({ name: title });
navPath.value = navArr;
return;
}
}
// 同上:modId 缺失时不做这条兜底,否则又是"第一条无 modId 路由"中奖
const r = (modId === undefined || modId === null)
? undefined
: router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
if (r) {
currPath.value = r.path;
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
return;
}
currPath.value = "";
// 深链页(/diagram 流程示意图、/assign-preview 选择办理人、/task/*)不挂菜单:
// 面包屑退回平台应用名(FL 配置的是「流程编辑器」),与实际页面标题不符(验收报告 P3)。
// 菜单匹配不上时至少给出本页 meta.title,父级缺失不再伪造。
navPath.value = title ? [{ name: title }] : [];
};
const linkMenu = (item: any) => {
const page: string = item?.page || "";
if (!page) {
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
return;
}
// 平台配置的外部链接模块:整页跳转
if (item?.external) {
window.location.href = page;
return;
}
if (page === route.path) {
router.go(0);
} else {
router.push(page);
}
};
const linkUserInfo = () => {
window.location.href = baseHost.value + "/v/userinfo";
};
const logout = () => {
let url = window.location.href;
url = "/logout?service=" + url;
window.location.href = ssoHost.value + url;
};
const childChange = (index: number) => {
const arr = modList.value || [];
if (arr[index]) {
arr[index].childState = !arr[index].childState;
}
};
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
* 兜底保留旧版 modId 对照。图标为通道 iconfont 掩码类(A 类接入后由 YschTheme 发版 icons.css 提供,本地副本已回收) */
const getMenuIcon = (item?: any): string => {
const page: string = item?.page || "";
if (page === "/list/new") return "icon-xiugaijl";
if (page === "/list") return "icon-fasong";
if (page === "/" || page === "/accept") return "icon-sjx";
if (page === "/noticelist") return "icon-liebiao";
if (page === "/stats") return "icon-tongji";
if (page === "/setting") return "icon-shezhi";
switch (item?.modId ?? item?.id) {
case 1: return "icon-xiugaijl";
case 2: return "icon-fasong";
case 3: return "icon-sjx";
case 4: return "icon-liebiao";
case 5: return "icon-wenzhang";
case 21: return "icon-shezhi";
case 22: return "icon-xiugaijl";
default: return "";
}
};
function linkHomePage() {
if (baseHost.value) {
// 旧 BschHome 已下线(线上 404),首页统一指向用户中心 userhome
window.location.href = baseHost.value + "/userhome/front/index.html";
} else {
window.location.href = "/";
}
}
</script>
<template>
<div class="layout-wrap">
<header class="header-container fix">
<!-- 云海 logo 徽标(结构与 Notice layout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div>
<div class="header-aside fr">
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
{{ userInfo.name }}
</a>
<div class="delimiter fl align-middle"></div>
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
<i class="iconfont icon-logout"></i>
</a>
</div>
</header>
<div class="left-menu" v-if="!isAlone">
<div class="menu-top-box"></div>
<ul>
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
<template v-if="item.children && item.children.length > 0">
<li>
<a href="javascript:;" @click="childChange(index)">
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
{{ item.name }}
</a>
</li>
<transition name="fade_child_ul">
<div class="child-item-wrap" v-show="item.childState">
<li
:class="citem.page == currPath ? 'active child-item' : 'child-item'"
v-for="citem in item.children"
:key="citem.id"
>
<a href="javascript:;" @click="linkMenu(citem)">
{{ citem.name }}
</a>
</li>
</div>
</transition>
</template>
<li v-else :class="item.page == currPath ? 'active' : ''">
<a href="javascript:;" @click="linkMenu(item)">
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
{{ item.name }}
</a>
</li>
</template>
</ul>
<div class="clear-box"></div>
</div>
<div class="path-nav" :style="pathNavStyle">
<Breadcrumb separator=">">
<a href="javascript:;" @click="linkHomePage">
<BreadcrumbItem>
<span class="crumb-home">
<i class="iconfont icon-category"></i>
首页
</span>
</BreadcrumbItem>
</a>
<BreadcrumbItem v-if="!isAlone" to="/">{{ appName }}</BreadcrumbItem>
<template v-for="(item, index) in navPath" :key="index">
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
{{ item.name }}
</BreadcrumbItem>
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
</template>
</Breadcrumb>
</div>
<transition name="fade_apps">
<div class="app-panel" v-if="isPanel">
<div class="tips-box"></div>
<ul class="fix">
<li class="fl" v-for="(item, index) in appList" :key="index">
<a :href="item.url" target="_blank" rel="noopener noreferrer">
<div class="icon-box">
<img :src="item.icon" alt="" />
</div>
<p class="text-ellipsis">{{ item.name }}</p>
</a>
</li>
</ul>
<div class="link-panel">
<a :href="baseHost + '/userhome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
全部应用
<i>→</i>
</a>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
/* 仅覆盖/补充必要的动态样式,其余样式由主题通道 base.layout.css 提供 */
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 面包屑「首页」垂直居中:通道 base.layout.css 的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不改通道产物,覆盖收在本组件内) */
.crumb-home {
display: inline-flex;
align-items: center;
gap: 4px;
}
.crumb-home .iconfont {
position: static;
top: 0;
}
</style>