完善组织树操作与空响应处理
This commit is contained in:
@@ -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, /主管:未设置/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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\)\);/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user