feat: ship OA drafts/badges, role dual-track, and test-env deploy tooling.

Add approval instance drafts with menu badges, dual-track user/role read model and role bound names, form-designer outline docs, and bare-metal test deploy scripts. Ignore local test-server credential file.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-06 13:58:47 +08:00
1 parent 7af53fc344
commit de22678c87
56 files changed
+11861 -6082

No files matched your search

+3
View File
@@ -94,3 +94,6 @@ exports/
# Graphify:增量检测/状态文件(commit hook 或 update-graph.sh 触发后产生) # Graphify:增量检测/状态文件(commit hook 或 update-graph.sh 触发后产生)
**/.graphify_detect.json **/.graphify_detect.json
**/.graphify_incremental.json **/.graphify_incremental.json
# 本地密钥(勿提交)
测试环境机器的账号密码
+18 -24
View File
@@ -26,8 +26,11 @@ from app.crud import menu as menu_svc
from app.crud import role as role_svc from app.crud import role as role_svc
from app.crud import user as user_svc from app.crud import user as user_svc
from app.crud.collection_protocol import _lookup_attachment_by_url from app.crud.collection_protocol import _lookup_attachment_by_url
from app.models.models import BizAttachment, SysDept, SysRole, SysUser, SysUserRole from app.models.models import BizAttachment, SysDept, SysRole, SysUser
from app.services.web_auth_roles import _map_role_name_to_web from app.services.web_auth_roles import (
_map_role_name_to_web,
list_user_ids_with_effective_role_name,
)
router = APIRouter(prefix="/web/admin", tags=["运营端管理"]) router = APIRouter(prefix="/web/admin", tags=["运营端管理"])
@@ -77,7 +80,7 @@ async def list_users_for_select(
): ):
"""返回用户下拉选项(label=昵称,value=用户ID),供流程设计器审批人选择使用。 """返回用户下拉选项(label=昵称,value=用户ID),供流程设计器审批人选择使用。
可选 roleName:按 sys_role.role_name 精确过滤拥有该角色的用户。 可选 roleName:按有效角色(直接绑定 ∪ 职位继承)精确过滤。
""" """
tenant_no = _user.get("tenant_no", "") tenant_no = _user.get("tenant_no", "")
q = db.query(SysUser).filter(SysUser.del_flag == 0, SysUser.status == "1") q = db.query(SysUser).filter(SysUser.del_flag == 0, SysUser.status == "1")
@@ -85,18 +88,12 @@ async def list_users_for_select(
q = q.filter(SysUser.tenant_no == tenant_no) q = q.filter(SysUser.tenant_no == tenant_no)
rn = (roleName or "").strip() rn = (roleName or "").strip()
if rn: if rn:
q = ( matched_ids = list_user_ids_with_effective_role_name(
q.join(SysUserRole, SysUserRole.user_id == SysUser.id) db, tenant_no=tenant_no or settings.DEFAULT_TENANT_NO, role_name=rn
.join(SysRole, SysRole.id == SysUserRole.role_id)
.filter(
SysUserRole.del_flag == 0,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.role_name == rn,
)
) )
if tenant_no: if not matched_ids:
q = q.filter(SysUserRole.tenant_no == tenant_no, SysRole.tenant_no == tenant_no) return success_response([])
q = q.filter(SysUser.id.in_(matched_ids))
rows = q.order_by(SysUser.create_time.desc()).distinct().all() rows = q.order_by(SysUser.create_time.desc()).distinct().all()
out = [{"label": (r.nick_name or r.user_no or ""), "value": r.id} for r in rows] out = [{"label": (r.nick_name or r.user_no or ""), "value": r.id} for r in rows]
return success_response(out) return success_response(out)
@@ -317,17 +314,14 @@ async def list_users(
query = query.filter(SysUser.status == status) query = query.filter(SysUser.status == status)
rn = (roleName or "").strip() rn = (roleName or "").strip()
if rn: if rn:
query = ( matched_ids = list_user_ids_with_effective_role_name(
query.join(SysUserRole, SysUserRole.user_id == SysUser.id) db, tenant_no=tenant_no, role_name=rn
.join(SysRole, SysRole.id == SysUserRole.role_id)
.filter(
SysUserRole.del_flag == 0,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.role_name == rn,
)
) )
query = query.filter(SysUserRole.tenant_no == tenant_no, SysRole.tenant_no == tenant_no) if not matched_ids:
return success_response(
{"list": [], "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0}
)
query = query.filter(SysUser.id.in_(matched_ids))
query = apply_data_scope(query, SysUser, user, db, tenant_no) query = apply_data_scope(query, SysUser, user, db, tenant_no)
total = query.distinct().count() total = query.distinct().count()
items = ( items = (
+272
View File
@@ -1,4 +1,5 @@
"""OA 审批管理接口(钉钉交互)""" """OA 审批管理接口(钉钉交互)"""
from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@@ -8,7 +9,9 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response from app.core.response import error_response, success_response
from app.core.utils import parse_datetime
from app.crud import oa_approval as oa_crud from app.crud import oa_approval as oa_crud
from app.crud import oa_instance_draft as oid_crud
from app.crud import oa_receive_account as ora_crud from app.crud import oa_receive_account as ora_crud
from app.models.models import SysUser from app.models.models import SysUser
from app.services.oa_receive_account import normalize_account_fields from app.services.oa_receive_account import normalize_account_fields
@@ -164,6 +167,258 @@ async def delete_receive_account(
return success_response(None, message="删除成功") return success_response(None, message="删除成功")
# ---------------------------------------------------------------------------
# 发起审批草稿箱(oa_instance_draft)
# ---------------------------------------------------------------------------
class OaInstanceDraftBody(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_by_node: Optional[Dict[str, List[str]]] = Field(
None, alias="initiatorSelectByNode"
)
class OaInstanceDraftUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
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_by_node: Optional[Dict[str, List[str]]] = Field(
None, alias="initiatorSelectByNode"
)
class OaInstanceDraftBatchDeleteBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
ids: List[str] = Field(default_factory=list)
class OaInstanceDraftSubmitBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
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_by_node: Optional[Dict[str, List[str]]] = Field(
None, alias="initiatorSelectByNode"
)
def _parse_draft_range_end(raw: Optional[str]) -> Optional[datetime]:
"""截止日期:仅日期时扩到当天 23:59:59。"""
if not raw or not str(raw).strip():
return None
s = str(raw).strip()
dt = parse_datetime(s)
if dt is None and len(s) == 10:
try:
dt = datetime.strptime(s, "%Y-%m-%d")
except ValueError:
return None
if dt is None:
return None
if len(s) <= 10:
return dt.replace(hour=23, minute=59, second=59)
return dt
def _parse_draft_range_start(raw: Optional[str]) -> Optional[datetime]:
if not raw or not str(raw).strip():
return None
s = str(raw).strip()
dt = parse_datetime(s)
if dt is None and len(s) == 10:
try:
return datetime.strptime(s, "%Y-%m-%d")
except ValueError:
return None
return dt
@router.get("/drafts/count")
async def oa_instance_draft_count(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
n = oid_crud.count_drafts(
db,
tenant_no=tenant_no,
owner_user_id=user.get("user_id") or "",
)
return success_response({"count": int(n)})
@router.get("/drafts")
async def list_oa_instance_drafts(
page: int = Query(1, ge=1),
pageSize: int = Query(20, ge=1, le=100),
keyword: Optional[str] = None,
groupId: Optional[str] = None,
updateTimeFrom: Optional[str] = None,
updateTimeTo: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = oid_crud.list_drafts(
db,
tenant_no=tenant_no,
owner_user_id=user.get("user_id") or "",
keyword=keyword,
group_id=groupId,
update_time_from=_parse_draft_range_start(updateTimeFrom),
update_time_to=_parse_draft_range_end(updateTimeTo),
page=page,
page_size=pageSize,
)
return success_response({"list": items, "total": total})
@router.post("/drafts")
async def create_oa_instance_draft(
body: OaInstanceDraftBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
data = oid_crud.create_draft(
db,
tenant_no=tenant_no,
owner_user_id=user.get("user_id") or "",
app_id=body.app_id,
form_data=body.form_data,
initiator_select_by_node=body.initiator_select_by_node,
initiator_dept_id=body.initiator_dept_id,
title=body.title,
create_dept_id=user.get("dept_id") or "",
)
return success_response(data, message="已存入草稿箱")
except ValueError as e:
return error_response(400, str(e))
@router.post("/drafts/batch-delete")
async def batch_delete_oa_instance_drafts(
body: OaInstanceDraftBatchDeleteBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
n = oid_crud.batch_soft_delete_drafts(
db,
body.ids,
tenant_no=tenant_no,
owner_user_id=user.get("user_id") or "",
)
return success_response({"deleted": n}, message="删除成功")
except oid_crud.DraftAccessError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/drafts/{draft_id}")
async def get_oa_instance_draft(
draft_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
data = oid_crud.get_draft_detail(
db,
draft_id,
tenant_no=tenant_no,
owner_user_id=user.get("user_id") or "",
)
return success_response(data)
except oid_crud.DraftAccessError as e:
return error_response(404, str(e))
@router.put("/drafts/{draft_id}")
async def update_oa_instance_draft(
draft_id: str,
body: OaInstanceDraftUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
data = oid_crud.update_draft(
db,
draft_id,
tenant_no=tenant_no,
owner_user_id=user.get("user_id") or "",
form_data=body.form_data,
initiator_select_by_node=body.initiator_select_by_node,
initiator_dept_id=body.initiator_dept_id,
title=body.title,
)
return success_response(data, message="草稿已保存")
except oid_crud.DraftAccessError as e:
return error_response(404, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.delete("/drafts/{draft_id}")
async def delete_oa_instance_draft(
draft_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
oid_crud.soft_delete_draft(
db,
draft_id,
tenant_no=tenant_no,
owner_user_id=user.get("user_id") or "",
)
return success_response(None, message="删除成功")
except oid_crud.DraftAccessError as e:
return error_response(404, str(e))
@router.post("/drafts/{draft_id}/submit")
async def submit_oa_instance_draft(
draft_id: str,
body: Optional[OaInstanceDraftSubmitBody] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services.oa_engine import OaEngineError
payload = body or OaInstanceDraftSubmitBody()
try:
tenant_no = _get_tenant_no(db, user)
data = oid_crud.submit_draft(
db,
draft_id,
tenant_no=tenant_no,
owner_user_id=user.get("user_id") or "",
form_data=payload.form_data,
initiator_select_by_node=payload.initiator_select_by_node,
initiator_dept_id=payload.initiator_dept_id,
title=payload.title,
)
return success_response(data, message="发起成功")
except oid_crud.DraftAccessError as e:
return error_response(404, str(e))
except OaEngineError as e:
return error_response(400, str(e))
except ValueError as e:
return error_response(400, str(e))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 分组 # 分组
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -700,6 +955,23 @@ async def get_related_summaries(
return success_response({"list": items}) return success_response({"list": items})
@router.get("/tasks/pending/count")
async def oa_pending_task_count(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services.oa_engine import OaEngineError, count_pending_tasks
try:
tenant_no = _get_tenant_no(db, user)
n = count_pending_tasks(
db, tenant_no=tenant_no, user_id=user.get("user_id") or ""
)
return success_response({"count": int(n)})
except OaEngineError as e:
return error_response(400, str(e))
@router.get("/tasks/cc/unread-count") @router.get("/tasks/cc/unread-count")
async def oa_cc_unread_count( async def oa_cc_unread_count(
user: dict = Depends(require_admin_or_super_admin), user: dict = Depends(require_admin_or_super_admin),
+368
View File
@@ -0,0 +1,368 @@
"""OA 发起审批草稿 CRUD(oa_instance_draft)"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import func, or_
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, OaAppGroup, OaAppVersion, OaInstanceDraft
def _loads(raw: Any, default: Any) -> Any:
if raw is None or raw == "":
return default
if isinstance(raw, (dict, list)):
return raw
try:
return json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return default
def _dumps(obj: Any) -> Optional[str]:
if obj is None:
return None
return json.dumps(obj, ensure_ascii=False)
def row_to_dict(
d: OaInstanceDraft,
*,
app_name: str = "",
version_no: Optional[int] = None,
group_id: str = "",
group_name: str = "",
include_payload: bool = False,
) -> Dict[str, Any]:
out: Dict[str, Any] = {
"id": d.id,
"appId": d.app_id,
"appVersionId": d.app_version_id,
"appName": app_name or "",
"versionNo": version_no,
"groupId": group_id or "",
"groupName": group_name or "",
"title": d.title or "",
"ownerUserId": d.owner_user_id,
"initiatorDeptId": d.initiator_dept_id or "",
"status": "draft",
"createTime": d.create_time.strftime("%Y-%m-%d %H:%M:%S") if d.create_time else "",
"updateTime": d.update_time.strftime("%Y-%m-%d %H:%M:%S") if d.update_time else "",
}
if include_payload:
out["formData"] = _loads(d.form_data_json, {}) or {}
out["initiatorSelectByNode"] = _loads(d.initiator_select_json, {}) or {}
return out
class DraftAccessError(ValueError):
"""草稿不存在或无权访问"""
def get_owned_draft(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
) -> OaInstanceDraft:
row = (
db.query(OaInstanceDraft)
.filter(
OaInstanceDraft.id == draft_id,
OaInstanceDraft.tenant_no == tenant_no,
OaInstanceDraft.owner_user_id == owner_user_id,
OaInstanceDraft.del_flag == 0,
)
.first()
)
if not row:
raise DraftAccessError("草稿不存在或无权访问")
return row
def _enrich(db: Session, d: OaInstanceDraft, *, include_payload: bool = False) -> Dict[str, Any]:
app = (
db.query(OaApp)
.filter(OaApp.id == d.app_id, OaApp.tenant_no == d.tenant_no, OaApp.del_flag == 0)
.first()
)
ver = (
db.query(OaAppVersion)
.filter(OaAppVersion.id == d.app_version_id, OaAppVersion.del_flag == 0)
.first()
)
group_name = ""
group_id = ""
if app and app.group_id:
group_id = app.group_id
g = (
db.query(OaAppGroup)
.filter(OaAppGroup.id == app.group_id, OaAppGroup.del_flag == 0)
.first()
)
if g:
group_name = g.group_name or ""
return row_to_dict(
d,
app_name=(app.app_name if app else "") or "",
version_no=ver.version_no if ver else None,
group_id=group_id,
group_name=group_name,
include_payload=include_payload,
)
def count_drafts(db: Session, *, tenant_no: str, owner_user_id: str) -> int:
return (
db.query(OaInstanceDraft)
.filter(
OaInstanceDraft.tenant_no == tenant_no,
OaInstanceDraft.owner_user_id == owner_user_id,
OaInstanceDraft.del_flag == 0,
)
.count()
)
def list_drafts(
db: Session,
*,
tenant_no: str,
owner_user_id: str,
keyword: Optional[str] = None,
group_id: Optional[str] = None,
update_time_from: Optional[datetime] = None,
update_time_to: Optional[datetime] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[Dict[str, Any]], int]:
q = (
db.query(OaInstanceDraft, OaApp, OaAppVersion, OaAppGroup)
.outerjoin(
OaApp,
(OaApp.id == OaInstanceDraft.app_id)
& (OaApp.tenant_no == OaInstanceDraft.tenant_no)
& (OaApp.del_flag == 0),
)
.outerjoin(
OaAppVersion,
(OaAppVersion.id == OaInstanceDraft.app_version_id) & (OaAppVersion.del_flag == 0),
)
.outerjoin(
OaAppGroup,
(OaAppGroup.id == OaApp.group_id) & (OaAppGroup.del_flag == 0),
)
.filter(
OaInstanceDraft.tenant_no == tenant_no,
OaInstanceDraft.owner_user_id == owner_user_id,
OaInstanceDraft.del_flag == 0,
)
)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(or_(OaApp.app_name.like(like), OaInstanceDraft.title.like(like)))
gid = (group_id or "").strip()
if gid:
q = q.filter(OaApp.group_id == gid)
if update_time_from is not None:
q = q.filter(OaInstanceDraft.update_time >= update_time_from)
if update_time_to is not None:
q = q.filter(OaInstanceDraft.update_time <= update_time_to)
total = q.order_by(None).with_entities(func.count(OaInstanceDraft.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
q.order_by(OaInstanceDraft.update_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
items: List[Dict[str, Any]] = []
for d, app, ver, group in rows:
items.append(
row_to_dict(
d,
app_name=(app.app_name if app else "") or "",
version_no=ver.version_no if ver else None,
group_id=(app.group_id if app else "") or "",
group_name=(group.group_name if group else "") or "",
)
)
return items, int(total)
def get_draft_detail(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
) -> Dict[str, Any]:
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
return _enrich(db, d, include_payload=True)
def create_draft(
db: Session,
*,
tenant_no: str,
owner_user_id: str,
app_id: str,
form_data: Optional[Dict[str, Any]] = None,
initiator_select_by_node: Optional[Dict[str, Any]] = None,
initiator_dept_id: Optional[str] = None,
title: Optional[str] = None,
create_dept_id: str = "",
) -> Dict[str, Any]:
app = oa_crud.get_app(db, app_id, tenant_no)
if not app or app.status != "1":
raise ValueError("应用不存在或已停用")
ver = oa_crud._find_latest_published(db, app_id, tenant_no)
if not ver:
raise ValueError("应用尚未发布,无法暂存草稿")
now = datetime.now()
row = OaInstanceDraft(
id=generate_uuid(),
app_id=app_id,
app_version_id=ver.id,
title=(title or app.app_name or "")[:200] or None,
form_data_json=_dumps(form_data or {}),
initiator_select_json=_dumps(initiator_select_by_node or {}),
initiator_dept_id=(initiator_dept_id or "").strip() or None,
owner_user_id=owner_user_id,
tenant_no=tenant_no,
create_by=owner_user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=owner_user_id,
update_time=now,
del_flag=0,
)
db.add(row)
db.commit()
db.refresh(row)
return _enrich(db, row, include_payload=True)
def update_draft(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
form_data: Optional[Dict[str, Any]] = None,
initiator_select_by_node: Optional[Dict[str, Any]] = None,
initiator_dept_id: Optional[str] = None,
title: Optional[str] = None,
) -> Dict[str, Any]:
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
ver = oa_crud._find_latest_published(db, d.app_id, tenant_no)
if ver:
d.app_version_id = ver.id
if form_data is not None:
d.form_data_json = _dumps(form_data)
if initiator_select_by_node is not None:
d.initiator_select_json = _dumps(initiator_select_by_node)
if initiator_dept_id is not None:
d.initiator_dept_id = (initiator_dept_id or "").strip() or None
if title is not None:
d.title = (title or "")[:200] or None
d.update_by = owner_user_id
d.update_time = datetime.now()
db.commit()
db.refresh(d)
return _enrich(db, d, include_payload=True)
def soft_delete_draft(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
) -> None:
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
d.del_flag = 1
d.update_by = owner_user_id
d.update_time = datetime.now()
db.commit()
def batch_soft_delete_drafts(
db: Session,
ids: List[str],
*,
tenant_no: str,
owner_user_id: str,
) -> int:
cleaned = [str(x).strip() for x in (ids or []) if str(x).strip()]
if not cleaned:
raise ValueError("请选择要删除的草稿")
rows = (
db.query(OaInstanceDraft)
.filter(
OaInstanceDraft.id.in_(cleaned),
OaInstanceDraft.tenant_no == tenant_no,
OaInstanceDraft.owner_user_id == owner_user_id,
OaInstanceDraft.del_flag == 0,
)
.all()
)
if len(rows) != len(set(cleaned)):
raise DraftAccessError("草稿不存在或无权访问")
now = datetime.now()
for d in rows:
d.del_flag = 1
d.update_by = owner_user_id
d.update_time = now
db.commit()
return len(rows)
def submit_draft(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
form_data: Optional[Dict[str, Any]] = None,
initiator_select_by_node: Optional[Dict[str, Any]] = None,
initiator_dept_id: Optional[str] = None,
title: Optional[str] = None,
) -> Dict[str, Any]:
"""提交草稿:走 start_instance,成功后软删草稿。"""
from app.services.oa_engine import start_instance
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
payload_form = form_data if form_data is not None else (_loads(d.form_data_json, {}) or {})
payload_select = (
initiator_select_by_node
if initiator_select_by_node is not None
else (_loads(d.initiator_select_json, {}) or {})
)
dept = initiator_dept_id if initiator_dept_id is not None else d.initiator_dept_id
use_title = title if title is not None else d.title
data = start_instance(
db,
tenant_no=tenant_no,
app_id=d.app_id,
initiator_user_id=owner_user_id,
initiator_dept_id=dept,
form_data=payload_form if isinstance(payload_form, dict) else {},
title=use_title,
initiator_select_by_node=payload_select if isinstance(payload_select, dict) else {},
)
d.del_flag = 1
d.update_by = owner_user_id
d.update_time = datetime.now()
db.commit()
return data
@@ -8,7 +8,8 @@ from sqlalchemy.orm import Session
from app.core.db_collation import eq_unicode from app.core.db_collation import eq_unicode
from app.core.utils import generate_uuid from app.core.utils import generate_uuid
from app.models.models import BizProductionTaskStep, BizProductionTaskStepAssignee, SysRole, SysUser, SysUserRole from app.models.models import BizProductionTaskStep, BizProductionTaskStepAssignee, SysUser
from app.services.web_auth_roles import list_user_ids_with_effective_role_name
PRODUCTION_STEP_ASSIGNEE_ROLE = "实验专员" PRODUCTION_STEP_ASSIGNEE_ROLE = "实验专员"
@@ -35,30 +36,17 @@ def _active_users_in_tenant(
def _active_users_with_role_in_tenant( def _active_users_with_role_in_tenant(
db: Session, user_ids: List[str], tenant_no: str, role_name: str db: Session, user_ids: List[str], tenant_no: str, role_name: str
) -> Set[str]: ) -> Set[str]:
"""校验候选用户是否拥有指定角色(直接绑定 ∪ 职位继承)。"""
ids = [x.strip() for x in user_ids if (x or "").strip()] ids = [x.strip() for x in user_ids if (x or "").strip()]
rn = (role_name or "").strip() rn = (role_name or "").strip()
if not ids or not rn: if not ids or not rn:
return set() return set()
rows = ( return list_user_ids_with_effective_role_name(
db.query(SysUser.id) db,
.join(SysUserRole, SysUserRole.user_id == SysUser.id) tenant_no=tenant_no,
.join(SysRole, SysRole.id == SysUserRole.role_id) role_name=rn,
.filter( candidate_user_ids=ids,
SysUser.id.in_(ids),
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
SysUser.status == "1",
SysUserRole.del_flag == 0,
SysUserRole.tenant_no == tenant_no,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant_no,
SysRole.role_name == rn,
)
.distinct()
.all()
) )
return {r[0] for r in rows}
def validate_assignee_user_ids( def validate_assignee_user_ids(
+170 -4
View File
@@ -5,11 +5,20 @@ import math
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_ from sqlalchemy import and_, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.utils import generate_uuid from app.core.utils import generate_uuid
from app.models.models import SysRole, SysRoleMenu, SysRoleDataScopeDetail from app.models.models import (
SysPost,
SysPostRole,
SysRole,
SysRoleDataScopeDetail,
SysRoleMenu,
SysUser,
SysUserPost,
SysUserRole,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -17,7 +26,151 @@ from app.models.models import SysRole, SysRoleMenu, SysRoleDataScopeDetail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _row_to_dict(r: SysRole) -> Dict[str, Any]: def _user_display_name(nick_name: Optional[str], user_no: Optional[str]) -> str:
n = (nick_name or "").strip()
if n:
return n
return (user_no or "").strip()
def _append_unique(out: Dict[str, List[str]], key: str, value: str) -> None:
if not key or not value:
return
bucket = out.setdefault(key, [])
if value not in bucket:
bucket.append(value)
def _bound_user_names_by_role_ids(
db: Session, role_ids: List[str], tenant_no: str
) -> Dict[str, List[str]]:
"""批量查询有效拥有角色的用户显示名(直接绑定 ∪ 启用职位继承)。"""
out: Dict[str, List[str]] = {}
ids = [rid for rid in (role_ids or []) if rid]
tenant = (tenant_no or "").strip()
if not ids or not tenant:
return out
direct_rows = (
db.query(SysUserRole.role_id, SysUser.nick_name, SysUser.user_no)
.join(
SysUser,
and_(SysUser.id == SysUserRole.user_id, SysUser.del_flag == 0),
)
.join(
SysRole,
and_(
SysRole.id == SysUserRole.role_id,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
),
)
.filter(
and_(
SysUserRole.role_id.in_(ids),
SysUserRole.tenant_no == tenant,
SysUserRole.del_flag == 0,
)
)
.order_by(SysUser.nick_name.asc(), SysUser.user_no.asc())
.all()
)
for role_id, nick_name, user_no in direct_rows:
_append_unique(out, role_id, _user_display_name(nick_name, user_no))
post_rows = (
db.query(SysPostRole.role_id, SysUser.nick_name, SysUser.user_no)
.join(
SysPost,
and_(
SysPost.id == SysPostRole.post_id,
SysPost.del_flag == 0,
SysPost.status == "1",
SysPost.tenant_no == tenant,
),
)
.join(
SysUserPost,
and_(
SysUserPost.post_id == SysPost.id,
SysUserPost.del_flag == 0,
SysUserPost.tenant_no == tenant,
),
)
.join(
SysUser,
and_(SysUser.id == SysUserPost.user_id, SysUser.del_flag == 0),
)
.join(
SysRole,
and_(
SysRole.id == SysPostRole.role_id,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
),
)
.filter(
and_(
SysPostRole.role_id.in_(ids),
SysPostRole.tenant_no == tenant,
SysPostRole.del_flag == 0,
)
)
.order_by(SysUser.nick_name.asc(), SysUser.user_no.asc())
.all()
)
for role_id, nick_name, user_no in post_rows:
_append_unique(out, role_id, _user_display_name(nick_name, user_no))
return out
def _bound_post_names_by_role_ids(
db: Session, role_ids: List[str], tenant_no: str
) -> Dict[str, List[str]]:
"""批量查询角色关联的启用职位名称。"""
out: Dict[str, List[str]] = {}
ids = [rid for rid in (role_ids or []) if rid]
tenant = (tenant_no or "").strip()
if not ids or not tenant:
return out
rows = (
db.query(SysPostRole.role_id, SysPost.post_name)
.join(
SysPost,
and_(
SysPost.id == SysPostRole.post_id,
SysPost.del_flag == 0,
SysPost.status == "1",
SysPost.tenant_no == tenant,
),
)
.filter(
and_(
SysPostRole.role_id.in_(ids),
SysPostRole.tenant_no == tenant,
SysPostRole.del_flag == 0,
)
)
.order_by(SysPost.post_name.asc())
.all()
)
for role_id, post_name in rows:
name = (post_name or "").strip()
_append_unique(out, role_id, name)
return out
def _row_to_dict(
r: SysRole,
*,
bound_user_names: Optional[List[str]] = None,
bound_post_names: Optional[List[str]] = None,
) -> Dict[str, Any]:
return { return {
"id": r.id, "id": r.id,
"roleName": r.role_name, "roleName": r.role_name,
@@ -26,6 +179,8 @@ def _row_to_dict(r: SysRole) -> Dict[str, Any]:
"tenantNo": r.tenant_no or "", "tenantNo": r.tenant_no or "",
"orderNum": r.order_num if r.order_num is not None else 0, "orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "", "remark": r.remark or "",
"boundUserNames": list(bound_user_names or []),
"boundPostNames": list(bound_post_names or []),
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "", "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 "", "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
} }
@@ -74,7 +229,18 @@ def list_roles(
.limit(page_size) .limit(page_size)
.all() .all()
) )
return [_row_to_dict(x) for x in rows], int(total) role_ids = [x.id for x in rows]
tenant = tenant_no or ""
user_name_map = _bound_user_names_by_role_ids(db, role_ids, tenant)
post_name_map = _bound_post_names_by_role_ids(db, role_ids, tenant)
return [
_row_to_dict(
x,
bound_user_names=user_name_map.get(x.id, []),
bound_post_names=post_name_map.get(x.id, []),
)
for x in rows
], int(total)
def get_role(db: Session, role_id: str) -> Optional[SysRole]: def get_role(db: Session, role_id: str) -> Optional[SysRole]:
+24
View File
@@ -849,6 +849,30 @@ class OaReceiveAccount(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class OaInstanceDraft(Base):
"""OA发起审批草稿 oa_instance_draft(非模板设计草稿)"""
__tablename__ = "oa_instance_draft"
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="标题")
form_data_json = Column(Text, nullable=True, comment="表单数据 JSON")
initiator_select_json = Column(Text, nullable=True, comment="发起人自选 JSON")
initiator_dept_id = Column(String(64), nullable=True, comment="发起人部门")
owner_user_id = Column(String(64), 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 OaInstance(Base): class OaInstance(Base):
"""OA审批实例 oa_instance""" """OA审批实例 oa_instance"""
+14
View File
@@ -1975,6 +1975,20 @@ def count_cc_unread(db: Session, *, tenant_no: str, user_id: str) -> int:
) )
def count_pending_tasks(db: Session, *, tenant_no: str, user_id: str) -> int:
return (
db.query(OaTask)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.assignee_user_id == user_id,
OaTask.task_type == "approval",
OaTask.status == "pending",
)
.count()
)
def mark_cc_task_read( def mark_cc_task_read(
db: Session, *, tenant_no: str, user_id: str, task_id: str db: Session, *, tenant_no: str, user_id: str, task_id: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
+216 -20
View File
@@ -4,7 +4,7 @@
""" """
from __future__ import annotations from __future__ import annotations
from typing import List, Set from typing import List, Optional, Set, Collection
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -39,13 +39,23 @@ def _map_role_name_to_web(role_name: str) -> str:
return "admin" return "admin"
def get_effective_role_ids(db: Session, user_id: str) -> List[str]: def _dedupe_role_id_rows(rows) -> List[str]:
"""用户有效角色 ID:直接绑定 ∪ 启用职位关联的启用角色(去重保序)。""" out: List[str] = []
seen: Set[str] = set()
for row in rows:
rid = str(row[0]).strip() if row and row[0] else ""
if rid and rid not in seen:
seen.add(rid)
out.append(rid)
return out
def get_direct_enabled_role_ids(db: Session, user_id: str) -> List[str]:
"""用户直接绑定且启用的角色 ID(sys_user_role → sys_role)。"""
if not user_id: if not user_id:
return [] return []
tenant = settings.DEFAULT_TENANT_NO tenant = settings.DEFAULT_TENANT_NO
rows = (
direct_rows = (
db.query(SysRole.id) db.query(SysRole.id)
.join(SysUserRole, SysUserRole.role_id == SysRole.id) .join(SysUserRole, SysUserRole.role_id == SysRole.id)
.filter( .filter(
@@ -59,8 +69,15 @@ def get_effective_role_ids(db: Session, user_id: str) -> List[str]:
.order_by(SysRole.create_time.desc()) .order_by(SysRole.create_time.desc())
.all() .all()
) )
return _dedupe_role_id_rows(rows)
post_rows = (
def get_post_inherited_role_ids(db: Session, user_id: str) -> List[str]:
"""经启用职位继承的启用角色 ID(sys_user_post → sys_post → sys_post_role → sys_role)。"""
if not user_id:
return []
tenant = settings.DEFAULT_TENANT_NO
rows = (
db.query(SysRole.id) db.query(SysRole.id)
.join(SysPostRole, SysPostRole.role_id == SysRole.id) .join(SysPostRole, SysPostRole.role_id == SysRole.id)
.join( .join(
@@ -91,35 +108,214 @@ def get_effective_role_ids(db: Session, user_id: str) -> List[str]:
.order_by(SysRole.create_time.desc()) .order_by(SysRole.create_time.desc())
.all() .all()
) )
return _dedupe_role_id_rows(rows)
def get_effective_role_ids(db: Session, user_id: str) -> List[str]:
"""用户有效角色 ID:直接绑定 ∪ 启用职位关联的启用角色(去重保序)。"""
if not user_id:
return []
out: List[str] = [] out: List[str] = []
seen: Set[str] = set() seen: Set[str] = set()
for row in list(direct_rows) + list(post_rows): for rid in get_direct_enabled_role_ids(db, user_id) + get_post_inherited_role_ids(
rid = str(row[0]).strip() if row and row[0] else "" db, user_id
if rid and rid not in seen: ):
if rid not in seen:
seen.add(rid) seen.add(rid)
out.append(rid) out.append(rid)
return out return out
def _active_role_name_rows(db: Session, user_id: str) -> list: def _normalize_candidate_user_ids(
"""查询用户当前有效的 sys_role.role_name 列表(含职位继承)。""" candidate_user_ids: Optional[Collection[str]],
role_ids = get_effective_role_ids(db, user_id) ) -> Optional[List[str]]:
if not role_ids: if candidate_user_ids is None:
return [] return None
tenant = settings.DEFAULT_TENANT_NO return list(dict.fromkeys([str(u).strip() for u in candidate_user_ids if u and str(u).strip()]))
rows = (
db.query(SysRole.role_name)
def list_user_ids_with_effective_role_name(
db: Session,
*,
tenant_no: str,
role_name: str,
candidate_user_ids: Optional[Collection[str]] = None,
) -> Set[str]:
"""拥有指定角色名的用户 ID(直接绑定 ∪ 职位继承)。"""
tenant = (tenant_no or "").strip()
rn = (role_name or "").strip()
if not tenant or not rn:
return set()
candidates = _normalize_candidate_user_ids(candidate_user_ids)
if candidates is not None and not candidates:
return set()
direct_q = (
db.query(SysUserRole.user_id)
.join(SysRole, SysRole.id == SysUserRole.role_id)
.filter( .filter(
SysRole.id.in_(role_ids), SysUserRole.del_flag == 0,
SysUserRole.tenant_no == tenant,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
SysRole.role_name == rn,
)
)
post_q = (
db.query(SysUserPost.user_id)
.join(
SysPost,
and_(
SysPost.id == SysUserPost.post_id,
SysPost.del_flag == 0,
SysPost.status == "1",
SysPost.tenant_no == tenant,
),
)
.join(
SysPostRole,
and_(
SysPostRole.post_id == SysPost.id,
SysPostRole.del_flag == 0,
SysPostRole.tenant_no == tenant,
),
)
.join(
SysRole,
and_(
SysRole.id == SysPostRole.role_id,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
SysRole.role_name == rn,
),
)
.filter(SysUserPost.del_flag == 0, SysUserPost.tenant_no == tenant)
)
if candidates is not None:
direct_q = direct_q.filter(SysUserRole.user_id.in_(candidates))
post_q = post_q.filter(SysUserPost.user_id.in_(candidates))
out: Set[str] = set()
for row in direct_q.all():
uid = str(row[0]).strip() if row and row[0] else ""
if uid:
out.add(uid)
for row in post_q.all():
uid = str(row[0]).strip() if row and row[0] else ""
if uid:
out.add(uid)
return out
def list_user_ids_with_any_effective_role_ids(
db: Session,
*,
tenant_no: str,
role_ids: List[str],
candidate_user_ids: Optional[Collection[str]] = None,
) -> List[str]:
"""拥有任一指定角色 ID 的用户 ID 列表(直接绑定 ∪ 职位继承,去重)。"""
tenant = (tenant_no or "").strip()
rids = list(dict.fromkeys([str(r).strip() for r in (role_ids or []) if r and str(r).strip()]))
if not tenant or not rids:
return []
candidates = _normalize_candidate_user_ids(candidate_user_ids)
if candidates is not None and not candidates:
return []
direct_q = (
db.query(SysUserRole.user_id)
.join(SysRole, SysRole.id == SysUserRole.role_id)
.filter(
SysUserRole.del_flag == 0,
SysUserRole.tenant_no == tenant,
SysUserRole.role_id.in_(rids),
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
)
)
post_q = (
db.query(SysUserPost.user_id)
.join(
SysPost,
and_(
SysPost.id == SysUserPost.post_id,
SysPost.del_flag == 0,
SysPost.status == "1",
SysPost.tenant_no == tenant,
),
)
.join(
SysPostRole,
and_(
SysPostRole.post_id == SysPost.id,
SysPostRole.del_flag == 0,
SysPostRole.tenant_no == tenant,
SysPostRole.role_id.in_(rids),
),
)
.join(
SysRole,
and_(
SysRole.id == SysPostRole.role_id,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
),
)
.filter(SysUserPost.del_flag == 0, SysUserPost.tenant_no == tenant)
)
if candidates is not None:
direct_q = direct_q.filter(SysUserRole.user_id.in_(candidates))
post_q = post_q.filter(SysUserPost.user_id.in_(candidates))
out: List[str] = []
seen: Set[str] = set()
for row in list(direct_q.all()) + list(post_q.all()):
uid = str(row[0]).strip() if row and row[0] else ""
if uid and uid not in seen:
seen.add(uid)
out.append(uid)
return out
def get_role_names_by_ids(db: Session, role_ids: List[str]) -> List[str]:
"""按给定角色 ID 返回启用角色名称(保序、去重)。"""
ordered = list(dict.fromkeys([str(r).strip() for r in (role_ids or []) if r]))
if not ordered:
return []
tenant = settings.DEFAULT_TENANT_NO
rows = (
db.query(SysRole.id, SysRole.role_name)
.filter(
SysRole.id.in_(ordered),
SysRole.del_flag == 0, SysRole.del_flag == 0,
SysRole.status == "1", SysRole.status == "1",
SysRole.tenant_no == tenant, SysRole.tenant_no == tenant,
) )
.order_by(SysRole.create_time.desc())
.all() .all()
) )
return [str(r[0]).strip() for r in rows if r and r[0]] name_by_id = {
str(rid).strip(): str(name).strip()
for rid, name in rows
if rid and name
}
out: List[str] = []
seen: Set[str] = set()
for rid in ordered:
name = name_by_id.get(rid)
if name and name not in seen:
seen.add(name)
out.append(name)
return out
def _active_role_name_rows(db: Session, user_id: str) -> list:
"""查询用户当前有效的 sys_role.role_name 列表(含职位继承)。"""
return get_role_names_by_ids(db, get_effective_role_ids(db, user_id))
def user_has_active_roles(db: Session, user_id: str) -> bool: def user_has_active_roles(db: Session, user_id: str) -> bool:
+8 -3
View File
@@ -13,12 +13,12 @@ from sqlalchemy.orm import Session
from app.crud import workflow as wf_crud from app.crud import workflow as wf_crud
from app.models.models import ( from app.models.models import (
SysUser, SysUser,
SysUserRole,
WfNode, WfNode,
WfProcessInstance, WfProcessInstance,
WfTask, WfTask,
WfTransition, WfTransition,
) )
from app.services.web_auth_roles import list_user_ids_with_any_effective_role_ids
from app.services.workflow_callback import trigger_callback from app.services.workflow_callback import trigger_callback
logger = logging.getLogger("sqxp.workflow_engine") logger = logging.getLogger("sqxp.workflow_engine")
@@ -250,11 +250,16 @@ class WorkflowEngine:
role_ids = config.get("roleIds", []) role_ids = config.get("roleIds", [])
if not role_ids: if not role_ids:
return [] return []
user_ids = list_user_ids_with_any_effective_role_ids(
self.db, tenant_no=tenant_no, role_ids=role_ids
)
if not user_ids:
return []
rows = ( rows = (
self.db.query(SysUser.id) self.db.query(SysUser.id)
.join(SysUserRole, SysUserRole.user_id == SysUser.id)
.filter( .filter(
SysUserRole.role_id.in_(role_ids), SysUser.id.in_(user_ids),
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0, SysUser.del_flag == 0,
SysUser.status == "1", SysUser.status == "1",
) )
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+85 -100
View File
@@ -1,11 +1,11 @@
# Graph Report - app (2026-08-06) # Graph Report - app (2026-08-06)
## Corpus Check ## Corpus Check
- Large corpus: 242 files · ~296,606 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only. - Large corpus: 242 files · ~296,735 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
## Summary ## Summary
- 5114 nodes · 27947 edges · 42 communities detected - 5118 nodes · 27963 edges · 39 communities detected
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 19091 edges (avg confidence: 0.6) - Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 19101 edges (avg confidence: 0.6)
- Token cost: 0 input · 0 output - Token cost: 0 input · 0 output
## Community Hubs (Navigation) ## Community Hubs (Navigation)
@@ -42,19 +42,16 @@
- [[_COMMUNITY_Community 30|Community 30]] - [[_COMMUNITY_Community 30|Community 30]]
- [[_COMMUNITY_Community 31|Community 31]] - [[_COMMUNITY_Community 31|Community 31]]
- [[_COMMUNITY_Community 32|Community 32]] - [[_COMMUNITY_Community 32|Community 32]]
- [[_COMMUNITY_Community 33|Community 33]]
- [[_COMMUNITY_Community 34|Community 34]]
- [[_COMMUNITY_Community 35|Community 35]] - [[_COMMUNITY_Community 35|Community 35]]
- [[_COMMUNITY_Community 36|Community 36]]
- [[_COMMUNITY_Community 37|Community 37]]
- [[_COMMUNITY_Community 38|Community 38]] - [[_COMMUNITY_Community 38|Community 38]]
- [[_COMMUNITY_Community 39|Community 39]] - [[_COMMUNITY_Community 39|Community 39]]
- [[_COMMUNITY_Community 40|Community 40]] - [[_COMMUNITY_Community 40|Community 40]]
- [[_COMMUNITY_Community 41|Community 41]]
- [[_COMMUNITY_Community 42|Community 42]]
- [[_COMMUNITY_Community 43|Community 43]]
## God Nodes (most connected - your core abstractions) ## God Nodes (most connected - your core abstractions)
1. `success_response()` - 730 edges 1. `success_response()` - 732 edges
2. `error_response()` - 604 edges 2. `error_response()` - 605 edges
3. `SysUser` - 591 edges 3. `SysUser` - 591 edges
4. `BizQcTaskResult` - 345 edges 4. `BizQcTaskResult` - 345 edges
5. `BizProductionMaterial` - 329 edges 5. `BizProductionMaterial` - 329 edges
@@ -80,216 +77,204 @@
### Community 0 - "Community 0" ### Community 0 - "Community 0"
Cohesion: 0.01 Cohesion: 0.01
Nodes (917): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+909 more) Nodes (966): create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post(), create_role_management() (+958 more)
### Community 1 - "Community 1" ### Community 1 - "Community 1"
Cohesion: 0.01 Cohesion: 0.01
Nodes (738): generate_uuid(), create_cell_sample_type(), get_cell_sample_type(), list_cell_sample_types(), 细胞样本类型 CRUD(biz_cell_sample_type), _row_to_dict(), soft_delete_cell_sample_type(), type_code_exists() (+730 more) Nodes (736): get_role_menus(), generate_uuid(), create_cell_sample_type(), get_cell_sample_type(), list_cell_sample_types(), 细胞样本类型 CRUD(biz_cell_sample_type), _row_to_dict(), soft_delete_cell_sample_type() (+728 more)
### Community 2 - "Community 2" ### Community 2 - "Community 2"
Cohesion: 0.01 Cohesion: 0.01
Nodes (679): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+671 more) Nodes (626): attachment_download_url(), AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, dict_data_options(), DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody (+618 more)
### Community 3 - "Community 3" ### Community 3 - "Community 3"
Cohesion: 0.05 Cohesion: 0.06
Nodes (603): 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API, 持久化协议子资源。 缴费(含明细校验)须在样本之前:明细校验失败时不得已占用样本编码。 各子表 CRUD 内部仍可能分段 commit,创建接, 与列表接口一致:按租户 + 角色数据权限判断是否可访问该协议单。, If mother info exists but no customerId, create a customer record and link it., Base, 细胞产品 CRUD(biz_cell_product), 解析任务样本投入物,返回祖先链单段(样本终止节点)。, 将 get_product_chain 结果展平为上游→下游节点列表(含 current,不含 descendants)。 (+595 more) Nodes (500): 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), Base, 按输注计划 ID 批量索引缴费记录(列表页展示用,不做关联 enrich), 输注计划创建时自动调用,生成 dfj 缴费记录, 关联查询申请单、患者、输注计划信息,附加到 dict, 将 BizMaterialUsage 行转换为字典(用于我的领用记录)。, 当前登录人的手动出库领用记录:领用人字段存昵称或用户 ID(与生产/质检一致)。, 获取当前用户的物料领用记录(手动出库记录) 筛选条件: - tenant_no = 当前用户租户 - source_type = 'm (+492 more)
### Community 4 - "Community 4" ### Community 4 - "Community 4"
Cohesion: 0.02 Cohesion: 0.01
Nodes (282): _normalize_date_bound(), complete_protocol_invoice_request(), create_collection_protocol(), delete_collection_protocol(), _draft_fields_from_body(), _ensure_customer_for_mother(), get_collection_protocol_detail(), _get_current_user_id() (+274 more) Nodes (342): _normalize_date_bound(), list_ethnics_for_select(), apply_data_scope(), format_date(), optional_date(), parse_date(), 将字符串 / date / datetime 转为 date 或 None, ApiRequestLogCRUD (+334 more)
### Community 5 - "Community 5" ### Community 5 - "Community 5"
Cohesion: 0.01 Cohesion: 0.02
Nodes (247): list_collection_regions(), list_ethnics_for_select(), get_designer_data(), apply_data_scope(), _get_child_dept_ids(), get_user_dept_id(), _resolve_user_role_scopes(), ApiRequestLogCRUD (+239 more) Nodes (267): _body_to_payload(), _get_current_user_id(), get_production_reception_by_sample_code(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), put_production_reception(), _resolve_reception_id() (+259 more)
### Community 6 - "Community 6" ### Community 6 - "Community 6"
Cohesion: 0.02 Cohesion: 0.02
Nodes (189): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+181 more) Nodes (139): get_designer_data(), _error_json(), lifespan(), 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+131 more)
### Community 7 - "Community 7" ### Community 7 - "Community 7"
Cohesion: 0.02 Cohesion: 0.02
Nodes (182): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), build_void_type_label_map(), format_protocol_status_label() (+174 more) Nodes (185): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+177 more)
### Community 8 - "Community 8" ### Community 8 - "Community 8"
Cohesion: 0.03 Cohesion: 0.02
Nodes (155): map_payments_by_schedule_ids(), _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict(), _handover_view_dict(), _parse_handover_time() (+147 more) Nodes (191): api_save_application(), get_protocol_nos_used_in_protocols(), _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict(), _handover_view_dict() (+183 more)
### Community 9 - "Community 9" ### Community 9 - "Community 9"
Cohesion: 0.02 Cohesion: 0.08
Nodes (96): _error_json(), lifespan(), 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError (+88 more) Nodes (189): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+181 more)
### Community 10 - "Community 10" ### Community 10 - "Community 10"
Cohesion: 0.05 Cohesion: 0.03
Nodes (112): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+104 more) Nodes (141): get_next_cell_product_code(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes() (+133 more)
### Community 11 - "Community 11" ### Community 11 - "Community 11"
Cohesion: 0.03 Cohesion: 0.05
Nodes (90): _jwt_user_payload(), Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。, refresh_token(), web_login(), web_login_rsa_public_key(), create_access_token(), create_refresh_token() (+82 more) Nodes (108): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+100 more)
### Community 12 - "Community 12" ### Community 12 - "Community 12"
Cohesion: 0.06 Cohesion: 0.04
Nodes (75): _body_to_payload(), _get_current_user_id(), get_production_reception_by_sample_code(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), put_production_reception(), _resolve_reception_id() (+67 more) Nodes (91): get_user_by_id(), _jwt_user_payload(), Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。, refresh_token(), web_login(), web_login_rsa_public_key(), create_access_token() (+83 more)
### Community 13 - "Community 13" ### Community 13 - "Community 13"
Cohesion: 0.05 Cohesion: 0.45
Nodes (77): api_save_application(), auto_generate_schedules(), complete_application(), create_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products() (+69 more) Nodes (71): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+63 more)
### Community 14 - "Community 14" ### Community 14 - "Community 14"
Cohesion: 0.05
Nodes (76): _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes(), create_cell_product() (+68 more)
### Community 15 - "Community 15"
Cohesion: 0.12 Cohesion: 0.12
Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块 支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3) Args: out_trade_no: 商户订单号 total, 申请退款 Args: out_trade_no: 商户订单号 total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API) Args: out_trade_no: 商户订单号 tot, 生成签名 V3 (SHA256 with RSA) (+3 more) Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块 支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3) Args: out_trade_no: 商户订单号 total, 申请退款 Args: out_trade_no: 商户订单号 total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API) Args: out_trade_no: 商户订单号 tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
### Community 16 - "Community 16" ### Community 15 - "Community 15"
Cohesion: 0.16
Nodes (22): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+14 more)
### Community 17 - "Community 17"
Cohesion: 0.21 Cohesion: 0.21
Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more) Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
### Community 18 - "Community 18" ### Community 16 - "Community 16"
Cohesion: 0.21 Cohesion: 0.2
Nodes (16): _field_label(), _is_non_empty_str(), _is_required(), _is_valid_receive_account_item(), normalize_account_fields(), _pick(), _pick_order_num(), _pick_str() (+8 more) Nodes (19): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+11 more)
### Community 19 - "Community 19" ### Community 17 - "Community 17"
Cohesion: 0.27 Cohesion: 0.27
Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more) Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more)
### Community 20 - "Community 20" ### Community 18 - "Community 18"
Cohesion: 0.24
Nodes (9): 微信消息通知服务 支持微信小程序模板消息推送 使用方式: from app.core.wechat_message import Wech, 发送模板消息 Args: openid: 用户openid templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
### Community 21 - "Community 21"
Cohesion: 0.23
Nodes (12): Config, ProductBase, ProductCategoryBase, ProductCategoryCreate, ProductCategoryResponse, ProductCreate, ProductResponse, ProductUnitBase (+4 more)
### Community 22 - "Community 22"
Cohesion: 0.27 Cohesion: 0.27
Nodes (12): _field_label(), _is_empty(), _is_required(), _parse_money(), OA 表单设计器:金额控件 form_data 校验, 校验 form_data 中金额控件的值格式,返回中文错误列表(空表示通过)。, 解析金额值。成功返回 (number, None);失败返回 (None, error_key)。, _rule_props() (+4 more) Nodes (12): _field_label(), _is_empty(), _is_required(), _parse_money(), OA 表单设计器:金额控件 form_data 校验, 校验 form_data 中金额控件的值格式,返回中文错误列表(空表示通过)。, 解析金额值。成功返回 (number, None);失败返回 (None, error_key)。, _rule_props() (+4 more)
### Community 23 - "Community 23" ### Community 19 - "Community 19"
Cohesion: 0.24
Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
### Community 20 - "Community 20"
Cohesion: 0.24 Cohesion: 0.24
Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more) Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
### Community 24 - "Community 24" ### Community 21 - "Community 21"
Cohesion: 0.31 Cohesion: 0.31
Nodes (10): create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), OA 收款账户 CRUD(oa_receive_account), row_to_dict(), soft_delete_account() (+2 more) Nodes (10): create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), OA 收款账户 CRUD(oa_receive_account), row_to_dict(), soft_delete_account() (+2 more)
### Community 25 - "Community 25" ### Community 22 - "Community 22"
Cohesion: 0.31 Cohesion: 0.31
Nodes (10): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。, _row_to_dict() (+2 more) Nodes (10): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。, _row_to_dict() (+2 more)
### Community 26 - "Community 26" ### Community 23 - "Community 23"
Cohesion: 0.38 Cohesion: 0.33
Nodes (9): create_env_detect_item(), get_env_detect_item(), item_code_exists(), list_active_env_detect_items(), list_env_detect_items(), _order_query(), _row_to_dict(), soft_delete_env_detect_item() (+1 more) Nodes (8): _build_tree(), _cascade_soft_delete(), get_menu(), list_menus(), list_menus_tree(), _row_to_dict(), soft_delete_menu(), update_menu()
### Community 27 - "Community 27" ### Community 24 - "Community 24"
Cohesion: 0.29 Cohesion: 0.29
Nodes (2): BaseModel, SoftDeleteModel Nodes (2): BaseModel, SoftDeleteModel
### Community 28 - "Community 28" ### Community 25 - "Community 25"
Cohesion: 0.38 Cohesion: 0.38
Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。 约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断; 若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal() Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。 约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断; 若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
### Community 29 - "Community 29" ### Community 26 - "Community 26"
Cohesion: 0.5 Cohesion: 0.5
Nodes (3): BaseSettings, Config, Settings Nodes (3): BaseSettings, Config, Settings
### Community 30 - "Community 30" ### Community 27 - "Community 27"
Cohesion: 0.5 Cohesion: 0.5
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
### Community 31 - "Community 31" ### Community 28 - "Community 28"
Cohesion: 0.67 Cohesion: 0.67
Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组) Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
### Community 32 - "Community 32" ### Community 29 - "Community 29"
Cohesion: 0.67 Cohesion: 0.67
Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。 Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
### Community 33 - "Community 33" ### Community 30 - "Community 30"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。 Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
### Community 34 - "Community 34" ### Community 31 - "Community 31"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): 请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传 Nodes (1): 请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
### Community 35 - "Community 35" ### Community 32 - "Community 32"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位) Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
### Community 38 - "Community 38" ### Community 35 - "Community 35"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): 发送订单状态通知 Args: openid: 用户openid ord Nodes (1): 发送订单状态通知 Args: openid: 用户openid ord
### Community 39 - "Community 39" ### Community 36 - "Community 36"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): 发送支付成功通知 Args: openid: 用户openid ord Nodes (1): 发送支付成功通知 Args: openid: 用户openid ord
### Community 40 - "Community 40" ### Community 37 - "Community 37"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): 发送配送通知 Args: openid: 配送员openid task Nodes (1): 发送配送通知 Args: openid: 配送员openid task
### Community 41 - "Community 41" ### Community 38 - "Community 38"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): 发送配送时效预警 Args: openid: 配送员openid ta Nodes (1): 发送配送时效预警 Args: openid: 配送员openid ta
### Community 42 - "Community 42" ### Community 39 - "Community 39"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): 发送退款结果通知 Args: openid: 用户openid ord Nodes (1): 发送退款结果通知 Args: openid: 用户openid ord
### Community 43 - "Community 43" ### Community 40 - "Community 40"
Cohesion: 1.0 Cohesion: 1.0
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
## Knowledge Gaps ## Knowledge Gaps
- **163 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+158 more) - **163 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+158 more)
These have ≤1 connection - possible missing edges or undocumented components. These have ≤1 connection - possible missing edges or undocumented components.
- **Thin community `Community 27`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()` - **Thin community `Community 24`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 30`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID` - **Thin community `Community 27`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 31`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)` - **Thin community `Community 28`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 32`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。` - **Thin community `Community 29`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 33`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。` - **Thin community `Community 30`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 34`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传` - **Thin community `Community 31`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 35`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)` - **Thin community `Community 32`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 38`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord` - **Thin community `Community 35`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 39`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord` - **Thin community `Community 36`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 40`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task` - **Thin community `Community 37`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 41`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta` - **Thin community `Community 38`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 42`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord` - **Thin community `Community 39`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 43`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid` - **Thin community `Community 40`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
Too small to be a meaningful cluster - may be noise or needs more connections extracted. Too small to be a meaningful cluster - may be noise or needs more connections extracted.
## Suggested Questions ## Suggested Questions
_Questions this graph is uniquely positioned to answer:_ _Questions this graph is uniquely positioned to answer:_
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 16`, `Community 23`?** - **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 19`, `Community 20`?**
_High betweenness centrality (0.113) - this node is a cross-community bridge._ _High betweenness centrality (0.112) - this node is a cross-community bridge._
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 16`, `Community 17`, `Community 19`, `Community 23`, `Community 24`, `Community 26`?** - **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 10`, `Community 12`, `Community 28`?**
_High betweenness centrality (0.048) - this node is a cross-community bridge._ _High betweenness centrality (0.048) - this node is a cross-community bridge._
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 4`, `Community 5`, `Community 11`, `Community 12`, `Community 13`, `Community 31`?** - **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 15`, `Community 16`, `Community 17`, `Community 19`, `Community 20`, `Community 21`?**
_High betweenness centrality (0.038) - this node is a cross-community bridge._ _High betweenness centrality (0.048) - this node is a cross-community bridge._
- **Are the 858 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?** - **Are the 859 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
_`str` has 858 INFERRED edges - model-reasoned connections that need verification._ _`str` has 859 INFERRED edges - model-reasoned connections that need verification._
- **Are the 729 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?** - **Are the 731 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
_`success_response()` has 729 INFERRED edges - model-reasoned connections that need verification._ _`success_response()` has 731 INFERRED edges - model-reasoned connections that need verification._
- **Are the 603 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?** - **Are the 604 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
_`error_response()` has 603 INFERRED edges - model-reasoned connections that need verification._ _`error_response()` has 604 INFERRED edges - model-reasoned connections that need verification._
- **Are the 589 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤) data_scope 值: 1 = 全部数据 2 = 本部门数据 3` and `从 SysUser 读取用户的 dept_id。`) actually correct?** - **Are the 589 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤) data_scope 值: 1 = 全部数据 2 = 本部门数据 3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
_`SysUser` has 589 INFERRED edges - model-reasoned connections that need verification._ _`SysUser` has 589 INFERRED edges - model-reasoned connections that need verification._
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""将「用户-角色」迁移为「用户-职位 + 职位-角色」,并清理直接绑定。
步骤(与产品约定一致):
1. 软删除清空 sys_post / sys_user_post / sys_post_role(指定租户)
2. 按 sys_role(未删除)逐条创建职位:post_name=角色名,post_code=名称拼音首字母缩写;
并为职位绑定该角色(sys_post_role 一对一)
3. 按 sys_user_role 为用户绑定对应职位(sys_user_post)
4. 软删除清空 sys_user_role(指定租户)
用法(在 backend 目录):
# 务必指向测试库,勿连生产
export ENV_FILE=./.env.test # 或确认 .env 已是测试库
source venv/bin/activate
pip install pypinyin # 若尚未安装
# 只预览,不写库
python scripts/migrate_user_role_to_post.py --dry-run
# 真正执行(需同时传 --execute 与库名确认)
python scripts/migrate_user_role_to_post.py --execute --confirm-database zkxw-sc
注意:
- 菜单/数据权限运行时已走有效角色并集,迁移后可由职位继承。
- 仍有少量代码只查 sys_user_role(如「实验专员」步骤负责人校验、
质检金额授权列表、工作流按角色选人)。若业务依赖这些入口,需另改代码或
保留对应直接绑定(可用 --preserve-role-ids)。
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
try:
from pypinyin import Style, lazy_pinyin
except ImportError as exc: # pragma: no cover
raise SystemExit(
"缺少依赖 pypinyin。请先执行: pip install pypinyin\n" + str(exc)
) from exc
from sqlalchemy import and_
from app.core.config import settings
from app.core.database import SessionLocal
from app.core.utils import generate_uuid
from app.models.models import SysPost, SysPostRole, SysRole, SysUserPost, SysUserRole
# 质检物料金额查看:授权管理模块仍读写 sys_user_role;默认迁移后一并清理,
# 若需保留其直接绑定,执行时加:
# --preserve-role-ids ROLE000000000000000000000000000011
DEFAULT_PRESERVE_ROLE_IDS: Tuple[str, ...] = ()
def _mask_database_url(url: str) -> str:
try:
u = urlparse(url)
user = u.username or ""
host = u.hostname or ""
port = f":{u.port}" if u.port else ""
db = (u.path or "").lstrip("/")
return f"{u.scheme}://{user}:***@{host}{port}/{db}"
except Exception:
return "***"
def _database_name(url: str) -> str:
try:
return (urlparse(url).path or "").lstrip("/").split("?")[0]
except Exception:
return ""
def role_name_to_post_code(role_name: str, used: Set[str]) -> str:
"""职位编码 = 角色名拼音首字母缩写(大写),冲突则追加序号。"""
name = (role_name or "").strip() or "POST"
letters = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER))
base = re.sub(r"[^A-Za-z0-9]", "", letters).upper()
if not base:
base = "POST"
# 编码不宜过长
base = base[:48]
code = base
n = 2
while code in used:
suffix = f"-{n}"
code = (base[: 48 - len(suffix)] + suffix).upper()
n += 1
used.add(code)
return code
def soft_clear_posts(db, tenant_no: str, now: datetime, dry_run: bool) -> Dict[str, int]:
counts = {}
for model, key in (
(SysPostRole, "sys_post_role"),
(SysUserPost, "sys_user_post"),
(SysPost, "sys_post"),
):
q = db.query(model).filter(model.tenant_no == tenant_no, model.del_flag == 0)
n = q.count()
counts[key] = n
if not dry_run and n:
q.update(
{"del_flag": 1, "update_time": now},
synchronize_session=False,
)
return counts
def load_roles(db, tenant_no: str) -> List[SysRole]:
return (
db.query(SysRole)
.filter(SysRole.tenant_no == tenant_no, SysRole.del_flag == 0)
.order_by(SysRole.order_num.asc(), SysRole.create_time.asc())
.all()
)
def create_posts_for_roles(
db,
roles: List[SysRole],
tenant_no: str,
operator_id: Optional[str],
now: datetime,
dry_run: bool,
) -> Dict[str, str]:
"""返回 role_id -> post_id。"""
used_codes: Set[str] = set()
role_to_post: Dict[str, str] = {}
for idx, role in enumerate(roles, start=1):
post_id = generate_uuid()
code = role_name_to_post_code(role.role_name, used_codes)
status = (role.status or "1").strip() or "1"
print(
f" [{idx}/{len(roles)}] 角色「{role.role_name}」({role.id}) "
f"→ 职位 {code} / {role.role_name}"
)
role_to_post[role.id] = post_id
if dry_run:
continue
db.add(
SysPost(
id=post_id,
post_name=role.role_name,
post_code=code,
status=status,
tenant_no=tenant_no,
order_num=role.order_num if role.order_num is not None else idx,
remark=f"由角色迁移生成,role_id={role.id}",
create_by=operator_id,
create_dept_id="",
create_time=now,
update_by=operator_id,
update_time=now,
del_flag=0,
)
)
db.add(
SysPostRole(
id=generate_uuid(),
post_id=post_id,
role_id=role.id,
tenant_no=tenant_no,
order_num=0,
remark="角色一对一迁移",
create_by=operator_id,
create_dept_id="",
create_time=now,
update_by=operator_id,
update_time=now,
del_flag=0,
)
)
return role_to_post
def migrate_user_posts(
db,
tenant_no: str,
role_to_post: Dict[str, str],
operator_id: Optional[str],
now: datetime,
dry_run: bool,
) -> Tuple[int, int]:
"""按用户-角色生成用户-职位。返回 (将写入条数, 跳过条数)。"""
rows = (
db.query(SysUserRole)
.filter(SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0)
.all()
)
# 同一用户同一职位只绑一次
seen: Set[Tuple[str, str]] = set()
written = 0
skipped = 0
for ur in rows:
post_id = role_to_post.get(ur.role_id)
if not post_id:
skipped += 1
continue
key = (ur.user_id, post_id)
if key in seen:
skipped += 1
continue
seen.add(key)
written += 1
if dry_run:
continue
db.add(
SysUserPost(
id=generate_uuid(),
user_id=ur.user_id,
post_id=post_id,
tenant_no=tenant_no,
order_num=ur.order_num if ur.order_num is not None else 0,
remark=f"由用户角色迁移,role_id={ur.role_id}",
create_by=operator_id,
create_dept_id="",
create_time=now,
update_by=operator_id,
update_time=now,
del_flag=0,
)
)
return written, skipped
def clear_user_roles(
db,
tenant_no: str,
preserve_role_ids: Set[str],
now: datetime,
dry_run: bool,
) -> Tuple[int, int]:
q = db.query(SysUserRole).filter(
SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0
)
if preserve_role_ids:
q = q.filter(~SysUserRole.role_id.in_(list(preserve_role_ids)))
n = q.count()
preserved = 0
if preserve_role_ids:
preserved = (
db.query(SysUserRole)
.filter(
SysUserRole.tenant_no == tenant_no,
SysUserRole.del_flag == 0,
SysUserRole.role_id.in_(list(preserve_role_ids)),
)
.count()
)
if not dry_run and n:
q.update({"del_flag": 1, "update_time": now}, synchronize_session=False)
return n, preserved
def run(
*,
tenant_no: str,
dry_run: bool,
execute: bool,
confirm_database: Optional[str],
operator_id: Optional[str],
preserve_role_ids: Set[str],
) -> None:
db_url = settings.DATABASE_URL
db_name = _database_name(db_url)
print("=== 用户角色 → 职位 迁移 ===")
print(f"ENV_FILE : {os.getenv('ENV_FILE', '(default backend/.env)')}")
print(f"DATABASE_URL : {_mask_database_url(db_url)}")
print(f"database : {db_name}")
print(f"tenant_no : {tenant_no}")
print(f"mode : {'DRY-RUN' if dry_run or not execute else 'EXECUTE'}")
if preserve_role_ids:
print(f"preserve UR : {', '.join(sorted(preserve_role_ids))}")
print()
if execute and not dry_run:
if not confirm_database:
raise SystemExit(
"执行写库必须传 --confirm-database <库名>,"
f"当前检测到库名为「{db_name}」。"
)
if confirm_database != db_name:
raise SystemExit(
f"--confirm-database={confirm_database!r} 与当前连接库名 "
f"{db_name!r} 不一致,已中止。"
)
db = SessionLocal()
now = datetime.now()
try:
roles = load_roles(db, tenant_no)
user_role_count = (
db.query(SysUserRole)
.filter(SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0)
.count()
)
print(f"当前有效角色数 : {len(roles)}")
print(f"当前用户-角色绑定数: {user_role_count}")
print()
print("步骤1:清空职位相关表(软删除)…")
cleared = soft_clear_posts(db, tenant_no, now, dry_run=dry_run or not execute)
for k, v in cleared.items():
print(f" {k}: {v}")
print("步骤2:按角色创建职位并绑定角色…")
role_to_post = create_posts_for_roles(
db,
roles,
tenant_no,
operator_id,
now,
dry_run=dry_run or not execute,
)
print("步骤3:按用户-角色生成用户-职位…")
written, skipped = migrate_user_posts(
db,
tenant_no,
role_to_post,
operator_id,
now,
dry_run=dry_run or not execute,
)
print(f" 将写入用户-职位: {written},跳过: {skipped}")
print("步骤4:清理用户-角色直接绑定(软删除)…")
cleared_ur, preserved_ur = clear_user_roles(
db,
tenant_no,
preserve_role_ids,
now,
dry_run=dry_run or not execute,
)
print(f" 将清理用户-角色: {cleared_ur},保留: {preserved_ur}")
if dry_run or not execute:
db.rollback()
print()
print("DRY-RUN 完成,未写入数据库。确认无误后执行:")
print(
f" ENV_FILE=... python scripts/migrate_user_role_to_post.py "
f"--execute --confirm-database {db_name}"
)
else:
db.commit()
print()
print("迁移已提交。")
print(
"请在测试环境登录验证:菜单权限、数据权限、职位管理、用户查看中的有效角色。"
)
except Exception:
db.rollback()
raise
finally:
db.close()
def main() -> None:
parser = argparse.ArgumentParser(
description="将用户-角色迁移为用户-职位(测试库专用)"
)
parser.add_argument("--tenant-no", default="zkxw", help="租户编号,默认 zkxw")
parser.add_argument(
"--dry-run",
action="store_true",
help="仅预览(默认若不带 --execute 也等同预览)",
)
parser.add_argument(
"--execute",
action="store_true",
help="真正写库(必须同时传 --confirm-database)",
)
parser.add_argument(
"--confirm-database",
default=None,
help="当前连接的数据库名(与 DATABASE_URL 路径段一致)",
)
parser.add_argument(
"--operator-id",
default=None,
help="create_by / update_by,可选",
)
parser.add_argument(
"--preserve-role-ids",
default="",
help="清理 sys_user_role 时保留的角色 ID,逗号分隔",
)
args = parser.parse_args()
preserve = set(DEFAULT_PRESERVE_ROLE_IDS)
for part in (args.preserve_role_ids or "").split(","):
pid = part.strip()
if pid:
preserve.add(pid)
# 未显式 --execute 时一律 dry-run
dry_run = bool(args.dry_run) or not bool(args.execute)
run(
tenant_no=args.tenant_no,
dry_run=dry_run,
execute=bool(args.execute),
confirm_database=args.confirm_database,
operator_id=args.operator_id,
preserve_role_ids=preserve,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,31 @@
"""按有效角色查用户:空入参行为(不依赖 DB)。"""
from app.services.web_auth_roles import (
list_user_ids_with_any_effective_role_ids,
list_user_ids_with_effective_role_name,
)
class _DummySession:
def query(self, *args, **kwargs): # pragma: no cover
raise AssertionError("空入参不应访问数据库")
def test_list_by_role_name_empty_inputs():
db = _DummySession()
assert list_user_ids_with_effective_role_name(db, tenant_no="", role_name="实验专员") == set()
assert list_user_ids_with_effective_role_name(db, tenant_no="zkxw", role_name="") == set()
assert list_user_ids_with_effective_role_name(
db, tenant_no="zkxw", role_name="实验专员", candidate_user_ids=[]
) == set()
def test_list_by_role_ids_empty_inputs():
db = _DummySession()
assert list_user_ids_with_any_effective_role_ids(db, tenant_no="zkxw", role_ids=[]) == []
assert list_user_ids_with_any_effective_role_ids(db, tenant_no="", role_ids=["r1"]) == []
assert (
list_user_ids_with_any_effective_role_ids(
db, tenant_no="zkxw", role_ids=["r1"], candidate_user_ids=[]
)
== []
)
+54
View File
@@ -0,0 +1,54 @@
"""OA 发起审批草稿序列化与访问错误"""
import unittest
from datetime import datetime
from types import SimpleNamespace
from app.crud.oa_instance_draft import DraftAccessError, row_to_dict
class TestOaInstanceDraftRowToDict(unittest.TestCase):
def _row(self, **kwargs):
base = dict(
id="d1",
app_id="a1",
app_version_id="v1",
title="请假",
form_data_json='{"days":1}',
initiator_select_json='{"n1":["u1"]}',
initiator_dept_id="dept1",
owner_user_id="u-owner",
create_time=datetime(2026, 8, 6, 10, 0, 0),
update_time=datetime(2026, 8, 6, 11, 0, 0),
)
base.update(kwargs)
return SimpleNamespace(**base)
def test_list_shape_without_payload(self):
d = row_to_dict(
self._row(),
app_name="请假申请",
version_no=3,
group_id="g1",
group_name="人事",
)
self.assertEqual(d["id"], "d1")
self.assertEqual(d["appId"], "a1")
self.assertEqual(d["appName"], "请假申请")
self.assertEqual(d["versionNo"], 3)
self.assertEqual(d["status"], "draft")
self.assertEqual(d["updateTime"], "2026-08-06 11:00:00")
self.assertNotIn("formData", d)
def test_detail_includes_payload(self):
d = row_to_dict(self._row(), include_payload=True)
self.assertEqual(d["formData"], {"days": 1})
self.assertEqual(d["initiatorSelectByNode"], {"n1": ["u1"]})
self.assertEqual(d["initiatorDeptId"], "dept1")
def test_access_error_message(self):
err = DraftAccessError("草稿不存在或无权访问")
self.assertEqual(str(err), "草稿不存在或无权访问")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,38 @@
"""OA 菜单角标:待办数 / 草稿数"""
import unittest
from unittest.mock import MagicMock, patch
from app.crud import oa_instance_draft as oid_crud
from app.services import oa_engine as eng
class TestCountPendingTasks(unittest.TestCase):
def test_filters_approval_pending_for_user(self):
db = MagicMock()
q = db.query.return_value
filtered = q.filter.return_value
filtered.count.return_value = 3
n = eng.count_pending_tasks(db, tenant_no="t1", user_id="u1")
self.assertEqual(n, 3)
db.query.assert_called_once()
q.filter.assert_called_once()
filtered.count.assert_called_once()
class TestCountDrafts(unittest.TestCase):
def test_filters_owner_and_tenant(self):
db = MagicMock()
q = db.query.return_value
filtered = q.filter.return_value
filtered.count.return_value = 5
n = oid_crud.count_drafts(db, tenant_no="t1", owner_user_id="u1")
self.assertEqual(n, 5)
db.query.assert_called_once()
q.filter.assert_called_once()
filtered.count.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,35 @@
"""角色列表 boundUserNames / boundPostNames enrichment。"""
import inspect
from app.crud import role as role_crud
class _DummySession:
def query(self, *args, **kwargs): # pragma: no cover
raise AssertionError("空入参不应访问数据库")
def test_bound_user_names_by_role_ids_empty():
db = _DummySession()
assert role_crud._bound_user_names_by_role_ids(db, [], "zkxw") == {}
assert role_crud._bound_user_names_by_role_ids(db, ["r1"], "") == {}
def test_bound_post_names_by_role_ids_empty():
db = _DummySession()
assert role_crud._bound_post_names_by_role_ids(db, [], "zkxw") == {}
assert role_crud._bound_post_names_by_role_ids(db, ["r1"], "") == {}
def test_user_display_name_prefers_nick():
assert role_crud._user_display_name("张三", "zhangsan") == "张三"
assert role_crud._user_display_name("", "zhangsan") == "zhangsan"
assert role_crud._user_display_name(" ", "u1") == "u1"
assert role_crud._user_display_name(None, None) == ""
def test_list_roles_enriches_bound_names():
source = inspect.getsource(role_crud.list_roles)
assert "_bound_user_names_by_role_ids" in source
assert "_bound_post_names_by_role_ids" in source
assert "boundUserNames" in inspect.getsource(role_crud._row_to_dict) or "bound_user_names" in source
+709
View File
@@ -0,0 +1,709 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OA 草稿箱 · Demo</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--primary: #10b981;
--primary-dark: #059669;
--primary-bg: #ecfdf5;
--text: #1e293b;
--text-muted: #64748b;
--border: #d9e8e2;
--bg-page: #f8fafc;
--surface: #ffffff;
--table-head: #f8fafc;
--row-alt: #fafafa;
--shadow: 0 1px 3px rgba(0,0,0,.05);
--radius: 10px;
--warn: #d97706;
--warn-bg: #fffbeb;
--danger: #dc2626;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: #eef2f0;
padding: 24px;
}
.page-head {
max-width: 1120px;
margin: 0 auto 20px;
}
.page-head h1 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
.tabs {
max-width: 1120px;
margin: 0 auto 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tab {
border: 1px solid var(--border);
background: #fff;
color: var(--text-muted);
border-radius: 999px;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tab.active {
background: var(--primary-bg);
border-color: #a7f3d0;
color: var(--primary-dark);
}
.panel {
max-width: 1120px;
margin: 0 auto;
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.panel.show { display: block; }
.note {
max-width: 1120px;
margin: 14px auto 0;
padding: 12px 14px;
background: #fff;
border: 1px dashed #a7f3d0;
border-radius: 8px;
color: var(--text-muted);
font-size: 13px;
}
.note strong { color: var(--primary-dark); }
/* shell */
.shell { display: flex; min-height: 560px; }
.sidebar {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: #fff;
padding: 16px 12px;
}
.logo {
font-weight: 700;
padding: 4px 12px 16px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.logo span { color: var(--primary); }
.nav-group {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
padding: 8px 12px;
letter-spacing: .02em;
}
.nav-item {
display: block;
padding: 9px 12px;
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-size: 13px;
margin-bottom: 2px;
}
.nav-item:hover { background: #f1f5f9; }
.nav-item.active {
background: var(--primary-bg);
color: var(--primary-dark);
font-weight: 600;
}
.nav-item.new {
position: relative;
}
.nav-item.new::after {
content: 'NEW';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
font-weight: 700;
color: var(--warn);
background: var(--warn-bg);
border: 1px solid #fcd34d;
border-radius: 4px;
padding: 1px 5px;
}
.main { flex: 1; background: var(--bg-page); padding: 20px; min-width: 0; }
.content-card {
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.panel-top {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.panel-top h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.panel-top h2::before {
content: '';
width: 3px;
height: 16px;
background: var(--primary);
border-radius: 2px;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
.hint { color: var(--text-muted); font-size: 13px; margin-right: auto; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.btn-main {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-main:hover { background: var(--primary-dark); }
.btn-ghost:hover { background: #f8fafc; }
.btn-warn {
background: #fff;
border-color: #fcd34d;
color: var(--warn);
}
.btn-danger {
color: var(--danger);
border-color: #fecaca;
}
.btn-op {
height: 30px;
padding: 0 10px;
font-size: 12px;
}
table { width: 100%; border-collapse: collapse; }
th, td {
padding: 12px 14px;
text-align: center;
border-bottom: 1px solid #eef2f0;
font-size: 13px;
}
th {
background: var(--table-head);
color: var(--text-muted);
font-weight: 600;
font-size: 12px;
}
tr:nth-child(even) td { background: var(--row-alt); }
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.tag-draft {
background: var(--warn-bg);
color: var(--warn);
}
.ops { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; }
/* start cards */
.cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 20px 20px;
}
.app-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.app-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--primary-bg);
color: var(--primary-dark);
display: grid;
place-items: center;
font-weight: 700;
flex-shrink: 0;
}
.app-body { flex: 1; min-width: 0; }
.app-name { font-weight: 600; display: block; }
.app-desc { color: var(--text-muted); font-size: 12px; }
/* dialog overlay mock */
.dlg-wrap {
position: relative;
min-height: 520px;
background: rgba(15, 23, 42, .35);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 28px 16px;
}
.dlg {
width: min(720px, 100%);
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 50px rgba(0,0,0,.18);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100vh - 120px);
}
.dlg-hd {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 16px;
}
.dlg-bd {
padding: 18px 20px;
overflow: auto;
flex: 1;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field.full { grid-column: 1 / -1; }
.field label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.field input, .field textarea, .field select {
height: 36px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0 10px;
font: inherit;
color: var(--text);
background: #fff;
}
.field textarea {
height: 72px;
padding: 8px 10px;
resize: none;
}
.flow-preview {
margin-top: 16px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fafafa;
}
.flow-preview .title {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 10px;
}
.steps {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.step {
padding: 6px 10px;
border-radius: 6px;
background: #fff;
border: 1px solid var(--border);
font-size: 12px;
}
.step.on {
border-color: #a7f3d0;
background: var(--primary-bg);
color: var(--primary-dark);
font-weight: 600;
}
.arrow { color: var(--text-muted); font-size: 12px; }
.dlg-ft {
padding: 12px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
background: #fff;
}
.change-callout {
margin: 0 0 12px;
padding: 10px 12px;
background: var(--warn-bg);
border: 1px solid #fcd34d;
border-radius: 8px;
color: #92400e;
font-size: 13px;
}
.pager {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
align-items: center;
}
.filter {
display: grid;
grid-template-columns: repeat(3, 1fr) auto;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: #fff;
}
.filter .field label { font-size: 11px; }
@media (max-width: 840px) {
.shell { flex-direction: column; }
.sidebar { width: 100%; }
.cards, .form-grid, .filter { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="page-head">
<h1>OA 审批 · 草稿箱 Demo</h1>
<p>对照 Fresh Mint:侧栏新增「草稿箱」、发起弹窗增加「暂存草稿」、草稿可再编辑 / 保存 / 提交。</p>
</div>
<div class="tabs">
<button class="tab active" data-tab="menu" type="button">1. 菜单位置</button>
<button class="tab" data-tab="start" type="button">2. 发起弹窗(暂存)</button>
<button class="tab" data-tab="drafts" type="button">3. 草稿箱列表</button>
<button class="tab" data-tab="edit" type="button">4. 继续编辑草稿</button>
</div>
<!-- 1 menu -->
<section class="panel show" id="panel-menu">
<div class="shell">
<aside class="sidebar">
<div class="logo">中科兴皖 · <span>OA</span></div>
<div class="nav-group">OA审批</div>
<a class="nav-item" href="#">审批管理</a>
<a class="nav-item" href="#">发起审批</a>
<a class="nav-item active new" href="#">草稿箱</a>
<a class="nav-item" href="#">待办中心</a>
<a class="nav-item" href="#">已办中心</a>
<a class="nav-item" href="#">我发起的</a>
<a class="nav-item" href="#">抄送我的</a>
</aside>
<div class="main">
<div class="content-card">
<div class="panel-top"><h2>草稿箱</h2></div>
<div class="toolbar">
<div class="hint">仅展示当前登录人自己暂存的审批草稿</div>
</div>
<table>
<thead>
<tr>
<th>序号</th>
<th>流程名称</th>
<th>最近保存</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>请假申请</td>
<td>2026-08-06 10:12</td>
<td><span class="tag tag-draft">草稿</span></td>
<td>
<div class="ops">
<button class="btn btn-op btn-main" type="button">编辑</button>
<button class="btn btn-op" type="button">提交</button>
<button class="btn btn-op btn-danger" type="button">删除</button>
</div>
</td>
</tr>
<tr>
<td>2</td>
<td>费用报销</td>
<td>2026-08-05 18:40</td>
<td><span class="tag tag-draft">草稿</span></td>
<td>
<div class="ops">
<button class="btn btn-op btn-main" type="button">编辑</button>
<button class="btn btn-op" type="button">提交</button>
<button class="btn btn-op btn-danger" type="button">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- 2 start dialog -->
<section class="panel" id="panel-start">
<div class="dlg-wrap">
<div class="dlg">
<div class="dlg-hd">请假申请</div>
<div class="dlg-bd">
<p class="change-callout">相对现状:底栏由「取消 / 提交」变为「取消 / 暂存草稿 / 提交」。</p>
<div class="form-grid">
<div class="field">
<label>申请人</label>
<input value="张三" readonly />
</div>
<div class="field">
<label>所属部门</label>
<input value="研发中心 / 前端组" readonly />
</div>
<div class="field">
<label>请假类型</label>
<select>
<option>年假</option>
<option>事假</option>
</select>
</div>
<div class="field">
<label>请假天数</label>
<input value="1.5" />
</div>
<div class="field full">
<label>请假事由</label>
<textarea>家庭事务,需请假半天办理…</textarea>
</div>
</div>
<div class="flow-preview">
<div class="title">审批流预览</div>
<div class="steps">
<span class="step on">发起人 · 张三</span>
<span class="arrow">→</span>
<span class="step">直属主管 · 李四</span>
<span class="arrow">→</span>
<span class="step">人事确认</span>
<span class="arrow">→</span>
<span class="step">结束</span>
</div>
</div>
</div>
<div class="dlg-ft">
<button class="btn btn-ghost" type="button">✕ 取消</button>
<button class="btn btn-warn" type="button">💾 暂存草稿</button>
<button class="btn btn-main" type="button">✓ 提交</button>
</div>
</div>
</div>
</section>
<!-- 3 drafts list -->
<section class="panel" id="panel-drafts">
<div class="shell">
<aside class="sidebar">
<div class="logo">中科兴皖 · <span>OA</span></div>
<div class="nav-group">OA审批</div>
<a class="nav-item" href="#">发起审批</a>
<a class="nav-item active" href="#">草稿箱</a>
<a class="nav-item" href="#">待办中心</a>
<a class="nav-item" href="#">我发起的</a>
</aside>
<div class="main">
<div class="content-card">
<div class="panel-top"><h2>草稿箱</h2></div>
<div class="filter">
<div class="field">
<label>流程名称</label>
<input placeholder="请输入流程名称" />
</div>
<div class="field">
<label>保存时间</label>
<input placeholder="开始日期 ~ 结束日期" />
</div>
<div class="field">
<label>模板分组</label>
<select><option>全部</option><option>人事</option><option>财务</option></select>
</div>
<div style="display:flex;align-items:flex-end;gap:8px;">
<button class="btn btn-main" type="button">查询</button>
<button class="btn" type="button">重置</button>
</div>
</div>
<div class="toolbar">
<div class="hint">已选 0 项</div>
<button class="btn btn-danger" type="button">批量删除</button>
</div>
<table>
<thead>
<tr>
<th style="width:48px"><input type="checkbox" /></th>
<th>序号</th>
<th>流程名称</th>
<th>模板版本</th>
<th>最近保存</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" /></td>
<td>1</td>
<td>请假申请</td>
<td>v3</td>
<td>2026-08-06 10:12</td>
<td><span class="tag tag-draft">草稿</span></td>
<td>
<div class="ops">
<button class="btn btn-op btn-main" type="button">编辑</button>
<button class="btn btn-op" type="button">提交审批</button>
<button class="btn btn-op btn-danger" type="button">删除</button>
</div>
</td>
</tr>
<tr>
<td><input type="checkbox" /></td>
<td>2</td>
<td>加班申请</td>
<td>v1</td>
<td>2026-08-04 09:22</td>
<td><span class="tag tag-draft">草稿</span></td>
<td>
<div class="ops">
<button class="btn btn-op btn-main" type="button">编辑</button>
<button class="btn btn-op" type="button">提交审批</button>
<button class="btn btn-op btn-danger" type="button">删除</button>
</div>
</td>
</tr>
<tr>
<td><input type="checkbox" /></td>
<td>3</td>
<td>费用报销</td>
<td>v2</td>
<td>2026-08-01 21:05</td>
<td><span class="tag tag-draft">草稿</span></td>
<td>
<div class="ops">
<button class="btn btn-op btn-main" type="button">编辑</button>
<button class="btn btn-op" type="button">提交审批</button>
<button class="btn btn-op btn-danger" type="button">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
<div class="pager">共 3 条 · 每页 20 条 · 1 / 1</div>
</div>
</div>
</div>
</section>
<!-- 4 edit draft -->
<section class="panel" id="panel-edit">
<div class="dlg-wrap">
<div class="dlg">
<div class="dlg-hd">编辑草稿 · 请假申请</div>
<div class="dlg-bd">
<p class="change-callout">从草稿箱点「编辑」打开同一套发起表单;可再次「暂存草稿」或「提交」发起正式审批。</p>
<div class="form-grid">
<div class="field">
<label>申请人</label>
<input value="张三" readonly />
</div>
<div class="field">
<label>所属部门</label>
<input value="研发中心 / 前端组" readonly />
</div>
<div class="field">
<label>请假类型</label>
<select>
<option selected>年假</option>
<option>事假</option>
</select>
</div>
<div class="field">
<label>请假天数</label>
<input value="1.5" />
</div>
<div class="field full">
<label>请假事由</label>
<textarea>家庭事务,需请假半天办理…(草稿已回填)</textarea>
</div>
</div>
</div>
<div class="dlg-ft">
<button class="btn btn-ghost" type="button">✕ 取消</button>
<button class="btn btn-warn" type="button">💾 保存草稿</button>
<button class="btn btn-main" type="button">✓ 提交审批</button>
</div>
</div>
</div>
</section>
<div class="note">
<strong>请确认:</strong>菜单放在「发起审批」正下方、弹窗三按钮(取消 / 暂存草稿 / 提交)、草稿箱支持编辑·提交·删除 —— 是否符合你们期望?
有偏好可点上方 Tab 对照后,在对话里告诉我要改哪里。
</div>
<script>
const tabs = document.querySelectorAll('.tab')
const panels = {
menu: document.getElementById('panel-menu'),
start: document.getElementById('panel-start'),
drafts: document.getElementById('panel-drafts'),
edit: document.getElementById('panel-edit')
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
tabs.forEach((t) => t.classList.remove('active'))
tab.classList.add('active')
Object.values(panels).forEach((p) => p.classList.remove('show'))
panels[tab.dataset.tab].classList.add('show')
})
})
</script>
</body>
</html>
@@ -0,0 +1,684 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OA 表单设计器 · 画布描边方案对比</title>
<style>
:root {
--mint: #10b981;
--mint-dark: #059669;
--mint-bg: #ecfdf5;
--mint-soft: rgba(16, 185, 129, 0.35);
--mint-softer: rgba(16, 185, 129, 0.18);
--slate-50: #f8fafc;
--slate-100: #f1f5f9;
--slate-200: #e2e8f0;
--slate-400: #94a3b8;
--slate-500: #64748b;
--slate-700: #334155;
--slate-900: #0f172a;
--blue: #2e73ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Helvetica Neue", Arial, sans-serif;
color: var(--slate-900);
background: linear-gradient(180deg, #f0fdf9 0%, var(--slate-50) 40%, #fff 100%);
line-height: 1.5;
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 32px 24px 64px;
}
header {
margin-bottom: 28px;
}
header h1 {
margin: 0 0 8px;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
header p {
margin: 0;
color: var(--slate-500);
font-size: 14px;
max-width: 720px;
}
.hint {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 6px 12px;
border-radius: 8px;
background: var(--mint-bg);
color: var(--mint-dark);
font-size: 13px;
font-weight: 500;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
}
.card {
background: #fff;
border: 1px solid var(--slate-200);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition: box-shadow 0.2s, border-color 0.2s;
cursor: pointer;
}
.card:hover {
border-color: #a7f3d0;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.1);
}
.card.selected {
border-color: var(--mint);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
}
.card-head {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--slate-100);
background: var(--slate-50);
}
.badge {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--mint);
color: #fff;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.card.baseline .badge { background: var(--slate-400); }
.card-head h2 {
margin: 0 0 2px;
font-size: 15px;
font-weight: 650;
}
.card-head p {
margin: 0;
font-size: 12px;
color: var(--slate-500);
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: var(--slate-100);
color: var(--slate-500);
}
.tag.rec {
background: var(--mint-bg);
color: var(--mint-dark);
font-weight: 600;
}
.tag.warn {
background: #fff7ed;
color: #c2410c;
}
.canvas {
padding: 16px;
min-height: 220px;
background: #fafafa;
user-select: none;
}
.canvas-label {
font-size: 11px;
color: var(--slate-400);
margin-bottom: 10px;
letter-spacing: 0.02em;
}
/* —— 通用控件外观 —— */
.field {
padding: 8px 10px;
background: #fff;
border-radius: 4px;
}
.field-label {
font-size: 12px;
color: var(--slate-700);
margin-bottom: 6px;
}
.field-ctrl {
height: 32px;
border: 1px solid var(--slate-200);
border-radius: 6px;
background: #fff;
display: flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
color: var(--slate-400);
}
.layout {
padding: 8px;
background: rgba(255, 255, 255, 0.6);
border-radius: 4px;
}
.layout-title {
font-size: 11px;
color: var(--slate-400);
margin-bottom: 8px;
padding-left: 2px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stack > * + * { margin-top: 8px; }
/* DragTool 模拟:操作钮 */
.dt {
position: relative;
padding: 2px;
border-radius: 2px;
}
.dt-btns {
display: none;
position: absolute;
right: 2px;
bottom: 2px;
z-index: 5;
gap: 2px;
}
.dt-btns span {
width: 18px;
height: 18px;
background: var(--mint);
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
}
.dt.active > .dt-btns,
.dt:hover > .dt-btns { display: flex; }
/* ========== A 现状:始终虚线 ========== */
.style-a .dt {
outline: 1px dashed var(--mint);
}
.style-a .dt.active {
outline: 1px solid var(--mint);
}
.style-a .dt:hover:not(.active) {
outline-style: solid;
outline-color: var(--mint-soft);
}
/* ========== B 悬停才显:默认无框 ========== */
.style-b .dt {
outline: 1px dashed transparent;
}
.style-b .dt:hover {
outline-color: var(--mint-soft);
outline-style: solid;
}
.style-b .dt.active {
outline: 1px solid var(--mint);
}
/* ========== C 仅选中:最干净 ========== */
.style-c .dt {
outline: 1px solid transparent;
}
.style-c .dt.active {
outline-color: var(--mint);
}
.style-c .dt:hover:not(.active) {
background: rgba(16, 185, 129, 0.04);
}
/* ========== D 淡化常驻:浅虚线 ========== */
.style-d .dt {
outline: 1px dashed var(--mint-softer);
}
.style-d .dt:hover {
outline-color: var(--mint-soft);
outline-style: solid;
}
.style-d .dt.active {
outline: 1px solid var(--mint);
}
/* ========== E 混合:布局淡框 + 字段悬停 ========== */
.style-e .dt.leaf {
outline: 1px dashed transparent;
}
.style-e .dt.leaf:hover {
outline-color: var(--mint-soft);
outline-style: solid;
}
.style-e .dt.leaf.active {
outline: 1px solid var(--mint);
}
.style-e .dt.container {
outline: 1px dashed var(--mint-softer);
}
.style-e .dt.container:hover {
outline-color: var(--mint-soft);
}
.style-e .dt.container.active {
outline: 1px solid var(--mint);
}
/* ========== F 父级指示条:左侧色条代替四周虚线 ========== */
.style-f .dt {
outline: none;
border-left: 2px solid transparent;
padding-left: 6px;
}
.style-f .dt.container {
border-left-color: var(--mint-softer);
background: rgba(16, 185, 129, 0.03);
}
.style-f .dt:hover {
border-left-color: var(--mint-soft);
background: rgba(16, 185, 129, 0.05);
}
.style-f .dt.active {
border-left-color: var(--mint);
background: rgba(16, 185, 129, 0.08);
box-shadow: inset 0 0 0 1px var(--mint);
}
footer {
margin-top: 28px;
padding: 16px 18px;
border-radius: 12px;
background: #fff;
border: 1px solid var(--slate-200);
font-size: 13px;
color: var(--slate-500);
}
footer strong { color: var(--slate-700); }
#choice {
margin-top: 8px;
font-weight: 600;
color: var(--mint-dark);
min-height: 1.5em;
}
</style>
</head>
<body>
<div class="page">
<header>
<h1>表单设计画布 · 描边方案对比</h1>
<p>
模拟「布局容器嵌套字段」场景。请在每个画布里<strong>点击选中</strong>、<strong>悬停</strong>感受差异。
选中整张卡片表示你倾向的方案(可多选对比后口头反馈)。
</p>
<div class="hint">色值对齐本项目 Mint:#10b981(已替换 FormCreate 默认蓝 #2E73FF)</div>
</header>
<div class="grid" id="options">
<!-- A -->
<article class="card baseline" data-choice="A">
<div class="card-head">
<div class="badge">A</div>
<div>
<h2>现状:始终蓝色/绿色虚线</h2>
<p>每个组件常驻 dashed outline,嵌套时多层叠加</p>
<div class="tag-row">
<span class="tag warn">噪音大</span>
<span class="tag">层级一目了然</span>
</div>
</div>
</div>
<div class="canvas style-a" data-demo>
<div class="canvas-label">点击组件试试 · 悬停看 hover</div>
<div class="stack">
<div class="dt container" tabindex="0">
<div class="layout">
<div class="layout-title">栅格布局</div>
<div class="row">
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">申请人</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">部门</div>
<div class="field-ctrl">请选择</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
<div class="dt-btns"><span>+</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">请假事由</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
</article>
<!-- B -->
<article class="card" data-choice="B">
<div class="card-head">
<div class="badge">B</div>
<div>
<h2>悬停显框</h2>
<p>默认无描边;悬停出现浅色实线;选中为实线主色</p>
<div class="tag-row">
<span class="tag rec">推荐候选</span>
<span class="tag">操作时才出现辅助线</span>
</div>
</div>
</div>
<div class="canvas style-b" data-demo>
<div class="canvas-label">默认干净 · 鼠标移上去才有框</div>
<div class="stack">
<div class="dt container" tabindex="0">
<div class="layout">
<div class="layout-title">栅格布局</div>
<div class="row">
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">申请人</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">部门</div>
<div class="field-ctrl">请选择</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
<div class="dt-btns"><span>+</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">请假事由</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
</article>
<!-- C -->
<article class="card" data-choice="C">
<div class="card-head">
<div class="badge">C</div>
<div>
<h2>仅选中显框</h2>
<p>默认完全无框;悬停仅微弱底色;只有选中才有实线</p>
<div class="tag-row">
<span class="tag">最干净</span>
<span class="tag">嵌套边界稍难辨认</span>
</div>
</div>
</div>
<div class="canvas style-c" data-demo>
<div class="canvas-label">几乎像真实表单 · 点一下才高亮</div>
<div class="stack">
<div class="dt container" tabindex="0">
<div class="layout">
<div class="layout-title">栅格布局</div>
<div class="row">
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">申请人</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">部门</div>
<div class="field-ctrl">请选择</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
<div class="dt-btns"><span>+</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">请假事由</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
</article>
<!-- D -->
<article class="card" data-choice="D">
<div class="card-head">
<div class="badge">D</div>
<div>
<h2>淡化常驻虚线</h2>
<p>保留常驻提示,但透明度大幅降低;选中再加重</p>
<div class="tag-row">
<span class="tag">改动最小</span>
<span class="tag">多层仍在,只是变淡</span>
</div>
</div>
</div>
<div class="canvas style-d" data-demo>
<div class="canvas-label">虚线还在,但不再抢眼</div>
<div class="stack">
<div class="dt container" tabindex="0">
<div class="layout">
<div class="layout-title">栅格布局</div>
<div class="row">
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">申请人</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">部门</div>
<div class="field-ctrl">请选择</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
<div class="dt-btns"><span>+</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">请假事由</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
</article>
<!-- E -->
<article class="card" data-choice="E">
<div class="card-head">
<div class="badge">E</div>
<div>
<h2>混合:布局淡框 + 字段悬停</h2>
<p>容器保留极淡虚线帮辨层级;普通字段默认无框</p>
<div class="tag-row">
<span class="tag rec">推荐候选</span>
<span class="tag">兼顾布局与干净</span>
</div>
</div>
</div>
<div class="canvas style-e" data-demo>
<div class="canvas-label">只有「栅格」常驻淡框 · 字段悬停才出</div>
<div class="stack">
<div class="dt container" tabindex="0">
<div class="layout">
<div class="layout-title">栅格布局</div>
<div class="row">
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">申请人</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">部门</div>
<div class="field-ctrl">请选择</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
<div class="dt-btns"><span>+</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">请假事由</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
</article>
<!-- F -->
<article class="card" data-choice="F">
<div class="card-head">
<div class="badge">F</div>
<div>
<h2>左侧指示条(无四周虚线)</h2>
<p>用左边色条 + 选中内描边,彻底去掉虚线网格感</p>
<div class="tag-row">
<span class="tag">视觉更现代</span>
<span class="tag">实现略多一点</span>
</div>
</div>
</div>
<div class="canvas style-f" data-demo>
<div class="canvas-label">无虚线框 · 左侧色条表示可编辑区域</div>
<div class="stack">
<div class="dt container" tabindex="0">
<div class="layout">
<div class="layout-title">栅格布局</div>
<div class="row">
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">申请人</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">部门</div>
<div class="field-ctrl">请选择</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
<div class="dt-btns"><span>+</span><span>×</span></div>
</div>
<div class="dt leaf" tabindex="0">
<div class="field">
<div class="field-label">请假事由</div>
<div class="field-ctrl">请输入</div>
</div>
<div class="dt-btns"><span>⧉</span><span>×</span></div>
</div>
</div>
</div>
</article>
</div>
<footer>
<strong>怎么选?</strong>
想尽快落地、改动小 → 看 B / D;要保留布局层级感 → 看 E;想最接近真实表单 → 看 C;想换交互隐喻 → 看 F。
<div id="choice">请点击卡片标题区域选择倾向方案…</div>
</footer>
</div>
<script>
// 画布内:点击选中组件(互斥)
document.querySelectorAll('[data-demo]').forEach((canvas) => {
canvas.addEventListener('click', (e) => {
const dt = e.target.closest('.dt')
if (!dt || !canvas.contains(dt)) return
e.stopPropagation()
canvas.querySelectorAll('.dt.active').forEach((el) => el.classList.remove('active'))
dt.classList.add('active')
})
})
// 卡片选择(方案偏好)
const choiceEl = document.getElementById('choice')
const selected = new Set()
document.querySelectorAll('.card').forEach((card) => {
card.addEventListener('click', (e) => {
// 若点在画布控件上,不切换卡片选中(避免冲突)
if (e.target.closest('[data-demo] .dt')) return
const key = card.dataset.choice
if (selected.has(key)) {
selected.delete(key)
card.classList.remove('selected')
} else {
selected.add(key)
card.classList.add('selected')
}
choiceEl.textContent = selected.size
? '当前倾向:方案 ' + [...selected].sort().join('、')
: '请点击卡片标题区域选择倾向方案…'
})
})
</script>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
-- OA 发起人填写草稿(非模板设计草稿)
CREATE TABLE IF NOT EXISTS `oa_instance_draft` (
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
`app_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '应用ID',
`app_version_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '暂存时锁定的已发布版本ID',
`title` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '标题',
`form_data_json` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '表单数据 JSON',
`initiator_select_json` text COLLATE utf8mb4_unicode_ci COMMENT '发起人自选 JSON',
`initiator_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '发起人部门',
`owner_user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿所属人',
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_oid_owner` (`tenant_no`,`owner_user_id`,`del_flag`) USING BTREE,
KEY `idx_oid_app` (`tenant_no`,`app_id`,`del_flag`) USING BTREE,
KEY `idx_oid_update` (`tenant_no`,`owner_user_id`,`update_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA发起审批草稿';
-- 草稿箱位于「发起审批」与「待办中心」之间;仅首次插入时顺延 order_num
UPDATE `sys_menu` AS m
INNER JOIN (
SELECT 1 AS ok
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu`
WHERE `id` = 'OA000000000000000000000000000109' AND `del_flag` = 0
)
) AS guard ON 1 = 1
SET m.`order_num` = m.`order_num` + 1
WHERE m.`parent_id` = 'OA000000000000000000000000000001'
AND m.`del_flag` = 0
AND m.`order_num` IS NOT NULL
AND m.`order_num` >= 4;
INSERT INTO `sys_menu` (
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
) SELECT
'OA000000000000000000000000000109', '草稿箱', 'OA000000000000000000000000000001',
'C', 'drafts', '/oa-approval/drafts/index', 'oa:drafts',
1, '1', '1', 'zkxw', 4, 'OA发起审批草稿箱',
NULL, '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `id` = 'OA000000000000000000000000000109' AND `del_flag` = 0
);
-- 已拥有「发起审批」菜单的角色自动获得「草稿箱」(幂等)
-- 注意:MySQL 5.7 下 INSERT…SELECT 中 UUID() 可能整批相同,导致主键冲突;改用按行唯一的 MD5。
INSERT INTO `sys_role_menu` (
`id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT
MD5(CONCAT(rm.`role_id`, '#', 'OA000000000000000000000000000109')),
rm.`role_id`,
'OA000000000000000000000000000109',
COALESCE(NULLIF(rm.`tenant_no`, ''), 'zkxw'),
0,
'auto from oa:start',
NULL,
'',
NOW(),
NULL,
NOW(),
0
FROM `sys_role_menu` rm
WHERE rm.`menu_id` = 'OA000000000000000000000000000103'
AND rm.`del_flag` = 0
AND NOT EXISTS (
SELECT 1
FROM `sys_role_menu` x
WHERE x.`role_id` = rm.`role_id`
AND x.`menu_id` = 'OA000000000000000000000000000109'
AND x.`del_flag` = 0
);
-37
View File
@@ -1,37 +0,0 @@
-- docs/sql/oa_receive_account.sql
CREATE TABLE IF NOT EXISTS `oa_receive_account` (
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
`account_type` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'alipay/personal/corporate',
`account_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '户名',
`account_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '账号',
`bank_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '银行',
`bank_province` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '省',
`bank_city` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '市',
`bank_branch` varchar(256) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '支行',
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_ora_tenant` (`tenant_no`,`del_flag`) USING BTREE,
KEY `idx_ora_type` (`tenant_no`,`account_type`,`del_flag`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA收款账户';
INSERT INTO `sys_menu` (
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
) SELECT
'OA000000000000000000000000000108', '收款账户管理', 'OA000000000000000000000000000001',
'C', 'receive-accounts', '/oa-approval/receive-accounts/index', 'oa:receive-accounts:list',
1, '1', '1', 'zkxw', 10, 'OA收款账户主数据',
NULL, '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `id` = 'OA000000000000000000000000000108' AND `del_flag` = 0
);
-19
View File
@@ -1,19 +0,0 @@
-- docs/sql/sys_post_role.sql
-- 职位-角色关联:职位可绑定多个角色,用户经职位继承角色权限(与直接角色取并集)
CREATE TABLE IF NOT EXISTS `sys_post_role` (
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
`post_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '职位ID',
`role_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色ID',
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_spr_post` (`tenant_no`,`post_id`,`del_flag`) USING BTREE,
KEY `idx_spr_role` (`tenant_no`,`role_id`,`del_flag`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位-角色关联';
-5
View File
@@ -1,5 +0,0 @@
-- docs/sql/sys_user_hire_probation.sql
-- 用户表补齐入职日期、试用期月数(转正套件主数据)
ALTER TABLE `sys_user`
ADD COLUMN `hire_date` date DEFAULT NULL COMMENT '入职日期' AFTER `phone_no`,
ADD COLUMN `probation_months` int(11) DEFAULT NULL COMMENT '试用期月数' AFTER `hire_date`;
-4
View File
@@ -1,4 +0,0 @@
-- docs/sql/sys_user_last_work_day.sql
-- 用户表补齐最后工作日(离职套件主数据)
ALTER TABLE `sys_user`
ADD COLUMN `last_work_day` date DEFAULT NULL COMMENT '最后工作日' AFTER `probation_months`;
@@ -0,0 +1,32 @@
# 角色列表有效用户与关联职位 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 角色列表支持名称快捷查看,并展示有效拥有用户与关联职位。
**Architecture:** 后端在 `list_roles` 批量 enrichment `boundUserNames` / `boundPostNames`;前端对齐职位列表的 link 与多值换行单元格。
**Tech Stack:** FastAPI/SQLAlchemy、Vue 3 + Element Plus
---
### Task 1: 后端 enrichment
**Files:** `backend/app/crud/role.py`, `backend/tests/test_role_list_bound_names.py`
- [x] 实现 `_bound_user_names_by_role_ids`、`_bound_post_names_by_role_ids`
- [x] `list_roles` / `_row_to_dict` 输出新字段
- [x] 空入参与 enrichment 调用的单测
### Task 2: 前端列表
**Files:** `web-admin/src/types/api.d.ts`, `web-admin/src/views/system/role/index.vue`
- [x] `RoleItem` 增加字段
- [x] 角色名称 link → `handleView`
- [x] 「拥有用户」「关联职位」两列
### Task 3: 验证与图谱
- [x] `pytest` 相关用例(4 passed)
- [ ] `./scripts/update-graph.sh backend`(graphify 因节点数变化拒绝覆盖,可后续 `force` 重跑)
@@ -0,0 +1,128 @@
# 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:** 发起审批可暂存草稿,并新增「草稿箱」菜单支持再编辑、保存、提交与删除。
**Architecture:** 独立表 `oa_instance_draft` 存发起人填写草稿;提交时复用 `oa_engine.start_instance`,成功后软删草稿。前端在发起弹窗加「暂存草稿」,新建 `drafts/index.vue` 列表页。
**Tech Stack:** FastAPI + SQLAlchemy + MySQL;Vue 3 + Element Plus;Fresh Mint UI。
**Spec:** `docs/superpowers/specs/2026-08-06-oa-approval-instance-draft-design.md`
**UI Demo:** `docs/demos/oa-approval-draft-box-demo.html`
---
## File map
| 文件 | 职责 |
|------|------|
| `docs/sql/oa_instance_draft.sql` | 建表 + `sys_menu` 草稿箱 |
| `backend/app/models/models.py` | `OaInstanceDraft` |
| `backend/app/crud/oa_instance_draft.py` | 草稿 CRUD |
| `backend/app/api/oa_approval.py` | drafts 路由 |
| `backend/tests/test_oa_instance_draft.py` | 权限/序列化/提交后软删等单测 |
| `web-admin/src/api/oaApproval.ts` | drafts API |
| `web-admin/src/router/modules/sqxp.ts` | 路由「草稿箱」 |
| `web-admin/src/views/oa-approval/drafts/index.vue` | 草稿箱页 |
| `web-admin/src/views/oa-approval/start/index.vue` | 暂存按钮 + draftId 回填 |
| `待同步到生产的sql脚本` | 追加本 SQL 引用说明(若团队习惯) |
---
### Task 1: SQL 建表与菜单
**Files:**
- Create: `docs/sql/oa_instance_draft.sql`
- [x] **Step 1: 编写 SQL**
- [x] **Step 2: 本地执行 SQL(若有 DB)** — 脚本已写入 `docs/sql/oa_instance_draft.sql`,需在目标库手动执行
- [x] **Step 3: Commit** — 待用户要求再提交
---
### Task 2: SQLAlchemy 模型
**Files:**
- Modify: `backend/app/models/models.py`(紧挨 `OaInstance` 之前或之后)
- [ ] **Step 1: 增加 `OaInstanceDraft`**
- [ ] **Step 2: Commit** `feat(backend): add OaInstanceDraft model.`
---
### Task 3: CRUD + API(TDD)
**Files:**
- Create: `backend/app/crud/oa_instance_draft.py`
- Modify: `backend/app/api/oa_approval.py`
- Create: `backend/tests/test_oa_instance_draft.py`
- [ ] **Step 1: 写失败/单元测试**(`_row_to_dict` 字段、owner 校验逻辑可用纯函数;提交后 `del_flag` 行为可用 mock Session)
- [ ] **Step 2: 实现 CRUD**
- `list_drafts`, `get_draft`, `create_draft`, `update_draft`, `soft_delete`, `batch_soft_delete`
- list join `OaApp` 取 `appName`、version_no;仅 `owner_user_id`
- [ ] **Step 3: 挂路由**
- GET/POST `/drafts`
- GET/PUT/DELETE `/drafts/{id}`
- POST `/drafts/batch-delete`
- POST `/drafts/{id}/submit` → `start_instance` + soft delete
- [ ] **Step 4: pytest 通过**
- [ ] **Step 5: Commit** `feat(backend): OA instance draft CRUD and submit API.`
---
### Task 4: 前端 API + 路由
**Files:**
- Modify: `web-admin/src/api/oaApproval.ts`
- Modify: `web-admin/src/router/modules/sqxp.ts`(发起审批与待办之间插入 drafts)
- Modify: `web-admin/src/types/api.d.ts`(若有 OA 类型需补)
- [ ] **Step 1: API 封装**
- [ ] **Step 2: 路由 meta.title=`草稿箱`, menuCode=`oa:drafts`**
- [ ] **Step 3: Commit**
---
### Task 5: 草稿箱页面
**Files:**
- Create: `web-admin/src/views/oa-approval/drafts/index.vue`
- [ ] **Step 1: 按 Demo +「我发起的」模式做列表**(筛选、表格、分页、操作列 min-width≥260、状态 Tag)
- [ ] **Step 2: 编辑打开弹窗(可内嵌复用 start 逻辑或跳转 `start?draftId=`)——**采用 `start?draftId=`** 减少重复
- [ ] **Step 3: 提交/删除/批量删除接线**
- [ ] **Step 4: Commit**
---
### Task 6: 发起页暂存与回填
**Files:**
- Modify: `web-admin/src/views/oa-approval/start/index.vue`
- [ ] **Step 1: footer 增加「暂存草稿」**(取消 / 暂存 / 提交;主色仅提交)
- [ ] **Step 2: `route.query.draftId` 加载详情并回填;保存走 PUT**
- [ ] **Step 3: 暂存成功提示并关弹窗**
- [ ] **Step 4: Commit**
---
### Task 7: 收尾
- [ ] **Step 1:** `./scripts/update-graph.sh all`(或 backend+frontend)
- [ ] **Step 2:** 自测清单对照 spec「测试要点」
- [ ] **Step 3:** 确认 `App.vue` panelTitleBarTextRules 含「草稿箱」(若需要竖条)
---
## 验收
1. 发起弹窗可暂存,草稿箱可见。
2. 编辑回填 → 再保存 → 提交后出现在「我发起的」,草稿消失。
3. 他人账号看不到该草稿。
4. 暂存不产生待办任务。
@@ -0,0 +1,41 @@
# 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:** 侧栏「草稿箱」「待办中心」展示数量角标,对齐「抄送我的」。
**Architecture:** 后端增加轻量 count 接口;前端扩展 `oa-menu-badge` store 与 `refreshOaMenuBadges`,在相关页操作后刷新。
**Tech Stack:** FastAPI / SQLAlchemy / Vue 3 / Pinia
**Spec:** `docs/superpowers/specs/2026-08-06-oa-drafts-pending-menu-badge-design.md`
---
### Task 1: Backend count helpers + API
**Files:**
- Modify: `backend/app/services/oa_engine.py`(`count_pending_tasks`)
- Modify: `backend/app/crud/oa_instance_draft.py`(`count_drafts`)
- Modify: `backend/app/api/oa_approval.py`
- Test: `backend/tests/test_oa_menu_badge_counts.py`
- [x] Step 1: 写失败单测(mock query.count)
- [x] Step 2: 实现 `count_pending_tasks` / `count_drafts` + 路由
- [x] Step 3: 跑通 pytest
### Task 2: Frontend badge wiring
**Files:**
- Modify: `web-admin/src/store/modules/oa-menu-badge.ts`
- Modify: `web-admin/src/utils/oa-menu-badge.ts`
- Modify: `web-admin/src/api/oaApproval.ts`
- Modify: `web-admin/src/views/oa-approval/drafts/index.vue`
- Modify: `web-admin/src/views/oa-approval/pending/index.vue`
- Modify: `web-admin/src/views/oa-approval/start/index.vue`
- Modify: `web-admin/src/views/oa-approval/instances/detail.vue`
- Modify: `web-admin/src/store/modules/user.ts`
- [x] Step 1: API + store codes + refresh 并行拉取
- [x] Step 2: 页面操作后 refresh;logout clear
- [x] Step 3: 更新 graphify(frontend + backend;HTML viz 过大时跳过)
@@ -0,0 +1,21 @@
# OA Form Designer Outline Style E 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:** Reduce canvas outline noise in OA form designer using hybrid style E (soft dashed for containers, hover/active for leaves).
**Architecture:** CSS-only overrides in `oa-fc-host/App.vue` targeting FormCreate `_fd-drag-tool`. Containers detected via `:has(._fd-drag-tool)`.
**Tech Stack:** Vue 3, `@form-create/designer`, SCSS/CSS in iframe host.
---
### Task 1: Add hybrid outline CSS
**Files:**
- Modify: `web-admin/src/oa-fc-host/App.vue`
- [x] Add leaf default transparent outline + hover solid soft mint + active solid mint
- [x] Add container `:has(._fd-drag-tool)` soft dashed default
- [x] Override hardcoded `#2E73FF` on `.active` outline and `._fd-drag-btn` to mint
- [ ] Manual verify in `/#/system-manage/oa-approval/designer/:id` → 表单设计 tab
@@ -0,0 +1,453 @@
# 测试环境裸机部署 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:** 在 `101.35.245.122` 的 `/root/zkxw` 以裸机方式部署 web-admin(Nginx :10001)与 backend(127.0.0.1:10002),中间件复用本机开发环境的 `47.99.164.66`。
**Architecture:** 本机 `pnpm build:test` + rsync 前端静态资源;本机 rsync backend 源码;测试机安装 Nginx、用已有 Python 3.12.3 建 `.venv`、`start-test.sh` 绑定本机回环。Nginx 反代 `/api` 与 `/health`,不反代 MinIO。
**Tech Stack:** Nginx、Python 3.12.3 + venv + uvicorn、`backend/start-test.sh`、Vue 3 web-admin(`pnpm build:test`)、rsync/ssh
**Spec:** `docs/superpowers/specs/2026-08-06-test-env-bare-metal-deploy-design.md`
---
## File Structure
| 路径 | 职责 |
|------|------|
| `测试环境部署/zkxw-test-10001.conf` | 新建:测试机 Nginx server 块(10001) |
| `测试环境部署/README.md` | 改写:新拓扑、端口、安装与发版步骤 |
| `web-admin/.env.test` | 仅更新注释中的访问入口 IP/端口 |
| `docs/superpowers/specs/2026-08-06-test-env-bare-metal-deploy-design.md` | 已存在,实现时勿改需求;若步骤偏差仅改计划 |
| 服务器 `/root/zkxw/**` | 运行时产物与 `.env.test`(不进 git) |
| `测试环境机器的账号密码` | 仅本机 SSH 用,**禁止** commit / 写入计划正文 |
不新建 Docker compose;不在服务器装 Node。
---
### Task 1: 仓库内新增 Nginx site 片段
**Files:**
- Create: `测试环境部署/zkxw-test-10001.conf`
- [ ] **Step 1: 创建完整 Nginx 配置文件**
写入以下内容(无 MinIO 反代;API/health 指向本机 10002):
```nginx
# ZKXW 测试环境(裸机)— 部署到 101.35.245.122
# 用法(按发行版二选一):
# Ubuntu/Debian: 复制到 /etc/nginx/sites-available/zkxw-test-10001.conf
# ln -sf ... /etc/nginx/sites-enabled/
# RHEL/CentOS: 复制到 /etc/nginx/conf.d/zkxw-test-10001.conf
# nginx -t && systemctl reload nginx
server {
listen 10001;
listen [::]:10001;
server_name 101.35.245.122 localhost 127.0.0.1;
root /root/zkxw/web-admin/dist;
index index.html;
client_max_body_size 100M;
access_log /var/log/nginx/zkxw_admin_10001_access.log;
error_log /var/log/nginx/zkxw_admin_10001_error.log;
location = /health {
proxy_pass http://127.0.0.1:10002/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
}
location ^~ /docs {
proxy_pass http://127.0.0.1:10002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://127.0.0.1:10002/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location ^~ /api/ {
proxy_pass http://127.0.0.1:10002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
```
- [ ] **Step 2: 本地核对关键指令**
Run:
```bash
grep -E 'listen|proxy_pass|root |minio' 测试环境部署/zkxw-test-10001.conf
```
Expected: `listen 10001`;`root /root/zkxw/web-admin/dist`;`proxy_pass` 均为 `127.0.0.1:10002`;**无** `minio` 行。
- [ ] **Step 3: Commit(仅当用户要求提交时执行;否则跳过)**
```bash
git add 测试环境部署/zkxw-test-10001.conf
git commit -m "$(cat <<'EOF'
docs: add nginx site for test env on port 10001
EOF
)"
```
---
### Task 2: 更新测试部署 README 与前端 env 注释
**Files:**
- Modify: `测试环境部署/README.md`(全文按新拓扑重写核心章节;可保留文末修订记录并追加一行)
- Modify: `web-admin/.env.test`(仅注释)
- [ ] **Step 1: 重写 `测试环境部署/README.md` 关键**
用下列结构覆盖/重写文档(保留必要历史可放「修订记录」)。**正文不要写入任何数据库密码或机器 root 密码。**
必须包含:
1. 拓扑表:
| 角色 | 地址 |
|------|------|
| 入口 | `http://101.35.245.122:10001` |
| Nginx 静态 | `/root/zkxw/web-admin/dist` |
| 后端 | `127.0.0.1:10002`(仅本机) |
| 中间件 | 与本机 `backend/.env` 相同(`47.99.164.66`) |
| MinIO 浏览器 | `http://47.99.164.66:20004/zkxw`(直连,Nginx 不反代) |
2. 机装:Python **已有 3.12.3**;**需安装 Nginx**;探测 `apt`/`yum`/`dnf` 后安装示例:
```bash
# Debian/Ubuntu
sudo apt-get update && sudo apt-get install -y nginx
# RHEL/CentOS/Alma(示例)
sudo yum install -y nginx # 或 dnf install -y nginx
sudo systemctl enable --now nginx
```
3. 目录、`.env.test` 来源(从本机 `backend/.env` 复制为服务器 `.env.test`)、启动:
```bash
cd /root/zkxw/backend
python3.12 -m venv .venv
./.venv/bin/pip install -U pip
./.venv/bin/pip install -r requirements.txt
HOST=127.0.0.1 PORT=10002 ./start-test.sh start
```
4. Nginx 启用 `zkxw-test-10001.conf` 的命令。
5. 日常发版 rsync 命令(与 spec §6.2 一致)。
6. 验收 curl(与 spec §7 一致)。
7. 说明:旧 8112 / 双机拓扑已废弃,以本文件为准。
- [ ] **Step 2: 更新 `web-admin/.env.test` 注释**
将文件头注释改为类似:
```env
# Web Admin 测试环境(打包:pnpm build:test)
# 访问入口:http://101.35.245.122:10001
# Nginx 将 /api 反代至本机 127.0.0.1:10002
# MinIO:浏览器直连 http://47.99.164.66:20004/zkxw(不经测试机 Nginx)
```
保持现有 `VITE_*` 变量值不变(`VITE_API_URL=/api`、`VITE_MINIO_BASE_URL=http://47.99.164.66:20004/zkxw` 等)。
- [ ] **Step 3: Commit(仅当用户要求时)**
```bash
git add 测试环境部署/README.md web-admin/.env.test
git commit -m "$(cat <<'EOF'
docs: document bare-metal test deploy on 10001/10002
EOF
)"
```
---
### Task 3: SSH 探测服务器并安装 Nginx
**Files:** 无仓库文件;操作目标 `101.35.245.122`
前置:本机可 SSH 登录(密钥或密码)。**不要**把密码写进仓库或命令历史脚本文件。
- [ ] **Step 1: 探测 OS、Python、端口占用**
```bash
ssh root@101.35.245.122 'set -e
uname -a
cat /etc/os-release | head -20
python3.12 --version || python3 --version
which nginx || true
ss -lntp | grep -E "10001|10002" || true
mkdir -p /root/zkxw/web-admin/dist /root/zkxw/backend /root/zkxw/nginx
'
```
Expected: Python 显示 3.12.x;nginx 可能不存在;目录创建成功。
- [ ] **Step 2: 按发行版安装 Nginx 并开机自启**
若 `ID` 为 ubuntu/debian:
```bash
ssh root@101.35.245.122 'export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y nginx
systemctl enable nginx
systemctl start nginx
nginx -v
'
```
若为 rhel/centos/almalinux/rocky:
```bash
ssh root@101.35.245.122 'yum install -y nginx || dnf install -y nginx
systemctl enable nginx
systemctl start nginx
nginx -v
'
```
Expected: `nginx version: nginx/...`
- [ ] **Step 3: 上传并启用 site**
```bash
scp 测试环境部署/zkxw-test-10001.conf root@101.35.245.122:/root/zkxw/nginx/zkxw-test-10001.conf
```
然后在服务器上(按 Step 1 探测结果选一条路径):
Debian/Ubuntu:
```bash
ssh root@101.35.245.122 '
cp /root/zkxw/nginx/zkxw-test-10001.conf /etc/nginx/sites-available/zkxw-test-10001.conf
ln -sfn /etc/nginx/sites-available/zkxw-test-10001.conf /etc/nginx/sites-enabled/zkxw-test-10001.conf
# 若 default 占口冲突可保留;本 site 仅 10001
nginx -t && systemctl reload nginx
'
```
RHEL 系:
```bash
ssh root@101.35.245.122 '
cp /root/zkxw/nginx/zkxw-test-10001.conf /etc/nginx/conf.d/zkxw-test-10001.conf
nginx -t && systemctl reload nginx
'
```
Expected: `nginx: configuration file ... test is successful`
- [ ] **Step 4: 提醒安全组**
确认云控制台入站已放行 **TCP 10001**(本步无法在代码中完成;执行者勾选确认)。
---
### Task 4: 部署 backend 并启动
**Files:** 服务器 `/root/zkxw/backend`;本机源 `backend/`
- [ ] **Step 1: rsync 源码**
在仓库根目录执行:
```bash
rsync -avz \
--exclude '.venv' \
--exclude 'venv' \
--exclude '__pycache__' \
--exclude '*.pyc' \
--exclude 'logs' \
--exclude 'graphify-out' \
--exclude '.env' \
--exclude '.env.*' \
backend/ root@101.35.245.122:/root/zkxw/backend/
```
Expected: `start-test.sh`、`requirements.txt`、`app/` 出现在远端。
- [ ] **Step 2: 生成并上传 `.env.test`(不进 git)**
本机:
```bash
cp backend/.env /tmp/zkxw-env.test
# 确认中间件段与开发一致;勿把 /tmp/zkxw-env.test 加入 git
scp /tmp/zkxw-env.test root@101.35.245.122:/root/zkxw/backend/.env.test
rm -f /tmp/zkxw-env.test
```
- [ ] **Step 3: 建 venv 并装依赖**
```bash
ssh root@101.35.245.122 '
set -e
cd /root/zkxw/backend
python3.12 -m venv .venv
./.venv/bin/pip install -U pip
./.venv/bin/pip install -r requirements.txt
./.venv/bin/python -c "import uvicorn; print(uvicorn.__version__)"
chmod +x start-test.sh
'
```
Expected: 打印 uvicorn 版本;无 pip 失败。
- [ ] **Step 4: 探测中间件连通性(从测试机)**
```bash
ssh root@101.35.245.122 '
set -e
timeout 5 bash -c "echo >/dev/tcp/47.99.164.66/3306" && echo mysql_ok
timeout 5 bash -c "echo >/dev/tcp/47.99.164.66/16379" && echo redis_ok
timeout 5 bash -c "echo >/dev/tcp/47.99.164.66/20004" && echo minio_ok
'
```
Expected: 三行 `*_ok`。若失败:先修安全组/防火墙,**不要**继续启动(否则后端会连库失败)。
- [ ] **Step 5: 启动后端**
```bash
ssh root@101.35.245.122 '
cd /root/zkxw/backend
HOST=127.0.0.1 PORT=10002 ./start-test.sh start
HOST=127.0.0.1 PORT=10002 ./start-test.sh status
curl -s http://127.0.0.1:10002/health
'
```
Expected: status 为 RUNNING;health 返回 JSON/成功体(非连接拒绝)。
---
### Task 5: 本机打包前端并 rsync
**Files:**
- Build: `web-admin/dist/`
- Remote: `/root/zkxw/web-admin/dist/`
- [ ] **Step 1: 打包**
```bash
cd web-admin
pnpm install
pnpm build:test
ls dist/index.html
```
Expected: `dist/index.html` 存在。
- [ ] **Step 2: 上传**
```bash
rsync -avz --delete web-admin/dist/ root@101.35.245.122:/root/zkxw/web-admin/dist/
```
(在仓库根执行时路径如上;若已在 `web-admin` 目录则用 `dist/`。)
Expected: rsync 完成无 error。
---
### Task 6: 端到端验收
- [ ] **Step 1: 本机/外网验收命令**
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://101.35.245.122:10001/
curl -s http://101.35.245.122:10001/health
curl -s -o /dev/null -w '%{http_code}\n' --connect-timeout 5 http://101.35.245.122:10002/health || echo '10002_closed_ok'
```
Expected:
- 第一行:`200`
- 第二行:健康检查成功响应
- 第三行:失败或超时,并打印 `10002_closed_ok`(证明未对公网开放)
- [ ] **Step 2: API 冒烟**
```bash
curl -s -o /dev/null -w '%{http_code}\n' \
http://101.35.245.122:10001/api/web/auth/login-rsa-public-key
```
Expected: `200`(或业务约定的成功码;非 502/504)。
- [ ] **Step 3: 浏览器手工**
打开 `http://101.35.245.122:10001/`,确认页面加载;用测试账号登录(默认文档账号以环境实际为准,常见 `admin`)。附件若打不开,检查浏览器到 `47.99.164.66:20004` 是否可达。
- [ ] **Step 4: 更新设计/README 修订记录(可选)**
在 `测试环境部署/README.md` 修订记录追加:`2026-08-06 | 裸机部署 10001/10002 于 101.35.245.122`。
- [ ] **Step 5: Commit 仓库文档变更(仅当用户要求)**
将 Task 1–2 产生的仓库文件与本计划、spec 一并提交(**排除** `测试环境机器的账号密码`、任何 `.env.test`)。
---
## Spec coverage (self-review)
| Spec 要求 | Task |
|-----------|------|
| 无 Docker 应用部署 | Task 3–5(裸机) |
| Nginx 10001 + 静态 + /api + /health | Task 1, 3 |
| 后端 127.0.0.1:10002 | Task 4 |
| 中间件照搬本机 .env | Task 4 Step 2 |
| MinIO 直连不反代 | Task 1(配置无 minio)、验收说明 |
| 安装 Nginx;Python 用 3.12.3 | Task 3, 4 |
| 本机 build:test + rsync | Task 5 |
| 验收清单 | Task 6 |
| 更新 README / env 注释 / nginx 片段 | Task 1–2 |
| 不提交密钥 | 全文约束 |
Placeholder scan: 无 TBD;发行版分支在 Task 3 用探测后二选一,非占位。
---
## Execution notes for agents
- SSH 失败时先修网络/密钥,勿把 root 密码写入 git。
- `start-test.sh` 每次启停都带上 `HOST=127.0.0.1 PORT=10002`,避免回到默认 `0.0.0.0:8112`。
- 依赖安装若因缺少 `python3.12-venv` 失败:Debian 上 `apt-get install -y python3.12-venv` 后再建 venv。
@@ -0,0 +1,29 @@
# 角色列表:有效用户与关联职位
**日期:** 2026-08-05
**状态:** 已确认
## 目标
角色管理列表增强可读性:角色名称可点开查看;展示有效拥有该角色的用户,以及关联该角色的职位。
## 列表展示
| 列 | 行为 |
|----|------|
| 角色名称 | 主色 link,点击打开现有查看弹窗 |
| 拥有用户 | 有效拥有(直接绑定 ∪ 职位继承),`、` 分隔,多则换行;空为 `—` |
| 关联职位 | `sys_post_role` 关联且启用未删的职位名,同上 |
## API enrichment
`GET /web/admin/role-management` 每条角色附加:
- `boundUserNames: string[]` — 有效用户显示名(`nick_name` 优先,否则 `user_no`),去重保序
- `boundPostNames: string[]` — 关联启用职位名,去重保序
有效用户语义与 `get_effective_role_ids` 一致:角色启用;直接 `sys_user_role`;或经启用职位 `sys_post_role` + `sys_user_post`。用户 `del_flag=0`。租户隔离与列表一致。批量查询,禁止 N+1。
## 非目标
不改查看弹窗结构、角色增删改、菜单/数据权限配置 UI。
@@ -0,0 +1,41 @@
# 用户角色双轨读模型(方案 B)
**日期:** 2026-08-05
**状态:** 已确认,按此实现
**前置:** [2026-08-02-post-role-permission-design.md](./2026-08-02-post-role-permission-design.md)
## 决策
| 项 | 结论 |
|----|------|
| 权限模型 | 长期双轨:直接绑定 ∪ 职位继承(运行时并集,已落地) |
| 用户管理写入 UI | **不提供**直接绑角色;日常仅配职位 |
| 直接绑定运维 | API / 脚本 / 库表,或专用授权模块(如质检金额查看) |
| 用户详情读模型 | 区分直接 / 职位继承 / 有效三层,便于审计 |
## 字段约定(用户详情)
| 字段 | 含义 |
|------|------|
| `roleIds` | 仅 `sys_user_role` 直接绑定(写路径 / 运维) |
| `postInheritedRoleIds` | 经启用职位继承的启用角色 ID |
| `effectiveRoleIds` | 有效角色 ID = 启用直接 ∪ 职位继承(与 `get_effective_role_ids` 一致) |
| `effectiveRoleNames` | 有效角色名称(去重保序) |
| `postIds` | 用户职位(不变) |
## 写入规则
- 用户新增/编辑 UI **不提交** `roleIds`。
- `update_user` 关联替换:**仅当请求体显式带 `roleIds` / `postIds` 时替换对应一侧**;只改职位不得清空直接角色。
- 菜单权限、数据权限、登录门禁继续走有效角色并集;本变更不改运行时语义。
## 前端
- 查看弹窗:展示有效角色,并只读附「直接绑定」「职位继承」。
- 新增/编辑:仅职位多选,无角色写入控件。
## 非目标
- 恢复用户页直接绑角色 UI
- 角色页反向关联职位
- 专用授权模块(如 `qc_material_amount_auth`)改为职位继承
@@ -0,0 +1,123 @@
# OA 发起审批草稿箱 Design
| 项 | 内容 |
|----|------|
| 日期 | 2026-08-06 |
| 状态 | 已定稿(UI 以 `docs/demos/oa-approval-draft-box-demo.html` 为准) |
| 范围 | 发起审批「暂存草稿」+ 侧栏「草稿箱」列表(再编辑 / 保存 / 提交 / 删除) |
## 背景
现状「发起审批」弹窗仅有「取消」「提交」,填到一半关闭即丢失。客户要求可暂存草稿,并在「发起审批」下方增加「草稿箱」菜单集中管理。
现有 `oa_app_version.status=draft` 仅为**模板设计草稿**,与本需求的**发起人填写草稿**无关,不得混用。
## 目标与非目标
**目标**
- 发起弹窗可「暂存草稿」,不进入审批流、不产生待办。
- 侧栏「草稿箱」列出当前用户草稿;支持编辑、再次保存、提交审批、删除与批量删除。
- 草稿仅本人可见(租户内 + 发起人 = 当前用户)。
**非目标**
- 不与「我发起的」实例混排;草稿不是 `oa_instance`。
- 不做跨用户共享草稿、不做审批人代填。
- 不做自动定时暂存(仅显式点击「暂存草稿 / 保存草稿」)。
- 不改造小程序端(本期仅 web-admin)。
## 方案对比与结论
| 方案 | 做法 | 优点 | 缺点 |
|------|------|------|------|
| A(推荐) | 独立表 `oa_instance_draft` | 与引擎解耦;「我发起的」无需过滤 draft;语义清晰 | 多一张表与一套 CRUD |
| B | `oa_instance.status=draft` | 少表 | 引擎/列表/关联审批处处要排除;误发起风险高 |
| C | 仅浏览器 localStorage | 无后端 | 换设备丢失;无法做菜单级权限与审计 |
**选定 A。** 提交时调用现有 `start_instance`,成功后软删对应草稿。
## 数据模型
表名:`oa_instance_draft`
| 字段 | 说明 |
|------|------|
| `id` | VARCHAR(64) PK,UUID |
| `app_id` | 审批应用 ID |
| `app_version_id` | 暂存时锁定的已发布版本(提交时优先用最新已发布版,见下) |
| `title` | 可选标题(可由应用名或表单字段推导,可空) |
| `form_data_json` | 表单数据 JSON |
| `initiator_select_json` | 发起人自选 `{nodeId:[userId,...]}` |
| `initiator_dept_id` | 发起部门(与发起接口一致) |
| `owner_user_id` | 草稿所属人(= 创建人) |
| 公共字段 | `tenant_no` … `del_flag`(遵循 database-conventions) |
约束与行为:
- 同一用户对同一 `app_id` **允许多条**草稿(与 Demo 列表一致)。
- 列表默认 `create_time DESC` 不适用时,用 **`update_time DESC`**(最近保存优先)。
- 删除:软删 `del_flag=1`。
- **暂存不做完整必填校验**;**提交**走与正式发起相同的校验。
### 版本策略
- 暂存:写入当时「最新已发布」`app_version_id`。
- 编辑打开:用该草稿的 `app_id` 取**当前最新已发布**模板渲染;`form_data` / `initiator_select` 按 field 名回填(字段已删则忽略多余键)。
- 若应用已无已发布版本:编辑/提交时提示「该审批单已下架或未发布」,禁止提交,允许删除草稿。
## API(`/api/admin/oa-approval`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/drafts` | 当前用户草稿分页;query:`keyword`(流程名)、`groupId`、`updateTimeFrom`/`updateTimeTo`、`page`/`pageSize` |
| GET | `/drafts/{id}` | 草稿详情(含 formData、initiatorSelect、app 基础信息) |
| POST | `/drafts` | 新建草稿(`appId` + formData + initiatorSelect + initiatorDeptId + title?) |
| PUT | `/drafts/{id}` | 更新草稿(仅 owner) |
| DELETE | `/drafts/{id}` | 软删单条 |
| POST | `/drafts/batch-delete` | body: `{ ids: string[] }` 批量软删 |
| POST | `/drafts/{id}/submit` | 校验 + `start_instance`;成功后软删草稿;返回实例信息 |
权限:均需登录管理端用户;读写仅限 `owner_user_id == 当前用户` 且同 `tenant_no`。菜单码:`oa:drafts`(列表/读写统一挂此码,与 `oa:start` 同级授权习惯)。
## 前端
### 菜单与路由
- 侧栏顺序:… → 发起审批 → **草稿箱** → 待办中心 → …
- 路由:`/#/system-manage/oa-approval/drafts`,组件 `web-admin/src/views/oa-approval/drafts/index.vue`
- 前端路由模块 `sqxp.ts` 与库表 `sys_menu`(SQL 脚本)同步增加菜单项。
### 发起页 `start/index.vue`
- 弹窗底栏:**取消** | **暂存草稿** | **提交**(主色仅「提交」)。
- 「暂存草稿」:调用 POST/PUT drafts;成功提示「已存入草稿箱」并关弹窗(或保持打开——**关弹窗**,与 Demo 心智一致)。
- 若路由带 `draftId`(从草稿箱「编辑」跳入):打开对应模板并回填,暂存走 PUT。
### 草稿箱页(对齐「我发起的」列表模式)
列:序号、流程名称、模板版本、最近保存、状态(Tag「草稿」`warning`)、操作(编辑 / 提交审批 / 删除)。
- **编辑**:`router.push` 到发起页并带 `draftId`(或本页弹窗复用发起表单——**推荐跳转发页 + query**,少复制逻辑;若发起页已是弹窗模式,则在草稿箱页复用同一套 dialog 逻辑亦可。实现择一:**草稿箱页内嵌与 start 相同的发起弹窗**,避免整页跳转丢上下文)。
- **提交审批**:调用 `POST /drafts/{id}/submit`;若校验失败,打开编辑弹窗并展示错误。
- **删除 / 批量删除**:二次确认后调用删除接口。
筛选:流程名称、保存时间范围、模板分组(与 Demo 一致)。
## 错误处理
- 非 owner / 已删:404 或 403,文案「草稿不存在或无权访问」。
- 提交时应用不可用:400 + 明确文案。
- 批量删除:跳过无权 id,返回成功数(或全部失败则报错)——采用 **全部校验通过再删**,任一无权则整批失败。
## 测试要点
- 暂存后草稿箱可见;他人不可见。
- 更新草稿只改本人记录。
- 提交成功产生 `oa_instance` 且草稿软删;「我发起的」出现新实例。
- 暂存不产生 `oa_task`。
- 无发布版本时禁止提交。
## UI 权威
`docs/demos/oa-approval-draft-box-demo.html`;视觉遵循 Fresh Mint / `docs/web-admin-ui-spec-fresh-mint.md`。
@@ -0,0 +1,38 @@
# OA「草稿箱 / 待办中心」菜单角标 Design
| 项 | 内容 |
|----|------|
| 日期 | 2026-08-06 |
| 状态 | 已定稿(用户确认:对齐抄送角标,0 隐藏) |
| 范围 | 侧栏「草稿箱」「待办中心」数量角标 |
## 背景
「抄送我的」已落地未读角标。产品要求「草稿箱」「待办中心」同样展示数量统计。
## 目标
- 侧栏 `oa:drafts`:当前用户未删除草稿总数;0 隐藏。
- 侧栏 `oa:pending`:当前用户待审批任务数(`task_type=approval` + `status=pending`);0 隐藏。
- 样式与刷新机制复用现有 `oa-menu-badge`(与抄送一致:`99+`、路由守卫刷新、业务操作后 `refreshOaMenuBadges()`)。
## 非目标
- 不改列表页筛选语义;角标为无筛选全量计数。
- 不改造小程序。
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/drafts/count` | `{ count }`;须注册在 `/drafts/{draft_id}` 之前 |
| GET | `/tasks/pending/count` | `{ count }` |
约束:租户隔离 + 归属当前用户;`del_flag=0`。
## 前端
- 扩展 `OA_MENU_CODE_DRAFTS` / `OA_MENU_CODE_PENDING`;`resolveOaMenuBadgeCode` 识别 drafts/pending。
- `refreshOaMenuBadges` 按可见菜单并行拉取(含既有 cc)。
- 草稿箱增删/提交、发起页存草稿/提交、待办相关审批动作、`onActivated` 刷新角标。
- 退出登录时 `clearBadges`(补齐既有遗漏)。
@@ -0,0 +1,35 @@
# OA 表单设计器画布描边优化(方案 E)
**日期:** 2026-08-06
**状态:** 已确认
**范围:** `web-admin` OA 审批设计器「表单设计」Tab(`oa-fc-host` iframe)
## 背景
`@form-create/designer` 为每个画布组件包一层 `_fd-drag-tool`,默认常驻蓝色虚线 `outline`。嵌套布局时多层虚线叠加,影响操作与阅读。对比 Demo(`docs/demos/oa-form-designer-outline-styles.html`)后选定 **方案 E**。
## 目标行为
| 对象 | 识别 | 默认 | 悬停 | 选中 |
|------|------|------|------|------|
| 叶子字段 | `._fd-drag-tool` 且内部无子 `._fd-drag-tool` | 无描边 | 浅绿实线 | Mint 实线 `#10b981` |
| 布局容器 | `._fd-drag-tool:has(._fd-drag-tool)` | 极淡虚线 | 略加深 | Mint 实线 `#10b981` |
## 方案
- **仅 CSS 覆盖**,落在 `web-admin/src/oa-fc-host/App.vue` 的 `<style>`。
- 容器识别用 `:has()`;空布局暂按叶子处理,拖入子项后自动变为淡虚线容器。
- 将库内残留的选中框 / 拖拽按钮默认蓝 `#2E73FF` 统一为 Mint。
- **不做:** 预览态、左侧组件列表、运行时开关、改 node_modules。
## 验收
1. 打开审批设计器 →「表单设计」。
2. 拖入普通输入:默认无虚线;悬停有浅框;点击选中为实线。
3. 拖入栅格/布局并放入字段:外层常驻极淡虚线,内层字段默认无框。
4. 操作按钮背景为主色绿,无默认蓝。
## 参考
- Demo:`docs/demos/oa-form-designer-outline-styles.html`(方案 E)
- 库样式:`@form-create/designer` → `DragTool.vue`(`outline: 1px dashed var(--fc-tool-border-color)`)
@@ -0,0 +1,180 @@
# 测试环境裸机部署设计(101.35.245.122)
> 日期:2026-08-06
> 状态:已确认设计,待写实现计划
> 范围:在测试机部署 web-admin + backend;**不使用 Docker**;中间件复用开发环境已有实例
---
## 1. 背景与目标
在测试机 `101.35.245.122` 的 `/root/zkxw` 部署一套可外网访问的测试环境:
- 前端:本机 `pnpm build:test` 后 rsync 静态产物;Nginx 对外 **10001**
- 后端:源码 rsync 到机器,用 `start-test.sh` 监听 **127.0.0.1:10002**
- 中间件:与本机开发一致,连接 `47.99.164.66` 上的 MySQL / Redis / MinIO(含库名等)
- **不**在测试机用 Docker 跑应用或中间件
成功标准:
- 浏览器打开 `http://101.35.245.122:10001` 可使用管理端
- `/api` 经 Nginx 反代到本机后端,健康检查与登录可用
- 外网无法直连 `10002`;附件走 MinIO 直连地址
---
## 2. 架构与流量
```text
浏览器
└─ http://101.35.245.122:10001
└─ Nginx(测试机,需新装)
├─ / → /root/zkxw/web-admin/dist
├─ /api/* → http://127.0.0.1:10002
└─ (不反代 MinIO)
后端(start-test.sh)
HOST=127.0.0.1 PORT=10002
.env.test 中间件 ≈ 本机 backend/.env
→ MySQL 47.99.164.66:3306 / zkxw-sc
→ Redis 47.99.164.66:16379 DB=8
→ MinIO 47.99.164.66:20004 bucket=zkxw
浏览器附件
└─ 直连 http://47.99.164.66:20004/zkxw/...
```
约束:
- 云安全组 / 防火墙仅放行 **TCP 10001**
- 后端绑定 `127.0.0.1`,不对公网暴露 10002
- 前端 `VITE_API_URL=/api`;`VITE_MINIO_BASE_URL=http://47.99.164.66:20004/zkxw`
---
## 3. 服务器目录
```text
/root/zkxw/
├── web-admin/
│ └── dist/ # 本机 build:test 产物
├── backend/
│ ├── app/
│ ├── requirements.txt
│ ├── start-test.sh
│ ├── .env.test # 敏感,勿提交 git
│ ├── .venv/ # 服务器用 Python 3.12.3 创建
│ ├── backend-test.pid
│ └── logs/
└── nginx/
└── zkxw-test-10001.conf # 仓库维护的 site 片段,部署到系统 nginx
```
rsync 排除:`venv/`、`.venv/`、`__pycache__/`、`*.pyc`、`logs/`、`graphify-out/`;`.env*` 默认不覆盖,首次单独 scp。
---
## 4. 机装依赖(实测前提)
| 组件 | 状态 | 动作 |
|------|------|------|
| Python | 已装 **3.12.3** | 用 `python3.12 -m venv .venv`,无需再装 Python |
| Nginx | **未安装** | 按 OS 安装、开机自启、加载 10001 site |
| Docker | 已装但本方案不用 | 不启动 zkxw compose 应用栈 |
实现阶段须探测发行版(apt / yum / dnf)后写入准确安装命令。
---
## 5. 配置
### 5.1 backend `.env.test`
- 中间件字段与本机 `backend/.env` 对齐(同一 MySQL 库名、Redis DB、MinIO endpoint/bucket/密钥)
- `API_ENCRYPT_*` 与 `web-admin/.env.test` 一致
- 启动覆盖:`HOST=127.0.0.1 PORT=10002`(脚本默认曾为 `0.0.0.0:8112`)
### 5.2 web-admin `.env.test`
- 保持 `VITE_API_URL=/api`、`VITE_MINIO_BASE_URL=http://47.99.164.66:20004/zkxw`
- 文档注释中的访问入口改为 `http://101.35.245.122:10001`
### 5.3 Nginx
- `listen 10001`
- `root /root/zkxw/web-admin/dist`;SPA `try_files`
- `location ^~ /api/` → `http://127.0.0.1:10002`(保留 URI)
- `location = /health` → `http://127.0.0.1:10002/health`(验收固定入口)
- **不**配置 MinIO 反代
---
## 6. 发版流程(方案:本机打包 + rsync)
### 6.1 首次
1. SSH 安装 Nginx,创建目录,放置 site,`nginx -t && reload`
2. rsync backend 源码;`python3.12 -m venv .venv`;`pip install -r requirements.txt`
3. scp `.env.test`;`HOST=127.0.0.1 PORT=10002 ./start-test.sh start`
4. 本机 `pnpm build:test`;rsync `dist/` 到服务器
5. 安全组放行 10001;按 §7 验收
### 6.2 日常
```bash
# 前端
cd web-admin && pnpm build:test
rsync -avz --delete dist/ root@101.35.245.122:/root/zkxw/web-admin/dist/
# 后端
rsync -avz --exclude .venv --exclude venv --exclude '__pycache__' --exclude logs \
backend/ root@101.35.245.122:/root/zkxw/backend/
ssh root@101.35.245.122 \
'cd /root/zkxw/backend && HOST=127.0.0.1 PORT=10002 ./start-test.sh restart'
```
---
## 7. 验收
| 检查 | 期望 |
|------|------|
| `curl -s -o /dev/null -w '%{http_code}' http://101.35.245.122:10001/` | 200 |
| `curl -s http://101.35.245.122:10001/health` | 后端健康检查成功 |
| SSH 上 `curl -s http://127.0.0.1:10002/health` | 成功 |
| 外网直连 `http://101.35.245.122:10002/health` | 失败(超时/拒绝) |
| 登录与附件预览 | 可用(MinIO 公网可达) |
---
## 8. 风险与回滚
| 风险 | 缓解 |
|------|------|
| 与开发共用库 `zkxw-sc`,测试写库影响开发数据 | 已知接受(选型 A);后续若需隔离再开独立库 |
| 测试机访问不到中间件端口 | 部署前从测试机 `curl`/连通性探测 3306、16379、20004 |
| 浏览器访问不到 MinIO | 确认 `47.99.164.66:20004` 对使用方网络开放 |
| 误提交密钥 / 机器密码 | `.env.test` 与账号文件不进 git |
回滚:前端保留上一版 `dist` 备份再覆盖;后端停服务或回退代码后 `restart`。
---
## 9. 仓库交付物(实现阶段)
- 本设计文档(本文件)
- Nginx site 示例:建议 `测试环境部署/zkxw-test-10001.conf`(或更新现有 nginx 说明)
- 更新 `测试环境部署/README.md`:新 IP、端口 10001/10002、无 Docker、Python 3.12、安装 Nginx 步骤
- 视需要微调 `web-admin/.env.test` 注释中的入口地址
- **不**把 `测试环境机器的账号密码`、真实 `.env.test` 提交入库
---
## 10. 明确不在范围
- Docker Compose / 应用镜像部署
- 在测试机新建 MySQL / Redis / MinIO
- 服务器上安装 Node 并执行 `pnpm build`
- 将后端 10002 对公网开放
- Nginx 反代 MinIO
@@ -43,11 +43,23 @@
系统 SHALL 通过 `sys_post_role` 维护职位与角色关联;职位管理界面 SHALL 支持为职位配置关联角色;用户管理界面 SHALL 支持为用户多选职位。职位本身 SHALL NOT 直接配置菜单树或独立 `data_scope`(权限仍落在 `sys_role`)。 系统 SHALL 通过 `sys_post_role` 维护职位与角色关联;职位管理界面 SHALL 支持为职位配置关联角色;用户管理界面 SHALL 支持为用户多选职位。职位本身 SHALL NOT 直接配置菜单树或独立 `data_scope`(权限仍落在 `sys_role`)。
用户详情接口 SHALL 区分:`roleIds`(直接绑定)、`postInheritedRoleIds`(职位继承)、`effectiveRoleIds` / `effectiveRoleNames`(有效并集)。用户管理新增/编辑界面 SHALL NOT 提供直接绑角色的写入控件;直接绑定由运维接口/脚本或专用授权模块维护。更新用户时若仅提交职位关联,SHALL NOT 清空该用户的直接角色绑定。
#### Scenario: 职位角色配置全量替换 #### Scenario: 职位角色配置全量替换
- **WHEN** 管理员在职位「角色配置」中保存角色 ID 列表 - **WHEN** 管理员在职位「角色配置」中保存角色 ID 列表
- **THEN** 系统 SHALL 将该职位的有效 `sys_post_role` 关联替换为该列表(软删除旧关联) - **THEN** 系统 SHALL 将该职位的有效 `sys_post_role` 关联替换为该列表(软删除旧关联)
#### Scenario: 用户详情返回三层角色
- **WHEN** 调用方请求用户详情
- **THEN** 响应 SHALL 同时包含直接绑定、职位继承与有效角色字段,且有效角色与运行时菜单/数据权限所用并集一致
#### Scenario: 仅更新职位不改动直接角色
- **WHEN** 更新用户请求体仅包含 `postIds`、未包含 `roleIds`
- **THEN** 系统 SHALL 仅替换 `sys_user_post`,SHALL NOT 修改 `sys_user_role`
### Requirement: Web 与小程序菜单树 ### Requirement: Web 与小程序菜单树
菜单管理 SHALL 同时支撑 Web 端与小程序端菜单;系统 SHALL 预置两棵顶层虚拟根(需求中的 parent_id=0 的「web 端菜单」「移动端菜单」),后续菜单 SHALL 挂载其下(与需求 §2.1.6)。 菜单管理 SHALL 同时支撑 Web 端与小程序端菜单;系统 SHALL 预置两棵顶层虚拟根(需求中的 parent_id=0 的「web 端菜单」「移动端菜单」),后续菜单 SHALL 挂载其下(与需求 §2.1.6)。
+91
View File
@@ -210,6 +210,93 @@ export function fetchStartOaInstance(data: {
}) })
} }
// ---------------------------------------------------------------------------
// 发起审批草稿箱
// ---------------------------------------------------------------------------
export function fetchOaInstanceDraftList(params?: {
keyword?: string
groupId?: string
updateTimeFrom?: string
updateTimeTo?: string
page?: number
pageSize?: number
}) {
return request.get<{ list: any[]; total: number }>({
url: `${BASE}/drafts`,
params
})
}
export function fetchOaInstanceDraftCount() {
return request.get<{ count: number }>({ url: `${BASE}/drafts/count` })
}
export function fetchOaInstanceDraftDetail(id: string) {
return request.get<any>({ url: `${BASE}/drafts/${id}` })
}
export function fetchCreateOaInstanceDraft(data: {
appId: string
formData?: Record<string, any>
title?: string
initiatorDeptId?: string
initiatorSelectByNode?: Record<string, string[]>
}) {
return request.post({
url: `${BASE}/drafts`,
data,
showSuccessMessage: true
})
}
export function fetchUpdateOaInstanceDraft(
id: string,
data: {
formData?: Record<string, any>
title?: string
initiatorDeptId?: string
initiatorSelectByNode?: Record<string, string[]>
}
) {
return request.put({
url: `${BASE}/drafts/${id}`,
data,
showSuccessMessage: true
})
}
export function fetchDeleteOaInstanceDraft(id: string) {
return request.del({
url: `${BASE}/drafts/${id}`,
showSuccessMessage: true
})
}
export function fetchBatchDeleteOaInstanceDrafts(ids: string[]) {
return request.post({
url: `${BASE}/drafts/batch-delete`,
data: { ids },
showSuccessMessage: true
})
}
export function fetchSubmitOaInstanceDraft(
id: string,
data?: {
formData?: Record<string, any>
title?: string
initiatorDeptId?: string
initiatorSelectByNode?: Record<string, string[]>
}
) {
return request.post({
url: `${BASE}/drafts/${id}/submit`,
data: data || {},
showSuccessMessage: true
})
}
/** 详情审批进度投影项(后端 build_progress_items) */ /** 详情审批进度投影项(后端 build_progress_items) */
export interface OaProgressPerson { export interface OaProgressPerson {
id?: string id?: string
@@ -254,6 +341,10 @@ export function fetchOaTasks(
}) })
} }
export function fetchOaPendingTaskCount() {
return request.get<{ count: number }>({ url: `${BASE}/tasks/pending/count` })
}
export function fetchOaCcUnreadCount() { export function fetchOaCcUnreadCount() {
return request.get<{ count: number }>({ url: `${BASE}/tasks/cc/unread-count` }) return request.get<{ count: number }>({ url: `${BASE}/tasks/cc/unread-count` })
} }
@@ -70,7 +70,7 @@
class="view-kv" class="view-kv"
:class="{ 'view-kv-span-2': field.span }" :class="{ 'view-kv-span-2': field.span }"
> >
<dt>{{ field.label }}</dt> <dt v-if="field.label">{{ field.label }}</dt>
<dd> <dd>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{ <el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value) formatViewValue(field.value)
@@ -662,7 +662,7 @@
cfg.postIds = [...(ac.postIds || [])] cfg.postIds = [...(ac.postIds || [])]
cfg.signMode = ac.signMode || 'or' cfg.signMode = ac.signMode || 'or'
memberPickBuffer.value = '' memberPickBuffer.value = ''
rolePickBuffer.value = '' postPickBuffer.value = ''
if (showUsersPicker.value) await loadUsers() if (showUsersPicker.value) await loadUsers()
if (showPostsPicker.value) await loadPosts() if (showPostsPicker.value) await loadPosts()
drawerVisible.value = true drawerVisible.value = true
+42
View File
@@ -662,4 +662,46 @@
.oa-fc-host--left-kit ._fc-l-group > ._fc-l-title { .oa-fc-host--left-kit ._fc-l-group > ._fc-l-title {
display: none; display: none;
} }
/*
* 画布描边 · 方案 E:布局容器淡虚线常驻,叶子字段默认无框(悬停/选中才显)
* 权威:docs/superpowers/specs/2026-08-06-oa-form-designer-outline-style-e-design.md
* Demo:docs/demos/oa-form-designer-outline-styles.html
*/
.oa-fc-host ._fd-drag-tool {
outline: 1px dashed transparent;
}
.oa-fc-host ._fd-drag-tool:hover {
outline-color: rgba(16, 185, 129, 0.35);
outline-style: solid;
z-index: 1;
}
.oa-fc-host ._fd-drag-tool.active {
outline: 1px solid #10b981;
}
/* 含嵌套子组件的布局容器:极淡虚线,便于辨认层级 */
.oa-fc-host ._fd-drag-tool:has(._fd-drag-tool) {
outline: 1px dashed rgba(16, 185, 129, 0.18);
}
.oa-fc-host ._fd-drag-tool:has(._fd-drag-tool):hover {
outline-color: rgba(16, 185, 129, 0.35);
outline-style: dashed;
}
.oa-fc-host ._fd-drag-tool:has(._fd-drag-tool).active {
outline: 1px solid #10b981;
}
/* 拖拽操作钮:库默认蓝 → Mint */
.oa-fc-host ._fd-drag-btn {
background-color: #10b981;
}
.oa-fc-host ._fd-drag-btn._fd-drag-danger {
background-color: #ff2e2e;
}
</style> </style>
+22 -11
View File
@@ -1374,17 +1374,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'oa:apps:list' menuCode: 'oa:apps:list'
} }
}, },
{
id: 'OA000000000000000000000000000108',
name: 'OaApprovalReceiveAccounts',
path: 'receive-accounts',
component: '/oa-approval/receive-accounts/index',
meta: {
title: '收款账户管理',
keepAlive: true,
menuCode: 'oa:receive-accounts:list'
}
},
{ {
id: 'OA000000000000000000000000000103', id: 'OA000000000000000000000000000103',
name: 'OaApprovalStart', name: 'OaApprovalStart',
@@ -1396,6 +1385,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'oa:start' menuCode: 'oa:start'
} }
}, },
{
id: 'OA000000000000000000000000000109',
name: 'OaApprovalDrafts',
path: 'drafts',
component: '/oa-approval/drafts/index',
meta: {
title: '草稿箱',
keepAlive: true,
menuCode: 'oa:drafts'
}
},
{ {
id: 'OA000000000000000000000000000104', id: 'OA000000000000000000000000000104',
name: 'OaApprovalPending', name: 'OaApprovalPending',
@@ -1440,6 +1440,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'oa:cc' menuCode: 'oa:cc'
} }
}, },
{
id: 'OA000000000000000000000000000108',
name: 'OaApprovalReceiveAccounts',
path: 'receive-accounts',
component: '/oa-approval/receive-accounts/index',
meta: {
title: '收款账户管理',
keepAlive: true,
menuCode: 'oa:receive-accounts:list'
}
},
{ {
id: 'OA000000000000000000000000000201', id: 'OA000000000000000000000000000201',
name: 'OaApprovalDesigner', name: 'OaApprovalDesigner',
@@ -3,6 +3,8 @@ import { ref } from 'vue'
import { formatOaCcUnreadBadgeCount } from '@/api/oaApproval' import { formatOaCcUnreadBadgeCount } from '@/api/oaApproval'
export const OA_MENU_CODE_CC = 'oa:cc' export const OA_MENU_CODE_CC = 'oa:cc'
export const OA_MENU_CODE_DRAFTS = 'oa:drafts'
export const OA_MENU_CODE_PENDING = 'oa:pending'
export const useOaMenuBadgeStore = defineStore('oaMenuBadgeStore', () => { export const useOaMenuBadgeStore = defineStore('oaMenuBadgeStore', () => {
const badgeByMenuCode = ref<Record<string, number>>({}) const badgeByMenuCode = ref<Record<string, number>>({})
+2
View File
@@ -22,6 +22,7 @@ import { resetRouterState } from '@/router/guards/beforeEach'
import { useMenuStore } from './menu' import { useMenuStore } from './menu'
import { useQcMenuBadgeStore } from './qc-menu-badge' import { useQcMenuBadgeStore } from './qc-menu-badge'
import { useProductionMenuBadgeStore } from './production-menu-badge' import { useProductionMenuBadgeStore } from './production-menu-badge'
import { useOaMenuBadgeStore } from './oa-menu-badge'
import { StorageConfig } from '@/utils/storage/storage-config' import { StorageConfig } from '@/utils/storage/storage-config'
import { fetchLogout } from '@/api/auth' import { fetchLogout } from '@/api/auth'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
@@ -149,6 +150,7 @@ export const useUserStore = defineStore(
useMenuStore().setHomePath('') useMenuStore().setHomePath('')
useQcMenuBadgeStore().clearBadges() useQcMenuBadgeStore().clearBadges()
useProductionMenuBadgeStore().clearBadges() useProductionMenuBadgeStore().clearBadges()
useOaMenuBadgeStore().clearBadges()
} }
/** /**
+8
View File
@@ -57,6 +57,12 @@ declare namespace Api {
deptName?: string deptName?: string
roleIds?: string[] roleIds?: string[]
postIds?: string[] postIds?: string[]
/** 经启用职位继承的启用角色 ID */
postInheritedRoleIds?: string[]
/** 有效角色 ID(启用直接绑定 ∪ 职位继承) */
effectiveRoleIds?: string[]
/** 有效角色名称 */
effectiveRoleNames?: string[]
} }
} }
@@ -633,6 +639,8 @@ declare namespace Api {
tenantNo: string tenantNo: string
orderNum: number orderNum: number
remark: string remark: string
boundUserNames?: string[]
boundPostNames?: string[]
createTime: string createTime: string
updateTime: string updateTime: string
} }
+48 -10
View File
@@ -1,14 +1,32 @@
import { fetchOaCcUnreadCount } from '@/api/oaApproval' import {
import { OA_MENU_CODE_CC, useOaMenuBadgeStore } from '@/store/modules/oa-menu-badge' fetchOaCcUnreadCount,
fetchOaInstanceDraftCount,
fetchOaPendingTaskCount
} from '@/api/oaApproval'
import {
OA_MENU_CODE_CC,
OA_MENU_CODE_DRAFTS,
OA_MENU_CODE_PENDING,
useOaMenuBadgeStore
} from '@/store/modules/oa-menu-badge'
import { useMenuStore } from '@/store/modules/menu' import { useMenuStore } from '@/store/modules/menu'
import type { AppRouteRecord } from '@/types/router' import type { AppRouteRecord } from '@/types/router'
const OA_BADGE_CODES = new Set([OA_MENU_CODE_CC, OA_MENU_CODE_DRAFTS, OA_MENU_CODE_PENDING])
export function resolveOaMenuBadgeCode(item: AppRouteRecord): string | undefined { export function resolveOaMenuBadgeCode(item: AppRouteRecord): string | undefined {
const code = (item.meta.menuCode || '').trim() const code = (item.meta.menuCode || '').trim()
if (code === OA_MENU_CODE_CC) return code if (OA_BADGE_CODES.has(code)) return code
if (String(item.name || '') === 'OaApprovalCc') return OA_MENU_CODE_CC const name = String(item.name || '')
if (name === 'OaApprovalCc') return OA_MENU_CODE_CC
if (name === 'OaApprovalDrafts') return OA_MENU_CODE_DRAFTS
if (name === 'OaApprovalPending') return OA_MENU_CODE_PENDING
// path 兜底仅限 oa: 菜单,避免命中旧工作流 pending
if (!code.startsWith('oa:')) return undefined
const path = String(item.path || '') const path = String(item.path || '')
if (path.endsWith('/cc') || path === 'cc') return OA_MENU_CODE_CC if (path.endsWith('/cc') || path === 'cc') return OA_MENU_CODE_CC
if (path.endsWith('/drafts') || path === 'drafts') return OA_MENU_CODE_DRAFTS
if (path.endsWith('/pending') || path === 'pending') return OA_MENU_CODE_PENDING
return undefined return undefined
} }
@@ -29,11 +47,31 @@ export async function refreshOaMenuBadges() {
const menuStore = useMenuStore() const menuStore = useMenuStore()
const badgeStore = useOaMenuBadgeStore() const badgeStore = useOaMenuBadgeStore()
const visibleCodes = collectVisibleOaMenuBadgeCodes(menuStore.menuList) const visibleCodes = collectVisibleOaMenuBadgeCodes(menuStore.menuList)
if (!visibleCodes.includes(OA_MENU_CODE_CC)) return if (!visibleCodes.length) return
try {
const data = await fetchOaCcUnreadCount() const jobs: Promise<void>[] = []
badgeStore.setBadge(OA_MENU_CODE_CC, data?.count ?? 0)
} catch { if (visibleCodes.includes(OA_MENU_CODE_CC)) {
badgeStore.setBadge(OA_MENU_CODE_CC, 0) jobs.push(
fetchOaCcUnreadCount()
.then((data) => badgeStore.setBadge(OA_MENU_CODE_CC, data?.count ?? 0))
.catch(() => badgeStore.setBadge(OA_MENU_CODE_CC, 0))
)
} }
if (visibleCodes.includes(OA_MENU_CODE_DRAFTS)) {
jobs.push(
fetchOaInstanceDraftCount()
.then((data) => badgeStore.setBadge(OA_MENU_CODE_DRAFTS, data?.count ?? 0))
.catch(() => badgeStore.setBadge(OA_MENU_CODE_DRAFTS, 0))
)
}
if (visibleCodes.includes(OA_MENU_CODE_PENDING)) {
jobs.push(
fetchOaPendingTaskCount()
.then((data) => badgeStore.setBadge(OA_MENU_CODE_PENDING, data?.count ?? 0))
.catch(() => badgeStore.setBadge(OA_MENU_CODE_PENDING, 0))
)
}
await Promise.all(jobs)
} }
@@ -0,0 +1,323 @@
<!-- OA 草稿箱:发起审批暂存记录 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">草稿箱</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="流程名称">
<el-input
v-model="searchForm.keyword"
placeholder="请输入流程名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="保存时间">
<el-date-picker
v-model="searchForm.updateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
clearable
/>
</el-form-item>
<el-form-item label="模板分组">
<el-select v-model="searchForm.groupId" placeholder="全部" clearable>
<el-option
v-for="g in groupOptions"
:key="g.id"
:label="g.groupName"
:value="g.id"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button
class="btn-ghost"
:disabled="!selectedRows.length"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="indexMethod"
/>
<el-table-column
prop="appName"
label="流程名称"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
</el-table-column>
<el-table-column
prop="versionNo"
label="模板版本"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.versionNo != null ? `v${row.versionNo}` : '-' }}
</template>
</el-table-column>
<el-table-column
prop="updateTime"
label="最近保存"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.updateTime || '-' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100" align="center">
<template #default>
<el-tag size="small" type="warning">草稿</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="320" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
class="btn-op-item"
:loading="submittingId === row.id"
@click="handleSubmit(row)"
>
<el-icon><Promotion /></el-icon>
提交审批
</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
@size-change="loadList"
@current-change="loadList"
/>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Delete, Edit, Promotion, RefreshLeft, Search } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import {
fetchBatchDeleteOaInstanceDrafts,
fetchDeleteOaInstanceDraft,
fetchOaGroupList,
fetchOaInstanceDraftList,
fetchSubmitOaInstanceDraft
} from '@/api/oaApproval'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
defineOptions({ name: 'OaApprovalDrafts' })
const router = useRouter()
const loading = ref(false)
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const groupOptions = ref<Array<{ id: string; groupName: string }>>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const submittingId = ref('')
const searchForm = reactive({
keyword: '',
groupId: '' as string,
updateRange: null as string[] | null
})
function indexMethod(index: number) {
return (page.value - 1) * pageSize.value + index + 1
}
function onSelectionChange(rows: any[]) {
selectedRows.value = rows
}
async function loadGroups() {
try {
const res = await fetchOaGroupList()
groupOptions.value = res?.list || []
} catch {
groupOptions.value = []
}
}
async function loadList() {
loading.value = true
try {
const range = searchForm.updateRange || []
const res = await fetchOaInstanceDraftList({
keyword: searchForm.keyword || undefined,
groupId: searchForm.groupId || undefined,
updateTimeFrom: range[0] || undefined,
updateTimeTo: range[1] || undefined,
page: page.value,
pageSize: pageSize.value
})
tableData.value = res?.list || []
total.value = res?.total || 0
selectedRows.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
loadList()
}
function handleReset() {
searchForm.keyword = ''
searchForm.groupId = ''
searchForm.updateRange = null
handleSearch()
}
function openEdit(row: any) {
if (!row?.id) return
router.push({
path: '/system-manage/oa-approval/start',
query: { draftId: row.id }
})
}
async function handleSubmit(row: any) {
if (!row?.id) return
try {
await ElMessageBox.confirm('确认提交该草稿并发起审批吗?', '提交审批', {
type: 'warning',
confirmButtonText: '提交',
cancelButtonText: '取消'
})
} catch {
return
}
submittingId.value = row.id
try {
const data: any = await fetchSubmitOaInstanceDraft(row.id)
await loadList()
void refreshOaMenuBadges()
if (data?.id) {
router.push({ path: `/system-manage/oa-approval/instances/${data.id}` })
}
} catch {
openEdit(row)
} finally {
submittingId.value = ''
}
}
async function handleDelete(row: any) {
if (!row?.id) return
try {
await ElMessageBox.confirm('确认删除该草稿吗?删除后不可恢复。', '删除草稿', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
} catch {
return
}
await fetchDeleteOaInstanceDraft(row.id)
await loadList()
void refreshOaMenuBadges()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id).filter(Boolean)
if (!ids.length) return
try {
await ElMessageBox.confirm(`确认删除选中的 ${ids.length} 条草稿吗?`, '批量删除', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
} catch {
return
}
await fetchBatchDeleteOaInstanceDrafts(ids)
await loadList()
void refreshOaMenuBadges()
}
onMounted(async () => {
await loadGroups()
await loadList()
void refreshOaMenuBadges()
})
onActivated(() => {
loadList()
void refreshOaMenuBadges()
})
</script>
@@ -206,6 +206,7 @@
import OaProgressChain from '@/components/oa-approval/OaProgressChain.vue' import OaProgressChain from '@/components/oa-approval/OaProgressChain.vue'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list' import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
import { import {
OA_FC_DEFAULT_GUTTER, OA_FC_DEFAULT_GUTTER,
ensureOaUploadRuleProps ensureOaUploadRuleProps
@@ -398,6 +399,7 @@
if (kind === 'approve') await fetchApproveOaTask(task.id, comment || undefined) if (kind === 'approve') await fetchApproveOaTask(task.id, comment || undefined)
else await fetchRejectOaTask(task.id, comment || undefined) else await fetchRejectOaTask(task.id, comment || undefined)
await load() await load()
void refreshOaMenuBadges()
} finally { } finally {
acting.value = false acting.value = false
} }
@@ -409,6 +411,7 @@
try { try {
await fetchWithdrawOaInstance(String(route.params.id || '')) await fetchWithdrawOaInstance(String(route.params.id || ''))
await load() await load()
void refreshOaMenuBadges()
} finally { } finally {
acting.value = false acting.value = false
} }
@@ -433,6 +436,7 @@
try { try {
await fetchReturnOaTask(task.id, comment) await fetchReturnOaTask(task.id, comment)
await load() await load()
void refreshOaMenuBadges()
} finally { } finally {
acting.value = false acting.value = false
} }
@@ -497,6 +501,7 @@
}) })
addSignVisible.value = false addSignVisible.value = false
await load() await load()
void refreshOaMenuBadges()
} finally { } finally {
acting.value = false acting.value = false
} }
@@ -524,6 +529,7 @@
}) })
transferVisible.value = false transferVisible.value = false
await load() await load()
void refreshOaMenuBadges()
} finally { } finally {
acting.value = false acting.value = false
} }
@@ -83,6 +83,7 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { View } from '@element-plus/icons-vue' import { View } from '@element-plus/icons-vue'
import { fetchOaTasks } from '@/api/oaApproval' import { fetchOaTasks } from '@/api/oaApproval'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
defineOptions({ name: 'OaApprovalPending' }) defineOptions({ name: 'OaApprovalPending' })
@@ -131,6 +132,12 @@
} }
} }
onMounted(load) onMounted(() => {
onActivated(load) load()
void refreshOaMenuBadges()
})
onActivated(() => {
load()
void refreshOaMenuBadges()
})
</script> </script>
+109 -7
View File
@@ -126,10 +126,14 @@
/> />
</div> </div>
<template #footer> <template #footer>
<el-button class="btn-ghost" @click="dlgVisible = false"> <el-button class="btn-ghost" @click="closeDialog">
<el-icon><Close /></el-icon> <el-icon><Close /></el-icon>
取消 取消
</el-button> </el-button>
<el-button class="btn-ghost" :loading="savingDraft" @click="saveDraft">
<el-icon><Document /></el-icon>
{{ editingDraftId ? '保存草稿' : '暂存草稿' }}
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="submit"> <el-button class="btn-main" type="primary" :loading="submitting" @click="submit">
<el-icon><Check /></el-icon> <el-icon><Check /></el-icon>
提交 提交
@@ -143,21 +147,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Check, Close, Promotion, Search } from '@element-plus/icons-vue' import { Check, Close, Document, Promotion, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin' import { fetchUserList } from '@/api/admin'
import { import {
fetchCreateOaInstanceDraft,
fetchOaFlowPreview, fetchOaFlowPreview,
fetchOaGroupList, fetchOaGroupList,
fetchOaInstanceDetail, fetchOaInstanceDetail,
fetchOaInstanceDraftDetail,
fetchOaTemplateDetail, fetchOaTemplateDetail,
fetchOaTemplates, fetchOaTemplates,
fetchStartOaInstance, fetchStartOaInstance,
fetchSubmitOaInstanceDraft,
fetchUpdateOaInstanceDraft,
type OaFlowPreviewStep type OaFlowPreviewStep
} from '@/api/oaApproval' } from '@/api/oaApproval'
import OaAppIcon from '@/components/oa-approval/OaAppIcon.vue' import OaAppIcon from '@/components/oa-approval/OaAppIcon.vue'
import OaFlowPreview from '@/components/oa-approval/OaFlowPreview.vue' import OaFlowPreview from '@/components/oa-approval/OaFlowPreview.vue'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list' import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
import { import {
OA_FC_DEFAULT_GUTTER, OA_FC_DEFAULT_GUTTER,
ensureOaUploadRuleProps ensureOaUploadRuleProps
@@ -214,6 +223,8 @@
}) })
const fapi = ref<any>(null) const fapi = ref<any>(null)
const submitting = ref(false) const submitting = ref(false)
const savingDraft = ref(false)
const editingDraftId = ref('')
const needInitiatorSelect = ref(false) const needInitiatorSelect = ref(false)
const initiatorSelectByNode = ref<Record<string, string[]>>({}) const initiatorSelectByNode = ref<Record<string, string[]>>({})
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([]) const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
@@ -334,6 +345,7 @@
} }
async function onPick(t: any) { async function onPick(t: any) {
editingDraftId.value = ''
picked.value = t picked.value = t
formData.value = {} formData.value = {}
formRule.value = [] formRule.value = []
@@ -418,6 +430,77 @@
} }
} }
function closeDialog() {
dlgVisible.value = false
editingDraftId.value = ''
}
async function openDraft(draftId: string) {
const detail = await fetchOaInstanceDraftDetail(draftId)
const appId = String(detail?.appId || '').trim()
if (!appId) {
ElMessage.error('草稿缺少审批模板')
return
}
let row = templates.value.find((t: any) => t.id === appId)
if (!row) {
row = {
id: appId,
appName: detail?.appName || '发起审批'
}
}
await onPick(row)
editingDraftId.value = draftId
const saved = { ...(detail?.formData || {}) }
formData.value = await applyOwnerFieldDefaults(
formRule.value,
saved,
(userStore.info || userStore.getUserInfo) as any
)
formData.value = applyOvertimeKitDefaults(
{ rule: formRule.value },
formData.value,
(userStore.info || userStore.getUserInfo) as any
)
const linked = applyConfirmationKitDateLinkage(
{ rule: formRule.value },
formData.value
)
formData.value = linked.data
confirmationKitPrevSnapshot = { ...linked.data }
leaveKitPrevSnapshot = { ...formData.value }
tripKitPrevSnapshot = { ...formData.value }
const sel = detail?.initiatorSelectByNode
initiatorSelectByNode.value =
sel && typeof sel === 'object' && !Array.isArray(sel) ? { ...sel } : {}
await loadFlowPreview(appId)
}
async function saveDraft() {
if (!picked.value?.id) return
savingDraft.value = true
try {
const payload = {
formData: { ...formData.value },
initiatorSelectByNode: { ...initiatorSelectByNode.value },
title: picked.value.appName || ''
}
if (editingDraftId.value) {
await fetchUpdateOaInstanceDraft(editingDraftId.value, payload)
} else {
const created: any = await fetchCreateOaInstanceDraft({
appId: picked.value.id,
...payload
})
if (created?.id) editingDraftId.value = created.id
}
void refreshOaMenuBadges()
closeDialog()
} finally {
savingDraft.value = false
}
}
function syncLeaveKitDurations() { function syncLeaveKitDurations() {
if (leaveKitSyncing) return if (leaveKitSyncing) return
if (!formRule.value.length) return if (!formRule.value.length) return
@@ -604,12 +687,24 @@
} }
submitting.value = true submitting.value = true
try { try {
const data: any = await fetchStartOaInstance({ const payload = {
appId: picked.value.id,
formData: { ...formData.value }, formData: { ...formData.value },
initiatorSelectByNode: { ...initiatorSelectByNode.value } initiatorSelectByNode: { ...initiatorSelectByNode.value }
}) }
dlgVisible.value = false let data: any
if (editingDraftId.value) {
data = await fetchSubmitOaInstanceDraft(editingDraftId.value, {
...payload,
title: picked.value.appName || ''
})
} else {
data = await fetchStartOaInstance({
appId: picked.value.id,
...payload
})
}
closeDialog()
void refreshOaMenuBadges()
if (data?.id) { if (data?.id) {
router.push({ path: `/system-manage/oa-approval/instances/${data.id}` }) router.push({ path: `/system-manage/oa-approval/instances/${data.id}` })
} }
@@ -624,9 +719,16 @@
const [tpl, groupsRes] = await Promise.all([fetchOaTemplates(), fetchOaGroupList()]) const [tpl, groupsRes] = await Promise.all([fetchOaTemplates(), fetchOaGroupList()])
templates.value = tpl?.list || [] templates.value = tpl?.list || []
groupOptions.value = groupsRes?.list || [] groupOptions.value = groupsRes?.list || []
const qDraftId = String(route.query.draftId || '').trim()
const qAppId = String(route.query.appId || '').trim() const qAppId = String(route.query.appId || '').trim()
const qCopyFrom = String(route.query.copyFromInstanceId || '').trim() const qCopyFrom = String(route.query.copyFromInstanceId || '').trim()
if (qAppId) { if (qDraftId) {
try {
await openDraft(qDraftId)
} catch {
ElMessage.error('无法打开草稿')
}
} else if (qAppId) {
const row = templates.value.find((t: any) => t.id === qAppId) const row = templates.value.find((t: any) => t.id === qAppId)
if (row) { if (row) {
await onPick(row) await onPick(row)
+49 -1
View File
@@ -72,7 +72,46 @@
min-width="140" min-width="140"
align="center" align="center"
header-align="center" header-align="center"
/> >
<template #default="{ row }">
<el-button
class="no-auto-icon"
link
type="primary"
@click="handleView(row)"
>
{{ row.roleName || '-' }}
</el-button>
</template>
</el-table-column>
<el-table-column
label="拥有用户"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">
<span class="role-bound-names-cell">{{
(row.boundUserNames || []).length
? (row.boundUserNames || []).join('、')
: '—'
}}</span>
</template>
</el-table-column>
<el-table-column
label="关联职位"
min-width="160"
align="center"
header-align="center"
>
<template #default="{ row }">
<span class="role-bound-names-cell">{{
(row.boundPostNames || []).length
? (row.boundPostNames || []).join('、')
: '—'
}}</span>
</template>
</el-table-column>
<el-table-column <el-table-column
prop="dataScope" prop="dataScope"
label="数据权限" label="数据权限"
@@ -1054,6 +1093,15 @@
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.role-bound-names-cell {
display: inline-block;
max-width: 100%;
white-space: normal;
word-break: break-word;
line-height: 1.45;
text-align: center;
}
.role-dialog-form, .role-dialog-form,
.data-scope-form { .data-scope-form {
:deep(.el-form-item) { :deep(.el-form-item) {
+63 -27
View File
@@ -5,7 +5,7 @@
<section class="panel"> <section class="panel">
<div class="flex gap-4"> <div class="flex gap-4">
<!-- 左侧:组织树 --> <!-- 左侧:组织树 -->
<div class="user-dept-aside w-[280px] shrink-0 border-r border-[var(--mint-border)] pr-4"> <div class="user-dept-aside w-[200px] shrink-0 border-r border-[var(--mint-border)] pr-4">
<!-- 标题与搜索同处 panel-top:避免标题栏底边与独立输入框之间出现不可控的视觉断层 --> <!-- 标题与搜索同处 panel-top:避免标题栏底边与独立输入框之间出现不可控的视觉断层 -->
<div class="panel-top user-dept-aside__head"> <div class="panel-top user-dept-aside__head">
<h2>组织</h2> <h2>组织</h2>
@@ -131,7 +131,14 @@
<template #default="{ row }"> <template #default="{ row }">
<div class="user-nick-cell"> <div class="user-nick-cell">
<el-avatar :size="24" :src="resolveUserAvatarFromInfo(row)" /> <el-avatar :size="24" :src="resolveUserAvatarFromInfo(row)" />
<span class="user-nick-cell__name">{{ row.nickName }}</span> <el-button
class="user-nick-cell__name no-auto-icon"
link
type="primary"
@click="handleView(row)"
>
{{ row.nickName || '-' }}
</el-button>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -146,6 +153,26 @@
{{ formatUserDeptDisplay(row) }} {{ formatUserDeptDisplay(row) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column
label="职位"
min-width="140"
align="left"
header-align="left"
>
<template #default="{ row }">
<span class="user-multi-label-cell">{{ formatUserPostDisplay(row) }}</span>
</template>
</el-table-column>
<el-table-column
label="角色"
min-width="160"
align="left"
header-align="left"
>
<template #default="{ row }">
<span class="user-multi-label-cell">{{ formatUserEffectiveRoleDisplay(row) }}</span>
</template>
</el-table-column>
<el-table-column <el-table-column
prop="phoneNo" prop="phoneNo"
label="手机号" label="手机号"
@@ -357,22 +384,6 @@
<el-option label="停用" value="0" /> <el-option label="停用" value="0" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="角色" style="grid-column: 1 / -1">
<el-select
v-model="formData.roleIds"
multiple
filterable
placeholder="请选择角色"
style="width: 100%"
>
<el-option
v-for="item in roleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="职位" style="grid-column: 1 / -1"> <el-form-item label="职位" style="grid-column: 1 / -1">
<el-select <el-select
v-model="formData.postIds" v-model="formData.postIds"
@@ -511,15 +522,28 @@
return deptPathLabelMap.value.get(id) || row.deptName || '—' return deptPathLabelMap.value.get(id) || row.deptName || '—'
} }
function formatUserRoleDisplay(row: Api.User.UserItem) { function formatRoleIdsDisplay(ids: string[] | undefined | null) {
const ids = row.roleIds || [] if (!ids?.length) return '—'
if (!ids.length) return '—'
const labels = ids const labels = ids
.map((id) => roleOptions.value.find((o) => o.value === id)?.label) .map((id) => roleOptions.value.find((o) => o.value === id)?.label)
.filter((name): name is string => Boolean(name)) .filter((name): name is string => Boolean(name))
return labels.length ? labels.join('、') : '—' return labels.length ? labels.join('、') : '—'
} }
function formatUserEffectiveRoleDisplay(row: Api.User.UserItem) {
const names = row.effectiveRoleNames || []
if (names.length) return names.join('、')
return formatRoleIdsDisplay(row.effectiveRoleIds)
}
function formatUserDirectRoleDisplay(row: Api.User.UserItem) {
return formatRoleIdsDisplay(row.roleIds)
}
function formatUserPostInheritedRoleDisplay(row: Api.User.UserItem) {
return formatRoleIdsDisplay(row.postInheritedRoleIds)
}
function formatUserPostDisplay(row: Api.User.UserItem) { function formatUserPostDisplay(row: Api.User.UserItem) {
const ids = row.postIds || [] const ids = row.postIds || []
if (!ids.length) return '—' if (!ids.length) return '—'
@@ -558,7 +582,6 @@
status: '1', status: '1',
orderNum: 0, orderNum: 0,
remark: '', remark: '',
roleIds: [] as string[],
postIds: [] as string[] postIds: [] as string[]
}) })
@@ -667,7 +690,6 @@
formData.status = '1' formData.status = '1'
formData.orderNum = 0 formData.orderNum = 0
formData.remark = '' formData.remark = ''
formData.roleIds = []
formData.postIds = [] formData.postIds = []
supervisorUserOptions.value = [] supervisorUserOptions.value = []
editingId.value = null editingId.value = null
@@ -708,7 +730,6 @@
formData.status = detail.status || '1' formData.status = detail.status || '1'
formData.orderNum = detail.orderNum ?? 0 formData.orderNum = detail.orderNum ?? 0
formData.remark = detail.remark || '' formData.remark = detail.remark || ''
formData.roleIds = detail.roleIds ? [...detail.roleIds] : []
formData.postIds = detail.postIds ? [...detail.postIds] : [] formData.postIds = detail.postIds ? [...detail.postIds] : []
if (formData.supervisorUserId) { if (formData.supervisorUserId) {
await remoteSearchSupervisorUsers(detail.nickName || '') await remoteSearchSupervisorUsers(detail.nickName || '')
@@ -744,7 +765,6 @@
status: formData.status, status: formData.status,
orderNum: formData.orderNum, orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined, remark: formData.remark?.trim() || undefined,
roleIds: [...formData.roleIds],
postIds: [...formData.postIds] postIds: [...formData.postIds]
}) })
} else { } else {
@@ -763,7 +783,6 @@
status: formData.status, status: formData.status,
orderNum: formData.orderNum, orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined, remark: formData.remark?.trim() || undefined,
roleIds: formData.roleIds.length ? formData.roleIds : undefined,
postIds: formData.postIds.length ? formData.postIds : undefined postIds: formData.postIds.length ? formData.postIds : undefined
}) })
} }
@@ -882,7 +901,9 @@
value: row.status === '1' ? '正常' : '停用', value: row.status === '1' ? '正常' : '停用',
tagType: row.status === '1' ? 'success' : 'info' tagType: row.status === '1' ? 'success' : 'info'
}, },
{ label: '角色', value: formatUserRoleDisplay(row), span: true }, { label: '有效角色', value: formatUserEffectiveRoleDisplay(row), span: true },
{ label: '直接绑定角色', value: formatUserDirectRoleDisplay(row), span: true },
{ label: '职位继承角色', value: formatUserPostInheritedRoleDisplay(row), span: true },
{ label: '职位', value: formatUserPostDisplay(row), span: true }, { label: '职位', value: formatUserPostDisplay(row), span: true },
{ label: '创建时间', value: row.createTime }, { label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true } { label: '备注', value: row.remark, span: true }
@@ -964,9 +985,24 @@
} }
&__name { &__name {
flex: 1;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
justify-content: flex-start;
padding: 0;
height: auto;
font-size: inherit;
} }
} }
/* 职位 / 角色多项用顿号分隔,列宽不足时自然换行 */
.user-multi-label-cell {
display: inline-block;
max-width: 100%;
line-height: 1.5;
white-space: normal;
word-break: break-word;
}
</style> </style>
+215 -340
View File
@@ -1,409 +1,284 @@
# 后端测试环境部署说明 # 测试环境裸机部署说明
> 本文档汇总测试环境相关结论与操作步骤,便于后续追查。 > 本文档描述测试机 `101.35.245.122` 上的 **web-admin + backend** 裸机部署拓扑与日常操作。
> 代码仓库路径:`backend/`;服务器部署目录示例:`/root/zkxw-backend/backend`。 > 代码仓库:`web-admin/`、`backend/`;服务器部署根目录:`/root/zkxw/`。
**说明**:旧 **8112 / 双机拓扑**(前端 `47.121.120.79` + 后端 `47.99.164.66:8112`)已废弃,**以本文件为准**。
--- ---
## 1. 环境与脚本对照 ## 1. 环境拓扑
| 项 | 测试环境 | 生产环境 | | 角色 | 地址 |
|----|----------|----------| |------|------|
| 入口 | `http://101.35.245.122:10001` |
| Nginx 静态 | `/root/zkxw/web-admin/dist` |
| 后端 | `127.0.0.1:10002`(仅本机) |
| 中间件 | 与本机 `backend/.env` 相同(`47.99.164.66`) |
| MinIO 浏览器 | `http://47.99.164.66:20004/zkxw`(直连,Nginx 不反代) |
流量路径:
```text
浏览器 → http://101.35.245.122:10001
├─ / → Nginx 静态(web-admin/dist)
├─ /api/* → 127.0.0.1:10002(本机后端)
└─ MinIO 附件 → 浏览器直连 47.99.164.66:20004/zkxw
```
云安全组 / 防火墙仅放行 **TCP 10001**;后端绑定 `127.0.0.1`,不对公网暴露 10002。
---
## 2. 机装依赖
| 组件 | 状态 | 说明 |
|------|------|------|
| Python | **已有 3.12.3** | 用于创建 `.venv` |
| Nginx | **需安装** | 对外 10001,反代 `/api` |
| 中间件 | 远程复用 | MySQL / Redis / MinIO 在 `47.99.164.66` |
探测包管理器后安装 Nginx(示例):
```bash
# Debian/Ubuntu
sudo apt-get update && sudo apt-get install -y nginx
# RHEL/CentOS/Alma(示例)
sudo yum install -y nginx # 或 dnf install -y nginx
sudo systemctl enable --now nginx
```
---
## 3. 服务器目录
```text
/root/zkxw/
├── web-admin/
│ └── dist/ # pnpm build:test 产物
├── backend/
│ ├── app/
│ ├── requirements.txt
│ ├── start-test.sh
│ ├── .env.test # 敏感,勿提交 git
│ ├── .venv/ # Python 3.12.3 虚拟环境
│ ├── backend-test.pid
│ └── logs/
└── (Nginx site 配置见下文)
```
---
## 4. 首次部署
### 4.1 后端环境变量
`.env.test` 来源:从**本机开发机** `backend/.env` 复制为服务器 `backend/.env.test`,按测试需要调整(库名、Redis DB、密钥等)。**勿将含密码的文件提交 git**。
```bash
# 在本机执行(示例)
scp backend/.env root@101.35.245.122:/root/zkxw/backend/.env.test
```
或使用模板 `backend/.env.test.example` 手工填写。
### 4.2 虚拟环境与启动
```bash
cd /root/zkxw/backend
python3.12 -m venv .venv
./.venv/bin/pip install -U pip
./.venv/bin/pip install -r requirements.txt
chmod +x start-test.sh
HOST=127.0.0.1 PORT=10002 ./start-test.sh start
./start-test.sh status
curl -s http://127.0.0.1:10002/health
```
`start-test.sh` 子命令:`start` / `stop` / `restart` / `status` / `logs` / `activate` / `help`。
### 4.3 前端静态资源
在本机构建后上传(见 §6 日常发版)。
### 4.4 启用 Nginx
配置文件已存在于仓库:`测试环境部署/zkxw-test-10001.conf`。
**Ubuntu / Debian:**
```bash
sudo cp 测试环境部署/zkxw-test-10001.conf /etc/nginx/sites-available/zkxw-test-10001.conf
sudo ln -sf /etc/nginx/sites-available/zkxw-test-10001.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
**RHEL / CentOS / Alma:**
```bash
sudo cp 测试环境部署/zkxw-test-10001.conf /etc/nginx/conf.d/zkxw-test-10001.conf
sudo nginx -t && sudo systemctl reload nginx
```
Nginx 关键配置摘要:
- `listen 10001`
- `root /root/zkxw/web-admin/dist`
- `location ^~ /api/` → `http://127.0.0.1:10002`
- **不**反代 MinIO(前端直连 `http://47.99.164.66:20004/zkxw`)
静态目录在 `/root/...` 下时,Nginx 工作用户(如 `www-data`)须能穿越 `/root`,否则首页 500 / Permission denied:
```bash
chmod 755 /root
chmod -R a+rX /root/zkxw/web-admin
```
(或将静态目录放到 `/var/www/zkxw` 等非 `/root` 路径。)
---
## 5. 脚本与环境对照
| 项 | 测试环境(本拓扑) | 生产环境 |
|----|-------------------|----------|
| 启动脚本 | `backend/start-test.sh` | `backend/start-prod.sh` | | 启动脚本 | `backend/start-test.sh` | `backend/start-prod.sh` |
| 环境变量文件 | `.env.test` | `.env.production` | | 环境变量文件 | `.env.test` | `.env.production` |
| 环境变量模板 | `backend/.env.test.example` | `backend/.env.example`(复制为 `.env.production`) | | 监听地址 | `127.0.0.1:10002` | `0.0.0.0:8111` |
| 默认端口 | **8112** | **8111** | | 虚拟环境 | `.venv` | `venv` |
| 默认监听 | `0.0.0.0` | `0.0.0.0` |
| 工作进程数 | 1 | 2 |
| 日志级别 | `debug` | `info` |
| 虚拟环境目录 | **`.venv`** | **`venv`** |
| PID 文件 | `backend-test.pid` | `backend-prod.pid` | | PID 文件 | `backend-test.pid` | `backend-prod.pid` |
| 运行日志 | `logs/backend-test.log` | `logs/backend-prod.log` | | 运行日志 | `logs/backend-test.log` | `logs/backend-prod.log` |
**同机部署**:测试用 8112、生产用 8111,避免端口冲突;启停命令必须分开,勿混用 `start-test` / `start-prod`。 应用通过 `ENV_FILE` 加载配置(见 `backend/app/core/config.py`),启动脚本会 `export ENV_FILE` 后再拉起 uvicorn。
应用通过环境变量 `ENV_FILE` 加载配置(见 `backend/app/core/config.py`),启动脚本会 `export ENV_FILE` 后再拉起 uvicorn。
--- ---
## 2. 服务器目录 ## 6. 日常发版
```text ### 6.1 推荐:一键脚本
/root/zkxw-backend/backend/
├── start-test.sh # 测试启停
├── start-prod.sh # 生产启停(勿与测试混用命令)
├── .env.test # 测试配置(勿提交敏感信息到 git)
├── .env.production # 生产配置
├── .env.test.example # 测试配置模板
├── .venv/ # 测试脚本默认虚拟环境
├── venv/ # 生产脚本默认虚拟环境
├── backend-test.pid
├── backend-prod.pid
└── logs/
├── backend-test.log
└── backend-prod.log
```
---
## 3. 首次部署(测试)
```bash ```bash
cd /root/zkxw-backend/backend # 仓库根目录执行(默认:前端 build:test + rsync、后端 rsync、重启、验收)
python3 测试环境部署/deploy_test_env.py
# 1) 测试环境变量 python3 测试环境部署/deploy_test_env.py --frontend-only # 仅前端
cp .env.test.example .env.test python3 测试环境部署/deploy_test_env.py --backend-only # 仅后端并重启
# 编辑 .env.test:测试库、Redis DB、MinIO bucket、SECRET_KEY、CORS、API 加密等与前端一致 python3 测试环境部署/deploy_test_env.py --verify # 仅验收
python3 测试环境部署/deploy_test_env.py --upload-env # 额外上传本机 backend/.env → 远端 .env.test
# 2) 虚拟环境与依赖(须 python3.9+,勿用系统默认 python3.6) python3 测试环境部署/deploy_test_env.py --pip # 远端重新 pip install -r requirements.txt
python3.9 -m venv .venv
./.venv/bin/pip install --upgrade pip
./.venv/bin/pip install -r requirements.txt
# 3) 赋予脚本执行权限
chmod +x start-test.sh
# 4) 启动
./start-test.sh start
./start-test.sh status
curl -s http://127.0.0.1:8112/health
``` ```
健康检查与文档: 依赖本机:`python3`、`pnpm`、`rsync`、`ssh`/`scp`;密码登录需 `sshpass`。
SSH 密码默认读仓库根 `测试环境机器的账号密码`,或环境变量 `ZKXW_TEST_SSH_PASS`(**勿提交密码到 git**)。
- 健康检查:`http://127.0.0.1:8112/health` ### 6.2 手工命令(等价)
- Swagger:`http://127.0.0.1:8112/docs`
```bash
# 前端
cd web-admin && pnpm build:test
rsync -avz --delete dist/ root@101.35.245.122:/root/zkxw/web-admin/dist/
# 后端
rsync -avz --exclude .venv --exclude venv --exclude '__pycache__' --exclude logs \
backend/ root@101.35.245.122:/root/zkxw/backend/
ssh root@101.35.245.122 \
'cd /root/zkxw/backend && HOST=127.0.0.1 PORT=10002 ./start-test.sh restart'
```
**注意**:
- 前端须使用 `pnpm build:test`(读取 `web-admin/.env.test`),勿误用 `pnpm build`。
- rsync 默认不覆盖服务器 `.env.test`;配置变更用脚本 `--upload-env` 或单独 scp。
- rsync 排除 `.venv`,依赖变更时用脚本 `--pip` 或在机器上重新 `pip install -r requirements.txt`。
--- ---
## 4. `start-test.sh` 命令 ## 7. 验收
```bash ```bash
./start-test.sh [start|stop|restart|status|logs|activate|help] curl -s -o /dev/null -w '%{http_code}\n' http://101.35.245.122:10001/
curl -s http://101.35.245.122:10001/health
curl -s -o /dev/null -w '%{http_code}\n' --connect-timeout 5 http://101.35.245.122:10002/health || echo '10002_closed_ok'
curl -s -o /dev/null -w '%{http_code}\n' http://101.35.245.122:10001/api/web/auth/login-rsa-public-key
``` ```
| 命令 | 说明 | 预期:
- 首页与 `/health` 返回 **200**
- 外网直连 `10002` **不可达**(输出 `10002_closed_ok` 或超时)
- 登录 RSA 公钥接口经 Nginx 反代返回 **200**
---
## 8. 前端环境变量(`web-admin/.env.test`)
| 变量 | 说明 |
|------|------| |------|------|
| `start` | 后台启动(默认) | | `VITE_BASE_URL` | `/`(部署在 10001 根路径) |
| `stop` | 停止 | | `VITE_API_URL` | `/api`(同域 Nginx 反代至 127.0.0.1:10002) |
| `restart` | 重启 | | `VITE_MINIO_BASE_URL` | `http://47.99.164.66:20004/zkxw`(浏览器直连,不经测试机 Nginx) |
| `status` | 查看运行状态 | | `VITE_API_ENCRYPT_*` | 与 `backend/.env.test` 中 `API_ENCRYPT_*` 一致 |
| `logs` | 最近 100 行日志 |
| `activate` | 激活 `.venv` 并进入交互 shell(等价 `source .venv/bin/activate`) |
| `help` | 帮助 |
### 激活虚拟环境
```bash
./start-test.sh activate
# 或手动:
source .venv/bin/activate
```
`activate` 会进入已激活 venv 的交互 shell,输入 `exit` 退出。
### 可选环境变量
```bash
HOST=0.0.0.0 PORT=8112 WORKERS=1 LOG_LEVEL=debug \
ENV_FILE=./.env.test VENV_DIR=./.venv ./start-test.sh start
```
若仍使用旧目录 `venv` 而非 `.venv`:
```bash
VENV_DIR=./venv ./start-test.sh start
```
若测试也必须占用 **8111**(需确保未启动生产实例):
```bash
PORT=8111 ./start-test.sh start
```
--- ---
## 5. 生产环境(对照,便于追查) ## 9. 常见问题
生产脚本:`backend/start-prod.sh`,默认端口 **8111**,读取 `.env.production`。
```bash
cd /root/zkxw-backend/backend
cp .env.example .env.production # 按生产填写
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
chmod +x start-prod.sh
./start-prod.sh start
curl -s http://127.0.0.1:8111/health
```
生产命令:`start` / `stop` / `restart` / `status` / `logs`(无 `activate` 子命令)。
---
## 6. `.env.test` 配置要点
模板文件:`backend/.env.test.example`。
| 配置项 | 建议 |
|--------|------|
| `DEBUG` | 测试可为 `true` |
| `DATABASE_URL` | **独立测试库**,勿直连生产库 |
| `SECRET_KEY` | 与生产区分,勿提交仓库 |
| `REDIS_DB` | 与生产区分 DB 编号 |
| `MINIO_BUCKET` | 建议独立 bucket |
| `API_ENCRYPT_*` | 与测试环境前端 `VITE_API_ENCRYPT_*` 一致 |
| `BACKEND_CORS_ORIGINS` | 测试管理端 / 小程序 H5 域名(JSON 数组) |
| 微信支付 | 测试可用 `WXPAY_SANDBOX=true` 或留空 |
`.env.test`、`.env.production` 含密钥,**不要提交到 git**。
---
## 7. Web 管理端(web-admin)测试打包与部署
### 7.1 环境拓扑
| 角色 | 地址 | 说明 |
|------|------|------|
| 前端 Nginx | `47.121.120.79:8112` | 静态资源 + `/api` 反代 |
| 前端静态目录 | `/root/zkxw/dist` | `pnpm build:test` 产物上传至此 |
| 测试后端 | `47.99.164.66:8112` | `backend/start-test.sh` 启动 |
浏览器访问:`http://47.121.120.79:8112`
API 请求:`http://47.121.120.79:8112/api/...` → Nginx 转发 → `http://47.99.164.66:8112/api/...`
### 7.2 本地打包命令
```bash
cd web-admin
# 首次或依赖变更
pnpm install
# 测试环境打包(读取 web-admin/.env.test)
pnpm build:test
# 产物目录
ls dist/
```
等价命令:
```bash
pnpm exec vite build --mode test
```
需要类型检查后再打包:
```bash
pnpm build:typecheck --mode test # 若需单独 typecheck,可先 vue-tsc --noEmit
```
### 7.3 上传到前端服务器
```bash
# 在 web-admin 目录执行(示例)
rsync -avz --delete dist/ root@47.121.120.79:/root/zkxw/dist/
```
或使用 scp:
```bash
scp -r dist/* root@47.121.120.79:/root/zkxw/dist/
```
### 7.4 前端环境变量(`web-admin/.env.test`)
| 变量 | 测试值 | 说明 |
|------|--------|------|
| `VITE_BASE_URL` | `/` | 部署在 8112 根路径 |
| `VITE_API_URL` | `/api` | 同域 Nginx 反代,避免跨域 |
| `VITE_API_ENCRYPT_ENABLED` | `true` | 与后端 `API_ENCRYPT_ENABLED` 一致 |
| `VITE_API_ENCRYPT_FIXED_SECRET` | 与后端相同 | 须与 `backend/.env.test` 一致 |
| `VITE_MINIO_BASE_URL` | `/minio/zkxw` | 由 Nginx 反代 MinIO |
### 7.5 Nginx 配置
配置文件:`测试环境部署/nginx.conf`(已新增 **8112** 的 `ZKXW` server 块)。
部署到服务器:
```bash
# 备份后覆盖或合并 include
nginx -t && nginx -s reload
```
关键配置摘要:
- `listen 8112`
- `root /root/zkxw/dist`
- `location ^~ /api/` → `http://47.99.164.66:8112`(完整 URI 转发)
- `location ^~ /minio/zkxw/` → `http://47.99.164.66:20003/zkxw/`(MinIO 端口按后端实际映射调整)
### 7.6 后端 CORS(若直连后端 API 时需要)
推荐走 Nginx 同域反代,一般**无需**额外 CORS。若前端改为直连 `http://47.99.164.66:8112`,须在 `backend/.env.test` 增加:
```env
BACKEND_CORS_ORIGINS=["http://47.121.120.79:8112"]
```
---
## 8. 常见问题
### API 请求地址是 47.121.120.79 而不是 47.99.164.66(正常)
测试环境使用 **Nginx 同域反代**,浏览器 Network 里应看到:
```text
http://47.121.120.79:8112/api/web/auth/login-rsa-public-key
```
**不会**显示 `47.99.164.66`——由 Nginx 在服务端转发到后端,这是预期行为。
### API 返回 504 Gateway Time-out(当前最可能问题)
已实测:`http://47.121.120.79:8112/` 静态页 **200**,但 `/api/...` 返回 **504**,且从外网直连 `47.99.164.66:8112` **连接超时**。
说明 **Nginx 反代配置已生效**,但 **前端服务器连不上后端 8112**,请在后端机 `47.99.164.66` 与网络侧排查:
**① 后端服务是否已启动并监听 8112**
```bash
cd /root/zkxw-backend/backend
./start-test.sh status
ss -lntp | grep 8112 # 应看到 0.0.0.0:8112
curl -s http://127.0.0.1:8112/health
curl -s http://127.0.0.1:8112/api/web/auth/login-rsa-public-key
```
未启动则:
```bash
cp .env.test.example .env.test # 若尚未配置
./start-test.sh start
./start-test.sh logs
```
**② 从前端 Nginx 服务器测试能否访问后端(关键)**
在 **47.121.120.79** 上执行:
```bash
curl -v --connect-timeout 5 http://47.99.164.66:8112/health
```
- 若此处 **超时/拒绝**:不是前端打包问题,需放通 **云安全组 / 防火墙**,允许 **47.121.120.79 → 47.99.164.66:8112**(无需对公网全网开放 8112)。
阿里云安全组示例:入方向 TCP 8112,源 IP `47.121.120.79/32`。
本机 firewalld 示例(仅允许 Nginx 服务器):
```bash
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="47.121.120.79/32" port port="8112" protocol="tcp" accept'
firewall-cmd --reload
```
**③ Nginx 配置是否已 reload**
```bash
nginx -t && nginx -s reload
tail -f /var/log/nginx/zkxw_admin_8112_error.log
```
**④ 确认前端用测试包打包**
须使用 `pnpm build:test`(读取 `.env.test`,`VITE_API_URL=/api`)。若误用 `pnpm build` 会走 `.env.production`,API 会打到生产域名。
### 启动报「环境文件不存在」 ### 启动报「环境文件不存在」
```bash ```bash
cp .env.test.example .env.test cp .env.test.example .env.test
# 编辑后重试 # 或从本机 backend/.env 复制后编辑
./start-test.sh start ./start-test.sh start
``` ```
### 脚本语法错误(`syntax error near unexpected token`) ### API 504 / 502
`start-*.sh` 须为 **Unix 换行(LF)**,不能是 CRLF。在服务器上可执行: 在测试机上排查:
```bash ```bash
sed -i 's/\r$//' start-test.sh start-prod.sh ./start-test.sh status
curl -s http://127.0.0.1:10002/health
sudo nginx -t
sudo tail -f /var/log/nginx/zkxw_admin_10001_error.log
``` ```
### 测试与生产同时运行 ### 脚本语法错误(CRLF)
- 端口:8112 / 8111 ```bash
- PID:`backend-test.pid` / `backend-prod.pid` sed -i 's/\r$//' start-test.sh
- 停止测试勿执行 `./start-prod.sh stop`,反之亦然。 ```
### 虚拟环境未找到 ### 虚拟环境未找到
```bash ```bash
python3.9 -m venv .venv python3.12 -m venv .venv
./.venv/bin/pip install --upgrade pip ./.venv/bin/pip install -U pip
./.venv/bin/pip install -r requirements.txt ./.venv/bin/pip install -r requirements.txt
``` ```
### Python 版本过低 / 依赖安装报 fastapi==0.109.0 找不到
**现象**:`系统 python3 版本: 3.6`,或 pip 版本 9.x,安装 fastapi 失败。
**原因**:系统默认 `python3` 为 3.6,而本项目需要 **Python 3.9+**。若曾用 3.6 创建过 `venv/` 或 `.venv/`,启动脚本会误用旧环境。
**处理**:
```bash
cd /root/zkxw-backend/backend
# 确认可用版本
python3.9 --version
# 检查是否误设了生产 venv 目录
echo "VENV_DIR=${VENV_DIR:-<未设置>}"
unset VENV_DIR # 若曾 export VENV_DIR=./venv,先取消
# 删除旧 venv,用 3.9 重建
rm -rf .venv venv
python3.9 -m venv .venv
./.venv/bin/pip install --upgrade pip
./.venv/bin/pip install -r requirements.txt
# 验证
./.venv/bin/python --version # 应为 3.9.x
./.venv/bin/python -c "import uvicorn; print('ok')"
./start-test.sh start
```
若 shell 中曾 `export VENV_DIR=./venv`(生产用),测试启动前须 `unset VENV_DIR` 或显式指定:
```bash
VENV_DIR=./.venv ./start-test.sh start
```
--- ---
## 9. 仓库内相关文件索引 ## 10. 仓库内相关文件
| 路径 | 说明 | | 路径 | 说明 |
|------|------| |------|------|
| `backend/start-test.sh` | 测试环境启动脚本 | | `backend/start-test.sh` | 测试环境启动脚本 |
| `backend/start-prod.sh` | 生产环境启动脚本 |
| `backend/.env.test.example` | 测试环境变量模板 | | `backend/.env.test.example` | 测试环境变量模板 |
| `backend/.env.example` | 通用环境变量示例 |
| `backend/app/core/config.py` | `ENV_FILE` 加载逻辑 |
| `web-admin/.env.test` | 测试环境前端打包变量 | | `web-admin/.env.test` | 测试环境前端打包变量 |
| `web-admin/package.json` | `pnpm build:test` | | `web-admin/package.json` | `pnpm build:test` |
| `测试环境部署/nginx.conf` | 前端 8112 + API/MinIO 反代 | | `测试环境部署/zkxw-test-10001.conf` | Nginx 10001 site 配置 |
| `AGENTS.md` | 开发说明中的 `start-test` / `start-prod` 命令摘要 | | `测试环境部署/deploy_test_env.py` | 日常发版一键脚本(前端打包 + rsync + 重启 + 验收) |
| `docs/superpowers/specs/2026-08-06-test-env-bare-metal-deploy-design.md` | 设计说明 |
--- ---
## 10. 修订记录 ## 11. 修订记录
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-06-01 | 初版:测试脚本 `start-test.sh`(8112、`.env.test`、`.venv`)、`activate` 命令、与生产对照及部署步骤 | | 2026-06-01 | 初版:测试脚本 `start-test.sh`(8112、`.env.test`、`.venv`)、`activate` 命令、与生产对照及部署步骤 |
| 2026-06-01 | 补充:`web-admin` 测试打包(`pnpm build:test`)、Nginx 8112、`/root/zkxw/dist`、API 反代 `47.99.164.66:8112` | | 2026-06-01 | 补充:`web-admin` 测试打包(`pnpm build:test`)、Nginx 8112、`/root/zkxw/dist`、API 反代 `47.99.164.66:8112` |
| 2026-08-06 | 裸机部署 10001/10002 于 101.35.245.122 |
| 2026-08-06 | 新增 `deploy_test_env.py` 日常发版脚本 |
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试环境日常发版脚本(裸机:Nginx 10001 + backend 127.0.0.1:10002)
在本机仓库根目录或任意目录执行均可(脚本会定位仓库根):
python3 测试环境部署/deploy_test_env.py # 前端打包 + 后端同步 + 重启
python3 测试环境部署/deploy_test_env.py --frontend-only
python3 测试环境部署/deploy_test_env.py --backend-only
python3 测试环境部署/deploy_test_env.py --verify
python3 测试环境部署/deploy_test_env.py --upload-env # 额外上传 backend/.env → 远端 .env.test
python3 测试环境部署/deploy_test_env.py --pip # 远端重新 pip install -r requirements.txt
SSH 密码默认读取仓库根「测试环境机器的账号密码」(root密码:...),
也可用环境变量 ZKXW_TEST_SSH_PASS,或 --password / --password-file。
勿把密码写进本脚本或提交到 git。
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import List, Optional, Sequence
# ---------------------------------------------------------------------------
# 默认拓扑(与 docs/superpowers/specs/2026-08-06-test-env-bare-metal-deploy-design.md 一致)
# ---------------------------------------------------------------------------
DEFAULT_HOST = "101.35.245.122"
DEFAULT_USER = "root"
REMOTE_ROOT = "/root/zkxw"
REMOTE_BACKEND = f"{REMOTE_ROOT}/backend"
REMOTE_DIST = f"{REMOTE_ROOT}/web-admin/dist"
BACKEND_HOST = "127.0.0.1"
BACKEND_PORT = "10002"
PUBLIC_HTTP_PORT = "10001"
BACKEND_EXCLUDES = (
".venv",
"venv",
"__pycache__",
"*.pyc",
"logs",
"graphify-out",
".env",
".env.*",
".pytest_cache",
".pycache",
".DS_Store",
)
def repo_root() -> Path:
here = Path(__file__).resolve().parent
# 测试环境部署/ → 仓库根
return here.parent
def die(msg: str, code: int = 1) -> None:
print(f"[ERROR] {msg}", file=sys.stderr)
raise SystemExit(code)
def info(msg: str) -> None:
print(f"[INFO] {msg}")
def run(
cmd: Sequence[str],
*,
cwd: Optional[Path] = None,
env: Optional[dict] = None,
check: bool = True,
) -> subprocess.CompletedProcess:
print(f"[CMD] {' '.join(cmd)}")
return subprocess.run(
list(cmd),
cwd=str(cwd) if cwd else None,
env=env,
check=check,
)
def load_password(args: argparse.Namespace, root: Path) -> Optional[str]:
if args.password:
return args.password
env_pass = os.environ.get("ZKXW_TEST_SSH_PASS") or os.environ.get("SSHPASS")
if env_pass:
return env_pass
path = Path(args.password_file) if args.password_file else root / "测试环境机器的账号密码"
if not path.is_file():
return None
text = path.read_text(encoding="utf-8", errors="replace")
m = re.search(r"^root密码[::]\s*(.+)\s*$", text, re.M)
if m:
return m.group(1).strip()
# 兜底:第二行非空内容
lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not ln.strip().startswith("#")]
if len(lines) >= 2 and "密码" in lines[1]:
return re.sub(r"^.*?[::]\s*", "", lines[1]).strip()
return None
def require_bin(name: str) -> str:
path = shutil.which(name)
if not path:
die(f"未找到命令 `{name}`,请先安装并加入 PATH")
return path
def ssh_argv(password: Optional[str]) -> List[str]:
"""ssh 可执行前缀(含可选 sshpass)。"""
ssh = require_bin("ssh")
opts = ["-o", "StrictHostKeyChecking=accept-new", "-o", "RequestTTY=no"]
if password:
require_bin("sshpass")
return ["sshpass", "-e", ssh, *opts]
return [ssh, "-o", "BatchMode=yes", *opts]
def scp_argv(password: Optional[str]) -> List[str]:
scp = require_bin("scp")
opts = ["-o", "StrictHostKeyChecking=accept-new", "-o", "RequestTTY=no"]
if password:
require_bin("sshpass")
return ["sshpass", "-e", scp, *opts]
return [scp, "-o", "BatchMode=yes", *opts]
def with_sshpass_env(password: Optional[str], base_env: Optional[dict] = None) -> dict:
env = dict(base_env or os.environ)
if password:
env["SSHPASS"] = password
return env
def remote_target(args: argparse.Namespace) -> str:
return f"{args.user}@{args.host}"
def ssh_run(args: argparse.Namespace, password: Optional[str], remote_cmd: str) -> None:
cmd = [*ssh_argv(password), remote_target(args), remote_cmd]
run(cmd, env=with_sshpass_env(password))
def rsync_to(
args: argparse.Namespace,
password: Optional[str],
local: str,
remote_path: str,
*,
delete: bool = False,
excludes: Sequence[str] = (),
) -> None:
require_bin("rsync")
# rsync -e 需要单个 shell 字符串
ssh_e = " ".join(ssh_argv(password))
cmd: List[str] = ["rsync", "-avz"]
if delete:
cmd.append("--delete")
for ex in excludes:
cmd.extend(["--exclude", ex])
cmd.extend(["-e", ssh_e, local, f"{remote_target(args)}:{remote_path}"])
run(cmd, env=with_sshpass_env(password))
def build_frontend(root: Path) -> None:
web = root / "web-admin"
if not (web / "package.json").is_file():
die(f"未找到前端目录: {web}")
require_bin("pnpm")
env = dict(os.environ)
# Vite 大项目默认堆易 OOM
env.setdefault("NODE_OPTIONS", "--max-old-space-size=8192")
run(["pnpm", "build:test"], cwd=web, env=env)
index = web / "dist" / "index.html"
if not index.is_file():
die(f"打包未生成 {index}")
def sync_frontend(args: argparse.Namespace, password: Optional[str], root: Path) -> None:
dist = root / "web-admin" / "dist"
if not (dist / "index.html").is_file():
die("本地无 web-admin/dist/index.html,请先打包或去掉 --skip-build")
rsync_to(
args,
password,
f"{dist}/",
f"{REMOTE_DIST}/",
delete=True,
)
# Nginx 读 /root 下静态文件需要可穿越权限
ssh_run(
args,
password,
f"chmod 755 /root && chmod -R a+rX {REMOTE_ROOT}/web-admin",
)
def sync_backend(args: argparse.Namespace, password: Optional[str], root: Path) -> None:
backend = root / "backend"
if not (backend / "start-test.sh").is_file():
die(f"未找到后端: {backend}/start-test.sh")
rsync_to(
args,
password,
f"{backend}/",
f"{REMOTE_BACKEND}/",
excludes=BACKEND_EXCLUDES,
)
def upload_env(args: argparse.Namespace, password: Optional[str], root: Path) -> None:
src = root / "backend" / ".env"
if not src.is_file():
die(f"本机无 {src},无法上传 .env.test")
require_bin("scp")
with tempfile.NamedTemporaryFile(prefix="zkxw-env-", suffix=".test", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
tmp_path.write_bytes(src.read_bytes())
cmd = [
*scp_argv(password),
str(tmp_path),
f"{remote_target(args)}:{REMOTE_BACKEND}/.env.test",
]
run(cmd, env=with_sshpass_env(password))
info(f"已上传 {src} → {REMOTE_BACKEND}/.env.test")
finally:
tmp_path.unlink(missing_ok=True)
def remote_pip(args: argparse.Namespace, password: Optional[str]) -> None:
ssh_run(
args,
password,
f"cd {REMOTE_BACKEND} && ./.venv/bin/pip install -r requirements.txt",
)
def restart_backend(args: argparse.Namespace, password: Optional[str]) -> None:
ssh_run(
args,
password,
f"cd {REMOTE_BACKEND} && chmod +x start-test.sh && "
f"HOST={BACKEND_HOST} PORT={BACKEND_PORT} ./start-test.sh restart",
)
def verify(args: argparse.Namespace) -> None:
require_bin("curl")
base = f"http://{args.host}:{PUBLIC_HTTP_PORT}"
checks = [
("首页", f"{base}/", True),
("health", f"{base}/health", False),
("login-rsa", f"{base}/api/web/auth/login-rsa-public-key", True),
]
ok = True
for name, url, code_only in checks:
if code_only:
cp = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--connect-timeout", "10", url],
capture_output=True,
text=True,
)
code = (cp.stdout or "").strip()
print(f"[VERIFY] {name}: HTTP {code} ({url})")
if code != "200":
ok = False
else:
cp = subprocess.run(
["curl", "-s", "--connect-timeout", "10", url],
capture_output=True,
text=True,
)
body = (cp.stdout or "").strip()
print(f"[VERIFY] {name}: {body[:120]} ({url})")
if "healthy" not in body and cp.returncode != 0:
ok = False
# 10002 应对外不可达
cp = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--connect-timeout",
"5",
f"http://{args.host}:{BACKEND_PORT}/health",
],
capture_output=True,
text=True,
)
code = (cp.stdout or "").strip()
if code in ("", "000") or cp.returncode != 0:
print(f"[VERIFY] 外网 10002: 不可达(符合预期) code={code or 'n/a'}")
else:
print(f"[VERIFY] 外网 10002: 意外可达 HTTP {code}(应仅本机监听)")
ok = False
if not ok:
die("验收未全部通过", code=2)
info("验收通过")
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
p = argparse.ArgumentParser(
description="ZKXW 测试环境发版(前端 build:test + rsync + backend restart)",
)
p.add_argument("--host", default=DEFAULT_HOST, help=f"测试机 IP(默认 {DEFAULT_HOST})")
p.add_argument("--user", default=DEFAULT_USER, help=f"SSH 用户(默认 {DEFAULT_USER})")
p.add_argument("--password", default=None, help="SSH 密码(不推荐;优先用密码文件或环境变量)")
p.add_argument(
"--password-file",
default=None,
help="密码文件路径(默认:仓库根/测试环境机器的账号密码)",
)
g = p.add_mutually_exclusive_group()
g.add_argument("--frontend-only", action="store_true", help="仅前端打包并上传")
g.add_argument("--backend-only", action="store_true", help="仅同步后端并重启")
g.add_argument("--verify", action="store_true", help="仅跑验收 curl")
p.add_argument("--skip-build", action="store_true", help="前端跳过 pnpm build:test,直接上传已有 dist")
p.add_argument("--skip-restart", action="store_true", help="同步后端后不重启")
p.add_argument("--upload-env", action="store_true", help="上传本机 backend/.env 为远端 .env.test")
p.add_argument("--pip", action="store_true", help="远端执行 pip install -r requirements.txt")
p.add_argument("--no-verify", action="store_true", help="发版后不自动验收")
return p.parse_args(argv)
def main(argv: Optional[Sequence[str]] = None) -> None:
args = parse_args(argv)
root = repo_root()
os.chdir(root)
info(f"仓库根目录: {root}")
if args.verify:
verify(args)
return
password = load_password(args, root)
if not password and not os.environ.get("SSH_AUTH_SOCK"):
info("未找到 SSH 密码;将尝试密钥登录(BatchMode)")
elif password:
info("已加载 SSH 密码(来自参数/环境变量/密码文件)")
do_fe = not args.backend_only
do_be = not args.frontend_only
if do_fe:
if not args.skip_build:
info("开始前端打包 pnpm build:test …")
build_frontend(root)
else:
info("跳过打包,使用现有 web-admin/dist")
info("上传前端 dist …")
sync_frontend(args, password, root)
if do_be:
info("同步后端源码 …")
sync_backend(args, password, root)
if args.upload_env:
upload_env(args, password, root)
if args.pip:
info("远端 pip install …")
remote_pip(args, password)
if not args.skip_restart:
info("重启后端 …")
restart_backend(args, password)
else:
info("已跳过后端重启")
if not args.no_verify:
info("发版后验收 …")
verify(args)
info("完成")
if __name__ == "__main__":
try:
main()
except subprocess.CalledProcessError as e:
die(f"命令失败(exit {e.returncode}): {e.cmd}")
except KeyboardInterrupt:
die("已中断", code=130)
+59
View File
@@ -0,0 +1,59 @@
# ZKXW 测试环境(裸机)— 部署到 101.35.245.122
# 用法(按发行版二选一):
# Ubuntu/Debian: 复制到 /etc/nginx/sites-available/zkxw-test-10001.conf
# ln -sf ... /etc/nginx/sites-enabled/
# RHEL/CentOS: 复制到 /etc/nginx/conf.d/zkxw-test-10001.conf
# nginx -t && systemctl reload nginx
server {
listen 10001;
listen [::]:10001;
server_name 101.35.245.122 localhost 127.0.0.1;
root /root/zkxw/web-admin/dist;
index index.html;
client_max_body_size 100M;
access_log /var/log/nginx/zkxw_admin_10001_access.log;
error_log /var/log/nginx/zkxw_admin_10001_error.log;
location = /health {
proxy_pass http://127.0.0.1:10002/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
}
location ^~ /docs {
proxy_pass http://127.0.0.1:10002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://127.0.0.1:10002/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location ^~ /api/ {
proxy_pass http://127.0.0.1:10002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / {
try_files $uri $uri/ /index.html;
}
}