完善组织树操作与空响应处理

This commit is contained in:
汤学会
2026-06-15 04:27:33 +08:00
parent c42d190f4b
commit 4c06295535
9 changed files with 701 additions and 19 deletions
@@ -42,3 +42,32 @@ describe("OrgPermissionTree employee list", () => {
assert.doesNotMatch(source, /\.action-row\s*\{[^}]*display:\s*flex;/);
});
});
describe("OrgPermissionTree node action parity", () => {
it("exposes a visible current-node action entry that emits the shared node context menu event", () => {
assert.match(source, /节点操作/);
assert.match(source, /@click\.stop="openSelectedNodeMenu"/);
assert.match(source, /function openSelectedNodeMenu\(event\)/);
assert.match(source, /emit\("node-contextmenu"/);
assert.doesNotMatch(source, /function runNodeAction\(/);
assert.doesNotMatch(source, /function nodeActions\(/);
});
it("keeps the original tree-node right-click path routed to the shared context menu event", () => {
assert.match(source, /@open-menu="openNodeMenu"/);
assert.match(source, /onContextmenu:\s*openMenu/);
assert.match(source, /function openMenu\(event\)[\s\S]*?branchEmit\("open-menu"/);
assert.match(source, /function openNodeMenu\(payload\)[\s\S]*?emit\("node-contextmenu",\s*payload\)/);
});
});
describe("OrgPermissionTree manager visibility", () => {
it("shows organization managers in both tree nodes and the selected-node header", () => {
assert.match(source, /class="node-manager-line"\s+:title="selectedNodeManagerText"/);
assert.match(source, /const selectedNodeManagerText = computed\(\(\) => selectedNode\.value \? nodeManagerText\(selectedNode\.value\) : "主管:未选择组织"\);/);
assert.match(source, /class:\s*"tree-node-manager",\s*title:\s*nodeManagerText\(branchProps\.node\)/);
assert.match(source, /function nodeManagerNames\(node\)/);
assert.match(source, /function nodeManagerText\(node\)/);
assert.match(source, /主管:未设置/);
});
});
+67 -9
View File
@@ -35,16 +35,27 @@
<div>
<p class="eyebrow">组织人员</p>
<h3>{{ selectedNodeLabel }}</h3>
<p class="node-manager-line" :title="selectedNodeManagerText">{{ selectedNodeManagerText }}</p>
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
</div>
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
<div class="employee-head-actions">
<button
class="ghost-button node-action-button"
type="button"
:disabled="!selectedNode"
@click.stop="openSelectedNodeMenu"
>
节点操作
</button>
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
</div>
</div>
<label class="employee-search-field">
@@ -206,7 +217,8 @@ const OrgTreeBranch = defineComponent({
h("span", { class: ["tree-node-icon", `tree-node-icon-${String(branchProps.node?.node_type || "ORG").toLowerCase()}`] }, nodeTypeShortLabel(branchProps.node?.node_type)),
h("span", { class: "tree-node-main" }, [
h("strong", null, nodeLabel(branchProps.node)),
h("small", null, `${nodeTypeLabel(branchProps.node?.node_type)} · ${employeeCount(branchProps.node)} 人`)
h("small", null, `${nodeTypeLabel(branchProps.node?.node_type)} · ${employeeCount(branchProps.node)} 人`),
h("small", { class: "tree-node-manager", title: nodeManagerText(branchProps.node) }, nodeManagerText(branchProps.node))
])
]
),
@@ -245,6 +257,7 @@ const requestedNode = computed(() => findNodeById(organizationNodes.value, props
const selectedNode = computed(() => requestedNode.value || firstRootNode.value);
const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? "");
const selectedNodeLabel = computed(() => selectedNode.value ? nodeLabel(selectedNode.value) : "未选择组织");
const selectedNodeManagerText = computed(() => selectedNode.value ? nodeManagerText(selectedNode.value) : "主管:未选择组织");
const selectedEmployees = computed(() => collectOrganizationEmployees(selectedNode.value));
const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type));
const filteredEmployees = computed(() => {
@@ -374,6 +387,21 @@ function nodeTypeShortLabel(type) {
}[type] || "组";
}
function nodeManagerNames(node) {
if (Array.isArray(node?.manager_names) && node.manager_names.length) {
return node.manager_names.filter(Boolean);
}
if (node?.manager_name) {
return [node.manager_name];
}
return [];
}
function nodeManagerText(node) {
const names = nodeManagerNames(node);
return names.length ? `主管:${names.join("、")}` : "主管:未设置";
}
function employeeCount(node) {
return countOrganizationEmployees(node);
}
@@ -397,6 +425,18 @@ function openNodeMenu(payload) {
emit("node-contextmenu", payload);
}
function openSelectedNodeMenu(event) {
if (!selectedNode.value) {
return;
}
const rect = event.currentTarget?.getBoundingClientRect?.();
emit("node-contextmenu", {
node: selectedNode.value,
x: rect ? rect.left : event.clientX,
y: rect ? rect.bottom + 6 : event.clientY
});
}
function employeeName(employee) {
return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员";
}
@@ -510,6 +550,24 @@ function removeEmployee(employee) {
align-items: center;
}
.employee-head-actions {
display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.node-action-button {
border: 1px solid rgba(148, 163, 184, 0.5);
background: #ffffff;
color: #334155;
}
.node-action-button:hover:not(:disabled) {
background: #eff6ff;
color: #1d4ed8;
}
.eyebrow {
margin: 0 0 6px;
color: #2563eb;
+5 -3
View File
@@ -1,10 +1,12 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api";
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "http://localhost:8000/api";
const SESSION_KEY = "forgeflow.erp.session";
async function parseResponse(response) {
export async function parseResponse(response) {
const contentType = response.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const data = isJson ? await response.json() : await response.text();
const hasEmptyBody = response.status === 204 || response.status === 205;
const rawText = hasEmptyBody ? "" : await response.text();
const data = isJson && rawText.trim() ? JSON.parse(rawText) : rawText || null;
if (!response.ok) {
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;
+39
View File
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseResponse } from "./api.js";
describe("api parseResponse", () => {
it("treats no-content JSON responses as empty success instead of parsing an empty body", async () => {
const response = new Response(null, {
status: 204,
headers: {
"content-type": "application/json"
}
});
assert.equal(await parseResponse(response), null);
});
it("parses normal JSON response bodies", async () => {
const response = new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"content-type": "application/json"
}
});
assert.deepEqual(await parseResponse(response), { ok: true });
});
it("keeps backend error details when JSON error responses include detail", async () => {
const response = new Response(JSON.stringify({ detail: "组织节点不存在" }), {
status: 404,
headers: {
"content-type": "application/json"
}
});
await assert.rejects(() => parseResponse(response), /组织节点不存在/);
});
});
@@ -71,3 +71,30 @@ describe("SystemPermissionView authorize drawer", () => {
assert.match(authorizeSource, /:disabled="!authorizeForm\.role_ids\.length"\s+@click="submitAuthorizeUser"/);
});
});
describe("SystemPermissionView organization view action parity", () => {
it("routes both mind map and tree node actions through the same context menu action pipeline", () => {
assert.match(source, /<OrgMindMap[\s\S]*?@node-contextmenu="openContextMenu"/);
assert.match(source, /<OrgPermissionTree[\s\S]*?@node-contextmenu="openContextMenu"/);
assert.match(source, /const contextMenuActions = computed\(\(\) => \(contextMenu\.node \? nodeActions\(contextMenu\.node\) : \[\]\)\);/);
assert.match(source, /function runNodeAction\(actionKey, node\)/);
assert.match(source, /openOrgRenameDrawer\(node\)/);
assert.match(source, /openOrgManagerDrawer\(node\)/);
assert.match(source, /openOrgCreateDrawer\(node,\s*"DEPARTMENT"\)/);
assert.match(source, /openOrgEmployeeDrawer\(node\)/);
});
it("allows company nodes to create both branches and direct departments", () => {
assert.match(source, /node\.node_type === "COMPANY"[\s\S]*?actions\.push\(\{ key: "addBranch", label: "新增分公司节点" \}\);[\s\S]*?actions\.push\(\{ key: "addDepartment", label: "新增直属部门" \}\);/);
assert.match(source, /actionKey === "addDepartment"[\s\S]*?openOrgCreateDrawer\(node,\s*"DEPARTMENT"\)/);
});
it("keeps the shared context menu inside the current viewport", () => {
assert.match(source, /const menuWidth = 220;/);
assert.match(source, /const menuHeight = 280;/);
assert.match(source, /const viewportWidth = window\.innerWidth \|\| 1280;/);
assert.match(source, /const viewportHeight = window\.innerHeight \|\| 720;/);
assert.match(source, /contextMenu\.x = Math\.max\(12,\s*Math\.min\(Number\(eventPayload\.x \|\| 12\),\s*viewportWidth - menuWidth - 12\)\);/);
assert.match(source, /contextMenu\.y = Math\.max\(12,\s*Math\.min\(Number\(eventPayload\.y \|\| 12\),\s*viewportHeight - menuHeight - 12\)\);/);
});
});
+7 -2
View File
@@ -715,6 +715,7 @@ function nodeActions(node) {
];
if (node.node_type === "COMPANY") {
actions.push({ key: "addBranch", label: "新增分公司节点" });
actions.push({ key: "addDepartment", label: "新增直属部门" });
}
if (node.node_type === "BRANCH") {
actions.push({ key: "addDepartment", label: "新增部门节点" });
@@ -734,9 +735,13 @@ function nodeActions(node) {
function openContextMenu(eventPayload) {
selectedNode.value = eventPayload.node;
const menuWidth = 220;
const menuHeight = 280;
const viewportWidth = window.innerWidth || 1280;
const viewportHeight = window.innerHeight || 720;
contextMenu.visible = true;
contextMenu.x = eventPayload.x;
contextMenu.y = eventPayload.y;
contextMenu.x = Math.max(12, Math.min(Number(eventPayload.x || 12), viewportWidth - menuWidth - 12));
contextMenu.y = Math.max(12, Math.min(Number(eventPayload.y || 12), viewportHeight - menuHeight - 12));
contextMenu.node = eventPayload.node;
}