Compare commits

...

11 Commits
BH_DEV ... main

Author SHA1 Message Date
汤学会
a5dbb5fa70 优化纸质单据抬头日期间距 2026-06-15 14:52:51 +08:00
汤学会
705ecf1e5a 修复纸质单据布局并统一退货单据样式 2026-06-15 14:26:30 +08:00
汤学会
b024b061e9 修复产品需规保存与表格布局问题 2026-06-15 10:52:23 +08:00
汤学会
867fc3beee 完善组织树操作与空响应处理 2026-06-15 04:24:46 +08:00
汤学会
9b60aa3e0d 优化组织人员路径与授权弹窗 2026-06-15 03:27:51 +08:00
汤学会
30fb7af159 修复组织树上级节点人员汇总 2026-06-15 02:39:36 +08:00
汤学会
6d87a2e1cd 优化组织树灰色虚线连接 2026-06-15 02:21:01 +08:00
汤学会
707db3a482 修复组织树连接线层级关系 2026-06-15 01:58:29 +08:00
汤学会
c424633030 修复组织树连线仅连接直接下属 2026-06-15 01:47:32 +08:00
汤学会
eb0f2eff95 fix: improve org tree hierarchy and user menu dismissal 2026-06-15 01:32:24 +08:00
汤学会
5ec8a98125 fix: hide internal production ledger integration hints 2026-06-15 00:55:37 +08:00
24 changed files with 2388 additions and 278 deletions

View File

@ -88,6 +88,87 @@ from app.services.system_permissions import list_employee_options
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
DEFAULT_PROCESS_CODE = "PROC-DEFAULT"
DEFAULT_PROCESS_NAME = "系统默认工序"
DEFAULT_WORK_CENTER_CODE = "WC-DEFAULT"
DEFAULT_WORK_CENTER_NAME = "系统默认工作中心"
DEFAULT_ORG_ROOT_CODE = "ORG_ROOT"
def _default_operation_department(db: Session) -> Department:
department = db.scalar(select(Department).where(Department.dept_code == DEFAULT_ORG_ROOT_CODE).limit(1))
if department:
return department
department = db.scalar(select(Department).order_by(Department.id.asc()).limit(1))
if department:
return department
now = datetime.now()
department = Department(
dept_code=DEFAULT_ORG_ROOT_CODE,
dept_name="总公司",
parent_id=None,
org_node_type="COMPANY",
dept_type="ADMIN",
manager_name=None,
manager_employee_id=None,
status="ACTIVE",
sort_no=0,
remark="系统自动创建的默认组织根节点",
created_at=now,
updated_at=now,
)
db.add(department)
db.flush()
return department
def _ensure_default_operation_foundation(db: Session) -> None:
changed = False
now = datetime.now()
process_exists = db.scalar(select(Process.id).limit(1))
if not process_exists:
db.add(
Process(
process_code=DEFAULT_PROCESS_CODE,
process_name=DEFAULT_PROCESS_NAME,
process_type="INTERNAL",
is_report_required=1,
is_quality_gate=0,
status="ACTIVE",
remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线",
created_at=now,
updated_at=now,
)
)
changed = True
work_center_exists = db.scalar(select(WorkCenter.id).limit(1))
if not work_center_exists:
department = _default_operation_department(db)
db.add(
WorkCenter(
center_code=DEFAULT_WORK_CENTER_CODE,
center_name=DEFAULT_WORK_CENTER_NAME,
dept_id=department.id,
center_type="INTERNAL",
shift_pattern="默认班次",
capacity_hours_per_day=Decimal("8.00"),
status="ACTIVE",
remark="系统自动创建,用于产品需规快速建档时生成默认工艺路线",
created_at=now,
updated_at=now,
)
)
changed = True
if changed:
try:
db.commit()
except IntegrityError:
db.rollback()
MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"]
MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"]
MATERIAL_STATUS_LABELS = {
@ -597,6 +678,7 @@ def list_work_centers(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[WorkCenterRead]:
_ensure_default_operation_foundation(db)
stmt = (
select(
WorkCenter.id.label("id"),
@ -766,6 +848,7 @@ def list_processes(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[ProcessRead]:
_ensure_default_operation_foundation(db)
rows = db.execute(
select(
Process.id.label("process_id"),

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

@ -0,0 +1,59 @@
from __future__ import annotations
import unittest
from sqlalchemy import BigInteger, create_engine, func, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes import master_data # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.operations import Process, WorkCenter # noqa: E402
from app.models.org import Department # noqa: E402
class MasterDataDefaultOperationFoundationTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
def tearDown(self) -> None:
self.db.close()
def test_list_processes_seeds_default_process_for_empty_system(self) -> None:
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 0)
rows = master_data.list_processes(limit=100, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].process_code, "PROC-DEFAULT")
self.assertEqual(rows[0].process_name, "系统默认工序")
self.assertEqual(self.db.scalar(select(func.count(Process.id))), 1)
def test_list_work_centers_seeds_default_work_center_for_empty_system(self) -> None:
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 0)
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 0)
rows = master_data.list_work_centers(limit=100, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].center_code, "WC-DEFAULT")
self.assertEqual(rows[0].center_name, "系统默认工作中心")
self.assertEqual(rows[0].dept_name, "总公司")
self.assertEqual(self.db.scalar(select(func.count(Department.id))), 1)
self.assertEqual(self.db.scalar(select(func.count(WorkCenter.id))), 1)

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

@ -17,7 +17,8 @@ assert.match(router, /loadSmartOperationReportConfig/, "router should guard oper
assert.match(app, /smartOperationReportEnabled/, "App should track switch state");
assert.match(app, /child\.key\s*===\s*"operation-reports"/, "App should hide operation reports child when disabled");
assert.match(systemExtension, /对接智能报工小程序/, "system extension should show switch");
assert.match(productionLedger, /工序明细/, "production ledger page should show operation detail wording when enabled");
assert.match(productionLedger, /当前未对接智能报工小程序/, "production ledger page should explain disabled mode");
assert.doesNotMatch(productionLedger, /当前未对接智能报工小程序/, "production ledger page should not expose disabled integration wording");
assert.doesNotMatch(productionLedger, /工序明细隐藏/, "production ledger page should not expose hidden operation detail wording");
assert.doesNotMatch(productionLedger, /BOM毛重\/净重反推/, "production ledger page should not expose internal calculation wording");
console.log("smart operation report toggle static checks passed");

View File

@ -19,3 +19,13 @@ describe("App reset password account picker", () => {
assert.match(source, /formatResetPasswordUserLabel\(user\)/);
});
});
describe("App user menu interactions", () => {
it("closes the avatar menu when the user clicks outside the menu shell", () => {
assert.match(source, /ref="userMenuShellRef"/);
assert.match(source, /function handleUserMenuOutsidePointerDown\(event\)/);
assert.match(source, /userMenuShellRef\.value\?\.contains\(event\.target\)/);
assert.match(source, /document\.addEventListener\("pointerdown", handleUserMenuOutsidePointerDown\)/);
assert.match(source, /document\.removeEventListener\("pointerdown", handleUserMenuOutsidePointerDown\)/);
});
});

View File

@ -110,7 +110,7 @@
<span class="assistant-bot-eye"></span>
</span>
</button>
<div class="user-menu-shell">
<div ref="userMenuShellRef" class="user-menu-shell">
<button class="user-avatar-button" type="button" @click="toggleUserMenu">
<span class="user-avatar">{{ avatarInitial }}</span>
<span class="user-avatar-meta">
@ -307,6 +307,7 @@ const activeBroadcasts = ref([]);
const activeBroadcastIndex = ref(0);
const assistantDrawerOpen = ref(false);
const userMenuOpen = ref(false);
const userMenuShellRef = ref(null);
const changePasswordOpen = ref(false);
const resetPasswordOpen = ref(false);
const passwordSubmitting = ref(false);
@ -1011,9 +1012,20 @@ function handleTagContextAction(action) {
function handleGlobalKeydown(event) {
if (event.key === "Escape") {
closeTagContextMenu();
userMenuOpen.value = false;
}
}
function handleUserMenuOutsidePointerDown(event) {
if (!userMenuOpen.value) {
return;
}
if (userMenuShellRef.value?.contains(event.target)) {
return;
}
userMenuOpen.value = false;
}
function updateShellViewportState() {
narrowShell.value = window.innerWidth < 1024;
if (narrowShell.value) {
@ -1040,6 +1052,7 @@ onMounted(() => {
window.addEventListener("resize", updateShellViewportState);
window.addEventListener("click", closeTagContextMenu);
window.addEventListener("keydown", handleGlobalKeydown);
document.addEventListener("pointerdown", handleUserMenuOutsidePointerDown);
void loadActiveBroadcast();
void loadSmartOperationFeature();
broadcastTimer = window.setInterval(() => {
@ -1054,6 +1067,7 @@ onUnmounted(() => {
window.removeEventListener("resize", updateShellViewportState);
window.removeEventListener("click", closeTagContextMenu);
window.removeEventListener("keydown", handleGlobalKeydown);
document.removeEventListener("pointerdown", handleUserMenuOutsidePointerDown);
if (broadcastTimer) {
window.clearInterval(broadcastTimer);
broadcastTimer = null;

View File

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "OrgPermissionTree.vue"), "utf8");
describe("OrgPermissionTree hierarchy lines", () => {
it("draws connector lines only between each node and its direct children", () => {
assert.match(source, /\.org-tree-children\s*>\s*\.org-tree-branch::before/);
assert.match(source, /\.org-tree-children\s*>\s*\.org-tree-branch\s*>\s*\.org-tree-node::before/);
assert.doesNotMatch(source, /\.org-tree-children::before/);
assert.doesNotMatch(source, /^\.org-tree-branch::before/m);
assert.match(source, /--tree-connector-x/);
assert.match(source, /--tree-node-start/);
assert.match(source, /--tree-arrow-gap/);
assert.match(source, /--tree-connector-color/);
assert.match(source, /--tree-connector-color:\s*rgba\(148,\s*163,\s*184/);
assert.match(source, /--tree-connector-width:\s*2px/);
assert.match(source, /border-left:\s*var\(--tree-connector-width\)\s*dashed\s*var\(--tree-connector-color\)/);
assert.match(source, /border-top:\s*var\(--tree-connector-width\)\s*dashed\s*var\(--tree-connector-color\)/);
assert.doesNotMatch(source, /background:\s*var\(--tree-connector-color\)/);
});
});
describe("OrgPermissionTree employee list", () => {
it("shows each employee organization path with full-value hover text", () => {
assert.match(source, /<th>组织路径<\/th>/);
assert.match(source, /employeeOrganizationPath\(employee\)/);
assert.match(source, /class="org-path-value"\s+:title="employeeOrganizationPath\(employee\)"/);
assert.match(source, /class="org-path-tail"\s+:title="employeeOrganizationPath\(employee\)"/);
assert.match(source, /function employeeOrganizationTail\(employee\)/);
assert.doesNotMatch(source, /org-path-pill/);
assert.match(source, /<td colspan="7" class="empty-row"/);
});
it("keeps row action buttons inside an inner flex container instead of flexing the table cell", () => {
assert.match(source, /<td class="action-row">\s*<div class="employee-actions">/);
assert.match(source, /\.employee-actions\s*\{[\s\S]*?display:\s*inline-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, /主管:未设置/);
});
});

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>
@ -54,9 +65,19 @@
<div class="table-wrap">
<table class="data-table compact-table org-employee-table">
<colgroup>
<col class="employee-col-name" />
<col class="employee-col-path" />
<col class="employee-col-phone" />
<col class="employee-col-position" />
<col class="employee-col-role" />
<col class="employee-col-status" />
<col class="employee-col-action" />
</colgroup>
<thead>
<tr>
<th>姓名</th>
<th>组织路径</th>
<th>电话号码 / 账号</th>
<th>岗位</th>
<th>角色</th>
@ -67,6 +88,10 @@
<tbody>
<tr v-for="employee in paginatedEmployees" :key="employeeKey(employee)">
<td>{{ employeeName(employee) }}</td>
<td class="org-path-cell">
<span class="org-path-value" :title="employeeOrganizationPath(employee)">{{ employeeOrganizationPath(employee) }}</span>
<span class="org-path-tail" :title="employeeOrganizationPath(employee)">{{ employeeOrganizationTail(employee) }}</span>
</td>
<td>{{ employeePhone(employee) }}</td>
<td>{{ employeePosition(employee) }}</td>
<td>{{ employeeRoles(employee) }}</td>
@ -74,12 +99,14 @@
<StatusBadge :value="employee?.status" :text="employeeStatus(employee)" />
</td>
<td class="action-row">
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">授权</button>
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除</button>
<div class="employee-actions">
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">人员授权</button>
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除记录</button>
</div>
</td>
</tr>
<tr v-if="!filteredEmployees.length">
<td colspan="6" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
<td colspan="7" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
</tr>
</tbody>
</table>
@ -95,6 +122,7 @@ import { computed, defineComponent, h, ref, watch } from "vue";
import PaginationBar from "./PaginationBar.vue";
import StatusBadge from "./StatusBadge.vue";
import { collectOrganizationEmployees, countOrganizationEmployees } from "../utils/orgTreeEmployees";
import { usePagination } from "../utils/pagination";
const props = defineProps({
@ -164,13 +192,16 @@ const OrgTreeBranch = defineComponent({
}
return () =>
h("div", { class: "org-tree-branch" }, [
h("div", {
class: ["org-tree-branch", { "org-tree-branch-root": branchProps.level === 0 }],
style: treeLevelStyle(branchProps.level)
}, [
h(
"button",
{
type: "button",
class: ["org-tree-node", { active: isSelected.value }],
style: { "--tree-level": branchProps.level },
style: treeLevelStyle(branchProps.level),
onClick: selectCurrent,
onContextmenu: openMenu
},
@ -186,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))
])
]
),
@ -225,7 +257,8 @@ 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 selectedEmployees = computed(() => Array.isArray(selectedNode.value?.employees) ? selectedNode.value.employees : []);
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(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
@ -233,7 +266,7 @@ const filteredEmployees = computed(() => {
return selectedEmployees.value;
}
return selectedEmployees.value.filter((employee) =>
[employeeName(employee), employeePhone(employee), employee.employee_code, employee.username]
[employeeName(employee), employeeOrganizationPath(employee), employeePhone(employee), employee.employee_code, employee.username]
.filter(Boolean)
.join(" ")
.toLowerCase()
@ -263,6 +296,16 @@ function organizationChildren(node) {
return (Array.isArray(node?.children) ? node.children : []).filter(isOrganizationNode);
}
function treeLevelStyle(level) {
const numericLevel = Math.max(Number(level || 0), 0);
return {
"--tree-level": numericLevel,
"--tree-connector-x": `${15 + Math.max(numericLevel - 1, 0) * 22}px`,
"--tree-node-start": `${6 + numericLevel * 22}px`
};
}
function filterOrgTreeByKeyword(nodes, keyword) {
const normalizedKeyword = String(keyword || "").trim().toLowerCase();
if (!normalizedKeyword) {
@ -344,26 +387,23 @@ function nodeTypeShortLabel(type) {
}[type] || "组";
}
function employeeCount(node) {
const employeeKeys = new Set();
collectEmployeeKeys(node, employeeKeys);
return employeeKeys.size;
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 collectEmployeeKeys(node, employeeKeys) {
if (!node) {
return;
}
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
employeeKeys.add(String(employeeKey(employee)));
}
for (const child of Array.isArray(node.children) ? node.children : []) {
if (isOrganizationNode(child)) {
collectEmployeeKeys(child, employeeKeys);
} else if (child?.employee_id) {
employeeKeys.add(String(employeeKey(child)));
}
}
function nodeManagerText(node) {
const names = nodeManagerNames(node);
return names.length ? `主管:${names.join("、")}` : "主管:未设置";
}
function employeeCount(node) {
return countOrganizationEmployees(node);
}
function toggleNode(node) {
@ -385,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 || "未命名人员";
}
@ -397,6 +449,15 @@ function employeePosition(employee) {
return employee?.job_title || employee?.position || employee?.post_name || employee?.title || "未设置岗位";
}
function employeeOrganizationPath(employee) {
return employee?.organization_path || employee?.parent_node_label || "未设置组织";
}
function employeeOrganizationTail(employee) {
const pathParts = Array.isArray(employee?.organization_path_parts) ? employee.organization_path_parts.filter(Boolean) : [];
return pathParts.length ? pathParts[pathParts.length - 1] : employeeOrganizationPath(employee);
}
function employeeRoles(employee) {
if (Array.isArray(employee?.role_names) && employee.role_names.length) {
return employee.role_names.join("、");
@ -424,8 +485,8 @@ function employeeKey(employee) {
function employeeWithParent(employee) {
return {
...employee,
parent_node_id: getNodeId(selectedNode.value),
parent_node_label: nodeLabel(selectedNode.value)
parent_node_id: employee?.parent_node_id ?? getNodeId(selectedNode.value),
parent_node_label: employee?.parent_node_label ?? nodeLabel(selectedNode.value)
};
}
@ -489,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;
@ -567,6 +646,10 @@ function removeEmployee(employee) {
}
.org-tree-list {
--tree-connector-color: rgba(148, 163, 184, 0.92);
--tree-connector-width: 2px;
--tree-arrow-gap: 6px;
--tree-node-center: 20px;
display: flex;
flex: 1;
flex-direction: column;
@ -579,12 +662,30 @@ function removeEmployee(employee) {
}
.org-tree-branch {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
}
.org-tree-children > .org-tree-branch::before {
content: "";
position: absolute;
top: calc(-1 * var(--tree-connector-width));
bottom: calc(-1 * var(--tree-connector-width));
left: var(--tree-connector-x);
width: 0;
border-left: var(--tree-connector-width) dashed var(--tree-connector-color);
pointer-events: none;
}
.org-tree-children > .org-tree-branch:last-child::before {
bottom: auto;
height: calc(var(--tree-node-center) + var(--tree-connector-width));
}
.org-tree-node {
position: relative;
display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr);
align-items: center;
@ -600,6 +701,17 @@ function removeEmployee(employee) {
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
}
.org-tree-children > .org-tree-branch > .org-tree-node::before {
content: "";
position: absolute;
top: var(--tree-node-center);
left: var(--tree-connector-x);
width: max(8px, calc(var(--tree-node-start) - var(--tree-connector-x) - var(--tree-arrow-gap)));
height: 0;
border-top: var(--tree-connector-width) dashed var(--tree-connector-color);
pointer-events: none;
}
.org-tree-node:hover,
.org-tree-node.active {
border-color: transparent;
@ -670,6 +782,7 @@ function removeEmployee(employee) {
}
.org-tree-children {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
@ -734,9 +847,23 @@ function removeEmployee(employee) {
}
.action-row {
display: flex;
flex-wrap: wrap;
overflow: visible;
white-space: nowrap;
}
.employee-actions {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-width: max-content;
}
.employee-actions .ghost-button {
flex: 0 0 auto;
min-height: 30px;
padding: 0 10px;
font-size: 12px;
}
.empty-row {
@ -749,13 +876,92 @@ function removeEmployee(employee) {
table-layout: fixed;
}
.org-employee-table .employee-col-name {
width: 10%;
}
.org-employee-table .employee-col-path {
width: 30%;
}
.org-employee-table .employee-col-phone {
width: 13%;
}
.org-employee-table .employee-col-position {
width: 10%;
}
.org-employee-table .employee-col-role {
width: 11%;
}
.org-employee-table .employee-col-status {
width: 7%;
}
.org-employee-table .employee-col-action {
width: 19%;
}
.org-path-cell {
min-width: 0;
}
.org-path-value,
.org-path-tail {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-path-value {
color: #334155;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.org-path-tail {
width: fit-content;
max-width: 100%;
margin-top: 3px;
border-radius: 999px;
padding: 2px 7px;
background: #eef6ff;
color: #2563eb;
font-size: 11px;
font-weight: 750;
line-height: 1.2;
}
:deep(.org-tree-branch) {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
}
:deep(.org-tree-children > .org-tree-branch::before) {
content: "";
position: absolute;
top: calc(-1 * var(--tree-connector-width));
bottom: calc(-1 * var(--tree-connector-width));
left: var(--tree-connector-x);
width: 0;
border-left: var(--tree-connector-width) dashed var(--tree-connector-color);
pointer-events: none;
}
:deep(.org-tree-children > .org-tree-branch:last-child::before) {
bottom: auto;
height: calc(var(--tree-node-center) + var(--tree-connector-width));
}
:deep(.org-tree-node) {
position: relative;
display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr);
align-items: center;
@ -772,6 +978,17 @@ function removeEmployee(employee) {
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
}
:deep(.org-tree-children > .org-tree-branch > .org-tree-node::before) {
content: "";
position: absolute;
top: var(--tree-node-center);
left: var(--tree-connector-x);
width: max(8px, calc(var(--tree-node-start) - var(--tree-connector-x) - var(--tree-arrow-gap)));
height: 0;
border-top: var(--tree-connector-width) dashed var(--tree-connector-color);
pointer-events: none;
}
:deep(.org-tree-node:hover),
:deep(.org-tree-node.active) {
border-color: transparent;
@ -842,6 +1059,7 @@ function removeEmployee(employee) {
}
:deep(.org-tree-children) {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;

View File

@ -8,10 +8,14 @@
:signature-labels="signatureLabels"
>
<div class="document-form-meta" :aria-label="metaAriaLabel">
<span class="document-form-meta-pair">
<span>{{ metaLabel }}</span>
<strong>{{ companyName }}</strong>
</span>
<span class="document-form-meta-pair document-form-meta-date">
<span>{{ dateLabel }}</span>
<strong>{{ displayBusinessDate }}</strong>
</span>
</div>
<main class="document-form-body">

View File

@ -49,4 +49,35 @@ describe("document form foundation", () => {
assert.match(mainCss, /\.document-settle-panel/);
assert.match(mainCss, /\.warehouse-paper-control-grid/);
});
it("keeps document header labels close to their values instead of stretching business date apart", () => {
const shell = readComponent("DocumentFormShell.vue");
assert.match(shell, /class="document-form-meta-pair"/);
assert.match(shell, /class="document-form-meta-pair document-form-meta-date"/);
assert.match(mainCss, /\.document-form-meta\s*\{[\s\S]*display:\s*flex;/);
assert.match(mainCss, /\.document-form-meta-date\s*\{[\s\S]*margin-left:\s*auto;/);
assert.doesNotMatch(
mainCss.match(/\.document-form-meta\s*\{[\s\S]*?\}/)?.[0] || "",
/grid-template-columns:[^;]*1fr/,
"document form meta should not use a flexible spacer that separates 业务日期 from its date value"
);
});
it("keeps the final paper layout contract after legacy warehouse grid rules", () => {
const legacyWarehouseGrid = mainCss.lastIndexOf(".warehouse-document-section-grid {");
const finalContract = mainCss.lastIndexOf("Final paper document layout contract");
const warehousePaperGrid = mainCss.lastIndexOf("body .warehouse-document-section-grid > .warehouse-paper-grid");
const genericWrappedWarehousePaperGrid = mainCss.lastIndexOf("body .document-section-grid > .warehouse-paper-grid");
const genericWrappedWarehouseMeta = mainCss.lastIndexOf("body .document-section-grid > .warehouse-document-meta-strip");
const genericDocumentGrid = mainCss.lastIndexOf("body .document-section-grid > .document-grid");
assert.ok(legacyWarehouseGrid > 0, "expected the legacy warehouse grid rule to exist");
assert.ok(finalContract > legacyWarehouseGrid, "expected final paper contract after legacy grid rules");
assert.ok(warehousePaperGrid > legacyWarehouseGrid, "expected warehouse paper grid full-width rule after legacy grid");
assert.ok(genericWrappedWarehousePaperGrid > legacyWarehouseGrid, "expected generic section wrapped warehouse paper grid rule after legacy grid");
assert.ok(genericWrappedWarehouseMeta > legacyWarehouseGrid, "expected generic section wrapped warehouse meta strip rule after legacy grid");
assert.ok(genericDocumentGrid > 0, "expected generic document grid full-width rule in final contract");
assert.match(mainCss.slice(finalContract), /grid-column: 1 \/ -1 !important;/);
});
});

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

@ -12062,32 +12062,37 @@ body .system-permission-page .tree-layout-card .org-employee-table td {
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(1),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(1) {
width: 13% !important;
width: 10% !important;
}
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(2),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(2) {
width: 20% !important;
width: 30% !important;
}
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(3),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(3) {
width: 14% !important;
width: 13% !important;
}
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(4),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(4) {
width: 17% !important;
width: 10% !important;
}
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(5),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(5) {
width: 12% !important;
width: 11% !important;
}
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(6),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(6) {
width: 24% !important;
width: 7% !important;
}
body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(7),
body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(7) {
width: 19% !important;
}
body .system-permission-page .tree-layout-card .org-employee-table .action-row {
@ -12096,9 +12101,17 @@ body .system-permission-page .tree-layout-card .org-employee-table .action-row {
white-space: nowrap !important;
}
body .system-permission-page .tree-layout-card .org-employee-table .employee-actions {
display: inline-flex !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 8px !important;
min-width: max-content !important;
}
body .system-permission-page .tree-layout-card .org-employee-table .action-row button {
min-height: 30px !important;
padding: 0 8px !important;
padding: 0 10px !important;
font-size: 12px !important;
}
@ -16085,6 +16098,14 @@ body .login-production-layout .login-security-foot p {
background: rgba(255, 250, 240, 0.5);
}
.document-grid-cell-wide {
grid-column: span 2;
}
.document-grid-cell-full {
grid-column: 1 / -1;
}
.document-grid-label,
.document-grid-value {
margin: 0;
@ -16515,10 +16536,11 @@ body .login-production-layout .login-security-foot p {
}
.document-form-meta {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
gap: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
align-items: center;
justify-content: flex-start;
padding: 9px 12px;
color: rgba(23, 18, 10, 0.72);
border: 2px solid rgba(20, 14, 6, 0.82);
@ -16531,11 +16553,29 @@ body .login-production-layout .login-security-foot p {
letter-spacing: 0.08em;
}
.document-form-meta-pair {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.document-form-meta-date {
margin-left: auto;
}
.document-form-meta-pair > span {
flex: 0 0 auto;
white-space: nowrap;
}
.document-form-meta strong {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
min-height: 26px;
padding: 0 10px;
white-space: nowrap;
color: #145c39;
border: 1.5px solid rgba(31, 122, 74, 0.64);
background: rgba(232, 246, 232, 0.62);
@ -16592,11 +16632,21 @@ body .login-production-layout .login-security-foot p {
padding: 14px;
}
.document-section-grid > .document-grid,
.document-section-grid > .document-line-table,
.document-section-grid > .document-control-grid,
.document-section-grid > .document-selector-panel,
.document-section-grid > .document-source-table,
.document-section-grid > .document-inline-summary,
.document-section-grid > .document-settle-panel,
.document-section-grid > .warehouse-document-meta-strip,
.document-section-grid > .warehouse-paper-grid,
.document-section-grid > .warehouse-paper-control-grid,
.document-section-grid > .warehouse-paper-source-lot-panel,
.document-section-grid > .warehouse-paper-source-lot-table,
.document-section-grid > .warehouse-paper-inline-summary,
.document-section-grid > .warehouse-paper-settle-panel,
.document-section-grid > .warehouse-paper-summary-grid,
.document-section-grid > .document-paper-field {
grid-column: 1 / -1;
width: 100%;
@ -16811,7 +16861,6 @@ body .login-production-layout .login-security-foot p {
}
@media (max-width: 960px) {
.document-form-meta,
.document-section-grid,
.document-control-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -16827,18 +16876,32 @@ body .login-production-layout .login-security-foot p {
padding-inline: 0;
}
.document-form-meta,
.document-section-grid,
.document-control-grid {
grid-template-columns: 1fr;
}
.document-form-meta {
align-items: stretch;
}
.document-form-meta-pair,
.document-form-meta-date {
width: 100%;
margin-left: 0;
}
.document-paper-field,
.document-paper-field-wide,
.document-paper-field-full {
grid-column: 1 / -1;
}
.document-grid-cell-wide,
.document-grid-cell-full {
grid-column: 1 / -1;
}
.document-action-bar,
.document-action-bar--with-state,
.document-action-state,
@ -19053,7 +19116,56 @@ body .warehouse-document-action-bar {
transform: none !important;
}
/* Final paper document layout contract: later legacy drawer grids must not squeeze formal document blocks. */
body .document-section-grid > .document-grid,
body .document-section-grid > .document-line-table,
body .document-section-grid > .document-control-grid,
body .document-section-grid > .document-selector-panel,
body .document-section-grid > .document-source-table,
body .document-section-grid > .document-inline-summary,
body .document-section-grid > .document-settle-panel,
body .document-section-grid > .document-paper-field,
body .document-section-grid > .warehouse-document-meta-strip,
body .document-section-grid > .warehouse-paper-grid,
body .document-section-grid > .warehouse-paper-control-grid,
body .document-section-grid > .warehouse-paper-source-lot-panel,
body .document-section-grid > .warehouse-paper-source-lot-table,
body .document-section-grid > .warehouse-paper-inline-summary,
body .document-section-grid > .warehouse-paper-settle-panel,
body .document-section-grid > .warehouse-paper-summary-grid,
body .warehouse-document-section-grid > .warehouse-document-meta-strip,
body .warehouse-document-section-grid > .warehouse-paper-grid,
body .warehouse-document-section-grid > .warehouse-paper-control-grid,
body .warehouse-document-section-grid > .warehouse-paper-source-lot-panel,
body .warehouse-document-section-grid > .warehouse-paper-source-lot-table,
body .warehouse-document-section-grid > .warehouse-paper-inline-summary,
body .warehouse-document-section-grid > .warehouse-paper-settle-panel,
body .warehouse-document-section-grid > .warehouse-paper-summary-grid {
grid-column: 1 / -1 !important;
width: 100% !important;
min-width: 0 !important;
}
body .workflow-drawer.form-drawer:has(.document-form-shell) {
width: min(1280px, calc(100vw - 24px)) !important;
}
body .workflow-drawer.form-drawer:has(.document-form-shell) .form-drawer-body {
overflow-x: hidden !important;
}
body .document-action-bar {
position: relative !important;
right: auto !important;
bottom: auto !important;
z-index: 1 !important;
margin: 0 0 0 auto !important;
transform: none !important;
}
@media (max-width: 620px) {
body .document-action-bar,
body .document-action-bar--with-state,
body .warehouse-document-action-bar,
body .warehouse-document-action-bar--shared,
body .warehouse-document-action-bar--shared.warehouse-document-action-bar--with-state {

View File

@ -0,0 +1,76 @@
export function collectOrganizationEmployees(node) {
const employees = [];
const seenKeys = new Set();
collectFromNode(node, node, employees, seenKeys, []);
return employees;
}
export function countOrganizationEmployees(node) {
return collectOrganizationEmployees(node).length;
}
function collectFromNode(node, parentNode, employees, seenKeys, pathParts) {
if (!node) {
return;
}
if (isEmployeeNode(node)) {
appendEmployee(node, parentNode, employees, seenKeys, pathParts);
return;
}
const currentPathParts = [...pathParts, nodeLabel(node)].filter(Boolean);
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
appendEmployee(employee, node, employees, seenKeys, currentPathParts);
}
for (const child of Array.isArray(node.children) ? node.children : []) {
if (isOrganizationNode(child)) {
collectFromNode(child, child, employees, seenKeys, currentPathParts);
} else if (isEmployeeNode(child)) {
appendEmployee(child, node, employees, seenKeys, currentPathParts);
}
}
}
function appendEmployee(employee, parentNode, employees, seenKeys, pathParts) {
const key = employeeKey(employee);
if (!key || seenKeys.has(key)) {
return;
}
const organizationPathParts = Array.isArray(employee.organization_path_parts) && employee.organization_path_parts.length
? employee.organization_path_parts
: pathParts;
seenKeys.add(key);
employees.push({
...employee,
parent_node_id: employee.parent_node_id ?? nodeId(parentNode),
parent_node_label: employee.parent_node_label ?? nodeLabel(parentNode),
organization_path_parts: organizationPathParts,
organization_path: employee.organization_path || organizationPathParts.join(" / ")
});
}
function isOrganizationNode(node) {
return Boolean(node) && node.node_type !== "EMPLOYEE" && !node.employee_id;
}
function isEmployeeNode(node) {
return Boolean(node) && (node.node_type === "EMPLOYEE" || Boolean(node.employee_id));
}
function employeeKey(employee) {
return String(employee?.employee_id ?? employee?.user_id ?? employee?.username ?? employee?.mobile ?? employee?.phone ?? "");
}
function nodeId(node) {
return node?.node_id ?? node?.id ?? node?.value ?? "";
}
function nodeLabel(node) {
return node?.node_label || node?.label || node?.name || "";
}

View File

@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { collectOrganizationEmployees, countOrganizationEmployees } from "./orgTreeEmployees.js";
describe("organization tree employee aggregation", () => {
it("collects employees from the selected organization and all descendant organizations", () => {
const branch = {
node_id: 10,
node_type: "BRANCH",
node_label: "旭升车间",
employees: [
{ employee_id: 1, employee_name: "直属主管", mobile: "13000000001" }
],
children: [
{
node_id: 11,
node_type: "DEPARTMENT",
node_label: "旭升加工部",
employees: [
{ employee_id: 2, employee_name: "张三", mobile: "13000000002" },
{ employee_id: 3, employee_name: "李四", mobile: "13000000003" }
],
children: [
{
node_id: 12,
node_type: "GROUP",
node_label: "冲压小组",
employees: [
{ employee_id: 4, employee_name: "王五", mobile: "13000000004" }
]
}
]
}
]
};
const employees = collectOrganizationEmployees(branch);
assert.deepEqual(employees.map((employee) => employee.employee_name), ["直属主管", "张三", "李四", "王五"]);
assert.equal(employees[1].parent_node_id, 11);
assert.equal(employees[1].parent_node_label, "旭升加工部");
assert.deepEqual(employees[1].organization_path_parts, ["旭升车间", "旭升加工部"]);
assert.equal(employees[1].organization_path, "旭升车间 / 旭升加工部");
assert.equal(employees[3].organization_path, "旭升车间 / 旭升加工部 / 冲压小组");
assert.equal(countOrganizationEmployees(branch), 4);
});
it("deduplicates employees that appear in multiple descendant branches", () => {
const employee = { employee_id: 9, employee_name: "重复人员", mobile: "13000000009" };
const branch = {
node_id: 20,
node_type: "BRANCH",
node_label: "嘉恒车间",
employees: [employee],
children: [
{
node_id: 21,
node_type: "DEPARTMENT",
node_label: "嘉恒生产部",
employees: [employee]
}
]
};
const employees = collectOrganizationEmployees(branch);
assert.equal(employees.length, 1);
assert.equal(countOrganizationEmployees(branch), 1);
});
});

View File

@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "ProductSpecLiteView.vue"), "utf8");
describe("ProductSpecLiteView operation foundation loading", () => {
it("loads process and work-center master data sequentially so backend default seeding can settle", () => {
assert.match(source, /const processRows = await fetchResource\("\/master-data\/processes", \[\]\);/);
assert.match(source, /const centerRows = await fetchResource\("\/master-data\/work-centers", \[\]\);/);
assert.ok(
source.indexOf('fetchResource("/master-data/processes", [])') <
source.indexOf('fetchResource("/master-data/work-centers", [])')
);
});
it("prepares route operations before creating a new product so validation cannot leave partial records", () => {
assert.match(source, /async function ensureOperationFoundationReady\(\)/);
assert.match(source, /await ensureOperationFoundationReady\(\);/);
assert.match(source, /const preparedRouteOperations = buildRouteOperationsPayload\(processRowsForSave\);/);
assert.ok(
source.indexOf("const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);") <
source.indexOf('await postResource("/master-data/products", productPayload)')
);
});
it("cleans newly created product-spec records if a later save step fails", () => {
assert.match(source, /let createdProductItemId = null;/);
assert.match(source, /const createdMiniappPayloads = \[\];/);
assert.match(source, /createdProductItemId = productItemId;/);
assert.match(source, /createdMiniappPayloads\.push\(payload\);/);
assert.match(source, /await deleteResource\(buildMiniappDeletePath\(payload\)\)\.catch\(\(\) => null\);/);
assert.match(source, /await deleteResource\(`\/master-data\/product-specs\/\$\{createdProductItemId\}`\)\.catch\(\(\) => null\);/);
});
it("uses a one-screen table layout so the action and pagination areas do not get pushed into the edge", () => {
assert.match(source, /class="table-wrap product-spec-table-wrap"/);
assert.match(source, /class="data-table compact-table product-spec-table"/);
assert.match(source, /<colgroup>[\s\S]*product-spec-col-action[\s\S]*<\/colgroup>/);
assert.match(source, /\.product-spec-table-wrap\.table-view-wide,[\s\S]*?overflow-x:\s*hidden !important;/);
assert.match(source, /\.product-spec-table[\s\S]*?table-layout:\s*fixed !important;/);
assert.match(source, /\.product-spec-table\s+\.action-row[\s\S]*?width:\s*100% !important;/);
});
});

View File

@ -23,8 +23,25 @@
placeholder="搜索考勤点、产品编码、名称、版本、原材料、小程序工序"
/>
<div class="table-wrap">
<table class="data-table compact-table">
<div class="table-wrap product-spec-table-wrap">
<table class="data-table compact-table product-spec-table">
<colgroup>
<col class="product-spec-col-code" />
<col class="product-spec-col-attendance" />
<col class="product-spec-col-project" />
<col class="product-spec-col-product" />
<col class="product-spec-col-version" />
<col class="product-spec-col-weight" />
<col class="product-spec-col-material" />
<col class="product-spec-col-gross" />
<col class="product-spec-col-rate" />
<col class="product-spec-col-rate" />
<col class="product-spec-col-price" />
<col class="product-spec-col-process" />
<col class="product-spec-col-beat" />
<col class="product-spec-col-status" />
<col class="product-spec-col-action" />
</colgroup>
<thead>
<tr>
<th>产品编码</th>
@ -73,6 +90,7 @@
<td class="action-row">
<button
class="ghost-button"
data-semantic-label="编辑"
type="button"
:disabled="!canModifySpec(row)"
:title="canModifySpec(row) ? '编辑' : '已停用的产品需规只读,不允许修改'"
@ -82,6 +100,7 @@
</button>
<button
class="ghost-button danger-ghost"
data-semantic-label="删除"
type="button"
:disabled="!canModifySpec(row)"
:title="canModifySpec(row) ? '删除' : '已停用的产品需规只读,不允许删除'"
@ -97,7 +116,7 @@
</tbody>
</table>
</div>
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="filteredRows.length" />
<PaginationBar class="product-spec-pagination" v-model:page="page" v-model:page-size="pageSize" :total="filteredRows.length" />
</section>
<FormDrawer
@ -856,19 +875,23 @@ function buildAutoRouteOperation(processRow, index) {
};
}
function buildRoutePayload(product, processRowsForSave) {
const productItemId = product.item_id;
function buildRouteOperationsPayload(processRowsForSave) {
const processRows = buildProcessGroupForRoute(processRowsForSave);
if (!processRows.length) {
throw new Error("请至少维护一道小程序工序");
}
return processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index));
}
function buildRoutePayload(product, processRowsForSave, preparedOperations = null) {
const productItemId = product.item_id;
return {
route_name: `${form.item_name} 工艺路线`,
product_item_id: productItemId,
version_no: form.version_no,
is_default: true,
status: "ACTIVE",
operations: processRows.map((processRow, index) => buildAutoRouteOperation(processRow, index))
operations: preparedOperations || buildRouteOperationsPayload(processRowsForSave)
};
}
@ -931,16 +954,16 @@ watch(calculatedLossRate, (lossRate) => {
}, { immediate: true });
async function fetchMasterData() {
const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows, processRows, centerRows] = await Promise.all([
const [productRows, bomRows, routeRows, materialRows, miniappProductRows, pointRows] = await Promise.all([
fetchResource("/master-data/products?limit=200", []),
fetchResource("/master-data/boms?limit=500", []),
fetchResource("/master-data/process-routes?limit=500", []),
fetchResource("/master-data/materials?limit=200", []),
fetchResource("/master-data/miniapp-products?limit=1000", []),
fetchResource("/master-data/miniapp-attendance-points", []),
fetchResource("/master-data/processes", []),
fetchResource("/master-data/work-centers", [])
fetchResource("/master-data/miniapp-attendance-points", [])
]);
const processRows = await fetchResource("/master-data/processes", []);
const centerRows = await fetchResource("/master-data/work-centers", []);
products.value = productRows;
boms.value = bomRows;
routes.value = routeRows;
@ -951,6 +974,18 @@ async function fetchMasterData() {
workCenters.value = centerRows;
}
async function ensureOperationFoundationReady() {
if (!processes.value.length) {
processes.value = await fetchResource("/master-data/processes", []);
}
if (!workCenters.value.length) {
workCenters.value = await fetchResource("/master-data/work-centers", []);
}
if (!processes.value.length || !workCenters.value.length) {
throw new Error("系统无法准备默认工艺基础资料,请稍后重试或联系管理员");
}
}
async function syncMiniappInBackground() {
if (miniappSyncing.value) {
return;
@ -977,8 +1012,12 @@ async function submitSpec() {
feedbackMessage.value = "";
errorMessage.value = "";
submitting.value = true;
let createdProductItemId = null;
const createdMiniappPayloads = [];
try {
const processRowsForSave = normalizeProcessFormRowsForSave();
await ensureOperationFoundationReady();
const preparedRouteOperations = buildRouteOperationsPayload(processRowsForSave);
const productPayload = {
item_name: form.item_name,
specification: editingRow.value?.specification || null,
@ -997,6 +1036,9 @@ async function submitSpec() {
? await putResource(`/master-data/products/${editingRow.value.item_id}`, productPayload)
: await postResource("/master-data/products", productPayload);
const productItemId = product.item_id;
if (!shouldUpdateErpProduct) {
createdProductItemId = productItemId;
}
const canUpdateExistingSpec = editingRow.value && Number(editingRow.value.item_id) === Number(productItemId);
if (canUpdateExistingSpec && editingRow.value.bom?.bom_id) {
await putResource(`/master-data/boms/${editingRow.value.bom.bom_id}`, buildBomPayload(productItemId));
@ -1004,19 +1046,27 @@ async function submitSpec() {
await postResource("/master-data/boms", buildBomPayload(productItemId));
}
if (canUpdateExistingSpec && editingRow.value.route?.route_id) {
await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave));
await putResource(`/master-data/process-routes/${editingRow.value.route.route_id}`, buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
} else {
await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave));
await postResource("/master-data/process-routes", buildRoutePayload(product, processRowsForSave, preparedRouteOperations));
}
const miniappPayloads = buildMiniappProductPayloads(product, processRowsForSave);
for (const payload of miniappPayloads) {
await postResource("/master-data/miniapp-products?sync=false", payload);
createdMiniappPayloads.push(payload);
}
await postResource("/master-data/miniapp-materials/sync?force=true", {});
feedbackMessage.value = `产品需规 ${product.item_code} 已保存`;
drawerOpen.value = false;
await loadAll();
} catch (error) {
if (createdProductItemId) {
for (const payload of createdMiniappPayloads) {
await deleteResource(buildMiniappDeletePath(payload)).catch(() => null);
}
await deleteResource(`/master-data/product-specs/${createdProductItemId}`).catch(() => null);
await fetchMasterData().catch(() => null);
}
errorMessage.value = error.message || "产品需规保存失败";
} finally {
submitting.value = false;
@ -1038,6 +1088,177 @@ onActivated(async () => {
</script>
<style scoped>
.product-spec-table-wrap,
.product-spec-table-wrap.table-view-wide,
.product-spec-table-wrap.table-view-overview {
width: 100%;
max-width: 100%;
overflow-x: hidden !important;
}
.product-spec-table,
.product-spec-table.data-table-wide,
.product-spec-table.data-table-overview,
.product-spec-table-wrap.table-view-wide .product-spec-table,
.product-spec-table-wrap.table-view-overview .product-spec-table {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
table-layout: fixed !important;
}
.product-spec-col-code {
width: 8%;
}
.product-spec-col-attendance {
width: 5.5%;
}
.product-spec-col-project {
width: 8.5%;
}
.product-spec-col-product {
width: 10.5%;
}
.product-spec-col-version {
width: 4.2%;
}
.product-spec-col-weight {
width: 4.6%;
}
.product-spec-col-material {
width: 11%;
}
.product-spec-col-gross {
width: 5.4%;
}
.product-spec-col-rate {
width: 5.7%;
}
.product-spec-col-price {
width: 5.4%;
}
.product-spec-col-process {
width: 6.2%;
}
.product-spec-col-beat {
width: 5.2%;
}
.product-spec-col-status {
width: 5%;
}
.product-spec-col-action {
width: 8.6%;
}
.product-spec-table th,
.product-spec-table td,
.product-spec-table.data-table-wide th,
.product-spec-table.data-table-overview th,
.product-spec-table-wrap.table-view-wide .product-spec-table th,
.product-spec-table-wrap.table-view-overview .product-spec-table th {
min-width: 0 !important;
max-width: 100% !important;
}
.product-spec-table th,
.product-spec-table th :deep(.sortable-th-button),
.product-spec-table th :deep(.sortable-th-label) {
overflow: visible !important;
text-overflow: clip !important;
white-space: normal !important;
word-break: keep-all !important;
overflow-wrap: anywhere !important;
line-height: 1.22;
}
.product-spec-table th :deep(.sortable-th-button) {
grid-template-columns: minmax(0, 1fr) 16px !important;
column-gap: 4px !important;
min-width: 0 !important;
}
.product-spec-table th :deep(.sortable-th-label) {
min-width: 0 !important;
}
.product-spec-table td:not(.action-row),
.product-spec-table.data-table-wide td:not(.action-row),
.product-spec-table.data-table-overview td:not(.action-row) {
min-width: 0 !important;
max-width: 100% !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.product-spec-table .action-row,
.product-spec-table.data-table-wide .action-row,
.product-spec-table.data-table-overview .action-row {
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
padding-right: 8px !important;
padding-left: 8px !important;
text-align: center !important;
white-space: nowrap !important;
}
.product-spec-table-wrap.table-view-wide .product-spec-table:has(td.action-row) thead th:last-child,
.product-spec-table-wrap.table-view-overview .product-spec-table:has(td.action-row) thead th:last-child,
.product-spec-table.data-table-wide:has(td.action-row) thead th:last-child,
.product-spec-table.data-table-overview:has(td.action-row) thead th:last-child {
width: auto !important;
min-width: 0 !important;
max-width: 100% !important;
text-align: center !important;
}
.product-spec-table .action-row .ghost-button,
.product-spec-table .action-row :deep(.semantic-action-button) {
width: calc((100% - 6px) / 2) !important;
min-width: 0 !important;
max-width: none !important;
justify-content: center !important;
margin-right: 0 !important;
padding: 0 6px !important;
gap: 5px !important;
}
.product-spec-table .action-row .ghost-button + .ghost-button,
.product-spec-table .action-row :deep(.semantic-action-button + .semantic-action-button) {
margin-left: 6px !important;
}
.product-spec-table .action-row :deep(.semantic-action-label) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-spec-pagination {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.product-spec-pagination :deep(.pagination-actions) {
min-width: 0;
}
.process-field-list {
display: grid;
gap: 10px;

View File

@ -13,15 +13,6 @@
</div>
</div>
<div
v-if="!smartOperationReportEnabled"
class="production-compact-tip production-mode-tip"
title="当前未对接智能报工小程序生产进度按成品入库数量和BOM毛重/净重反推;工序明细在此模式下隐藏。"
>
<strong>未对接智能报工小程序</strong>
<span>生产进度按成品入库数量和BOM毛重/净重反推工序明细隐藏</span>
<FieldHint text="当前未对接智能报工小程序生产进度按成品入库数量和BOM毛重/净重反推;工序明细在此模式下隐藏。" />
</div>
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
@ -169,23 +160,6 @@
</div>
</template>
</DrawerDataTable>
<div
v-if="smartOperationReportEnabled"
class="production-compact-tip production-drawer-tip"
title="工序明细由智能报工小程序按材料库存批次号回传,并在生产台账中用于辅助判断生产进度。"
>
<strong>小程序工序明细</strong>
<span>按材料库存批次号回传用于辅助判断生产进度</span>
</div>
<div
v-else
class="production-compact-tip production-drawer-tip"
title="当前未对接智能报工小程序工序明细隐藏系统按ERP入库数据反推生产进度。"
>
<strong>工序明细隐藏</strong>
<span>当前未对接智能报工小程序系统按ERP入库数据反推生产进度</span>
</div>
</aside>
</div>
</div>
@ -198,13 +172,11 @@ import { computed, onMounted, ref } from "vue";
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
import DrawerDataTable from "../components/DrawerDataTable.vue";
import FieldHint from "../components/FieldHint.vue";
import PaginationBar from "../components/PaginationBar.vue";
import StatusBadge from "../components/StatusBadge.vue";
import TableControls from "../components/TableControls.vue";
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
import { downloadResource, fetchResource, openResource, postResource } from "../services/api";
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
import { formatDateTime, formatQty, formatWeight } from "../utils/formatters";
import { usePagination } from "../utils/pagination";
import { useTableControls } from "../utils/tableControls";
@ -216,7 +188,6 @@ const expandedTxnRows = ref([]);
const feedbackMessage = ref("");
const errorMessage = ref("");
const lockingLedger = ref(false);
const smartOperationReportEnabled = ref(true);
const activeLedger = computed(() => ledgers.value.find((item) => Number(item.production_ledger_id) === Number(selectedLedgerId.value)) || null);
const activeLedgerTxns = computed(() => activeLedger.value?.txns || []);
@ -358,12 +329,8 @@ async function regenerateDocumentArchive(typeKey, businessId) {
async function loadAll() {
errorMessage.value = "";
const [rows, config] = await Promise.all([
fetchResource("/production/production-ledger?limit=500", []),
loadSmartOperationReportConfig()
]);
const rows = await fetchResource("/production/production-ledger?limit=500", []);
ledgers.value = Array.isArray(rows) ? rows : [];
smartOperationReportEnabled.value = config.enabled !== false;
}
onMounted(async () => {

View File

@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "ReturnManagementView.vue"), "utf8");
function drawerBlock(openBinding, nextMarker) {
const start = source.indexOf(`:open="${openBinding}"`);
assert.ok(start >= 0, `expected drawer ${openBinding} to exist`);
const end = source.indexOf(nextMarker, start + openBinding.length);
assert.ok(end > start, `expected drawer ${openBinding} to end before ${nextMarker}`);
return source.slice(start, end);
}
describe("ReturnManagementView formal document layout", () => {
it("renders return registration as a formal paper document instead of a stack form", () => {
const block = drawerBlock("returnDrawerOpen", ':open="dispositionDrawerOpen"');
assert.match(block, /<DocumentFormShell[\s\S]*title="退货登记单"/);
assert.match(block, /<DocumentSection index="01" title="退货来源信息"/);
assert.match(block, /<DocumentSection index="02" title="退货明细"/);
assert.match(block, /<DocumentGrid :fields="returnDocumentHeaderFields"/);
assert.match(block, /<DocumentLineTable title="退货明细"/);
assert.match(block, /<DocumentActionBar>/);
assert.doesNotMatch(block, /<form class="stack-form"/);
assert.doesNotMatch(block, /order-item-card/);
});
it("renders return disposition as a formal paper document instead of a stack form", () => {
const block = drawerBlock("dispositionDrawerOpen", ':open="dispositionRecordsDrawerOpen"');
assert.match(block, /<DocumentFormShell[\s\S]*title="退货处置单"/);
assert.match(block, /<DocumentSection index="01" title="处置信息"/);
assert.match(block, /<DocumentSection index="02" title="处置对象预览"/);
assert.match(block, /<DocumentGrid :fields="dispositionDocumentHeaderFields"/);
assert.match(block, /<DocumentLineTable title="退货行明细"/);
assert.match(block, /<DocumentActionBar>/);
assert.doesNotMatch(block, /<form class="stack-form"/);
});
it("keeps the formal document field definitions explicit", () => {
assert.match(source, /const returnDocumentHeaderFields = computed/);
assert.match(source, /const returnDocumentLineColumns = \[/);
assert.match(source, /const dispositionDocumentHeaderFields = computed/);
assert.match(source, /const dispositionDocumentLineColumns = \[/);
});
});

View File

@ -73,120 +73,116 @@
wide
@close="returnDrawerOpen = false"
>
<form class="stack-form" @submit.prevent="submitReturnOrder">
<div class="double-field">
<label class="form-field">
<span>客户</span>
<DocumentFormShell
class="return-document-form"
title="退货登记单"
document-no="提交后自动生成"
meta-label="售后联单"
:business-date="new Date()"
:signature-labels="['售后登记', '仓库接收', '质量复核', '制单人']"
tone="green"
@submit="submitReturnOrder"
>
<DocumentSection index="01" title="退货来源信息">
<DocumentGrid :fields="returnDocumentHeaderFields" columns="3">
<template #customer>
<select v-model.number="orderForm.customer_id" required>
<option disabled value="">请选择客户</option>
<option v-for="customer in customers" :key="customer.id" :value="customer.id">
{{ customer.customer_name }}
</option>
</select>
</label>
<label class="form-field">
<span>订单</span>
</template>
<template #sales_order>
<select v-model.number="orderForm.sales_order_id">
<option value="">不关联</option>
<option v-for="order in orders" :key="order.order_id" :value="order.order_id">
{{ order.order_no }}
</option>
</select>
</label>
</div>
<div class="double-field">
<label class="form-field">
<span>发货单</span>
</template>
<template #delivery>
<select v-model.number="orderForm.delivery_id">
<option value="">不关联</option>
<option v-for="delivery in deliveries" :key="delivery.delivery_id" :value="delivery.delivery_id">
{{ delivery.delivery_no }} · {{ delivery.order_no }}
</option>
</select>
</label>
<label class="form-field">
<span>处理人</span>
</template>
<template #handler>
<select v-model.number="orderForm.handler_employee_id">
<option value="">请选择</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employeeOptionLabel(employee) }}
</option>
</select>
</label>
</div>
<label class="form-field">
<span>退货原因</span>
</template>
<template #return_reason>
<textarea v-model.trim="orderForm.return_reason" rows="2"></textarea>
</label>
</template>
</DocumentGrid>
</DocumentSection>
<div class="subsection-head">
<strong>退货明细</strong>
<DocumentSection index="02" title="退货明细">
<DocumentLineTable title="退货明细" :columns="returnDocumentLineColumns">
<template #actions>
<button class="ghost-button" type="button" @click="addRow">增加一行</button>
</div>
<div class="order-item-stack">
<article v-for="(item, index) in orderForm.items" :key="index" class="order-item-card">
<div class="subsection-head">
<strong>明细 {{ index + 1 }}</strong>
<button v-if="orderForm.items.length > 1" class="ghost-button danger-ghost" type="button" @click="removeRow(index)">
删除
</button>
</div>
<label class="form-field">
<span>订单行</span>
</template>
<tr v-for="(item, index) in orderForm.items" :key="index">
<td class="document-line-index">{{ index + 1 }}</td>
<td>
<select v-model.number="item.sales_order_item_id" @change="applyOrderItem(index)">
<option value="">手工选产品</option>
<option v-for="orderItem in orderItems" :key="orderItem.sales_order_item_id" :value="orderItem.sales_order_item_id">
{{ orderItem.order_no }} · {{ orderItem.product_name }}
</option>
</select>
</label>
<div class="double-field">
<label class="form-field">
<span>产品</span>
</td>
<td>
<select v-model.number="item.product_item_id" required @change="recalculateReturnWeight(index)">
<option disabled value="">请选择产品</option>
<option v-for="product in products" :key="product.item_id" :value="product.item_id">
{{ product.item_code }} · {{ product.item_name }}
</option>
</select>
</label>
<label class="form-field">
<span>来源批次</span>
</td>
<td>
<select v-model.number="item.source_lot_id">
<option value="">不指定</option>
<option v-for="lot in finishedLots" :key="lot.lot_id" :value="lot.lot_id">
{{ lot.lot_no }} · {{ lot.item_name }}
</option>
</select>
</label>
</div>
<div class="triple-field">
<label class="form-field">
<span>退货数量</span>
</td>
<td>
<input v-model.number="item.return_qty" type="number" min="0" step="1" @input="recalculateReturnWeight(index)" />
</label>
<label class="form-field">
<span>退货重量(kg)</span>
</td>
<td>
<input v-model.number="item.return_weight_kg" type="number" min="0" step="0.001" />
</label>
<label class="form-field">
<span>责任归属</span>
</td>
<td>
<input v-model.trim="item.responsibility_type" type="text" placeholder="内部 / 外部 / 客户" />
</label>
</div>
</article>
</div>
</td>
<td>
<button
v-if="orderForm.items.length > 1"
class="ghost-button danger-ghost table-action-button"
type="button"
@click="removeRow(index)"
>
删除
</button>
</td>
</tr>
</DocumentLineTable>
</DocumentSection>
<DocumentActionBar>
<button class="primary-button" type="submit" :disabled="submittingOrder">
{{ submittingOrder ? "提交中..." : "创建退货单" }}
</button>
</form>
</DocumentActionBar>
</DocumentFormShell>
</FormDrawer>
<FormDrawer
@ -198,50 +194,70 @@
wide
@close="dispositionDrawerOpen = false"
>
<form class="stack-form" @submit.prevent="submitDisposition">
<label class="form-field">
<span>待处置退货行</span>
<DocumentFormShell
class="return-document-form"
title="退货处置单"
document-no="保存后自动生成"
meta-label="售后处置联单"
:business-date="new Date()"
:signature-labels="['售后确认', '仓库复核', '生产接收', '制单人']"
tone="green"
@submit="submitDisposition"
>
<DocumentSection index="01" title="处置信息">
<DocumentGrid :fields="dispositionDocumentHeaderFields" columns="3">
<template #return_item>
<select v-model.number="dispositionForm.return_item_id" required @change="applyDispositionItem">
<option disabled value="">请选择退货行</option>
<option v-for="item in currentDispositionItems" :key="item.return_item_id" :value="item.return_item_id">
{{ item.return_no }} · {{ item.product_name }} · {{ formatQty(item.return_qty) }}
</option>
</select>
</label>
<label class="form-field">
<span>处置方式</span>
</template>
<template #disposition_type>
<select v-model="dispositionForm.disposition_type" @change="applyDispositionType">
<option value="REWORK">返工</option>
<option value="SCRAP">报废</option>
</select>
</label>
<label v-if="dispositionForm.disposition_type === 'REWORK'" class="form-field">
<span>额外人工成本</span>
</template>
<template #extra_cost_amount>
<input v-model.number="dispositionForm.extra_cost_amount" type="number" min="0" step="0.01" />
</label>
<div v-if="dispositionForm.disposition_type === 'SCRAP'" class="double-field">
<label class="form-field">
<span>报废重量(kg)</span>
</template>
<template #scrap_weight_kg>
<input v-model.number="dispositionForm.scrap_weight_kg" type="number" min="0" step="0.001" />
</label>
<label class="form-field">
<span>废料销售金额</span>
</template>
<template #scrap_sale_amount>
<input v-model.number="dispositionForm.scrap_sale_amount" type="number" min="0" step="0.01" />
</label>
</div>
<label class="form-field">
<span>备注</span>
</template>
<template #remark>
<textarea v-model.trim="dispositionForm.remark" rows="2"></textarea>
</label>
</template>
</DocumentGrid>
</DocumentSection>
<DocumentSection index="02" title="处置对象预览">
<DocumentLineTable title="退货行明细" :columns="dispositionDocumentLineColumns">
<tr v-if="activeDispositionItem">
<td class="document-line-index">1</td>
<td>{{ activeDispositionItem.return_no || "-" }}</td>
<td>{{ activeDispositionItem.product_name || "-" }}</td>
<td>{{ activeDispositionItem.source_lot_no || "-" }}</td>
<td>{{ formatQty(activeDispositionItem.return_qty) }}</td>
<td>{{ formatWeight(activeDispositionItem.return_weight_kg) }}</td>
<td>{{ activeDispositionItem.responsibility_type || "-" }}</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-row">请选择待处置退货行</td>
</tr>
</DocumentLineTable>
</DocumentSection>
<DocumentActionBar>
<button class="primary-button" type="submit" :disabled="submittingDisposition">
{{ submittingDisposition ? "处理中..." : "提交处置" }}
</button>
</form>
</DocumentActionBar>
</DocumentFormShell>
</FormDrawer>
<FormDrawer
@ -287,6 +303,11 @@
import { computed, onMounted, reactive, ref } from "vue";
import DrawerDataTable from "../components/DrawerDataTable.vue";
import DocumentActionBar from "../components/documentForms/DocumentActionBar.vue";
import DocumentFormShell from "../components/documentForms/DocumentFormShell.vue";
import DocumentGrid from "../components/documentForms/DocumentGrid.vue";
import DocumentLineTable from "../components/documentForms/DocumentLineTable.vue";
import DocumentSection from "../components/documentForms/DocumentSection.vue";
import FormDrawer from "../components/FormDrawer.vue";
import PaginationBar from "../components/PaginationBar.vue";
import TableControls from "../components/TableControls.vue";
@ -330,6 +351,7 @@ const currentDispositionItems = computed(() => {
}
return pendingItems.value.filter((item) => item.return_order_id === Number(selectedReturnOrderId.value));
});
const activeDispositionItem = computed(() => selectedDispositionItem());
const visibleDispositions = computed(() => {
const order = activeReturnOrder.value;
if (!order) {
@ -385,6 +407,48 @@ const dispositionDrawerColumns = computed(() => [
{ key: "rework_work_order_no", label: "返工工单", detailOnly: true },
{ key: "scrap_sale_amount", label: "废料销售金额", detailOnly: true, format: (value) => `¥${formatAmount(value)}` }
]);
const returnDocumentHeaderFields = computed(() => [
{ key: "customer", label: "客户" },
{ key: "sales_order", label: "订单" },
{ key: "delivery", label: "发货单" },
{ key: "handler", label: "处理人" },
{ key: "return_reason", label: "退货原因", class: "document-grid-cell-wide" }
]);
const returnDocumentLineColumns = [
{ key: "index", label: "序号", width: "58px" },
{ key: "sales_order_item", label: "订单行", width: "22%" },
{ key: "product", label: "产品", width: "20%" },
{ key: "source_lot", label: "来源批次", width: "18%" },
{ key: "return_qty", label: "退货数量", width: "104px" },
{ key: "return_weight_kg", label: "退货重量(kg)", width: "126px" },
{ key: "responsibility_type", label: "责任归属", width: "132px" },
{ key: "actions", label: "操作", width: "92px" }
];
const dispositionDocumentHeaderFields = computed(() => {
const fields = [
{ key: "return_item", label: "待处置退货行", class: "document-grid-cell-wide" },
{ key: "disposition_type", label: "处置方式" }
];
if (dispositionForm.disposition_type === "SCRAP") {
fields.push(
{ key: "scrap_weight_kg", label: "报废重量(kg)" },
{ key: "scrap_sale_amount", label: "废料销售金额" }
);
} else {
fields.push({ key: "extra_cost_amount", label: "额外人工成本" });
}
fields.push({ key: "remark", label: "备注", class: "document-grid-cell-wide" });
return fields;
});
const dispositionDocumentLineColumns = [
{ key: "index", label: "序号", width: "58px" },
{ key: "return_no", label: "退货单号", width: "18%" },
{ key: "product", label: "产品", width: "24%" },
{ key: "source_lot", label: "来源批次", width: "20%" },
{ key: "return_qty", label: "退货数量", width: "104px" },
{ key: "return_weight_kg", label: "退货重量", width: "112px" },
{ key: "responsibility_type", label: "责任归属", width: "120px" }
];
const {
page: returnOrderPage,
pageSize: returnOrderPageSize,

View File

@ -46,3 +46,55 @@ describe("SystemPermissionView maintenance scrolling", () => {
assert.match(mainStyles, /\.system-permission-page\.system-permission-page--maintenance\s+\.system-permission-maintenance-card\s*\{[\s\S]*?overflow:\s*visible;/);
});
});
describe("SystemPermissionView organization employee table columns", () => {
it("keeps the organization employee table aligned after adding the organization path column", () => {
assert.match(mainStyles, /\.tree-layout-card\s+\.org-employee-table th:nth-child\(7\),[\s\S]*?td:nth-child\(7\)\s*\{[\s\S]*?width:\s*19%\s*!important;/);
assert.match(mainStyles, /\.tree-layout-card\s+\.org-employee-table \.action-row\s*\{[\s\S]*?display:\s*table-cell\s*!important;/);
assert.match(mainStyles, /\.tree-layout-card\s+\.org-employee-table \.employee-actions\s*\{[\s\S]*?display:\s*inline-flex\s*!important;[\s\S]*?min-width:\s*max-content\s*!important;/);
});
});
describe("SystemPermissionView authorize drawer", () => {
it("uses card-style role selection instead of a native multi-select", () => {
const authorizeStart = source.indexOf('v-if="showAuthorizeDrawer"');
const authorizeEnd = source.indexOf('v-if="showRoleDrawer"', authorizeStart);
const authorizeSource = source.slice(authorizeStart, authorizeEnd);
assert.doesNotMatch(authorizeSource, /<select\s+v-model="authorizeForm\.role_ids"\s+multiple>/);
assert.match(authorizeSource, /class="authorize-role-grid"/);
assert.match(authorizeSource, /v-for="role in roles"/);
assert.match(authorizeSource, /@click="toggleAuthorizeRole\(role\.role_id\)"/);
assert.match(authorizeSource, /isAuthorizeRoleSelected\(role\.role_id\)/);
assert.match(authorizeSource, /selectedAuthorizeRoleNames/);
assert.match(authorizeSource, /roleRiskText\(role\)/);
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

@ -156,42 +156,87 @@
</div>
<div v-if="showAuthorizeDrawer" class="workflow-drawer-mask system-permission-drawer-mask" @click.self="showAuthorizeDrawer = false">
<aside class="form-drawer system-permission-drawer">
<div class="drawer-header">
<aside class="form-drawer system-permission-drawer authorize-drawer">
<div class="drawer-header authorize-drawer-header">
<div>
<p class="eyebrow">人员授权</p>
<h2>{{ authorizingEmployee?.employee_name || "人员授权" }}</h2>
<p class="authorize-header-note">为该人员创建或更新系统账号并分配可访问的系统角色</p>
</div>
<button class="ghost-button" type="button" @click="showAuthorizeDrawer = false">关闭</button>
<button class="ghost-button authorize-close-button" type="button" @click="showAuthorizeDrawer = false">关闭窗口</button>
</div>
<div class="authorize-summary">
<section class="authorize-profile-card" aria-label="授权对象">
<div class="authorize-avatar">{{ authorizingEmployeeInitial }}</div>
<div class="authorize-profile-main">
<strong>{{ authorizingEmployee?.employee_name || "未命名人员" }}</strong>
<span>{{ authorizingOrganizationPath }}</span>
</div>
<div class="authorize-profile-meta">
<span>电话 / 账号{{ authorizingEmployeePhone || "未设置电话号码" }}</span>
<span>所属组织{{ authorizingEmployee?.parent_node_label || "未标记组织" }}</span>
<span>账号状态<StatusBadge :value="authorizingSystemUser?.status" :text="authorizingSystemUser ? '' : '未创建账号'" /></span>
<span>账号状态<StatusBadge :value="authorizingSystemUser?.status" :text="authorizingSystemUser ? accountStatusLabel(authorizingSystemUser.status) : '未创建账号'" /></span>
</div>
</section>
<label class="form-field">
<span>角色</span>
<select v-model="authorizeForm.role_ids" multiple>
<option v-for="role in roles" :key="role.role_id" :value="role.role_id">
{{ role.role_name || role.role_code }}
</option>
</select>
</label>
<label v-if="!authorizingSystemUser" class="form-field">
<span>密码</span>
<section class="authorize-section authorize-role-section" aria-label="角色授权">
<div class="authorize-section-head">
<div>
<p class="eyebrow">角色授权</p>
<h3>选择系统角色</h3>
</div>
<span class="authorize-selected-count">已选 {{ selectedAuthorizeRoles.length }} 个角色</span>
</div>
<div class="authorize-selected-strip">
<span v-if="selectedAuthorizeRoleNames.length">{{ selectedAuthorizeRoleNames.join("") }}</span>
<span v-else>尚未选择角色至少选择一个角色后才能保存</span>
</div>
<div class="authorize-role-grid">
<button
v-for="role in roles"
:key="role.role_id"
type="button"
class="authorize-role-card"
:class="{ active: isAuthorizeRoleSelected(role.role_id), danger: isHighRiskRole(role) }"
@click="toggleAuthorizeRole(role.role_id)"
>
<span class="authorize-role-check" aria-hidden="true">{{ isAuthorizeRoleSelected(role.role_id) ? "✓" : "" }}</span>
<span class="authorize-role-body">
<strong>{{ roleDisplayName(role) }}</strong>
<small>{{ roleDisplayCode(role) }}</small>
<em>{{ roleRiskText(role) }}</em>
</span>
</button>
</div>
</section>
<section class="authorize-section authorize-account-section" aria-label="账号设置">
<div class="authorize-section-head">
<div>
<p class="eyebrow">账号设置</p>
<h3>{{ authorizingSystemUser ? "账号状态" : "创建账号" }}</h3>
</div>
</div>
<label v-if="!authorizingSystemUser" class="form-field authorize-password-field">
<span>初始密码</span>
<input v-model="authorizeForm.password" type="password" placeholder="首次创建账号必填最少6位" />
</label>
<label class="form-field">
<span>账号状态</span>
<select v-model="authorizeForm.status">
<option value="ACTIVE">启用</option>
<option value="INACTIVE">停用</option>
</select>
</label>
<div class="drawer-actions">
<button class="primary-button" type="button" @click="submitAuthorizeUser">保存授权</button>
<div v-else class="authorize-account-note">
该人员已创建系统账号本次仅更新角色和账号启停状态如需修改密码请使用右上角账号重置入口
</div>
<div class="authorize-status-switch" role="group" aria-label="账号状态">
<button type="button" :class="{ active: authorizeForm.status === 'ACTIVE' }" @click="authorizeForm.status = 'ACTIVE'">启用账号</button>
<button type="button" :class="{ active: authorizeForm.status === 'INACTIVE' }" @click="authorizeForm.status = 'INACTIVE'">停用账号</button>
</div>
</section>
<div class="authorize-save-summary">
将为 <strong>{{ authorizingEmployee?.employee_name || "该人员" }}</strong>
授权 <strong>{{ selectedAuthorizeRoles.length }}</strong> 个角色账号状态为
<strong>{{ accountStatusLabel(authorizeForm.status) }}</strong>
</div>
<div class="drawer-actions authorize-actions">
<button class="primary-button" type="button" :disabled="!authorizeForm.role_ids.length" @click="submitAuthorizeUser">保存授权</button>
<button class="ghost-button" type="button" @click="showAuthorizeDrawer = false">取消</button>
</div>
</aside>
@ -450,6 +495,22 @@ const authorizingEmployeePhone = computed(() =>
authorizingEmployee.value?.mobile || authorizingSystemUser.value?.mobile || authorizingSystemUser.value?.username || ""
);
const authorizingOrganizationPath = computed(() =>
authorizingEmployee.value?.organization_path || authorizingEmployee.value?.parent_node_label || "未标记组织"
);
const authorizingEmployeeInitial = computed(() => {
const name = authorizingEmployee.value?.employee_name || authorizingEmployee.value?.display_name || authorizingEmployee.value?.username || "人";
return String(name).slice(0, 1);
});
const selectedAuthorizeRoles = computed(() => {
const selectedIds = new Set(authorizeForm.role_ids.map(String));
return roles.value.filter((role) => selectedIds.has(String(role.role_id)));
});
const selectedAuthorizeRoleNames = computed(() => selectedAuthorizeRoles.value.map((role) => roleDisplayName(role)));
const orgDrawerTitle = computed(() => {
if (orgDrawerMode.value === "rename") {
return "重命名";
@ -598,6 +659,46 @@ function defaultDeptType(type) {
}[type] || "ADMIN";
}
function isAuthorizeRoleSelected(roleId) {
return authorizeForm.role_ids.map(String).includes(String(roleId));
}
function toggleAuthorizeRole(roleId) {
const targetId = String(roleId);
if (isAuthorizeRoleSelected(targetId)) {
authorizeForm.role_ids = authorizeForm.role_ids.filter((id) => String(id) !== targetId);
} else {
authorizeForm.role_ids = [...authorizeForm.role_ids, targetId];
}
}
function roleDisplayName(role) {
return role?.role_name || role?.name || role?.role_code || "未命名角色";
}
function roleDisplayCode(role) {
return role?.role_code ? `角色编码:${role.role_code}` : "未设置角色编码";
}
function isHighRiskRole(role) {
const text = [role?.role_code, roleDisplayName(role)].filter(Boolean).join(" ").toUpperCase();
return text.includes("ADMIN") || text.includes("超级") || text.includes("管理员");
}
function roleRiskText(role) {
if (isHighRiskRole(role)) {
return "高权限角色,请谨慎授权";
}
return "业务角色,可按岗位职责授权";
}
function accountStatusLabel(status) {
if (status === "INACTIVE") {
return "停用";
}
return "启用";
}
function nodeActions(node) {
if (!node) {
return [];
@ -614,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: "新增部门节点" });
@ -633,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;
}
@ -1291,6 +1397,294 @@ onMounted(async () => {
background: #ffffff;
}
.authorize-drawer {
width: min(760px, calc(100vw - 32px));
gap: 16px;
}
.authorize-drawer-header {
align-items: flex-start;
}
.authorize-header-note {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.authorize-close-button {
min-width: 108px;
}
.authorize-profile-card,
.authorize-section,
.authorize-save-summary {
border: 1px solid rgba(190, 205, 224, 0.95);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.05);
}
.authorize-profile-card {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) minmax(220px, 0.72fr);
align-items: center;
gap: 14px;
padding: 16px;
background:
linear-gradient(135deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.92));
}
.authorize-avatar {
display: grid;
place-items: center;
width: 54px;
height: 54px;
border-radius: 18px;
background: linear-gradient(135deg, #2563eb, #38bdf8);
color: #ffffff;
font-size: 24px;
font-weight: 900;
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.24);
}
.authorize-profile-main,
.authorize-profile-meta {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.authorize-profile-main strong {
color: #0f172a;
font-size: 22px;
font-weight: 900;
}
.authorize-profile-main span,
.authorize-profile-meta span {
overflow: hidden;
color: #475569;
font-size: 13px;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.authorize-section {
display: grid;
gap: 12px;
padding: 16px;
}
.authorize-section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.authorize-section-head h3 {
margin: 0;
color: #0f172a;
font-size: 18px;
}
.authorize-selected-count {
flex: 0 0 auto;
border-radius: 999px;
padding: 6px 10px;
background: #dbeafe;
color: #1d4ed8;
font-size: 12px;
font-weight: 850;
}
.authorize-selected-strip {
min-height: 36px;
border: 1px dashed rgba(96, 165, 250, 0.5);
border-radius: 14px;
padding: 9px 12px;
background: #f8fbff;
color: #475569;
font-size: 13px;
font-weight: 750;
}
.authorize-role-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.authorize-role-card {
position: relative;
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: flex-start;
gap: 10px;
min-height: 96px;
border: 1px solid rgba(190, 205, 224, 0.95);
border-radius: 18px;
padding: 14px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
color: #0f172a;
text-align: left;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease, background 0.16s ease;
}
.authorize-role-card:hover {
border-color: rgba(37, 99, 235, 0.42);
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.authorize-role-card.active {
border-color: rgba(37, 99, 235, 0.78);
background: linear-gradient(180deg, #eff6ff, #ffffff);
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18), 0 14px 28px rgba(37, 99, 235, 0.12);
}
.authorize-role-card.danger.active {
border-color: rgba(249, 115, 22, 0.86);
background: linear-gradient(180deg, #fff7ed, #ffffff);
box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.2), 0 14px 28px rgba(249, 115, 22, 0.12);
}
.authorize-role-check {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
border: 1px solid rgba(148, 163, 184, 0.72);
border-radius: 8px;
background: #ffffff;
color: #2563eb;
font-size: 14px;
font-weight: 950;
}
.authorize-role-card.active .authorize-role-check {
border-color: #2563eb;
background: #2563eb;
color: #ffffff;
}
.authorize-role-card.danger.active .authorize-role-check {
border-color: #f97316;
background: #f97316;
}
.authorize-role-body {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.authorize-role-body strong,
.authorize-role-body small,
.authorize-role-body em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.authorize-role-body strong {
color: #0f172a;
font-size: 16px;
font-style: normal;
font-weight: 900;
}
.authorize-role-body small {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.authorize-role-body em {
width: fit-content;
max-width: 100%;
border-radius: 999px;
padding: 3px 8px;
background: #eef6ff;
color: #2563eb;
font-size: 11px;
font-style: normal;
font-weight: 850;
}
.authorize-role-card.danger .authorize-role-body em {
background: #fff7ed;
color: #c2410c;
}
.authorize-account-section {
background:
linear-gradient(135deg, rgba(248, 250, 252, 0.98), rgba(255, 255, 255, 0.92));
}
.authorize-password-field {
margin: 0;
}
.authorize-account-note {
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 14px;
padding: 11px 12px;
background: #f8fafc;
color: #475569;
font-size: 13px;
font-weight: 750;
line-height: 1.6;
}
.authorize-status-switch {
display: inline-flex;
width: fit-content;
border: 1px solid rgba(190, 205, 224, 0.95);
border-radius: 999px;
padding: 4px;
background: #f8fafc;
gap: 4px;
}
.authorize-status-switch button {
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: transparent;
color: #64748b;
font-weight: 850;
cursor: pointer;
}
.authorize-status-switch button.active {
background: #2563eb;
color: #ffffff;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.22);
}
.authorize-save-summary {
padding: 13px 16px;
color: #475569;
font-size: 13px;
font-weight: 750;
}
.authorize-save-summary strong {
color: #0f172a;
font-weight: 900;
}
.authorize-actions {
justify-content: flex-end;
}
.permission-tree {
display: flex;
flex-direction: column;