fix: improve org tree hierarchy and user menu dismissal

This commit is contained in:
汤学会 2026-06-15 01:32:24 +08:00
parent 5ec8a98125
commit eb0f2eff95
4 changed files with 114 additions and 2 deletions

View File

@ -19,3 +19,13 @@ describe("App reset password account picker", () => {
assert.match(source, /formatResetPasswordUserLabel\(user\)/); assert.match(source, /formatResetPasswordUserLabel\(user\)/);
}); });
}); });
describe("App user menu interactions", () => {
it("closes the avatar menu when the user clicks outside the menu shell", () => {
assert.match(source, /ref="userMenuShellRef"/);
assert.match(source, /function handleUserMenuOutsidePointerDown\(event\)/);
assert.match(source, /userMenuShellRef\.value\?\.contains\(event\.target\)/);
assert.match(source, /document\.addEventListener\("pointerdown", handleUserMenuOutsidePointerDown\)/);
assert.match(source, /document\.removeEventListener\("pointerdown", handleUserMenuOutsidePointerDown\)/);
});
});

View File

@ -110,7 +110,7 @@
<span class="assistant-bot-eye"></span> <span class="assistant-bot-eye"></span>
</span> </span>
</button> </button>
<div class="user-menu-shell"> <div ref="userMenuShellRef" class="user-menu-shell">
<button class="user-avatar-button" type="button" @click="toggleUserMenu"> <button class="user-avatar-button" type="button" @click="toggleUserMenu">
<span class="user-avatar">{{ avatarInitial }}</span> <span class="user-avatar">{{ avatarInitial }}</span>
<span class="user-avatar-meta"> <span class="user-avatar-meta">
@ -307,6 +307,7 @@ const activeBroadcasts = ref([]);
const activeBroadcastIndex = ref(0); const activeBroadcastIndex = ref(0);
const assistantDrawerOpen = ref(false); const assistantDrawerOpen = ref(false);
const userMenuOpen = ref(false); const userMenuOpen = ref(false);
const userMenuShellRef = ref(null);
const changePasswordOpen = ref(false); const changePasswordOpen = ref(false);
const resetPasswordOpen = ref(false); const resetPasswordOpen = ref(false);
const passwordSubmitting = ref(false); const passwordSubmitting = ref(false);
@ -1011,9 +1012,20 @@ function handleTagContextAction(action) {
function handleGlobalKeydown(event) { function handleGlobalKeydown(event) {
if (event.key === "Escape") { if (event.key === "Escape") {
closeTagContextMenu(); closeTagContextMenu();
userMenuOpen.value = false;
} }
} }
function handleUserMenuOutsidePointerDown(event) {
if (!userMenuOpen.value) {
return;
}
if (userMenuShellRef.value?.contains(event.target)) {
return;
}
userMenuOpen.value = false;
}
function updateShellViewportState() { function updateShellViewportState() {
narrowShell.value = window.innerWidth < 1024; narrowShell.value = window.innerWidth < 1024;
if (narrowShell.value) { if (narrowShell.value) {
@ -1040,6 +1052,7 @@ onMounted(() => {
window.addEventListener("resize", updateShellViewportState); window.addEventListener("resize", updateShellViewportState);
window.addEventListener("click", closeTagContextMenu); window.addEventListener("click", closeTagContextMenu);
window.addEventListener("keydown", handleGlobalKeydown); window.addEventListener("keydown", handleGlobalKeydown);
document.addEventListener("pointerdown", handleUserMenuOutsidePointerDown);
void loadActiveBroadcast(); void loadActiveBroadcast();
void loadSmartOperationFeature(); void loadSmartOperationFeature();
broadcastTimer = window.setInterval(() => { broadcastTimer = window.setInterval(() => {
@ -1054,6 +1067,7 @@ onUnmounted(() => {
window.removeEventListener("resize", updateShellViewportState); window.removeEventListener("resize", updateShellViewportState);
window.removeEventListener("click", closeTagContextMenu); window.removeEventListener("click", closeTagContextMenu);
window.removeEventListener("keydown", handleGlobalKeydown); window.removeEventListener("keydown", handleGlobalKeydown);
document.removeEventListener("pointerdown", handleUserMenuOutsidePointerDown);
if (broadcastTimer) { if (broadcastTimer) {
window.clearInterval(broadcastTimer); window.clearInterval(broadcastTimer);
broadcastTimer = null; broadcastTimer = null;

View File

@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "OrgPermissionTree.vue"), "utf8");
describe("OrgPermissionTree hierarchy lines", () => {
it("draws connector lines so parent-child hierarchy is easier to read", () => {
assert.match(source, /\.org-tree-branch::before/);
assert.match(source, /\.org-tree-node::before/);
assert.match(source, /--tree-connector-x/);
assert.match(source, /--tree-connector-color/);
});
});

View File

@ -164,7 +164,10 @@ const OrgTreeBranch = defineComponent({
} }
return () => return () =>
h("div", { class: "org-tree-branch" }, [ h("div", {
class: ["org-tree-branch", { "org-tree-branch-root": branchProps.level === 0 }],
style: { "--tree-level": branchProps.level }
}, [
h( h(
"button", "button",
{ {
@ -567,6 +570,8 @@ function removeEmployee(employee) {
} }
.org-tree-list { .org-tree-list {
--tree-connector-color: rgba(64, 158, 255, 0.34);
--tree-connector-x: calc(15px + var(--tree-level, 0) * 22px);
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@ -579,12 +584,34 @@ function removeEmployee(employee) {
} }
.org-tree-branch { .org-tree-branch {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.org-tree-branch::before {
content: "";
position: absolute;
top: -2px;
bottom: -2px;
left: var(--tree-connector-x);
width: 1px;
background: linear-gradient(180deg, transparent, var(--tree-connector-color) 12px, var(--tree-connector-color) calc(100% - 12px), transparent);
pointer-events: none;
}
.org-tree-branch-root::before {
content: none;
}
.org-tree-branch:not(.org-tree-branch-root):last-child::before {
bottom: calc(100% - 21px);
background: var(--tree-connector-color);
}
.org-tree-node { .org-tree-node {
position: relative;
display: grid; display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr); grid-template-columns: 18px 24px minmax(0, 1fr);
align-items: center; align-items: center;
@ -600,6 +627,17 @@ function removeEmployee(employee) {
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease; transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
} }
.org-tree-branch:not(.org-tree-branch-root) > .org-tree-node::before {
content: "";
position: absolute;
top: 50%;
left: var(--tree-connector-x);
width: 14px;
height: 1px;
background: var(--tree-connector-color);
pointer-events: none;
}
.org-tree-node:hover, .org-tree-node:hover,
.org-tree-node.active { .org-tree-node.active {
border-color: transparent; border-color: transparent;
@ -750,12 +788,34 @@ function removeEmployee(employee) {
} }
:deep(.org-tree-branch) { :deep(.org-tree-branch) {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
:deep(.org-tree-branch::before) {
content: "";
position: absolute;
top: -2px;
bottom: -2px;
left: var(--tree-connector-x);
width: 1px;
background: linear-gradient(180deg, transparent, var(--tree-connector-color) 12px, var(--tree-connector-color) calc(100% - 12px), transparent);
pointer-events: none;
}
:deep(.org-tree-branch-root::before) {
content: none;
}
:deep(.org-tree-branch:not(.org-tree-branch-root):last-child::before) {
bottom: calc(100% - 21px);
background: var(--tree-connector-color);
}
:deep(.org-tree-node) { :deep(.org-tree-node) {
position: relative;
display: grid; display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr); grid-template-columns: 18px 24px minmax(0, 1fr);
align-items: center; align-items: center;
@ -772,6 +832,17 @@ function removeEmployee(employee) {
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease; transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
} }
:deep(.org-tree-branch:not(.org-tree-branch-root) > .org-tree-node::before) {
content: "";
position: absolute;
top: 50%;
left: var(--tree-connector-x);
width: 14px;
height: 1px;
background: var(--tree-connector-color);
pointer-events: none;
}
:deep(.org-tree-node:hover), :deep(.org-tree-node:hover),
:deep(.org-tree-node.active) { :deep(.org-tree-node.active) {
border-color: transparent; border-color: transparent;