feat(oa): add return, resubmit, and multi-round approval progress
Align with DingTalk semantics: reject ends the instance, return keeps it for initiator edits, and revoke-and-resubmit creates a linked new instance. Progress timeline projects rounds via oa_instance_event. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
265d790ea6
commit
26800865cd
13 files changed
+4736
-72
No files matched your search
@@ -0,0 +1,745 @@
|
||||
"""OA 审批管理接口(钉钉交互)"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import oa_approval as oa_crud
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
tenant = (user.get("tenant_no") or "").strip()
|
||||
if tenant:
|
||||
return tenant
|
||||
uid = user.get("user_id")
|
||||
if uid:
|
||||
row = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
if row and row.tenant_no:
|
||||
return row.tenant_no
|
||||
return "zkxw"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工作流角色
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OaRoleCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
role_name: str = Field(..., alias="roleName")
|
||||
role_code: str = Field(..., alias="roleCode")
|
||||
status: Optional[str] = Field("1")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class OaRoleUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
role_name: Optional[str] = Field(None, alias="roleName")
|
||||
status: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class OaRoleUsersBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
user_ids: List[str] = Field(default_factory=list, alias="userIds")
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
async def list_oa_roles(
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = oa_crud.list_roles(db, tenant_no=tenant_no, keyword=keyword, status=status)
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
@router.post("/roles/ensure-presets")
|
||||
async def ensure_oa_role_presets(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
created = oa_crud.ensure_presets(db, tenant_no, user_id=user.get("user_id"))
|
||||
return success_response(
|
||||
{"createdCount": len(created)},
|
||||
message="预置角色已就绪",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/roles")
|
||||
async def create_oa_role(
|
||||
body: OaRoleCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = oa_crud.create_role(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
role_name=body.role_name,
|
||||
role_code=body.role_code,
|
||||
status=body.status or "1",
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(oa_crud._role_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/roles/{role_id}")
|
||||
async def update_oa_role(
|
||||
role_id: str,
|
||||
body: OaRoleUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = oa_crud.update_role(
|
||||
db,
|
||||
role_id,
|
||||
tenant_no=tenant_no,
|
||||
role_name=payload.get("role_name"),
|
||||
status=payload.get("status"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(oa_crud._role_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/roles/{role_id}")
|
||||
async def delete_oa_role(
|
||||
role_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
oa_crud.delete_role(db, role_id, tenant_no=tenant_no, user_id=user.get("user_id"))
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/roles/{role_id}/users")
|
||||
async def list_oa_role_users(
|
||||
role_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
if not oa_crud.get_role(db, role_id, tenant_no):
|
||||
return error_response(404, "角色不存在")
|
||||
return success_response({"list": oa_crud.list_role_users(db, role_id, tenant_no)})
|
||||
|
||||
|
||||
@router.put("/roles/{role_id}/users")
|
||||
async def replace_oa_role_users(
|
||||
role_id: str,
|
||||
body: OaRoleUsersBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
oa_crud.replace_role_users(
|
||||
db,
|
||||
role_id,
|
||||
tenant_no=tenant_no,
|
||||
user_ids=body.user_ids,
|
||||
operator_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(
|
||||
{"list": oa_crud.list_role_users(db, role_id, tenant_no)},
|
||||
message="成员已更新",
|
||||
)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分组
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OaGroupCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
group_name: str = Field(..., alias="groupName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class OaGroupUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
group_name: Optional[str] = Field(None, alias="groupName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_oa_groups(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response({"list": oa_crud.list_groups(db, tenant_no=tenant_no)})
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_oa_group(
|
||||
body: OaGroupCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = oa_crud.create_group(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
group_name=body.group_name,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(oa_crud._group_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_oa_group(
|
||||
group_id: str,
|
||||
body: OaGroupUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = oa_crud.update_group(
|
||||
db,
|
||||
group_id,
|
||||
tenant_no=tenant_no,
|
||||
group_name=payload.get("group_name"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(oa_crud._group_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_oa_group(
|
||||
group_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
oa_crud.delete_group(db, group_id, tenant_no=tenant_no, user_id=user.get("user_id"))
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 应用 / 草稿
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OaAppCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
app_name: str = Field(..., alias="appName")
|
||||
group_id: Optional[str] = Field(None, alias="groupId")
|
||||
icon_url: Optional[str] = Field(None, alias="iconUrl")
|
||||
description: Optional[str] = None
|
||||
initiator_scope: Optional[str] = Field("all", alias="initiatorScope")
|
||||
initiator_user_ids: Optional[List[str]] = Field(None, alias="initiatorUserIds")
|
||||
admin_scope: Optional[str] = Field("all_oa_admin", alias="adminScope")
|
||||
admin_user_ids: Optional[List[str]] = Field(None, alias="adminUserIds")
|
||||
status: Optional[str] = "1"
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class OaAppUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
app_name: Optional[str] = Field(None, alias="appName")
|
||||
group_id: Optional[str] = Field(None, alias="groupId")
|
||||
icon_url: Optional[str] = Field(None, alias="iconUrl")
|
||||
description: Optional[str] = None
|
||||
initiator_scope: Optional[str] = Field(None, alias="initiatorScope")
|
||||
initiator_user_ids: Optional[List[str]] = Field(None, alias="initiatorUserIds")
|
||||
admin_scope: Optional[str] = Field(None, alias="adminScope")
|
||||
admin_user_ids: Optional[List[str]] = Field(None, alias="adminUserIds")
|
||||
status: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class OaDraftSaveBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
basic: Optional[Dict[str, Any]] = None
|
||||
form_schema_json: Optional[Any] = Field(None, alias="formSchemaJson")
|
||||
flow_tree_json: Optional[Any] = Field(None, alias="flowTreeJson")
|
||||
advanced_settings_json: Optional[Any] = Field(None, alias="advancedSettingsJson")
|
||||
|
||||
|
||||
@router.get("/apps")
|
||||
async def list_oa_apps(
|
||||
groupId: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = oa_crud.list_apps(
|
||||
db, tenant_no=tenant_no, group_id=groupId, keyword=keyword, status=status
|
||||
)
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
@router.post("/apps")
|
||||
async def create_oa_app(
|
||||
body: OaAppCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = oa_crud.create_app(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
app_name=body.app_name,
|
||||
group_id=body.group_id,
|
||||
icon_url=body.icon_url,
|
||||
description=body.description,
|
||||
initiator_scope=body.initiator_scope or "all",
|
||||
initiator_user_ids=body.initiator_user_ids,
|
||||
admin_scope=body.admin_scope or "all_oa_admin",
|
||||
admin_user_ids=body.admin_user_ids,
|
||||
status=body.status or "1",
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
group_name = ""
|
||||
if obj.group_id:
|
||||
g = oa_crud.get_group(db, obj.group_id, tenant_no)
|
||||
group_name = g.group_name if g else ""
|
||||
return success_response(
|
||||
oa_crud._app_to_dict(obj, group_name=group_name, has_draft=True),
|
||||
message="创建成功",
|
||||
)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/apps/{app_id}")
|
||||
async def update_oa_app(
|
||||
app_id: str,
|
||||
body: OaAppUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = oa_crud.update_app(db, app_id, tenant_no=tenant_no, user_id=user.get("user_id"), **payload)
|
||||
group_name = ""
|
||||
if obj.group_id:
|
||||
g = oa_crud.get_group(db, obj.group_id, tenant_no)
|
||||
group_name = g.group_name if g else ""
|
||||
return success_response(oa_crud._app_to_dict(obj, group_name=group_name), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/apps/{app_id}")
|
||||
async def delete_oa_app(
|
||||
app_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
oa_crud.delete_app(db, app_id, tenant_no=tenant_no, user_id=user.get("user_id"))
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/apps/{app_id}/draft")
|
||||
async def get_oa_app_draft(
|
||||
app_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = oa_crud.get_or_create_draft(
|
||||
db, app_id, tenant_no=tenant_no, user_id=user.get("user_id")
|
||||
)
|
||||
return success_response(data)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/apps/{app_id}/draft")
|
||||
async def save_oa_app_draft(
|
||||
app_id: str,
|
||||
body: OaDraftSaveBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = oa_crud.save_draft(
|
||||
db,
|
||||
app_id,
|
||||
tenant_no=tenant_no,
|
||||
basic=body.basic,
|
||||
form_schema_json=body.form_schema_json,
|
||||
flow_tree_json=body.flow_tree_json,
|
||||
advanced_settings_json=body.advanced_settings_json,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(data, message="草稿已保存")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/apps/{app_id}/publish")
|
||||
async def publish_oa_app(
|
||||
app_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_publish import publish_app
|
||||
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data, errors = publish_app(
|
||||
db, app_id, tenant_no=tenant_no, user_id=user.get("user_id")
|
||||
)
|
||||
if errors:
|
||||
return error_response(400, "发布校验未通过", data={"errors": errors})
|
||||
return success_response(data, message="发布成功")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 运行时:模板 / 实例 / 任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OaStartInstanceBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
app_id: str = Field(..., alias="appId")
|
||||
form_data: Optional[Dict[str, Any]] = Field(None, alias="formData")
|
||||
title: Optional[str] = None
|
||||
initiator_dept_id: Optional[str] = Field(None, alias="initiatorDeptId")
|
||||
initiator_select_user_ids: Optional[List[str]] = Field(None, alias="initiatorSelectUserIds")
|
||||
related_instance_ids: Optional[List[str]] = Field(None, alias="relatedInstanceIds")
|
||||
source_instance_id: Optional[str] = Field(None, alias="sourceInstanceId")
|
||||
|
||||
|
||||
class OaTaskActionBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class OaAddSignBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
user_ids: List[str] = Field(default_factory=list, alias="userIds")
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class OaResubmitBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
form_data: Optional[Dict[str, Any]] = Field(None, alias="formData")
|
||||
initiator_select_user_ids: Optional[List[str]] = Field(None, alias="initiatorSelectUserIds")
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_oa_templates(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services import oa_engine
|
||||
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = oa_engine.list_templates(db, tenant_no=tenant_no, user_id=user.get("user_id") or "")
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
@router.get("/templates/{app_id}")
|
||||
async def get_oa_template(
|
||||
app_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, get_template_detail
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = get_template_detail(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
app_id=app_id,
|
||||
user_id=user.get("user_id") or "",
|
||||
)
|
||||
return success_response(data)
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/templates/{app_id}/flow-preview")
|
||||
async def preview_oa_template_flow(
|
||||
app_id: str,
|
||||
initiator_select_user_ids: Optional[str] = Query(None, alias="initiatorSelectUserIds"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""发起前预览审批链(解析具体审批人)。"""
|
||||
from app.services.oa_engine import OaEngineError, preview_start_flow
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
uid = user.get("user_id") or ""
|
||||
dept_id = None
|
||||
if uid:
|
||||
row = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
if row:
|
||||
dept_id = row.dept_id
|
||||
select_ids = None
|
||||
if initiator_select_user_ids:
|
||||
select_ids = [x.strip() for x in initiator_select_user_ids.split(",") if x.strip()]
|
||||
data = preview_start_flow(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
app_id=app_id,
|
||||
initiator_user_id=uid,
|
||||
initiator_dept_id=dept_id,
|
||||
initiator_select_user_ids=select_ids,
|
||||
)
|
||||
return success_response(data)
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/instances")
|
||||
async def start_oa_instance(
|
||||
body: OaStartInstanceBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, start_instance
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = start_instance(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
app_id=body.app_id,
|
||||
initiator_user_id=user.get("user_id") or "",
|
||||
initiator_dept_id=body.initiator_dept_id,
|
||||
form_data=body.form_data,
|
||||
title=body.title,
|
||||
initiator_select_user_ids=body.initiator_select_user_ids,
|
||||
related_instance_ids=body.related_instance_ids,
|
||||
source_instance_id=body.source_instance_id,
|
||||
)
|
||||
return success_response(data, message="发起成功")
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/instances/{instance_id}")
|
||||
async def get_oa_instance(
|
||||
instance_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, get_instance_detail
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(get_instance_detail(db, instance_id, tenant_no=tenant_no))
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/tasks/{kind}")
|
||||
async def list_oa_tasks(
|
||||
kind: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, list_tasks
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = list_tasks(
|
||||
db, tenant_no=tenant_no, user_id=user.get("user_id") or "", kind=kind
|
||||
)
|
||||
return success_response({"list": items})
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/approve")
|
||||
async def approve_oa_task(
|
||||
task_id: str,
|
||||
body: Optional[OaTaskActionBody] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, complete_task
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = complete_task(
|
||||
db,
|
||||
task_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id") or "",
|
||||
action="approve",
|
||||
comment=(body.comment if body else None),
|
||||
)
|
||||
return success_response(data, message="已同意")
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/reject")
|
||||
async def reject_oa_task(
|
||||
task_id: str,
|
||||
body: Optional[OaTaskActionBody] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, complete_task
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = complete_task(
|
||||
db,
|
||||
task_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id") or "",
|
||||
action="reject",
|
||||
comment=(body.comment if body else None),
|
||||
)
|
||||
return success_response(data, message="已拒绝")
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/return")
|
||||
async def return_oa_task(
|
||||
task_id: str,
|
||||
body: Optional[OaTaskActionBody] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, return_task
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = return_task(
|
||||
db,
|
||||
task_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id") or "",
|
||||
comment=(body.comment if body else None),
|
||||
)
|
||||
return success_response(data, message="已打回")
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/instances/{instance_id}/resubmit")
|
||||
async def resubmit_oa_instance(
|
||||
instance_id: str,
|
||||
body: Optional[OaResubmitBody] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, resubmit_instance
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = resubmit_instance(
|
||||
db,
|
||||
instance_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id") or "",
|
||||
form_data=(body.form_data if body else None),
|
||||
initiator_select_user_ids=(body.initiator_select_user_ids if body else None),
|
||||
)
|
||||
return success_response(data, message="已再次提交")
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/add-sign")
|
||||
async def add_sign_oa_task(
|
||||
task_id: str,
|
||||
body: OaAddSignBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, add_sign
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = add_sign(
|
||||
db,
|
||||
task_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id") or "",
|
||||
user_ids=body.user_ids,
|
||||
comment=body.comment,
|
||||
)
|
||||
return success_response(data, message="加签成功")
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/instances/{instance_id}/withdraw")
|
||||
async def withdraw_oa_instance(
|
||||
instance_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.services.oa_engine import OaEngineError, withdraw_instance
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = withdraw_instance(
|
||||
db, instance_id, tenant_no=tenant_no, user_id=user.get("user_id") or ""
|
||||
)
|
||||
return success_response(data, message="已撤销")
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -0,0 +1,915 @@
|
||||
"""OA 审批 CRUD(工作流角色、分组、应用、草稿等)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import OaApp, OaAppGroup, OaAppVersion, OaRole, OaRoleUser, SysUser
|
||||
|
||||
DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = {
|
||||
"supervisorFallbackToDeptLeader": True,
|
||||
"allowWithdrawRunning": True,
|
||||
"approverDedup": False,
|
||||
"commentRequired": False,
|
||||
"allowAddSign": True,
|
||||
"allowReturn": True,
|
||||
}
|
||||
|
||||
|
||||
def _dumps(data: Any) -> Optional[str]:
|
||||
if data is None:
|
||||
return None
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
def _loads(text: Optional[str], default: Any = None) -> Any:
|
||||
if text is None or text == "":
|
||||
return default
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
PRESET_ROLES = (
|
||||
("GM", "总经理"),
|
||||
("CHAIRMAN", "董事长"),
|
||||
)
|
||||
|
||||
|
||||
def _role_to_dict(r: OaRole, *, member_count: int = 0) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"roleName": r.role_name,
|
||||
"roleCode": r.role_code,
|
||||
"roleKind": r.role_kind,
|
||||
"status": r.status,
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"memberCount": member_count,
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def ensure_presets(db: Session, tenant_no: str, user_id: Optional[str] = None) -> List[OaRole]:
|
||||
"""幂等写入预置角色 GM / CHAIRMAN。"""
|
||||
now = datetime.now()
|
||||
created: List[OaRole] = []
|
||||
for code, name in PRESET_ROLES:
|
||||
exists = (
|
||||
db.query(OaRole)
|
||||
.filter(
|
||||
OaRole.tenant_no == tenant_no,
|
||||
OaRole.role_code == code,
|
||||
OaRole.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
obj = OaRole(
|
||||
id=generate_uuid(),
|
||||
role_name=name,
|
||||
role_code=code,
|
||||
role_kind="preset",
|
||||
status="1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=0 if code == "GM" else 1,
|
||||
remark="系统预置工作流角色",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
created.append(obj)
|
||||
if created:
|
||||
db.commit()
|
||||
for obj in created:
|
||||
db.refresh(obj)
|
||||
return created
|
||||
|
||||
|
||||
def _member_count_map(db: Session, role_ids: List[str], tenant_no: str) -> Dict[str, int]:
|
||||
if not role_ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(OaRoleUser.oa_role_id, OaRoleUser.id)
|
||||
.filter(
|
||||
OaRoleUser.oa_role_id.in_(role_ids),
|
||||
OaRoleUser.tenant_no == tenant_no,
|
||||
OaRoleUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
counts: Dict[str, int] = {rid: 0 for rid in role_ids}
|
||||
for rid, _ in rows:
|
||||
counts[rid] = counts.get(rid, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def list_roles(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
ensure_presets(db, tenant_no)
|
||||
q = db.query(OaRole).filter(OaRole.tenant_no == tenant_no, OaRole.del_flag == 0)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter((OaRole.role_name.like(like)) | (OaRole.role_code.like(like)))
|
||||
if status:
|
||||
q = q.filter(OaRole.status == status)
|
||||
rows = q.order_by(OaRole.role_kind.desc(), OaRole.order_num.asc(), OaRole.create_time.desc()).all()
|
||||
counts = _member_count_map(db, [r.id for r in rows], tenant_no)
|
||||
return [_role_to_dict(r, member_count=counts.get(r.id, 0)) for r in rows]
|
||||
|
||||
|
||||
def get_role(db: Session, role_id: str, tenant_no: str) -> Optional[OaRole]:
|
||||
return (
|
||||
db.query(OaRole)
|
||||
.filter(OaRole.id == role_id, OaRole.tenant_no == tenant_no, OaRole.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_role(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
role_name: str,
|
||||
role_code: str,
|
||||
status: str = "1",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaRole:
|
||||
name = (role_name or "").strip()
|
||||
code = (role_code or "").strip().upper()
|
||||
if not name:
|
||||
raise ValueError("角色名称不能为空")
|
||||
if not code:
|
||||
raise ValueError("角色编码不能为空")
|
||||
if code in {c for c, _ in PRESET_ROLES}:
|
||||
raise ValueError("该编码为系统预置,请勿占用")
|
||||
exists = (
|
||||
db.query(OaRole)
|
||||
.filter(OaRole.tenant_no == tenant_no, OaRole.role_code == code, OaRole.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
raise ValueError("角色编码已存在")
|
||||
now = datetime.now()
|
||||
obj = OaRole(
|
||||
id=generate_uuid(),
|
||||
role_name=name,
|
||||
role_code=code,
|
||||
role_kind="custom",
|
||||
status=status or "1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 10,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_role(
|
||||
db: Session,
|
||||
role_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
role_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaRole:
|
||||
obj = get_role(db, role_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("角色不存在")
|
||||
if role_name is not None:
|
||||
name = (role_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("角色名称不能为空")
|
||||
obj.role_name = name
|
||||
if status is not None:
|
||||
obj.status = status
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_role(db: Session, role_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
|
||||
obj = get_role(db, role_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("角色不存在")
|
||||
if obj.role_kind == "preset":
|
||||
raise ValueError("预置角色不可删除")
|
||||
obj.del_flag = 1
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_role_users(db: Session, role_id: str, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(OaRoleUser, SysUser)
|
||||
.outerjoin(SysUser, and_(SysUser.id == OaRoleUser.user_id, SysUser.del_flag == 0))
|
||||
.filter(
|
||||
OaRoleUser.oa_role_id == role_id,
|
||||
OaRoleUser.tenant_no == tenant_no,
|
||||
OaRoleUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for ru, u in rows:
|
||||
out.append(
|
||||
{
|
||||
"id": ru.id,
|
||||
"userId": ru.user_id,
|
||||
"nickName": (u.nick_name if u else "") or "",
|
||||
"userNo": (u.user_no if u else "") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def replace_role_users(
|
||||
db: Session,
|
||||
role_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_ids: List[str],
|
||||
operator_id: Optional[str] = None,
|
||||
) -> None:
|
||||
obj = get_role(db, role_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("角色不存在")
|
||||
now = datetime.now()
|
||||
existing = (
|
||||
db.query(OaRoleUser)
|
||||
.filter(
|
||||
OaRoleUser.oa_role_id == role_id,
|
||||
OaRoleUser.tenant_no == tenant_no,
|
||||
OaRoleUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in existing:
|
||||
row.del_flag = 1
|
||||
row.update_by = operator_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
seen = set()
|
||||
for uid in user_ids or []:
|
||||
uid = (uid or "").strip()
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
db.add(
|
||||
OaRoleUser(
|
||||
id=generate_uuid(),
|
||||
oa_role_id=role_id,
|
||||
user_id=uid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=operator_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=operator_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_oa_role_user_ids(db: Session, role_ids: List[str], tenant_no: str) -> List[str]:
|
||||
if not role_ids:
|
||||
return []
|
||||
enabled = {
|
||||
r.id
|
||||
for r in db.query(OaRole)
|
||||
.filter(
|
||||
OaRole.id.in_(role_ids),
|
||||
OaRole.tenant_no == tenant_no,
|
||||
OaRole.del_flag == 0,
|
||||
OaRole.status == "1",
|
||||
)
|
||||
.all()
|
||||
}
|
||||
if not enabled:
|
||||
return []
|
||||
rows = (
|
||||
db.query(OaRoleUser.user_id, OaRoleUser.oa_role_id)
|
||||
.filter(
|
||||
OaRoleUser.oa_role_id.in_(list(enabled)),
|
||||
OaRoleUser.tenant_no == tenant_no,
|
||||
OaRoleUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for uid, rid in rows:
|
||||
if rid in enabled and uid and uid not in seen:
|
||||
seen.add(uid)
|
||||
out.append(uid)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分组 oa_app_group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _group_to_dict(g: OaAppGroup) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": g.id,
|
||||
"groupName": g.group_name,
|
||||
"tenantNo": g.tenant_no,
|
||||
"orderNum": g.order_num if g.order_num is not None else 0,
|
||||
"remark": g.remark or "",
|
||||
"createTime": g.create_time.strftime("%Y-%m-%d %H:%M:%S") if g.create_time else "",
|
||||
"updateTime": g.update_time.strftime("%Y-%m-%d %H:%M:%S") if g.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_groups(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(OaAppGroup)
|
||||
.filter(OaAppGroup.tenant_no == tenant_no, OaAppGroup.del_flag == 0)
|
||||
.order_by(OaAppGroup.order_num.asc(), OaAppGroup.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
return [_group_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_group(db: Session, group_id: str, tenant_no: str) -> Optional[OaAppGroup]:
|
||||
return (
|
||||
db.query(OaAppGroup)
|
||||
.filter(OaAppGroup.id == group_id, OaAppGroup.tenant_no == tenant_no, OaAppGroup.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_group(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
group_name: str,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaAppGroup:
|
||||
name = (group_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("分组名称不能为空")
|
||||
now = datetime.now()
|
||||
obj = OaAppGroup(
|
||||
id=generate_uuid(),
|
||||
group_name=name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_group(
|
||||
db: Session,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
group_name: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaAppGroup:
|
||||
obj = get_group(db, group_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("分组不存在")
|
||||
if group_name is not None:
|
||||
name = (group_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("分组名称不能为空")
|
||||
obj.group_name = name
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_group(db: Session, group_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
|
||||
obj = get_group(db, group_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("分组不存在")
|
||||
used = (
|
||||
db.query(OaApp.id)
|
||||
.filter(OaApp.group_id == group_id, OaApp.tenant_no == tenant_no, OaApp.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if used:
|
||||
raise ValueError("分组下仍有应用,无法删除")
|
||||
obj.del_flag = 1
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 应用 oa_app / 草稿 oa_app_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_to_dict(a: OaApp, *, group_name: str = "", has_draft: bool = False, latest_published: Optional[int] = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": a.id,
|
||||
"groupId": a.group_id or "",
|
||||
"groupName": group_name or "",
|
||||
"appName": a.app_name,
|
||||
"iconUrl": a.icon_url or "",
|
||||
"description": a.description or "",
|
||||
"initiatorScope": a.initiator_scope or "all",
|
||||
"initiatorUserIds": _loads(a.initiator_user_ids, []),
|
||||
"adminScope": a.admin_scope or "all_oa_admin",
|
||||
"adminUserIds": _loads(a.admin_user_ids, []),
|
||||
"status": a.status,
|
||||
"tenantNo": a.tenant_no,
|
||||
"orderNum": a.order_num if a.order_num is not None else 0,
|
||||
"remark": a.remark or "",
|
||||
"hasDraft": has_draft,
|
||||
"latestPublishedVersion": latest_published,
|
||||
"createTime": a.create_time.strftime("%Y-%m-%d %H:%M:%S") if a.create_time else "",
|
||||
"updateTime": a.update_time.strftime("%Y-%m-%d %H:%M:%S") if a.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def get_app(db: Session, app_id: str, tenant_no: str) -> Optional[OaApp]:
|
||||
return (
|
||||
db.query(OaApp)
|
||||
.filter(OaApp.id == app_id, OaApp.tenant_no == tenant_no, OaApp.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def list_apps(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
group_id: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
q = db.query(OaApp).filter(OaApp.tenant_no == tenant_no, OaApp.del_flag == 0)
|
||||
if group_id:
|
||||
q = q.filter(OaApp.group_id == group_id)
|
||||
if status:
|
||||
q = q.filter(OaApp.status == status)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(OaApp.app_name.like(like))
|
||||
rows = q.order_by(OaApp.order_num.asc(), OaApp.create_time.desc()).all()
|
||||
group_ids = list({r.group_id for r in rows if r.group_id})
|
||||
group_names: Dict[str, str] = {}
|
||||
if group_ids:
|
||||
for g in (
|
||||
db.query(OaAppGroup)
|
||||
.filter(OaAppGroup.id.in_(group_ids), OaAppGroup.del_flag == 0)
|
||||
.all()
|
||||
):
|
||||
group_names[g.id] = g.group_name
|
||||
out: List[Dict[str, Any]] = []
|
||||
for r in rows:
|
||||
draft = (
|
||||
db.query(OaAppVersion.id)
|
||||
.filter(
|
||||
OaAppVersion.app_id == r.id,
|
||||
OaAppVersion.tenant_no == tenant_no,
|
||||
OaAppVersion.status == "draft",
|
||||
OaAppVersion.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
pub = (
|
||||
db.query(OaAppVersion)
|
||||
.filter(
|
||||
OaAppVersion.app_id == r.id,
|
||||
OaAppVersion.tenant_no == tenant_no,
|
||||
OaAppVersion.is_latest_published == "1",
|
||||
OaAppVersion.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
out.append(
|
||||
_app_to_dict(
|
||||
r,
|
||||
group_name=group_names.get(r.group_id or "", ""),
|
||||
has_draft=bool(draft),
|
||||
latest_published=pub.version_no if pub else None,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _create_version(
|
||||
db: Session,
|
||||
*,
|
||||
app_id: str,
|
||||
tenant_no: str,
|
||||
version_no: int,
|
||||
status: str,
|
||||
form_schema: Any,
|
||||
flow_tree: Any,
|
||||
advanced: Any,
|
||||
user_id: Optional[str],
|
||||
is_latest_published: str = "0",
|
||||
) -> OaAppVersion:
|
||||
now = datetime.now()
|
||||
ver = OaAppVersion(
|
||||
id=generate_uuid(),
|
||||
app_id=app_id,
|
||||
version_no=version_no,
|
||||
status=status,
|
||||
is_latest_published=is_latest_published,
|
||||
form_schema_json=_dumps(form_schema if form_schema is not None else {}),
|
||||
flow_tree_json=_dumps(flow_tree if flow_tree is not None else {}),
|
||||
advanced_settings_json=_dumps(advanced if advanced is not None else DEFAULT_ADVANCED_SETTINGS),
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(ver)
|
||||
return ver
|
||||
|
||||
|
||||
def create_app(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
app_name: str,
|
||||
group_id: Optional[str] = None,
|
||||
icon_url: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
initiator_scope: str = "all",
|
||||
initiator_user_ids: Optional[List[str]] = None,
|
||||
admin_scope: str = "all_oa_admin",
|
||||
admin_user_ids: Optional[List[str]] = None,
|
||||
status: str = "1",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaApp:
|
||||
name = (app_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("应用名称不能为空")
|
||||
if len(name) > 50:
|
||||
raise ValueError("应用名称不能超过50字")
|
||||
gid = (group_id or "").strip() or None
|
||||
if gid and not get_group(db, gid, tenant_no):
|
||||
raise ValueError("分组不存在")
|
||||
now = datetime.now()
|
||||
obj = OaApp(
|
||||
id=generate_uuid(),
|
||||
group_id=gid,
|
||||
app_name=name,
|
||||
icon_url=(icon_url or "").strip() or None,
|
||||
description=(description or "").strip() or None,
|
||||
initiator_scope=initiator_scope or "all",
|
||||
initiator_user_ids=_dumps(initiator_user_ids or []),
|
||||
admin_scope=admin_scope or "all_oa_admin",
|
||||
admin_user_ids=_dumps(admin_user_ids or []),
|
||||
status=status or "1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
_create_version(
|
||||
db,
|
||||
app_id=obj.id,
|
||||
tenant_no=tenant_no,
|
||||
version_no=1,
|
||||
status="draft",
|
||||
form_schema={},
|
||||
flow_tree={},
|
||||
advanced=DEFAULT_ADVANCED_SETTINGS,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_app(
|
||||
db: Session,
|
||||
app_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
app_name: Optional[str] = None,
|
||||
group_id: Optional[str] = None,
|
||||
icon_url: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
initiator_scope: Optional[str] = None,
|
||||
initiator_user_ids: Optional[List[str]] = None,
|
||||
admin_scope: Optional[str] = None,
|
||||
admin_user_ids: Optional[List[str]] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> OaApp:
|
||||
obj = get_app(db, app_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("应用不存在")
|
||||
if app_name is not None:
|
||||
name = (app_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("应用名称不能为空")
|
||||
if len(name) > 50:
|
||||
raise ValueError("应用名称不能超过50字")
|
||||
obj.app_name = name
|
||||
if group_id is not None:
|
||||
gid = (group_id or "").strip() or None
|
||||
if gid and not get_group(db, gid, tenant_no):
|
||||
raise ValueError("分组不存在")
|
||||
obj.group_id = gid
|
||||
if icon_url is not None:
|
||||
obj.icon_url = (icon_url or "").strip() or None
|
||||
if description is not None:
|
||||
desc = (description or "").strip()
|
||||
if len(desc) > 100:
|
||||
raise ValueError("表单说明不能超过100字")
|
||||
obj.description = desc or None
|
||||
if initiator_scope is not None:
|
||||
obj.initiator_scope = initiator_scope
|
||||
if initiator_user_ids is not None:
|
||||
obj.initiator_user_ids = _dumps(initiator_user_ids)
|
||||
if admin_scope is not None:
|
||||
obj.admin_scope = admin_scope
|
||||
if admin_user_ids is not None:
|
||||
obj.admin_user_ids = _dumps(admin_user_ids)
|
||||
if status is not None:
|
||||
obj.status = status
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_app(db: Session, app_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
|
||||
obj = get_app(db, app_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("应用不存在")
|
||||
now = datetime.now()
|
||||
obj.del_flag = 1
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
for ver in (
|
||||
db.query(OaAppVersion)
|
||||
.filter(OaAppVersion.app_id == app_id, OaAppVersion.tenant_no == tenant_no, OaAppVersion.del_flag == 0)
|
||||
.all()
|
||||
):
|
||||
ver.del_flag = 1
|
||||
ver.update_by = user_id
|
||||
ver.update_time = now
|
||||
db.add(ver)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _find_draft(db: Session, app_id: str, tenant_no: str) -> Optional[OaAppVersion]:
|
||||
return (
|
||||
db.query(OaAppVersion)
|
||||
.filter(
|
||||
OaAppVersion.app_id == app_id,
|
||||
OaAppVersion.tenant_no == tenant_no,
|
||||
OaAppVersion.status == "draft",
|
||||
OaAppVersion.del_flag == 0,
|
||||
)
|
||||
.order_by(OaAppVersion.version_no.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _find_latest_published(db: Session, app_id: str, tenant_no: str) -> Optional[OaAppVersion]:
|
||||
return (
|
||||
db.query(OaAppVersion)
|
||||
.filter(
|
||||
OaAppVersion.app_id == app_id,
|
||||
OaAppVersion.tenant_no == tenant_no,
|
||||
OaAppVersion.is_latest_published == "1",
|
||||
OaAppVersion.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _version_draft_to_dict(app: OaApp, ver: OaAppVersion) -> Dict[str, Any]:
|
||||
advanced = _loads(ver.advanced_settings_json, None)
|
||||
if not isinstance(advanced, dict):
|
||||
advanced = dict(DEFAULT_ADVANCED_SETTINGS)
|
||||
else:
|
||||
merged = dict(DEFAULT_ADVANCED_SETTINGS)
|
||||
merged.update(advanced)
|
||||
advanced = merged
|
||||
return {
|
||||
"appId": app.id,
|
||||
"versionId": ver.id,
|
||||
"versionNo": ver.version_no,
|
||||
"status": ver.status,
|
||||
"basic": {
|
||||
"groupId": app.group_id or "",
|
||||
"appName": app.app_name,
|
||||
"iconUrl": app.icon_url or "",
|
||||
"description": app.description or "",
|
||||
"initiatorScope": app.initiator_scope or "all",
|
||||
"initiatorUserIds": _loads(app.initiator_user_ids, []),
|
||||
"adminScope": app.admin_scope or "all_oa_admin",
|
||||
"adminUserIds": _loads(app.admin_user_ids, []),
|
||||
"status": app.status,
|
||||
"orderNum": app.order_num if app.order_num is not None else 0,
|
||||
"remark": app.remark or "",
|
||||
},
|
||||
"formSchemaJson": _loads(ver.form_schema_json, {}),
|
||||
"flowTreeJson": _loads(ver.flow_tree_json, {}),
|
||||
"advancedSettingsJson": advanced,
|
||||
"updateTime": ver.update_time.strftime("%Y-%m-%d %H:%M:%S") if ver.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def get_or_create_draft(
|
||||
db: Session, app_id: str, *, tenant_no: str, user_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
app = get_app(db, app_id, tenant_no)
|
||||
if not app:
|
||||
raise ValueError("应用不存在")
|
||||
draft = _find_draft(db, app_id, tenant_no)
|
||||
if draft:
|
||||
return _version_draft_to_dict(app, draft)
|
||||
published = _find_latest_published(db, app_id, tenant_no)
|
||||
if published:
|
||||
next_no = (published.version_no or 0) + 1
|
||||
draft = _create_version(
|
||||
db,
|
||||
app_id=app_id,
|
||||
tenant_no=tenant_no,
|
||||
version_no=next_no,
|
||||
status="draft",
|
||||
form_schema=_loads(published.form_schema_json, {}),
|
||||
flow_tree=_loads(published.flow_tree_json, {}),
|
||||
advanced=_loads(published.advanced_settings_json, DEFAULT_ADVANCED_SETTINGS),
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
max_row = (
|
||||
db.query(OaAppVersion)
|
||||
.filter(OaAppVersion.app_id == app_id, OaAppVersion.tenant_no == tenant_no, OaAppVersion.del_flag == 0)
|
||||
.order_by(OaAppVersion.version_no.desc())
|
||||
.first()
|
||||
)
|
||||
next_no = (max_row.version_no if max_row else 0) + 1
|
||||
draft = _create_version(
|
||||
db,
|
||||
app_id=app_id,
|
||||
tenant_no=tenant_no,
|
||||
version_no=next_no or 1,
|
||||
status="draft",
|
||||
form_schema={},
|
||||
flow_tree={},
|
||||
advanced=DEFAULT_ADVANCED_SETTINGS,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(draft)
|
||||
return _version_draft_to_dict(app, draft)
|
||||
|
||||
|
||||
def save_draft(
|
||||
db: Session,
|
||||
app_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
basic: Optional[Dict[str, Any]] = None,
|
||||
form_schema_json: Any = None,
|
||||
flow_tree_json: Any = None,
|
||||
advanced_settings_json: Any = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
app = get_app(db, app_id, tenant_no)
|
||||
if not app:
|
||||
raise ValueError("应用不存在")
|
||||
# 确保草稿存在
|
||||
get_or_create_draft(db, app_id, tenant_no=tenant_no, user_id=user_id)
|
||||
draft = _find_draft(db, app_id, tenant_no)
|
||||
if not draft:
|
||||
raise ValueError("草稿不存在")
|
||||
if basic:
|
||||
field_map = {
|
||||
"appName": "app_name",
|
||||
"groupId": "group_id",
|
||||
"iconUrl": "icon_url",
|
||||
"description": "description",
|
||||
"initiatorScope": "initiator_scope",
|
||||
"initiatorUserIds": "initiator_user_ids",
|
||||
"adminScope": "admin_scope",
|
||||
"adminUserIds": "admin_user_ids",
|
||||
"status": "status",
|
||||
"orderNum": "order_num",
|
||||
"remark": "remark",
|
||||
}
|
||||
kwargs = {field_map[k]: basic[k] for k in field_map if k in basic}
|
||||
if kwargs:
|
||||
update_app(db, app_id, tenant_no=tenant_no, user_id=user_id, **kwargs)
|
||||
app = get_app(db, app_id, tenant_no)
|
||||
draft = _find_draft(db, app_id, tenant_no)
|
||||
if not draft:
|
||||
raise ValueError("草稿不存在")
|
||||
now = datetime.now()
|
||||
if form_schema_json is not None:
|
||||
draft.form_schema_json = _dumps(form_schema_json)
|
||||
if flow_tree_json is not None:
|
||||
draft.flow_tree_json = _dumps(flow_tree_json)
|
||||
if advanced_settings_json is not None:
|
||||
if isinstance(advanced_settings_json, dict):
|
||||
merged = dict(DEFAULT_ADVANCED_SETTINGS)
|
||||
merged.update(advanced_settings_json)
|
||||
draft.advanced_settings_json = _dumps(merged)
|
||||
else:
|
||||
draft.advanced_settings_json = _dumps(advanced_settings_json)
|
||||
draft.update_by = user_id
|
||||
draft.update_time = now
|
||||
db.add(draft)
|
||||
db.commit()
|
||||
db.refresh(draft)
|
||||
assert app is not None
|
||||
return _version_draft_to_dict(app, draft)
|
||||
@@ -26,6 +26,7 @@ class SysUser(Base):
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
dept_id = Column(String(64), nullable=True, comment="部门ID")
|
||||
supervisor_user_id = Column(String(64), nullable=True, comment="直属主管用户ID")
|
||||
user_no = Column(String(30), nullable=False, comment="用户账号")
|
||||
nick_name = Column(String(30), nullable=False, comment="用户名")
|
||||
email = Column(String(50), nullable=False, default="", comment="用户邮箱")
|
||||
@@ -159,6 +160,7 @@ class SysDept(Base):
|
||||
dept_name = Column(String(200), nullable=False, comment="组织名称")
|
||||
dept_category_code = Column(String(64), nullable=True, comment="组织类别编码")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="部门状态")
|
||||
leader_user_id = Column(String(64), nullable=True, comment="部门负责人用户ID")
|
||||
leader = Column(String(100), nullable=True, comment="部门负责人")
|
||||
leader_phone = Column(String(32), nullable=True, comment="部门负责人联系电话")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
@@ -728,6 +730,221 @@ class WfTask(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OA 审批(钉钉交互,oa_*)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OaAppGroup(Base):
|
||||
"""OA审批分组 oa_app_group"""
|
||||
|
||||
__tablename__ = "oa_app_group"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
group_name = Column(String(100), nullable=False, comment="分组名称")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaApp(Base):
|
||||
"""OA审批应用 oa_app"""
|
||||
|
||||
__tablename__ = "oa_app"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
group_id = Column(String(64), nullable=True, comment="分组ID")
|
||||
app_name = Column(String(50), nullable=False, comment="表单/应用名称")
|
||||
icon_url = Column(String(512), nullable=True, comment="图标")
|
||||
description = Column(String(100), nullable=True, comment="表单说明")
|
||||
initiator_scope = Column(String(32), nullable=False, default="all", comment="发起范围")
|
||||
initiator_user_ids = Column(Text, nullable=True, comment="指定发起人JSON")
|
||||
admin_scope = Column(String(32), nullable=False, default="all_oa_admin", comment="管理员范围")
|
||||
admin_user_ids = Column(Text, nullable=True, comment="指定管理员JSON")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="1启用 0停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaAppVersion(Base):
|
||||
"""OA审批应用版本 oa_app_version"""
|
||||
|
||||
__tablename__ = "oa_app_version"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
app_id = Column(String(64), nullable=False, comment="应用ID")
|
||||
version_no = Column(Integer, nullable=False, comment="版本号")
|
||||
status = Column(String(16), nullable=False, default="draft", comment="draft/published")
|
||||
is_latest_published = Column(CHAR(1), nullable=False, default="0", comment="是否最新已发布")
|
||||
form_schema_json = Column(Text, nullable=True, comment="表单Schema JSON")
|
||||
flow_tree_json = Column(Text, nullable=True, comment="流程树 JSON")
|
||||
advanced_settings_json = Column(Text, nullable=True, comment="高级设置 JSON")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaRole(Base):
|
||||
"""OA工作流角色 oa_role"""
|
||||
|
||||
__tablename__ = "oa_role"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
role_name = Column(String(100), nullable=False, comment="角色名称")
|
||||
role_code = Column(String(64), nullable=False, comment="角色编码")
|
||||
role_kind = Column(String(16), nullable=False, default="custom", comment="custom/preset")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="1启用 0停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaRoleUser(Base):
|
||||
"""OA工作流角色成员 oa_role_user"""
|
||||
|
||||
__tablename__ = "oa_role_user"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
oa_role_id = Column(String(64), nullable=False, comment="工作流角色ID")
|
||||
user_id = Column(String(64), nullable=False, comment="用户ID")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaInstance(Base):
|
||||
"""OA审批实例 oa_instance"""
|
||||
|
||||
__tablename__ = "oa_instance"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
app_id = Column(String(64), nullable=False, comment="应用ID")
|
||||
app_version_id = Column(String(64), nullable=False, comment="锁定版本ID")
|
||||
title = Column(String(200), nullable=True, comment="实例标题")
|
||||
initiator_user_id = Column(String(64), nullable=False, comment="发起人")
|
||||
initiator_dept_id = Column(String(64), nullable=True, comment="发起人部门")
|
||||
form_data_json = Column(Text, nullable=True, comment="表单数据 JSON")
|
||||
status = Column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default="running",
|
||||
comment="running/returned/completed/rejected/cancelled",
|
||||
)
|
||||
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID")
|
||||
current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaTask(Base):
|
||||
"""OA审批任务 oa_task"""
|
||||
|
||||
__tablename__ = "oa_task"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
instance_id = Column(String(64), nullable=False, comment="实例ID")
|
||||
node_key = Column(String(64), nullable=True, comment="节点key")
|
||||
node_name = Column(String(200), nullable=True, comment="节点名称")
|
||||
task_type = Column(String(16), nullable=False, default="approval", comment="approval/cc")
|
||||
assignee_user_id = Column(String(64), nullable=False, comment="处理人")
|
||||
status = Column(String(32), nullable=False, default="pending", comment="任务状态")
|
||||
comment_text = Column(String(512), nullable=True, comment="意见")
|
||||
resolve_source = Column(String(64), nullable=True, comment="解析来源")
|
||||
sign_mode = Column(String(8), nullable=True, comment="or/and")
|
||||
round_no = Column(Integer, nullable=False, default=1, comment="所属提交轮次")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaInstanceEvent(Base):
|
||||
"""OA实例操作事件 oa_instance_event"""
|
||||
|
||||
__tablename__ = "oa_instance_event"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
instance_id = Column(String(64), nullable=False, comment="实例ID")
|
||||
round_no = Column(Integer, nullable=False, default=1, comment="轮次")
|
||||
event_type = Column(String(32), nullable=False, comment="submit/return/withdraw/…")
|
||||
actor_user_id = Column(String(64), nullable=True, comment="操作人")
|
||||
task_id = Column(String(64), nullable=True, comment="关联任务")
|
||||
node_key = Column(String(64), nullable=True, comment="节点key")
|
||||
comment_text = Column(String(512), nullable=True, comment="意见")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaRelation(Base):
|
||||
"""OA关联审批 oa_relation"""
|
||||
|
||||
__tablename__ = "oa_relation"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
from_instance_id = Column(String(64), nullable=False, comment="本实例ID")
|
||||
to_instance_id = Column(String(64), nullable=False, comment="被关联实例ID")
|
||||
field_key = Column(String(64), nullable=True, comment="表单字段key")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 细胞存储 — 采集协议单(biz_collection_protocol / audit / payment)
|
||||
# =============================================================================
|
||||
|
||||
@@ -9,7 +9,16 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud import oa_approval as oa_crud
|
||||
from app.models.models import OaApp, OaAppVersion, OaInstance, OaRelation, OaRole, OaTask, SysUser
|
||||
from app.models.models import (
|
||||
OaApp,
|
||||
OaAppVersion,
|
||||
OaInstance,
|
||||
OaInstanceEvent,
|
||||
OaRelation,
|
||||
OaRole,
|
||||
OaTask,
|
||||
SysUser,
|
||||
)
|
||||
from app.services.oa_assignee import AssigneeResolveError, resolve_assignees
|
||||
from app.services.oa_formula import FormulaError, apply_formulas_and_linkages, eval_formula
|
||||
|
||||
@@ -106,9 +115,10 @@ def _sort_tasks_for_detail(
|
||||
node_order = _flow_node_key_order(flow_root) if isinstance(flow_root, dict) else {}
|
||||
far = 10**9
|
||||
|
||||
def key(t: OaTask) -> Tuple[int, int, datetime, str]:
|
||||
def key(t: OaTask) -> Tuple[int, int, int, datetime, str]:
|
||||
nk = (t.node_key or "").strip()
|
||||
return (
|
||||
int(getattr(t, "round_no", None) or 1),
|
||||
node_order.get(nk, far),
|
||||
int(t.order_num) if t.order_num is not None else 0,
|
||||
t.create_time or datetime.min,
|
||||
@@ -141,58 +151,33 @@ def _terminal_item(status: str, title: str, inst: Any) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def build_progress_items(
|
||||
def _task_round(t: Any) -> int:
|
||||
v = getattr(t, "round_no", None)
|
||||
try:
|
||||
if v is None:
|
||||
return 1
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def _append_pending_nodes(
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
inst: Any,
|
||||
flow_root: Optional[Dict[str, Any]],
|
||||
tasks: List[Any],
|
||||
name_map: Dict[str, str],
|
||||
events: Optional[List[Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""投影审批进度。P0:submit + 按树序 tasks + pending_node + terminal。
|
||||
P1:插入 return/resubmit 事件并带 roundNo。"""
|
||||
_ = events # P1 使用
|
||||
items: List[Dict[str, Any]] = []
|
||||
round_no = 1
|
||||
items.append({
|
||||
"kind": "submit",
|
||||
"roundNo": round_no,
|
||||
"title": "发起人",
|
||||
"status": "submitted",
|
||||
"assigneeUserId": inst.initiator_user_id,
|
||||
"assigneeUserName": name_map.get(inst.initiator_user_id or "", ""),
|
||||
"timestamp": _fmt_dt(getattr(inst, "create_time", None)),
|
||||
"commentText": "",
|
||||
"nodeKey": "",
|
||||
"taskType": "",
|
||||
})
|
||||
sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root)
|
||||
reached_keys = {(t.node_key or "").strip() for t in sorted_tasks if (t.node_key or "").strip()}
|
||||
for t in sorted_tasks:
|
||||
items.append({
|
||||
"kind": "task",
|
||||
"roundNo": round_no,
|
||||
"title": t.node_name or t.node_key or "-",
|
||||
"status": t.status,
|
||||
"assigneeUserId": t.assignee_user_id,
|
||||
"assigneeUserName": name_map.get(t.assignee_user_id or "", ""),
|
||||
"timestamp": _fmt_dt(t.update_time or t.create_time),
|
||||
"commentText": t.comment_text or "",
|
||||
"nodeKey": t.node_key or "",
|
||||
"taskType": t.task_type or "",
|
||||
"resolveSource": getattr(t, "resolve_source", None) or "",
|
||||
"taskId": t.id,
|
||||
})
|
||||
if inst.status == "running" and isinstance(flow_root, dict):
|
||||
order = _flow_node_key_order(flow_root)
|
||||
for nid, _idx in sorted(order.items(), key=lambda x: x[1]):
|
||||
node = _find_node(flow_root, nid)
|
||||
if not node:
|
||||
continue
|
||||
nt = _node_type(node)
|
||||
if nt not in ("approval", "cc") or nid in reached_keys:
|
||||
continue
|
||||
items.append({
|
||||
flow_root: Dict[str, Any],
|
||||
reached_keys: set,
|
||||
round_no: int,
|
||||
) -> None:
|
||||
order = _flow_node_key_order(flow_root)
|
||||
for nid, _idx in sorted(order.items(), key=lambda x: x[1]):
|
||||
node = _find_node(flow_root, nid)
|
||||
if not node:
|
||||
continue
|
||||
nt = _node_type(node)
|
||||
if nt not in ("approval", "cc") or nid in reached_keys:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"kind": "pending_node",
|
||||
"roundNo": round_no,
|
||||
"title": node.get("name") or ("抄送人" if nt == "cc" else "审批人"),
|
||||
@@ -203,7 +188,101 @@ def build_progress_items(
|
||||
"commentText": "",
|
||||
"nodeKey": nid,
|
||||
"taskType": nt,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_progress_items(
|
||||
*,
|
||||
inst: Any,
|
||||
flow_root: Optional[Dict[str, Any]],
|
||||
tasks: List[Any],
|
||||
name_map: Dict[str, str],
|
||||
events: Optional[List[Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""投影审批进度:按轮次 submit → 树序 tasks → return 事件 → 未到达;终态收尾。"""
|
||||
events = list(events or [])
|
||||
sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root)
|
||||
current_round = int(getattr(inst, "current_round", None) or 1)
|
||||
round_set = {1, current_round}
|
||||
for t in sorted_tasks:
|
||||
round_set.add(_task_round(t))
|
||||
for e in events:
|
||||
round_set.add(int(getattr(e, "round_no", None) or 1))
|
||||
rounds = sorted(round_set)
|
||||
|
||||
returns_by_round: Dict[int, Any] = {}
|
||||
for e in events:
|
||||
if (getattr(e, "event_type", None) or "") == "return":
|
||||
returns_by_round[int(getattr(e, "round_no", None) or 1)] = e
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for rn in rounds:
|
||||
submit_ts = _fmt_dt(getattr(inst, "create_time", None)) if rn == 1 else ""
|
||||
for e in events:
|
||||
if (getattr(e, "event_type", None) or "") == "submit" and int(
|
||||
getattr(e, "round_no", None) or 1
|
||||
) == rn:
|
||||
submit_ts = _fmt_dt(getattr(e, "create_time", None)) or submit_ts
|
||||
break
|
||||
items.append(
|
||||
{
|
||||
"kind": "submit",
|
||||
"roundNo": rn,
|
||||
"title": "发起人" if rn == 1 else f"发起人(第 {rn} 次提交)",
|
||||
"status": "submitted",
|
||||
"assigneeUserId": inst.initiator_user_id,
|
||||
"assigneeUserName": name_map.get(inst.initiator_user_id or "", ""),
|
||||
"timestamp": submit_ts,
|
||||
"commentText": "",
|
||||
"nodeKey": "",
|
||||
"taskType": "",
|
||||
}
|
||||
)
|
||||
round_tasks = [t for t in sorted_tasks if _task_round(t) == rn]
|
||||
reached_keys = {
|
||||
(t.node_key or "").strip() for t in round_tasks if (t.node_key or "").strip()
|
||||
}
|
||||
for t in round_tasks:
|
||||
items.append(
|
||||
{
|
||||
"kind": "task",
|
||||
"roundNo": rn,
|
||||
"title": t.node_name or t.node_key or "-",
|
||||
"status": t.status,
|
||||
"assigneeUserId": t.assignee_user_id,
|
||||
"assigneeUserName": name_map.get(t.assignee_user_id or "", ""),
|
||||
"timestamp": _fmt_dt(t.update_time or t.create_time),
|
||||
"commentText": t.comment_text or "",
|
||||
"nodeKey": t.node_key or "",
|
||||
"taskType": t.task_type or "",
|
||||
"resolveSource": getattr(t, "resolve_source", None) or "",
|
||||
"taskId": t.id,
|
||||
}
|
||||
)
|
||||
ret = returns_by_round.get(rn)
|
||||
if ret:
|
||||
actor = getattr(ret, "actor_user_id", None) or ""
|
||||
items.append(
|
||||
{
|
||||
"kind": "return",
|
||||
"roundNo": rn,
|
||||
"title": "打回",
|
||||
"status": "returned",
|
||||
"assigneeUserId": actor,
|
||||
"assigneeUserName": name_map.get(actor, ""),
|
||||
"timestamp": _fmt_dt(getattr(ret, "create_time", None)),
|
||||
"commentText": getattr(ret, "comment_text", None) or "",
|
||||
"nodeKey": getattr(ret, "node_key", None) or "",
|
||||
"taskType": "approval",
|
||||
"taskId": getattr(ret, "task_id", None) or "",
|
||||
}
|
||||
)
|
||||
if inst.status == "running" and rn == current_round and isinstance(flow_root, dict):
|
||||
_append_pending_nodes(
|
||||
items, flow_root=flow_root, reached_keys=reached_keys, round_no=rn
|
||||
)
|
||||
|
||||
if inst.status == "completed":
|
||||
items.append(_terminal_item("done", "流程结束", inst))
|
||||
elif inst.status == "rejected":
|
||||
@@ -213,6 +292,41 @@ def build_progress_items(
|
||||
return items
|
||||
|
||||
|
||||
def _add_instance_event(
|
||||
db: Session,
|
||||
*,
|
||||
inst: OaInstance,
|
||||
event_type: str,
|
||||
actor_user_id: Optional[str],
|
||||
round_no: int,
|
||||
comment: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
node_key: Optional[str] = None,
|
||||
) -> OaInstanceEvent:
|
||||
now = datetime.now()
|
||||
ev = OaInstanceEvent(
|
||||
id=generate_uuid(),
|
||||
instance_id=inst.id,
|
||||
round_no=round_no,
|
||||
event_type=event_type,
|
||||
actor_user_id=actor_user_id,
|
||||
task_id=task_id,
|
||||
node_key=node_key,
|
||||
comment_text=(comment or "").strip() or None,
|
||||
tenant_no=inst.tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=actor_user_id,
|
||||
create_dept_id=inst.initiator_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=actor_user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(ev)
|
||||
return ev
|
||||
|
||||
|
||||
def _advanced(ver: OaAppVersion) -> Dict[str, Any]:
|
||||
data = _loads(ver.advanced_settings_json, {}) or {}
|
||||
if not isinstance(data, dict):
|
||||
@@ -242,6 +356,8 @@ def _instance_to_dict(inst: OaInstance, *, app_name: str = "") -> Dict[str, Any]
|
||||
"initiatorDeptId": inst.initiator_dept_id or "",
|
||||
"formDataJson": _loads(inst.form_data_json, {}),
|
||||
"status": inst.status,
|
||||
"sourceInstanceId": getattr(inst, "source_instance_id", None) or "",
|
||||
"currentRound": int(getattr(inst, "current_round", None) or 1),
|
||||
"createTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
|
||||
"updateTime": inst.update_time.strftime("%Y-%m-%d %H:%M:%S") if inst.update_time else "",
|
||||
}
|
||||
@@ -615,6 +731,7 @@ def _create_tasks_for_node(
|
||||
comment_text=None,
|
||||
resolve_source=resolve_source,
|
||||
sign_mode=sign_mode if task_type == "approval" else None,
|
||||
round_no=int(getattr(inst, "current_round", None) or 1),
|
||||
tenant_no=inst.tenant_no,
|
||||
order_num=seq,
|
||||
remark=None,
|
||||
@@ -753,6 +870,7 @@ def start_instance(
|
||||
title: Optional[str] = None,
|
||||
initiator_select_user_ids: Optional[List[str]] = None,
|
||||
related_instance_ids: Optional[List[str]] = None,
|
||||
source_instance_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
app = oa_crud.get_app(db, app_id, tenant_no)
|
||||
if not app or app.status != "1":
|
||||
@@ -762,6 +880,14 @@ def start_instance(
|
||||
if not ver:
|
||||
raise OaEngineError("应用尚未发布,无法发起")
|
||||
|
||||
src_id = (source_instance_id or "").strip() or None
|
||||
if src_id:
|
||||
src = _get_instance(db, src_id, tenant_no)
|
||||
if src.initiator_user_id != initiator_user_id:
|
||||
raise OaEngineError("只能从本人撤销的审批重提")
|
||||
if src.status != "cancelled":
|
||||
raise OaEngineError("仅已撤销的审批可关联重提")
|
||||
|
||||
schema = _loads(ver.form_schema_json, {})
|
||||
data = dict(form_data or {})
|
||||
try:
|
||||
@@ -777,15 +903,20 @@ def start_instance(
|
||||
dept_id = (initiator_dept_id or (user.dept_id if user else "") or "").strip() or None
|
||||
|
||||
now = datetime.now()
|
||||
title_text = (title or app.app_name or "").strip()
|
||||
if src_id and title_text and not title_text.startswith("重提-"):
|
||||
title_text = f"重提-{title_text}"
|
||||
inst = OaInstance(
|
||||
id=generate_uuid(),
|
||||
app_id=app_id,
|
||||
app_version_id=ver.id,
|
||||
title=(title or app.app_name or "").strip()[:200],
|
||||
title=title_text[:200],
|
||||
initiator_user_id=initiator_user_id,
|
||||
initiator_dept_id=dept_id,
|
||||
form_data_json=_dumps(data),
|
||||
status="running",
|
||||
source_instance_id=src_id,
|
||||
current_round=1,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
@@ -820,6 +951,13 @@ def start_instance(
|
||||
)
|
||||
)
|
||||
|
||||
_add_instance_event(
|
||||
db,
|
||||
inst=inst,
|
||||
event_type="submit",
|
||||
actor_user_id=initiator_user_id,
|
||||
round_no=1,
|
||||
)
|
||||
advance_from(
|
||||
db,
|
||||
inst=inst,
|
||||
@@ -830,7 +968,7 @@ def start_instance(
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(inst)
|
||||
return _instance_to_dict(inst, app_name=app.app_name)
|
||||
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
|
||||
|
||||
|
||||
def _get_instance(db: Session, instance_id: str, tenant_no: str) -> OaInstance:
|
||||
@@ -882,15 +1020,37 @@ def get_instance_detail(db: Session, instance_id: str, *, tenant_no: str) -> Dic
|
||||
]
|
||||
data["relatedInstanceIds"] = [r.to_instance_id for r in rels]
|
||||
data["formSchemaJson"] = _loads(ver.form_schema_json, {}) if ver else {}
|
||||
data["advancedSettingsJson"] = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
|
||||
data["allowAddSign"] = bool((_advanced(ver) if ver else {}).get("allowAddSign", True))
|
||||
adv = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
|
||||
data["advancedSettingsJson"] = adv
|
||||
data["allowAddSign"] = bool(adv.get("allowAddSign", True))
|
||||
data["allowReturn"] = bool(adv.get("allowReturn", True))
|
||||
events = (
|
||||
db.query(OaInstanceEvent)
|
||||
.filter(OaInstanceEvent.instance_id == instance_id, OaInstanceEvent.del_flag == 0)
|
||||
.order_by(OaInstanceEvent.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
event_actors = [e.actor_user_id for e in events if e.actor_user_id]
|
||||
if event_actors:
|
||||
name_map.update(_user_name_map(db, event_actors))
|
||||
data["progress"] = build_progress_items(
|
||||
inst=inst,
|
||||
flow_root=flow_root,
|
||||
tasks=tasks,
|
||||
name_map=name_map,
|
||||
events=[],
|
||||
events=events,
|
||||
)
|
||||
resub = (
|
||||
db.query(OaInstance)
|
||||
.filter(
|
||||
OaInstance.source_instance_id == instance_id,
|
||||
OaInstance.tenant_no == tenant_no,
|
||||
OaInstance.del_flag == 0,
|
||||
)
|
||||
.order_by(OaInstance.create_time.desc())
|
||||
.first()
|
||||
)
|
||||
data["resubmittedInstanceId"] = resub.id if resub else ""
|
||||
return data
|
||||
|
||||
|
||||
@@ -1155,6 +1315,7 @@ def add_sign(
|
||||
comment_text=None,
|
||||
resolve_source="add_sign",
|
||||
sign_mode=task.sign_mode or "or",
|
||||
round_no=int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1),
|
||||
tenant_no=tenant_no,
|
||||
order_num=next_order,
|
||||
remark=(comment or "").strip() or None,
|
||||
@@ -1180,6 +1341,144 @@ def add_sign(
|
||||
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
|
||||
|
||||
|
||||
def return_task(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
comment: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""审批人打回:退回发起人修改,同实例不结束。"""
|
||||
task = (
|
||||
db.query(OaTask)
|
||||
.filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if not task:
|
||||
raise OaEngineError("任务不存在")
|
||||
if task.assignee_user_id != user_id:
|
||||
raise OaEngineError("无权打回该任务")
|
||||
if task.task_type != "approval" or task.status != "pending":
|
||||
raise OaEngineError("仅待处理的审批任务可打回")
|
||||
|
||||
inst = _get_instance(db, task.instance_id, tenant_no)
|
||||
if inst.status != "running":
|
||||
raise OaEngineError("仅进行中的审批可打回")
|
||||
|
||||
ver = (
|
||||
db.query(OaAppVersion)
|
||||
.filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if not ver:
|
||||
raise OaEngineError("流程版本不存在")
|
||||
advanced = _advanced(ver)
|
||||
if not advanced.get("allowReturn", True):
|
||||
raise OaEngineError("该应用不允许打回")
|
||||
|
||||
comment_text = (comment or "").strip()
|
||||
if not comment_text:
|
||||
raise OaEngineError("打回意见必填")
|
||||
|
||||
now = datetime.now()
|
||||
round_no = int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1)
|
||||
task.status = "returned"
|
||||
task.comment_text = comment_text
|
||||
task.update_by = user_id
|
||||
task.update_time = now
|
||||
db.add(task)
|
||||
|
||||
for t in (
|
||||
db.query(OaTask)
|
||||
.filter(
|
||||
OaTask.instance_id == inst.id,
|
||||
OaTask.status == "pending",
|
||||
OaTask.del_flag == 0,
|
||||
OaTask.id != task.id,
|
||||
)
|
||||
.all()
|
||||
):
|
||||
t.status = "cancelled"
|
||||
t.update_by = user_id
|
||||
t.update_time = now
|
||||
db.add(t)
|
||||
|
||||
inst.status = "returned"
|
||||
inst.update_by = user_id
|
||||
inst.update_time = now
|
||||
db.add(inst)
|
||||
_add_instance_event(
|
||||
db,
|
||||
inst=inst,
|
||||
event_type="return",
|
||||
actor_user_id=user_id,
|
||||
round_no=round_no,
|
||||
comment=comment_text,
|
||||
task_id=task.id,
|
||||
node_key=task.node_key,
|
||||
)
|
||||
db.commit()
|
||||
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
|
||||
|
||||
|
||||
def resubmit_instance(
|
||||
db: Session,
|
||||
instance_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
form_data: Optional[Dict[str, Any]] = None,
|
||||
initiator_select_user_ids: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""打回后发起人修改表单并再次提交(同实例新一轮)。"""
|
||||
inst = _get_instance(db, instance_id, tenant_no)
|
||||
if inst.initiator_user_id != user_id:
|
||||
raise OaEngineError("仅发起人可再次提交")
|
||||
if inst.status != "returned":
|
||||
raise OaEngineError("仅打回待修改的审批可再次提交")
|
||||
|
||||
ver = (
|
||||
db.query(OaAppVersion)
|
||||
.filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if not ver:
|
||||
raise OaEngineError("流程版本不存在")
|
||||
|
||||
schema = _loads(ver.form_schema_json, {})
|
||||
data = dict(form_data if form_data is not None else (_loads(inst.form_data_json, {}) or {}))
|
||||
try:
|
||||
data = apply_formulas_and_linkages(schema, data)
|
||||
except FormulaError as e:
|
||||
raise OaEngineError(f"公式计算失败: {e}") from e
|
||||
|
||||
now = datetime.now()
|
||||
inst.form_data_json = _dumps(data)
|
||||
inst.current_round = int(getattr(inst, "current_round", None) or 1) + 1
|
||||
inst.status = "running"
|
||||
inst.update_by = user_id
|
||||
inst.update_time = now
|
||||
db.add(inst)
|
||||
_add_instance_event(
|
||||
db,
|
||||
inst=inst,
|
||||
event_type="submit",
|
||||
actor_user_id=user_id,
|
||||
round_no=inst.current_round,
|
||||
)
|
||||
advance_from(
|
||||
db,
|
||||
inst=inst,
|
||||
ver=ver,
|
||||
from_node=None,
|
||||
user_id=user_id,
|
||||
initiator_select_user_ids=initiator_select_user_ids,
|
||||
)
|
||||
db.commit()
|
||||
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
|
||||
|
||||
|
||||
def withdraw_instance(
|
||||
db: Session, instance_id: str, *, tenant_no: str, user_id: str
|
||||
) -> Dict[str, Any]:
|
||||
@@ -1207,9 +1506,16 @@ def withdraw_instance(
|
||||
.filter(OaTask.instance_id == inst.id, OaTask.status == "pending", OaTask.del_flag == 0)
|
||||
.all()
|
||||
):
|
||||
t.status = "done"
|
||||
t.status = "cancelled"
|
||||
t.update_by = user_id
|
||||
t.update_time = now
|
||||
db.add(t)
|
||||
_add_instance_event(
|
||||
db,
|
||||
inst=inst,
|
||||
event_type="withdraw",
|
||||
actor_user_id=user_id,
|
||||
round_no=int(getattr(inst, "current_round", None) or 1),
|
||||
)
|
||||
db.commit()
|
||||
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
|
||||
@@ -243,13 +243,14 @@ class TestCompleteTaskOrSign(unittest.TestCase):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.database import Base
|
||||
from app.models.models import OaAppVersion, OaInstance, OaTask
|
||||
from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask
|
||||
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
# 仅建引擎相关表
|
||||
OaAppVersion.__table__.create(self.engine)
|
||||
OaInstance.__table__.create(self.engine)
|
||||
OaTask.__table__.create(self.engine)
|
||||
OaInstanceEvent.__table__.create(self.engine)
|
||||
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
|
||||
self.db = Session()
|
||||
self.Base = Base
|
||||
@@ -429,6 +430,7 @@ class TestBuildProgress(unittest.TestCase):
|
||||
status="pending",
|
||||
assignee_user_id="u2",
|
||||
order_num=0,
|
||||
round_no=1,
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
comment_text=None,
|
||||
@@ -442,6 +444,7 @@ class TestBuildProgress(unittest.TestCase):
|
||||
status="done",
|
||||
assignee_user_id="u9",
|
||||
order_num=0,
|
||||
round_no=1,
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
comment_text=None,
|
||||
@@ -450,6 +453,7 @@ class TestBuildProgress(unittest.TestCase):
|
||||
inst = MagicMock(
|
||||
status="running",
|
||||
initiator_user_id="u0",
|
||||
current_round=1,
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
)
|
||||
@@ -498,6 +502,7 @@ class TestBuildProgress(unittest.TestCase):
|
||||
status="rejected",
|
||||
assignee_user_id="u2",
|
||||
order_num=0,
|
||||
round_no=1,
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
comment_text="不同意",
|
||||
@@ -507,6 +512,7 @@ class TestBuildProgress(unittest.TestCase):
|
||||
inst = MagicMock(
|
||||
status=status,
|
||||
initiator_user_id="u0",
|
||||
current_round=1,
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
)
|
||||
@@ -523,6 +529,156 @@ class TestBuildProgress(unittest.TestCase):
|
||||
self.assertEqual(len(terminal), 1, f"status={status} should have one terminal")
|
||||
|
||||
|
||||
class TestReturnAndResubmit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask
|
||||
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
OaAppVersion.__table__.create(self.engine)
|
||||
OaInstance.__table__.create(self.engine)
|
||||
OaTask.__table__.create(self.engine)
|
||||
OaInstanceEvent.__table__.create(self.engine)
|
||||
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
|
||||
self.db = Session()
|
||||
self.OaInstance = OaInstance
|
||||
self.OaTask = OaTask
|
||||
self.OaInstanceEvent = OaInstanceEvent
|
||||
|
||||
flow = {
|
||||
"id": "s",
|
||||
"type": "start",
|
||||
"child": {
|
||||
"id": "a1",
|
||||
"type": "approval",
|
||||
"name": "审批人",
|
||||
"approverConfig": {
|
||||
"assigneeType": "users",
|
||||
"userIds": ["u1"],
|
||||
"signMode": "or",
|
||||
},
|
||||
"child": {"id": "e1", "type": "end"},
|
||||
},
|
||||
}
|
||||
now = datetime.now()
|
||||
self.db.add(
|
||||
OaAppVersion(
|
||||
id="v1",
|
||||
app_id="app1",
|
||||
version_no=1,
|
||||
status="published",
|
||||
is_latest_published="1",
|
||||
form_schema_json="{}",
|
||||
flow_tree_json=json.dumps(flow),
|
||||
advanced_settings_json=json.dumps({"allowReturn": True}),
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
OaInstance(
|
||||
id="i1",
|
||||
app_id="app1",
|
||||
app_version_id="v1",
|
||||
title="打回测",
|
||||
initiator_user_id="u0",
|
||||
initiator_dept_id="d1",
|
||||
form_data_json="{}",
|
||||
status="running",
|
||||
current_round=1,
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
OaTask(
|
||||
id="t1",
|
||||
instance_id="i1",
|
||||
node_key="a1",
|
||||
node_name="审批人",
|
||||
task_type="approval",
|
||||
assignee_user_id="u1",
|
||||
status="pending",
|
||||
sign_mode="or",
|
||||
round_no=1,
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_return_sets_returned_and_event(self):
|
||||
from app.services.oa_engine import return_task
|
||||
|
||||
with patch(
|
||||
"app.services.oa_engine.get_instance_detail",
|
||||
return_value={"id": "i1", "status": "returned"},
|
||||
):
|
||||
return_task(
|
||||
self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
|
||||
)
|
||||
inst = self.db.query(self.OaInstance).filter_by(id="i1").first()
|
||||
task = self.db.query(self.OaTask).filter_by(id="t1").first()
|
||||
ev = (
|
||||
self.db.query(self.OaInstanceEvent)
|
||||
.filter_by(instance_id="i1", event_type="return")
|
||||
.first()
|
||||
)
|
||||
self.assertEqual(inst.status, "returned")
|
||||
self.assertEqual(task.status, "returned")
|
||||
self.assertIsNotNone(ev)
|
||||
self.assertEqual(ev.comment_text, "请补充材料")
|
||||
|
||||
def test_resubmit_starts_new_round(self):
|
||||
from app.services.oa_engine import resubmit_instance, return_task
|
||||
|
||||
with patch(
|
||||
"app.services.oa_engine.get_instance_detail",
|
||||
return_value={"id": "i1", "status": "returned"},
|
||||
):
|
||||
return_task(
|
||||
self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
|
||||
)
|
||||
|
||||
fake_task = MagicMock()
|
||||
with patch(
|
||||
"app.services.oa_engine._create_tasks_for_node", return_value=[fake_task]
|
||||
), patch(
|
||||
"app.services.oa_engine.get_instance_detail",
|
||||
return_value={"id": "i1", "status": "running"},
|
||||
):
|
||||
resubmit_instance(
|
||||
self.db,
|
||||
"i1",
|
||||
tenant_no="zkxw",
|
||||
user_id="u0",
|
||||
form_data={"x": 1},
|
||||
)
|
||||
inst = self.db.query(self.OaInstance).filter_by(id="i1").first()
|
||||
self.assertEqual(inst.status, "running")
|
||||
self.assertEqual(inst.current_round, 2)
|
||||
submit_ev = (
|
||||
self.db.query(self.OaInstanceEvent)
|
||||
.filter_by(instance_id="i1", event_type="submit", round_no=2)
|
||||
.first()
|
||||
)
|
||||
self.assertIsNotNone(submit_ev)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- OA 审批进度多轮 + 打回 + 撤销重提关联
|
||||
-- MySQL 5.7 / utf8mb4
|
||||
|
||||
ALTER TABLE oa_instance
|
||||
ADD COLUMN source_instance_id VARCHAR(64) NULL COMMENT '撤销重提所关联的原实例ID' AFTER status,
|
||||
ADD COLUMN current_round INT NOT NULL DEFAULT 1 COMMENT '当前提交轮次,从1起' AFTER source_instance_id;
|
||||
|
||||
-- status 语义:running / returned / completed / rejected / cancelled
|
||||
-- returned = 已打回,待发起人修改再提交
|
||||
|
||||
ALTER TABLE oa_task
|
||||
ADD COLUMN round_no INT NOT NULL DEFAULT 1 COMMENT '所属提交轮次' AFTER sign_mode;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oa_instance_event (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键ID',
|
||||
instance_id VARCHAR(64) NOT NULL COMMENT '实例ID',
|
||||
round_no INT NOT NULL DEFAULT 1 COMMENT '轮次',
|
||||
event_type VARCHAR(32) NOT NULL COMMENT 'submit/return/withdraw/complete/reject_end',
|
||||
actor_user_id VARCHAR(64) NULL COMMENT '操作人',
|
||||
task_id VARCHAR(64) NULL COMMENT '关联任务(打回时)',
|
||||
node_key VARCHAR(64) NULL COMMENT '节点key',
|
||||
comment_text VARCHAR(512) NULL COMMENT '意见',
|
||||
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
order_num INT NULL COMMENT '排序号',
|
||||
remark VARCHAR(512) NULL COMMENT '备注',
|
||||
create_by VARCHAR(64) NULL COMMENT '创建人id',
|
||||
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
update_by VARCHAR(64) NULL COMMENT '更新人id',
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间',
|
||||
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_oa_evt_inst (instance_id, del_flag, create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA实例操作事件(进度轴)';
|
||||
@@ -0,0 +1,759 @@
|
||||
# OA 审批进度与打回/撤销重提 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:** 按钉钉语义落地审批进度展示,并分阶段实现:P0 稳单轮进度轴;P1 打回同实例再提交 + 撤销关联新单。
|
||||
|
||||
**Architecture:** 详情进度由后端投影 `progress`(发起/任务/未到达/终态/轮次),任务仍落 `oa_task`;P1 新增 `oa_instance_event` 记录提交/打回/撤销等非任务事件,并给实例增加 `returned` 状态与 `source_instance_id`。轮内按流程树节点序排序(已有 `_sort_tasks_for_detail`),轮间按 `round_no`。
|
||||
|
||||
**Tech Stack:** FastAPI · SQLAlchemy · MySQL 5.7 · Vue 3 · Element Plus · Python `unittest`
|
||||
|
||||
**Design spec:** `docs/superpowers/specs/2026-07-15-oa-approval-progress-timeline-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Open questions 收口(本计划采纳)
|
||||
|
||||
| # | 决议 |
|
||||
|---|------|
|
||||
| 1 | 打回后实例状态 = **`returned`**(待发起人修改再提交);列表「我发起的」需能筛出该状态 |
|
||||
| 2 | P1 打回目标 **仅退回发起人(start)**;不支持退回到中间审批节点 |
|
||||
| 3 | 使用独立表 **`oa_instance_event`**(对齐钉钉 `operation_records` 思路),不用纯 task 投影硬扛多轮 |
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `docs/sql/patch_2026-07-15_oa_progress_return.sql` | **P1 新建** `oa_instance_event`;`oa_instance` 增 `source_instance_id`、`current_round`;注释扩展 status |
|
||||
| `backend/app/models/models.py` | `OaInstance` 新字段;**新建** `OaInstanceEvent` |
|
||||
| `backend/app/services/oa_engine.py` | `build_progress`;`return_task`;`resubmit_instance`;start/withdraw 写事件;详情带 `progress` |
|
||||
| `backend/app/crud/oa_approval.py` | `DEFAULT_ADVANCED_SETTINGS.allowReturn` |
|
||||
| `backend/app/api/oa_approval.py` | `POST .../return`、`POST .../resubmit`;start 接受 `sourceInstanceId` |
|
||||
| `backend/tests/test_oa_engine.py` | 排序/progress/打回/再提交/关联单测 |
|
||||
| `backend/tests/test_oa_progress.py` | **新建** progress 投影单测(可并入 engine 测) |
|
||||
| `web-admin/src/api/oaApproval.ts` | return / resubmit / start 扩展 |
|
||||
| `web-admin/src/views/oa-approval/instances/detail.vue` | 消费 `progress`;打回/再提交按钮;轮次 UI |
|
||||
| `web-admin/src/views/oa-approval/start/index.vue` | 撤销重提带 `sourceInstanceId`;`returned` 再提交入口可进编辑 |
|
||||
| `web-admin/src/views/oa-approval/designer/index.vue` | 高级设置「允许打回」开关 |
|
||||
| `web-admin/src/views/oa-approval/mine/index.vue` 或 `RuntimeList.vue` | 状态文案含 `returned` |
|
||||
|
||||
---
|
||||
|
||||
## 依赖顺序
|
||||
|
||||
```text
|
||||
Task 1 P0 progress 投影 API + 单测
|
||||
→ Task 2 P0 详情页改吃 progress + 未到达 + 文案
|
||||
→ Task 3 P1 schema / model / allowReturn
|
||||
→ Task 4 P1 return_task 引擎 + API
|
||||
→ Task 5 P1 resubmit_instance + 事件轮次
|
||||
→ Task 6 P1 撤销重提关联(source_instance_id)
|
||||
→ Task 7 P1 前端打回 / 再提交 / 多轮轴 / 关联提示
|
||||
→ Task 8 验收清单走查 + 图谱更新
|
||||
```
|
||||
|
||||
**说明:** 会话中已落地的 `_flow_node_key_order` / `_sort_tasks_for_detail` / 详情简易 `timelineItems` 视为 Task 1–2 的起点,需并入正式 `progress` 契约,避免前后端各拼一套。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: P0 — 后端 `progress` 投影
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/oa_engine.py`
|
||||
- Modify: `backend/tests/test_oa_engine.py`
|
||||
|
||||
- [ ] **Step 1: 增加 failing 单测(progress 含发起 + 抄送先于审批 + 未到达)**
|
||||
|
||||
在 `backend/tests/test_oa_engine.py` 增加:
|
||||
|
||||
```python
|
||||
class TestBuildProgress(unittest.TestCase):
|
||||
def test_cc_before_approval_and_pending_ahead(self):
|
||||
from app.services.oa_engine import build_progress_items
|
||||
|
||||
root = {
|
||||
"id": "s",
|
||||
"type": "start",
|
||||
"child": {
|
||||
"id": "c1",
|
||||
"type": "cc",
|
||||
"name": "抄送人",
|
||||
"child": {
|
||||
"id": "a1",
|
||||
"type": "approval",
|
||||
"name": "审批人",
|
||||
"child": {
|
||||
"id": "a2",
|
||||
"type": "approval",
|
||||
"name": "总经理",
|
||||
"child": {"id": "e1", "type": "end"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ts = datetime(2026, 7, 15, 14, 5, 7)
|
||||
# 审批任务 create 同秒但列表故意审批在前
|
||||
t_approval = MagicMock(
|
||||
id="tA",
|
||||
node_key="a1",
|
||||
node_name="审批人",
|
||||
task_type="approval",
|
||||
status="pending",
|
||||
assignee_user_id="u2",
|
||||
order_num=0,
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
comment_text=None,
|
||||
resolve_source="users",
|
||||
)
|
||||
t_cc = MagicMock(
|
||||
id="tC",
|
||||
node_key="c1",
|
||||
node_name="抄送人",
|
||||
task_type="cc",
|
||||
status="done",
|
||||
assignee_user_id="u9",
|
||||
order_num=0,
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
comment_text=None,
|
||||
resolve_source="users",
|
||||
)
|
||||
inst = MagicMock(
|
||||
status="running",
|
||||
initiator_user_id="u0",
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
)
|
||||
items = build_progress_items(
|
||||
inst=inst,
|
||||
flow_root=root,
|
||||
tasks=[t_approval, t_cc],
|
||||
name_map={"u0": "发起人甲", "u2": "审批乙", "u9": "抄送丙"},
|
||||
events=[], # P0 可空
|
||||
)
|
||||
kinds = [x["kind"] for x in items]
|
||||
self.assertEqual(kinds[0], "submit")
|
||||
# 已产生:cc 再 approval
|
||||
self.assertEqual(
|
||||
[x["nodeKey"] for x in items if x["kind"] == "task"],
|
||||
["c1", "a1"],
|
||||
)
|
||||
# running 时未到达 a2
|
||||
pending = [x for x in items if x["kind"] == "pending_node"]
|
||||
self.assertTrue(any(p["nodeKey"] == "a2" for p in pending))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine.TestBuildProgress -v
|
||||
```
|
||||
|
||||
Expected: `ImportError` 或 `AttributeError: build_progress_items`
|
||||
|
||||
- [ ] **Step 3: 实现 `build_progress_items` + 详情挂载**
|
||||
|
||||
在 `oa_engine.py` 增加(要点):
|
||||
|
||||
```python
|
||||
def build_progress_items(
|
||||
*,
|
||||
inst: Any,
|
||||
flow_root: Optional[Dict[str, Any]],
|
||||
tasks: List[Any],
|
||||
name_map: Dict[str, str],
|
||||
events: Optional[List[Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""投影审批进度。P0:submit + 按树序 tasks + pending_node + terminal。
|
||||
P1:插入 return/resubmit 事件并带 roundNo。"""
|
||||
items: List[Dict[str, Any]] = []
|
||||
round_no = 1
|
||||
# 1) 首条提交(无 events 时用实例创建)
|
||||
items.append({
|
||||
"kind": "submit",
|
||||
"roundNo": round_no,
|
||||
"title": "发起人",
|
||||
"status": "submitted",
|
||||
"assigneeUserId": inst.initiator_user_id,
|
||||
"assigneeUserName": name_map.get(inst.initiator_user_id or "", ""),
|
||||
"timestamp": _fmt_dt(getattr(inst, "create_time", None)),
|
||||
"commentText": "",
|
||||
"nodeKey": "",
|
||||
"taskType": "",
|
||||
})
|
||||
sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root)
|
||||
reached_keys = {(t.node_key or "").strip() for t in sorted_tasks if (t.node_key or "").strip()}
|
||||
for t in sorted_tasks:
|
||||
items.append({
|
||||
"kind": "task",
|
||||
"roundNo": round_no,
|
||||
"title": t.node_name or t.node_key or "-",
|
||||
"status": t.status,
|
||||
"assigneeUserId": t.assignee_user_id,
|
||||
"assigneeUserName": name_map.get(t.assignee_user_id or "", ""),
|
||||
"timestamp": _fmt_dt(t.update_time or t.create_time),
|
||||
"commentText": t.comment_text or "",
|
||||
"nodeKey": t.node_key or "",
|
||||
"taskType": t.task_type or "",
|
||||
"resolveSource": getattr(t, "resolve_source", None) or "",
|
||||
"taskId": t.id,
|
||||
})
|
||||
# 2) 未到达:running 时,流程树中 approval/cc 且不在 reached_keys
|
||||
if inst.status == "running" and isinstance(flow_root, dict):
|
||||
order = _flow_node_key_order(flow_root)
|
||||
for nid, _idx in sorted(order.items(), key=lambda x: x[1]):
|
||||
node = _find_node(flow_root, nid)
|
||||
if not node:
|
||||
continue
|
||||
nt = _node_type(node)
|
||||
if nt not in ("approval", "cc") or nid in reached_keys:
|
||||
continue
|
||||
items.append({
|
||||
"kind": "pending_node",
|
||||
"roundNo": round_no,
|
||||
"title": node.get("name") or ("抄送人" if nt == "cc" else "审批人"),
|
||||
"status": "not_reached",
|
||||
"assigneeUserId": "",
|
||||
"assigneeUserName": "",
|
||||
"timestamp": "",
|
||||
"commentText": "",
|
||||
"nodeKey": nid,
|
||||
"taskType": nt,
|
||||
})
|
||||
# 3) 终态
|
||||
if inst.status == "completed":
|
||||
items.append(_terminal_item("done", "流程结束", inst))
|
||||
elif inst.status == "rejected":
|
||||
items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst))
|
||||
elif inst.status == "cancelled":
|
||||
items.append(_terminal_item("cancelled", "已撤销", inst))
|
||||
return items
|
||||
|
||||
|
||||
def _fmt_dt(dt: Any) -> str:
|
||||
if not dt:
|
||||
return ""
|
||||
if hasattr(dt, "strftime"):
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(dt)
|
||||
|
||||
|
||||
def _terminal_item(status: str, title: str, inst: Any) -> Dict[str, Any]:
|
||||
return {
|
||||
"kind": "terminal",
|
||||
"roundNo": getattr(inst, "current_round", None) or 1,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"assigneeUserId": "",
|
||||
"assigneeUserName": "",
|
||||
"timestamp": _fmt_dt(getattr(inst, "update_time", None)),
|
||||
"commentText": "",
|
||||
"nodeKey": "",
|
||||
"taskType": "",
|
||||
}
|
||||
```
|
||||
|
||||
在 `get_instance_detail` 中:
|
||||
|
||||
```python
|
||||
data["progress"] = build_progress_items(
|
||||
inst=inst,
|
||||
flow_root=flow_root,
|
||||
tasks=tasks,
|
||||
name_map=name_map,
|
||||
events=[], # Task 3+ 再查 oa_instance_event
|
||||
)
|
||||
# 保留 data["tasks"] 供审批按钮找 pending;UI 进度优先 progress
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测通过**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine.TestBuildProgress tests.test_oa_engine.TestOaEngineHelpers -v
|
||||
```
|
||||
|
||||
Expected: OK
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/oa_engine.py backend/tests/test_oa_engine.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): project approval progress for instance detail
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: P0 — 详情页消费 `progress`
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/oa-approval/instances/detail.vue`
|
||||
- Modify: `web-admin/src/api/oaApproval.ts`(可选:为 progress 补类型注释)
|
||||
|
||||
- [ ] **Step 1: 用 `detail.progress` 替换本地 `timelineItems` 拼装**
|
||||
|
||||
`timelineItems` 改为:
|
||||
|
||||
```typescript
|
||||
const timelineItems = computed(() => {
|
||||
const list = detail.value?.progress
|
||||
if (Array.isArray(list) && list.length) {
|
||||
return list.map((p: any, idx: number) => ({
|
||||
key: `${p.kind}-${p.taskId || p.nodeKey || idx}-${p.roundNo || 1}`,
|
||||
title: p.title || '-',
|
||||
status: p.status || 'info',
|
||||
timestamp: p.timestamp || '',
|
||||
assignee: p.assigneeUserName || p.assigneeUserId || '',
|
||||
resolveSource: p.resolveSource || '',
|
||||
commentText: p.commentText || '',
|
||||
taskType: p.taskType || '',
|
||||
kind: p.kind || '',
|
||||
roundNo: p.roundNo || 1
|
||||
}))
|
||||
}
|
||||
return [] // 无 progress 时不要静默用错序 tasks
|
||||
})
|
||||
```
|
||||
|
||||
文案映射补充:
|
||||
|
||||
```typescript
|
||||
not_reached: '未到达',
|
||||
submitted: '已提交',
|
||||
```
|
||||
|
||||
模板中:若 `item.kind === 'pending_node'`,不显示「处理人」行;`item.kind === 'terminal'` 可不显示处理人。
|
||||
|
||||
按钮文案确认:拒绝按钮文案为 **「拒绝」**,禁止写成「打回」。
|
||||
|
||||
- [ ] **Step 2: 手工验收**
|
||||
|
||||
1. 打开已有实例 `F6E46BC0CB2C4A12A4B324A9735ED1E0`(或本地等价:设计抄送→审批)。
|
||||
2. 进度应为:发起人 → 抄送人 → 审批人(待处理)→(若有后续)未到达。
|
||||
3. 拒绝后进度末尾为「流程结束(已拒绝)」且无「未到达」。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/api/oaApproval.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): render instance detail progress from API projection
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: P1 — Schema / Model / 高级设置
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/patch_2026-07-15_oa_progress_return.sql`
|
||||
- Modify: `backend/app/models/models.py`
|
||||
- Modify: `backend/app/crud/oa_approval.py`
|
||||
|
||||
- [ ] **Step 1: 编写 SQL patch**
|
||||
|
||||
```sql
|
||||
-- docs/sql/patch_2026-07-15_oa_progress_return.sql
|
||||
-- OA 审批进度多轮 + 打回 + 撤销重提关联
|
||||
|
||||
ALTER TABLE oa_instance
|
||||
ADD COLUMN source_instance_id VARCHAR(64) NULL COMMENT '撤销重提所关联的原实例ID' AFTER status,
|
||||
ADD COLUMN current_round INT NOT NULL DEFAULT 1 COMMENT '当前提交轮次,从1起' AFTER source_instance_id;
|
||||
|
||||
-- status 语义扩展(无需改列类型):running/returned/completed/rejected/cancelled
|
||||
-- returned = 已打回,待发起人修改再提交
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oa_instance_event (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键ID',
|
||||
instance_id VARCHAR(64) NOT NULL COMMENT '实例ID',
|
||||
round_no INT NOT NULL DEFAULT 1 COMMENT '轮次',
|
||||
event_type VARCHAR(32) NOT NULL COMMENT 'submit/return/withdraw/complete/reject_end',
|
||||
actor_user_id VARCHAR(64) NULL COMMENT '操作人',
|
||||
task_id VARCHAR(64) NULL COMMENT '关联任务(打回时)',
|
||||
node_key VARCHAR(64) NULL COMMENT '节点key',
|
||||
comment_text VARCHAR(512) NULL COMMENT '意见',
|
||||
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
order_num INT NULL COMMENT '排序号',
|
||||
remark VARCHAR(512) NULL COMMENT '备注',
|
||||
create_by VARCHAR(64) NULL COMMENT '创建人id',
|
||||
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
update_by VARCHAR(64) NULL COMMENT '更新人id',
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间',
|
||||
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_oa_evt_inst (instance_id, del_flag, create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA实例操作事件(进度轴)';
|
||||
```
|
||||
|
||||
在目标库执行该 patch(本地/测试库按环境)。
|
||||
|
||||
- [ ] **Step 2: 更新 SQLAlchemy 模型**
|
||||
|
||||
`OaInstance` 增加:
|
||||
|
||||
```python
|
||||
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例")
|
||||
current_round = Column(Integer, nullable=False, default=1, comment="当前轮次")
|
||||
```
|
||||
|
||||
新建:
|
||||
|
||||
```python
|
||||
class OaInstanceEvent(Base):
|
||||
__tablename__ = "oa_instance_event"
|
||||
id = Column(String(64), primary_key=True)
|
||||
instance_id = Column(String(64), nullable=False)
|
||||
round_no = Column(Integer, nullable=False, default=1)
|
||||
event_type = Column(String(32), nullable=False)
|
||||
actor_user_id = Column(String(64), nullable=True)
|
||||
task_id = Column(String(64), nullable=True)
|
||||
node_key = Column(String(64), nullable=True)
|
||||
comment_text = Column(String(512), nullable=True)
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw")
|
||||
order_num = Column(Integer, nullable=True)
|
||||
remark = Column(String(512), nullable=True)
|
||||
create_by = Column(String(64), nullable=True)
|
||||
create_dept_id = Column(String(64), nullable=False, default="")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now)
|
||||
update_by = Column(String(64), nullable=True)
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 高级设置默认值**
|
||||
|
||||
```python
|
||||
DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = {
|
||||
"supervisorFallbackToDeptLeader": True,
|
||||
"allowWithdrawRunning": True,
|
||||
"approverDedup": False,
|
||||
"commentRequired": False,
|
||||
"allowAddSign": True,
|
||||
"allowReturn": True, # 打回
|
||||
}
|
||||
```
|
||||
|
||||
设计器 `designer/index.vue` 增加与「允许加签」同级的 `el-switch`:`allowReturn`,文案「允许审批人打回(退回发起人修改)」。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/sql/patch_2026-07-15_oa_progress_return.sql backend/app/models/models.py backend/app/crud/oa_approval.py web-admin/src/views/oa-approval/designer/index.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): add instance events schema and allowReturn setting
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: P1 — `return_task`(打回)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/oa_engine.py`
|
||||
- Modify: `backend/app/api/oa_approval.py`
|
||||
- Modify: `backend/tests/test_oa_engine.py`
|
||||
|
||||
- [ ] **Step 1: 写 failing 单测(sqlite 内存或 MagicMock 链路)**
|
||||
|
||||
优先沿用 `TestCompleteTaskOrSign` 的 sqlite 内存风格:准备实例 `running` + pending task → `return_task` → 断言:
|
||||
|
||||
- `inst.status == "returned"`
|
||||
- 所有 `pending` 任务变为 `cancelled`(新状态;展示「已取消」)
|
||||
- 存在 `event_type == "return"` 的 `OaInstanceEvent`
|
||||
- 打回任务自身 `status == "returned"`(或 `comment` + 专用状态;**统一用任务 status=`returned`**)
|
||||
|
||||
```python
|
||||
def test_return_sets_instance_returned_and_cancels_pending(self):
|
||||
from app.services.oa_engine import return_task
|
||||
# …插入 ver/inst/task 后
|
||||
return_task(self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料")
|
||||
inst = self.db.query(self.OaInstance).filter_by(id="i1").first()
|
||||
self.assertEqual(inst.status, "returned")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 `return_task`**
|
||||
|
||||
语义:
|
||||
|
||||
1. 校验任务 assignee、`task_type=approval`、`status=pending`、实例 `running`。
|
||||
2. `advanced.allowReturn` 必须为 true。
|
||||
3. 评论:若 `commentRequired` 或打回场景 **SHALL 要求非空意见**(打回强制意见,即使全局意见非必填)。
|
||||
4. 当前任务 `status=returned`,写入 `comment_text`。
|
||||
5. 同实例其余 `pending` → `cancelled`。
|
||||
6. 实例 `status=returned`。
|
||||
7. 写入 `oa_instance_event`:`event_type=return`,`round_no=inst.current_round`。
|
||||
8. `commit` + `get_instance_detail`。
|
||||
|
||||
**注意:** `complete_task(..., action="reject")` 保持整单 `rejected`,**不得**改成打回。
|
||||
|
||||
- [ ] **Step 3: API**
|
||||
|
||||
```python
|
||||
@router.post("/tasks/{task_id}/return")
|
||||
async def return_oa_task(task_id: str, body: CommentBody, ...):
|
||||
data = return_task(db, task_id, tenant_no=..., user_id=..., comment=body.comment)
|
||||
return success_response(data, message="已打回")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测 + Commit**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine -v
|
||||
git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py backend/tests/test_oa_engine.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): add return-to-initiator action for approval tasks
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: P1 — `resubmit_instance`(同实例再提交)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/oa_engine.py`
|
||||
- Modify: `backend/app/api/oa_approval.py`
|
||||
- Modify: `backend/tests/test_oa_engine.py`
|
||||
|
||||
- [ ] **Step 1: Failing 单测**
|
||||
|
||||
- 仅 `status=returned` 且操作者为发起人可再提交。
|
||||
- 更新 `form_data_json` 后 `current_round += 1`,状态改回 `running`。
|
||||
- 写 `event_type=submit`(新轮)。
|
||||
- 调用现有 `advance_from(..., from_node=None)` 从 start 后第一跳重建任务(中间节点重审)。
|
||||
- **保留**旧轮 `oa_task` / `return` 事件,禁止物理删除历史任务。
|
||||
|
||||
- [ ] **Step 2: 实现**
|
||||
|
||||
```python
|
||||
def resubmit_instance(
|
||||
db: Session,
|
||||
instance_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
form_data: Optional[Dict[str, Any]] = None,
|
||||
initiator_select_user_ids: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
inst = _get_instance(db, instance_id, tenant_no)
|
||||
if inst.initiator_user_id != user_id:
|
||||
raise OaEngineError("仅发起人可再次提交")
|
||||
if inst.status != "returned":
|
||||
raise OaEngineError("仅打回待修改的审批可再次提交")
|
||||
ver = ...
|
||||
# 公式/校验与 start_instance 同源片段可抽私有函数
|
||||
now = datetime.now()
|
||||
if form_data is not None:
|
||||
inst.form_data_json = _dumps(form_data)
|
||||
inst.current_round = int(inst.current_round or 1) + 1
|
||||
inst.status = "running"
|
||||
inst.update_by = user_id
|
||||
inst.update_time = now
|
||||
db.add(inst)
|
||||
_add_event(db, inst, event_type="submit", round_no=inst.current_round, actor=user_id)
|
||||
advance_from(
|
||||
db,
|
||||
inst=inst,
|
||||
ver=ver,
|
||||
from_node=None,
|
||||
user_id=user_id,
|
||||
initiator_select_user_ids=initiator_select_user_ids,
|
||||
)
|
||||
db.commit()
|
||||
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
|
||||
```
|
||||
|
||||
创建任务时把 `remark` 或后续字段带上 `round_no`:**最低成本**在 `_create_tasks_for_node` 写入 `remark=json.dumps({"roundNo": inst.current_round})`,或给 `oa_task` 增加 `round_no` 列。
|
||||
|
||||
**本计划采纳:** patch 中同时
|
||||
|
||||
```sql
|
||||
ALTER TABLE oa_task ADD COLUMN round_no INT NOT NULL DEFAULT 1 COMMENT '所属提交轮次' AFTER sign_mode;
|
||||
```
|
||||
|
||||
新建任务:`round_no=inst.current_round`。`build_progress_items` 按 `round_no` 分组排序。
|
||||
|
||||
- [ ] **Step 3: API `POST /instances/{id}/resubmit`**
|
||||
|
||||
Body 与 start 类似:`formData`、`initiatorSelectUserIds`。
|
||||
|
||||
- [ ] **Step 4: 升级 `build_progress_items`**
|
||||
|
||||
- 查询 events,按 `(round_no, create_time)` 展开。
|
||||
- 每一轮:`submit` 事件 → 该轮 `tasks`(`round_no` 匹配)按树序 → 若为本轮且 running 则 pending_node。
|
||||
- `return` 事件插在对应轮任务之后。
|
||||
- 终态仅一轮结束时追加。
|
||||
|
||||
- [ ] **Step 5: 测通 + Commit**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine -v
|
||||
git add docs/sql/patch_2026-07-15_oa_progress_return.sql backend/app/models/models.py backend/app/services/oa_engine.py backend/app/api/oa_approval.py backend/tests/test_oa_engine.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): support resubmit after return with multi-round progress
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: P1 — 撤销后重提关联新实例
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/oa_engine.py`(`start_instance`、`withdraw_instance`)
|
||||
- Modify: `backend/app/api/oa_approval.py`
|
||||
- Modify: `web-admin/src/api/oaApproval.ts`
|
||||
- Modify: `web-admin/src/views/oa-approval/start/index.vue`
|
||||
- Modify: `web-admin/src/views/oa-approval/mine/index.vue`(或列表组件)
|
||||
|
||||
- [ ] **Step 1: `start_instance` 增加可选 `source_instance_id`**
|
||||
|
||||
```python
|
||||
# 若传入 source_instance_id:
|
||||
src = _get_instance(db, source_instance_id, tenant_no)
|
||||
if src.initiator_user_id != initiator_user_id:
|
||||
raise OaEngineError("只能从本人撤销的审批重提")
|
||||
if src.status != "cancelled":
|
||||
raise OaEngineError("仅已撤销的审批可关联重提")
|
||||
inst.source_instance_id = src.id
|
||||
# 可选:复制标题前缀「重提-」
|
||||
```
|
||||
|
||||
`withdraw_instance` 写 `event_type=withdraw`。
|
||||
|
||||
首次 `start_instance` 也写 `event_type=submit`(round 1),便于 progress 统一走 events。
|
||||
|
||||
- [ ] **Step 2: 详情返回**
|
||||
|
||||
```python
|
||||
data["sourceInstanceId"] = inst.source_instance_id or ""
|
||||
# 若有人从本单重提,可反查:
|
||||
data["resubmittedInstanceId"] = ... # query source_instance_id == inst.id limit 1
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 前端「我发起的」**
|
||||
|
||||
对 `cancelled` 行提供「修改并重新提交」→ 跳转发起页:
|
||||
|
||||
`/#/system-manage/oa-approval/start?appId=...&sourceInstanceId=...`
|
||||
|
||||
发起页 `onMounted`:若有 `sourceInstanceId`,拉旧详情预填 `formData`,提交时带上该字段。
|
||||
|
||||
详情页顶部:若有 `sourceInstanceId`,展示链接「由已撤销单重提,查看原单」。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py web-admin/src/api/oaApproval.ts web-admin/src/views/oa-approval/start/index.vue web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): link revoke-and-resubmit as a new instance
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: P1 — 详情/待办 UI(打回 + 再提交 + 多轮)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/oa-approval/instances/detail.vue`
|
||||
- Modify: `web-admin/src/api/oaApproval.ts`
|
||||
- Modify: `web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue`(状态文案)
|
||||
|
||||
- [ ] **Step 1: API 封装**
|
||||
|
||||
```typescript
|
||||
export function fetchReturnOaTask(taskId: string, comment: string) {
|
||||
return request.post({ url: `${BASE}/tasks/${taskId}/return`, data: { comment } })
|
||||
}
|
||||
export function fetchResubmitOaInstance(
|
||||
id: string,
|
||||
data: { formData?: Record<string, any>; initiatorSelectUserIds?: string[] }
|
||||
) {
|
||||
return request.post({ url: `${BASE}/instances/${id}/resubmit`, data })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 详情操作区**
|
||||
|
||||
- 审批人待办:在「拒绝」旁增加 **「打回」**(`allowReturn !== false`),`ElMessageBox.prompt` **必填意见**,调 `fetchReturnOaTask`。
|
||||
- 发起人且 `status === 'returned'`:主按钮 **「修改并再次提交」** → 打开与发起类似的可编辑表单(可复用 start 页组件或详情内嵌 form-create 可编辑),提交调 `resubmit`。
|
||||
- 进度轴:按 `roundNo` 在轮与轮之间插入分隔标题「第 N 轮」(`v-if` 轮号变化)。
|
||||
- 状态 tag:`returned` → `warning`「待修改」。
|
||||
|
||||
- [ ] **Step 3: 手工走查脚本**
|
||||
|
||||
1. 发起(抄送→两级审批)→ 第一级同意 → 第二级打回。
|
||||
2. 进度可见第 1 轮 + 打回意见;实例「待修改」。
|
||||
3. 发起人改表单再提交 → 第 2 轮;原同意节点重新出现待办。
|
||||
4. 拒绝一条单 → 结束且不能再提交。
|
||||
5. 撤销 → 「我发起的」重提 → **新 instanceId**,详情可回跳旧单。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/oaApproval.ts web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): UI for return, resubmit, and multi-round progress
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 收尾
|
||||
|
||||
- [ ] **Step 1: 对照设计验收勾选**
|
||||
|
||||
规格 `2026-07-15-oa-approval-progress-timeline-design.md` §6 P0/P1 清单全部勾选或注明缺口。
|
||||
|
||||
- [ ] **Step 2: 更新知识图谱**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh backend
|
||||
# 若前端路由/API 有新增:
|
||||
./scripts/update-graph.sh frontend
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 规格状态**
|
||||
|
||||
将设计文档表头「状态」改为「已定稿;实现按 plans/2026-07-15-oa-approval-progress-timeline.md」。
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage 自检
|
||||
|
||||
| 规格要求 | Task |
|
||||
|----------|------|
|
||||
| 轮内按设计节点序 | 1(继承排序)+ 5 |
|
||||
| 发起人 + 终态 + 未到达 | 1–2 |
|
||||
| 拒绝 ≠ 打回 | 4(独立 API)+ 7(文案) |
|
||||
| 撤销 → 新实例再提 | 6 |
|
||||
| 打回 → 同实例多轮 | 4–5、7 |
|
||||
| 打回后中间节点重审 | 5(`advance_from` from start) |
|
||||
| allowReturn 高级设置 | 3 |
|
||||
| operation_records 等价 | 3(`oa_instance_event`) |
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意
|
||||
|
||||
- MySQL 5.7:`ALTER` 加列注意锁表窗口;测试库先跑 patch。
|
||||
- 旧实例无 `oa_instance_event`:`build_progress_items` 必须能在 `events=[]` 时退化到 P0(用 `create_time` 伪造首条 submit)。
|
||||
- 任务 status 新增 `returned` / `cancelled`:列表「已处理」查询勿把 `cancelled` 当同意。
|
||||
- Autoflush=False 会话:取消 pending 时写库顺序参考现有或签测试。
|
||||
@@ -0,0 +1,206 @@
|
||||
# OA 审批进度展示与实例流转设计(钉钉对齐)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 状态 | 已定稿;实现已落地(见 `docs/superpowers/plans/2026-07-15-oa-approval-progress-timeline.md`) |
|
||||
| 日期 | 2026-07-15 |
|
||||
| 范围 | web-admin 审批详情「审批进度」;拒绝 / 撤销 / 打回 的实例语义 |
|
||||
| 权威对齐 | 钉钉 OA 审批详情(`tasks` + `operation_records` 思路) |
|
||||
| 关联 | `2026-07-14-oa-approval-dingtalk-design.md`(本文件**修订**其中「驳回 = 整单结束、V1 不做回退」——拆成「拒绝」与「打回」) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
产品与研发对齐过程中发现两处易混:
|
||||
|
||||
1. **进度排序**:流程设计为「发起人 → 抄送 → 审批 → 结束」,详情曾因同秒创建任务、仅按 `create_time` 排序导致抄送/审批颠倒。
|
||||
2. **打回 vs 拒绝 vs 撤销后再提**:口语「打回」常被当成「拒绝结束」;实际上需要三套语义,且「同实例第二次提交」主要出现在**打回**路径。
|
||||
|
||||
本文件固定:**实例怎么活** + **进度怎么画**,并与钉钉主流体感对齐。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 审批进度可读、可审计,与流程设计顺序在「同一轮」内一致。
|
||||
- 明确拒绝 / 撤销 / 打回三种动作对实例生命周期的影响。
|
||||
- 分阶段落地:先稳单轮 + 拒绝/撤销收尾;再做打回同实例多轮与撤销关联新单。
|
||||
|
||||
非目标(本文件不展开实现细节到接口字段级,留给实现计划):
|
||||
|
||||
- 转交、评论独立墙、代理人等钉钉全量操作类型的 UI 一一复刻。
|
||||
- 小程序端进度 UI(语义同本章,交互另规)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三种动作(SHALL)
|
||||
|
||||
| 动作 | 操作者 | 本单命运 | 再次进入审批 | 钉钉类比 |
|
||||
|------|--------|----------|--------------|----------|
|
||||
| **拒绝** | 审批人 | **结束**(结果:已拒绝) | 只能**新开实例** | 任务 `REFUSE` + 实例完结 |
|
||||
| **撤销** | 发起人(受高级设置约束) | **终止**(已撤销) | **新实例**再提(可关联旧单) | `TERMINATE`;重提常带 `biz_action=REVOKE` / 附属实例 |
|
||||
| **打回**(退回) | 审批人 | **不结束** | **同一实例**改表单后再次提交 | `REDIRECT_PROCESS` |
|
||||
|
||||
### 3.1 禁止混淆的表述
|
||||
|
||||
- **不得**将「打回」实现为「拒绝结束」。
|
||||
- **不得**将「拒绝」实现为「退回发起人改单」。
|
||||
- 「撤销后再提交」**不是**同实例复活(见 §3.2);「打回后再提交」**才是**同实例第二轮(见 §3.3)。
|
||||
|
||||
### 3.2 撤销 → 修改 → 再提交(钉钉思路)
|
||||
|
||||
```
|
||||
实例 #1:… → 发起人撤销 → 状态终态「已撤销」
|
||||
实例 #2:重新发起(新 instanceId)→ 可选关联 #1
|
||||
```
|
||||
|
||||
- 列表:旧单「已撤销」,新单「审批中 / 已完成 …」。
|
||||
- 详情:各自独立进度轴;新单详情可展示「由某某已撤销单重提」并跳转旧单。
|
||||
- **SHALL NOT** 在已撤销实例上直接改状态为 running 并清空历史后重跑(避免审计与待办混乱)。
|
||||
|
||||
### 3.3 打回 → 修改 → 再提交(同实例多轮)
|
||||
|
||||
```
|
||||
同一实例:
|
||||
第 1 轮提交 → … → 审批人打回(附意见)
|
||||
→ 发起人改表单 → 第 2 轮提交 → 按退回策略重走节点 …
|
||||
```
|
||||
|
||||
- 进度轴**保留第 1 轮记录**,再追加第 2 轮,不得覆盖抹掉。
|
||||
- **退回后中间已通过节点是否重审**:默认与钉钉宜搭高级流程一致——**需要重新审批**(从退回目标之后的路径重走;V1 打回目标默认为「发起人 / 开始节点」)。若日后支持「退回到指定节点」,另开变更,默认仍「退回后至结束前节点重审」。
|
||||
|
||||
### 3.4 拒绝(打回≠拒绝)
|
||||
|
||||
- 当前审批人拒绝 → 实例进入终态「已拒绝」。
|
||||
- 进度:拒绝节点结果 + 「流程结束(已拒绝)」;**不得**再展示后续未到达节点为待处理。
|
||||
- 业务上若需继续办理:发起人**新开一张**审批(与旧单无强制引擎关联;产品可选「复制表单发起」属后续增强)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 审批进度展示模型(SHALL)
|
||||
|
||||
对齐钉钉:**对外一条时间轴,对内区分「流程快照感」与「操作/轮次事件」**。
|
||||
|
||||
### 4.1 推荐模型:混合
|
||||
|
||||
| 层级 | 规则 |
|
||||
|------|------|
|
||||
| **轮次(大序)** | 同一实例内按「提交轮 / 打回事件」分段或打标(第 1 轮、打回、第 2 轮…) |
|
||||
| **轮内(小序)** | **严格按该版本流程树设计节点顺序**(抄送在审批前则展示在审批前) |
|
||||
| **同节点多人** | 会签 / 或签 / 加签:**一人一条**结果,不合并成一条含糊节点 |
|
||||
| **未到达** | 仅当前轮且实例 `running` 时可灰显「未到达」;终态(通过 / 拒绝 / 撤销)后**不再**挂未达节点 |
|
||||
| **抄送** | 不阻断;创建即记入进度(知会),排序仍服从设计序 |
|
||||
| **结束** | 通过 →「流程结束」;拒绝 →「流程结束(已拒绝)」;撤销 →「已撤销」(可不展示虚假「流程正常结束」) |
|
||||
|
||||
### 4.2 单轮 happy path 示意
|
||||
|
||||
```
|
||||
发起人 · 张三 · 已提交 · t0
|
||||
抄送人 · … · 已抄送 · t1 ← 设计在审批前则在上
|
||||
审批人 · … · 待处理 / 已同意 · t2
|
||||
(可选)后续节点 · 未到达
|
||||
(全部通过后)流程结束
|
||||
```
|
||||
|
||||
### 4.3 打回多轮示意
|
||||
|
||||
```
|
||||
—— 第 1 轮 ——
|
||||
发起人 · 已提交
|
||||
审批人甲 · 已同意
|
||||
审批人乙 · 打回(意见:请补充附件)
|
||||
—— 第 2 轮 ——
|
||||
发起人 · 再次提交
|
||||
审批人甲 · 待处理 / …
|
||||
…
|
||||
```
|
||||
|
||||
### 4.4 撤销后再提(跨实例)示意
|
||||
|
||||
```
|
||||
【实例 #1 详情】
|
||||
… → 已撤销
|
||||
|
||||
【实例 #2 详情】
|
||||
提示:本单由「实例 #1」撤销后重提
|
||||
发起人 · 已提交
|
||||
…
|
||||
```
|
||||
|
||||
### 4.5 排序与数据权威
|
||||
|
||||
- **展示排序权威**:流程树节点序(`node_key` 在 flow tree 先序/路径序中的位置)→ 同节点内 `order_num` → `create_time` → `id`。
|
||||
- **禁止**仅依赖同秒精度的 `create_time` 作为唯一排序键(会导致抄送/审批颠倒)。
|
||||
- 新建任务时 **SHALL** 写入递增 `order_num`,便于同节点加签等场景稳定排序。
|
||||
- 长期演进:可引入独立 `operation_records`(或等价事件表),与钉钉 `operation_records` 对齐;在引入前,允许由「实例事件 + oa_task」投影生成时间轴,但语义须符合本章。
|
||||
|
||||
### 4.6 与「发起页流程预览」的关系
|
||||
|
||||
- 发起页底部预览(见 `2026-07-15-oa-start-flow-preview-design.md`):提交前的**预期链**。
|
||||
- 详情审批进度:提交后的**实际发生 + 当前轮预期未达**。
|
||||
- 两者节点名与顺序在同一发布版本下应一致;条件分支以实际表单路径为准。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分阶段落地
|
||||
|
||||
### 5.1 阶段 P0(当前可验收,不依赖打回引擎)
|
||||
|
||||
1. 详情进度按**流程树节点序**排列任务(兼容历史 `order_num=0` 同秒数据)。
|
||||
2. 时间轴含:**发起人(已提交)**;任务节点;终态时 **流程结束 / 已拒绝 / 已撤销**。
|
||||
3. 拒绝、撤销行为保持:本单结束,无同实例第二轮。
|
||||
4. 撤销后再提:用户从发起入口**新开实例**(关联旧单可为 P1)。
|
||||
|
||||
### 5.2 阶段 P1(钉钉完整语义)
|
||||
|
||||
1. 审批人操作拆分:**同意 / 拒绝 / 打回**(打回需意见;高级设置可开关)。
|
||||
2. 打回后:取消当前及后续未完成待办;实例进入「待发起人修改」类状态(命名实现时确定);发起人可改表单并**再次提交**;引擎按 §3.3 重走。
|
||||
3. 进度轴支持**多轮**展示(§4.3)。
|
||||
4. 撤销后重提:创建新实例时写入关联(如 `oa_relation` 或实例字段 `prev_instance_id` / `biz_action=revoke_resubmit`),双方详情可跳转。
|
||||
|
||||
### 5.3 与旧规格的关系
|
||||
|
||||
`2026-07-14-oa-approval-dingtalk-design.md` §5.2 原文:
|
||||
|
||||
> 结束:实例 `completed` / `rejected`(驳回策略:整单结束,V1 不做复杂回退多版本)
|
||||
|
||||
**修订为**:
|
||||
|
||||
- 「驳回」一词废弃;改为「**拒绝** = 整单结束」。
|
||||
- 「**打回** = 同实例退回重提」列入 P1,不再用「V1 永不做回退」表述封死产品方向。
|
||||
- P0 仍可不实现打回引擎,但产品文案与按钮不得把拒绝叫做打回。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收要点
|
||||
|
||||
### P0
|
||||
|
||||
- [x] 设计为「抄送 → 审批」时,详情进度在同秒创建下仍为抄送在上、审批在下。
|
||||
- [x] 进度首条为发起人已提交;拒绝/撤销/通过后有正确终态文案。
|
||||
- [x] 拒绝后列表与详情均为已结束,无法在同单再提交。
|
||||
|
||||
### P1
|
||||
|
||||
- [x] 打回后同实例可再次提交,轴上可见两轮提交与中间打回意见。(引擎+UI 已实现;需业务侧走查)
|
||||
- [x] 打回后中间已同意节点默认需重审。
|
||||
- [x] 撤销后重提为新 `instanceId`,可从新单跳到旧单。
|
||||
- [x] 拒绝与打回按钮/结果文案互不混用。
|
||||
|
||||
---
|
||||
|
||||
## 7. 决议记录
|
||||
|
||||
| 日期 | 决议 |
|
||||
|------|------|
|
||||
| 2026-07-15 | 按钉钉思路:拒绝结束本单;撤销后再提 = 新实例;打回 = 同实例再提交;进度 = 轮次时间轴 + 轮内设计序。 |
|
||||
| 2026-07-15 | 未到达节点:进行中可展示;终态不展示(在混合模型中确认)。 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 开放问题(已在实现计划收口)
|
||||
|
||||
1. 打回后实例状态 = **`returned`**(待发起人修改再提交)。
|
||||
2. P1 打回目标 **仅退回发起人(start)**。
|
||||
3. 使用独立表 **`oa_instance_event`**(对齐钉钉 operation_records)。
|
||||
@@ -210,6 +210,7 @@ export function fetchStartOaInstance(data: {
|
||||
initiatorDeptId?: string
|
||||
initiatorSelectUserIds?: string[]
|
||||
relatedInstanceIds?: string[]
|
||||
sourceInstanceId?: string
|
||||
}) {
|
||||
return request.post({
|
||||
url: `${BASE}/instances`,
|
||||
@@ -260,6 +261,25 @@ export function fetchRejectOaTask(taskId: string, comment?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchReturnOaTask(taskId: string, comment: string) {
|
||||
return request.post({
|
||||
url: `${BASE}/tasks/${taskId}/return`,
|
||||
data: { comment },
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchResubmitOaInstance(
|
||||
id: string,
|
||||
data: { formData?: Record<string, any>; initiatorSelectUserIds?: string[] }
|
||||
) {
|
||||
return request.post({
|
||||
url: `${BASE}/instances/${id}/resubmit`,
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAddSignOaTask(
|
||||
taskId: string,
|
||||
data: { userIds: string[]; comment?: string }
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!-- OA 办理中心列表(待处理 / 已处理 / 我发起 / 抄送) -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">{{ title }}</h2>
|
||||
</div>
|
||||
<div class="list-body list-wrap">
|
||||
<el-table class="list-table" :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="kind === 'mine'"
|
||||
prop="title"
|
||||
label="标题"
|
||||
min-width="200"
|
||||
align="left"
|
||||
header-align="left"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="kind !== 'mine'"
|
||||
prop="nodeName"
|
||||
label="节点"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column prop="status" label="状态" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusType(row.status)">
|
||||
{{ statusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="时间" min-width="170" align="center" />
|
||||
<el-table-column label="操作" min-width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="openDetail(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="kind === 'mine' && row.status === 'cancelled' && row.appId"
|
||||
class="btn-op-item"
|
||||
@click="openResubmitFromCancelled(row)"
|
||||
>
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
修改并重新提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onActivated, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RefreshRight, View } from '@element-plus/icons-vue'
|
||||
import { fetchOaTasks } from '@/api/oaApproval'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
kind: 'pending' | 'done' | 'mine' | 'cc'
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
|
||||
function statusType(s: string) {
|
||||
if (s === 'pending' || s === 'running' || s === 'returned') return 'warning'
|
||||
if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
|
||||
if (s === 'rejected' || s === 'cancelled') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function statusText(s: string) {
|
||||
if (!s) return '-'
|
||||
const map: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
running: '进行中',
|
||||
approved: '已同意',
|
||||
rejected: '已拒绝',
|
||||
completed: '已完成',
|
||||
cancelled: '已撤销',
|
||||
done: '已结束',
|
||||
returned: '待修改'
|
||||
}
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
function openDetail(row: any) {
|
||||
const id = props.kind === 'mine' ? row.id : row.instanceId
|
||||
if (!id) return
|
||||
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
|
||||
}
|
||||
|
||||
function openResubmitFromCancelled(row: any) {
|
||||
if (!row?.appId || !row?.id) return
|
||||
router.push({
|
||||
path: '/system-manage/oa-approval/start',
|
||||
query: { appId: row.appId, sourceInstanceId: row.id }
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchOaTasks(props.kind)
|
||||
rows.value = res?.list || []
|
||||
} catch {
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
// 菜单 keepAlive:切回本页时重新拉取,避免审批后列表仍是旧数据
|
||||
onActivated(load)
|
||||
</script>
|
||||
@@ -0,0 +1,430 @@
|
||||
<!-- OA 四步设计器壳(D10 完善基础/高级;E11/E12 填表单与流程) -->
|
||||
<template>
|
||||
<div class="page-container oa-designer">
|
||||
<section class="panel oa-designer__panel">
|
||||
<div class="oa-designer__toolbar">
|
||||
<el-button class="btn-ghost" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<div class="oa-designer__actions">
|
||||
<el-button class="btn-ghost" :loading="saving" @click="saveDraft">
|
||||
<el-icon><Document /></el-icon>
|
||||
保存草稿
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="publishing" @click="publish">
|
||||
<el-icon><Upload /></el-icon>
|
||||
发布
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeStep" class="oa-designer__tabs" @tab-change="onStepChange">
|
||||
<el-tab-pane label="1 基础设置" name="basic" />
|
||||
<el-tab-pane label="2 表单设计" name="form" />
|
||||
<el-tab-pane label="3 流程设计" name="flow" />
|
||||
<el-tab-pane label="4 高级设置" name="advanced" />
|
||||
</el-tabs>
|
||||
|
||||
<div v-loading="loading" class="oa-designer__body">
|
||||
<div v-show="activeStep === 'basic'" class="oa-designer__step">
|
||||
<el-form label-position="top" class="oa-designer__form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="应用图标" class="form-col-span-2">
|
||||
<div class="oa-designer__icon-row">
|
||||
<span class="oa-designer__icon-preview">
|
||||
<img :src="resolveOaAppIconUrl(basic.iconUrl)" alt="" />
|
||||
</span>
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:http-request="uploadBasicIcon"
|
||||
:disabled="iconUploading"
|
||||
>
|
||||
<el-button class="btn-ghost" :loading="iconUploading">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button class="btn-ghost" @click="basic.iconUrl = pickRandomOaAppIconUrl()">
|
||||
换默认
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="应用名称">
|
||||
<el-input v-model="basic.appName" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="basic.status">
|
||||
<el-option label="启用" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="表单说明" class="form-col-span-2">
|
||||
<el-input v-model="basic.description" type="textarea" :rows="3" maxlength="100" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-show="activeStep === 'form'" class="oa-designer__form-step">
|
||||
<OaFormDesignerEmbed
|
||||
v-if="formDesignerMounted"
|
||||
ref="formEmbedRef"
|
||||
v-model="formSchema"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="activeStep === 'flow'" class="oa-designer__step oa-designer__flow-step">
|
||||
<FlowDesigner v-model="flowTreeJson" />
|
||||
</div>
|
||||
|
||||
<div v-show="activeStep === 'advanced'" class="oa-designer__step">
|
||||
<el-form label-position="top" class="oa-designer__form oa-designer__form--narrow">
|
||||
<el-form-item label="主管为空时回退部门负责人">
|
||||
<el-switch v-model="advanced.supervisorFallbackToDeptLeader" />
|
||||
</el-form-item>
|
||||
<el-form-item label="允许撤销进行中的审批">
|
||||
<el-switch v-model="advanced.allowWithdrawRunning" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审批意见必填">
|
||||
<el-switch v-model="advanced.commentRequired" />
|
||||
</el-form-item>
|
||||
<el-form-item label="允许加签">
|
||||
<el-switch v-model="advanced.allowAddSign" />
|
||||
</el-form-item>
|
||||
<el-form-item label="允许审批人打回(退回发起人修改)">
|
||||
<el-switch v-model="advanced.allowReturn" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审批人去重">
|
||||
<el-switch v-model="advanced.approverDedup" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { UploadRequestOptions } from 'element-plus'
|
||||
import { ArrowLeft, Document, Upload } from '@element-plus/icons-vue'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
import { fetchOaAppDraft, fetchPublishOaApp, fetchSaveOaAppDraft } from '@/api/oaApproval'
|
||||
import FlowDesigner from '@/components/oa-approval/flow-tree/FlowDesigner.vue'
|
||||
import { createDefaultFlow, ensureFlowRoot } from '@/components/oa-approval/flow-tree/types'
|
||||
import OaFormDesignerEmbed from '@/components/oa-approval/OaFormDesignerEmbed.vue'
|
||||
import { pickRandomOaAppIconUrl, resolveOaAppIconUrl } from '@/utils/oa-app-icons'
|
||||
|
||||
defineOptions({ name: 'OaApprovalDesigner' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appId = String(route.params.id || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const publishing = ref(false)
|
||||
const activeStep = ref('basic')
|
||||
const draft = ref<any>(null)
|
||||
/** iframe 表单设计器(与主应用 Tailwind 隔离) */
|
||||
const formEmbedRef = ref<{ fetchSchema: () => Promise<{ rule: unknown; option: unknown }> } | null>(
|
||||
null
|
||||
)
|
||||
const formDesignerMounted = ref(false)
|
||||
const formSchema = ref<{ rule: unknown; option: unknown }>({ rule: [], option: {} })
|
||||
|
||||
const basic = reactive({
|
||||
appName: '',
|
||||
description: '',
|
||||
status: '1',
|
||||
groupId: '',
|
||||
iconUrl: ''
|
||||
})
|
||||
const iconUploading = ref(false)
|
||||
|
||||
const advanced = reactive({
|
||||
supervisorFallbackToDeptLeader: true,
|
||||
allowWithdrawRunning: true,
|
||||
commentRequired: false,
|
||||
allowAddSign: true,
|
||||
allowReturn: true,
|
||||
approverDedup: false
|
||||
})
|
||||
|
||||
const flowTreeJson = ref(createDefaultFlow())
|
||||
|
||||
async function collectFormSchema() {
|
||||
if (formEmbedRef.value?.fetchSchema) {
|
||||
try {
|
||||
const schema = await formEmbedRef.value.fetchSchema()
|
||||
formSchema.value = schema
|
||||
return schema
|
||||
} catch {
|
||||
/* 超时则回退本地缓存 */
|
||||
}
|
||||
}
|
||||
return {
|
||||
rule: formSchema.value?.rule ?? draft.value?.formSchemaJson?.rule ?? [],
|
||||
option: formSchema.value?.option ?? draft.value?.formSchemaJson?.option ?? {}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBasicIcon(options: UploadRequestOptions) {
|
||||
const file = options.file as File
|
||||
if (!file?.type?.startsWith('image/')) {
|
||||
ElMessage.warning('请上传图片文件')
|
||||
return
|
||||
}
|
||||
iconUploading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res: any = await fetchUploadAttachment(fd)
|
||||
const url = res?.fileUrl || res?.data?.fileUrl
|
||||
if (!url) {
|
||||
ElMessage.error('上传失败,未返回地址')
|
||||
return
|
||||
}
|
||||
basic.iconUrl = url
|
||||
options.onSuccess?.(res)
|
||||
} catch (e) {
|
||||
options.onError?.(e as any)
|
||||
} finally {
|
||||
iconUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDraft() {
|
||||
if (!appId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchOaAppDraft(appId)
|
||||
draft.value = data
|
||||
const b = data?.basic || {}
|
||||
basic.appName = b.appName || ''
|
||||
basic.description = b.description || ''
|
||||
basic.status = b.status || '1'
|
||||
basic.groupId = b.groupId || ''
|
||||
basic.iconUrl = b.iconUrl || ''
|
||||
const a = data?.advancedSettingsJson || {}
|
||||
advanced.supervisorFallbackToDeptLeader = a.supervisorFallbackToDeptLeader !== false
|
||||
advanced.allowWithdrawRunning = a.allowWithdrawRunning !== false
|
||||
advanced.commentRequired = !!a.commentRequired
|
||||
advanced.allowAddSign = a.allowAddSign !== false
|
||||
advanced.allowReturn = a.allowReturn !== false
|
||||
advanced.approverDedup = !!a.approverDedup
|
||||
flowTreeJson.value = ensureFlowRoot(data?.flowTreeJson)
|
||||
const fs = data?.formSchemaJson
|
||||
// 存纯 JSON,便于 iframe postMessage 克隆与 FormCreate setRule
|
||||
try {
|
||||
const plain = JSON.parse(JSON.stringify(fs ?? {}))
|
||||
formSchema.value = {
|
||||
rule: Array.isArray(plain?.rule) ? plain.rule : Array.isArray(plain) ? plain : [],
|
||||
option: plain?.option && typeof plain.option === 'object' ? plain.option : {}
|
||||
}
|
||||
} catch {
|
||||
formSchema.value = { rule: [], option: {} }
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
saving.value = true
|
||||
try {
|
||||
const formSchemaJson = await collectFormSchema()
|
||||
const data = await fetchSaveOaAppDraft(appId, {
|
||||
basic: {
|
||||
appName: basic.appName.trim(),
|
||||
description: basic.description,
|
||||
status: basic.status,
|
||||
groupId: basic.groupId || undefined,
|
||||
iconUrl: basic.iconUrl || undefined
|
||||
},
|
||||
formSchemaJson,
|
||||
flowTreeJson: flowTreeJson.value,
|
||||
advancedSettingsJson: { ...advanced }
|
||||
})
|
||||
draft.value = data
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
await saveDraft()
|
||||
await ElMessageBox.confirm('确定发布当前草稿吗?', '发布确认', { type: 'warning' })
|
||||
publishing.value = true
|
||||
try {
|
||||
await fetchPublishOaApp(appId)
|
||||
// 勿再 loadDraft:GET /draft 会无草稿时从已发布版克隆出新草稿,导致列表仍显示「草稿」
|
||||
goBack()
|
||||
} catch (e: any) {
|
||||
const errors = e?.data?.errors || e?.response?.data?.data?.errors
|
||||
if (Array.isArray(errors) && errors.length) {
|
||||
await ElMessageBox.alert(errors.map((x: string) => `· ${x}`).join('\n'), '发布校验未通过', {
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ path: '/system-manage/oa-approval/apps' })
|
||||
}
|
||||
|
||||
async function onStepChange(name: string | number) {
|
||||
if (formDesignerMounted.value) {
|
||||
try {
|
||||
const schema = await collectFormSchema()
|
||||
if (draft.value) draft.value.formSchemaJson = schema
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (name === 'form') {
|
||||
formDesignerMounted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDraft()
|
||||
if (activeStep.value === 'form') {
|
||||
formDesignerMounted.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-designer__panel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.oa-designer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.oa-designer__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Tab 导航居中 */
|
||||
.oa-designer__tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-scroll) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav) {
|
||||
float: none;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
padding: 0 22px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-designer__body {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.oa-designer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.oa-designer__tabs.el-tabs),
|
||||
:deep(.el-tabs__content) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 各步内容水平居中 */
|
||||
.oa-designer__step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oa-designer__form {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-designer__form--narrow {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.oa-designer__icon-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oa-designer__icon-preview {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-designer__form-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oa-designer__flow-step {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.oa-flow-designer) {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,6 +28,15 @@
|
||||
<el-icon><Close /></el-icon>
|
||||
拒绝
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canApprove && detail?.allowReturn !== false"
|
||||
class="btn-ghost"
|
||||
:loading="acting"
|
||||
@click="doReturn"
|
||||
>
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
打回
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canApprove && detail?.allowAddSign !== false"
|
||||
class="btn-ghost"
|
||||
@@ -37,6 +46,16 @@
|
||||
<el-icon><Plus /></el-icon>
|
||||
加签
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail?.status === 'returned' && isInitiator"
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:loading="acting"
|
||||
@click="openResubmit"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
修改并再次提交
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail?.status === 'running' && isInitiator"
|
||||
class="btn-ghost"
|
||||
@@ -50,6 +69,16 @@
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="oa-detail__body">
|
||||
<p v-if="detail?.sourceInstanceId" class="oa-detail__link-tip">
|
||||
本单由已撤销审批重提,
|
||||
<el-button link type="primary" @click="goInstance(detail.sourceInstanceId)">查看原单</el-button>
|
||||
</p>
|
||||
<p v-if="detail?.resubmittedInstanceId" class="oa-detail__link-tip">
|
||||
已有重提单,
|
||||
<el-button link type="primary" @click="goInstance(detail.resubmittedInstanceId)">
|
||||
查看重提单
|
||||
</el-button>
|
||||
</p>
|
||||
<section class="oa-detail__section">
|
||||
<h3>表单内容</h3>
|
||||
<div class="oa-detail__form-wrap">
|
||||
@@ -67,13 +96,18 @@
|
||||
<h3>审批进度</h3>
|
||||
<div class="oa-detail__timeline-wrap">
|
||||
<el-timeline v-if="timelineItems.length">
|
||||
<el-timeline-item
|
||||
v-for="item in timelineItems"
|
||||
:key="item.key"
|
||||
:type="timelineType(item.status)"
|
||||
:timestamp="item.timestamp"
|
||||
placement="top"
|
||||
>
|
||||
<template v-for="(item, idx) in timelineItems" :key="item.key">
|
||||
<div
|
||||
v-if="idx === 0 || item.roundNo !== timelineItems[idx - 1].roundNo"
|
||||
class="oa-tl__round"
|
||||
>
|
||||
第 {{ item.roundNo }} 轮
|
||||
</div>
|
||||
<el-timeline-item
|
||||
:type="timelineType(item.status)"
|
||||
:timestamp="item.timestamp"
|
||||
placement="top"
|
||||
>
|
||||
<div class="oa-tl">
|
||||
<div class="oa-tl__title">
|
||||
{{ item.title }}
|
||||
@@ -81,6 +115,7 @@
|
||||
{{ statusText(item.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.taskType === 'cc'" size="small" type="info">抄送</el-tag>
|
||||
<el-tag v-if="item.kind === 'return'" size="small" type="warning">打回</el-tag>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.assignee && item.kind !== 'pending_node' && item.kind !== 'terminal'"
|
||||
@@ -93,7 +128,8 @@
|
||||
</div>
|
||||
<div v-if="item.commentText" class="oa-tl__comment">意见:{{ item.commentText }}</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline-item>
|
||||
</template>
|
||||
</el-timeline>
|
||||
<p v-else class="oa-detail__empty">暂无任务记录</p>
|
||||
</div>
|
||||
@@ -161,6 +197,8 @@
|
||||
fetchApproveOaTask,
|
||||
fetchOaInstanceDetail,
|
||||
fetchRejectOaTask,
|
||||
fetchResubmitOaInstance,
|
||||
fetchReturnOaTask,
|
||||
fetchWithdrawOaInstance
|
||||
} from '@/api/oaApproval'
|
||||
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
|
||||
@@ -227,7 +265,7 @@
|
||||
const canApprove = computed(() => !!myPendingTask.value)
|
||||
|
||||
function statusType(s: string) {
|
||||
if (s === 'pending' || s === 'running') return 'warning'
|
||||
if (s === 'pending' || s === 'running' || s === 'returned') return 'warning'
|
||||
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'success'
|
||||
if (s === 'rejected' || s === 'cancelled') return 'danger'
|
||||
if (s === 'not_reached') return 'info'
|
||||
@@ -244,15 +282,18 @@
|
||||
cancelled: '已撤销',
|
||||
done: '已结束',
|
||||
submitted: '已提交',
|
||||
not_reached: '未到达'
|
||||
not_reached: '未到达',
|
||||
returned: '待修改'
|
||||
}
|
||||
// 任务被打回后取消:展示「已取消」
|
||||
if (s === 'cancelled') return '已撤销'
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
function timelineType(s: string) {
|
||||
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'success'
|
||||
if (s === 'rejected' || s === 'cancelled') return 'danger'
|
||||
if (s === 'pending') return 'warning'
|
||||
if (s === 'pending' || s === 'returned') return 'warning'
|
||||
if (s === 'not_reached') return 'info'
|
||||
return 'primary'
|
||||
}
|
||||
@@ -330,6 +371,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function doReturn() {
|
||||
const task = myPendingTask.value
|
||||
if (!task) return
|
||||
let comment = ''
|
||||
try {
|
||||
const res = await ElMessageBox.prompt('请填写打回意见(必填)', '打回', {
|
||||
inputPlaceholder: '请发起人修改后再次提交',
|
||||
confirmButtonText: '确定打回',
|
||||
cancelButtonText: '取消',
|
||||
inputValidator: (v) => !!(v && String(v).trim()) || '打回意见必填'
|
||||
})
|
||||
comment = String(res.value || '').trim()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
acting.value = true
|
||||
try {
|
||||
await fetchReturnOaTask(task.id, comment)
|
||||
await load()
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openResubmit() {
|
||||
await ElMessageBox.confirm(
|
||||
'将使用当前表单内容再次提交并重新走审批。若需改字段,请先在下方确认表单后确定。',
|
||||
'再次提交',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
acting.value = true
|
||||
try {
|
||||
await fetchResubmitOaInstance(String(route.params.id || ''), {
|
||||
formData: { ...(viewFormData.value || detail.value?.formDataJson || {}) }
|
||||
})
|
||||
await load()
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goInstance(id: string) {
|
||||
if (!id) return
|
||||
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
userLoading.value = true
|
||||
try {
|
||||
@@ -447,6 +534,20 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.oa-detail__link-tip {
|
||||
max-width: 960px;
|
||||
margin: 0 auto 8px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.oa-tl__round {
|
||||
margin: 8px 0 4px 4px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.oa-tl__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
<!-- 发起审批 · 列表样式对齐审批管理 -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel oa-apps">
|
||||
<div class="oa-apps__search-wrap">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
class="oa-apps__search"
|
||||
clearable
|
||||
placeholder="请输入审批单名称"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="oa-apps__tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
class="oa-apps__tab"
|
||||
:class="{ active: activeGroupId === '' }"
|
||||
role="tab"
|
||||
@click="activeGroupId = ''"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
v-for="g in groupOptions"
|
||||
:key="g.id"
|
||||
type="button"
|
||||
class="oa-apps__tab"
|
||||
:class="{ active: activeGroupId === g.id }"
|
||||
role="tab"
|
||||
@click="activeGroupId = g.id"
|
||||
>
|
||||
{{ g.groupName }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oa-apps__toolbar">
|
||||
<div class="oa-apps__toolbar-hint">选择一个审批单即可发起</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="oa-apps__body">
|
||||
<div v-if="!displayGroups.length && !loading" class="oa-apps__empty">
|
||||
暂无可发起的审批模板,请先在「审批管理」中发布应用。
|
||||
</div>
|
||||
<section
|
||||
v-for="sec in displayGroups"
|
||||
:key="sec.id || '_ungrouped'"
|
||||
class="oa-apps__section"
|
||||
>
|
||||
<div class="oa-apps__section-head">
|
||||
<h3>{{ sec.name }}</h3>
|
||||
<span class="oa-apps__dot" aria-hidden="true" />
|
||||
<span class="oa-apps__section-meta">{{ sec.apps.length }} 个表单</span>
|
||||
</div>
|
||||
<div class="oa-apps__card-row">
|
||||
<div
|
||||
v-for="row in sec.apps"
|
||||
:key="row.id"
|
||||
class="oa-app-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onPick(row)"
|
||||
@keydown.enter="onPick(row)"
|
||||
>
|
||||
<div class="oa-app-card__main">
|
||||
<span class="oa-app-card__icon">
|
||||
<img :src="resolveOaAppIconUrl(row.iconUrl)" alt="" />
|
||||
</span>
|
||||
<span class="oa-app-card__body">
|
||||
<span class="oa-app-card__name">{{ row.appName }}</span>
|
||||
<span class="oa-app-card__desc">{{ row.description || '点击发起审批' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="oa-app-card__ops">
|
||||
<el-button class="oa-app-card__btn primary" @click.stop="onPick(row)">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
发起
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dlgVisible"
|
||||
:title="picked?.appName || '发起审批'"
|
||||
width="720px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div v-loading="detailLoading">
|
||||
<el-form v-if="needInitiatorSelect" label-position="top" class="oa-start__meta">
|
||||
<el-form-item label="自选审批人" required>
|
||||
<el-select
|
||||
v-model="initiatorSelectUserIds"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="请选择审批人"
|
||||
style="width: 100%"
|
||||
:loading="userLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in userOptions"
|
||||
:key="u.id"
|
||||
:label="`${u.nickName}(${u.userNo})`"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<form-create
|
||||
v-if="formRule.length"
|
||||
v-model="formData"
|
||||
v-model:api="fapi"
|
||||
:rule="formRule"
|
||||
:option="formOption"
|
||||
/>
|
||||
<p v-else-if="!detailLoading" class="oa-start__tip">该模板暂无表单字段,仍可直接提交。</p>
|
||||
<OaFlowPreview
|
||||
v-if="!detailLoading"
|
||||
:steps="flowSteps"
|
||||
:loading="previewLoading"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dlgVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitting" @click="submit">
|
||||
<el-icon><Check /></el-icon>
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Check, Close, Promotion, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchUserList } from '@/api/admin'
|
||||
import {
|
||||
fetchOaFlowPreview,
|
||||
fetchOaGroupList,
|
||||
fetchOaInstanceDetail,
|
||||
fetchOaTemplateDetail,
|
||||
fetchOaTemplates,
|
||||
fetchStartOaInstance,
|
||||
type OaFlowPreviewStep
|
||||
} from '@/api/oaApproval'
|
||||
import OaFlowPreview from '@/components/oa-approval/OaFlowPreview.vue'
|
||||
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
|
||||
import { resolveOaAppIconUrl } from '@/utils/oa-app-icons'
|
||||
|
||||
defineOptions({ name: 'OaApprovalStart' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const templates = ref<any[]>([])
|
||||
const groupOptions = ref<Array<{ id: string; groupName: string }>>([])
|
||||
const searchKeyword = ref('')
|
||||
const activeGroupId = ref('')
|
||||
const dlgVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const picked = ref<any>(null)
|
||||
const formData = ref<Record<string, any>>({})
|
||||
const formRule = ref<any[]>([])
|
||||
const formOption = ref<Record<string, any>>({ submitBtn: false, resetBtn: false })
|
||||
const fapi = ref<any>(null)
|
||||
const submitting = ref(false)
|
||||
const sourceInstanceId = ref('')
|
||||
const needInitiatorSelect = ref(false)
|
||||
const initiatorSelectUserIds = ref<string[]>([])
|
||||
const userLoading = ref(false)
|
||||
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
|
||||
const flowSteps = ref<OaFlowPreviewStep[]>([])
|
||||
const previewLoading = ref(false)
|
||||
|
||||
const displayGroups = computed(() => {
|
||||
const kw = (searchKeyword.value || '').trim().toLowerCase()
|
||||
let list = [...(templates.value || [])]
|
||||
if (activeGroupId.value) {
|
||||
list = list.filter((a) => a.groupId === activeGroupId.value)
|
||||
}
|
||||
if (kw) {
|
||||
list = list.filter((a) => String(a.appName || '').toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
const bucket = new Map<string, { id: string; name: string; apps: any[] }>()
|
||||
for (const g of groupOptions.value) {
|
||||
bucket.set(g.id, { id: g.id, name: g.groupName, apps: [] })
|
||||
}
|
||||
const ungrouped = { id: '_ungrouped', name: '未分组', apps: [] as any[] }
|
||||
|
||||
for (const app of list) {
|
||||
if (app.groupId && bucket.has(app.groupId)) {
|
||||
bucket.get(app.groupId)!.apps.push(app)
|
||||
} else {
|
||||
ungrouped.apps.push(app)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeGroupId.value) {
|
||||
const one = bucket.get(activeGroupId.value)
|
||||
return one && one.apps.length ? [one] : []
|
||||
}
|
||||
|
||||
const sections: { id: string; name: string; apps: any[] }[] = []
|
||||
for (const g of groupOptions.value) {
|
||||
const sec = bucket.get(g.id)!
|
||||
if (sec.apps.length) sections.push(sec)
|
||||
}
|
||||
if (ungrouped.apps.length) sections.push(ungrouped)
|
||||
return sections
|
||||
})
|
||||
|
||||
function flowNeedsInitiatorSelect(node: any): boolean {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
const cfg = node.approverConfig || {}
|
||||
if ((cfg.assigneeType || '') === 'initiator_select') return true
|
||||
if (node.child && flowNeedsInitiatorSelect(node.child)) return true
|
||||
for (const g of node.conditionGroups || []) {
|
||||
if (g?.child && flowNeedsInitiatorSelect(g.child)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
userLoading.value = true
|
||||
try {
|
||||
const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
|
||||
const raw: any[] = []
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const res = await fetchUserList({ page, pageSize, status: '1' })
|
||||
const list = res?.list || []
|
||||
raw.push(...list)
|
||||
if (list.length < pageSize) break
|
||||
if (typeof res?.total === 'number' && raw.length >= res.total) break
|
||||
page += 1
|
||||
}
|
||||
userOptions.value = raw.map((u) => ({
|
||||
id: u.id,
|
||||
nickName: u.nickName || '',
|
||||
userNo: u.userNo || ''
|
||||
}))
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFlowPreview(appId: string) {
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const preview = await fetchOaFlowPreview(
|
||||
appId,
|
||||
needInitiatorSelect.value ? [...initiatorSelectUserIds.value] : undefined
|
||||
)
|
||||
flowSteps.value = preview?.steps || []
|
||||
} catch {
|
||||
flowSteps.value = []
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPick(t: any) {
|
||||
picked.value = t
|
||||
formData.value = {}
|
||||
formRule.value = []
|
||||
needInitiatorSelect.value = false
|
||||
initiatorSelectUserIds.value = []
|
||||
flowSteps.value = []
|
||||
dlgVisible.value = true
|
||||
detailLoading.value = true
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const detail = await fetchOaTemplateDetail(t.id)
|
||||
const schema = detail?.formSchemaJson || {}
|
||||
formRule.value = Array.isArray(schema.rule) ? schema.rule : Array.isArray(schema) ? schema : []
|
||||
formOption.value = {
|
||||
...(schema.option || {}),
|
||||
form: {
|
||||
...(schema.option?.form || {}),
|
||||
labelPosition: 'top'
|
||||
},
|
||||
submitBtn: false,
|
||||
resetBtn: false
|
||||
}
|
||||
const flow = detail?.flowTreeJson || {}
|
||||
const root = flow.root || flow
|
||||
needInitiatorSelect.value = flowNeedsInitiatorSelect(root)
|
||||
if (needInitiatorSelect.value && !userOptions.value.length) {
|
||||
await loadUsers()
|
||||
}
|
||||
await loadFlowPreview(t.id)
|
||||
} catch {
|
||||
formRule.value = []
|
||||
flowSteps.value = []
|
||||
previewLoading.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
initiatorSelectUserIds,
|
||||
() => {
|
||||
if (dlgVisible.value && picked.value?.id && needInitiatorSelect.value) {
|
||||
loadFlowPreview(picked.value.id)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (!picked.value?.id) return
|
||||
if (needInitiatorSelect.value && !initiatorSelectUserIds.value.length) {
|
||||
ElMessage.warning('请选择自选审批人')
|
||||
return
|
||||
}
|
||||
if (fapi.value?.validate) {
|
||||
try {
|
||||
await fapi.value.validate()
|
||||
} catch {
|
||||
ElMessage.warning('请完善表单必填项')
|
||||
return
|
||||
}
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: any = await fetchStartOaInstance({
|
||||
appId: picked.value.id,
|
||||
formData: { ...formData.value },
|
||||
initiatorSelectUserIds: needInitiatorSelect.value
|
||||
? [...initiatorSelectUserIds.value]
|
||||
: undefined,
|
||||
sourceInstanceId: sourceInstanceId.value || undefined
|
||||
})
|
||||
dlgVisible.value = false
|
||||
sourceInstanceId.value = ''
|
||||
if (data?.id) {
|
||||
router.push({ path: `/system-manage/oa-approval/instances/${data.id}` })
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [tpl, groupsRes] = await Promise.all([fetchOaTemplates(), fetchOaGroupList()])
|
||||
templates.value = tpl?.list || []
|
||||
groupOptions.value = groupsRes?.list || []
|
||||
const qAppId = String(route.query.appId || '').trim()
|
||||
const qSource = String(route.query.sourceInstanceId || '').trim()
|
||||
if (qAppId) {
|
||||
const row = templates.value.find((t: any) => t.id === qAppId)
|
||||
if (row) {
|
||||
sourceInstanceId.value = qSource
|
||||
await onPick(row)
|
||||
if (qSource) {
|
||||
try {
|
||||
const old = await fetchOaInstanceDetail(qSource)
|
||||
formData.value = { ...(old?.formDataJson || {}) }
|
||||
} catch {
|
||||
/* 预填失败仍可手工填 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-apps {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.oa-apps__search-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
|
||||
.oa-apps__search {
|
||||
width: min(520px, 100%);
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
height: 42px;
|
||||
padding-left: 16px;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--mint-border, #cbd5e1) inset,
|
||||
0 1px 2px rgb(15 23 42 / 6%);
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--primary, #10b981) inset,
|
||||
0 0 0 3px var(--primary-bg, #ecfdf5);
|
||||
}
|
||||
}
|
||||
|
||||
.oa-apps__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.oa-apps__tab {
|
||||
position: relative;
|
||||
padding: 0 0 12px;
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-dark, #059669);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--mint-text-strong, #0f172a);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
content: '';
|
||||
background: var(--mint-text-strong, #0f172a);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oa-apps__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
|
||||
.oa-apps__toolbar-hint {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.oa-apps__body {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.oa-apps__empty {
|
||||
padding: 48px 16px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oa-apps__section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.oa-apps__section-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-apps__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #f87171;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.oa-apps__section-meta {
|
||||
margin-left: auto;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oa-apps__card-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.oa-app-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 260px;
|
||||
min-height: 100px;
|
||||
padding: 14px 16px 10px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mint-border, #cbd5e1);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #86efac;
|
||||
box-shadow: 0 4px 14px rgb(16 185 129 / 16%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.oa-app-card__main {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oa-app-card__icon {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-app-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oa-app-card__name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--mint-text-strong, #0f172a);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oa-app-card__desc {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oa-app-card__ops {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.oa-app-card__btn {
|
||||
height: 24px !important;
|
||||
padding: 0 8px !important;
|
||||
font-size: 11px !important;
|
||||
border-radius: 5px !important;
|
||||
|
||||
.el-icon {
|
||||
margin-right: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #fff !important;
|
||||
background: var(--primary, #10b981) !important;
|
||||
border-color: var(--primary, #10b981) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-start__tip {
|
||||
margin: 8px 0 0;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.oa-start__meta {
|
||||
margin-bottom: 8px;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.oa-app-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user