完善组织树操作与空响应处理
This commit is contained in:
parent
9b60aa3e0d
commit
867fc3beee
@ -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": {},
|
||||
|
||||
@ -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
|
||||
|
||||
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;/);
|
||||
});
|
||||
});
|
||||
|
||||
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>
|
||||
<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;
|
||||
|
||||
@ -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
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"/);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user