完善组织树操作与空响应处理
This commit is contained in:
parent
9b60aa3e0d
commit
867fc3beee
@ -85,7 +85,10 @@ MENU_PERMISSION_TREE = [
|
|||||||
ORG_ROOT_CODE = "ORG_ROOT"
|
ORG_ROOT_CODE = "ORG_ROOT"
|
||||||
ORG_ROOT_NAME = "总公司"
|
ORG_ROOT_NAME = "总公司"
|
||||||
VALID_CHILD_NODE_TYPES = {
|
VALID_CHILD_NODE_TYPES = {
|
||||||
"COMPANY": {"BRANCH": "总公司下只能新增分公司"},
|
"COMPANY": {
|
||||||
|
"BRANCH": "总公司下只能新增分公司或直属部门",
|
||||||
|
"DEPARTMENT": "总公司下只能新增分公司或直属部门",
|
||||||
|
},
|
||||||
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
|
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
|
||||||
"DEPARTMENT": {"GROUP": "部门下只能新增小组"},
|
"DEPARTMENT": {"GROUP": "部门下只能新增小组"},
|
||||||
"GROUP": {},
|
"GROUP": {},
|
||||||
|
|||||||
@ -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].parent_id, self.company.id)
|
||||||
self.assertEqual(tree[0].children[0].node_type, "BRANCH")
|
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:
|
def test_create_org_node_rejects_invalid_child_level(self) -> None:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from app.schemas.system_permissions import OrgNodeCreate
|
from app.schemas.system_permissions import OrgNodeCreate
|
||||||
@ -408,9 +434,9 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
|||||||
self.db,
|
self.db,
|
||||||
OrgNodeCreate(
|
OrgNodeCreate(
|
||||||
parent_id=self.company.id,
|
parent_id=self.company.id,
|
||||||
node_type="DEPARTMENT",
|
node_type="GROUP",
|
||||||
node_label="错误部门",
|
node_label="错误小组",
|
||||||
dept_code="BAD-DEPT",
|
dept_code="BAD-GROUP",
|
||||||
dept_type="ADMIN",
|
dept_type="ADMIN",
|
||||||
manager_employee_id=None,
|
manager_employee_id=None,
|
||||||
sort_no=1,
|
sort_no=1,
|
||||||
@ -419,7 +445,7 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(ctx.exception.status_code, 400)
|
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:
|
def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|||||||
493
docs/superpowers/plans/2026-06-15-org-tree-action-parity.md
Normal file
493
docs/superpowers/plans/2026-06-15-org-tree-action-parity.md
Normal 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`。
|
||||||
|
|
||||||
@ -42,3 +42,32 @@ describe("OrgPermissionTree employee list", () => {
|
|||||||
assert.doesNotMatch(source, /\.action-row\s*\{[^}]*display:\s*flex;/);
|
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,8 +35,18 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">组织人员</p>
|
<p class="eyebrow">组织人员</p>
|
||||||
<h3>{{ selectedNodeLabel }}</h3>
|
<h3>{{ selectedNodeLabel }}</h3>
|
||||||
|
<p class="node-manager-line" :title="selectedNodeManagerText">{{ selectedNodeManagerText }}</p>
|
||||||
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
|
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="employee-head-actions">
|
||||||
|
<button
|
||||||
|
class="ghost-button node-action-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!selectedNode"
|
||||||
|
@click.stop="openSelectedNodeMenu"
|
||||||
|
>
|
||||||
|
节点操作
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="primary-button"
|
class="primary-button"
|
||||||
type="button"
|
type="button"
|
||||||
@ -46,6 +56,7 @@
|
|||||||
新增人员
|
新增人员
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="employee-search-field">
|
<label class="employee-search-field">
|
||||||
<span>搜索</span>
|
<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-icon", `tree-node-icon-${String(branchProps.node?.node_type || "ORG").toLowerCase()}`] }, nodeTypeShortLabel(branchProps.node?.node_type)),
|
||||||
h("span", { class: "tree-node-main" }, [
|
h("span", { class: "tree-node-main" }, [
|
||||||
h("strong", null, nodeLabel(branchProps.node)),
|
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 selectedNode = computed(() => requestedNode.value || firstRootNode.value);
|
||||||
const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? "");
|
const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? "");
|
||||||
const selectedNodeLabel = computed(() => selectedNode.value ? nodeLabel(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 selectedEmployees = computed(() => collectOrganizationEmployees(selectedNode.value));
|
||||||
const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type));
|
const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type));
|
||||||
const filteredEmployees = computed(() => {
|
const filteredEmployees = computed(() => {
|
||||||
@ -374,6 +387,21 @@ function nodeTypeShortLabel(type) {
|
|||||||
}[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) {
|
function employeeCount(node) {
|
||||||
return countOrganizationEmployees(node);
|
return countOrganizationEmployees(node);
|
||||||
}
|
}
|
||||||
@ -397,6 +425,18 @@ function openNodeMenu(payload) {
|
|||||||
emit("node-contextmenu", 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) {
|
function employeeName(employee) {
|
||||||
return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员";
|
return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员";
|
||||||
}
|
}
|
||||||
@ -510,6 +550,24 @@ function removeEmployee(employee) {
|
|||||||
align-items: center;
|
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 {
|
.eyebrow {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
color: #2563eb;
|
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";
|
const SESSION_KEY = "forgeflow.erp.session";
|
||||||
|
|
||||||
async function parseResponse(response) {
|
export async function parseResponse(response) {
|
||||||
const contentType = response.headers.get("content-type") || "";
|
const contentType = response.headers.get("content-type") || "";
|
||||||
const isJson = contentType.includes("application/json");
|
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) {
|
if (!response.ok) {
|
||||||
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;
|
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;
|
||||||
|
|||||||
39
frontend/src/services/api.test.js
Normal file
39
frontend/src/services/api.test.js
Normal 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"/);
|
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") {
|
if (node.node_type === "COMPANY") {
|
||||||
actions.push({ key: "addBranch", label: "新增分公司节点" });
|
actions.push({ key: "addBranch", label: "新增分公司节点" });
|
||||||
|
actions.push({ key: "addDepartment", label: "新增直属部门" });
|
||||||
}
|
}
|
||||||
if (node.node_type === "BRANCH") {
|
if (node.node_type === "BRANCH") {
|
||||||
actions.push({ key: "addDepartment", label: "新增部门节点" });
|
actions.push({ key: "addDepartment", label: "新增部门节点" });
|
||||||
@ -734,9 +735,13 @@ function nodeActions(node) {
|
|||||||
|
|
||||||
function openContextMenu(eventPayload) {
|
function openContextMenu(eventPayload) {
|
||||||
selectedNode.value = eventPayload.node;
|
selectedNode.value = eventPayload.node;
|
||||||
|
const menuWidth = 220;
|
||||||
|
const menuHeight = 280;
|
||||||
|
const viewportWidth = window.innerWidth || 1280;
|
||||||
|
const viewportHeight = window.innerHeight || 720;
|
||||||
contextMenu.visible = true;
|
contextMenu.visible = true;
|
||||||
contextMenu.x = eventPayload.x;
|
contextMenu.x = Math.max(12, Math.min(Number(eventPayload.x || 12), viewportWidth - menuWidth - 12));
|
||||||
contextMenu.y = eventPayload.y;
|
contextMenu.y = Math.max(12, Math.min(Number(eventPayload.y || 12), viewportHeight - menuHeight - 12));
|
||||||
contextMenu.node = eventPayload.node;
|
contextMenu.node = eventPayload.node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user