Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dbb5fa70 | ||
|
|
705ecf1e5a | ||
|
|
b024b061e9 | ||
|
|
867fc3beee | ||
|
|
9b60aa3e0d | ||
|
|
30fb7af159 | ||
|
|
6d87a2e1cd | ||
|
|
707db3a482 | ||
|
|
c424633030 | ||
|
|
eb0f2eff95 | ||
|
|
5ec8a98125 |
@ -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"),
|
||||
|
||||
@ -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": {},
|
||||
|
||||
@ -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)
|
||||
@ -398,6 +398,32 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
||||
self.assertEqual(tree[0].children[0].parent_id, self.company.id)
|
||||
self.assertEqual(tree[0].children[0].node_type, "BRANCH")
|
||||
|
||||
def test_create_org_node_allows_company_direct_department(self) -> None:
|
||||
from app.schemas.system_permissions import OrgNodeCreate
|
||||
from app.services.system_permissions import create_org_node, build_org_tree
|
||||
|
||||
direct_department = create_org_node(
|
||||
self.db,
|
||||
OrgNodeCreate(
|
||||
parent_id=self.company.id,
|
||||
node_type="DEPARTMENT",
|
||||
node_label="总公司直属部门",
|
||||
dept_code="DIRECT-DEPT",
|
||||
dept_type="ADMIN",
|
||||
manager_employee_id=None,
|
||||
sort_no=1,
|
||||
remark=None,
|
||||
status="ACTIVE",
|
||||
),
|
||||
)
|
||||
|
||||
tree = build_org_tree(self.db)
|
||||
self.assertEqual(direct_department.parent_id, self.company.id)
|
||||
self.assertEqual(direct_department.node_type, "DEPARTMENT")
|
||||
self.assertTrue(
|
||||
any(child.node_label == "总公司直属部门" and child.node_type == "DEPARTMENT" for child in tree[0].children)
|
||||
)
|
||||
|
||||
def test_create_org_node_rejects_invalid_child_level(self) -> None:
|
||||
from fastapi import HTTPException
|
||||
from app.schemas.system_permissions import OrgNodeCreate
|
||||
@ -408,9 +434,9 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
||||
self.db,
|
||||
OrgNodeCreate(
|
||||
parent_id=self.company.id,
|
||||
node_type="DEPARTMENT",
|
||||
node_label="错误部门",
|
||||
dept_code="BAD-DEPT",
|
||||
node_type="GROUP",
|
||||
node_label="错误小组",
|
||||
dept_code="BAD-GROUP",
|
||||
dept_type="ADMIN",
|
||||
manager_employee_id=None,
|
||||
sort_no=1,
|
||||
@ -419,7 +445,7 @@ class SystemPermissionManagementTest(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
self.assertIn("总公司下只能新增分公司", str(ctx.exception.detail))
|
||||
self.assertIn("总公司下只能新增分公司或直属部门", str(ctx.exception.detail))
|
||||
|
||||
def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
493
docs/superpowers/plans/2026-06-15-org-tree-action-parity.md
Normal file
493
docs/superpowers/plans/2026-06-15-org-tree-action-parity.md
Normal file
@ -0,0 +1,493 @@
|
||||
# 组织树与脑图操作能力一致 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让“组织树模式”和“脑图模式”只是展示形式不同,组织节点和人员节点的操作能力完全一致。
|
||||
|
||||
**Architecture:** 保留 `SystemPermissionView.vue` 作为唯一组织动作编排层,继续复用现有 `nodeActions(node)`、`runNodeAction(actionKey, node)`、各类 drawer 打开函数和删除/授权函数。`OrgPermissionTree.vue` 只负责展示组织树、选中节点、发出节点菜单事件,不新增第二套业务动作逻辑。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`、现有静态 Node test、Vite、现有 `OrgPermissionTree.vue` / `SystemPermissionView.vue` 组件体系。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `frontend/src/components/OrgPermissionTree.vue`
|
||||
- 增加右侧“节点操作”入口。
|
||||
- 继续支持组织树节点右键菜单。
|
||||
- 通过 `node-contextmenu` emit 当前节点和菜单坐标,不直接执行业务动作。
|
||||
- 调整右侧 header 按钮布局,让“节点操作”和“新增人员”共存。
|
||||
|
||||
- Modify: `frontend/src/components/OrgPermissionTree.test.js`
|
||||
- 锁定组织树存在“节点操作”按钮。
|
||||
- 锁定组织树节点右键仍然 emit `open-menu`。
|
||||
- 锁定组织树不新增第二套动作函数,只 emit `node-contextmenu`。
|
||||
|
||||
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||||
- 复用现有 `openContextMenu(eventPayload)` 和 `runNodeAction(actionKey, node)`。
|
||||
- 必要时给当前节点操作入口提供更稳定的菜单坐标处理。
|
||||
- 不复制 `nodeActions` 到子组件。
|
||||
|
||||
- Modify: `frontend/src/views/SystemPermissionView.test.js`
|
||||
- 锁定组织树仍绑定 `@node-contextmenu="openContextMenu"`。
|
||||
- 锁定组织树的“节点操作”最终仍进入同一套 `contextMenuActions` / `runNodeAction`。
|
||||
|
||||
- Modify: `frontend/src/styles/main.css` if needed
|
||||
- 如果组件 scoped 样式不够覆盖全局表格/抽屉样式,补少量全局样式。
|
||||
- 不引入新的视觉体系。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 锁定组织树动作入口契约
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/OrgPermissionTree.test.js`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,要求组织树有显式节点操作入口**
|
||||
|
||||
在 `OrgPermissionTree.test.js` 末尾追加:
|
||||
|
||||
```js
|
||||
describe("OrgPermissionTree node action parity", () => {
|
||||
it("exposes a visible current-node action entry that emits the shared node context menu event", () => {
|
||||
assert.match(source, /节点操作/);
|
||||
assert.match(source, /@click="openSelectedNodeMenu"/);
|
||||
assert.match(source, /function openSelectedNodeMenu\(event\)/);
|
||||
assert.match(source, /emit\("node-contextmenu"/);
|
||||
assert.doesNotMatch(source, /function runNodeAction\(/);
|
||||
assert.doesNotMatch(source, /function nodeActions\(/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||
node src/components/OrgPermissionTree.test.js
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
not ok ... exposes a visible current-node action entry ...
|
||||
```
|
||||
|
||||
失败原因应是当前源码没有 `节点操作`、`openSelectedNodeMenu`。
|
||||
|
||||
- [ ] **Step 3: 不修改生产代码,先提交测试是不需要的**
|
||||
|
||||
此任务只建立红灯,不单独提交。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 在组织树右侧 header 增加“节点操作”入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/OrgPermissionTree.vue`
|
||||
|
||||
- [ ] **Step 1: 修改右侧 header 按钮区**
|
||||
|
||||
将当前右侧 header 中的单个“新增人员”按钮:
|
||||
|
||||
```vue
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="!canAddEmployee"
|
||||
@click="addEmployee"
|
||||
>
|
||||
新增人员
|
||||
</button>
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```vue
|
||||
<div class="employee-head-actions">
|
||||
<button
|
||||
class="ghost-button node-action-button"
|
||||
type="button"
|
||||
:disabled="!selectedNode"
|
||||
@click="openSelectedNodeMenu"
|
||||
>
|
||||
节点操作
|
||||
</button>
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="!canAddEmployee"
|
||||
@click="addEmployee"
|
||||
>
|
||||
新增人员
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 `openSelectedNodeMenu` 函数**
|
||||
|
||||
在 `openNodeMenu(payload)` 下方添加:
|
||||
|
||||
```js
|
||||
function openSelectedNodeMenu(event) {
|
||||
if (!selectedNode.value) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget?.getBoundingClientRect?.();
|
||||
emit("node-contextmenu", {
|
||||
node: selectedNode.value,
|
||||
x: rect ? rect.left : event.clientX,
|
||||
y: rect ? rect.bottom + 6 : event.clientY
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
说明:这里仍然只 emit `node-contextmenu`,由父组件现有 `openContextMenu` 接管菜单,不在子组件里实现动作。
|
||||
|
||||
- [ ] **Step 3: 添加 scoped 样式**
|
||||
|
||||
在 `.employee-head` 附近增加:
|
||||
|
||||
```css
|
||||
.employee-head-actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.node-action-button {
|
||||
border: 1px solid rgba(148, 163, 184, 0.5);
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.node-action-button:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行组件测试确认 Task 1 变绿**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||
node src/components/OrgPermissionTree.test.js
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
# pass ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 锁定父页面复用同一套节点菜单
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/SystemPermissionView.test.js`
|
||||
|
||||
- [ ] **Step 1: 写测试确认组织树和脑图共用 `openContextMenu`**
|
||||
|
||||
在 `SystemPermissionView.test.js` 末尾追加:
|
||||
|
||||
```js
|
||||
describe("SystemPermissionView organization view action parity", () => {
|
||||
it("routes both mind map and tree node actions through the same context menu action pipeline", () => {
|
||||
assert.match(source, /<OrgMindMap[\s\S]*?@node-contextmenu="openContextMenu"/);
|
||||
assert.match(source, /<OrgPermissionTree[\s\S]*?@node-contextmenu="openContextMenu"/);
|
||||
assert.match(source, /const contextMenuActions = computed\(\(\) => \(contextMenu\.node \? nodeActions\(contextMenu\.node\) : \[\]\)\);/);
|
||||
assert.match(source, /function runNodeAction\(actionKey, node\)/);
|
||||
assert.match(source, /openOrgRenameDrawer\(node\)/);
|
||||
assert.match(source, /openOrgManagerDrawer\(node\)/);
|
||||
assert.match(source, /openOrgCreateDrawer\(node,\s*"DEPARTMENT"\)/);
|
||||
assert.match(source, /openOrgEmployeeDrawer\(node\)/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||
node src/views/SystemPermissionView.test.js
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
# pass ...
|
||||
```
|
||||
|
||||
如果失败,说明当前源码实际函数名或字符串不同;不要新增第二套函数,先核对现有 `nodeActions` 和 `runNodeAction`,再调整测试为真实函数名。
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 优化上下文菜单打开位置与组织树使用体验
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||||
|
||||
- [ ] **Step 1: 检查现有 `openContextMenu`**
|
||||
|
||||
确认当前函数类似:
|
||||
|
||||
```js
|
||||
function openContextMenu(eventPayload) {
|
||||
selectedNode.value = eventPayload.node;
|
||||
contextMenu.visible = true;
|
||||
contextMenu.x = eventPayload.x;
|
||||
contextMenu.y = eventPayload.y;
|
||||
contextMenu.node = eventPayload.node;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改为带视口保护的位置计算**
|
||||
|
||||
将 `openContextMenu` 替换为:
|
||||
|
||||
```js
|
||||
function openContextMenu(eventPayload) {
|
||||
selectedNode.value = eventPayload.node;
|
||||
const menuWidth = 220;
|
||||
const menuHeight = 280;
|
||||
const viewportWidth = window.innerWidth || 1280;
|
||||
const viewportHeight = window.innerHeight || 720;
|
||||
contextMenu.visible = true;
|
||||
contextMenu.x = Math.max(12, Math.min(Number(eventPayload.x || 12), viewportWidth - menuWidth - 12));
|
||||
contextMenu.y = Math.max(12, Math.min(Number(eventPayload.y || 12), viewportHeight - menuHeight - 12));
|
||||
contextMenu.node = eventPayload.node;
|
||||
}
|
||||
```
|
||||
|
||||
说明:组织树右侧“节点操作”按钮会把菜单开在按钮下方;如果靠右或靠下,菜单不应跑出屏幕。
|
||||
|
||||
- [ ] **Step 3: 运行父页面测试**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||
node src/views/SystemPermissionView.test.js
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
# pass ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 手动浏览器验收
|
||||
|
||||
**Files:**
|
||||
- No code changes.
|
||||
|
||||
- [ ] **Step 1: 打开系统权限管理页面**
|
||||
|
||||
Use in-app Browser:
|
||||
|
||||
```text
|
||||
http://localhost:5173/system-permissions
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验收组织树模式下组织节点操作**
|
||||
|
||||
操作:
|
||||
|
||||
1. 切换到“组织树模式”。
|
||||
2. 点击左侧“总公司”节点。
|
||||
3. 点击右侧“节点操作”。
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
菜单出现,包含:重命名、添加该节点的主管、新增分公司节点。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验收分公司节点操作**
|
||||
|
||||
操作:
|
||||
|
||||
1. 点击“旭升车间”或“嘉恒车间”。
|
||||
2. 点击“节点操作”。
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
菜单出现,包含:重命名、添加该节点的主管、新增部门节点、删除节点。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验收部门节点操作**
|
||||
|
||||
操作:
|
||||
|
||||
1. 点击“旭升加工部”或“嘉恒生产部”。
|
||||
2. 点击“节点操作”。
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
菜单出现,包含:重命名、添加该节点的主管、新增小组节点、新增人员、删除节点。
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验收右键入口**
|
||||
|
||||
操作:
|
||||
|
||||
1. 在组织树左侧任意组织节点上右键。
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
弹出的菜单内容和右侧“节点操作”按钮一致。
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 验收人员操作仍正常**
|
||||
|
||||
操作:
|
||||
|
||||
1. 在右侧人员列表点击“人员授权”。
|
||||
2. 确认弹窗仍为卡片式角色授权界面。
|
||||
3. 关闭弹窗。
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
人员授权弹窗打开正常,不受节点操作入口影响。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 回归验证与提交
|
||||
|
||||
**Files:**
|
||||
- All modified files from earlier tasks.
|
||||
|
||||
- [ ] **Step 1: 运行关键测试**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||
node src/components/OrgPermissionTree.test.js
|
||||
node src/views/SystemPermissionView.test.js
|
||||
node src/utils/orgTreeEmployees.test.js
|
||||
node src/App.test.js
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
# fail 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行构建**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
✓ built
|
||||
```
|
||||
|
||||
允许出现当前项目既有的大 chunk warning,不作为阻断。
|
||||
|
||||
- [ ] **Step 3: 检查 diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||||
git diff --stat
|
||||
git diff -- frontend/src/components/OrgPermissionTree.vue frontend/src/components/OrgPermissionTree.test.js frontend/src/views/SystemPermissionView.vue frontend/src/views/SystemPermissionView.test.js
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
只包含组织树动作入口、菜单位置保护、相关测试和少量样式。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 等用户确认后提交 main**
|
||||
|
||||
Run only after user confirms:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||||
git add frontend/src/components/OrgPermissionTree.vue frontend/src/components/OrgPermissionTree.test.js frontend/src/views/SystemPermissionView.vue frontend/src/views/SystemPermissionView.test.js
|
||||
git commit -m "补齐组织树节点操作能力"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
main -> main
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 同步 BH_DEV**
|
||||
|
||||
Run only after main push succeeds:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||||
git switch BH_DEV
|
||||
git pull --ff-only origin BH_DEV
|
||||
git cherry-pick <main_commit_hash>
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
[BH_DEV <hash>] 补齐组织树节点操作能力
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 在 BH_DEV 运行同样验证并推送**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||||
node src/components/OrgPermissionTree.test.js
|
||||
node src/views/SystemPermissionView.test.js
|
||||
node src/utils/orgTreeEmployees.test.js
|
||||
node src/App.test.js
|
||||
npm run build
|
||||
cd ..
|
||||
git push origin BH_DEV
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
BH_DEV -> BH_DEV
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
已覆盖“组织树和脑图只是展示不同,功能一致”的核心要求。组织树右键入口和显式“节点操作”入口都会进入现有 `openContextMenu`,菜单项仍由 `nodeActions` 根据节点类型动态决定。
|
||||
|
||||
**Placeholder scan:**
|
||||
未使用 TBD/TODO/后续补充。每个任务都有具体文件、代码片段、命令和预期结果。
|
||||
|
||||
**Type consistency:**
|
||||
计划中使用的函数名与当前代码一致:`openContextMenu`、`nodeActions`、`runNodeAction`、`openOrgCreateDrawer`、`openOrgRenameDrawer`、`openOrgManagerDrawer`、`openOrgEmployeeDrawer`、`openAuthorizeDrawer`、`removeOrgEmployee`、`removeOrgNode`。
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
73
frontend/src/components/OrgPermissionTree.test.js
Normal file
73
frontend/src/components/OrgPermissionTree.test.js
Normal 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, /主管:未设置/);
|
||||
});
|
||||
});
|
||||
@ -35,16 +35,27 @@
|
||||
<div>
|
||||
<p class="eyebrow">组织人员</p>
|
||||
<h3>{{ selectedNodeLabel }}</h3>
|
||||
<p class="node-manager-line" :title="selectedNodeManagerText">{{ selectedNodeManagerText }}</p>
|
||||
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
|
||||
</div>
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="!canAddEmployee"
|
||||
@click="addEmployee"
|
||||
>
|
||||
新增人员
|
||||
</button>
|
||||
<div class="employee-head-actions">
|
||||
<button
|
||||
class="ghost-button node-action-button"
|
||||
type="button"
|
||||
:disabled="!selectedNode"
|
||||
@click.stop="openSelectedNodeMenu"
|
||||
>
|
||||
节点操作
|
||||
</button>
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="!canAddEmployee"
|
||||
@click="addEmployee"
|
||||
>
|
||||
新增人员
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="employee-search-field">
|
||||
@ -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;
|
||||
|
||||
@ -8,10 +8,14 @@
|
||||
:signature-labels="signatureLabels"
|
||||
>
|
||||
<div class="document-form-meta" :aria-label="metaAriaLabel">
|
||||
<span>{{ metaLabel }}</span>
|
||||
<strong>{{ companyName }}</strong>
|
||||
<span>{{ dateLabel }}</span>
|
||||
<strong>{{ displayBusinessDate }}</strong>
|
||||
<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">
|
||||
|
||||
@ -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;/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api";
|
||||
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "http://localhost:8000/api";
|
||||
const SESSION_KEY = "forgeflow.erp.session";
|
||||
|
||||
async function parseResponse(response) {
|
||||
export async function parseResponse(response) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const isJson = contentType.includes("application/json");
|
||||
const data = isJson ? await response.json() : await response.text();
|
||||
const hasEmptyBody = response.status === 204 || response.status === 205;
|
||||
const rawText = hasEmptyBody ? "" : await response.text();
|
||||
const data = isJson && rawText.trim() ? JSON.parse(rawText) : rawText || null;
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;
|
||||
|
||||
39
frontend/src/services/api.test.js
Normal file
39
frontend/src/services/api.test.js
Normal file
@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { parseResponse } from "./api.js";
|
||||
|
||||
describe("api parseResponse", () => {
|
||||
it("treats no-content JSON responses as empty success instead of parsing an empty body", async () => {
|
||||
const response = new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(await parseResponse(response), null);
|
||||
});
|
||||
|
||||
it("parses normal JSON response bodies", async () => {
|
||||
const response = new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(await parseResponse(response), { ok: true });
|
||||
});
|
||||
|
||||
it("keeps backend error details when JSON error responses include detail", async () => {
|
||||
const response = new Response(JSON.stringify({ detail: "组织节点不存在" }), {
|
||||
status: 404,
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(() => parseResponse(response), /组织节点不存在/);
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
76
frontend/src/utils/orgTreeEmployees.js
Normal file
76
frontend/src/utils/orgTreeEmployees.js
Normal 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 || "";
|
||||
}
|
||||
71
frontend/src/utils/orgTreeEmployees.test.js
Normal file
71
frontend/src/utils/orgTreeEmployees.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
47
frontend/src/views/ProductSpecLiteView.test.js
Normal file
47
frontend/src/views/ProductSpecLiteView.test.js
Normal 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;/);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
50
frontend/src/views/ReturnManagementView.test.js
Normal file
50
frontend/src/views/ReturnManagementView.test.js
Normal 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 = \[/);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<textarea v-model.trim="orderForm.return_reason" rows="2"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="subsection-head">
|
||||
<strong>退货明细</strong>
|
||||
<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>
|
||||
<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 }}
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</template>
|
||||
<template #return_reason>
|
||||
<textarea v-model.trim="orderForm.return_reason" rows="2"></textarea>
|
||||
</template>
|
||||
</DocumentGrid>
|
||||
</DocumentSection>
|
||||
|
||||
<div class="double-field">
|
||||
<label class="form-field">
|
||||
<span>产品</span>
|
||||
<DocumentSection index="02" title="退货明细">
|
||||
<DocumentLineTable title="退货明细" :columns="returnDocumentLineColumns">
|
||||
<template #actions>
|
||||
<button class="ghost-button" type="button" @click="addRow">增加一行</button>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<button class="primary-button" type="submit" :disabled="submittingOrder">
|
||||
{{ submittingOrder ? "提交中..." : "创建退货单" }}
|
||||
</button>
|
||||
</form>
|
||||
<DocumentActionBar>
|
||||
<button class="primary-button" type="submit" :disabled="submittingOrder">
|
||||
{{ submittingOrder ? "提交中..." : "创建退货单" }}
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<template #disposition_type>
|
||||
<select v-model="dispositionForm.disposition_type" @change="applyDispositionType">
|
||||
<option value="REWORK">返工</option>
|
||||
<option value="SCRAP">报废</option>
|
||||
</select>
|
||||
</template>
|
||||
<template #extra_cost_amount>
|
||||
<input v-model.number="dispositionForm.extra_cost_amount" type="number" min="0" step="0.01" />
|
||||
</template>
|
||||
<template #scrap_weight_kg>
|
||||
<input v-model.number="dispositionForm.scrap_weight_kg" type="number" min="0" step="0.001" />
|
||||
</template>
|
||||
<template #scrap_sale_amount>
|
||||
<input v-model.number="dispositionForm.scrap_sale_amount" type="number" min="0" step="0.01" />
|
||||
</template>
|
||||
<template #remark>
|
||||
<textarea v-model.trim="dispositionForm.remark" rows="2"></textarea>
|
||||
</template>
|
||||
</DocumentGrid>
|
||||
</DocumentSection>
|
||||
|
||||
<label class="form-field">
|
||||
<span>处置方式</span>
|
||||
<select v-model="dispositionForm.disposition_type" @change="applyDispositionType">
|
||||
<option value="REWORK">返工</option>
|
||||
<option value="SCRAP">报废</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<label v-if="dispositionForm.disposition_type === 'REWORK'" class="form-field">
|
||||
<span>额外人工成本</span>
|
||||
<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>
|
||||
<input v-model.number="dispositionForm.scrap_weight_kg" type="number" min="0" step="0.001" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>废料销售金额</span>
|
||||
<input v-model.number="dispositionForm.scrap_sale_amount" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span>备注</span>
|
||||
<textarea v-model.trim="dispositionForm.remark" rows="2"></textarea>
|
||||
</label>
|
||||
|
||||
<button class="primary-button" type="submit" :disabled="submittingDisposition">
|
||||
{{ submittingDisposition ? "处理中..." : "提交处置" }}
|
||||
</button>
|
||||
</form>
|
||||
<DocumentActionBar>
|
||||
<button class="primary-button" type="submit" :disabled="submittingDisposition">
|
||||
{{ submittingDisposition ? "处理中..." : "提交处置" }}
|
||||
</button>
|
||||
</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,
|
||||
|
||||
@ -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\)\);/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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">
|
||||
<span>电话 / 账号:{{ authorizingEmployeePhone || "未设置电话号码" }}</span>
|
||||
<span>所属组织:{{ authorizingEmployee?.parent_node_label || "未标记组织" }}</span>
|
||||
<span>账号状态:<StatusBadge :value="authorizingSystemUser?.status" :text="authorizingSystemUser ? '' : '未创建账号'" /></span>
|
||||
<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>账号状态:<StatusBadge :value="authorizingSystemUser?.status" :text="authorizingSystemUser ? accountStatusLabel(authorizingSystemUser.status) : '未创建账号'" /></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user