瀏覽代碼

feat(oa): add owner person/dept fields with initiator defaults

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 月之前
父節點
當前提交
4ebc2dbe82

+ 10 - 4
backend/app/services/oa_dept_select.py

@@ -125,11 +125,17 @@ def _validate_dept_rule(rule: dict, data: Dict[str, Any]) -> List[str]:
 
 
 def validate_dept_form_values(schema: Any, form_data: Optional[Dict[str, Any]]) -> List[str]:
-    """校验 form_data 中部门选择控件的值格式,返回中文错误列表(空表示通过)。"""
+    """校验 form_data 中部门 / 归属部门控件的值格式,返回中文错误列表(空表示通过)。"""
     data = form_data or {}
     errors: List[str] = []
     for rule in _schema_rules(schema):
-        if str(rule.get("type") or "") != "dept":
-            continue
-        errors.extend(_validate_dept_rule(rule, data))
+        typ = str(rule.get("type") or "")
+        if typ == "dept":
+            errors.extend(_validate_dept_rule(rule, data))
+        elif typ == "ownerDept":
+            forced = dict(rule)
+            props = dict(_rule_props(rule))
+            props["multiple"] = False
+            forced["props"] = props
+            errors.extend(_validate_dept_rule(forced, data))
     return errors

+ 81 - 0
backend/app/services/oa_owner_fields.py

@@ -0,0 +1,81 @@
+"""OA 归属人 / 归属部门:只读强制回填与校验辅助"""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+
+def _walk_rules(rules: Any, out: List[dict]) -> None:
+    if not isinstance(rules, list):
+        return
+    for r in rules:
+        if not isinstance(r, dict):
+            continue
+        out.append(r)
+        children = r.get("children") or (r.get("props") or {}).get("rule") or []
+        if isinstance(children, list):
+            _walk_rules(children, out)
+
+
+def _schema_rules(schema: Any) -> List[dict]:
+    if isinstance(schema, list):
+        out: List[dict] = []
+        _walk_rules(schema, out)
+        return out
+    if not isinstance(schema, dict):
+        return []
+    out: List[dict] = []
+    _walk_rules(schema.get("rule") or schema.get("rules") or [], out)
+    return out
+
+
+def _rule_props(rule: dict) -> dict:
+    props = rule.get("props")
+    return props if isinstance(props, dict) else {}
+
+
+def _allow_modify(rule: dict) -> bool:
+    return _rule_props(rule).get("allowModify") is True
+
+
+def apply_owner_field_defaults(
+    schema: Any,
+    form_data: Optional[Dict[str, Any]],
+    *,
+    user_id: str,
+    user_name: str,
+    dept_id: Optional[str],
+    dept_name: Optional[str],
+) -> Dict[str, Any]:
+    """
+    归属字段处理:
+    - allowModify 为假:强制写入当前发起人 / 主部门(防篡改)
+    - allowModify 为真且字段为空:补默认值
+    """
+    data = dict(form_data or {})
+    uid = (user_id or "").strip()
+    uname = (user_name or "").strip() or uid
+    did = (dept_id or "").strip()
+    dname = (dept_name or "").strip() or did
+
+    person_val = {"id": uid, "name": uname} if uid else None
+    dept_val = {"id": did, "name": dname} if did else None
+
+    for rule in _schema_rules(schema):
+        typ = str(rule.get("type") or "")
+        field = str(rule.get("field") or "").strip()
+        if not field:
+            continue
+        allow = _allow_modify(rule)
+        cur = data.get(field)
+        empty = cur is None or cur == "" or cur == [] or (
+            isinstance(cur, dict) and not str(cur.get("id") or "").strip()
+        )
+
+        if typ == "ownerPerson" and person_val:
+            if not allow or empty:
+                data[field] = dict(person_val)
+        elif typ == "ownerDept" and dept_val:
+            if not allow or empty:
+                data[field] = dict(dept_val)
+
+    return data

+ 11 - 4
backend/app/services/oa_person_select.py

@@ -125,13 +125,20 @@ def _validate_person_rule(rule: dict, data: Dict[str, Any]) -> List[str]:
 
 
 def validate_person_form_values(schema: Any, form_data: Optional[Dict[str, Any]]) -> List[str]:
-    """校验 form_data 中人员选择控件的值格式,返回中文错误列表(空表示通过)。"""
+    """校验 form_data 中人员 / 归属人控件的值格式,返回中文错误列表(空表示通过)。"""
     data = form_data or {}
     errors: List[str] = []
     for rule in _schema_rules(schema):
-        if str(rule.get("type") or "") != "person":
-            continue
-        errors.extend(_validate_person_rule(rule, data))
+        typ = str(rule.get("type") or "")
+        if typ == "person":
+            errors.extend(_validate_person_rule(rule, data))
+        elif typ == "ownerPerson":
+            # 归属人固定单选
+            forced = dict(rule)
+            props = dict(_rule_props(rule))
+            props["multiple"] = False
+            forced["props"] = props
+            errors.extend(_validate_person_rule(forced, data))
     return errors
 
 

+ 1 - 1
backend/app/services/oa_related_approval.py

@@ -104,7 +104,7 @@ def _format_value(typ: str, raw: Any) -> Tuple[str, bool]:
         else:
             n = 0
         return f"已关联 {n} 条", False
-    if (typ == "dept" or typ == "person" or typ == "post"):
+    if (typ == "dept" or typ == "person" or typ == "post" or typ == "ownerDept" or typ == "ownerPerson"):
         if isinstance(raw, list):
             names = []
             for x in raw:

+ 106 - 0
backend/tests/test_oa_owner_fields.py

@@ -0,0 +1,106 @@
+import unittest
+
+from app.services.oa_dept_select import validate_dept_form_values
+from app.services.oa_owner_fields import apply_owner_field_defaults
+from app.services.oa_person_select import validate_person_form_values
+
+
+class TestOaOwnerFields(unittest.TestCase):
+    def test_force_overwrite_when_not_allow_modify(self):
+        schema = {
+            "rule": [
+                {
+                    "type": "ownerPerson",
+                    "field": "op1",
+                    "title": "归属人",
+                    "props": {"allowModify": False},
+                },
+                {
+                    "type": "ownerDept",
+                    "field": "od1",
+                    "title": "归属部门",
+                    "props": {"allowModify": False},
+                },
+            ]
+        }
+        data = apply_owner_field_defaults(
+            schema,
+            {
+                "op1": {"id": "hack", "name": "黑客"},
+                "od1": {"id": "d-hack", "name": "假部门"},
+            },
+            user_id="u1",
+            user_name="张三",
+            dept_id="d1",
+            dept_name="研发部",
+        )
+        self.assertEqual(data["op1"], {"id": "u1", "name": "张三"})
+        self.assertEqual(data["od1"], {"id": "d1", "name": "研发部"})
+
+    def test_fill_empty_when_allow_modify(self):
+        schema = {
+            "rule": [
+                {
+                    "type": "ownerPerson",
+                    "field": "op1",
+                    "props": {"allowModify": True},
+                }
+            ]
+        }
+        data = apply_owner_field_defaults(
+            schema,
+            {},
+            user_id="u1",
+            user_name="张三",
+            dept_id="d1",
+            dept_name="研发部",
+        )
+        self.assertEqual(data["op1"], {"id": "u1", "name": "张三"})
+
+    def test_keep_user_value_when_allow_modify(self):
+        schema = {
+            "rule": [
+                {
+                    "type": "ownerPerson",
+                    "field": "op1",
+                    "props": {"allowModify": True},
+                }
+            ]
+        }
+        data = apply_owner_field_defaults(
+            schema,
+            {"op1": {"id": "u2", "name": "李四"}},
+            user_id="u1",
+            user_name="张三",
+            dept_id="d1",
+            dept_name="研发部",
+        )
+        self.assertEqual(data["op1"], {"id": "u2", "name": "李四"})
+
+    def test_validate_owner_person_single(self):
+        schema = {
+            "rule": [{"type": "ownerPerson", "field": "op1", "title": "归属人"}]
+        }
+        self.assertEqual(
+            validate_person_form_values(
+                schema, {"op1": {"id": "u1", "name": "张三"}}
+            ),
+            [],
+        )
+        errs = validate_person_form_values(
+            schema, {"op1": [{"id": "u1", "name": "张三"}]}
+        )
+        self.assertTrue(any("单个" in e for e in errs))
+
+    def test_validate_owner_dept_single(self):
+        schema = {
+            "rule": [{"type": "ownerDept", "field": "od1", "title": "归属部门"}]
+        }
+        self.assertEqual(
+            validate_dept_form_values(schema, {"od1": {"id": "d1", "name": "研发"}}),
+            [],
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 50 - 0
docs/superpowers/plans/2026-07-30-oa-owner-fields.md

@@ -0,0 +1,50 @@
+# 归属人 / 归属部门 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:** 新控件类型 `ownerPerson` / `ownerDept` 包装现有人员/部门选择器;发起页注入默认值;后端发布白名单 + 只读强制回填。
+
+**Tech Stack:** Vue 3、FormCreate Designer、FastAPI
+
+**Spec:** `docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md`
+
+---
+
+### Task 1: 前端控件与注册
+
+**Files:**
+- Create: `web-admin/src/utils/oa-owner-person-select-rule.ts`
+- Create: `web-admin/src/utils/oa-owner-dept-select-rule.ts`
+- Create: `web-admin/src/utils/oa-form-create-owner-person.ts`
+- Create: `web-admin/src/utils/oa-form-create-owner-dept.ts`
+- Create: `web-admin/src/components/oa-approval/OaOwnerPersonSelect.vue`
+- Create: `web-admin/src/components/oa-approval/OaOwnerDeptSelect.vue`
+- Create: `web-admin/src/utils/oa-owner-field-defaults.ts`
+- Modify: `web-admin/src/main.ts`, `web-admin/src/oa-fc-host/main.ts`
+- Modify: `web-admin/src/views/oa-approval/start/index.vue`
+- Modify: print / condition / related display as needed
+
+- [x] 拖拽规则:label 归属人/归属部门;props.allowModify 默认 false;单选
+- [x] 组件:`disabled || !allowModify`;包装 OaPersonSelect / OaDeptSelect
+- [x] `applyOwnerFieldDefaults` + 发起页调用
+- [x] 注册 setup;打印识别新 type;条件候选含 ownerPerson
+
+### Task 2: 后端白名单与强制回填
+
+**Files:**
+- Modify: `backend/app/services/oa_publish.py`
+- Modify: `backend/app/services/oa_person_select.py` / `oa_dept_select.py` 或新增 apply
+- Modify: `backend/app/services/oa_engine.py` start_instance
+- Modify: `backend/app/services/oa_related_approval.py`
+- Test: `backend/tests/test_oa_owner_fields.py`
+
+- [x] FILLABLE + CONDITION(ownerPerson)
+- [x] 校验 owner* 同单选 person/dept
+- [x] allowModify 为假时强制当前用户/部门
+- [x] 单测覆盖
+
+### Task 3: 图谱更新
+
+- [x] `./scripts/update-graph.sh frontend`(backend 因节点过多 HTML 失败,服务代码已落地)

+ 112 - 0
docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md

@@ -0,0 +1,112 @@
+# OA 表单设计 · 归属人 / 归属部门 设计文档
+
+**日期**: 2026-07-30  
+**状态**: 已实现(计划见 `docs/superpowers/plans/2026-07-30-oa-owner-fields.md`)  
+**范围**: `web-admin` 审批设计器「2 表单设计」组件 Tab;发起页默认值;`backend` 发布白名单与提交时只读强制回填  
+**界面模式**: 对齐现有「人员」「部门」控件;视觉 Fresh Mint  
+
+---
+
+## 1. 背景与目标
+
+表单设计「组件」Tab 已有 **人员**、**部门**。业务需要两个带默认值的同类控件:
+
+- **归属人**:默认当前登录人  
+- **归属部门**:默认当前登录人主部门  
+
+### 1.1 目标
+
+- 左侧「组件」新增「归属人」「归属部门」,与人员/部门并列。  
+- 发起时自动填入当前登录人 / 主部门。  
+- 设计器属性「允许修改」:默认 **关**(只读);开则可改选。  
+- 值结构与人员/部门一致,便于打印、详情、条件(归属人)复用。
+
+### 1.2 非目标
+
+- 不改现有「人员」「部门」行为。  
+- 不放入「套件」Tab。  
+- 不支持多选。
+
+---
+
+## 2. 产品决策(已确认)
+
+| 项 | 决策 |
+|----|------|
+| 控件类型 | `ownerPerson` / `ownerDept` |
+| 默认值时机 | 发起审批表单加载时写入;设计器画布不依赖设计者登录身份作为业务默认 |
+| 默认来源 | 归属人 = 当前用户 id + 姓名;归属部门 = 主部门 id + 名称(全路径优先) |
+| 允许修改 | 属性开关 `allowModify`,**默认 false** |
+| 选型 | 固定单选 |
+| 值格式 | `{ id, name }`(与 person / dept 一致) |
+| 只读防篡改 | `allowModify === false` 时,后端发起提交强制回填为当前用户 / 主部门 |
+
+---
+
+## 3. Schema
+
+```json
+{
+  "type": "ownerPerson",
+  "field": "Fxxxx",
+  "title": "归属人",
+  "props": {
+    "allowModify": false
+  }
+}
+```
+
+```json
+{
+  "type": "ownerDept",
+  "field": "Fyyyy",
+  "title": "归属部门",
+  "props": {
+    "allowModify": false
+  }
+}
+```
+
+---
+
+## 4. 运行时
+
+### 4.1 前端(发起页)
+
+- 加载 schema 后:对空字段写入默认值。  
+- `allowModify === false`:控件 `disabled`(只读展示)。  
+- `allowModify === true`:复用人员/部门选择器,可改选。  
+- 详情页:展示已存 `formData`,不再按当前查看者覆盖。
+
+### 4.2 后端(发起)
+
+- 发布白名单纳入 `ownerPerson` / `ownerDept`。  
+- 校验值格式同单选 person / dept。  
+- `allowModify` 为假时:提交前强制写入发起人 id/姓名、主部门 id/名称。  
+- 条件分支:`ownerPerson` 与 `person` 同属人员类(`contains`);`ownerDept` 暂不进条件候选(与现有 `dept` 一致)。
+
+### 4.3 打印 / 关联展示
+
+- 打印与关联审批摘要:按 person / dept 同路径格式化展示。
+
+---
+
+## 5. 登录用户字段约定
+
+web-admin `userInfo`(见 `backend/app/api/public.py` `_build_user_info_payload`):
+
+| 用途 | 字段 |
+|------|------|
+| 用户 id | `id`(兼容 `userId`) |
+| 姓名 | `realName`(兼容 username) |
+| 部门 id | `regionId`(即 `sys_user.dept_id`) |
+| 部门名称 | 登录态常无 `regionName`;前端用部门树解析全路径,后端用库表 `dept_name` |
+
+---
+
+## 6. 自检
+
+- [x] 无未决占位符  
+- [x] 与「人员/部门」值格式一致  
+- [x] 默认不允许修改 + 后端强制回填  
+- [x] 范围不含套件、不多选  

+ 11 - 0
web-admin/src/assets/styles/core/oa-fc-runtime.scss

@@ -76,4 +76,15 @@
   ._fc-exceed .el-upload {
     display: none;
   }
+
+  /* 请假/加班套件时长:左对齐(EP 默认 center) */
+  .oa-leave-kit .el-input-number .el-input__inner,
+  .oa-leave-duration.el-input-number .el-input__inner,
+  .oa-leave-duration .el-input__inner,
+  .oa-overtime-kit .el-input-number .el-input__inner,
+  .oa-overtime-duration.el-input-number .el-input__inner,
+  .oa-overtime-duration .el-input__inner,
+  .el-input-number.is-left .el-input__inner {
+    text-align: left !important;
+  }
 }

+ 45 - 0
web-admin/src/components/oa-approval/OaOwnerDeptSelect.vue

@@ -0,0 +1,45 @@
+<!-- FormCreate:归属部门(默认当前登录人主部门;allowModify 控制可否改选) -->
+<template>
+  <OaDeptSelect
+    :model-value="modelValue"
+    :disabled="effectiveDisabled"
+    :multiple="false"
+    :form-create-inject="formCreateInject"
+    @update:model-value="onUpdate"
+    @change="onChange"
+  />
+</template>
+
+<script setup lang="ts">
+  import { computed } from 'vue'
+  import OaDeptSelect from '@/components/oa-approval/OaDeptSelect.vue'
+  import type { OaDeptValueItem } from '@/utils/oa-dept-value'
+
+  defineOptions({ name: 'ownerDept' })
+
+  const props = withDefaults(
+    defineProps<{
+      modelValue?: unknown
+      disabled?: boolean
+      /** 是否允许改选;默认 false(只读展示默认值) */
+      allowModify?: boolean
+      formCreateInject?: Record<string, unknown>
+    }>(),
+    { allowModify: false }
+  )
+
+  const emit = defineEmits<{
+    (e: 'update:modelValue', v: OaDeptValueItem | OaDeptValueItem[] | null): void
+    (e: 'change', v: OaDeptValueItem | OaDeptValueItem[] | null): void
+  }>()
+
+  const effectiveDisabled = computed(() => !!props.disabled || !props.allowModify)
+
+  function onUpdate(v: OaDeptValueItem | OaDeptValueItem[] | null) {
+    emit('update:modelValue', v)
+  }
+
+  function onChange(v: OaDeptValueItem | OaDeptValueItem[] | null) {
+    emit('change', v)
+  }
+</script>

+ 45 - 0
web-admin/src/components/oa-approval/OaOwnerPersonSelect.vue

@@ -0,0 +1,45 @@
+<!-- FormCreate:归属人(默认当前登录人;allowModify 控制可否改选) -->
+<template>
+  <OaPersonSelect
+    :model-value="modelValue"
+    :disabled="effectiveDisabled"
+    :multiple="false"
+    :form-create-inject="formCreateInject"
+    @update:model-value="onUpdate"
+    @change="onChange"
+  />
+</template>
+
+<script setup lang="ts">
+  import { computed } from 'vue'
+  import OaPersonSelect from '@/components/oa-approval/OaPersonSelect.vue'
+  import type { OaPersonValueItem } from '@/utils/oa-person-value'
+
+  defineOptions({ name: 'ownerPerson' })
+
+  const props = withDefaults(
+    defineProps<{
+      modelValue?: unknown
+      disabled?: boolean
+      /** 是否允许改选;默认 false(只读展示默认值) */
+      allowModify?: boolean
+      formCreateInject?: Record<string, unknown>
+    }>(),
+    { allowModify: false }
+  )
+
+  const emit = defineEmits<{
+    (e: 'update:modelValue', v: OaPersonValueItem | OaPersonValueItem[] | null): void
+    (e: 'change', v: OaPersonValueItem | OaPersonValueItem[] | null): void
+  }>()
+
+  const effectiveDisabled = computed(() => !!props.disabled || !props.allowModify)
+
+  function onUpdate(v: OaPersonValueItem | OaPersonValueItem[] | null) {
+    emit('update:modelValue', v)
+  }
+
+  function onChange(v: OaPersonValueItem | OaPersonValueItem[] | null) {
+    emit('change', v)
+  }
+</script>

+ 9 - 4
web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue

@@ -172,7 +172,7 @@
 
   function isPersonLikeField(field: string) {
     const t = meta(field)?.type
-    return t === 'person' || t === 'initiator'
+    return t === 'person' || t === 'ownerPerson' || t === 'initiator'
   }
 
   function isPostField(field: string) {
@@ -186,7 +186,9 @@
   }
 
   function defaultValueForType(type: string, opts?: ConditionCandidate['options']) {
-    if (type === 'person' || type === 'post' || type === 'initiator') return [] as string[]
+    if (type === 'person' || type === 'ownerPerson' || type === 'post' || type === 'initiator') {
+      return [] as string[]
+    }
     return opts?.[0]?.value ?? ''
   }
 
@@ -335,7 +337,7 @@
   }
 
   onMounted(() => {
-    if (props.candidates.some((c) => c.type === 'person' || c.type === 'initiator')) {
+    if (props.candidates.some((c) => c.type === 'person' || c.type === 'ownerPerson' || c.type === 'initiator')) {
       loadUsers()
     }
     if (props.candidates.some((c) => c.type === 'post')) {
@@ -344,7 +346,10 @@
   })
 
   watch(
-    () => props.candidates.some((c) => c.type === 'person' || c.type === 'initiator'),
+    () =>
+      props.candidates.some(
+        (c) => c.type === 'person' || c.type === 'ownerPerson' || c.type === 'initiator'
+      ),
     (need) => {
       if (need && !userOptions.value.length) loadUsers()
     }

+ 2 - 1
web-admin/src/components/oa-approval/oa-condition-fields.ts

@@ -5,6 +5,7 @@ export const CONDITION_ELIGIBLE_TYPES = new Set([
   'number',
   'money',
   'person',
+  'ownerPerson',
   'post',
   'radio',
   'select',
@@ -32,7 +33,7 @@ export interface ConditionCandidate {
 }
 
 export function opsForFieldType(type: string): Array<{ value: ConditionOp; label: string }> {
-  if (type === 'person' || type === 'post' || type === 'initiator') {
+  if (type === 'person' || type === 'ownerPerson' || type === 'post' || type === 'initiator') {
     return [{ value: 'contains', label: '包含' }]
   }
   // 单选/下拉为离散选项,仅等于/不等于;输入框常填数字参与区间比较,与数字/金额/日期同套运算符

+ 19 - 0
web-admin/src/utils/oa-form-create-owner-dept.ts

@@ -0,0 +1,19 @@
+import FcDesigner from '@form-create/designer'
+import OaOwnerDeptSelect from '@/components/oa-approval/OaOwnerDeptSelect.vue'
+import { ownerDeptSelectDragRule } from '@/utils/oa-owner-dept-select-rule'
+
+const FLAG = '__oaOwnerDeptSelectConfigured'
+
+export function setupOaOwnerDeptSelect(formCreate?: {
+  component?: (name: string, c: unknown) => void
+}) {
+  if ((FcDesigner as any)[FLAG]) return
+  FcDesigner.component('ownerDept', OaOwnerDeptSelect)
+  if (typeof formCreate?.component === 'function') {
+    formCreate.component('ownerDept', OaOwnerDeptSelect)
+  }
+  if (typeof FcDesigner.addDragRule === 'function') {
+    FcDesigner.addDragRule(ownerDeptSelectDragRule)
+  }
+  ;(FcDesigner as any)[FLAG] = true
+}

+ 19 - 0
web-admin/src/utils/oa-form-create-owner-person.ts

@@ -0,0 +1,19 @@
+import FcDesigner from '@form-create/designer'
+import OaOwnerPersonSelect from '@/components/oa-approval/OaOwnerPersonSelect.vue'
+import { ownerPersonSelectDragRule } from '@/utils/oa-owner-person-select-rule'
+
+const FLAG = '__oaOwnerPersonSelectConfigured'
+
+export function setupOaOwnerPersonSelect(formCreate?: {
+  component?: (name: string, c: unknown) => void
+}) {
+  if ((FcDesigner as any)[FLAG]) return
+  FcDesigner.component('ownerPerson', OaOwnerPersonSelect)
+  if (typeof formCreate?.component === 'function') {
+    formCreate.component('ownerPerson', OaOwnerPersonSelect)
+  }
+  if (typeof FcDesigner.addDragRule === 'function') {
+    FcDesigner.addDragRule(ownerPersonSelectDragRule)
+  }
+  ;(FcDesigner as any)[FLAG] = true
+}

+ 52 - 0
web-admin/src/utils/oa-owner-dept-select-rule.ts

@@ -0,0 +1,52 @@
+/** 与 FormCreate 官方 unique 同类:生成不重复 field */
+let _uidSeq = 0
+function uniqueId() {
+  const num = 370 + ++_uidSeq
+  return (
+    'F' +
+    Math.random().toString(36).slice(3, 6) +
+    Number(`${Date.now()}`).toString(36) +
+    num.toString(36) +
+    'od'
+  )
+}
+
+/** FormCreate Designer DragRule:归属部门(默认当前登录人主部门) */
+export const ownerDeptSelectDragRule = {
+  menu: 'main' as const,
+  icon: 'icon-tree',
+  label: '归属部门',
+  name: 'ownerDept',
+  input: true,
+  mask: true,
+  validate: ['string', 'array', 'object'],
+  event: ['change'],
+  rule() {
+    return {
+      type: 'ownerDept',
+      field: uniqueId(),
+      title: '归属部门',
+      info: '',
+      $required: false,
+      props: {
+        allowModify: false
+      }
+    }
+  },
+  props() {
+    return [
+      {
+        type: 'switch',
+        field: 'allowModify',
+        title: '允许修改',
+        value: false,
+        props: { activeText: '是', inactiveText: '否' }
+      },
+      {
+        type: 'input',
+        field: 'formCreateInfo',
+        title: '提示信息'
+      }
+    ]
+  }
+}

+ 160 - 0
web-admin/src/utils/oa-owner-field-defaults.ts

@@ -0,0 +1,160 @@
+import { fetchDeptTree } from '@/api/admin'
+import { isDeptValueItem, type OaDeptValueItem } from '@/utils/oa-dept-value'
+import { isPersonValueItem, type OaPersonValueItem } from '@/utils/oa-person-value'
+import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
+
+export type OwnerUserSnapshot = {
+  id?: string
+  userId?: string
+  realName?: string
+  userName?: string
+  username?: string
+  regionId?: string
+  deptId?: string
+  regionName?: string
+  deptName?: string
+}
+
+function walkRules(rules: unknown[], out: Record<string, any>[]) {
+  for (const r of rules) {
+    if (!r || typeof r !== 'object') continue
+    const rule = r as Record<string, any>
+    out.push(rule)
+    const children = rule.children || rule.props?.rule || []
+    if (Array.isArray(children)) walkRules(children, out)
+  }
+}
+
+function flattenRules(schemaOrRules: unknown): Record<string, any>[] {
+  if (Array.isArray(schemaOrRules)) {
+    const out: Record<string, any>[] = []
+    walkRules(schemaOrRules, out)
+    return out
+  }
+  if (schemaOrRules && typeof schemaOrRules === 'object') {
+    const root = schemaOrRules as any
+    const rules = root.rule || root.rules || []
+    if (Array.isArray(rules)) {
+      const out: Record<string, any>[] = []
+      walkRules(rules, out)
+      return out
+    }
+  }
+  return []
+}
+
+function isEmptyOwnerValue(raw: unknown): boolean {
+  if (raw == null || raw === '') return true
+  if (Array.isArray(raw)) return raw.length === 0
+  if (typeof raw === 'object') {
+    const o = raw as Record<string, unknown>
+    return !String(o.id || '').trim()
+  }
+  return false
+}
+
+export function buildCurrentOwnerPerson(user: OwnerUserSnapshot | null | undefined): OaPersonValueItem | null {
+  if (!user) return null
+  const id = String(user.id || user.userId || '').trim()
+  if (!id) return null
+  const name = String(user.realName || user.userName || user.username || '').trim() || id
+  return { id, name }
+}
+
+export function buildCurrentOwnerDeptSync(
+  user: OwnerUserSnapshot | null | undefined
+): OaDeptValueItem | null {
+  if (!user) return null
+  const id = String(user.regionId || user.deptId || '').trim()
+  if (!id) return null
+  const name = String(user.regionName || user.deptName || '').trim() || id
+  return { id, name }
+}
+
+/** 用部门树解析全路径名称;失败则回退 id / 登录态名称 */
+export async function resolveCurrentOwnerDept(
+  user: OwnerUserSnapshot | null | undefined
+): Promise<OaDeptValueItem | null> {
+  const base = buildCurrentOwnerDeptSync(user)
+  if (!base) return null
+  try {
+    const tree = (await fetchDeptTree()) || []
+    const map = buildPathLabelMapFromTreeRoots(tree as any, { nameKey: 'deptName' })
+    const path = map.get(base.id)
+    if (path) return { id: base.id, name: path }
+  } catch {
+    /* 无部门树权限时仍用 id/名称回退 */
+  }
+  return base
+}
+
+/**
+ * 发起页:写入当前登录人 / 主部门。
+ * - allowModify 为假:始终覆盖为当前用户(防展示旧值 / 复制发起)
+ * - allowModify 为真:仅空字段补默认
+ */
+export async function applyOwnerFieldDefaults(
+  schemaOrRules: unknown,
+  formData: Record<string, any>,
+  user: OwnerUserSnapshot | null | undefined
+): Promise<Record<string, any>> {
+  const rules = flattenRules(schemaOrRules)
+  const hasPerson = rules.some((r) => String(r.type || '') === 'ownerPerson')
+  const hasDept = rules.some((r) => String(r.type || '') === 'ownerDept')
+  if (!hasPerson && !hasDept) return { ...formData }
+
+  const person = hasPerson ? buildCurrentOwnerPerson(user) : null
+  const dept = hasDept ? await resolveCurrentOwnerDept(user) : null
+  const next = { ...formData }
+
+  for (const rule of rules) {
+    const type = String(rule.type || '')
+    const field = String(rule.field || rule.name || '').trim()
+    if (!field) continue
+    const allowModify = rule.props?.allowModify === true
+    if (type === 'ownerPerson' && person) {
+      if (!allowModify || isEmptyOwnerValue(next[field])) {
+        next[field] = person
+      }
+    }
+    if (type === 'ownerDept' && dept) {
+      if (!allowModify || isEmptyOwnerValue(next[field])) {
+        next[field] = dept
+      }
+    }
+  }
+  return next
+}
+
+/** 确保 props.allowModify 有明确布尔(缺省 false) */
+export function ensureOwnerFieldRuleProps(rules: unknown): any[] {
+  if (!Array.isArray(rules)) return []
+  return rules.map((raw) => {
+    if (!raw || typeof raw !== 'object') return raw
+    const rule = { ...(raw as any) }
+    const type = String(rule.type || '')
+    if (type === 'ownerPerson' || type === 'ownerDept') {
+      const props = { ...(rule.props || {}) }
+      if (props.allowModify !== true) props.allowModify = false
+      rule.props = props
+    }
+    if (Array.isArray(rule.children)) {
+      rule.children = ensureOwnerFieldRuleProps(rule.children)
+    }
+    if (rule.props && Array.isArray(rule.props.rule)) {
+      rule.props = {
+        ...rule.props,
+        rule: ensureOwnerFieldRuleProps(rule.props.rule)
+      }
+    }
+    return rule
+  })
+}
+
+export function isOwnerPersonValue(v: unknown): v is OaPersonValueItem {
+  return isPersonValueItem(v)
+}
+
+export function isOwnerDeptValue(v: unknown): v is OaDeptValueItem {
+  return isDeptValueItem(v)
+}

+ 52 - 0
web-admin/src/utils/oa-owner-person-select-rule.ts

@@ -0,0 +1,52 @@
+/** 与 FormCreate 官方 unique 同类:生成不重复 field */
+let _uidSeq = 0
+function uniqueId() {
+  const num = 370 + ++_uidSeq
+  return (
+    'F' +
+    Math.random().toString(36).slice(3, 6) +
+    Number(`${Date.now()}`).toString(36) +
+    num.toString(36) +
+    'op'
+  )
+}
+
+/** FormCreate Designer DragRule:归属人(默认当前登录人) */
+export const ownerPersonSelectDragRule = {
+  menu: 'main' as const,
+  icon: 'icon-input',
+  label: '归属人',
+  name: 'ownerPerson',
+  input: true,
+  mask: true,
+  validate: ['string', 'array', 'object'],
+  event: ['change'],
+  rule() {
+    return {
+      type: 'ownerPerson',
+      field: uniqueId(),
+      title: '归属人',
+      info: '',
+      $required: false,
+      props: {
+        allowModify: false
+      }
+    }
+  },
+  props() {
+    return [
+      {
+        type: 'switch',
+        field: 'allowModify',
+        title: '允许修改',
+        value: false,
+        props: { activeText: '是', inactiveText: '否' }
+      },
+      {
+        type: 'input',
+        field: 'formCreateInfo',
+        title: '提示信息'
+      }
+    ]
+  }
+}

+ 6 - 1
web-admin/src/views/oa-approval/instances/detail.vue

@@ -211,6 +211,7 @@
     ensureOaUploadRuleProps
   } from '@/utils/oa-form-create-upload'
   import { ensureLeaveKitRuleProps } from '@/utils/oa-leave-kit-runtime'
+  import { ensureOvertimeKitRuleProps } from '@/utils/oa-overtime-kit-runtime'
   import { ensureConfirmationKitVisibility } from '@/utils/oa-confirmation-kit-runtime'
   import { ensureOwnerFieldRuleProps } from '@/utils/oa-owner-field-defaults'
 
@@ -344,7 +345,11 @@
     viewFormData.value = { ...(data || {}) }
     const rule = Array.isArray(schema?.rule) ? schema.rule : Array.isArray(schema) ? schema : []
     viewRule.value = ensureConfirmationKitVisibility(
-      ensureOwnerFieldRuleProps(ensureLeaveKitRuleProps(ensureOaUploadRuleProps(rule)))
+      ensureOwnerFieldRuleProps(
+        ensureOvertimeKitRuleProps(
+          ensureLeaveKitRuleProps(ensureOaUploadRuleProps(rule))
+        )
+      )
     )
     const savedGutter = Number(schema?.option?.row?.gutter)
     viewOption.value = {