feat(oidc): 接入统一认证登录与身份自助绑定

- 登录页接入统一认证入口并处理认证回调
- 个人中心新增认证身份区块,支持自助绑定与解绑
- 头像菜单与应用中心新增认证身份入口
- 应用中心删除经营管理分组,业务系统卡片指向统一认证授权地址
- 用户管理页移除认证身份代绑入口与弹窗
- 身份关联接口从用户域移到认证域
This commit is contained in:
Ray.Hao committed 2026-10-28 08:21:27 +08:00
1 parent 9cddb95fb7
commit b5bacd2cbf
205 files changed
+6705 -7079

No files matched your search

+176 -99
View File
@@ -26,12 +26,26 @@
v-for="row in list"
:key="row.id"
class="role-list__item"
:class="{ 'is-active': row.id === currentRole?.id }"
:class="{
'is-active': row.id === currentRole?.id,
'is-disabled': row.status === 0,
}"
@click="handleRoleClick(row)"
>
<div class="role-list__info">
<span class="role-list__name" :title="row.name">{{ row.name }}</span>
<span class="role-list__code" :title="row.code">{{ row.code }}</span>
<span class="role-list__title">
<span
class="role-list__dot"
:class="{ 'is-stopped': row.status !== CommonStatus.ENABLED }"
/>
<span class="role-list__name" :title="row.name">{{ row.name }}</span>
</span>
<span class="role-list__meta">
<span class="role-list__code" :title="row.code">{{ row.code }}</span>
<span v-if="row.status !== CommonStatus.ENABLED" class="role-list__stopped">
停用
</span>
</span>
</div>
<div v-if="row.code !== ROLE_ROOT" class="role-list__actions">
<el-button
@@ -200,7 +214,7 @@
</div>
</el-card>
<el-card v-else class="page-content role-page__empty" shadow="never">
<el-card v-else class="page-content" shadow="never">
<el-empty description="请先在左侧选择角色" />
</el-card>
</div>
@@ -290,7 +304,7 @@ import {
} from "@element-plus/icons-vue";
import RoleAPI from "@/api/system/role";
import type { RoleForm, RoleItem, RoleQueryParams } from "@/api/system/role";
import type { RoleForm, RoleItem, RoleQuery } from "@/api/system/role";
import MenuAPI from "@/api/system/menu";
import type { MenuItem } from "@/api/system/menu";
import DeptAPI from "@/api/system/dept";
@@ -304,20 +318,6 @@ defineOptions({
inheritAttrs: false,
});
/** 权限表格行,按钮权限挂在所属菜单行的 buttons 上而不单独成行 */
interface PermRow {
/** 节点 ID */
id: string;
/** 节点名称 */
name: string;
/** 是否为目录分组行 */
group: boolean;
/** 层级,仅用于缩进 */
depth: number;
/** 行内展示的按钮权限 */
buttons: PermRow[];
}
// 左侧角色列表折叠状态
const sidebarCollapsed = ref(false);
@@ -337,7 +337,7 @@ const dataScopeOptions = [
];
// 左侧角色列表分页数据管理
const { loading, list, total, params, fetchData } = usePageTable<RoleItem, RoleQueryParams>({
const { loading, list, total, params, fetchData } = usePageTable<RoleItem, RoleQuery>({
initialParams: {
pageNum: 1,
pageSize: 20,
@@ -351,37 +351,25 @@ const currentRole = ref<RoleItem>();
// 当前选中角色是否为超级管理员:权限固定为全部,不允许编辑与分配
const isRootRole = computed(() => currentRole.value?.code === ROLE_ROOT);
// 权限表格数据与勾选状态
const permRows = ref<PermRow[]>([]);
const checkedIds = ref(new Set<string>());
const permLoading = ref(false);
const saving = ref(false);
const permKeywords = ref("");
/** 勾选模式:父子联动 / 节点独立 */
type CheckMode = "linked" | "independent";
const CHECK_MODE_OPTIONS: { value: CheckMode; label: string }[] = [
{ value: "linked", label: "父子联动" },
{ value: "independent", label: "节点独立" },
];
const checkMode = ref<CheckMode>("linked");
// 折叠的目录 ID,折叠后其下菜单行不展示
const collapsedIds = ref(new Set<string>());
// 节点祖先链,勾选子级时用于回填父级
const nodeParents = new Map<string, string[]>();
// 节点及其全部后代的 ID
const nodeFamilies = new Map<string, string[]>();
// 菜单树只请求一次,切换角色时复用
let menuTreeLoaded = false;
/**
* 权限表格行,按钮权限挂在所属菜单行的 buttons 上而不单独成行
*/
interface PermRow {
/** 节点 ID */
id: string;
/** 节点名称 */
name: string;
/** 是否为目录分组行 */
group: boolean;
/** 层级,仅用于缩进 */
depth: number;
/** 行内展示的按钮权限 */
buttons: PermRow[];
}
/**
* 整理菜单层级
* 后端返回的可能是树,也可能是扁平列表,扁平数据按 parentId 就地组装成树
* 后端返回的可能是树,也可能是扁平列表,扁平数据按 parentId 复制节点后组装成树
*
* @param list 菜单列表
* @returns 顶层菜单列表
@@ -406,57 +394,94 @@ function normalizeMenuTree(list: MenuItem[]): MenuItem[] {
}
/**
* 菜单树转权限表格行
* 目录节点占一行做分组标题,菜单节点占一行并把它下面的按钮权限摊到行内
*
* @param nodes 菜单节点列表
* @param depth 当前层级
* @param ancestors 祖先 ID 链
* @returns 权限表格行列表
* 一次返回行与索引;后代集合包含节点自身,重建不会保留旧索引。
*/
function buildPermRows(nodes: MenuItem[], depth = 0, ancestors: string[] = []): PermRow[] {
const rows: PermRow[] = [];
function buildPermissionModel(nodes: MenuItem[]) {
const ancestorsById = new Map<string, string[]>();
const descendantIdsById = new Map<string, string[]>();
nodes.forEach((node) => {
const id = String(node.id);
const children = node.children ?? [];
const subMenus = children.filter((child) => child.type !== "B");
const buttons: PermRow[] = children
.filter((child) => child.type === "B")
.map((child) => ({
id: String(child.id),
name: child.name ?? "",
group: false,
depth: depth + 1,
buttons: [],
}));
/**
* 按菜单层级构建权限表格行。
*/
function buildRows(nodes: MenuItem[], depth = 0, ancestors: string[] = []): PermRow[] {
const rows: PermRow[] = [];
rows.push({
id,
name: node.name ?? "",
group: subMenus.length > 0,
depth,
buttons,
});
buttons.forEach((btn) => {
nodeParents.set(btn.id, [...ancestors, id]);
nodeFamilies.set(btn.id, [btn.id]);
nodes.forEach((node) => {
const id = String(node.id);
const children = node.children ?? [];
const subMenus = children.filter((child) => child.type !== "B");
const buttons: PermRow[] = children
.filter((child) => child.type === "B")
.map((child) => ({
id: String(child.id),
name: child.name ?? "",
group: false,
depth: depth + 1,
buttons: [],
}));
rows.push({
id,
name: node.name ?? "",
group: subMenus.length > 0,
depth,
buttons,
});
buttons.forEach((btn) => {
ancestorsById.set(btn.id, [...ancestors, id]);
descendantIdsById.set(btn.id, [btn.id]);
});
const childRows = buildRows(subMenus, depth + 1, [...ancestors, id]);
rows.push(...childRows);
ancestorsById.set(id, ancestors);
descendantIdsById.set(id, [
...new Set([
id,
...buttons.map((btn) => btn.id),
...childRows.flatMap((row) => descendantIdsById.get(row.id) ?? []),
]),
]);
});
const childRows = buildPermRows(subMenus, depth + 1, [...ancestors, id]);
rows.push(...childRows);
return rows;
}
nodeParents.set(id, ancestors);
nodeFamilies.set(id, [
id,
...buttons.map((btn) => btn.id),
...childRows.flatMap((row) => nodeFamilies.get(row.id) ?? []),
]);
});
return rows;
const rows = buildRows(normalizeMenuTree(nodes));
return { rows, ancestorsById, descendantIdsById };
}
// 权限表格数据与勾选状态
const permRows = ref<PermRow[]>([]);
const checkedIds = ref(new Set<string>());
const permLoading = ref(false);
const saving = ref(false);
const permKeywords = ref("");
/**
* 勾选模式:父子联动 / 节点独立
*/
type CheckMode = "linked" | "independent";
const CHECK_MODE_OPTIONS: { value: CheckMode; label: string }[] = [
{ value: "linked", label: "父子联动" },
{ value: "independent", label: "节点独立" },
];
const checkMode = ref<CheckMode>("linked");
// 折叠的目录 ID,折叠后其下菜单行不展示
const collapsedIds = ref(new Set<string>());
// 节点祖先链,勾选子级时用于回填父级
let ancestorsById = new Map<string, string[]>();
// 节点及其全部后代的 ID
let descendantIdsById = new Map<string, string[]>();
// 菜单树只请求一次,切换角色时复用
let menuTreeLoaded = false;
/**
* 按关键字过滤权限行
* 分组内任一行命中即整组保留,避免只剩零散菜单丢失所属目录
@@ -553,7 +578,10 @@ async function loadMenuTree(): Promise<void> {
if (menuTreeLoaded) return;
const menuList = await MenuAPI.getList({});
permRows.value = buildPermRows(normalizeMenuTree(menuList));
const model = buildPermissionModel(menuList);
permRows.value = model.rows;
ancestorsById = model.ancestorsById;
descendantIdsById = model.descendantIdsById;
menuTreeLoaded = true;
}
@@ -648,9 +676,9 @@ function handleNodeCheck(nodeId: string, checked: boolean | string | number): vo
return;
}
const family = nodeFamilies.get(nodeId) ?? [nodeId];
const family = descendantIdsById.get(nodeId) ?? [nodeId];
if (isChecked) {
[...family, ...(nodeParents.get(nodeId) ?? [])].forEach((id) => checkedIds.value.add(id));
[...family, ...(ancestorsById.get(nodeId) ?? [])].forEach((id) => checkedIds.value.add(id));
} else {
family.forEach((id) => checkedIds.value.delete(id));
}
@@ -861,11 +889,6 @@ onMounted(() => {
--page-aside-width: 280px;
}
.role-page__empty {
align-items: center;
justify-content: center;
}
.role-list {
display: flex;
flex-direction: column;
@@ -879,6 +902,7 @@ onMounted(() => {
}
&__item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
@@ -894,6 +918,17 @@ onMounted(() => {
&.is-active {
background-color: var(--el-color-primary-light-9);
}
&.is-active::before {
position: absolute;
top: 6px;
bottom: 6px;
left: 0;
width: 2px;
content: "";
background-color: var(--el-color-primary);
border-radius: 1px;
}
}
&__info {
@@ -904,7 +939,16 @@ onMounted(() => {
line-height: 1.35;
}
&__title {
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
&__name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
@@ -913,6 +957,25 @@ onMounted(() => {
white-space: nowrap;
}
&__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
background-color: var(--el-color-success);
border-radius: 50%;
&.is-stopped {
background-color: var(--el-text-color-placeholder);
}
}
&__meta {
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
&__code {
overflow: hidden;
text-overflow: ellipsis;
@@ -921,10 +984,24 @@ onMounted(() => {
white-space: nowrap;
}
&__stopped {
flex-shrink: 0;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: var(--el-color-warning);
background-color: var(--el-color-warning-light-9);
border-radius: 3px;
}
&__item.is-active &__name {
color: var(--el-color-primary);
}
&__item.is-disabled:not(.is-active) &__name {
color: var(--el-text-color-secondary);
}
&__actions {
flex-shrink: 0;
margin-left: 4px;