feat(oa): add resignation kit with last_work_day writeback on approval

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-30 17:51:33 +08:00
1 parent 958a6a8cb8
commit 684f152683
22 files changed
+869 -4

No files matched your search

+5
View File
@@ -255,6 +255,7 @@ class UserCreateBody(BaseModel):
phone_no: Optional[str] = Field("", alias="phoneNo")
hire_date: Optional[str] = Field(None, alias="hireDate")
probation_months: Optional[int] = Field(None, alias="probationMonths")
last_work_day: Optional[str] = Field(None, alias="lastWorkDay")
sex: Optional[str] = Field("0", alias="sex")
avatar: Optional[str] = Field(None, alias="avatar")
status: Optional[str] = Field("1", alias="status")
@@ -273,6 +274,7 @@ class UserUpdateBody(BaseModel):
phone_no: Optional[str] = Field(None, alias="phoneNo")
hire_date: Optional[str] = Field(None, alias="hireDate")
probation_months: Optional[int] = Field(None, alias="probationMonths")
last_work_day: Optional[str] = Field(None, alias="lastWorkDay")
sex: Optional[str] = Field(None, alias="sex")
avatar: Optional[str] = Field(None, alias="avatar")
status: Optional[str] = Field(None, alias="status")
@@ -384,6 +386,7 @@ async def create_user(
phone_no=body.phone_no,
hire_date=body.hire_date,
probation_months=body.probation_months,
last_work_day=body.last_work_day,
sex=body.sex,
avatar=body.avatar,
status=body.status,
@@ -427,6 +430,8 @@ async def update_user(
update_kwargs["hire_date"] = payload.get("hire_date")
if "probation_months" in payload:
update_kwargs["probation_months"] = payload.get("probation_months")
if "last_work_day" in payload:
update_kwargs["last_work_day"] = payload.get("last_work_day")
obj = user_svc.update_user(db, user_id, **update_kwargs)
return success_response(user_svc._row_to_dict(obj), message="更新成功")
except ValueError as e:
+3
View File
@@ -61,6 +61,9 @@ def _build_user_info_payload(user: SysUser, web_role: str, role_names: list = No
"email": (getattr(user, "email", None) or "").strip() or None,
"hireDate": user.hire_date.strftime("%Y-%m-%d") if getattr(user, "hire_date", None) else None,
"probationMonths": getattr(user, "probation_months", None),
"lastWorkDay": user.last_work_day.strftime("%Y-%m-%d")
if getattr(user, "last_work_day", None)
else None,
"sex": getattr(user, "sex", None) or "0",
"remark": (getattr(user, "remark", None) or "").strip() or None,
"role": web_role,
+8
View File
@@ -48,6 +48,9 @@ def _row_to_dict(u: SysUser, dept_name: str = "") -> Dict[str, Any]:
"probationMonths": (
u.probation_months if getattr(u, "probation_months", None) is not None else None
),
"lastWorkDay": u.last_work_day.strftime("%Y-%m-%d")
if getattr(u, "last_work_day", None)
else "",
"sex": u.sex or "0",
"avatar": u.avatar or "",
"status": u.status,
@@ -311,6 +314,7 @@ def create_user(
phone_no: str = "",
hire_date: Optional[Union[str, date]] = None,
probation_months: Optional[int] = None,
last_work_day: Optional[Union[str, date]] = None,
sex: str = "0",
avatar: Optional[str] = None,
status: str = "1",
@@ -342,6 +346,7 @@ def create_user(
phone_no=(phone_no or "").strip(),
hire_date=_parse_hire_date(hire_date),
probation_months=probation_months,
last_work_day=_parse_hire_date(last_work_day),
sex=sex or "0",
avatar=avatar,
password=_hash_password(pw),
@@ -375,6 +380,7 @@ def update_user(
phone_no: Optional[str] = None,
hire_date: Any = ...,
probation_months: Any = ...,
last_work_day: Any = ...,
sex: Optional[str] = None,
avatar: Optional[str] = None,
status: Optional[str] = None,
@@ -406,6 +412,8 @@ def update_user(
obj.probation_months = None
else:
obj.probation_months = int(probation_months)
if last_work_day is not ...:
obj.last_work_day = _parse_hire_date(last_work_day)
if sex is not None:
obj.sex = sex
if avatar is not None:
+1
View File
@@ -33,6 +33,7 @@ class SysUser(Base):
phone_no = Column(String(11), nullable=False, default="", comment="手机号码")
hire_date = Column(Date, nullable=True, comment="入职日期")
probation_months = Column(Integer, nullable=True, comment="试用期月数")
last_work_day = Column(Date, nullable=True, comment="最后工作日")
sex = Column(CHAR(1), nullable=True, default="0", comment="用户性别")
avatar = Column(String(512), nullable=True, comment="头像")
password = Column("password", String(100), nullable=False, default="", comment="密码")
+7
View File
@@ -30,6 +30,7 @@ from app.services.oa_assignee import (
)
from app.services.oa_formula import FormulaError, apply_formulas_and_linkages, eval_formula
from app.services.oa_confirmation_kit_sync import apply_confirmation_kit_user_sync
from app.services.oa_resignation_kit_sync import apply_resignation_kit_user_sync
import logging
@@ -1647,6 +1648,12 @@ def advance_from(
logger.exception(
"confirmation kit user sync failed instance=%s", getattr(inst, "id", None)
)
try:
apply_resignation_kit_user_sync(db, inst)
except Exception:
logger.exception(
"resignation kit user sync failed instance=%s", getattr(inst, "id", None)
)
return
if ntype == "condition":
cur = next_node(cur)
+2
View File
@@ -60,6 +60,8 @@ LAYOUT_TYPES = frozenset(
"object",
"leaveKit",
"confirmationKit",
"overtimeKit",
"resignationKit",
}
)
@@ -0,0 +1,135 @@
"""离职套件:审批通过后回写发起人 last_work_day。"""
from __future__ import annotations
import json
import logging
import re
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.models.models import OaAppVersion, OaInstance, SysUser
logger = logging.getLogger(__name__)
_YMD = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
def _loads(raw: Any, default: Any = None) -> Any:
if raw is None or raw == "":
return default
if isinstance(raw, (dict, list)):
return raw
try:
return json.loads(raw)
except Exception:
return default
def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
if form_schema is None:
return []
if isinstance(form_schema, list):
return [r for r in form_schema if isinstance(r, dict)]
if isinstance(form_schema, dict):
rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
if isinstance(rules, list):
return [r for r in rules if isinstance(r, dict)]
if form_schema.get("type") or form_schema.get("field"):
return [form_schema]
return []
def _walk(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
for rule in rules:
out.append(rule)
children = rule.get("children") or (rule.get("props") or {}).get("rule") or []
if isinstance(children, list):
_walk([c for c in children if isinstance(c, dict)], out)
def _last_work_day_field(kit: Dict[str, Any]) -> Optional[str]:
props = kit.get("props") if isinstance(kit.get("props"), dict) else {}
if props.get("lastWorkDayField"):
return str(props["lastWorkDayField"])
children = kit.get("children") or props.get("rule") or []
if isinstance(children, list):
for c in children:
if not isinstance(c, dict) or not c.get("field"):
continue
cprops = c.get("props") if isinstance(c.get("props"), dict) else {}
if str(cprops.get("_fc_kit_field") or "") == "lastWorkDay":
return str(c["field"])
return None
def _parse_ymd(value: Any) -> Optional[date]:
if value is None or value == "":
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
s = str(value).strip()[:10]
if not _YMD.match(s):
return None
try:
return datetime.strptime(s, "%Y-%m-%d").date()
except ValueError:
return None
def extract_last_work_day(schema: Any, form_data: Dict[str, Any]) -> Optional[date]:
flat: List[Dict[str, Any]] = []
_walk(_extract_rules(schema), flat)
for rule in flat:
if str(rule.get("type") or "") != "resignationKit":
continue
field = _last_work_day_field(rule)
if not field:
continue
parsed = _parse_ymd(form_data.get(field))
if parsed is not None:
return parsed
return None
def apply_resignation_kit_user_sync(db: Session, inst: OaInstance) -> None:
try:
if not inst or not getattr(inst, "id", None):
return
form_data = _loads(getattr(inst, "form_data_json", None), {}) or {}
if not isinstance(form_data, dict):
form_data = {}
ver = (
db.query(OaAppVersion)
.filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
.first()
)
schema = _loads(getattr(ver, "form_schema_json", None) if ver else None, {}) or {}
last_day = extract_last_work_day(schema, form_data)
if last_day is None:
return
user = (
db.query(SysUser)
.filter(
SysUser.id == inst.initiator_user_id,
SysUser.tenant_no == inst.tenant_no,
SysUser.del_flag == 0,
)
.first()
)
if not user:
logger.warning(
"resignation kit sync: initiator not found id=%s",
inst.initiator_user_id,
)
return
user.last_work_day = last_day
user.update_time = datetime.now()
db.add(user)
except Exception:
logger.exception(
"resignation kit user sync failed instance=%s", getattr(inst, "id", None)
)
@@ -0,0 +1,39 @@
from datetime import date
from app.services.oa_resignation_kit_sync import extract_last_work_day
def test_extract_last_work_day():
schema = {
"rule": [
{
"type": "resignationKit",
"props": {"lastWorkDayField": "f_last"},
"children": [
{
"type": "datePicker",
"field": "f_last",
"props": {"_fc_kit_field": "lastWorkDay"},
}
],
}
]
}
assert extract_last_work_day(schema, {"f_last": "2026-08-01"}) == date(2026, 8, 1)
def test_empty_skipped():
schema = {
"rule": [
{
"type": "resignationKit",
"props": {"lastWorkDayField": "f_last"},
"children": [],
}
]
}
assert extract_last_work_day(schema, {"f_last": ""}) is None
def test_no_kit():
assert extract_last_work_day({"rule": [{"type": "input", "field": "x"}]}, {"x": 1}) is None
+4
View File
@@ -0,0 +1,4 @@
-- docs/sql/sys_user_last_work_day.sql
-- 用户表补齐最后工作日(离职套件主数据)
ALTER TABLE `sys_user`
ADD COLUMN `last_work_day` date DEFAULT NULL COMMENT '最后工作日' AFTER `probation_months`;
@@ -0,0 +1,202 @@
# OA 表单设计 · 离职套件(Resignation Kit)设计文档
**日期**: 2026-07-30
**状态**: 已实现
**范围**: `web-admin` 用户管理补齐最后工作日;审批套件 Tab 离职套件;发起默认入职日期;`backend` 审批通过后回写发起人 `last_work_day`
**界面模式**: 对齐请假/转正套件;视觉 Fresh Mint
**前置**:
- `docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md`
- `docs/superpowers/specs/2026-07-30-oa-confirmation-kit-design.md`(回写模式)
---
## 1. 背景与目标
套件 Tab 增加 **离职套件**:采集入职日期、最后工作日、离职原因及备注;审批通过后将最后工作日回写发起人用户主数据。
### 1.1 目标
- `sys_user` 增补 **最后工作日**;用户管理新增/编辑/详情可维护。
- 拖入离职套件得到 4 个子字段。
- 发起时入职日期默认申请人(可改)。
- 离职原因内置 5 项,设计器可维护 options。
- 审批 **全部通过** 后仅回写发起人 `last_work_day`。
### 1.2 非目标(首期)
- 通过后自动停用账号。
- 入职日期、离职原因、备注回写用户表。
- 「最后工作日 ≥ 入职日期」交叉校验。
- 代他人离职并回写他人主数据。
---
## 2. 产品决策(已确认)
| 项 | 决策 |
|----|------|
| 实现方案 | 套件容器 + 子字段拼装 |
| 容器类型 | `resignationKit` |
| 回写时机 | 审批全部通过 |
| 回写对象 | 始终发起人 |
| 回写字段 | 仅最后工作日 |
| 账号状态 | 不动 |
| 用户管理 | 最后工作日可新增/编辑/详情 |
| 入职日期 | 默认申请人,可改,不回写 |
| 最后工作日 | 默认必填(设计器可改);手填 |
| 交叉校验 | 首期不做 |
| 原因/备注 | 仅审批单 |
---
## 3. 信息模型
### 3.1 心智模型
```
┌─ 离职套件(resignationKit)──────────────────────────┐
│ · datePicker 入职日期 ← userInfo.hireDate │
│ · datePicker 最后工作日 ← 手填;通过后回写 │
│ · select 离职原因 ← 5 默认项,可配置 │
│ · textarea 离职原因备注 │
└──────────────────────────────────────────────────────┘
```
运行时不渲染套件边框/标题。
### 3.2 子字段
| 语义 key | title | type | 必填默认 | 回写 |
|----------|-------|------|----------|------|
| `hireDate` | 入职日期 | datePicker 仅日期 | 是 | ✗ |
| `lastWorkDay` | 最后工作日 | datePicker 仅日期 | 是 | ✓ → `last_work_day` |
| `resignReason` | 离职原因 | select | 是 | ✗ |
| `resignRemark` | 离职原因备注 | textarea | 否 | ✗ |
离职原因默认 options:
- 家庭原因
- 个人原因
- 发展原因
- 合同到期不续签
- 其他
语义识别:`props._fc_kit_field`;容器 props 可挂各 `*Field` id。
### 3.3 Schema 示意
```json
{
"type": "resignationKit",
"props": {
"hireDateField": "...",
"lastWorkDayField": "...",
"resignReasonField": "...",
"resignRemarkField": "..."
},
"children": [
{
"type": "datePicker",
"title": "入职日期",
"props": { "_fc_kit_field": "hireDate", "type": "date", "valueFormat": "YYYY-MM-DD" },
"$required": true
},
{
"type": "datePicker",
"title": "最后工作日",
"props": { "_fc_kit_field": "lastWorkDay", "type": "date", "valueFormat": "YYYY-MM-DD" },
"$required": true
},
{
"type": "select",
"title": "离职原因",
"options": [
{ "label": "家庭原因", "value": "家庭原因" },
{ "label": "个人原因", "value": "个人原因" },
{ "label": "发展原因", "value": "发展原因" },
{ "label": "合同到期不续签", "value": "合同到期不续签" },
{ "label": "其他", "value": "其他" }
],
"props": { "_fc_kit_field": "resignReason" },
"$required": true
},
{
"type": "textarea",
"title": "离职原因备注",
"props": { "_fc_kit_field": "resignRemark", "rows": 3 },
"$required": false
}
]
}
```
---
## 4. 用户主数据
### 4.1 增列
```sql
ALTER TABLE `sys_user`
ADD COLUMN `last_work_day` date DEFAULT NULL COMMENT '最后工作日' AFTER `probation_months`;
```
(若库中无 `probation_months`,则 AFTER `hire_date` 或 `phone_no`。)
同步:`SysUser`、`_row_to_dict`(`lastWorkDay`)、Create/Update Body、用户管理表单与详情、`userInfo` 可选带出 `lastWorkDay`(套件默认不依赖此项)。
---
## 5. 运行时
### 5.1 发起默认
仅对空的入职日期写入 `userInfo.hireDate`。其余字段不默认。
### 5.2 提交 / 发布
- 可见必填按 FormCreate 校验。
- `LAYOUT_TYPES` 纳入 `resignationKit`。
- 打印 unwrap 同其它套件。
### 5.3 通过后回写
挂在实例 `status = completed` 后(可与转正 sync 并列调用):
`apply_resignation_kit_user_sync(db, inst)`
- 扫描 `resignationKit` → 取 `lastWorkDay`
- 有合法 `YYYY-MM-DD` 则更新发起人 `last_work_day`
- 空 / 非法:跳过 + log;异常不回滚通过状态
---
## 6. 建议文件落点
| 区域 | 路径 |
|------|------|
| SQL | `docs/sql/sys_user_last_work_day.sql` |
| 模型/CRUD/API/用户管理 | 同转正补字段路径 |
| 容器 | `OaResignationKit.vue` |
| DragRule / 注册 | `oa-resignation-kit-rule.ts`、`oa-form-create-resignation-kit.ts` |
| 默认值 | `oa-resignation-kit-runtime.ts` |
| 回写 | `oa_resignation_kit_sync.py` + `oa_engine` completed 钩子 |
| 发布/打印 | `oa_publish.py`、`oa-approval-print.ts` |
| 设计器拖入刷新 | `oa-fc-host/App.vue`(纳入 kit 名列表) |
---
## 7. 验收标准
1. 用户管理可维护最后工作日。
2. 套件 Tab 可拖「离职」;原因默认 5 项且设计器可改。
3. 发起:入职日期自动带出可改;最后工作日/原因/备注按规则校验。
4. 通过前主数据最后工作日不变;通过后写入发起人;空不写。
5. 入职日期、原因、备注不回写;账号状态不变。
---
## 8. 开放细节(不阻塞)
- `userInfo` 是否带出 `lastWorkDay`:可选,套件不依赖。
- 与转正 sync 共用一个 hook 文件或并列两个函数:实现任选,逻辑分离即可。
+2
View File
@@ -134,6 +134,7 @@ export function fetchCreateUser(data: {
phoneNo?: string
hireDate?: string
probationMonths?: number | null
lastWorkDay?: string
sex?: string
avatar?: string
status?: string
@@ -159,6 +160,7 @@ export function fetchUpdateUser(
phoneNo: string
hireDate: string | null
probationMonths: number | null
lastWorkDay: string | null
sex: string
avatar: string
status: string
@@ -0,0 +1,23 @@
<!-- 离职套件:仅组合容器 -->
<template>
<div class="oa-resignation-kit" v-bind="$attrs">
<slot />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'OaResignationKit', inheritAttrs: false })
defineProps<{
hireDateField?: string
lastWorkDayField?: string
resignReasonField?: string
resignRemarkField?: string
}>()
</script>
<style scoped lang="scss">
.oa-resignation-kit {
width: 100%;
}
</style>
+4
View File
@@ -26,6 +26,8 @@ import { setupOaPostSelect } from '@/utils/oa-form-create-post'
import { setupOaReceiveAccountSelect } from '@/utils/oa-form-create-receive-account'
import { setupOaLeaveKit } from '@/utils/oa-form-create-leave-kit'
import { setupOaConfirmationKit } from '@/utils/oa-form-create-confirmation-kit'
import { setupOaOvertimeKit } from '@/utils/oa-form-create-overtime-kit'
import { setupOaResignationKit } from '@/utils/oa-form-create-resignation-kit'
// FormCreate 按字符串解析 el-input / fc-radio 等,主应用是按需自动导入,不会全局注册;
// 必须挂上官方 auto-import,否则发起审批/详情里动态表单控件会残缺(如单选项只剩文字)。
@@ -42,6 +44,8 @@ setupOaPostSelect(FcDesigner.formCreate)
setupOaReceiveAccountSelect(FcDesigner.formCreate)
setupOaLeaveKit(FcDesigner.formCreate)
setupOaConfirmationKit(FcDesigner.formCreate)
setupOaOvertimeKit(FcDesigner.formCreate)
setupOaResignationKit(FcDesigner.formCreate)
function maskSecret(value?: string) {
if (!value) return '(empty)'
+175 -2
View File
@@ -7,12 +7,13 @@
height="100%"
:config="designerConfig"
:locale="fcLocale"
@drag="onDesignerDrag"
/>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import formCreateZh from '@form-create/designer/locale/zh-cn'
import { CONDITION_ELIGIBLE_TYPES } from '@/components/oa-approval/oa-condition-fields'
@@ -27,18 +28,26 @@
getOptionsJson?: () => string
setRule?: (rule: unknown[]) => void
setOption?: (option: unknown) => void
activeMenuTab?: string
}
const designerRef = ref<DesignerApi | null>(null)
const fcLocale = formCreateZh
let pendingSchema: { rule: unknown; option: unknown } | null = null
let leaveKitRefreshTimer: number | null = null
/** 组件 Tab 不展示套件分组;套件 Tab 只展示 kit */
const HIDDEN_ON_COMPONENT_TAB = ['kit']
const HIDDEN_ON_KIT_TAB = ['main', 'aide', 'layout', 'subform']
const COND_FIELD = 'formCreateUseInCondition'
const designerConfig = {
const designerConfig = reactive({
showSaveBtn: false,
showDevice: true,
showAi: false,
/** 默认在「组件」Tab:隐藏套件分组 */
hiddenMenu: [...HIDDEN_ON_COMPONENT_TAB] as string[],
/** 审批提交走流程,表单自身提交按钮默认关闭 */
formOptions: {
submitBtn: false
@@ -65,6 +74,122 @@
]
}
}
})
/**
* FormCreate 首次拖入带预置 children 的容器时,子规则未走 loadRule/makeRule,画布空白;
* 切 Tab 触发 setRule 后才显示。拖入 leaveKit 后用 getRule→setRule 走与回显相同路径。
*/
function refreshLeaveKitCanvas() {
const api = designerRef.value
if (!api?.getRule || !api?.setRule) return
try {
const rules = api.getRule()
const cloned = JSON.parse(JSON.stringify(rules || []))
api.setRule(Array.isArray(cloned) ? cloned : [])
} catch (e) {
console.warn('[oa-fc-host] leaveKit canvas refresh failed', e)
}
}
function onDesignerDrag(payload: any) {
const name =
payload?.item?.name || payload?.dragRule?.name || payload?.menu?.name || ''
if (
name !== 'leaveKit' &&
name !== 'overtimeKit' &&
name !== 'confirmationKit' &&
name !== 'resignationKit'
)
return
if (leaveKitRefreshTimer != null) {
window.clearTimeout(leaveKitRefreshTimer)
}
// drag 事件早于 children.splice,延后一帧再刷新
leaveKitRefreshTimer = window.setTimeout(() => {
leaveKitRefreshTimer = null
nextTick(() => refreshLeaveKitCanvas())
}, 30)
}
/** 记住左侧面板 Tab,模块切换(国际化↔组件列表)重建 DOM 后恢复 */
const leftPanelTab = ref<'menu' | 'kit' | 'tree'>('menu')
function getDesignerState(): any {
const inst = designerRef.value as any
if (!inst) return null
return inst.setupState || inst.$?.setupState || inst
}
function applyLeftPanelTab(tab: 'menu' | 'kit' | 'tree') {
leftPanelTab.value = tab
const host = document.querySelector('.oa-fc-host')
host?.classList.remove('oa-fc-host--left-menu', 'oa-fc-host--left-kit', 'oa-fc-host--left-tree')
const state = getDesignerState()
if (tab === 'tree') {
host?.classList.add('oa-fc-host--left-tree')
if (state) state.activeMenuTab = 'tree'
designerConfig.hiddenMenu = [...HIDDEN_ON_COMPONENT_TAB]
} else if (tab === 'kit') {
host?.classList.add('oa-fc-host--left-kit')
if (state) state.activeMenuTab = 'menu'
designerConfig.hiddenMenu = [...HIDDEN_ON_KIT_TAB]
} else {
host?.classList.add('oa-fc-host--left-menu')
if (state) state.activeMenuTab = 'menu'
designerConfig.hiddenMenu = [...HIDDEN_ON_COMPONENT_TAB]
}
}
/** 在 FormCreate「组件 | 大纲」之间插入「套件」Tab;DOM 被模块切换销毁后可重复注入 */
function setupLeftPanelTabs() {
const header = document.querySelector('.oa-fc-host ._fc-l-tabs') as HTMLElement | null
if (!header) return false
if (header.dataset.oaKitTabs === '1' && header.querySelector('.oa-fc-l-tabs')) {
// 已注入:仅同步 hiddenMenu / activeMenuTab(防止仍停在套件过滤态却显示原生「组件」)
applyLeftPanelTab(leftPanelTab.value)
return true
}
header.dataset.oaKitTabs = '1'
header.querySelectorAll(':scope > ._fc-l-tab').forEach((el) => {
;(el as HTMLElement).style.display = 'none'
})
// 清掉上次残留(极少见)
header.querySelectorAll(':scope > .oa-fc-l-tabs').forEach((el) => el.remove())
const wrap = document.createElement('div')
wrap.className = 'oa-fc-l-tabs'
const cur = leftPanelTab.value
wrap.innerHTML = [
`<div class="oa-fc-l-tab${cur === 'menu' ? ' active' : ''}" data-oa-tab="menu">组件</div>`,
`<div class="oa-fc-l-tab${cur === 'kit' ? ' active' : ''}" data-oa-tab="kit">套件</div>`,
`<div class="oa-fc-l-tab${cur === 'tree' ? ' active' : ''}" data-oa-tab="tree">大纲</div>`
].join('')
header.appendChild(wrap)
wrap.addEventListener('click', (ev) => {
const target = (ev.target as HTMLElement | null)?.closest?.(
'[data-oa-tab]'
) as HTMLElement | null
if (!target) return
const tab = target.dataset.oaTab as 'menu' | 'kit' | 'tree'
wrap.querySelectorAll('.oa-fc-l-tab').forEach((el) => {
el.classList.toggle('active', el === target)
})
applyLeftPanelTab(tab)
})
// 从国际化等模块切回时,恢复上次 Tab,并同步 hiddenMenu
applyLeftPanelTab(leftPanelTab.value || 'menu')
return true
}
function ensureLeftPanelTabs() {
const header = document.querySelector('.oa-fc-host ._fc-l-tabs') as HTMLElement | null
if (!header) return
if (header.dataset.oaKitTabs === '1' && header.querySelector('.oa-fc-l-tabs')) return
setupLeftPanelTabs()
}
const MSG = {
@@ -374,6 +499,7 @@
}
let brandObserver: MutationObserver | null = null
let tabsObserver: MutationObserver | null = null
onMounted(async () => {
window.addEventListener('message', onMessage)
@@ -382,12 +508,19 @@
removeFormCreateBrand()
brandObserver = new MutationObserver(() => removeFormCreateBrand())
brandObserver.observe(document.body, { childList: true, subtree: true })
// 切到「国际化」再回「组件列表」会销毁重建 ._fc-l-tabs,需持续补注套件 Tab
tabsObserver = new MutationObserver(() => {
ensureLeftPanelTabs()
})
tabsObserver.observe(document.body, { childList: true, subtree: true })
setupLeftPanelTabs()
window.parent?.postMessage({ type: MSG.READY }, '*')
if (pendingSchema) {
void applySchema(pendingSchema)
}
pollTimer = window.setInterval(() => {
syncActivePanel()
ensureLeftPanelTabs()
}, 250)
})
@@ -395,10 +528,16 @@
window.removeEventListener('message', onMessage)
brandObserver?.disconnect()
brandObserver = null
tabsObserver?.disconnect()
tabsObserver = null
if (pollTimer != null) {
window.clearInterval(pollTimer)
pollTimer = null
}
if (leaveKitRefreshTimer != null) {
window.clearTimeout(leaveKitRefreshTimer)
leaveKitRefreshTimer = null
}
})
</script>
@@ -488,4 +627,38 @@
.oa-fc-host ._fc-r {
--el-color-primary: #10b981;
}
/* 自研三 Tab:组件 | 套件 | 大纲(对齐 FormCreate 原 Tab 样式,主色 Mint) */
.oa-fc-host ._fc-l-tabs {
padding: 0;
}
.oa-fc-host .oa-fc-l-tabs {
display: flex;
width: 100%;
height: 40px;
}
.oa-fc-host .oa-fc-l-tab {
flex: 1;
height: 40px;
box-sizing: border-box;
line-height: 40px;
text-align: center;
font-size: 14px;
font-weight: 600;
color: #303133;
cursor: pointer;
user-select: none;
}
.oa-fc-host .oa-fc-l-tab.active {
color: #059669;
border-bottom: 2px solid #10b981;
}
/* 套件 Tab:隐藏分组标题「套件」,只保留卡片列表 */
.oa-fc-host--left-kit ._fc-l-group > ._fc-l-title {
display: none;
}
</style>
+4
View File
@@ -23,6 +23,8 @@ import { setupOaPostSelect } from '@/utils/oa-form-create-post'
import { setupOaReceiveAccountSelect } from '@/utils/oa-form-create-receive-account'
import { setupOaLeaveKit } from '@/utils/oa-form-create-leave-kit'
import { setupOaConfirmationKit } from '@/utils/oa-form-create-confirmation-kit'
import { setupOaOvertimeKit } from '@/utils/oa-form-create-overtime-kit'
import { setupOaResignationKit } from '@/utils/oa-form-create-resignation-kit'
import App from './App.vue'
const app = createApp(App)
@@ -49,4 +51,6 @@ setupOaPostSelect(FcDesigner.formCreate)
setupOaReceiveAccountSelect(FcDesigner.formCreate)
setupOaLeaveKit(FcDesigner.formCreate)
setupOaConfirmationKit(FcDesigner.formCreate)
setupOaOvertimeKit(FcDesigner.formCreate)
setupOaResignationKit(FcDesigner.formCreate)
app.mount('#app')
+1
View File
@@ -45,6 +45,7 @@ declare namespace Api {
phoneNo: string
hireDate?: string
probationMonths?: number | null
lastWorkDay?: string
sex: string
avatar: string
status: string
+3 -1
View File
@@ -97,7 +97,9 @@ const SKIP_RULE_TYPES = new Set([
'html',
'vanCellGroup',
'leaveKit',
'confirmationKit'
'confirmationKit',
'overtimeKit',
'resignationKit'
])
export function resolveStamp(status: string | undefined | null): {
@@ -0,0 +1,28 @@
import FcDesigner from '@form-create/designer'
import OaResignationKit from '@/components/oa-approval/OaResignationKit.vue'
import { resignationKitDragRule } from '@/utils/oa-resignation-kit-rule'
const FLAG = '__oaResignationKitConfigured'
export function setupOaResignationKit(formCreate?: {
component?: (name: string, c: unknown) => void
}) {
if ((FcDesigner as any)[FLAG]) return
if (typeof FcDesigner.addMenu === 'function') {
try {
FcDesigner.addMenu({ name: 'kit', title: '套件', list: [] })
} catch {
/* already */
}
}
FcDesigner.component('resignationKit', OaResignationKit)
if (typeof formCreate?.component === 'function') {
formCreate.component('resignationKit', OaResignationKit)
}
if (typeof FcDesigner.addDragRule === 'function') {
FcDesigner.addDragRule(resignationKitDragRule)
}
;(FcDesigner as any)[FLAG] = true
}
@@ -0,0 +1,117 @@
/** FormCreate Designer DragRule:离职套件 */
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) +
'g'
)
}
export const RESIGN_REASON_OPTIONS = [
{ label: '家庭原因', value: '家庭原因' },
{ label: '个人原因', value: '个人原因' },
{ label: '发展原因', value: '发展原因' },
{ label: '合同到期不续签', value: '合同到期不续签' },
{ label: '其他', value: '其他' }
]
function buildResignationKitChildren() {
const hireDateField = uniqueId()
const lastWorkDayField = uniqueId()
const resignReasonField = uniqueId()
const resignRemarkField = uniqueId()
return {
hireDateField,
lastWorkDayField,
resignReasonField,
resignRemarkField,
children: [
{
type: 'datePicker',
field: hireDateField,
title: '入职日期',
info: '',
$required: true,
props: {
_fc_kit_field: 'hireDate',
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
placeholder: '请选择入职日期'
}
},
{
type: 'datePicker',
field: lastWorkDayField,
title: '最后工作日',
info: '',
$required: true,
props: {
_fc_kit_field: 'lastWorkDay',
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
placeholder: '请选择最后工作日'
}
},
{
type: 'select',
field: resignReasonField,
title: '离职原因',
info: '',
$required: true,
options: RESIGN_REASON_OPTIONS.map((o) => ({ ...o })),
props: {
_fc_kit_field: 'resignReason',
placeholder: '请选择离职原因',
clearable: true
}
},
{
type: 'textarea',
field: resignRemarkField,
title: '离职原因备注',
info: '',
$required: false,
props: {
_fc_kit_field: 'resignRemark',
rows: 3,
placeholder: '可选'
}
}
]
}
}
export const resignationKitDragRule = {
menu: 'kit' as const,
icon: 'icon-user',
label: '离职',
name: 'resignationKit',
input: false,
drag: true,
dragBtn: true,
mask: false,
inside: true,
rule() {
const built = buildResignationKitChildren()
return {
type: 'resignationKit',
props: {
hireDateField: built.hireDateField,
lastWorkDayField: built.lastWorkDayField,
resignReasonField: built.resignReasonField,
resignRemarkField: built.resignRemarkField
},
children: built.children
}
},
props() {
return []
}
}
@@ -0,0 +1,80 @@
/** 离职套件运行时:入职日期默认申请人 */
function asRules(schema: unknown): Record<string, unknown>[] {
if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === 'object') as any
if (schema && typeof schema === 'object') {
const s = schema as any
const rules = s.rule || s.rules || s.fields
if (Array.isArray(rules)) return rules.filter((r: any) => r && typeof r === 'object')
if (s.type || s.field) return [s]
}
return []
}
function walk(rules: Record<string, unknown>[], out: Record<string, unknown>[]) {
for (const r of rules) {
out.push(r)
if (Array.isArray(r.children)) {
walk(r.children as any, out)
}
const props = r.props as any
if (props && Array.isArray(props.rule)) walk(props.rule, out)
}
}
export type ResignationKitBinding = {
hireDateField?: string
lastWorkDayField?: string
}
function resolveBinding(kit: Record<string, unknown>): ResignationKitBinding {
const props = (kit.props && typeof kit.props === 'object' ? kit.props : {}) as Record<
string,
any
>
let hireDateField = props.hireDateField ? String(props.hireDateField) : undefined
let lastWorkDayField = props.lastWorkDayField ? String(props.lastWorkDayField) : undefined
const children = (
Array.isArray(kit.children)
? kit.children
: Array.isArray(props.rule)
? props.rule
: []
) as Record<string, any>[]
for (const c of children) {
if (!c?.field) continue
const sem = String(c.props?._fc_kit_field || '')
if (sem === 'hireDate' && !hireDateField) hireDateField = String(c.field)
if (sem === 'lastWorkDay' && !lastWorkDayField) lastWorkDayField = String(c.field)
}
return { hireDateField, lastWorkDayField }
}
export function listResignationKitBindings(schema: unknown): ResignationKitBinding[] {
const flat: Record<string, unknown>[] = []
walk(asRules(schema), flat)
return flat
.filter((r) => String(r.type || '') === 'resignationKit')
.map(resolveBinding)
}
function isEmpty(raw: unknown): boolean {
return raw == null || raw === ''
}
/** 仅对空入职日期写入申请人 hireDate */
export function applyResignationKitDefaults(
schema: unknown,
formData: Record<string, unknown>,
user: Record<string, unknown> | null | undefined
): Record<string, unknown> {
const data = { ...formData }
if (!user) return data
const hireDate = String(user.hireDate || '').trim()
if (!hireDate) return data
for (const kit of listResignationKitBindings(schema)) {
const f = kit.hireDateField
if (f && isEmpty(data[f])) data[f] = hireDate
}
return data
}
@@ -171,6 +171,8 @@
listLeaveKitBindings,
markLeaveDurationManual
} from '@/utils/oa-leave-kit-runtime'
import { ensureOvertimeKitRuleProps } from '@/utils/oa-overtime-kit-runtime'
import { applyResignationKitDefaults } from '@/utils/oa-resignation-kit-runtime'
import {
applyConfirmationKitDateLinkage,
applyConfirmationKitDefaults,
@@ -350,7 +352,9 @@
: []
formRule.value = ensureConfirmationKitVisibility(
ensureOwnerFieldRuleProps(
ensureLeaveKitRuleProps(ensureOaUploadRuleProps(rawRule))
ensureOvertimeKitRuleProps(
ensureLeaveKitRuleProps(ensureOaUploadRuleProps(rawRule))
)
)
)
const savedGutter = Number(schema.option?.row?.gutter)
@@ -378,6 +382,11 @@
data,
(userStore.info || userStore.getUserInfo) as any
)
data = applyResignationKitDefaults(
{ rule: formRule.value },
data,
(userStore.info || userStore.getUserInfo) as any
)
const linked = applyConfirmationKitDateLinkage({ rule: formRule.value }, data)
formData.value = linked.data
confirmationKitPrevSnapshot = { ...linked.data }
+16
View File
@@ -334,6 +334,16 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="最后工作日">
<el-date-picker
v-model="formData.lastWorkDay"
type="date"
value-format="YYYY-MM-DD"
placeholder="可选"
class="w-full"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="性别">
<el-select v-model="formData.sex" placeholder="请选择">
<el-option label="男" value="0" />
@@ -502,6 +512,7 @@
phoneNo: '',
hireDate: '' as string,
probationMonths: null as number | null,
lastWorkDay: '' as string,
sex: '0',
avatar: '',
status: '1',
@@ -609,6 +620,7 @@
formData.phoneNo = ''
formData.hireDate = ''
formData.probationMonths = null
formData.lastWorkDay = ''
formData.sex = '0'
formData.avatar = ''
formData.status = '1'
@@ -648,6 +660,7 @@
detail.probationMonths === null || detail.probationMonths === undefined
? null
: Number(detail.probationMonths)
formData.lastWorkDay = detail.lastWorkDay || ''
formData.sex = detail.sex || '0'
formData.avatar = detail.avatar || ''
formData.status = detail.status || '1'
@@ -683,6 +696,7 @@
phoneNo: formData.phoneNo?.trim() || undefined,
hireDate: formData.hireDate || null,
probationMonths: formData.probationMonths,
lastWorkDay: formData.lastWorkDay || null,
sex: formData.sex,
status: formData.status,
orderNum: formData.orderNum,
@@ -700,6 +714,7 @@
phoneNo: formData.phoneNo?.trim() || undefined,
hireDate: formData.hireDate || undefined,
probationMonths: formData.probationMonths ?? undefined,
lastWorkDay: formData.lastWorkDay || undefined,
sex: formData.sex,
status: formData.status,
orderNum: formData.orderNum,
@@ -811,6 +826,7 @@
? '—'
: `${row.probationMonths} 个月`
},
{ label: '最后工作日', value: row.lastWorkDay || '—' },
{
label: '性别',
value: