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

This commit is contained in:
汤学会 2026-06-15 04:24:46 +08:00
parent 9b60aa3e0d
commit 867fc3beee
9 changed files with 701 additions and 19 deletions

View File

@ -85,7 +85,10 @@ MENU_PERMISSION_TREE = [
ORG_ROOT_CODE = "ORG_ROOT"
ORG_ROOT_NAME = "总公司"
VALID_CHILD_NODE_TYPES = {
"COMPANY": {"BRANCH": "总公司下只能新增分公司"},
"COMPANY": {
"BRANCH": "总公司下只能新增分公司或直属部门",
"DEPARTMENT": "总公司下只能新增分公司或直属部门",
},
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
"DEPARTMENT": {"GROUP": "部门下只能新增小组"},
"GROUP": {},

View File

@ -398,6 +398,32 @@ class SystemPermissionManagementTest(unittest.TestCase):
self.assertEqual(tree[0].children[0].parent_id, self.company.id)
self.assertEqual(tree[0].children[0].node_type, "BRANCH")
def test_create_org_node_allows_company_direct_department(self) -> None:
from app.schemas.system_permissions import OrgNodeCreate
from app.services.system_permissions import create_org_node, build_org_tree
direct_department = create_org_node(
self.db,
OrgNodeCreate(
parent_id=self.company.id,
node_type="DEPARTMENT",
node_label="总公司直属部门",
dept_code="DIRECT-DEPT",
dept_type="ADMIN",
manager_employee_id=None,
sort_no=1,
remark=None,
status="ACTIVE",
),
)
tree = build_org_tree(self.db)
self.assertEqual(direct_department.parent_id, self.company.id)
self.assertEqual(direct_department.node_type, "DEPARTMENT")
self.assertTrue(
any(child.node_label == "总公司直属部门" and child.node_type == "DEPARTMENT" for child in tree[0].children)
)
def test_create_org_node_rejects_invalid_child_level(self) -> None:
from fastapi import HTTPException
from app.schemas.system_permissions import OrgNodeCreate
@ -408,9 +434,9 @@ class SystemPermissionManagementTest(unittest.TestCase):
self.db,
OrgNodeCreate(
parent_id=self.company.id,
node_type="DEPARTMENT",
node_label="错误部门",
dept_code="BAD-DEPT",
node_type="GROUP",
node_label="错误小组",
dept_code="BAD-GROUP",
dept_type="ADMIN",
manager_employee_id=None,
sort_no=1,
@ -419,7 +445,7 @@ class SystemPermissionManagementTest(unittest.TestCase):
),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("总公司下只能新增分公司", str(ctx.exception.detail))
self.assertIn("总公司下只能新增分公司或直属部门", str(ctx.exception.detail))
def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None:
from fastapi import HTTPException

View File

@ -0,0 +1,493 @@
# 组织树与脑图操作能力一致 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让“组织树模式”和“脑图模式”只是展示形式不同,组织节点和人员节点的操作能力完全一致。
**Architecture:** 保留 `SystemPermissionView.vue` 作为唯一组织动作编排层,继续复用现有 `nodeActions(node)`、`runNodeAction(actionKey, node)`、各类 drawer 打开函数和删除/授权函数。`OrgPermissionTree.vue` 只负责展示组织树、选中节点、发出节点菜单事件,不新增第二套业务动作逻辑。
**Tech Stack:** Vue 3 `<script setup>`、现有静态 Node test、Vite、现有 `OrgPermissionTree.vue` / `SystemPermissionView.vue` 组件体系。
---
## File Structure
- Modify: `frontend/src/components/OrgPermissionTree.vue`
- 增加右侧“节点操作”入口。
- 继续支持组织树节点右键菜单。
- 通过 `node-contextmenu` emit 当前节点和菜单坐标,不直接执行业务动作。
- 调整右侧 header 按钮布局,让“节点操作”和“新增人员”共存。
- Modify: `frontend/src/components/OrgPermissionTree.test.js`
- 锁定组织树存在“节点操作”按钮。
- 锁定组织树节点右键仍然 emit `open-menu`
- 锁定组织树不新增第二套动作函数,只 emit `node-contextmenu`
- Modify: `frontend/src/views/SystemPermissionView.vue`
- 复用现有 `openContextMenu(eventPayload)``runNodeAction(actionKey, node)`
- 必要时给当前节点操作入口提供更稳定的菜单坐标处理。
- 不复制 `nodeActions` 到子组件。
- Modify: `frontend/src/views/SystemPermissionView.test.js`
- 锁定组织树仍绑定 `@node-contextmenu="openContextMenu"`
- 锁定组织树的“节点操作”最终仍进入同一套 `contextMenuActions` / `runNodeAction`
- Modify: `frontend/src/styles/main.css` if needed
- 如果组件 scoped 样式不够覆盖全局表格/抽屉样式,补少量全局样式。
- 不引入新的视觉体系。
---
## Task 1: 锁定组织树动作入口契约
**Files:**
- Modify: `frontend/src/components/OrgPermissionTree.test.js`
- [ ] **Step 1: 写失败测试,要求组织树有显式节点操作入口**
`OrgPermissionTree.test.js` 末尾追加:
```js
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="openSelectedNodeMenu"/);
assert.match(source, /function openSelectedNodeMenu\(event\)/);
assert.match(source, /emit\("node-contextmenu"/);
assert.doesNotMatch(source, /function runNodeAction\(/);
assert.doesNotMatch(source, /function nodeActions\(/);
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/components/OrgPermissionTree.test.js
```
Expected:
```text
not ok ... exposes a visible current-node action entry ...
```
失败原因应是当前源码没有 `节点操作`、`openSelectedNodeMenu`。
- [ ] **Step 3: 不修改生产代码,先提交测试是不需要的**
此任务只建立红灯,不单独提交。
---
## Task 2: 在组织树右侧 header 增加“节点操作”入口
**Files:**
- Modify: `frontend/src/components/OrgPermissionTree.vue`
- [ ] **Step 1: 修改右侧 header 按钮区**
将当前右侧 header 中的单个“新增人员”按钮:
```vue
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
```
替换为:
```vue
<div class="employee-head-actions">
<button
class="ghost-button node-action-button"
type="button"
:disabled="!selectedNode"
@click="openSelectedNodeMenu"
>
节点操作
</button>
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
</div>
```
- [ ] **Step 2: 添加 `openSelectedNodeMenu` 函数**
`openNodeMenu(payload)` 下方添加:
```js
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
});
}
```
说明:这里仍然只 emit `node-contextmenu`,由父组件现有 `openContextMenu` 接管菜单,不在子组件里实现动作。
- [ ] **Step 3: 添加 scoped 样式**
`.employee-head` 附近增加:
```css
.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;
}
```
- [ ] **Step 4: 运行组件测试确认 Task 1 变绿**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/components/OrgPermissionTree.test.js
```
Expected:
```text
# pass ...
```
---
## Task 3: 锁定父页面复用同一套节点菜单
**Files:**
- Modify: `frontend/src/views/SystemPermissionView.test.js`
- [ ] **Step 1: 写测试确认组织树和脑图共用 `openContextMenu`**
`SystemPermissionView.test.js` 末尾追加:
```js
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\)/);
});
});
```
- [ ] **Step 2: 运行测试**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/views/SystemPermissionView.test.js
```
Expected:
```text
# pass ...
```
如果失败,说明当前源码实际函数名或字符串不同;不要新增第二套函数,先核对现有 `nodeActions``runNodeAction`,再调整测试为真实函数名。
---
## Task 4: 优化上下文菜单打开位置与组织树使用体验
**Files:**
- Modify: `frontend/src/views/SystemPermissionView.vue`
- [ ] **Step 1: 检查现有 `openContextMenu`**
确认当前函数类似:
```js
function openContextMenu(eventPayload) {
selectedNode.value = eventPayload.node;
contextMenu.visible = true;
contextMenu.x = eventPayload.x;
contextMenu.y = eventPayload.y;
contextMenu.node = eventPayload.node;
}
```
- [ ] **Step 2: 改为带视口保护的位置计算**
`openContextMenu` 替换为:
```js
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 = 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;
}
```
说明:组织树右侧“节点操作”按钮会把菜单开在按钮下方;如果靠右或靠下,菜单不应跑出屏幕。
- [ ] **Step 3: 运行父页面测试**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/views/SystemPermissionView.test.js
```
Expected:
```text
# pass ...
```
---
## Task 5: 手动浏览器验收
**Files:**
- No code changes.
- [ ] **Step 1: 打开系统权限管理页面**
Use in-app Browser:
```text
http://localhost:5173/system-permissions
```
- [ ] **Step 2: 验收组织树模式下组织节点操作**
操作:
1. 切换到“组织树模式”。
2. 点击左侧“总公司”节点。
3. 点击右侧“节点操作”。
Expected:
```text
菜单出现,包含:重命名、添加该节点的主管、新增分公司节点。
```
- [ ] **Step 3: 验收分公司节点操作**
操作:
1. 点击“旭升车间”或“嘉恒车间”。
2. 点击“节点操作”。
Expected:
```text
菜单出现,包含:重命名、添加该节点的主管、新增部门节点、删除节点。
```
- [ ] **Step 4: 验收部门节点操作**
操作:
1. 点击“旭升加工部”或“嘉恒生产部”。
2. 点击“节点操作”。
Expected:
```text
菜单出现,包含:重命名、添加该节点的主管、新增小组节点、新增人员、删除节点。
```
- [ ] **Step 5: 验收右键入口**
操作:
1. 在组织树左侧任意组织节点上右键。
Expected:
```text
弹出的菜单内容和右侧“节点操作”按钮一致。
```
- [ ] **Step 6: 验收人员操作仍正常**
操作:
1. 在右侧人员列表点击“人员授权”。
2. 确认弹窗仍为卡片式角色授权界面。
3. 关闭弹窗。
Expected:
```text
人员授权弹窗打开正常,不受节点操作入口影响。
```
---
## Task 6: 回归验证与提交
**Files:**
- All modified files from earlier tasks.
- [ ] **Step 1: 运行关键测试**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/components/OrgPermissionTree.test.js
node src/views/SystemPermissionView.test.js
node src/utils/orgTreeEmployees.test.js
node src/App.test.js
```
Expected:
```text
# fail 0
```
- [ ] **Step 2: 运行构建**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
允许出现当前项目既有的大 chunk warning不作为阻断。
- [ ] **Step 3: 检查 diff**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git diff --stat
git diff -- frontend/src/components/OrgPermissionTree.vue frontend/src/components/OrgPermissionTree.test.js frontend/src/views/SystemPermissionView.vue frontend/src/views/SystemPermissionView.test.js
```
Expected:
```text
只包含组织树动作入口、菜单位置保护、相关测试和少量样式。
```
- [ ] **Step 4: 等用户确认后提交 main**
Run only after user confirms:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add frontend/src/components/OrgPermissionTree.vue frontend/src/components/OrgPermissionTree.test.js frontend/src/views/SystemPermissionView.vue frontend/src/views/SystemPermissionView.test.js
git commit -m "补齐组织树节点操作能力"
git push origin main
```
Expected:
```text
main -> main
```
- [ ] **Step 5: 同步 BH_DEV**
Run only after main push succeeds:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git switch BH_DEV
git pull --ff-only origin BH_DEV
git cherry-pick <main_commit_hash>
```
Expected:
```text
[BH_DEV <hash>] 补齐组织树节点操作能力
```
- [ ] **Step 6: 在 BH_DEV 运行同样验证并推送**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node src/components/OrgPermissionTree.test.js
node src/views/SystemPermissionView.test.js
node src/utils/orgTreeEmployees.test.js
node src/App.test.js
npm run build
cd ..
git push origin BH_DEV
```
Expected:
```text
BH_DEV -> BH_DEV
```
---
## Self-Review
**Spec coverage:**
已覆盖“组织树和脑图只是展示不同,功能一致”的核心要求。组织树右键入口和显式“节点操作”入口都会进入现有 `openContextMenu`,菜单项仍由 `nodeActions` 根据节点类型动态决定。
**Placeholder scan:**
未使用 TBD/TODO/后续补充。每个任务都有具体文件、代码片段、命令和预期结果。
**Type consistency:**
计划中使用的函数名与当前代码一致:`openContextMenu`、`nodeActions`、`runNodeAction`、`openOrgCreateDrawer`、`openOrgRenameDrawer`、`openOrgManagerDrawer`、`openOrgEmployeeDrawer`、`openAuthorizeDrawer`、`removeOrgEmployee`、`removeOrgNode`。

View File

@ -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, /主管:未设置/);
});
});

View File

@ -35,8 +35,18 @@
<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>
<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"
@ -46,6 +56,7 @@
新增人员
</button>
</div>
</div>
<label class="employee-search-field">
<span>搜索</span>
@ -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;

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}`;

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), /组织节点不存在/);
});
});

View File

@ -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\)\);/);
});
});

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;
}