提交
This commit is contained in:
1 parent
8248e5ef15
commit
b9b4cc2673
57 files changed
+42800
-30473
No files matched your search
+111
-90
@@ -7,6 +7,7 @@ from app.core.response import success_response
|
||||
router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
|
||||
|
||||
# 与 web-admin/src/router/modules/sqxp.ts 一致:5 个一级菜单分组
|
||||
# 市场二级顺序:细胞存储、科研捐赠、市场基础信息、中试服务、基础实验
|
||||
STATIC_MENU_TREE = [
|
||||
# ────────────────────────────────────────────
|
||||
# 1. 市场 (menu:market)
|
||||
@@ -76,6 +77,7 @@ STATIC_MENU_TREE = [
|
||||
# },
|
||||
],
|
||||
},
|
||||
|
||||
# 科研捐赠
|
||||
{
|
||||
"id": "RSC00000000000000000000000000001",
|
||||
@@ -112,6 +114,106 @@ STATIC_MENU_TREE = [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
# 市场基础信息(前段:存储/捐赠相关;后段:中试/基础实验相关)
|
||||
{
|
||||
"id": "MKT00000000000000000000000000001",
|
||||
"name": "MarketInfo",
|
||||
"path": "market-info",
|
||||
"component": "",
|
||||
"meta": {
|
||||
"title": "市场基础信息",
|
||||
"icon": "ri:price-tag-3-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "122",
|
||||
"name": "BizCellSampleType",
|
||||
"path": "cell-sample-type",
|
||||
"component": "/basic-info/cell-sample-type/index",
|
||||
"meta": {"title": "细胞样本基础信息维护", "keepAlive": True, "menuCode": "biz:cellSampleType:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "123",
|
||||
"name": "BizCollectionRegion",
|
||||
"path": "collection-region",
|
||||
"component": "/basic-info/collection-region/index",
|
||||
"meta": {"title": "采集地区及医院", "keepAlive": True, "menuCode": "biz:collectionRegion:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "40000000000000000000000000000007",
|
||||
"name": "BasicInfoInsuranceCompany",
|
||||
"path": "insurance-company",
|
||||
"component": "/basic-info/insurance-company/index",
|
||||
"meta": {"title": "保险公司及险种价格", "keepAlive": True, "menuCode": "basic-info:insurance-company:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "50000000000000000000000000000009",
|
||||
"name": "MarketMaterial",
|
||||
"path": "material",
|
||||
"component": "/cell-storage/material/index",
|
||||
"meta": {"title": "市场物料库", "keepAlive": True, "menuCode": "biz:material:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "95000000000000000000000000000003",
|
||||
"name": "MarketMyMaterialUsage",
|
||||
"path": "my-material-usage",
|
||||
"component": "/cell-storage/my-material-usage/index",
|
||||
"meta": {
|
||||
"title": "我的物料领用记录",
|
||||
"keepAlive": True,
|
||||
"menuCode": "market:material-usage:list",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "121",
|
||||
"name": "BizCustomer",
|
||||
"path": "customer",
|
||||
"component": "/basic-info/customer/index",
|
||||
"meta": {"title": "客户管理", "keepAlive": True, "menuCode": "biz:customer:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "80000000000000000000000000000002",
|
||||
"name": "CellProductType",
|
||||
"path": "cell-product-type",
|
||||
"component": "/pilot-service/cell-product-type/index",
|
||||
"meta": {"title": "细胞产品基础信息维护", "keepAlive": True, "menuCode": "pilot-service:cell-product-type:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "80000000000000000000000000000007",
|
||||
"name": "CheckupInstitution",
|
||||
"path": "checkup-institution",
|
||||
"component": "/pilot-service/checkup-institution/index",
|
||||
"meta": {"title": "体检机构及项目价格", "keepAlive": True, "menuCode": "pilot-service:checkup-institution:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "80000000000000000000000000000006",
|
||||
"name": "Channel",
|
||||
"path": "channel",
|
||||
"component": "/pilot-service/channel/index",
|
||||
"meta": {"title": "渠道信息管理", "keepAlive": True, "menuCode": "pilot-service:channel:list"},
|
||||
},
|
||||
|
||||
{
|
||||
"id": "80000000000000000000000000000009",
|
||||
"name": "ChannelServiceOrderFinance",
|
||||
"path": "channel-service-order-finance",
|
||||
"component": "/pilot-service/channel-service-order-finance/index",
|
||||
"meta": {"title": "渠道服务订单(财务确认)", "keepAlive": True, "menuCode": "pilot-service:channel-service-order-finance:list"},
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
# 中试服务
|
||||
{
|
||||
"id": "PIL00000000000000000000000000001",
|
||||
@@ -141,6 +243,7 @@ STATIC_MENU_TREE = [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
# 基础实验
|
||||
{
|
||||
"id": "BEX00000000000000000000000000001",
|
||||
@@ -188,96 +291,7 @@ STATIC_MENU_TREE = [
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
# 市场基础信息
|
||||
{
|
||||
"id": "MKT00000000000000000000000000001",
|
||||
"name": "MarketInfo",
|
||||
"path": "market-info",
|
||||
"component": "",
|
||||
"meta": {
|
||||
"title": "市场基础信息",
|
||||
"icon": "ri:price-tag-3-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "122",
|
||||
"name": "BizCellSampleType",
|
||||
"path": "cell-sample-type",
|
||||
"component": "/basic-info/cell-sample-type/index",
|
||||
"meta": {"title": "细胞样本基础信息维护", "keepAlive": True, "menuCode": "biz:cellSampleType:list"},
|
||||
},
|
||||
{
|
||||
"id": "123",
|
||||
"name": "BizCollectionRegion",
|
||||
"path": "collection-region",
|
||||
"component": "/basic-info/collection-region/index",
|
||||
"meta": {"title": "细胞采集基础信息维护", "keepAlive": True, "menuCode": "biz:collectionRegion:list"},
|
||||
},
|
||||
{
|
||||
"id": "80000000000000000000000000000002",
|
||||
"name": "CellProductType",
|
||||
"path": "cell-product-type",
|
||||
"component": "/pilot-service/cell-product-type/index",
|
||||
"meta": {"title": "细胞产品基础信息维护", "keepAlive": True, "menuCode": "pilot-service:cell-product-type:list"},
|
||||
},
|
||||
{
|
||||
"id": "40000000000000000000000000000007",
|
||||
"name": "BasicInfoInsuranceCompany",
|
||||
"path": "insurance-company",
|
||||
"component": "/basic-info/insurance-company/index",
|
||||
"meta": {"title": "保险公司及险种价格", "keepAlive": True, "menuCode": "basic-info:insurance-company:list"},
|
||||
},
|
||||
{
|
||||
"id": "80000000000000000000000000000007",
|
||||
"name": "CheckupInstitution",
|
||||
"path": "checkup-institution",
|
||||
"component": "/pilot-service/checkup-institution/index",
|
||||
"meta": {"title": "体检机构及项目价格", "keepAlive": True, "menuCode": "pilot-service:checkup-institution:list"},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000009",
|
||||
"name": "MarketMaterial",
|
||||
"path": "material",
|
||||
"component": "/cell-storage/material/index",
|
||||
"meta": {"title": "市场物料库", "keepAlive": True, "menuCode": "biz:material:list"},
|
||||
},
|
||||
{
|
||||
"id": "95000000000000000000000000000003",
|
||||
"name": "MarketMyMaterialUsage",
|
||||
"path": "my-material-usage",
|
||||
"component": "/cell-storage/my-material-usage/index",
|
||||
"meta": {
|
||||
"title": "我的物料领用记录",
|
||||
"keepAlive": True,
|
||||
"menuCode": "market:material-usage:list",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "121",
|
||||
"name": "BizCustomer",
|
||||
"path": "customer",
|
||||
"component": "/basic-info/customer/index",
|
||||
"meta": {"title": "客户管理", "keepAlive": True, "menuCode": "biz:customer:list"},
|
||||
},
|
||||
{
|
||||
"id": "80000000000000000000000000000006",
|
||||
"name": "Channel",
|
||||
"path": "channel",
|
||||
"component": "/pilot-service/channel/index",
|
||||
"meta": {"title": "渠道信息管理", "keepAlive": True, "menuCode": "pilot-service:channel:list"},
|
||||
},
|
||||
{
|
||||
"id": "80000000000000000000000000000009",
|
||||
"name": "ChannelServiceOrderFinance",
|
||||
"path": "channel-service-order-finance",
|
||||
"component": "/pilot-service/channel-service-order-finance/index",
|
||||
"meta": {"title": "渠道服务订单(财务确认)", "keepAlive": True, "menuCode": "pilot-service:channel-service-order-finance:list"},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@@ -944,6 +958,13 @@ STATIC_MENU_TREE = [
|
||||
"component": "/oa-approval/cc/index",
|
||||
"meta": {"title": "抄送我的", "keepAlive": True, "menuCode": "oa:cc"},
|
||||
},
|
||||
{
|
||||
"id": "OA000000000000000000000000000110",
|
||||
"name": "OaApprovalMention",
|
||||
"path": "mentions",
|
||||
"component": "/oa-approval/mentions/index",
|
||||
"meta": {"title": "提到我的", "keepAlive": True, "menuCode": "oa:mention"},
|
||||
},
|
||||
{
|
||||
"id": "OA000000000000000000000000000201",
|
||||
"name": "OaApprovalDesigner",
|
||||
|
||||
@@ -103,6 +103,8 @@ def _mobile_menu_icon(path: str) -> str:
|
||||
return "oa-mine"
|
||||
if "oa/cc" in p:
|
||||
return "oa-cc"
|
||||
if "oa/mentions" in p:
|
||||
return "oa-mentions"
|
||||
if "oa/wechat-notify" in p:
|
||||
return "oa-wechat-notify"
|
||||
if "form-sink" in p:
|
||||
@@ -123,6 +125,7 @@ def _mobile_menu_icon_colors(path: str) -> dict:
|
||||
"oa/done",
|
||||
"oa/mine",
|
||||
"oa/cc",
|
||||
"oa/mentions",
|
||||
"oa/wechat-notify",
|
||||
)
|
||||
):
|
||||
|
||||
@@ -766,10 +766,11 @@ class OaTransferBody(BaseModel):
|
||||
|
||||
class OaCommentCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
content: str
|
||||
content: Optional[str] = ""
|
||||
attachment_refs: Optional[List[Dict[str, Any]]] = Field(
|
||||
None, alias="attachmentRefs"
|
||||
)
|
||||
mention_user_ids: Optional[List[str]] = Field(None, alias="mentionUserIds")
|
||||
|
||||
|
||||
class OaResubmitBody(BaseModel):
|
||||
@@ -1069,6 +1070,7 @@ async def create_oa_instance_comment(
|
||||
dept_id=user.get("dept_id") or "",
|
||||
content=body.content,
|
||||
attachment_refs=body.attachment_refs,
|
||||
mention_user_ids=body.mention_user_ids,
|
||||
)
|
||||
return success_response(data)
|
||||
except OaEngineError as e:
|
||||
|
||||
@@ -993,6 +993,7 @@ class OaInstanceComment(Base):
|
||||
attachment_refs_json = Column(Text, nullable=True, comment="引用表单附件快照 JSON")
|
||||
status = Column(String(16), nullable=False, default="normal", comment="normal/revoked")
|
||||
revoked_time = Column(DateTime, nullable=True, comment="撤回时间")
|
||||
mention_user_ids_json = Column(Text, nullable=True, comment="评论@用户ID列表 JSON")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
@@ -1004,6 +1005,28 @@ class OaInstanceComment(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaInstanceViewGrant(Base):
|
||||
"""OA审批实例查看授权(评论@产生,不写入抄送任务)"""
|
||||
|
||||
__tablename__ = "oa_instance_view_grant"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
instance_id = Column(String(64), nullable=False, comment="实例ID")
|
||||
user_id = Column(String(64), nullable=False, comment="被授权查看人")
|
||||
source = Column(String(32), nullable=False, default="comment_mention", comment="comment_mention")
|
||||
comment_id = Column(String(64), nullable=True, comment="来源评论ID,撤回评论不删除授权")
|
||||
granted_by = Column(String(64), nullable=True, comment="授权人")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class OaRelation(Base):
|
||||
"""OA关联审批 oa_relation"""
|
||||
|
||||
|
||||
@@ -2866,6 +2866,37 @@ def list_tasks(
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
if kind == "mention":
|
||||
from app.services.oa_instance_comment import list_mentioned_instance_ids
|
||||
|
||||
ids = list_mentioned_instance_ids(db, tenant_no=tenant_no, user_id=user_id)
|
||||
if not ids:
|
||||
return {"list": [], "total": 0}
|
||||
q = db.query(OaInstance).filter(
|
||||
OaInstance.tenant_no == tenant_no,
|
||||
OaInstance.del_flag == 0,
|
||||
OaInstance.id.in_(ids),
|
||||
)
|
||||
q, _, _ = _apply_instance_list_filters(
|
||||
q,
|
||||
app_name=app_name,
|
||||
initiator_name=initiator_name,
|
||||
keyword=keyword,
|
||||
apply_time_from=apply_time_from,
|
||||
apply_time_to=apply_time_to,
|
||||
instance_status=instance_status,
|
||||
)
|
||||
insts = q.all()
|
||||
rank = {iid: i for i, iid in enumerate(ids)}
|
||||
insts.sort(key=lambda inst: rank.get(inst.id, len(ids)))
|
||||
total = len(insts)
|
||||
insts = insts[offset : offset + page_size]
|
||||
rows = [_instance_to_dict(i) for i in insts]
|
||||
return {
|
||||
"list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
if kind == "pending":
|
||||
q = db.query(OaTask).filter(
|
||||
OaTask.tenant_no == tenant_no,
|
||||
|
||||
@@ -8,7 +8,13 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import OaAppVersion, OaInstance, OaInstanceComment
|
||||
from app.models.models import (
|
||||
OaAppVersion,
|
||||
OaInstance,
|
||||
OaInstanceComment,
|
||||
OaInstanceViewGrant,
|
||||
SysUser,
|
||||
)
|
||||
from app.services.oa_engine import OaEngineError, _get_instance, _loads, _user_name_map
|
||||
from app.services.oa_related_approval import (
|
||||
UPLOAD_TYPES,
|
||||
@@ -18,6 +24,7 @@ from app.services.oa_related_approval import (
|
||||
)
|
||||
|
||||
CONTENT_MAX = 1024
|
||||
MENTION_MAX = 20
|
||||
|
||||
|
||||
def enumerate_form_attachments(
|
||||
@@ -77,10 +84,46 @@ def _allowed_url_index(
|
||||
return index
|
||||
|
||||
|
||||
def _attachment_url_candidates(url: str) -> List[str]:
|
||||
raw = (url or "").strip()
|
||||
path = raw.split("?", 1)[0].strip()
|
||||
found: List[str] = []
|
||||
for item in (raw, path, path.lstrip("/")):
|
||||
if item and item not in found:
|
||||
found.append(item)
|
||||
try:
|
||||
from app.core.config import settings
|
||||
|
||||
base = (getattr(settings, "MINIO_BASE_URL", None) or "").rstrip("/")
|
||||
except Exception:
|
||||
base = ""
|
||||
if base and path.startswith(base + "/"):
|
||||
rel = path[len(base) + 1 :]
|
||||
if rel and rel not in found:
|
||||
found.append(rel)
|
||||
return found
|
||||
|
||||
|
||||
def _find_uploaded_attachment(db: Session, tenant_no: str, url: str):
|
||||
from app.models.models import BizAttachment
|
||||
|
||||
q = db.query(BizAttachment).filter(
|
||||
BizAttachment.del_flag == 0,
|
||||
BizAttachment.tenant_no == tenant_no,
|
||||
)
|
||||
for candidate in _attachment_url_candidates(url):
|
||||
row = q.filter(BizAttachment.file_url == candidate).first()
|
||||
if row is not None:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def normalize_attachment_refs(
|
||||
schema: Any,
|
||||
form_data: Optional[Dict[str, Any]],
|
||||
refs: Optional[List[Dict[str, Any]]],
|
||||
db: Optional[Session] = None,
|
||||
tenant_no: str = "",
|
||||
) -> List[Dict[str, str]]:
|
||||
if not refs:
|
||||
return []
|
||||
@@ -93,14 +136,37 @@ def normalize_attachment_refs(
|
||||
if not isinstance(raw, dict):
|
||||
raise OaEngineError("引用的附件不存在或不属于本表单")
|
||||
url = str(raw.get("url") or "").strip()
|
||||
if not url or url not in index:
|
||||
raise OaEngineError("引用的附件不存在或不属于本表单")
|
||||
if url in seen:
|
||||
source = str(raw.get("source") or "form").strip() or "form"
|
||||
if not url or url in seen:
|
||||
if not url:
|
||||
raise OaEngineError("引用的附件不存在或不属于本表单")
|
||||
continue
|
||||
if source == "upload":
|
||||
if db is None:
|
||||
raise OaEngineError("上传的附件不存在")
|
||||
row = _find_uploaded_attachment(db, tenant_no, url)
|
||||
if row is None:
|
||||
raise OaEngineError("上传的附件不存在")
|
||||
seen.add(url)
|
||||
out.append(
|
||||
{
|
||||
"source": "upload",
|
||||
"fieldKey": "",
|
||||
"fieldLabel": "",
|
||||
"url": str(row.file_url or url),
|
||||
"fileName": str(row.original_name or "").strip() or url,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if source != "form":
|
||||
raise OaEngineError("attachmentRefs 格式无效")
|
||||
if url not in index:
|
||||
raise OaEngineError("引用的附件不存在或不属于本表单")
|
||||
seen.add(url)
|
||||
meta = index[url]
|
||||
out.append(
|
||||
{
|
||||
"source": "form",
|
||||
"fieldKey": meta["fieldKey"],
|
||||
"fieldLabel": meta["fieldLabel"],
|
||||
"url": url,
|
||||
@@ -114,6 +180,25 @@ def _fmt_time(dt: Optional[datetime]) -> str:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""
|
||||
|
||||
|
||||
def _mention_ids(row: OaInstanceComment) -> List[str]:
|
||||
raw = _loads(getattr(row, "mention_user_ids_json", None), [])
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: List[str] = []
|
||||
for item in raw:
|
||||
uid = str(item or "").strip()
|
||||
if uid and uid not in out:
|
||||
out.append(uid)
|
||||
return out
|
||||
|
||||
|
||||
def _mentions_payload(row: OaInstanceComment, name_map: Dict[str, str]) -> List[Dict[str, str]]:
|
||||
return [
|
||||
{"userId": uid, "userName": name_map.get(uid, uid)}
|
||||
for uid in _mention_ids(row)
|
||||
]
|
||||
|
||||
|
||||
def _comment_to_dict(
|
||||
row: OaInstanceComment,
|
||||
*,
|
||||
@@ -141,6 +226,7 @@ def _comment_to_dict(
|
||||
"createByName": name_map.get(row.create_by or "", row.create_by or ""),
|
||||
"createTime": _fmt_time(row.create_time),
|
||||
"canRevoke": can_revoke,
|
||||
"mentions": _mentions_payload(row, name_map),
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +259,10 @@ def list_comments(
|
||||
.order_by(OaInstanceComment.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
name_map = _user_name_map(db, [r.create_by for r in rows if r.create_by])
|
||||
name_ids = [r.create_by for r in rows if r.create_by]
|
||||
for r in rows:
|
||||
name_ids.extend(_mention_ids(r))
|
||||
name_map = _user_name_map(db, name_ids)
|
||||
return [
|
||||
_comment_to_dict(
|
||||
r,
|
||||
@@ -196,6 +285,129 @@ def list_form_attachments(
|
||||
return enumerate_form_attachments(schema, form)
|
||||
|
||||
|
||||
def normalize_mention_user_ids(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
actor_user_id: str,
|
||||
user_ids: Optional[List[str]],
|
||||
) -> List[str]:
|
||||
if not user_ids:
|
||||
return []
|
||||
if not isinstance(user_ids, list):
|
||||
raise OaEngineError("mentionUserIds 格式无效")
|
||||
actor = (actor_user_id or "").strip()
|
||||
seen: List[str] = []
|
||||
for raw in user_ids:
|
||||
uid = str(raw or "").strip()
|
||||
if not uid or uid in seen or uid == actor:
|
||||
continue
|
||||
seen.append(uid)
|
||||
if len(seen) > MENTION_MAX:
|
||||
raise OaEngineError(f"一次最多@{MENTION_MAX}人")
|
||||
if not seen:
|
||||
return []
|
||||
rows = (
|
||||
db.query(SysUser.id)
|
||||
.filter(
|
||||
SysUser.id.in_(seen),
|
||||
SysUser.tenant_no == tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.status == "1",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
found = {str(r[0]) for r in rows}
|
||||
if any(uid not in found for uid in seen):
|
||||
raise OaEngineError("提及的用户不存在或已停用")
|
||||
return seen
|
||||
|
||||
|
||||
def _ensure_view_grants(
|
||||
db: Session,
|
||||
*,
|
||||
instance_id: str,
|
||||
tenant_no: str,
|
||||
user_ids: List[str],
|
||||
comment_id: str,
|
||||
actor_user_id: str,
|
||||
dept_id: str,
|
||||
) -> None:
|
||||
if not user_ids:
|
||||
return
|
||||
existing = (
|
||||
db.query(OaInstanceViewGrant.user_id)
|
||||
.filter(
|
||||
OaInstanceViewGrant.instance_id == instance_id,
|
||||
OaInstanceViewGrant.tenant_no == tenant_no,
|
||||
OaInstanceViewGrant.del_flag == 0,
|
||||
OaInstanceViewGrant.user_id.in_(user_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
have = {str(r[0]) for r in existing}
|
||||
now = datetime.now()
|
||||
for uid in user_ids:
|
||||
if uid in have:
|
||||
continue
|
||||
db.add(
|
||||
OaInstanceViewGrant(
|
||||
id=generate_uuid(),
|
||||
instance_id=instance_id,
|
||||
user_id=uid,
|
||||
source="comment_mention",
|
||||
comment_id=comment_id,
|
||||
granted_by=actor_user_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=actor_user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
create_time=now,
|
||||
update_by=actor_user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_mentioned_instance_ids(
|
||||
db: Session, *, tenant_no: str, user_id: str
|
||||
) -> List[str]:
|
||||
"""评论@授权的实例。被@即出现在「提到我的」,不因发起人/审批人/抄送身份而隐藏。"""
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return []
|
||||
grants = (
|
||||
db.query(OaInstanceViewGrant)
|
||||
.filter(
|
||||
OaInstanceViewGrant.tenant_no == tenant_no,
|
||||
OaInstanceViewGrant.user_id == uid,
|
||||
OaInstanceViewGrant.del_flag == 0,
|
||||
)
|
||||
.order_by(OaInstanceViewGrant.create_time.desc(), OaInstanceViewGrant.id.desc())
|
||||
.all()
|
||||
)
|
||||
inst_ids: List[str] = []
|
||||
seen = set()
|
||||
for g in grants:
|
||||
iid = (g.instance_id or "").strip()
|
||||
if iid and iid not in seen:
|
||||
seen.add(iid)
|
||||
inst_ids.append(iid)
|
||||
if not inst_ids:
|
||||
return []
|
||||
allowed = {
|
||||
str(r[0])
|
||||
for r in db.query(OaInstance.id)
|
||||
.filter(
|
||||
OaInstance.tenant_no == tenant_no,
|
||||
OaInstance.del_flag == 0,
|
||||
OaInstance.id.in_(inst_ids),
|
||||
)
|
||||
.all()
|
||||
}
|
||||
return [iid for iid in inst_ids if iid in allowed]
|
||||
|
||||
|
||||
def create_comment(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -205,26 +417,34 @@ def create_comment(
|
||||
dept_id: str,
|
||||
content: str,
|
||||
attachment_refs: Optional[List[Dict[str, Any]]],
|
||||
mention_user_ids: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
inst = _get_instance(db, instance_id, tenant_no)
|
||||
if (inst.status or "") != "running":
|
||||
raise OaEngineError("仅审批进行中可评论")
|
||||
text = (content or "").strip()
|
||||
if not text:
|
||||
raise OaEngineError("评论内容不能为空")
|
||||
if len(text) > CONTENT_MAX:
|
||||
raise OaEngineError(f"评论内容不能超过{CONTENT_MAX}字")
|
||||
schema = _load_version_schema(db, inst)
|
||||
form = _loads(inst.form_data_json, {})
|
||||
if not isinstance(form, dict):
|
||||
form = {}
|
||||
refs = normalize_attachment_refs(schema, form, attachment_refs)
|
||||
refs = normalize_attachment_refs(schema, form, attachment_refs, db=db, tenant_no=tenant_no)
|
||||
mentions = normalize_mention_user_ids(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
actor_user_id=user_id,
|
||||
user_ids=mention_user_ids,
|
||||
)
|
||||
if not text and not mentions and not refs:
|
||||
raise OaEngineError("请填写评论、@人员或添加附件")
|
||||
now = datetime.now()
|
||||
row = OaInstanceComment(
|
||||
id=generate_uuid(),
|
||||
instance_id=instance_id,
|
||||
content=text,
|
||||
attachment_refs_json=json.dumps(refs, ensure_ascii=False) if refs else None,
|
||||
mention_user_ids_json=json.dumps(mentions, ensure_ascii=False) if mentions else None,
|
||||
status="normal",
|
||||
revoked_time=None,
|
||||
tenant_no=tenant_no,
|
||||
@@ -236,9 +456,18 @@ def create_comment(
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
_ensure_view_grants(
|
||||
db,
|
||||
instance_id=instance_id,
|
||||
tenant_no=tenant_no,
|
||||
user_ids=mentions,
|
||||
comment_id=row.id,
|
||||
actor_user_id=user_id,
|
||||
dept_id=dept_id or "",
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
name_map = _user_name_map(db, [user_id])
|
||||
name_map = _user_name_map(db, [user_id, *mentions])
|
||||
return _comment_to_dict(
|
||||
row,
|
||||
name_map=name_map,
|
||||
@@ -281,7 +510,7 @@ def revoke_comment(
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
name_map = _user_name_map(db, [row.create_by] if row.create_by else [])
|
||||
name_map = _user_name_map(db, [row.create_by, *_mention_ids(row)] if row.create_by else _mention_ids(row))
|
||||
return _comment_to_dict(
|
||||
row,
|
||||
name_map=name_map,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+14040
-10721
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,11 @@
|
||||
# Graph Report - app (2026-09-18)
|
||||
# Graph Report - app (2026-09-21)
|
||||
|
||||
## Corpus Check
|
||||
- Large corpus: 265 files · ~319,283 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
- Large corpus: 266 files · ~321,223 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
|
||||
## Summary
|
||||
- 5508 nodes · 30303 edges · 40 communities detected
|
||||
- Extraction: 31% EXTRACTED · 69% INFERRED · 0% AMBIGUOUS · INFERRED: 20768 edges (avg confidence: 0.6)
|
||||
- 5538 nodes · 30525 edges · 48 communities detected
|
||||
- Extraction: 31% EXTRACTED · 69% INFERRED · 0% AMBIGUOUS · INFERRED: 20936 edges (avg confidence: 0.6)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
@@ -43,30 +43,38 @@
|
||||
- [[_COMMUNITY_Community 31|Community 31]]
|
||||
- [[_COMMUNITY_Community 32|Community 32]]
|
||||
- [[_COMMUNITY_Community 33|Community 33]]
|
||||
- [[_COMMUNITY_Community 34|Community 34]]
|
||||
- [[_COMMUNITY_Community 35|Community 35]]
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 38|Community 38]]
|
||||
- [[_COMMUNITY_Community 39|Community 39]]
|
||||
- [[_COMMUNITY_Community 40|Community 40]]
|
||||
- [[_COMMUNITY_Community 41|Community 41]]
|
||||
- [[_COMMUNITY_Community 44|Community 44]]
|
||||
- [[_COMMUNITY_Community 45|Community 45]]
|
||||
- [[_COMMUNITY_Community 46|Community 46]]
|
||||
- [[_COMMUNITY_Community 47|Community 47]]
|
||||
- [[_COMMUNITY_Community 48|Community 48]]
|
||||
- [[_COMMUNITY_Community 49|Community 49]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `success_response()` - 770 edges
|
||||
2. `error_response()` - 639 edges
|
||||
3. `SysUser` - 612 edges
|
||||
4. `BizQcTaskResult` - 354 edges
|
||||
5. `BizProductionMaterial` - 338 edges
|
||||
6. `BizProductionMaterialUsage` - 331 edges
|
||||
7. `BizQcMaterialUsage` - 329 edges
|
||||
8. `BizQcMaterial` - 319 edges
|
||||
9. `BizProductionMaterialStockBatch` - 318 edges
|
||||
10. `BizMaterialUsage` - 315 edges
|
||||
1. `success_response()` - 774 edges
|
||||
2. `error_response()` - 643 edges
|
||||
3. `SysUser` - 616 edges
|
||||
4. `BizQcTaskResult` - 358 edges
|
||||
5. `BizProductionMaterial` - 342 edges
|
||||
6. `BizProductionMaterialUsage` - 335 edges
|
||||
7. `BizQcMaterialUsage` - 333 edges
|
||||
8. `BizQcMaterial` - 323 edges
|
||||
9. `BizProductionMaterialStockBatch` - 322 edges
|
||||
10. `BizMaterialUsage` - 319 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog` [INFERRED]
|
||||
core/api_request_log_middleware.py → models/models.py
|
||||
- `sync_lab_storage_seq_after_use()` --calls--> `generate_uuid()` [INFERRED]
|
||||
crud/sample_reception_lab_code.py → core/utils.py
|
||||
- `get_menu_list()` --calls--> `success_response()` [INFERRED]
|
||||
api/menu.py → core/response.py
|
||||
- `微信小程序登录业务:openid + 手机号一致性校验。` --uses--> `SysUser` [INFERRED]
|
||||
services/wechat_login_service.py → models/models.py
|
||||
- `已绑定 openid 时静默换票(无需手机号授权)。` --uses--> `SysUser` [INFERRED]
|
||||
@@ -78,206 +86,240 @@
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.01
|
||||
Nodes (1019): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+1011 more)
|
||||
Nodes (1054): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+1046 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.01
|
||||
Nodes (821): _ensure_customer_for_mother(), 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() (+813 more)
|
||||
Nodes (754): generate_uuid(), _apply_cell_product_status_change(), create_cell_product(), create_cell_products_batch(), _validate_batch_items(), create_cell_sample_type(), get_cell_sample_type(), list_cell_sample_types() (+746 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.01
|
||||
Nodes (621): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+613 more)
|
||||
Nodes (649): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+641 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.07
|
||||
Nodes (451): 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), 审批意见:前端传 commentText,兼容 comment。, TaskActionBody, Base, 采集协议单 / 审核 / 缴费明细 CRUD(细胞存储 2.2.6~2.2.10), 返回已在采集协议单或科研捐赠单样本表中占用的样本编码集合(同租户、未删除)。, 创建/更新样本行前校验:样本编码不得已被任一业务单占用(转移配对单豁免)。, 缴费明细行 → 前端字典(含协议、客户、转账附件)。 (+443 more)
|
||||
Cohesion: 0.06
|
||||
Nodes (567): 环境检测 — 检测项维护 + 登记管理 API, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody (+559 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.01
|
||||
Nodes (302): _normalize_date_bound(), _persist_protocol_sub_resources(), _body_to_payload(), _get_current_user_id(), get_production_reception_by_sample_code(), get_production_reception_detail(), _get_tenant_no(), post_production_reception() (+294 more)
|
||||
Nodes (320): _normalize_date_bound(), _persist_protocol_sub_resources(), list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), get_user_dept_id(), _resolve_user_role_scopes(), ApiRequestLogCRUD (+312 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.07
|
||||
Nodes (323): 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API, 持久化协议子资源。 缴费(含明细校验)须在样本之前:明细校验失败时不得已占用样本编码。 各子表 CRUD 内部仍可能分段 commit,创建接, 与列表接口一致:按租户 + 角色数据权限判断是否可访问该协议单。, If mother info exists but no customerId, create a customer record and link it., BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem (+315 more)
|
||||
Cohesion: 0.01
|
||||
Nodes (323): get_production_reception_by_sample_code(), eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), build_void_type_label_map() (+315 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.02
|
||||
Nodes (296): list_ethnics_for_select(), AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, create_access_log(), create_uv_disinfect_log(), delete_access_log() (+288 more)
|
||||
Nodes (236): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+228 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.02
|
||||
Nodes (249): 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() (+241 more)
|
||||
Nodes (147): get_designer_data(), _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+139 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.02
|
||||
Nodes (235): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+227 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (206): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+198 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.02
|
||||
Nodes (140): get_designer_data(), _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+132 more)
|
||||
Nodes (143): mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, _resolve_user_id(), web_qr_confirm() (+135 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.02
|
||||
Nodes (142): mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, _resolve_user_id(), web_qr_confirm() (+134 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (83): batch_delete_research_donations(), change_donation_status(), convert_from_storage(), convert_to_storage(), create_research_donation(), delete_research_donation(), _get_current_user_id(), get_research_donation_detail() (+75 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.03
|
||||
Nodes (152): _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() (+144 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (85): format_date(), join_equipment_ids(), parse_equipment_ids(), 工艺/步骤设备多选:逗号分隔 ID 与名称快照解析, 校验设备列表,返回 (equipment_ids 逗号串, equipment_name 逗号串快照)。, resolve_equipment_list_snapshot(), _resolve_course_display(), 登记步骤物料使用;关联领用记录时不扣减库存。调用方控制事务,本函数不 commit。 (+77 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.04
|
||||
Nodes (102): _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict(), _handover_view_dict(), _parse_handover_time(), 供输注计划 detail 聚合的交接只读信息。 (+94 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (68): map_payments_by_schedule_ids(), create_customer(), get_customer(), id_card_taken(), list_customers(), 客户 CRUD(biz_customer), _row_to_dict(), soft_delete_customer() (+60 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.05
|
||||
Nodes (71): aggregate_slot_stats_by_facility(), _apply_slot_select_base_filters(), _apply_slot_select_query_filters(), batch_create_facilities(), batch_create_slots(), _build_node_path_cache(), _build_slot_keyword_sql_filters(), clear_slots_by_facility_node() (+63 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.06
|
||||
Nodes (66): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+58 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.09
|
||||
Nodes (44): get_next_cell_product_code(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes(), create_cell_products_by_quantity() (+36 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.13
|
||||
Nodes (29): _assert_routes_exist(), collect_missing_route_ids(), create_set(), create_set_from_work_order(), create_tasks_from_set(), delete_set(), get_set(), _item_count_map() (+21 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
### Community 17 - "Community 17"
|
||||
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 (10): 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, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API) Args: out_trade_no: 商户订单号 tot, 生成签名 V3 (SHA256 with RSA), wechat_pay() (+2 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.14
|
||||
Nodes (11): apply_formulas_and_linkages(), eval_formula(), _extract_rules(), preprocess_formula(), OA 表单公式与数据联动(禁止任意 eval), 按 schema 中字段 props.formula / props.linkage 回填 form_data。 linkage 示例: { "when, 将 {field} 转为合法标识符引用 field。, _SafeEval (+3 more)
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.16
|
||||
Nodes (12): apply_formulas_and_linkages(), eval_formula(), _extract_rules(), FormulaError, preprocess_formula(), OA 表单公式与数据联动(禁止任意 eval), 按 schema 中字段 props.formula / props.linkage 回填 form_data。 linkage 示例: { "when, 将 {field} 转为合法标识符引用 field。 (+4 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.14
|
||||
Nodes (22): 验证密码(哈希非 bcrypt 或损坏时返回 False,避免抛错), verify_password(), _attach_user_relations(), change_password(), create_user(), _empty_user_relation_fields(), enrich_user_dicts_with_relations(), get_user() (+14 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.16
|
||||
Nodes (21): _build_tree(), _cascade_soft_delete(), get_menu(), list_menus(), list_menus_tree(), _row_to_dict(), soft_delete_menu(), update_menu() (+13 more)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.19
|
||||
Nodes (21): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+13 more)
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
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)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 0.2
|
||||
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"
|
||||
Cohesion: 0.25
|
||||
Nodes (15): _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _format_ymd(), _intersect_interval(), _interval_minutes() (+7 more)
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 0.27
|
||||
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 25 - "Community 25"
|
||||
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 26 - "Community 26"
|
||||
Cohesion: 0.23
|
||||
Nodes (12): Config, ProductBase, ProductCategoryBase, ProductCategoryCreate, ProductCategoryResponse, ProductCreate, ProductResponse, ProductUnitBase (+4 more)
|
||||
|
||||
### Community 27 - "Community 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)
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
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 21 - "Community 21"
|
||||
### Community 29 - "Community 29"
|
||||
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)
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
### Community 30 - "Community 30"
|
||||
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)
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
### Community 31 - "Community 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)
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 0.33
|
||||
Nodes (8): _build_tree(), _cascade_soft_delete(), get_menu(), list_menus(), list_menus_tree(), _row_to_dict(), soft_delete_menu(), update_menu()
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 0.29
|
||||
Nodes (2): BaseModel, SoftDeleteModel
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 0.38
|
||||
Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。 约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断; 若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 0.4
|
||||
Nodes (5): batch_matches_stock_status(), normalize_batch_stock_status(), 质检物料入库批次:按剩余数量过滤的语义辅助。, 纯函数判定(供单测);list_batches 用 SQL 等价条件。, 返回 'available' | 'exhausted';全部 / 空 / 非法 → None(不加剩余数量条件)。
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 0.5
|
||||
Nodes (3): BaseSettings, Config, Settings
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 0.67
|
||||
Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
### Community 40 - "Community 40"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
### Community 41 - "Community 41"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
### Community 44 - "Community 44"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送订单状态通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
### Community 45 - "Community 45"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送支付成功通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
### Community 46 - "Community 46"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送配送通知 Args: openid: 配送员openid task
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
### Community 47 - "Community 47"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送配送时效预警 Args: openid: 配送员openid ta
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
### Community 48 - "Community 48"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送退款结果通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 41 - "Community 41"
|
||||
### Community 49 - "Community 49"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
|
||||
|
||||
## Knowledge Gaps
|
||||
- **184 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+179 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 25`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
|
||||
- **Thin community `Community 32`** (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.
|
||||
- **Thin community `Community 29`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
|
||||
- **Thin community `Community 36`** (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.
|
||||
- **Thin community `Community 30`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
|
||||
- **Thin community `Community 37`** (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.
|
||||
- **Thin community `Community 31`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
|
||||
- **Thin community `Community 38`** (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.
|
||||
- **Thin community `Community 32`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
|
||||
- **Thin community `Community 39`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 33`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
|
||||
- **Thin community `Community 40`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 36`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
|
||||
- **Thin community `Community 41`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 37`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
|
||||
- **Thin community `Community 44`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 38`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
|
||||
- **Thin community `Community 45`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 39`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
|
||||
- **Thin community `Community 46`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 40`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
|
||||
- **Thin community `Community 47`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 41`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
|
||||
- **Thin community `Community 48`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 49`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 12`, `Community 16`, `Community 20`, `Community 21`?**
|
||||
_High betweenness centrality (0.109) - this node is a cross-community bridge._
|
||||
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 4`, `Community 6`, `Community 9`, `Community 10`?**
|
||||
_High betweenness centrality (0.039) - this node is a cross-community bridge._
|
||||
- **Why does `OaEngineError` connect `Community 8` to `Community 1`, `Community 2`, `Community 3`?**
|
||||
_High betweenness centrality (0.033) - this node is a cross-community bridge._
|
||||
- **Are the 930 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
|
||||
_`str` has 930 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 769 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
|
||||
_`success_response()` has 769 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 638 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
|
||||
_`error_response()` has 638 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 610 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤) data_scope 值: 1 = 全部数据 2 = 本部门数据 3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
|
||||
_`SysUser` has 610 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Why does `SysUser` connect `Community 2` to `Community 1`, `Community 3`, `Community 4`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 19`, `Community 28`, `Community 29`?**
|
||||
_High betweenness centrality (0.128) - this node is a cross-community bridge._
|
||||
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 4`, `Community 37`, `Community 5`, `Community 7`, `Community 9`, `Community 10`, `Community 15`?**
|
||||
_High betweenness centrality (0.046) - this node is a cross-community bridge._
|
||||
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 16`, `Community 19`, `Community 20`, `Community 22`, `Community 23`, `Community 24`, `Community 28`, `Community 29`, `Community 30`?**
|
||||
_High betweenness centrality (0.034) - this node is a cross-community bridge._
|
||||
- **Are the 941 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
|
||||
_`str` has 941 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 773 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
|
||||
_`success_response()` has 773 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 642 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
|
||||
_`error_response()` has 642 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 614 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤) data_scope 值: 1 = 全部数据 2 = 本部门数据 3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
|
||||
_`SysUser` has 614 INFERRED edges - model-reasoned connections that need verification._
|
||||
+17491
-14557
File diff suppressed because it is too large.
Load diff
@@ -33,7 +33,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/material.py": 1782894693.6389399,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/production_material.py": 1782404882.9262497,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_param.py": 1782379593.174925,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1789701693.5477362,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1789971994.638983,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/cleanroom_garment.py": 1781347080.6165943,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/cn_holiday.py": 1786373798.242758,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/__init__.py": 1781344070.5899634,
|
||||
@@ -195,7 +195,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1789710686.7963963,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/material.py": 1782894693.6582327,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production_material.py": 1782404982.2474263,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_menu.py": 1786680837.227191,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_menu.py": 1789979771.8476667,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1787193588.7157009,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/market.py": 1781522283.6823432,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_production_reception.py": 1788159565.164049,
|
||||
@@ -210,9 +210,9 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pre_transit_sample.py": 1786938943.6929982,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc_sms_notification.py": 1780326875.7886882,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation_sample_collection.py": 1780457788.2998126,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1787892843.1222966,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1789977632.0231144,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/operations.py": 1785213144.2881503,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1789696372.4245293,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1789972063.9200244,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_delivery.py": 1779655584.820777,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1786010386.7044516,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1786945487.513935,
|
||||
@@ -229,7 +229,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_collection.py": 1780457788.2093296,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/leave.py": 1779266168.8693042,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/clinical_payment.py": 1781750841.074568,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1787892839.3046124,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1789972028.300875,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_overtime_hours.py": 1786373766.5113294,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_callback.py": 1778727966.7277644,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/qc_work_order_fee_summary.py": 1783329884.6125586,
|
||||
@@ -251,6 +251,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_related_approval.py": 1787892816.3154016,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_message.py": 1786689383.1677845,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_leave_hours.py": 1785397787.0493405,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_instance_comment.py": 1790001317.4419823,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_resignation_kit_sync.py": 1785405020.285242,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_auth.py": 1786615330.0733685,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_assignee.py": 1785835523.951382,
|
||||
|
||||
@@ -64,6 +64,83 @@ class TestNormalizeAttachmentRefs(unittest.TestCase):
|
||||
[{"fieldKey": "files", "url": "https://evil/x.pdf"}],
|
||||
)
|
||||
|
||||
def test_marks_form_ref_source(self):
|
||||
schema = {"rule": [{"type": "upload", "field": "files", "title": "附件"}]}
|
||||
form = {"files": [{"url": "https://x/a.pdf", "name": "a.pdf"}]}
|
||||
out = normalize_attachment_refs(
|
||||
schema,
|
||||
form,
|
||||
[{"url": "https://x/a.pdf"}],
|
||||
)
|
||||
self.assertEqual(out[0]["source"], "form")
|
||||
|
||||
def test_accepts_tenant_upload_and_uses_stored_name(self):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.models import BizAttachment
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
BizAttachment.__table__.create(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
db.add(
|
||||
BizAttachment(
|
||||
id="att1",
|
||||
original_name="note.pdf",
|
||||
file_url="oa-approval/note.pdf",
|
||||
tenant_no="t1",
|
||||
create_dept_id="",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
out = normalize_attachment_refs(
|
||||
None,
|
||||
{},
|
||||
[{"source": "upload", "url": "oa-approval/note.pdf", "fileName": "hack.pdf"}],
|
||||
db=db,
|
||||
tenant_no="t1",
|
||||
)
|
||||
self.assertEqual(out[0]["source"], "upload")
|
||||
self.assertEqual(out[0]["fileName"], "note.pdf")
|
||||
self.assertEqual(out[0]["fieldKey"], "")
|
||||
db.close()
|
||||
|
||||
def test_rejects_upload_from_other_tenant_or_unknown_url(self):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.models import BizAttachment
|
||||
from app.services.oa_engine import OaEngineError
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
BizAttachment.__table__.create(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
db.add(
|
||||
BizAttachment(
|
||||
id="att2",
|
||||
original_name="note.pdf",
|
||||
file_url="oa-approval/note.pdf",
|
||||
tenant_no="other",
|
||||
create_dept_id="",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
with self.assertRaises(OaEngineError):
|
||||
normalize_attachment_refs(
|
||||
None,
|
||||
{},
|
||||
[{"source": "upload", "url": "oa-approval/note.pdf"}],
|
||||
db=db,
|
||||
tenant_no="t1",
|
||||
)
|
||||
with self.assertRaises(OaEngineError):
|
||||
normalize_attachment_refs(
|
||||
None,
|
||||
{},
|
||||
[{"source": "upload", "url": "https://evil.example/a.pdf"}],
|
||||
db=db,
|
||||
tenant_no="t1",
|
||||
)
|
||||
db.close()
|
||||
|
||||
|
||||
class TestCommentCrud(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -198,3 +275,253 @@ class TestCommentCrud(unittest.TestCase):
|
||||
content="x",
|
||||
attachment_refs=None,
|
||||
)
|
||||
|
||||
|
||||
class TestCommentMentionGrant(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.models import (
|
||||
OaAppVersion,
|
||||
OaInstance,
|
||||
OaInstanceComment,
|
||||
OaInstanceViewGrant,
|
||||
OaTask,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
for table in (
|
||||
OaAppVersion.__table__,
|
||||
OaInstance.__table__,
|
||||
OaInstanceComment.__table__,
|
||||
OaInstanceViewGrant.__table__,
|
||||
OaTask.__table__,
|
||||
SysUser.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
|
||||
self.db = Session()
|
||||
now = datetime.now()
|
||||
self.db.add(
|
||||
OaAppVersion(
|
||||
id="v1",
|
||||
app_id="app1",
|
||||
version_no=1,
|
||||
status="published",
|
||||
is_latest_published="1",
|
||||
form_schema_json="{}",
|
||||
flow_tree_json="{}",
|
||||
advanced_settings_json="{}",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
OaInstance(
|
||||
id="i1",
|
||||
app_id="app1",
|
||||
app_version_id="v1",
|
||||
title="t",
|
||||
status="running",
|
||||
initiator_user_id="u0",
|
||||
initiator_dept_id="d1",
|
||||
form_data_json="{}",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="d1",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
current_round=1,
|
||||
)
|
||||
)
|
||||
for uid, name, status in (("u1", "张三", "1"), ("u2", "李四", "1"), ("u3", "王五", "0")):
|
||||
self.db.add(
|
||||
SysUser(
|
||||
id=uid,
|
||||
tenant_no="zkxw",
|
||||
user_no=uid,
|
||||
nick_name=name,
|
||||
email="",
|
||||
phone_no="",
|
||||
password="",
|
||||
status=status,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def test_mention_grants_view_and_is_idempotent(self):
|
||||
from app.models.models import OaInstanceViewGrant
|
||||
from app.services import oa_instance_comment as svc
|
||||
|
||||
row = svc.create_comment(
|
||||
self.db,
|
||||
instance_id="i1",
|
||||
tenant_no="zkxw",
|
||||
user_id="u1",
|
||||
dept_id="d1",
|
||||
content="",
|
||||
attachment_refs=None,
|
||||
mention_user_ids=["u2", "u2", "u1"],
|
||||
)
|
||||
self.assertEqual(row["content"], "")
|
||||
self.assertEqual([m["userId"] for m in row["mentions"]], ["u2"])
|
||||
self.assertEqual(row["mentions"][0]["userName"], "李四")
|
||||
grants = (
|
||||
self.db.query(OaInstanceViewGrant)
|
||||
.filter(OaInstanceViewGrant.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(len(grants), 1)
|
||||
self.assertEqual(grants[0].user_id, "u2")
|
||||
self.assertEqual(grants[0].source, "comment_mention")
|
||||
|
||||
svc.create_comment(
|
||||
self.db,
|
||||
instance_id="i1",
|
||||
tenant_no="zkxw",
|
||||
user_id="u1",
|
||||
dept_id="d1",
|
||||
content="再提一次",
|
||||
attachment_refs=None,
|
||||
mention_user_ids=["u2"],
|
||||
)
|
||||
grants = (
|
||||
self.db.query(OaInstanceViewGrant)
|
||||
.filter(OaInstanceViewGrant.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(len(grants), 1)
|
||||
|
||||
def test_rejects_empty_without_mention(self):
|
||||
from app.services import oa_instance_comment as svc
|
||||
from app.services.oa_engine import OaEngineError
|
||||
|
||||
with self.assertRaises(OaEngineError):
|
||||
svc.create_comment(
|
||||
self.db,
|
||||
instance_id="i1",
|
||||
tenant_no="zkxw",
|
||||
user_id="u1",
|
||||
dept_id="d1",
|
||||
content=" ",
|
||||
attachment_refs=None,
|
||||
mention_user_ids=None,
|
||||
)
|
||||
|
||||
def test_allows_upload_only_comment(self):
|
||||
from app.models.models import BizAttachment
|
||||
from app.services import oa_instance_comment as svc
|
||||
|
||||
BizAttachment.__table__.create(self.engine)
|
||||
self.db.add(
|
||||
BizAttachment(
|
||||
id="att-c1",
|
||||
original_name="note.pdf",
|
||||
file_url="oa-approval/note.pdf",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
row = svc.create_comment(
|
||||
self.db,
|
||||
instance_id="i1",
|
||||
tenant_no="zkxw",
|
||||
user_id="u1",
|
||||
dept_id="d1",
|
||||
content=" ",
|
||||
attachment_refs=[{"source": "upload", "url": "oa-approval/note.pdf"}],
|
||||
mention_user_ids=None,
|
||||
)
|
||||
self.assertEqual(row["content"], "")
|
||||
self.assertEqual(row["attachmentRefs"][0]["source"], "upload")
|
||||
self.assertEqual(row["attachmentRefs"][0]["fileName"], "note.pdf")
|
||||
|
||||
def test_rejects_disabled_user(self):
|
||||
from app.services import oa_instance_comment as svc
|
||||
from app.services.oa_engine import OaEngineError
|
||||
|
||||
with self.assertRaises(OaEngineError):
|
||||
svc.create_comment(
|
||||
self.db,
|
||||
instance_id="i1",
|
||||
tenant_no="zkxw",
|
||||
user_id="u1",
|
||||
dept_id="d1",
|
||||
content="x",
|
||||
attachment_refs=None,
|
||||
mention_user_ids=["u3"],
|
||||
)
|
||||
|
||||
def test_revoke_keeps_grant_and_list_skips_participants(self):
|
||||
from app.models.models import OaInstance, OaInstanceViewGrant, OaTask
|
||||
from app.services import oa_instance_comment as svc
|
||||
|
||||
row = svc.create_comment(
|
||||
self.db,
|
||||
instance_id="i1",
|
||||
tenant_no="zkxw",
|
||||
user_id="u1",
|
||||
dept_id="d1",
|
||||
content="请看",
|
||||
attachment_refs=None,
|
||||
mention_user_ids=["u2"],
|
||||
)
|
||||
svc.revoke_comment(
|
||||
self.db,
|
||||
instance_id="i1",
|
||||
comment_id=row["id"],
|
||||
tenant_no="zkxw",
|
||||
user_id="u1",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.query(OaInstanceViewGrant)
|
||||
.filter(OaInstanceViewGrant.del_flag == 0)
|
||||
.count(),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(
|
||||
svc.list_mentioned_instance_ids(self.db, tenant_no="zkxw", user_id="u2"),
|
||||
["i1"],
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
self.db.add(
|
||||
OaTask(
|
||||
id="t-cc",
|
||||
instance_id="i1",
|
||||
task_type="cc",
|
||||
assignee_user_id="u2",
|
||||
status="done",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
round_no=1,
|
||||
is_read=0,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
# 即使已是抄送人,被@仍应出现在「提到我的」
|
||||
self.assertEqual(
|
||||
svc.list_mentioned_instance_ids(self.db, tenant_no="zkxw", user_id="u2"),
|
||||
["i1"],
|
||||
)
|
||||
|
||||
inst = self.db.query(OaInstance).filter(OaInstance.id == "i1").one()
|
||||
inst.initiator_user_id = "u2"
|
||||
self.db.commit()
|
||||
# 即使已是发起人,被@仍应出现在「提到我的」
|
||||
self.assertEqual(
|
||||
svc.list_mentioned_instance_ids(self.db, tenant_no="zkxw", user_id="u2"),
|
||||
["i1"],
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 采集地区 — 工作人员绑定(可多名负责人)
|
||||
-- Spec: docs/superpowers/specs/2026-09-18-collection-region-staff-binding-design.md
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `biz_collection_region_staff` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`region_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采集地区ID',
|
||||
`user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统用户ID',
|
||||
`is_leader` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否地区负责人 1是0否',
|
||||
`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_crs_region` (`region_id`, `del_flag`) USING BTREE,
|
||||
KEY `idx_crs_user` (`user_id`, `del_flag`) USING BTREE,
|
||||
KEY `idx_crs_tenant` (`tenant_no`, `del_flag`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采集地区工作人员';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 菜单更名:细胞采集基础信息维护 → 采集地区及医院
|
||||
-- 对应前端路由/静态菜单:sqxp.ts、backend/app/api/menu.py
|
||||
|
||||
UPDATE `sys_menu`
|
||||
SET
|
||||
`menu_name` = '采集地区及医院',
|
||||
`update_time` = NOW()
|
||||
WHERE `id` = '123'
|
||||
AND `del_flag` = 0;
|
||||
|
||||
-- 兼容按权限标识匹配(若环境环境 id 不同)
|
||||
UPDATE `sys_menu`
|
||||
SET
|
||||
`menu_name` = '采集地区及医院',
|
||||
`update_time` = NOW()
|
||||
WHERE `del_flag` = 0
|
||||
AND `perms` = 'biz:collectionRegion:list'
|
||||
AND `menu_name` <> '采集地区及医院';
|
||||
@@ -0,0 +1,97 @@
|
||||
-- 评论 @ 用户:查看授权(不写入抄送任务)
|
||||
ALTER TABLE `oa_instance_comment`
|
||||
ADD COLUMN `mention_user_ids_json` text COLLATE utf8mb4_unicode_ci NULL COMMENT '评论@用户ID列表 JSON' AFTER `revoked_time`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `oa_instance_view_grant` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`instance_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实例ID',
|
||||
`user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '被授权查看人',
|
||||
`source` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'comment_mention' COMMENT 'comment_mention',
|
||||
`comment_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源评论ID,撤回评论不删除授权',
|
||||
`granted_by` varchar(64) 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` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_oa_view_grant_user` (`tenant_no`,`instance_id`,`user_id`),
|
||||
KEY `idx_oa_view_grant_user_time` (`tenant_no`,`user_id`,`create_time`)
|
||||
) 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
|
||||
MD5(CONCAT('oa-mention-', s.`tenant_no`, '-', s.`id`)),
|
||||
'提到我的',
|
||||
s.`parent_id`,
|
||||
s.`menu_type`,
|
||||
'/pages/oa/mentions/index',
|
||||
s.`component`,
|
||||
'oa:mention',
|
||||
s.`is_frame`,
|
||||
s.`visible`,
|
||||
s.`status`,
|
||||
s.`tenant_no`,
|
||||
IFNULL(s.`order_num`, 0) + 1,
|
||||
'评论@后可查看的审批单',
|
||||
s.`create_by`,
|
||||
s.`create_dept_id`,
|
||||
NOW(),
|
||||
s.`update_by`,
|
||||
NOW(),
|
||||
0
|
||||
FROM `sys_menu` s
|
||||
WHERE s.`del_flag` = 0
|
||||
AND s.`menu_name` = '抄送我的'
|
||||
AND s.`path` LIKE '%oa/cc%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` x
|
||||
WHERE x.`del_flag` = 0
|
||||
AND x.`tenant_no` = s.`tenant_no`
|
||||
AND x.`path` LIKE '%oa/mentions%'
|
||||
);
|
||||
|
||||
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('oa-mention-role-', rm.`role_id`, '-', m.`id`)),
|
||||
rm.`role_id`,
|
||||
m.`id`,
|
||||
m.`tenant_no`,
|
||||
NULL,
|
||||
NULL,
|
||||
rm.`create_by`,
|
||||
IFNULL(rm.`create_dept_id`, ''),
|
||||
NOW(),
|
||||
rm.`update_by`,
|
||||
NOW(),
|
||||
0
|
||||
FROM `sys_menu` m
|
||||
JOIN `sys_menu` cc
|
||||
ON cc.`del_flag` = 0
|
||||
AND cc.`tenant_no` = m.`tenant_no`
|
||||
AND cc.`parent_id` = m.`parent_id`
|
||||
AND cc.`menu_name` = '抄送我的'
|
||||
AND cc.`path` LIKE '%oa/cc%'
|
||||
JOIN `sys_role_menu` rm
|
||||
ON rm.`del_flag` = 0
|
||||
AND rm.`menu_id` = cc.`id`
|
||||
WHERE m.`del_flag` = 0
|
||||
AND m.`path` LIKE '%oa/mentions%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_role_menu` x
|
||||
WHERE x.`del_flag` = 0
|
||||
AND x.`role_id` = rm.`role_id`
|
||||
AND x.`menu_id` = m.`id`
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- =============================================================================
|
||||
-- 清空 OA 审批「运行时实例」数据(上线前清理测试数据用)
|
||||
--
|
||||
-- 删除范围(物理删除):
|
||||
-- oa_instance_view_grant 评论@产生的查看授权
|
||||
-- oa_instance_comment 实例评论
|
||||
-- oa_relation 关联审批
|
||||
-- oa_instance_event 实例操作事件
|
||||
-- oa_task 待办/已办/抄送任务
|
||||
-- oa_instance 审批实例
|
||||
-- oa_instance_draft 发起审批草稿(非模板设计草稿)
|
||||
--
|
||||
-- 保留(不删除):
|
||||
-- oa_app_group / oa_app / oa_app_version 流程定义与已发布版本
|
||||
-- oa_receive_account 收款账户主数据
|
||||
--
|
||||
-- 使用前请先备份;默认清理全部租户。若只清某一租户,取消下方 @tenant_no 注释并赋值。
|
||||
-- =============================================================================
|
||||
|
||||
-- SET @tenant_no = 'zkxw';
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 1) 评论查看授权
|
||||
DELETE FROM `oa_instance_view_grant`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 2) 实例评论
|
||||
DELETE FROM `oa_instance_comment`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 3) 关联审批
|
||||
DELETE FROM `oa_relation`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 4) 实例事件
|
||||
DELETE FROM `oa_instance_event`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 5) 审批任务
|
||||
DELETE FROM `oa_task`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 6) 审批实例
|
||||
DELETE FROM `oa_instance`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
-- 7) 发起草稿
|
||||
DELETE FROM `oa_instance_draft`
|
||||
-- WHERE `tenant_no` = @tenant_no
|
||||
;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 执行后核对(期望均为 0)
|
||||
SELECT 'oa_instance_view_grant' AS tbl, COUNT(*) AS cnt FROM `oa_instance_view_grant`
|
||||
UNION ALL SELECT 'oa_instance_comment', COUNT(*) FROM `oa_instance_comment`
|
||||
UNION ALL SELECT 'oa_relation', COUNT(*) FROM `oa_relation`
|
||||
UNION ALL SELECT 'oa_instance_event', COUNT(*) FROM `oa_instance_event`
|
||||
UNION ALL SELECT 'oa_task', COUNT(*) FROM `oa_task`
|
||||
UNION ALL SELECT 'oa_instance', COUNT(*) FROM `oa_instance`
|
||||
UNION ALL SELECT 'oa_instance_draft', COUNT(*) FROM `oa_instance_draft`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,255 @@
|
||||
# OA 审批实例评论(Web 先)设计文档
|
||||
|
||||
**日期**: 2026-09-20
|
||||
**状态**: 已批准
|
||||
**关联**: `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md`(审批意见 ≠ 本能力)
|
||||
**交付策略**: Web 管理端先实现 → 同一套 API 再推广到微信小程序 / 移动端
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
在 OA 审批实例详情中提供独立「讨论评论」能力:参与人可发表文字评论、引用流程表单中已有附件,并可撤回自己的评论。评论**不改变**审批结果、不推进流程。
|
||||
|
||||
### 1.2 V1 包含(Web)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 发表评论 | 纯文字,必填,最长 1024 字 |
|
||||
| 引用表单附件 | 从当前实例 `form_data_json` 中 upload 类字段勾选已有文件;存引用快照,不复制对象存储文件 |
|
||||
| 撤回自己的评论 | 仅作者;撤回后时间线保留「已撤回」痕迹 |
|
||||
| 评论列表 | 详情页独立评论区展示;含正文、引用附件、人、时间 |
|
||||
| 状态门禁 | 仅实例 `status=running` 可发表 / 撤回;结束后只读 |
|
||||
| 权限 | 与「能打开该实例详情」同一套可见性;租户隔离 |
|
||||
|
||||
### 1.3 V1 明确不做
|
||||
|
||||
- 一键通知审批人 / 抄送人 / 参与人
|
||||
- @ 某人
|
||||
- 快捷回复词、表情面板
|
||||
- 评论区**新上传**图片 / 附件(仅引用表单已有附件)
|
||||
- 楼中楼回复、编辑已发评论
|
||||
- 将讨论评论混入审批进度链(`OaProgressChain`)中的办理意见
|
||||
- 本期不交付小程序 UI(接口按双端预留)
|
||||
|
||||
### 1.4 与「审批意见」的边界(SHALL)
|
||||
|
||||
| | 审批评论(本能力) | 审批意见(现有) |
|
||||
|---|---|---|
|
||||
| 存储 | `oa_instance_comment` | `oa_task.comment_text` / `oa_instance_event.comment_text` |
|
||||
| 时机 | 详情评论区随时(running) | 同意 / 拒绝 / 打回 / 加签 / 转交 |
|
||||
| 是否推进流程 | 否 | 是 |
|
||||
| UI | 独立「评论」区块 | 办理弹窗 / 进度链「意见」 |
|
||||
|
||||
**禁止**把讨论评论写入 `OaTask.comment_text`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案决策
|
||||
|
||||
曾对比:独立表 / 复用 `oa_instance_event` / 塞入实例 JSON。
|
||||
|
||||
**选定:独立表 `oa_instance_comment`。**
|
||||
|
||||
理由:发表 / 撤回 / 列表语义清晰;Web 与小程序共用同一 API;不污染办理事件模型。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 表 `oa_instance_comment`
|
||||
|
||||
字段顺序:`id` → 业务字段 → 公共字段(`tenant_no` … `del_flag`),与工程 database-conventions 一致。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | VARCHAR(64) PK | UUID |
|
||||
| `instance_id` | VARCHAR(64) NOT NULL | 所属 `oa_instance.id` |
|
||||
| `content` | VARCHAR(1024) NOT NULL | 评论文本(撤回后仍保留原文,便于审计;前端对 revoked 不展示正文或仅展示占位) |
|
||||
| `attachment_refs_json` | TEXT NULL | 引用快照 JSON 数组,见 §3.2 |
|
||||
| `status` | VARCHAR(16) NOT NULL | `normal` \| `revoked`,默认 `normal` |
|
||||
| `revoked_time` | DATETIME NULL | 撤回时间 |
|
||||
| `tenant_no` | … | 公共 |
|
||||
| `order_num` | … | 公共 |
|
||||
| `remark` | … | 公共 |
|
||||
| `create_by` | … | **评论作者 user_id**(与现网一致用 create_by) |
|
||||
| `create_dept_id` | … | 公共 |
|
||||
| `create_time` | … | 发表时间 |
|
||||
| `update_by` / `update_time` | … | 撤回时更新 |
|
||||
| `del_flag` | … | 默认 0;列表默认排除硬删;撤回用 `status=revoked`,**不**靠 del_flag 表示撤回 |
|
||||
|
||||
索引建议:`(tenant_no, instance_id, create_time)`。
|
||||
|
||||
SQL:新增迁移脚本 `docs/sql/migrations/2026-09-20-oa-instance-comment.sql`,并按仓库惯例评估是否同步全量 `docs/sql/zkxw.sql`。
|
||||
|
||||
### 3.2 `attachment_refs_json` 结构
|
||||
|
||||
数组元素:
|
||||
|
||||
```json
|
||||
{
|
||||
"fieldKey": "protocolFiles",
|
||||
"fieldLabel": "协议附件",
|
||||
"url": "https://minio.example/.../xx.pdf",
|
||||
"fileName": "协议.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
- 发表时服务端根据实例 `form_schema_json` + `form_data_json` 校验:每个 `url` **必须**出现在该实例某一 upload 类字段的当前值中(规范化逻辑对齐 `oa_related_approval._extract_upload_urls`)。
|
||||
- 校验失败 → 400,中文错误(如「引用的附件不存在或不属于本表单」)。
|
||||
- 快照在发表时刻固化;之后表单数据变更不影响已发表评论中的展示名 / url(若对象存储文件被删导致 404,属存储层问题,本期不处理)。
|
||||
|
||||
Upload 类字段类型(与现网一致):`upload` / `fcUpload` / `image` / `oaAttachment`。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 设计
|
||||
|
||||
前缀:现有 `/web/admin/oa-approval`(与 `backend/app/api/oa_approval.py` 一致)。
|
||||
|
||||
权限:调用方须通过与 `GET /instances/{instance_id}` **相同的实例可见性校验**;所有查询 / 写入强制 `tenant_no` = 当前用户租户。
|
||||
|
||||
### 4.1 可引用附件列表
|
||||
|
||||
`GET /instances/{instance_id}/form-attachments`
|
||||
|
||||
响应概念:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"fieldKey": "protocolFiles",
|
||||
"fieldLabel": "协议附件",
|
||||
"files": [
|
||||
{ "url": "...", "fileName": "协议.pdf" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
无附件时 `items: []`。
|
||||
|
||||
### 4.2 评论列表
|
||||
|
||||
`GET /instances/{instance_id}/comments`
|
||||
|
||||
- 返回该实例全部评论(含 `revoked`),按 `create_time` **升序**。
|
||||
- 每条含:`id`、`content`、`attachmentRefs`、`status`、`revokedTime`、`createBy`、`createByName`(若详情已有用户名投影则对齐)、`createTime`、`canRevoke`(当前用户且 status=normal 且实例 running)。
|
||||
|
||||
另:`GET /instances/{instance_id}` 详情响应 **SHALL** 增加字段 `comments`(结构同列表项数组),Web 首屏少一次请求;独立列表接口供刷新 / 小程序复用。
|
||||
|
||||
### 4.3 发表评论
|
||||
|
||||
`POST /instances/{instance_id}/comments`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "请补充第二页签字页",
|
||||
"attachmentRefs": [
|
||||
{ "fieldKey": "protocolFiles", "url": "...", "fileName": "协议.pdf" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `content` trim 后非空,长度 ≤ 1024。
|
||||
- `attachmentRefs` 可选;有则服务端补全 / 校验 `fieldLabel`,拒绝非法 url。
|
||||
- 仅 `oa_instance.status == running`;否则 400「仅审批进行中可评论」。
|
||||
- 成功返回新建评论对象(同列表项)。
|
||||
|
||||
### 4.4 撤回评论
|
||||
|
||||
`POST /instances/{instance_id}/comments/{comment_id}/revoke`
|
||||
|
||||
规则:
|
||||
|
||||
- 评论存在、未删、属于该实例与租户。
|
||||
- 仅 `create_by == 当前用户`。
|
||||
- 仅 `status == normal`;已撤回 → 幂等成功或 400(实现选一种,建议 **幂等成功**)。
|
||||
- 仅实例 `running`;结束后不可撤回。
|
||||
- 置 `status=revoked`、`revoked_time=now`,更新 `update_by` / `update_time`。
|
||||
- **不**物理删除,**不**清空 `content` / `attachment_refs_json`(审计保留;UI 对 revoked 不展示正文与附件)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端实现要点
|
||||
|
||||
| 位置 | 说明 |
|
||||
|------|------|
|
||||
| `backend/app/models/models.py` | 新增 `OaInstanceComment` |
|
||||
| `backend/app/schemas/` | CreateBody / 列表项 Pydantic(camelCase 对外) |
|
||||
| `backend/app/crud/` 或 `services/` | 列表、发表校验、撤回;附件枚举可放 `oa_related_approval` 旁或 `oa_instance_comment` 服务模块 |
|
||||
| `backend/app/api/oa_approval.py` | 注册上述 4 个路由;详情组装注入 `comments` |
|
||||
| 字段一致性 | `_row_to_dict` 为可读字段真相源;CreateBody 与之对齐可写字段 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Web 管理端 UI
|
||||
|
||||
### 6.1 页面
|
||||
|
||||
- 主入口:`web-admin/src/views/oa-approval/instances/detail.vue`
|
||||
- 若存在详情弹窗复用同一详情数据源:`OaInstanceDetailDialog.vue` 等亦需挂载同一面板(与现网详情入口对齐,避免漏入口)。
|
||||
|
||||
### 6.2 组件
|
||||
|
||||
新建 `web-admin/src/components/oa-approval/OaCommentPanel.vue`(建议):
|
||||
|
||||
1. **列表区**:升序;normal 展示头像/姓名、时间、正文、引用附件芯片(预览/下载对齐现有附件交互);revoked 展示灰条「{姓名} 撤回了一条评论」+ 撤回时间。
|
||||
2. **操作**:本人 + normal + 实例 running → 「撤回」按钮(需确认框)。
|
||||
3. **输入区**(仅 running):`el-input` textarea;「引用附件」打开多选(数据来自 form-attachments);「发送」。
|
||||
4. 按钮带图标(ui-conventions);遮罩无 blur;文案中文。
|
||||
|
||||
视觉:Fresh Mint;独立 `section`,**不要**并入 `OaProgressChain`。
|
||||
|
||||
> **2026-09-21 更新(钉钉对齐)**:展示已改为并入审批进度时间线,见 `docs/superpowers/specs/2026-09-21-oa-comment-in-progress-timeline-design.md`。`OaCommentPanel` 仅保留底部输入;列表/撤回在 `OaProgressChain`(`kind=comment`)。
|
||||
|
||||
### 6.3 API 模块
|
||||
|
||||
`web-admin/src/api/oaApproval.ts` 增加对应 fetch 方法。
|
||||
|
||||
---
|
||||
|
||||
## 7. 移动端 / 小程序(二期,本期仅约束)
|
||||
|
||||
- V1 不实现小程序页面。
|
||||
- API 不得依赖仅 Web 才有的请求头或 Element 专用结构;响应字段稳定、camelCase。
|
||||
- 二期在微信小程序审批详情复用:`form-attachments` / `comments` CRUD;UI 遵循 `docs/miniprogram-ui-spec-sanqiang.md`。
|
||||
- 现有 `mobile-oa-h5` 若与小程序并行,二期可择一或双端同接,不在本设计展开。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. 有详情可见权限的用户,在 running 实例详情可发表非空文字评论。
|
||||
2. 可多选引用表单附件;非法 url 被拒绝;评论区可预览/下载引用文件。
|
||||
3. 作者可撤回自己的 normal 评论;他人不可撤;撤回后见「已撤回」占位,正文与附件对用户隐藏。
|
||||
4. 非 running(completed / rejected / cancelled / returned 等)不可发、不可撤,仍可读历史评论。
|
||||
5. 同意/拒绝意见与进度链行为与改前一致。
|
||||
6. 租户隔离:不可读写他租户实例评论。
|
||||
|
||||
---
|
||||
|
||||
## 9. 非目标与后续
|
||||
|
||||
- 评论通知、@、新上传附件、楼中楼 → 另开变更。
|
||||
- 小程序 UI → V2。
|
||||
- 是否把评论摘要打进打印 PDF → 不在 V1。
|
||||
|
||||
---
|
||||
|
||||
## 10. 实现顺序建议
|
||||
|
||||
1. SQL + Model + Schema
|
||||
2. 服务:附件枚举、发表校验、撤回、列表
|
||||
3. API 路由 + 详情注入 `comments`
|
||||
4. 前端 API + `OaCommentPanel` + 挂到详情页
|
||||
5. 手工验收(Web)
|
||||
6. (可选)`./scripts/update-graph.sh backend` / `frontend`
|
||||
@@ -0,0 +1,20 @@
|
||||
# OA 评论并入审批进度时间线(钉钉对齐)
|
||||
|
||||
**日期**: 2026-09-21
|
||||
**状态**: 已批准(方案 A)
|
||||
**前置**: `2026-09-20-oa-instance-comment-design.md`
|
||||
|
||||
## 决策
|
||||
|
||||
- **方案 A**:前端将 `comments` 按时间插入审批进度链;后端 API / 表结构不变。
|
||||
- 去掉独立评论列表区;输入区放在「审批进度」下方。
|
||||
- 进度链新增 `kind=comment` 展示;撤回仍走既有 revoke API。
|
||||
|
||||
## 合并规则
|
||||
|
||||
1. `progress` 中 `status === not_reached` 的节点保持在轴末尾(原顺序)。
|
||||
2. **当前待审节点**(`pending` / `running` / `kind=pending_node`)不参与与评论的时间交错,挂在「已办节点 + 评论」之后、未到达节点之前。
|
||||
3. 其余已办进度项(发起、已同意、已拒绝、抄送完成等)与评论按 `timestamp` / `createTime` 升序交错。
|
||||
4. 同秒时:进度节点优先,评论紧随其后。
|
||||
|
||||
效果:A 已通过、流程停在 B 时再发评论 → 评论出现在 **A 后面、B 前面**。
|
||||
@@ -55,6 +55,7 @@
|
||||
'oa-done': '/static/menu-icons/oa-done.png',
|
||||
'oa-mine': '/static/menu-icons/oa-mine.png',
|
||||
'oa-cc': '/static/menu-icons/oa-cc.png',
|
||||
'oa-mentions': '/static/menu-icons/oa-mentions.png',
|
||||
'oa-wechat-notify': '/static/menu-icons/oa-wechat-notify.png'
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,17 @@
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/oa/mentions/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "提到我的",
|
||||
"navigationBarBackgroundColor": "#ecfdf5",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"onReachBottomDistance": 80,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/demo/list",
|
||||
"style": {
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="toolbar">
|
||||
<view class="search-box" :class="{ 'is-focus': searchFocused }">
|
||||
<view class="search-ico" aria-hidden="true" />
|
||||
<input
|
||||
class="search-input"
|
||||
:value="keyword"
|
||||
confirm-type="search"
|
||||
placeholder="搜索流程名称、申请人"
|
||||
placeholder-class="search-ph"
|
||||
@input="onSearchInput"
|
||||
@confirm="onSearchConfirm"
|
||||
@focus="searchFocused = true"
|
||||
@blur="searchFocused = false"
|
||||
/>
|
||||
<view v-if="keyword" class="search-clear" @click="clearKeyword">
|
||||
<text class="search-clear__x">×</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state state-loading">
|
||||
<view class="pulse-dots" aria-hidden="true">
|
||||
<view class="pulse-dot" />
|
||||
<view class="pulse-dot" />
|
||||
<view class="pulse-dot" />
|
||||
</view>
|
||||
<text class="state-loading__text">加载提到我的…</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!list.length" class="oa-empty-wrap">
|
||||
<VanEmpty
|
||||
:description="keyword ? '未找到相关记录' : '暂无提到我的审批'"
|
||||
:tip="
|
||||
keyword
|
||||
? '试试其他关键词,或清空搜索'
|
||||
: '评论里@你的审批会出现在这里'
|
||||
"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else class="pending-list">
|
||||
<view
|
||||
v-for="(row, idx) in list"
|
||||
:key="row.id"
|
||||
class="dd-card"
|
||||
@click="openView(row)"
|
||||
@longpress="onCardLongPress(row)"
|
||||
>
|
||||
<view class="dd-card__corner" aria-hidden="true">
|
||||
<text class="dd-card__corner-num">{{ cornerIndex(idx) }}</text>
|
||||
</view>
|
||||
<view class="dd-card__head">
|
||||
<text class="dd-card__title">{{ cardTitle(row) }}</text>
|
||||
<text class="dd-card__time">{{
|
||||
formatRelativeTime(row.applyTime || row.createTime)
|
||||
}}</text>
|
||||
</view>
|
||||
<view v-if="metaLines(row).length" class="dd-card__meta">
|
||||
<text
|
||||
v-for="(line, idx) in metaLines(row)"
|
||||
:key="idx"
|
||||
class="dd-card__meta-line"
|
||||
>
|
||||
{{ line }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="dd-card__foot">
|
||||
<text
|
||||
class="dd-card__hint-tag"
|
||||
:class="'is-' + statusTone(row.instanceStatus || row.status)"
|
||||
>
|
||||
{{ submitterText(row) }}
|
||||
</text>
|
||||
<text
|
||||
class="dd-card__status"
|
||||
:class="'is-' + statusTone(row.instanceStatus || row.status)"
|
||||
>
|
||||
{{ statusText(row.instanceStatus || row.status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
|
||||
<view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VanEmpty from '../../../components/van-empty/van-empty.vue'
|
||||
import { fetchOaTasks } from '../../../utils/oa-api.js'
|
||||
import { forceRelogin, getToken } from '../../../utils/auth.js'
|
||||
import { stashOaDetailTitle, stashOaStartTitle } from '../../../utils/oa-h5.js'
|
||||
import { formatCornerIndex } from '../../../utils/oa-corner-index.js'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const STATUS_MAP = {
|
||||
pending: '待处理',
|
||||
running: '进行中',
|
||||
approved: '已同意',
|
||||
rejected: '已拒绝',
|
||||
completed: '已完成',
|
||||
cancelled: '已撤销',
|
||||
done: '已结束',
|
||||
returned: '已打回',
|
||||
transferred: '已转交'
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? '0' + n : String(n)
|
||||
}
|
||||
|
||||
function parseDateTime(str) {
|
||||
if (!str) return null
|
||||
const s = String(str).trim().replace(/-/g, '/')
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { VanEmpty },
|
||||
data() {
|
||||
return {
|
||||
keyword: '',
|
||||
searchFocused: false,
|
||||
list: [],
|
||||
page: 1,
|
||||
total: 0,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
hasMore: true,
|
||||
_searchTimer: null
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
if (!getToken()) {
|
||||
forceRelogin('请先登录')
|
||||
return
|
||||
}
|
||||
this.loadList(true)
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.loadList(true)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
},
|
||||
onReachBottom() {
|
||||
if (!this.hasMore || this.loading || this.loadingMore) return
|
||||
this.loadList(false)
|
||||
},
|
||||
onUnload() {
|
||||
if (this._searchTimer) {
|
||||
clearTimeout(this._searchTimer)
|
||||
this._searchTimer = null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cornerIndex(idx) {
|
||||
return formatCornerIndex(Number(idx) + 1)
|
||||
},
|
||||
cardTitle(row) {
|
||||
return (row && (row.appName || row.title)) || '-'
|
||||
},
|
||||
rowStatus(row) {
|
||||
return String((row && (row.instanceStatus || row.status)) || '')
|
||||
},
|
||||
canCopyRestart() {
|
||||
return false
|
||||
},
|
||||
submitterText(row) {
|
||||
const status = this.rowStatus(row)
|
||||
const names = String((row && row.currentApproverNames) || '').trim()
|
||||
// 已结束时后端 currentApproverNames 常为 "-",勿拼成「待-处理」
|
||||
const inProgress = status === 'pending' || status === 'running'
|
||||
if (inProgress && names && names !== '-') {
|
||||
return '待' + names + '处理'
|
||||
}
|
||||
const node = String((row && row.currentNodeName) || '').trim()
|
||||
if (node && node !== '-') return node
|
||||
return '提到我的'
|
||||
},
|
||||
statusText(s) {
|
||||
if (!s) return '-'
|
||||
return STATUS_MAP[s] || s
|
||||
},
|
||||
statusTone(s) {
|
||||
if (s === 'pending' || s === 'running') return 'warn'
|
||||
if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
|
||||
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
|
||||
return 'info'
|
||||
},
|
||||
metaLines(row) {
|
||||
const lines = []
|
||||
const fields = (row && row.previewFields) || []
|
||||
for (let i = 0; i < fields.length && i < 3; i++) {
|
||||
const f = fields[i] || {}
|
||||
const label = String(f.label || '').trim() || '字段'
|
||||
const value =
|
||||
f.value != null && String(f.value).trim() !== ''
|
||||
? String(f.value)
|
||||
: '-'
|
||||
lines.push(label + ':' + value)
|
||||
}
|
||||
return lines
|
||||
},
|
||||
formatRelativeTime(str) {
|
||||
const d = parseDateTime(str)
|
||||
if (!d) return str || '-'
|
||||
const now = new Date()
|
||||
const startToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
)
|
||||
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
const dayDiff = Math.round(
|
||||
(startToday.getTime() - startThat.getTime()) / 86400000
|
||||
)
|
||||
if (dayDiff === 0) {
|
||||
return pad2(d.getHours()) + ':' + pad2(d.getMinutes())
|
||||
}
|
||||
if (dayDiff === 1) return '昨天'
|
||||
if (dayDiff === 2) return '前天'
|
||||
if (dayDiff > 2 && dayDiff < 7) return dayDiff + '天前'
|
||||
return (
|
||||
d.getFullYear() +
|
||||
'.' +
|
||||
pad2(d.getMonth() + 1) +
|
||||
'.' +
|
||||
pad2(d.getDate())
|
||||
)
|
||||
},
|
||||
_inputVal(e) {
|
||||
if (e && e.detail && e.detail.value != null) return String(e.detail.value)
|
||||
if (e && e.target) return String(e.target.value || '')
|
||||
return ''
|
||||
},
|
||||
onSearchInput(e) {
|
||||
this.keyword = this._inputVal(e)
|
||||
if (this._searchTimer) clearTimeout(this._searchTimer)
|
||||
this._searchTimer = setTimeout(() => {
|
||||
this.loadList(true)
|
||||
}, 400)
|
||||
},
|
||||
onSearchConfirm() {
|
||||
if (this._searchTimer) {
|
||||
clearTimeout(this._searchTimer)
|
||||
this._searchTimer = null
|
||||
}
|
||||
this.loadList(true)
|
||||
},
|
||||
clearKeyword() {
|
||||
this.keyword = ''
|
||||
this.onSearchConfirm()
|
||||
},
|
||||
onCardLongPress(row) {
|
||||
if (!row) return
|
||||
const itemList = ['打印']
|
||||
if (this.canCopyRestart(row)) {
|
||||
itemList.push('复制并重新发起')
|
||||
}
|
||||
uni.showActionSheet({
|
||||
itemList,
|
||||
success: (res) => {
|
||||
const tap = res && res.tapIndex
|
||||
if (tap === 0) {
|
||||
this.openView(row, { autoPrint: true })
|
||||
} else if (tap === 1 && this.canCopyRestart(row)) {
|
||||
this.openCopyRestart(row)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
openView(row, opts) {
|
||||
if (!row) return
|
||||
// mine 行是实例:Web 用 row.id;兼容 instanceId
|
||||
const instanceId = String(row.instanceId || row.id || '').trim()
|
||||
if (!instanceId) {
|
||||
uni.showToast({ title: '缺少实例 ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const autoPrint = !!(opts && opts.autoPrint)
|
||||
stashOaDetailTitle(row.appName || row.title)
|
||||
uni.navigateTo({
|
||||
url:
|
||||
'/pages/oa/webview/index?instanceId=' +
|
||||
encodeURIComponent(instanceId) +
|
||||
'&mode=view' +
|
||||
(autoPrint ? '&autoPrint=1' : '')
|
||||
})
|
||||
},
|
||||
openCopyRestart(row) {
|
||||
if (!row) return
|
||||
const appId = String(row.appId || '').trim()
|
||||
const fromInstanceId = String(row.instanceId || row.id || '').trim()
|
||||
if (!appId || !fromInstanceId) {
|
||||
uni.showToast({ title: '无法复制:缺少应用或实例', icon: 'none' })
|
||||
return
|
||||
}
|
||||
stashOaStartTitle(row.appName || row.title)
|
||||
uni.navigateTo({
|
||||
url:
|
||||
'/pages/oa/webview/index?appId=' +
|
||||
encodeURIComponent(appId) +
|
||||
'&fromInstanceId=' +
|
||||
encodeURIComponent(fromInstanceId)
|
||||
})
|
||||
},
|
||||
async loadList(reset) {
|
||||
if (!getToken()) {
|
||||
forceRelogin('请先登录')
|
||||
return
|
||||
}
|
||||
const isReset = !!reset
|
||||
if (isReset) {
|
||||
this.page = 1
|
||||
this.hasMore = true
|
||||
this.loading = true
|
||||
} else {
|
||||
this.loadingMore = true
|
||||
}
|
||||
try {
|
||||
const res = await fetchOaTasks('mention', {
|
||||
keyword: (this.keyword || '').trim() || undefined,
|
||||
page: this.page,
|
||||
pageSize: PAGE_SIZE
|
||||
})
|
||||
const rows = (res && res.list) || []
|
||||
const total = (res && res.total) != null ? Number(res.total) : 0
|
||||
this.total = total
|
||||
if (isReset) this.list = rows
|
||||
else this.list = this.list.concat(rows)
|
||||
const loaded = this.list.length
|
||||
this.hasMore = loaded < total && rows.length > 0
|
||||
if (rows.length > 0) this.page += 1
|
||||
} catch (e) {
|
||||
if (isReset) this.list = []
|
||||
uni.showToast({
|
||||
title: (e && e.message) || '加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.loadingMore = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 48rpx;
|
||||
background: #f5f6f8;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
padding: 16rpx 24rpx;
|
||||
border-bottom: 1rpx solid #f1f5f9;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid #e8eaed;
|
||||
background: #fafafa;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.search-box.is-focus {
|
||||
border-color: #cbd5e1;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 6rpx rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.search-ico {
|
||||
position: relative;
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
border: 3rpx solid #94a3b8;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-ico::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12rpx;
|
||||
height: 3rpx;
|
||||
right: -8rpx;
|
||||
bottom: -5rpx;
|
||||
background: #94a3b8;
|
||||
border-radius: 3rpx;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1f2937;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-ph {
|
||||
color: #b0b7c3;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-clear__x {
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
color: #6b7280;
|
||||
margin-top: -2rpx;
|
||||
}
|
||||
|
||||
.pending-list {
|
||||
padding: 20rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dd-card {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx 28rpx 24rpx 84rpx;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 4rpx 20rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.dd-card:active {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.dd-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.dd-card__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.dd-card__time {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
color: #8f959e;
|
||||
padding-top: 4rpx;
|
||||
}
|
||||
|
||||
.dd-card__meta {
|
||||
margin-top: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.dd-card__meta-line {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.45;
|
||||
color: #8f959e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dd-card__foot {
|
||||
margin-top: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.dd-card__hint-tag {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
font-size: 22rpx;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.dd-card__hint-tag.is-success {
|
||||
background: rgba(10, 159, 109, 0.12);
|
||||
color: #0a9f6d;
|
||||
}
|
||||
|
||||
.dd-card__hint-tag.is-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.dd-card__hint-tag.is-info {
|
||||
background: rgba(100, 106, 115, 0.12);
|
||||
color: #646a73;
|
||||
}
|
||||
|
||||
.dd-card__status {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.dd-card__status.is-success {
|
||||
color: #0a9f6d;
|
||||
}
|
||||
|
||||
.dd-card__status.is-danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.dd-card__status.is-info {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 80rpx 24rpx;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.state-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28rpx;
|
||||
min-height: 50vh;
|
||||
padding: 96rpx 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pulse-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-green, #22c55e);
|
||||
animation: oa-pulse-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pulse-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pulse-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.state-loading__text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #5f7a6d);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@keyframes oa-pulse-bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-12rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -10,6 +10,7 @@ const OA_PNG_ICONS = new Set([
|
||||
'oa-done',
|
||||
'oa-mine',
|
||||
'oa-cc',
|
||||
'oa-mentions',
|
||||
'oa-wechat-notify'
|
||||
])
|
||||
|
||||
@@ -20,6 +21,7 @@ export const MENU_ICON_COLOR_DEFAULTS = {
|
||||
'oa-done': { iconBg: 'transparent', iconFg: '' },
|
||||
'oa-mine': { iconBg: 'transparent', iconFg: '' },
|
||||
'oa-cc': { iconBg: 'transparent', iconFg: '' },
|
||||
'oa-mentions': { iconBg: 'transparent', iconFg: '' },
|
||||
'oa-wechat-notify': { iconBg: 'transparent', iconFg: '' },
|
||||
'orders-o': { iconBg: '#eef2f1', iconFg: '#3d524a' },
|
||||
'apps-o': { iconBg: '#eef2f1', iconFg: '#3d524a' }
|
||||
|
||||
@@ -4,6 +4,11 @@ export function fetchUserList(params) {
|
||||
return get('/web/admin/users', params)
|
||||
}
|
||||
|
||||
/** 轻量用户下拉(label/value),评论 @ 预缓存用 */
|
||||
export function fetchUserOptions(params) {
|
||||
return get('/web/admin/users/options', params)
|
||||
}
|
||||
|
||||
export function fetchDeptTree(params) {
|
||||
return get('/web/admin/depts', params)
|
||||
}
|
||||
@@ -16,6 +21,10 @@ export function fetchReceiveAccountOptions() {
|
||||
return get('/web/admin/oa-approval/receive-accounts/options')
|
||||
}
|
||||
|
||||
export function fetchCreateReceiveAccount(data) {
|
||||
return post('/web/admin/oa-approval/receive-accounts', data)
|
||||
}
|
||||
|
||||
export function fetchRelatedCandidates(params) {
|
||||
return get('/web/admin/oa-approval/related-candidates', params)
|
||||
}
|
||||
@@ -138,6 +147,26 @@ export function fetchWithdrawOaInstance(instanceId) {
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchOaFormAttachments(instanceId) {
|
||||
return get(
|
||||
`/web/admin/oa-approval/instances/${encodeURIComponent(instanceId)}/form-attachments`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCreateOaComment(instanceId, data) {
|
||||
return post(
|
||||
`/web/admin/oa-approval/instances/${encodeURIComponent(instanceId)}/comments`,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchRevokeOaComment(instanceId, commentId) {
|
||||
return post(
|
||||
`/web/admin/oa-approval/instances/${encodeURIComponent(instanceId)}/comments/${encodeURIComponent(commentId)}/revoke`,
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchMarkOaCcTaskRead(taskId) {
|
||||
return post(`/web/admin/oa-approval/tasks/${encodeURIComponent(taskId)}/read`, {})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
<!-- 移动端评论输入:进度时间线下方,附件用底部弹层小缩略图;支持 @人员 / 输入@选人 -->
|
||||
<template>
|
||||
<div v-if="instanceStatus === 'running'" class="oa-cmt">
|
||||
<div class="oa-cmt__editor">
|
||||
<van-field
|
||||
ref="fieldRef"
|
||||
v-model="draft"
|
||||
class="oa-cmt__field"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
autosize
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
placeholder="填写评论,输入 @ 可提到人员"
|
||||
@focus="onDraftFocus"
|
||||
@update:model-value="onDraftInput"
|
||||
@keydown="onDraftKeydown"
|
||||
/>
|
||||
<div v-if="atOpen" class="oa-cmt-at">
|
||||
<p v-if="atLoading" class="oa-cmt-at__empty">加载中…</p>
|
||||
<button
|
||||
v-for="(u, idx) in atOptions"
|
||||
v-else
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="oa-cmt-at__item"
|
||||
:class="{ 'is-active': idx === atActive }"
|
||||
@mousedown.prevent="pickAtUser(u)"
|
||||
@click.prevent="pickAtUser(u)"
|
||||
>
|
||||
{{ u.name }}
|
||||
</button>
|
||||
<p v-if="!atLoading && !atOptions.length" class="oa-cmt-at__empty">未找到人员</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mentionUsers.length" class="oa-cmt__picked">
|
||||
<span v-for="u in mentionUsers" :key="u.id" class="oa-cmt__chip">
|
||||
@{{ u.name }}
|
||||
<button type="button" class="oa-cmt__chip-x" aria-label="移除" @click="removeMention(u.id)">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="picked.length" class="oa-cmt__files">
|
||||
<OaCommentFileCards :files="picked" removable @remove="removePicked" />
|
||||
</div>
|
||||
<div class="oa-cmt__actions">
|
||||
<button type="button" class="oa-cmt__btn oa-cmt__btn--ghost" @click="openMention">@人员</button>
|
||||
<button type="button" class="oa-cmt__btn oa-cmt__btn--ghost" @click="openAttach">引用附件</button>
|
||||
<button type="button" class="oa-cmt__btn oa-cmt__btn--ghost" :disabled="uploading" @click="pickUpload">
|
||||
{{ uploading ? '上传中…' : '上传附件' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="oa-cmt__btn oa-cmt__btn--primary"
|
||||
:disabled="sending"
|
||||
@click="onSend"
|
||||
>
|
||||
{{ sending ? '发送中…' : '发送' }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="oa-cmt__file"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar"
|
||||
@change="onUploadFiles"
|
||||
/>
|
||||
|
||||
<van-popup v-model:show="attachVisible" position="bottom" round teleport="body" :style="{ maxHeight: '72%' }">
|
||||
<div class="oa-cmt-sheet">
|
||||
<div class="oa-cmt-sheet__hd">
|
||||
<button type="button" class="oa-cmt-sheet__link" @click="attachVisible = false">取消</button>
|
||||
<strong>引用表单附件</strong>
|
||||
<button type="button" class="oa-cmt-sheet__link is-ok" @click="confirmAttach">确定</button>
|
||||
</div>
|
||||
<div class="oa-cmt-sheet__body">
|
||||
<p v-if="attachLoading" class="oa-cmt-sheet__empty">加载中…</p>
|
||||
<p v-else-if="!attachGroups.length" class="oa-cmt-sheet__empty">暂无可引用附件</p>
|
||||
<div v-for="g in attachGroups" v-else :key="g.fieldKey" class="oa-cmt-sheet__group">
|
||||
<div class="oa-cmt-sheet__label">{{ g.fieldLabel || g.fieldKey }}</div>
|
||||
<div class="oa-cmt-sheet__grid">
|
||||
<button
|
||||
v-for="f in g.files"
|
||||
:key="f.url"
|
||||
type="button"
|
||||
class="oa-cmt-card"
|
||||
:class="{ 'is-on': isSelected(f.url) }"
|
||||
@click="toggleUrl(f.url)"
|
||||
>
|
||||
<span class="oa-cmt-card__thumb">
|
||||
<img v-if="isImageRef(f)" :src="thumbSrc(f.url)" :alt="f.fileName || ''" />
|
||||
<span v-else class="oa-cmt-card__doc">{{ fileExt(f) }}</span>
|
||||
<span v-if="isSelected(f.url)" class="oa-cmt-card__check">✓</span>
|
||||
</span>
|
||||
<span class="oa-cmt-card__name">{{ f.fileName || '附件' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="mentionVisible" position="bottom" round teleport="body" :style="{ maxHeight: '72%' }">
|
||||
<div class="oa-cmt-sheet">
|
||||
<div class="oa-cmt-sheet__hd">
|
||||
<button type="button" class="oa-cmt-sheet__link" @click="mentionVisible = false">取消</button>
|
||||
<strong>提到人员</strong>
|
||||
<button type="button" class="oa-cmt-sheet__link is-ok" @click="confirmMention">确定</button>
|
||||
</div>
|
||||
<div class="oa-cmt-sheet__body">
|
||||
<input
|
||||
v-model="mentionKeyword"
|
||||
class="oa-cmt-mention__search"
|
||||
type="search"
|
||||
placeholder="搜索姓名"
|
||||
/>
|
||||
<p v-if="mentionLoading" class="oa-cmt-sheet__empty">加载中…</p>
|
||||
<p v-else-if="!filteredMentionUsers.length" class="oa-cmt-sheet__empty">暂无人员</p>
|
||||
<button
|
||||
v-for="u in filteredMentionUsers"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="oa-cmt-mention__row"
|
||||
@click="toggleMention(u.id)"
|
||||
>
|
||||
<span class="oa-cmt-mention__name">{{ u.name }}</span>
|
||||
<span class="oa-cmt-mention__mark">{{ mentionDraftIds.includes(u.id) ? '已选' : '' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import {
|
||||
fetchCreateOaComment,
|
||||
fetchOaFormAttachments,
|
||||
fetchUserOptions,
|
||||
uploadAttachment
|
||||
} from '../../api/oa.js'
|
||||
import OaCommentFileCards from './OaCommentFileCards.vue'
|
||||
import { attachmentFullUrl } from '../../utils/attachment.js'
|
||||
|
||||
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/i
|
||||
|
||||
const props = defineProps({
|
||||
instanceId: { type: String, default: '' },
|
||||
instanceStatus: { type: String, default: '' }
|
||||
})
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const fieldRef = ref(null)
|
||||
const draft = ref('')
|
||||
const picked = ref([])
|
||||
const sending = ref(false)
|
||||
const uploading = ref(false)
|
||||
const fileInputRef = ref(null)
|
||||
const mentionUsers = ref([])
|
||||
const mentionVisible = ref(false)
|
||||
const mentionLoading = ref(false)
|
||||
const mentionKeyword = ref('')
|
||||
const mentionDraftIds = ref([])
|
||||
const allMentionUsers = ref([])
|
||||
let userCachePromise = null
|
||||
|
||||
const atOpen = ref(false)
|
||||
const atStart = ref(-1)
|
||||
const atActive = ref(0)
|
||||
const atOptions = ref([])
|
||||
const atLoading = ref(false)
|
||||
|
||||
const attachVisible = ref(false)
|
||||
const attachLoading = ref(false)
|
||||
const attachGroups = ref([])
|
||||
const selectedUrls = ref([])
|
||||
|
||||
const filteredMentionUsers = computed(() => {
|
||||
const q = mentionKeyword.value.trim().toLowerCase()
|
||||
const pickedIds = new Set(mentionDraftIds.value)
|
||||
// 已选的排前面,便于回看
|
||||
const base = allMentionUsers.value
|
||||
const list = q
|
||||
? base.filter((u) => u.name.toLowerCase().includes(q) || u.id.toLowerCase().includes(q))
|
||||
: base
|
||||
return [...list].sort((a, b) => Number(pickedIds.has(b.id)) - Number(pickedIds.has(a.id)))
|
||||
})
|
||||
|
||||
function getTextareaEl() {
|
||||
const inst = fieldRef.value
|
||||
if (!inst) return null
|
||||
const root = inst.$el || inst
|
||||
return root?.querySelector?.('textarea') || null
|
||||
}
|
||||
|
||||
async function ensureUserCache() {
|
||||
if (allMentionUsers.value.length) return
|
||||
if (userCachePromise) return userCachePromise
|
||||
userCachePromise = (async () => {
|
||||
try {
|
||||
const res = await fetchUserOptions()
|
||||
const list = Array.isArray(res) ? res : res?.list || []
|
||||
const next = []
|
||||
const seen = new Set()
|
||||
for (const u of list) {
|
||||
const id = String(u?.value || u?.id || '').trim()
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const name = String(u?.label || u?.nickName || id).trim() || id
|
||||
next.push({ id, name })
|
||||
}
|
||||
allMentionUsers.value = next
|
||||
} catch {
|
||||
allMentionUsers.value = []
|
||||
} finally {
|
||||
userCachePromise = null
|
||||
}
|
||||
})()
|
||||
return userCachePromise
|
||||
}
|
||||
|
||||
function filterAtUsers(keyword) {
|
||||
const q = (keyword || '').trim().toLowerCase()
|
||||
const pickedIds = new Set(mentionUsers.value.map((u) => u.id))
|
||||
const out = []
|
||||
for (const u of allMentionUsers.value) {
|
||||
if (pickedIds.has(u.id)) continue
|
||||
if (q && !u.name.toLowerCase().includes(q) && !u.id.toLowerCase().includes(q)) continue
|
||||
out.push(u)
|
||||
if (out.length >= 30) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function closeAtSuggest() {
|
||||
atOpen.value = false
|
||||
atStart.value = -1
|
||||
atActive.value = 0
|
||||
atOptions.value = []
|
||||
}
|
||||
|
||||
async function applyAtSuggest(query) {
|
||||
const needFetch = !allMentionUsers.value.length
|
||||
if (needFetch) atLoading.value = true
|
||||
try {
|
||||
await ensureUserCache()
|
||||
if (!atOpen.value) return
|
||||
atOptions.value = filterAtUsers(query)
|
||||
if (atActive.value >= atOptions.value.length) {
|
||||
atActive.value = Math.max(0, atOptions.value.length - 1)
|
||||
}
|
||||
} finally {
|
||||
atLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function detectAtSuggest() {
|
||||
const ta = getTextareaEl()
|
||||
if (!ta) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const pos = ta.selectionStart ?? 0
|
||||
const before = draft.value.slice(0, pos)
|
||||
const matched = before.match(/(?:^|[\s\n])(@([^\s@]*))$/)
|
||||
if (!matched) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const token = matched[1] || ''
|
||||
const query = matched[2] || ''
|
||||
atStart.value = before.length - token.length
|
||||
atOpen.value = true
|
||||
atActive.value = 0
|
||||
if (allMentionUsers.value.length) {
|
||||
atOptions.value = filterAtUsers(query)
|
||||
} else {
|
||||
void applyAtSuggest(query)
|
||||
}
|
||||
}
|
||||
|
||||
function onDraftFocus() {
|
||||
void ensureUserCache()
|
||||
}
|
||||
|
||||
function onDraftInput() {
|
||||
nextTick(() => detectAtSuggest())
|
||||
}
|
||||
|
||||
function onDraftKeydown(e) {
|
||||
if (!atOpen.value) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!atOptions.value.length) return
|
||||
atActive.value = (atActive.value + 1) % atOptions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!atOptions.value.length) return
|
||||
atActive.value = (atActive.value - 1 + atOptions.value.length) % atOptions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
const u = atOptions.value[atActive.value]
|
||||
if (!u) return
|
||||
e.preventDefault()
|
||||
pickAtUser(u)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeAtSuggest()
|
||||
}
|
||||
}
|
||||
|
||||
function addMentionUser(u) {
|
||||
if (mentionUsers.value.some((x) => x.id === u.id)) return true
|
||||
if (mentionUsers.value.length >= 20) {
|
||||
showToast('一次最多@20人')
|
||||
return false
|
||||
}
|
||||
mentionUsers.value.push(u)
|
||||
return true
|
||||
}
|
||||
|
||||
function pickAtUser(u) {
|
||||
if (!addMentionUser(u)) return
|
||||
const start = atStart.value
|
||||
if (start < 0) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const ta = getTextareaEl()
|
||||
const end = ta?.selectionStart ?? draft.value.length
|
||||
const insert = `@${u.name} `
|
||||
draft.value = `${draft.value.slice(0, start)}${insert}${draft.value.slice(end)}`
|
||||
closeAtSuggest()
|
||||
nextTick(() => {
|
||||
const el = getTextareaEl()
|
||||
if (!el) return
|
||||
const pos = start + insert.length
|
||||
el.focus()
|
||||
el.setSelectionRange(pos, pos)
|
||||
})
|
||||
}
|
||||
|
||||
function isImageRef(f) {
|
||||
const name = String(f?.fileName || '')
|
||||
const url = String(f?.url || '')
|
||||
return IMAGE_EXT.test(name) || IMAGE_EXT.test(url)
|
||||
}
|
||||
|
||||
function fileExt(f) {
|
||||
const raw = String(f?.fileName || f?.url || '')
|
||||
const base = raw.split('?')[0]
|
||||
const ext = (base.split('.').pop() || '').slice(0, 4).toUpperCase()
|
||||
return ext || 'FILE'
|
||||
}
|
||||
|
||||
function thumbSrc(url) {
|
||||
return attachmentFullUrl(url) || url
|
||||
}
|
||||
|
||||
function isSelected(url) {
|
||||
return selectedUrls.value.includes(url)
|
||||
}
|
||||
|
||||
function toggleUrl(url) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return
|
||||
const idx = selectedUrls.value.indexOf(u)
|
||||
if (idx >= 0) selectedUrls.value.splice(idx, 1)
|
||||
else selectedUrls.value.push(u)
|
||||
}
|
||||
|
||||
async function openAttach() {
|
||||
attachVisible.value = true
|
||||
attachLoading.value = true
|
||||
selectedUrls.value = picked.value.filter((f) => f.source !== 'upload').map((f) => f.url)
|
||||
try {
|
||||
const res = await fetchOaFormAttachments(props.instanceId)
|
||||
attachGroups.value = res?.items || []
|
||||
if (!attachGroups.value.length) showToast('暂无可引用附件')
|
||||
} catch (e) {
|
||||
attachGroups.value = []
|
||||
showToast(e?.message || '加载附件失败')
|
||||
} finally {
|
||||
attachLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAttach() {
|
||||
const map = new Map()
|
||||
for (const g of attachGroups.value) {
|
||||
for (const f of g.files || []) {
|
||||
if (f?.url) map.set(f.url, f)
|
||||
}
|
||||
}
|
||||
const cited = selectedUrls.value
|
||||
.map((url) => map.get(url) || picked.value.find((p) => p.url === url && p.source !== 'upload'))
|
||||
.filter(Boolean)
|
||||
.map((f) => ({ ...f, source: 'form' }))
|
||||
const uploads = picked.value.filter((f) => f.source === 'upload')
|
||||
picked.value = [...cited, ...uploads]
|
||||
attachVisible.value = false
|
||||
}
|
||||
|
||||
function removePicked(index) {
|
||||
picked.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function pickUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onUploadFiles(ev) {
|
||||
const input = ev.target
|
||||
const files = Array.from(input?.files || [])
|
||||
if (input) input.value = ''
|
||||
if (!files.length) return
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of files) {
|
||||
const res = await uploadAttachment(file)
|
||||
const url = String(res?.fileUrl || res?.data?.fileUrl || '').trim()
|
||||
if (!url) {
|
||||
showToast(`${file.name || '文件'} 上传失败`)
|
||||
continue
|
||||
}
|
||||
if (picked.value.some((p) => p.url === url)) continue
|
||||
picked.value.push({
|
||||
source: 'upload',
|
||||
fieldKey: '',
|
||||
fieldLabel: '',
|
||||
url,
|
||||
fileName: String(res?.fileName || res?.data?.fileName || file.name || '').trim() || file.name
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeMention(id) {
|
||||
mentionUsers.value = mentionUsers.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
function toggleMention(id) {
|
||||
const set = new Set(mentionDraftIds.value)
|
||||
if (set.has(id)) set.delete(id)
|
||||
else {
|
||||
if (set.size >= 20) {
|
||||
showToast('一次最多@20人')
|
||||
return
|
||||
}
|
||||
set.add(id)
|
||||
}
|
||||
mentionDraftIds.value = [...set]
|
||||
}
|
||||
|
||||
async function openMention() {
|
||||
closeAtSuggest()
|
||||
mentionDraftIds.value = mentionUsers.value.map((u) => u.id)
|
||||
mentionKeyword.value = ''
|
||||
mentionVisible.value = true
|
||||
mentionLoading.value = !allMentionUsers.value.length
|
||||
try {
|
||||
await ensureUserCache()
|
||||
} catch (e) {
|
||||
showToast(e?.message || '加载人员失败')
|
||||
} finally {
|
||||
mentionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmMention() {
|
||||
const map = new Map(allMentionUsers.value.map((u) => [u.id, u.name]))
|
||||
for (const u of mentionUsers.value) map.set(u.id, u.name)
|
||||
const next = mentionDraftIds.value.map((id) => ({ id, name: map.get(id) || id })).slice(0, 20)
|
||||
mentionUsers.value = next
|
||||
for (const u of next) {
|
||||
const token = `@${u.name}`
|
||||
if (!draft.value.includes(token)) {
|
||||
draft.value = `${draft.value.trimEnd()}${draft.value.trim() ? ' ' : ''}${token} `
|
||||
}
|
||||
}
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
async function onSend() {
|
||||
const content = draft.value.trim()
|
||||
if (!content && !mentionUsers.value.length && !picked.value.length) {
|
||||
showToast('请填写评论、@人员或添加附件')
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
await fetchCreateOaComment(props.instanceId, {
|
||||
content,
|
||||
attachmentRefs: picked.value.length ? picked.value.map((f) => ({ ...f })) : undefined,
|
||||
mentionUserIds: mentionUsers.value.length ? mentionUsers.value.map((u) => u.id) : undefined
|
||||
})
|
||||
draft.value = ''
|
||||
picked.value = []
|
||||
mentionUsers.value = []
|
||||
closeAtSuggest()
|
||||
showToast('已发送')
|
||||
emit('refresh')
|
||||
} catch (e) {
|
||||
showToast(e?.message || '发送失败')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void ensureUserCache()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oa-cmt {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border, #d8f3e5);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.oa-cmt__editor {
|
||||
position: relative;
|
||||
}
|
||||
.oa-cmt__field {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.oa-cmt-at {
|
||||
position: absolute;
|
||||
top: calc(100% - 22px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
width: min(280px, 100%);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
.oa-cmt-at__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary, #0f291e);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.oa-cmt-at__item.is-active,
|
||||
.oa-cmt-at__item:active {
|
||||
background: var(--brand-green-soft, #ecfdf5);
|
||||
}
|
||||
.oa-cmt-at__empty {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.oa-cmt__picked {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.oa-cmt__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 4px 8px;
|
||||
color: var(--text-primary, #0f291e);
|
||||
font-size: 12px;
|
||||
background: var(--brand-green-soft, #ecfdf5);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.oa-cmt__chip-x {
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
padding: 0;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.oa-cmt__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.oa-cmt__btn {
|
||||
flex: 1 1 30%;
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border, #d8f3e5);
|
||||
}
|
||||
.oa-cmt__file {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.oa-cmt__btn--ghost {
|
||||
color: var(--text-primary, #0f291e);
|
||||
background: #fff;
|
||||
}
|
||||
.oa-cmt__btn--primary {
|
||||
color: #fff;
|
||||
background: var(--brand-green, #22c55e);
|
||||
border-color: var(--brand-green, #22c55e);
|
||||
}
|
||||
.oa-cmt__btn:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.oa-cmt-sheet__hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.oa-cmt-sheet__link {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.oa-cmt-sheet__link.is-ok {
|
||||
color: var(--brand-green-dark, #16a34a);
|
||||
font-weight: 600;
|
||||
}
|
||||
.oa-cmt-sheet__body {
|
||||
max-height: 56vh;
|
||||
padding: 12px 16px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
.oa-cmt-sheet__empty {
|
||||
margin: 24px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.oa-cmt-sheet__group + .oa-cmt-sheet__group {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.oa-cmt-sheet__label {
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary, #0f291e);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.oa-cmt-sheet__grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.oa-cmt-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 72px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.oa-cmt-card.is-on {
|
||||
border-color: var(--brand-green, #22c55e);
|
||||
box-shadow: 0 0 0 1px var(--brand-green, #22c55e);
|
||||
}
|
||||
.oa-cmt-card__thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 56px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.oa-cmt-card__thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.oa-cmt-card__doc {
|
||||
color: #0284c7;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.oa-cmt-card__check {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
background: var(--brand-green, #22c55e);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.oa-cmt-card__name {
|
||||
display: -webkit-box;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.oa-cmt-mention__search {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
border: 1px solid var(--border, #d8f3e5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.oa-cmt-mention__row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 8px 4px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.oa-cmt-mention__name {
|
||||
color: var(--text-primary, #0f291e);
|
||||
font-size: 15px;
|
||||
}
|
||||
.oa-cmt-mention__mark {
|
||||
margin-left: auto;
|
||||
color: var(--brand-green-dark, #16a34a);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<!-- 评论附件缩略图:文字区分「引用 / 上传」;预览/下载/移除图标放在缩略图下方 -->
|
||||
<template>
|
||||
<div class="oa-cmt-files">
|
||||
<div v-for="(f, idx) in files" :key="`${f.url}-${idx}`" class="oa-cmt-files__item">
|
||||
<div class="oa-cmt-files__thumb">
|
||||
<img v-if="isImage(f)" :src="fullUrl(f.url)" :alt="f.fileName || '附件'" />
|
||||
<span v-else class="oa-cmt-files__ext">{{ fileExt(f) }}</span>
|
||||
<span class="oa-cmt-files__badge">{{ sourceLabel(f) }}</span>
|
||||
</div>
|
||||
<div class="oa-cmt-files__actions">
|
||||
<button type="button" aria-label="预览" @click.stop="preview(f)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" aria-label="下载" @click.stop="download(f)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 3v12" />
|
||||
<path d="M7 10l5 5 5-5" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
aria-label="移除"
|
||||
@click.stop="emit('remove', idx)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oa-cmt-files__name">{{ f.fileName || '附件' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { showImagePreview, showToast } from 'vant'
|
||||
import { attachmentFullUrl } from '../../utils/attachment.js'
|
||||
import { downloadAttachmentFile } from '../../utils/download-attachment.js'
|
||||
|
||||
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/i
|
||||
|
||||
defineProps({
|
||||
files: { type: Array, default: () => [] },
|
||||
removable: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
function isImage(f) {
|
||||
const name = String(f?.fileName || '')
|
||||
const url = String(f?.url || '')
|
||||
return IMAGE_EXT.test(name) || IMAGE_EXT.test(url)
|
||||
}
|
||||
|
||||
function fileExt(f) {
|
||||
const raw = String(f?.fileName || f?.url || '')
|
||||
const base = raw.split('?')[0]
|
||||
const ext = (base.split('.').pop() || '').slice(0, 4).toUpperCase()
|
||||
return ext || 'FILE'
|
||||
}
|
||||
|
||||
function fullUrl(url) {
|
||||
return attachmentFullUrl(url) || url
|
||||
}
|
||||
|
||||
function sourceLabel(f) {
|
||||
return f?.source === 'upload' ? '上传' : '引用'
|
||||
}
|
||||
|
||||
function preview(f) {
|
||||
const url = fullUrl(f?.url)
|
||||
if (!url) return
|
||||
if (isImage(f)) {
|
||||
showImagePreview([url])
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
async function download(f) {
|
||||
try {
|
||||
await downloadAttachmentFile({
|
||||
fileUrl: f?.url,
|
||||
fileName: f?.fileName
|
||||
})
|
||||
} catch (e) {
|
||||
showToast(e?.message || '下载失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oa-cmt-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.oa-cmt-files__item {
|
||||
width: 88px;
|
||||
}
|
||||
.oa-cmt-files__thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 88px;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.oa-cmt-files__thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.oa-cmt-files__ext {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.oa-cmt-files__badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
z-index: 1;
|
||||
padding: 0 4px;
|
||||
color: #334155;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.oa-cmt-files__actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 32px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.oa-cmt-files__actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
color: var(--brand-green-dark, #16a34a);
|
||||
background: var(--brand-green-soft, #ecfdf5);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.oa-cmt-files__actions button:active {
|
||||
color: #fff;
|
||||
background: var(--brand-green, #22c55e);
|
||||
}
|
||||
.oa-cmt-files__actions svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.oa-cmt-files__name {
|
||||
margin-top: 4px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -37,7 +37,16 @@
|
||||
:disabled="true"
|
||||
/>
|
||||
<p v-else class="oa-detail-sheet__empty">暂无表单内容</p>
|
||||
<OaProgressTimeline :items="timelineItems" />
|
||||
<OaProgressTimeline
|
||||
:items="timelineItems"
|
||||
:instance-id="String(instanceId || '')"
|
||||
@refresh="loadDetail(instanceId)"
|
||||
/>
|
||||
<OaCommentComposer
|
||||
:instance-id="String(instanceId || '')"
|
||||
:instance-status="detail?.status || ''"
|
||||
@refresh="loadDetail(instanceId)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,7 +59,9 @@ import { showToast } from 'vant'
|
||||
import { fetchOaInstanceDetail } from '../../api/oa.js'
|
||||
import { prepareMobileFormSchema } from '../schema-prep.js'
|
||||
import { statusText, statusTone } from '../status-label.js'
|
||||
import { buildTimelineItems } from '../oa-progress-merge-comments.js'
|
||||
import OaProgressTimeline from './OaProgressTimeline.vue'
|
||||
import OaCommentComposer from './OaCommentComposer.vue'
|
||||
import OaPulseLoading from './OaPulseLoading.vue'
|
||||
|
||||
defineOptions({ name: 'OaInstanceDetailSheet' })
|
||||
@@ -73,41 +84,7 @@ const formOption = ref({
|
||||
|
||||
const sheetTitle = computed(() => detail.value?.title || '审批详情')
|
||||
|
||||
const timelineItems = computed(() => {
|
||||
const list = detail.value?.progress
|
||||
if (!Array.isArray(list) || !list.length) return []
|
||||
return list
|
||||
.filter((p) => {
|
||||
const kind = String(p?.kind || '')
|
||||
const status = String(p?.status || '')
|
||||
const title = String(p?.title || '').trim()
|
||||
if (kind === 'terminal' && (status === 'returned' || title === '已打回')) return false
|
||||
return true
|
||||
})
|
||||
.map((p, idx) => ({
|
||||
key: `${p.kind}-${p.nodeKey || p.taskId || idx}-${p.roundNo || 1}`,
|
||||
title: p.title || '-',
|
||||
status: p.status || '',
|
||||
timestamp: p.timestamp || '',
|
||||
assignee: p.assigneeUserName || p.assigneeUserId || '',
|
||||
commentText: p.commentText || '',
|
||||
taskType: p.taskType || '',
|
||||
kind: p.kind || '',
|
||||
signMode: p.signMode || '',
|
||||
people: Array.isArray(p.people)
|
||||
? p.people.map((x) => ({
|
||||
id: x.id || '',
|
||||
nickName: x.nickName || x.id || '',
|
||||
status: x.status || '',
|
||||
timestamp: x.timestamp || '',
|
||||
commentText: x.commentText || '',
|
||||
taskId: x.taskId || '',
|
||||
transferToUserId: x.transferToUserId || '',
|
||||
transferToUserName: x.transferToUserName || ''
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
})
|
||||
const timelineItems = computed(() => buildTimelineItems(detail.value))
|
||||
|
||||
function parseFormData(d) {
|
||||
if (d?.formData && typeof d.formData === 'object') return { ...d.formData }
|
||||
|
||||
@@ -8,7 +8,36 @@
|
||||
<div class="oa-progress-dot" :class="dotClass(item)" />
|
||||
<div v-if="idx < items.length - 1" class="oa-progress-line" />
|
||||
</div>
|
||||
<div class="oa-progress-card">
|
||||
<div class="oa-progress-card" :class="{ 'is-comment': item.kind === 'comment' }">
|
||||
<template v-if="item.kind === 'comment'">
|
||||
<div v-if="item.status === 'revoked'" class="oa-progress-comment-revoked">
|
||||
{{ item.title }} 撤回了一条评论
|
||||
<span v-if="item.revokedTime" class="oa-progress-comment-revoked__time">{{ item.revokedTime }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="oa-progress-card__row">
|
||||
<span class="oa-progress-card__name">{{ item.title || '-' }}</span>
|
||||
<span class="oa-progress-badge cc">评论</span>
|
||||
<span v-if="item.timestamp" class="oa-progress-comment__time">{{ item.timestamp }}</span>
|
||||
<button
|
||||
v-if="item.canRevoke && item.commentId"
|
||||
type="button"
|
||||
class="oa-progress-comment__revoke"
|
||||
:disabled="revokingId === item.commentId"
|
||||
@click="onRevoke(item)"
|
||||
>
|
||||
撤回
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="item.commentText" class="oa-progress-comment__body">{{ item.commentText }}</div>
|
||||
<div v-if="mentionText(item)" class="oa-progress-comment__mentions">提到:{{ mentionText(item) }}</div>
|
||||
<OaCommentFileCards
|
||||
v-if="item.attachmentRefs && item.attachmentRefs.length"
|
||||
:files="item.attachmentRefs"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="oa-progress-card__row">
|
||||
<span class="oa-progress-card__name">{{ item.title || '-' }}</span>
|
||||
<span
|
||||
@@ -109,6 +138,7 @@
|
||||
<div v-if="showNodeComment(item)" class="oa-progress-card__comment">
|
||||
意见:{{ item.commentText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,15 +146,50 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showConfirmDialog, showToast } from 'vant'
|
||||
import { statusText, statusTone } from '../status-label.js'
|
||||
import {
|
||||
formatCcPersonLines,
|
||||
formatProgressPersonLines
|
||||
} from '../oa-progress-person-lines.js'
|
||||
import { fetchRevokeOaComment } from '../../api/oa.js'
|
||||
import OaCommentFileCards from './OaCommentFileCards.vue'
|
||||
|
||||
defineProps({
|
||||
items: { type: Array, default: () => [] }
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
instanceId: { type: String, default: '' }
|
||||
})
|
||||
const emit = defineEmits(['refresh'])
|
||||
const revokingId = ref('')
|
||||
|
||||
function mentionText(item) {
|
||||
const names = (item?.mentions || [])
|
||||
.map((m) => String(m?.userName || m?.userId || '').trim())
|
||||
.filter(Boolean)
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
async function onRevoke(item) {
|
||||
const id = String(item?.commentId || '').trim()
|
||||
const instanceId = String(props.instanceId || '').trim()
|
||||
if (!id || !instanceId) return
|
||||
try {
|
||||
await showConfirmDialog({ title: '提示', message: '确定撤回该评论吗?' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
revokingId.value = id
|
||||
try {
|
||||
await fetchRevokeOaComment(instanceId, id)
|
||||
showToast('已撤回')
|
||||
emit('refresh')
|
||||
} catch (e) {
|
||||
showToast(e?.message || '撤回失败')
|
||||
} finally {
|
||||
revokingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function displayPeople(item) {
|
||||
const list = Array.isArray(item?.people)
|
||||
@@ -138,7 +203,7 @@ function displayPeople(item) {
|
||||
}
|
||||
|
||||
function personLines(item) {
|
||||
if (item?.kind === 'terminal' || item?.kind === 'submit') return []
|
||||
if (item?.kind === 'terminal' || item?.kind === 'submit' || item?.kind === 'comment') return []
|
||||
if (item?.taskType === 'cc' || item?.signMode === 'cc') return []
|
||||
const list = displayPeople(item)
|
||||
if (!list.length) return []
|
||||
@@ -150,6 +215,7 @@ function ccPersonLines(item) {
|
||||
}
|
||||
|
||||
function showNodeComment(item) {
|
||||
if (item?.kind === 'comment') return false
|
||||
// 有按人行时不再展示节点级拼接意见,避免双份
|
||||
if (personLines(item).length) return false
|
||||
// 撤销终态已在人员区展示「发起人撤销」,勿再套「意见:」
|
||||
@@ -186,6 +252,7 @@ function nodeStatusTone(item) {
|
||||
}
|
||||
|
||||
function dotClass(item) {
|
||||
if (item?.kind === 'comment') return item?.status === 'revoked' ? 'mute' : 'ok'
|
||||
return nodeStatusTone(item)
|
||||
}
|
||||
</script>
|
||||
@@ -257,6 +324,46 @@ function dotClass(item) {
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.oa-progress-card.is-comment {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.oa-progress-comment-revoked {
|
||||
color: var(--text-muted, #5f7a6d);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.oa-progress-comment-revoked__time {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.oa-progress-comment__time {
|
||||
color: var(--text-muted, #5f7a6d);
|
||||
font-size: 12px;
|
||||
}
|
||||
.oa-progress-comment__revoke {
|
||||
margin-left: auto;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
color: #d14343;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.oa-progress-comment__body {
|
||||
margin-top: 6px;
|
||||
color: var(--text-primary, #0f291e);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.oa-progress-comment__mentions {
|
||||
margin-top: 6px;
|
||||
color: var(--brand-green-dark, #16a34a);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.oa-progress-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -56,15 +56,129 @@
|
||||
</van-checkbox-group>
|
||||
<van-empty v-if="!loading && !filtered.length" description="暂无收款账户" />
|
||||
</div>
|
||||
<button type="button" class="oa-recv__add" @click="openCreate">
|
||||
<van-icon name="plus" />
|
||||
新增收款账户
|
||||
</button>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<van-popup
|
||||
v-model:show="createVisible"
|
||||
position="bottom"
|
||||
round
|
||||
:style="{ height: '88%' }"
|
||||
>
|
||||
<div class="oa-recv__sheet">
|
||||
<div class="oa-recv__hd">
|
||||
<span @click="createVisible = false">取消</span>
|
||||
<strong>新增收款账户</strong>
|
||||
<span
|
||||
class="oa-recv__ok"
|
||||
:class="{ 'is-busy': createLoading }"
|
||||
@click="submitCreate"
|
||||
>
|
||||
{{ createLoading ? '提交中' : '确定' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="oa-recv__form">
|
||||
<van-field
|
||||
:model-value="accountTypeLabel"
|
||||
label="账户类型"
|
||||
label-align="top"
|
||||
input-align="left"
|
||||
readonly
|
||||
is-link
|
||||
required
|
||||
placeholder="请选择"
|
||||
@click="typePickerVisible = true"
|
||||
/>
|
||||
<van-field
|
||||
v-model="createForm.accountName"
|
||||
label="户名"
|
||||
label-align="top"
|
||||
input-align="left"
|
||||
required
|
||||
maxlength="128"
|
||||
placeholder="请输入户名"
|
||||
/>
|
||||
<van-field
|
||||
v-model="createForm.accountNo"
|
||||
label="账号"
|
||||
label-align="top"
|
||||
input-align="left"
|
||||
required
|
||||
maxlength="128"
|
||||
placeholder="请输入账号"
|
||||
/>
|
||||
<template v-if="createForm.accountType !== 'alipay'">
|
||||
<van-field
|
||||
v-model="createForm.bankName"
|
||||
label="银行"
|
||||
label-align="top"
|
||||
input-align="left"
|
||||
required
|
||||
maxlength="128"
|
||||
placeholder="如:中国工商银行"
|
||||
/>
|
||||
<van-field
|
||||
v-model="createForm.bankProvince"
|
||||
label="银行所在省"
|
||||
label-align="top"
|
||||
input-align="left"
|
||||
required
|
||||
maxlength="64"
|
||||
placeholder="如:安徽省"
|
||||
/>
|
||||
<van-field
|
||||
v-model="createForm.bankCity"
|
||||
label="银行所在市"
|
||||
label-align="top"
|
||||
input-align="left"
|
||||
required
|
||||
maxlength="64"
|
||||
placeholder="如:合肥市"
|
||||
/>
|
||||
<van-field
|
||||
v-model="createForm.bankBranch"
|
||||
label="银行支行"
|
||||
label-align="top"
|
||||
input-align="left"
|
||||
required
|
||||
maxlength="256"
|
||||
placeholder="请输入开户支行全称"
|
||||
/>
|
||||
</template>
|
||||
<van-field
|
||||
v-model="createForm.remark"
|
||||
label="备注"
|
||||
label-align="top"
|
||||
input-align="left"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
autosize
|
||||
maxlength="512"
|
||||
placeholder="选填"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="typePickerVisible" position="bottom" round>
|
||||
<van-picker
|
||||
:columns="accountTypeColumns"
|
||||
:model-value="[createForm.accountType]"
|
||||
@confirm="onTypeConfirm"
|
||||
@cancel="typePickerVisible = false"
|
||||
/>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { fetchReceiveAccountOptions } from '../../api/oa.js'
|
||||
import { fetchCreateReceiveAccount, fetchReceiveAccountOptions } from '../../api/oa.js'
|
||||
import {
|
||||
ACCOUNT_TYPE_LABEL,
|
||||
formatReceiveAccountLine,
|
||||
@@ -88,6 +202,32 @@ const keyword = ref('')
|
||||
const pendingIds = ref([])
|
||||
const snapshotMap = ref(new Map())
|
||||
const accountOptions = ref([])
|
||||
const createVisible = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const typePickerVisible = ref(false)
|
||||
const accountTypeColumns = [
|
||||
{ text: '支付宝', value: 'alipay' },
|
||||
{ text: '个人银行卡', value: 'personal' },
|
||||
{ text: '对公银行账户', value: 'corporate' }
|
||||
]
|
||||
const createForm = ref(emptyCreateForm())
|
||||
|
||||
function emptyCreateForm() {
|
||||
return {
|
||||
accountType: 'alipay',
|
||||
accountName: '',
|
||||
accountNo: '',
|
||||
bankName: '',
|
||||
bankProvince: '',
|
||||
bankCity: '',
|
||||
bankBranch: '',
|
||||
remark: ''
|
||||
}
|
||||
}
|
||||
|
||||
const accountTypeLabel = computed(
|
||||
() => ACCOUNT_TYPE_LABEL[createForm.value.accountType] || ''
|
||||
)
|
||||
|
||||
const normalized = computed(() => normalizeReceiveAccountModelValue(props.modelValue))
|
||||
/** 触发条短文案;完整信息见下方 oa-recv__line(可换行) */
|
||||
@@ -135,6 +275,112 @@ function confirm() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function optionFromSnap(snap) {
|
||||
const typeLabel = ACCOUNT_TYPE_LABEL[snap.accountType] || snap.accountType
|
||||
const label = [typeLabel, snap.accountName].filter(Boolean).join(' · ')
|
||||
const subParts = [snap.accountNo]
|
||||
if (
|
||||
(snap.accountType === 'personal' || snap.accountType === 'corporate') &&
|
||||
snap.bankName
|
||||
) {
|
||||
subParts.push(snap.bankName)
|
||||
}
|
||||
return {
|
||||
id: snap.id,
|
||||
label: label || optionLabelFromRow(snap),
|
||||
sub: subParts.filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = emptyCreateForm()
|
||||
typePickerVisible.value = false
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
function onTypeConfirm({ selectedOptions }) {
|
||||
const next = selectedOptions?.[0]?.value || 'alipay'
|
||||
const form = { ...createForm.value, accountType: next }
|
||||
if (next === 'alipay') {
|
||||
form.bankName = ''
|
||||
form.bankProvince = ''
|
||||
form.bankCity = ''
|
||||
form.bankBranch = ''
|
||||
}
|
||||
createForm.value = form
|
||||
typePickerVisible.value = false
|
||||
}
|
||||
|
||||
function validateCreate(form) {
|
||||
if (!form.accountName.trim()) return '请输入户名'
|
||||
if (!form.accountNo.trim()) return '请输入账号'
|
||||
if (form.accountType !== 'alipay') {
|
||||
if (!form.bankName.trim()) return '请填写银行'
|
||||
if (!form.bankProvince.trim()) return '请填写银行所在省'
|
||||
if (!form.bankCity.trim()) return '请填写银行所在市'
|
||||
if (!form.bankBranch.trim()) return '请填写银行支行'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (createLoading.value) return
|
||||
const form = createForm.value
|
||||
const invalid = validateCreate(form)
|
||||
if (invalid) {
|
||||
showToast(invalid)
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
accountType: form.accountType,
|
||||
accountName: form.accountName.trim(),
|
||||
accountNo: form.accountNo.trim(),
|
||||
remark: form.remark.trim() || undefined
|
||||
}
|
||||
if (form.accountType === 'alipay') {
|
||||
payload.bankName = ''
|
||||
payload.bankProvince = ''
|
||||
payload.bankCity = ''
|
||||
payload.bankBranch = ''
|
||||
} else {
|
||||
payload.bankName = form.bankName.trim()
|
||||
payload.bankProvince = form.bankProvince.trim()
|
||||
payload.bankCity = form.bankCity.trim()
|
||||
payload.bankBranch = form.bankBranch.trim()
|
||||
}
|
||||
createLoading.value = true
|
||||
try {
|
||||
const created = await fetchCreateReceiveAccount(payload)
|
||||
const snap = snapshotFromRow(created)
|
||||
if (!snap.id) {
|
||||
showToast('创建成功但未返回账户 ID')
|
||||
return
|
||||
}
|
||||
await loadOptions()
|
||||
if (!snapshotMap.value.has(snap.id)) {
|
||||
const map = new Map(snapshotMap.value)
|
||||
map.set(snap.id, snap)
|
||||
snapshotMap.value = map
|
||||
if (!accountOptions.value.some((x) => x.id === snap.id)) {
|
||||
accountOptions.value = [...accountOptions.value, optionFromSnap(snap)]
|
||||
}
|
||||
}
|
||||
const selected = new Set(normalized.value.map((x) => x.id))
|
||||
selected.add(snap.id)
|
||||
emitItems([...selected].map((id) => snapshotMap.value.get(id)).filter(Boolean))
|
||||
if (!pendingIds.value.includes(snap.id)) {
|
||||
pendingIds.value = [...pendingIds.value, snap.id]
|
||||
}
|
||||
keyword.value = ''
|
||||
createVisible.value = false
|
||||
showToast('创建成功')
|
||||
} catch (e) {
|
||||
showToast(e?.message || '新增收款账户失败')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -146,21 +392,7 @@ async function loadOptions() {
|
||||
const snap = snapshotFromRow(row)
|
||||
if (!snap.id) continue
|
||||
map.set(snap.id, snap)
|
||||
const typeLabel = ACCOUNT_TYPE_LABEL[snap.accountType] || snap.accountType
|
||||
const label = [typeLabel, snap.accountName].filter(Boolean).join(' · ')
|
||||
const subParts = [snap.accountNo]
|
||||
if (
|
||||
(snap.accountType === 'personal' || snap.accountType === 'corporate') &&
|
||||
snap.bankName
|
||||
) {
|
||||
subParts.push(snap.bankName)
|
||||
}
|
||||
const sub = subParts.filter(Boolean).join(' · ')
|
||||
opts.push({
|
||||
id: snap.id,
|
||||
label: label || optionLabelFromRow(snap),
|
||||
sub
|
||||
})
|
||||
opts.push(optionFromSnap(snap))
|
||||
}
|
||||
for (const x of normalized.value) {
|
||||
if (!map.has(x.id)) map.set(x.id, x)
|
||||
@@ -210,4 +442,33 @@ onMounted(() => {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oa-recv__add {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border, #d8f3e5);
|
||||
background: #fff;
|
||||
color: var(--brand-green-dark, #16a34a);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.oa-recv__form {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.oa-recv__ok.is-busy {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<!-- 覆盖 @form-create/vant 内置 select:支持 props.multiple 多选(Web el-select 对齐) -->
|
||||
<template>
|
||||
<div class="_fc-select oa-select">
|
||||
<div v-if="isReadonly" class="oa-select__readonly">{{ displayText || '—' }}</div>
|
||||
<van-field
|
||||
v-else
|
||||
ref="fieldRef"
|
||||
:model-value="displayText"
|
||||
:placeholder="placeholderText"
|
||||
readonly
|
||||
is-link
|
||||
input-align="left"
|
||||
:border="false"
|
||||
@click="open"
|
||||
/>
|
||||
|
||||
<!-- 单选:滚轮 -->
|
||||
<van-popup v-if="!isMultiple" v-model:show="show" round position="bottom">
|
||||
<van-picker
|
||||
:model-value="pickerValue"
|
||||
:columns="pickerColumns"
|
||||
:columns-field-names="columnsFieldNames"
|
||||
@cancel="show = false"
|
||||
@confirm="onSingleConfirm"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 多选:勾选列表 -->
|
||||
<van-popup
|
||||
v-else
|
||||
v-model:show="show"
|
||||
position="bottom"
|
||||
round
|
||||
teleport="body"
|
||||
:style="{ height: '70%' }"
|
||||
>
|
||||
<div class="oa-select__sheet">
|
||||
<div class="oa-select__hd">
|
||||
<button type="button" class="oa-select__link" @click="show = false">取消</button>
|
||||
<strong>{{ titleText }}</strong>
|
||||
<button type="button" class="oa-select__link is-ok" @click="confirmMultiple">确定</button>
|
||||
</div>
|
||||
<van-search
|
||||
v-model="keyword"
|
||||
shape="round"
|
||||
placeholder="搜索选项"
|
||||
input-align="left"
|
||||
/>
|
||||
<div class="oa-select__list">
|
||||
<van-checkbox-group v-model="pendingValues">
|
||||
<div
|
||||
v-for="opt in filteredOptions"
|
||||
:key="String(opt.value)"
|
||||
class="oa-select__row"
|
||||
:class="{ 'is-selected': isPending(opt.value) }"
|
||||
@click="toggle(opt.value)"
|
||||
>
|
||||
<van-checkbox class="oa-select__check" :name="opt.value" @click.stop />
|
||||
<span class="oa-select__label">{{ opt.text }}</span>
|
||||
</div>
|
||||
</van-checkbox-group>
|
||||
<p v-if="!filteredOptions.length" class="oa-select__empty">暂无选项</p>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ name: 'fcSelect' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Boolean, Array], default: undefined },
|
||||
disabled: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: '' },
|
||||
multiple: { type: [Boolean, String, Number], default: false },
|
||||
options: { type: Array, default: () => [] },
|
||||
columnsFieldNames: { type: Object, default: undefined },
|
||||
formCreateInject: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'fc.el'])
|
||||
const attrs = useAttrs()
|
||||
|
||||
const fieldRef = ref(null)
|
||||
const show = ref(false)
|
||||
const keyword = ref('')
|
||||
const pendingValues = ref([])
|
||||
|
||||
const isMultiple = computed(() => {
|
||||
const candidates = [props.multiple, attrs.multiple, props.formCreateInject?.rule?.props?.multiple]
|
||||
return candidates.some((m) => m === true || m === 'true' || m === 1 || m === '1')
|
||||
})
|
||||
|
||||
const isReadonly = computed(() => {
|
||||
if (props.disabled) return true
|
||||
const a = attrs.disabled
|
||||
if (a === true || a === '' || a === 'true' || a === 1 || a === '1') return true
|
||||
const inj = props.formCreateInject
|
||||
if (inj?.api?.options?.form?.disabled) return true
|
||||
if (inj?.rule?.props?.disabled) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const textKey = computed(() => props.columnsFieldNames?.text || 'text')
|
||||
const valueKey = computed(() => props.columnsFieldNames?.value || 'value')
|
||||
|
||||
const normalizedOptions = computed(() => {
|
||||
const raw =
|
||||
(Array.isArray(props.options) && props.options.length
|
||||
? props.options
|
||||
: props.formCreateInject?.rule?.options) || []
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((item) => {
|
||||
if (item == null || typeof item !== 'object') {
|
||||
return { text: String(item), value: item }
|
||||
}
|
||||
const text =
|
||||
item[textKey.value] ??
|
||||
item.label ??
|
||||
item.text ??
|
||||
item.name ??
|
||||
String(item[valueKey.value] ?? '')
|
||||
const value = item[valueKey.value] ?? item.value
|
||||
return { ...item, text: String(text), value }
|
||||
})
|
||||
})
|
||||
|
||||
const pickerColumns = computed(() => normalizedOptions.value)
|
||||
const columnsFieldNames = computed(() => ({
|
||||
text: 'text',
|
||||
value: 'value',
|
||||
...(props.columnsFieldNames || {})
|
||||
}))
|
||||
|
||||
const titleText = computed(() => {
|
||||
const t = props.formCreateInject?.rule?.title
|
||||
return t ? String(t) : '请选择'
|
||||
})
|
||||
|
||||
const placeholderText = computed(() => props.placeholder || '请选择')
|
||||
|
||||
function asArray(val) {
|
||||
if (val == null || val === '') return []
|
||||
if (Array.isArray(val)) return val.filter((x) => x != null && x !== '')
|
||||
return [val]
|
||||
}
|
||||
|
||||
function labelOf(val) {
|
||||
const hit = normalizedOptions.value.find((o) => o.value === val || String(o.value) === String(val))
|
||||
return hit ? hit.text : String(val ?? '')
|
||||
}
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (isMultiple.value) {
|
||||
const ids = asArray(props.modelValue)
|
||||
if (!ids.length) return ''
|
||||
return ids.map(labelOf).filter(Boolean).join('、')
|
||||
}
|
||||
if (props.modelValue == null || props.modelValue === '') return ''
|
||||
return labelOf(props.modelValue)
|
||||
})
|
||||
|
||||
const pickerValue = computed(() => {
|
||||
if (props.modelValue == null || props.modelValue === '') return []
|
||||
return [props.modelValue]
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase()
|
||||
if (!q) return normalizedOptions.value
|
||||
return normalizedOptions.value.filter(
|
||||
(o) =>
|
||||
String(o.text || '')
|
||||
.toLowerCase()
|
||||
.includes(q) ||
|
||||
String(o.value ?? '')
|
||||
.toLowerCase()
|
||||
.includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
function emitValue(val) {
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val)
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (isReadonly.value) return
|
||||
if (isMultiple.value) {
|
||||
keyword.value = ''
|
||||
pendingValues.value = asArray(props.modelValue).map((v) => {
|
||||
const hit = normalizedOptions.value.find(
|
||||
(o) => o.value === v || String(o.value) === String(v)
|
||||
)
|
||||
return hit ? hit.value : v
|
||||
})
|
||||
}
|
||||
show.value = true
|
||||
}
|
||||
|
||||
function onSingleConfirm({ selectedValues }) {
|
||||
const v = selectedValues?.[0]
|
||||
emitValue(v == null ? '' : v)
|
||||
show.value = false
|
||||
}
|
||||
|
||||
function isPending(val) {
|
||||
return pendingValues.value.some((x) => x === val || String(x) === String(val))
|
||||
}
|
||||
|
||||
function toggle(val) {
|
||||
if (isPending(val)) {
|
||||
pendingValues.value = pendingValues.value.filter(
|
||||
(x) => !(x === val || String(x) === String(val))
|
||||
)
|
||||
} else {
|
||||
pendingValues.value = [...pendingValues.value, val]
|
||||
}
|
||||
}
|
||||
|
||||
function confirmMultiple() {
|
||||
emitValue([...pendingValues.value])
|
||||
show.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
emit('fc.el', fieldRef.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oa-select__readonly {
|
||||
min-height: 24px;
|
||||
color: var(--text-primary, #0f291e);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.oa-select__sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.oa-select__hd {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.oa-select__link {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.oa-select__link.is-ok {
|
||||
color: var(--brand-green-dark, #16a34a);
|
||||
font-weight: 600;
|
||||
}
|
||||
.oa-select__list {
|
||||
flex: 1;
|
||||
padding: 0 12px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
.oa-select__row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.oa-select__row.is-selected {
|
||||
background: var(--brand-green-soft, #ecfdf5);
|
||||
}
|
||||
.oa-select__check {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.oa-select__label {
|
||||
color: var(--text-primary, #0f291e);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.oa-select__empty {
|
||||
margin: 24px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -20,8 +20,11 @@
|
||||
<div class="oa-upload__add-txt">添加</div>
|
||||
</div>
|
||||
<template #preview-cover="slotProps">
|
||||
<div v-if="isImageFileItem(slotProps)" class="oa-upload__img-name" :title="itemName(slotProps)">
|
||||
{{ shortDisplayName(slotProps) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="!isImageFileItem(slotProps)"
|
||||
v-else
|
||||
class="oa-upload__file-cover"
|
||||
>
|
||||
<div
|
||||
@@ -40,7 +43,9 @@
|
||||
<path v-if="fileKind(slotProps) === 'xls'" d="M8 13h8M8 17h5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="oa-upload__file-name">{{ shortDisplayName(slotProps) }}</div>
|
||||
<div class="oa-upload__file-name" :title="itemName(slotProps)">{{
|
||||
shortDisplayName(slotProps)
|
||||
}}</div>
|
||||
<div class="oa-upload__dl-hint">点击下载</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -231,12 +236,17 @@ watch(
|
||||
)
|
||||
|
||||
function emitUrls() {
|
||||
const urls = fileList.value
|
||||
const items = fileList.value
|
||||
.filter((f) => f.status === 'done' || f.isString)
|
||||
.map((f) => f.url)
|
||||
.map((f) => {
|
||||
const url = String(f.url || '').trim()
|
||||
if (!url || url.startsWith('blob:')) return null
|
||||
const name = resolveDisplayName(url, (f.file && f.file.name) || f.name)
|
||||
return { url, name: name || displayNameFromUrl(url) || '未命名文件' }
|
||||
})
|
||||
.filter(Boolean)
|
||||
const limit = Number(props.limit) || 9
|
||||
const value = limit === 1 ? urls[0] || '' : urls
|
||||
const value = limit === 1 ? items[0] || '' : items
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
@@ -409,6 +419,24 @@ async function onClickPreview(item) {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.oa-upload__img-name {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 12px 4px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0), rgba(15, 23, 42, 0.72));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.oa-upload__dl-hint {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/** 将讨论评论按时间插入审批进度;待审节点与未到达节点挂在已办节点与评论之后。 */
|
||||
|
||||
function isNotReached(item) {
|
||||
return String(item?.status || '') === 'not_reached'
|
||||
}
|
||||
|
||||
function isCurrentPending(item) {
|
||||
if (isNotReached(item)) return false
|
||||
const status = String(item?.status || '')
|
||||
const kind = String(item?.kind || '')
|
||||
return status === 'pending' || status === 'running' || kind === 'pending_node'
|
||||
}
|
||||
|
||||
export function mapProgressItems(list) {
|
||||
if (!Array.isArray(list) || !list.length) return []
|
||||
return list
|
||||
.filter((p) => {
|
||||
const kind = String(p?.kind || '')
|
||||
const status = String(p?.status || '')
|
||||
const title = String(p?.title || '').trim()
|
||||
if (kind === 'terminal' && (status === 'returned' || title === '已打回')) return false
|
||||
return true
|
||||
})
|
||||
.map((p, idx) => ({
|
||||
key: `${p.kind}-${p.nodeKey || p.taskId || idx}-${p.roundNo || 1}`,
|
||||
title: p.title || '-',
|
||||
status: p.status || '',
|
||||
timestamp: p.timestamp || '',
|
||||
assignee: p.assigneeUserName || p.assigneeUserId || '',
|
||||
commentText: p.commentText || '',
|
||||
taskType: p.taskType || '',
|
||||
kind: p.kind || '',
|
||||
signMode: p.signMode || '',
|
||||
people: Array.isArray(p.people)
|
||||
? p.people.map((x) => ({
|
||||
id: x.id || '',
|
||||
nickName: x.nickName || x.id || '',
|
||||
status: x.status || '',
|
||||
timestamp: x.timestamp || '',
|
||||
commentText: x.commentText || '',
|
||||
taskId: x.taskId || '',
|
||||
transferToUserId: x.transferToUserId || '',
|
||||
transferToUserName: x.transferToUserName || ''
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
}
|
||||
|
||||
export function mergeProgressWithComments(progressItems, comments) {
|
||||
const base = Array.isArray(progressItems) ? [...progressItems] : []
|
||||
const future = base.filter((p) => isNotReached(p))
|
||||
const current = base.filter((p) => isCurrentPending(p))
|
||||
const done = base.filter((p) => !isNotReached(p) && !isCurrentPending(p))
|
||||
const commentRows = (comments || []).map((c) => ({
|
||||
key: `comment-${c.id}`,
|
||||
kind: 'comment',
|
||||
title: c.createByName || c.createBy || '评论',
|
||||
status: c.status === 'revoked' ? 'revoked' : 'commented',
|
||||
timestamp: c.createTime || '',
|
||||
commentText: c.content || '',
|
||||
assignee: c.createByName || c.createBy || '',
|
||||
attachmentRefs: Array.isArray(c.attachmentRefs) ? c.attachmentRefs : [],
|
||||
canRevoke: !!c.canRevoke,
|
||||
commentId: c.id,
|
||||
revokedTime: c.revokedTime || '',
|
||||
mentions: Array.isArray(c.mentions) ? c.mentions : []
|
||||
}))
|
||||
const merged = [...done, ...commentRows].sort((a, b) => {
|
||||
const ta = String(a.timestamp || '')
|
||||
const tb = String(b.timestamp || '')
|
||||
if (ta !== tb) return ta < tb ? -1 : 1
|
||||
const aIsComment = a.kind === 'comment'
|
||||
const bIsComment = b.kind === 'comment'
|
||||
if (aIsComment !== bIsComment) return aIsComment ? 1 : -1
|
||||
return String(a.key || '').localeCompare(String(b.key || ''))
|
||||
})
|
||||
return [...merged, ...current, ...future]
|
||||
}
|
||||
|
||||
export function buildTimelineItems(detail) {
|
||||
return mergeProgressWithComments(mapProgressItems(detail?.progress), detail?.comments || [])
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import OaOwnerDeptSelect from './components/OaOwnerDeptSelect.vue'
|
||||
import OaKitShell from './components/OaKitShell.vue'
|
||||
import OaTripItinerary from './components/OaTripItinerary.vue'
|
||||
import OaDatePicker from './components/OaDatePicker.vue'
|
||||
import OaSelectField from './components/OaSelectField.vue'
|
||||
|
||||
/** 与 web-admin type 名对齐 */
|
||||
const REAL_COMPONENTS = {
|
||||
@@ -41,4 +42,7 @@ export function registerOaComponents(formCreate) {
|
||||
// 覆盖 @form-create/vant 内置日期:按 props.type 区分 date / datetime
|
||||
formCreate.component('datePicker', OaDatePicker)
|
||||
formCreate.component('fcDatePicker', OaDatePicker)
|
||||
// 覆盖内置选择器:支持 props.multiple(Web 端 el-select 多选)
|
||||
formCreate.component('select', OaSelectField)
|
||||
formCreate.component('fcSelect', OaSelectField)
|
||||
}
|
||||
@@ -23,7 +23,16 @@
|
||||
/>
|
||||
<p v-else class="oa-detail__empty">暂无表单内容</p>
|
||||
</div>
|
||||
<OaProgressTimeline :items="timelineItems" />
|
||||
<OaProgressTimeline
|
||||
:items="timelineItems"
|
||||
:instance-id="instanceId"
|
||||
@refresh="reloadDetail"
|
||||
/>
|
||||
<OaCommentComposer
|
||||
:instance-id="instanceId"
|
||||
:instance-status="detail?.status || ''"
|
||||
@refresh="reloadDetail"
|
||||
/>
|
||||
</div>
|
||||
<footer
|
||||
v-if="canApprove || showViewFooter"
|
||||
@@ -209,6 +218,8 @@ import { prepareMobileFormSchema } from '../oa/schema-prep.js'
|
||||
import { exportOaApprovalPdf } from '../oa/print/exportOaApprovalPdf.js'
|
||||
import { defaultComment, buildMoreActions } from '../oa/oa-action-defaults.js'
|
||||
import OaProgressTimeline from '../oa/components/OaProgressTimeline.vue'
|
||||
import OaCommentComposer from '../oa/components/OaCommentComposer.vue'
|
||||
import { buildTimelineItems } from '../oa/oa-progress-merge-comments.js'
|
||||
import OaPulseLoading from '../oa/components/OaPulseLoading.vue'
|
||||
import OaBtnLoadingInner from '../oa/components/OaBtnLoadingInner.vue'
|
||||
import OaPersonSelect from '../oa/components/OaPersonSelect.vue'
|
||||
@@ -261,41 +272,7 @@ const mode = computed(() => {
|
||||
return m || 'view'
|
||||
})
|
||||
|
||||
const timelineItems = computed(() => {
|
||||
const list = detail.value?.progress
|
||||
if (!Array.isArray(list) || !list.length) return []
|
||||
return list
|
||||
.filter((p) => {
|
||||
const kind = String(p?.kind || '')
|
||||
const status = String(p?.status || '')
|
||||
const title = String(p?.title || '').trim()
|
||||
if (kind === 'terminal' && (status === 'returned' || title === '已打回')) return false
|
||||
return true
|
||||
})
|
||||
.map((p, idx) => ({
|
||||
key: `${p.kind}-${p.nodeKey || p.taskId || idx}-${p.roundNo || 1}`,
|
||||
title: p.title || '-',
|
||||
status: p.status || '',
|
||||
timestamp: p.timestamp || '',
|
||||
assignee: p.assigneeUserName || p.assigneeUserId || '',
|
||||
commentText: p.commentText || '',
|
||||
taskType: p.taskType || '',
|
||||
kind: p.kind || '',
|
||||
signMode: p.signMode || '',
|
||||
people: Array.isArray(p.people)
|
||||
? p.people.map((x) => ({
|
||||
id: x.id || '',
|
||||
nickName: x.nickName || x.id || '',
|
||||
status: x.status || '',
|
||||
timestamp: x.timestamp || '',
|
||||
commentText: x.commentText || '',
|
||||
taskId: x.taskId || '',
|
||||
transferToUserId: x.transferToUserId || '',
|
||||
transferToUserName: x.transferToUserName || ''
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
})
|
||||
const timelineItems = computed(() => buildTimelineItems(detail.value))
|
||||
|
||||
function isPendingApprovalForMe(t) {
|
||||
const uid = currentUserId.value
|
||||
@@ -644,6 +621,16 @@ async function submitTransfer() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadDetail() {
|
||||
const id = instanceId.value
|
||||
if (!id) return
|
||||
try {
|
||||
detail.value = await fetchOaInstanceDetail(id)
|
||||
} catch (e) {
|
||||
showToast(e?.message || '刷新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,11 @@
|
||||
# Graph Report - src (2026-09-20)
|
||||
# Graph Report - src (2026-09-21)
|
||||
|
||||
## Corpus Check
|
||||
- Large corpus: 1001 files · ~663,299 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
- Large corpus: 1004 files · ~666,057 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
|
||||
## Summary
|
||||
- 3652 nodes · 5246 edges · 72 communities detected
|
||||
- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 1169 edges (avg confidence: 0.8)
|
||||
- 3651 nodes · 5243 edges · 70 communities detected
|
||||
- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 1172 edges (avg confidence: 0.8)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
@@ -53,38 +53,36 @@
|
||||
- [[_COMMUNITY_Community 41|Community 41]]
|
||||
- [[_COMMUNITY_Community 42|Community 42]]
|
||||
- [[_COMMUNITY_Community 43|Community 43]]
|
||||
- [[_COMMUNITY_Community 44|Community 44]]
|
||||
- [[_COMMUNITY_Community 45|Community 45]]
|
||||
- [[_COMMUNITY_Community 46|Community 46]]
|
||||
- [[_COMMUNITY_Community 48|Community 48]]
|
||||
- [[_COMMUNITY_Community 47|Community 47]]
|
||||
- [[_COMMUNITY_Community 49|Community 49]]
|
||||
- [[_COMMUNITY_Community 50|Community 50]]
|
||||
- [[_COMMUNITY_Community 52|Community 52]]
|
||||
- [[_COMMUNITY_Community 55|Community 55]]
|
||||
- [[_COMMUNITY_Community 51|Community 51]]
|
||||
- [[_COMMUNITY_Community 53|Community 53]]
|
||||
- [[_COMMUNITY_Community 54|Community 54]]
|
||||
- [[_COMMUNITY_Community 56|Community 56]]
|
||||
- [[_COMMUNITY_Community 57|Community 57]]
|
||||
- [[_COMMUNITY_Community 58|Community 58]]
|
||||
- [[_COMMUNITY_Community 59|Community 59]]
|
||||
- [[_COMMUNITY_Community 60|Community 60]]
|
||||
- [[_COMMUNITY_Community 61|Community 61]]
|
||||
- [[_COMMUNITY_Community 62|Community 62]]
|
||||
- [[_COMMUNITY_Community 63|Community 63]]
|
||||
- [[_COMMUNITY_Community 64|Community 64]]
|
||||
- [[_COMMUNITY_Community 66|Community 66]]
|
||||
- [[_COMMUNITY_Community 69|Community 69]]
|
||||
- [[_COMMUNITY_Community 67|Community 67]]
|
||||
- [[_COMMUNITY_Community 73|Community 73]]
|
||||
- [[_COMMUNITY_Community 75|Community 75]]
|
||||
- [[_COMMUNITY_Community 77|Community 77]]
|
||||
- [[_COMMUNITY_Community 76|Community 76]]
|
||||
- [[_COMMUNITY_Community 78|Community 78]]
|
||||
- [[_COMMUNITY_Community 79|Community 79]]
|
||||
- [[_COMMUNITY_Community 80|Community 80]]
|
||||
- [[_COMMUNITY_Community 81|Community 81]]
|
||||
- [[_COMMUNITY_Community 82|Community 82]]
|
||||
- [[_COMMUNITY_Community 87|Community 87]]
|
||||
- [[_COMMUNITY_Community 99|Community 99]]
|
||||
- [[_COMMUNITY_Community 102|Community 102]]
|
||||
- [[_COMMUNITY_Community 103|Community 103]]
|
||||
- [[_COMMUNITY_Community 85|Community 85]]
|
||||
- [[_COMMUNITY_Community 97|Community 97]]
|
||||
- [[_COMMUNITY_Community 100|Community 100]]
|
||||
- [[_COMMUNITY_Community 101|Community 101]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `get()` - 321 edges
|
||||
2. `push()` - 128 edges
|
||||
2. `push()` - 129 edges
|
||||
3. `emit()` - 30 edges
|
||||
4. `WebSocketClient` - 26 edges
|
||||
5. `set()` - 22 edges
|
||||
@@ -97,36 +95,36 @@
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `handleBatchDelete()` --calls--> `withFullscreenLoading()` [INFERRED]
|
||||
views/basic-info/cell-sample-type/index.vue → utils/fullscreen-loading.ts
|
||||
- `handleBatchDelete()` --calls--> `withFullscreenLoading()` [INFERRED]
|
||||
views/basic-info/customer/index.vue → utils/fullscreen-loading.ts
|
||||
- `formatReceiveAccountLine()` --calls--> `push()` [INFERRED]
|
||||
utils/oa-receive-account-value.ts → views/operations/storage-facility/index.vue
|
||||
- `dash()` --calls--> `formatDisplayOrDash()` [INFERRED]
|
||||
components/business/infusion-schedule-outbound-chain-view/index.vue → utils/datetime-display.ts
|
||||
- `dash()` --calls--> `formatDisplayOrDash()` [INFERRED]
|
||||
components/business/clinical-schedule-payment-detail/index.vue → utils/datetime-display.ts
|
||||
- `displayCell()` --calls--> `formatDisplayOrDash()` [INFERRED]
|
||||
views/production/waste-output/index.vue → utils/datetime-display.ts
|
||||
- `buildTaskListDisplayItems()` --calls--> `push()` [INFERRED]
|
||||
utils/qc-company-task-group.ts → views/operations/storage-facility/index.vue
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.01
|
||||
Nodes (188): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchChannelAccount() (+180 more)
|
||||
Cohesion: 0.0
|
||||
Nodes (216): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchChannelAccount() (+208 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.02
|
||||
Nodes (107): fetchCheckupInstitutionInvoices(), fetchCheckupInstitutionProjects(), fetchCollectionRegionList(), fetchCreateCheckupInstitution(), fetchCreateCheckupInstitutionInvoice(), fetchCreateCheckupInstitutionProject(), fetchCreateCollectionRegion(), fetchCreateHospital() (+99 more)
|
||||
Nodes (96): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchPilotCheckupDetail(), fetchPilotCheckupList(), markPilotCheckupChecked() (+88 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.02
|
||||
Nodes (68): fetchBatchDeleteOaReceiveAccounts(), fetchBatchMarkOaCcTasksRead(), fetchCreateOaReceiveAccount(), fetchDeleteOaReceiveAccount(), fetchMarkOaCcTaskRead(), fetchOaAppDraft(), fetchOaCcUnreadCount(), fetchOaFormAttachments() (+60 more)
|
||||
Nodes (74): fetchBatchDeleteOaReceiveAccounts(), fetchBatchMarkOaCcTasksRead(), fetchCreateOaReceiveAccount(), fetchDeleteOaReceiveAccount(), fetchMarkOaCcTaskRead(), fetchOaAppDraft(), fetchOaCcUnreadCount(), fetchOaFormAttachments() (+66 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.03
|
||||
Nodes (75): fetchUploadAttachment(), FireworkSystem, handleReportUpload(), handleReportUpload(), walk(), if(), async(), addDispatchItem() (+67 more)
|
||||
Nodes (78): fetchUploadAttachment(), FireworkSystem, handleReportUpload(), handleReportUpload(), walk(), if(), async(), addDispatchItem() (+70 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.03
|
||||
Nodes (86): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchPilotCheckupActualItems(), fetchPilotCheckupDetail(), fetchPilotCheckupList() (+78 more)
|
||||
Cohesion: 0.02
|
||||
Nodes (78): fetchCheckupInstitutionInvoices(), fetchCheckupInstitutionProjects(), fetchCreateCheckupInstitution(), fetchCreateCheckupInstitutionInvoice(), fetchCreateCheckupInstitutionProject(), fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer() (+70 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.03
|
||||
@@ -134,63 +132,63 @@ Nodes (79): fetchEthnicList(), formatNowDatetime(), applyDonationBundleToForm(),
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.03
|
||||
Nodes (59): buildInboundBasicSection(), buildInboundCellStorageViewSections(), buildInboundResearchDonationViewSections(), buildReceptionSection(), buildTransportSection(), resolveInboundProtocolStatus(), buildBaseFields(), buildInboundBasicSection() (+51 more)
|
||||
Nodes (65): buildInboundBasicSection(), buildInboundCellStorageViewSections(), buildInboundResearchDonationViewSections(), buildReceptionSection(), buildTemporaryViewSections(), buildTransportSection(), resolveInboundProtocolStatus(), buildInsertedSiblingCard() (+57 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.03
|
||||
Nodes (57): fetchOaRelatedSummaries(), useSettingsHandlers(), useSettingsPanel(), useSettingsState(), RoutePermissionValidator, useCeremony(), useTheme(), if() (+49 more)
|
||||
Nodes (56): handleBlur(), handleFocus(), handleInput(), fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolDetail(), fetchCollectionProtocolList() (+48 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.03
|
||||
Nodes (48): fetchProductionWasteOutputList(), fetchProductionReceptionBySampleCode(), fetchProductionReceptionSubmit(), fetchProductionReceptionUpdate(), handleDialogClose(), branchConditionLines(), nodeSummary(), onAdd() (+40 more)
|
||||
Nodes (43): fetchGetMenuList(), fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchUpdateProfile(), fetchUploadProfileAvatar(), fetchWxQrStatus(), MenuProcessor (+35 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.03
|
||||
Nodes (33): fetchGetMenuList(), ComponentLoader, IframeRouteManager, MenuProcessor, RouteRegistry, RouteTransformer, useAppMode(), useAuth() (+25 more)
|
||||
Nodes (53): fetchMyProductionTasks(), fetchMyQcTaskList(), fetchQcTaskDetail(), fetchQcTaskList(), fetchMineTasksForWorkOrders(), buildMineTableRows(), buildPresetTasksMap(), buildWorkOrderFromTasks() (+45 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.02
|
||||
Nodes (29): fetchAllConsumablePacks(), fetchAllConsumables(), fetchAllConsumableStockLogs(), fetchAllEquipments(), fetchAllOutputTypes(), fetchAllProcessCategories(), fetchAllProcesses(), fetchAllReagents() (+21 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (53): fetchOaRelatedSummaries(), useSettingsHandlers(), useSettingsPanel(), useSettingsState(), useCeremony(), useTheme(), if(), nodeSummary() (+45 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.03
|
||||
Nodes (56): fetchMyProductionTasks(), fetchMyQcTaskList(), fetchQcTaskDetail(), fetchQcTaskList(), fetchMineTasksForWorkOrders(), useInboundSiblingNavigation(), shouldApplyTriggerSourceFilter(), useQcWorkOrderTabPage() (+48 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (53): attachmentFullUrl(), dash(), handleAttachmentDownload(), handleAttachmentPreview(), handleDownload(), handlePreview(), buildFormRows(), buildOaPrintFilename() (+45 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.04
|
||||
Nodes (53): buildTemporaryViewSections(), buildInsertedSiblingCard(), resolveShowProtocolPaymentAtGroup(), appendTaskResultSections(), batchTasksForPanelSlot(), buildConfiguredOnlySection(), for(), set() (+45 more)
|
||||
Nodes (43): buildBaseFields(), buildFeeSection(), buildInboundBasicSection(), buildInboundCellStorageProductionViewSections(), buildInboundResearchDonationProductionViewSections(), buildOutboundBasicExperimentProductionViewSections(), buildOutboundClinicalProductionViewSections(), buildOutboundDetail() (+35 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.04
|
||||
Nodes (51): fetchAttachmentDownloadUrl(), qcAttachmentFullUrl(), qcDownloadAttachment(), qcPreviewAttachment(), handleDownloadAttachment(), attachmentFullUrl(), dash(), handleAttachmentDownload() (+43 more)
|
||||
Nodes (44): dash(), fmtMoney(), buildReceptionSection(), auditorDisplay(), dash(), ensureUnitsInOptions(), expressName(), formatYm() (+36 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.04
|
||||
Nodes (67): fetchDeptTree(), fetchOaFlowPreview(), fetchOaTemplateDetail(), applySchema(), flowNeedsInitiatorSelect(), loadFlowPreview(), loadUsers(), onPick() (+59 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (62): fetchDeptTree(), fetchUserList(), fetchOaFlowPreview(), fetchOaTemplateDetail(), remoteSearchHandoverUsers(), applySchema(), flowNeedsInitiatorSelect(), loadFlowPreview() (+54 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.04
|
||||
Nodes (43): fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolDetail(), fetchCollectionProtocolList(), fetchCreateProtocolInvoiceRequest(), fetchInvoiceEligiblePayments(), fetchPaymentDetailList() (+35 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.04
|
||||
Nodes (39): fetchCellSampleTypeList(), fetchCreateCellSampleType(), fetchCreateMenu(), fetchDeleteCellSampleType(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateCellSampleType(), fetchUpdateMenu() (+31 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.05
|
||||
Nodes (39): fetchCreateQcInspectionItemTemplate(), fetchDeleteQcInspectionItemTemplate(), fetchQcInspectionItemTemplateDetail(), fetchQcInspectionItemTemplateList(), fetchQcInspectionItemTemplatesActive(), fetchUpdateQcInspectionItemTemplate(), fetchQcLabTestItemList(), fetchQcLabTestItemsActive() (+31 more)
|
||||
Nodes (43): fetchAttachmentDownloadUrl(), qcAttachmentFullUrl(), qcDownloadAttachment(), qcPreviewAttachment(), formatHandoverUserLabel(), formatNowDatetime(), fullFileUrl(), handleDownloadAttachment() (+35 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.04
|
||||
Nodes (32): fetchProductionTaskList(), fetchProductionWasteOutputList(), fetchProductionReceptionBySampleCode(), fetchProductionReceptionSubmit(), fetchProductionReceptionUpdate(), handleSubmit(), onUserSelectVisible(), remoteSearchUsers() (+24 more)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.04
|
||||
Nodes (33): fetchUpdatePostBoundRoles(), fetchMineQcWorkOrderTabUnfinishedCounts(), fetchOutboundQcDetail(), fetchQcWorkOrderDetail(), fetchQcWorkOrderList(), fetchQcWorkOrdersBySample(), fetchQcWorkOrderTabUnfinishedCounts(), reload() (+25 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (49): fetchCollectionRegionList(), fetchCreateCollectionRegion(), fetchCreateHospital(), fetchCreateRegionStaff(), fetchDeleteCollectionRegion(), fetchDeleteHospital(), fetchDeleteRegionStaff(), fetchHospitalList() (+41 more)
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.05
|
||||
Nodes (24): handleBlur(), handleFocus(), handleInput(), fetchClinicalReceiptConfirmed(), buildConfirmedParams(), buildPendingParams(), formatAmount(), handleConfirmedReset() (+16 more)
|
||||
Nodes (39): fetchCreateQcInspectionItemTemplate(), fetchDeleteQcInspectionItemTemplate(), fetchQcInspectionItemTemplateDetail(), fetchQcInspectionItemTemplateList(), fetchQcInspectionItemTemplatesActive(), fetchUpdateQcInspectionItemTemplate(), fetchQcLabTestItemList(), fetchQcLabTestItemsActive() (+31 more)
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.06
|
||||
Nodes (37): fetchUserList(), fetchAddTaskStep(), fetchCopyTaskStep(), fetchDeleteTaskStep(), fetchDispatchProductionTaskSteps(), fetchForwardProductionTask(), fetchProductionTaskDetail(), fetchProductionTaskEditorInit() (+29 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (35): fetchUpdatePostBoundRoles(), branchConditionLines(), collectConditionReferencedFields(), collectFormConditionReferencedFields(), flattenFormRules(), formatConditionPreview(), formatConditionPreviewLines(), listConditionCandidates() (+27 more)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.06
|
||||
@@ -205,252 +203,242 @@ Cohesion: 0.06
|
||||
Nodes (22): fetchQcMaterialBatchDetail(), fetchQcMaterialBatchList(), fetchQcMaterialBatchOptions(), fetchQcMaterialList(), fetchQcMaterialOptions(), fetchUpdateQcMaterialBatch(), fetchMarkQcMaterialUsageFullyUsed(), financeAttachmentsToUrlString() (+14 more)
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 0.06
|
||||
Nodes (26): fetchProductionTaskList(), createEmptySnapshots(), normalizeConfiguredReagents(), normalizeConsumables(), normalizeDataCollections(), normalizeReagents(), normalizeStepOutputs(), serializeAttachments() (+18 more)
|
||||
Cohesion: 0.09
|
||||
Nodes (4): handleReset(), handleSearch(), WebSocketClient, TableCache
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 0.1
|
||||
Nodes (2): WebSocketClient, TableCache
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.08
|
||||
Nodes (33): fetchRefreshToken(), toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url() (+25 more)
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.07
|
||||
Nodes (13): fetchProductionWorkOrderQcInfo(), dash(), fmtMoney(), auditorDisplay(), dash(), ensureUnitsInOptions(), expressName(), formatYm() (+5 more)
|
||||
Nodes (24): fetchMineProductionWorkOrderTabStepCounts(), fetchMineProductionWorkOrderTabStepCountsDeduped(), fetchProductionWorkOrderCellProductOptions(), fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), fetchProductionWorkOrderQcDispatchOptions(), fetchProductionWorkOrderQcInfo(), fetchProductionWorkOrderTabStepCounts() (+16 more)
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.09
|
||||
Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.08
|
||||
Nodes (12): ComponentLoader, IframeRouteManager, RouteTransformer, getFirstMenuPath(), getFirstSecondLevelMenuPath(), isIframe(), isNavigableMenuItem(), isWorkbenchMenuItem() (+4 more)
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 0.1
|
||||
Nodes (28): buildFormRows(), buildOaPrintFilename(), buildOaPrintModel(), canvasToPdf(), ensurePlainObject(), exportOaApprovalPdf(), extractUrls(), formatFieldValue() (+20 more)
|
||||
Cohesion: 0.09
|
||||
Nodes (29): fetchAddTaskStep(), fetchCopyTaskStep(), fetchDeleteTaskStep(), fetchDispatchProductionTaskSteps(), fetchForwardProductionTask(), fetchProductionTaskDetail(), fetchProductionTaskEditorInit(), fetchReassignProductionTask() (+21 more)
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 0.08
|
||||
Nodes (22): fetchMineProductionWorkOrderTabStepCounts(), fetchMineProductionWorkOrderTabStepCountsDeduped(), fetchProductionWorkOrderCellProductOptions(), fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), fetchProductionWorkOrderQcDispatchOptions(), fetchProductionWorkOrderTabStepCounts(), fetchProductionWorkOrderTabStepCountsDeduped() (+14 more)
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 0.07
|
||||
Nodes (19): fetchDeleteMaterialBatch(), fetchMaterialBatchList(), fetchMaterialBatchOptions(), fetchMaterialList(), fetchMaterialOptions(), fetchUpdateMaterialBatch(), fetchMaterialUsageList(), fetchMyMaterialUsageList() (+11 more)
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 0.08
|
||||
Nodes (20): createEmptySnapshots(), normalizeConfiguredReagents(), normalizeConsumables(), normalizeDataCollections(), normalizeReagents(), normalizeStepOutputs(), serializeAttachments(), serializeEquipmentIds() (+12 more)
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 0.09
|
||||
Nodes (22): fetchOaAppList(), fetchOaGroupList(), fetchPublishOaApp(), loadData(), loadGroups(), resetForm(), onSuccess(), goBack() (+14 more)
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 0.1
|
||||
Nodes (21): displayPeople(), nodeStatusText(), personLines(), personStatusHint(), personTitle(), showNodeComment(), signBadge(), signBadgeText() (+13 more)
|
||||
Cohesion: 0.09
|
||||
Nodes (12): fetchMineQcWorkOrderTabUnfinishedCounts(), fetchOutboundQcDetail(), fetchQcWorkOrderDetail(), fetchQcWorkOrderList(), fetchQcWorkOrdersBySample(), fetchQcWorkOrderTabUnfinishedCounts(), reload(), reloadTabCounts() (+4 more)
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 0.11
|
||||
Nodes (21): fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchUpdateProfile(), fetchUploadProfileAvatar(), fetchWxQrStatus(), fetchUserInfo(), buildWebEncryptedLoginBody() (+13 more)
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 0.1
|
||||
Nodes (12): fetchOutboundReleaseMaterial(), fetchOutboundReleaseReviewDetail(), fetchOutboundReleaseReviewList(), fetchReleaseReviewList(), handleReset(), handleSearch(), handleTabChange(), handleToggleSelect() (+4 more)
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 0.12
|
||||
Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 0.13
|
||||
Nodes (8): chipTitle(), confirmPicker(), memberOptionLabel(), removeUser(), resolveUser(), signBadge(), signBadgeText(), updateSelect()
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 0.2
|
||||
Nodes (14): applyFlagToRule(), applySchema(), countRules(), getActiveRule(), getDesigner(), getSetupState(), onMessage(), readFlag() (+6 more)
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 0.16
|
||||
Nodes (14): fetchCreateEnvDetectItem(), fetchDeleteEnvDetectItem(), fetchEnvDetectItemDetail(), fetchEnvDetectItemList(), fetchEnvDetectItemsActive(), fetchUpdateEnvDetectItem(), handleAdd(), handleBatchDelete() (+6 more)
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 0.12
|
||||
Nodes (6): setupOaConfirmationKit(), ensureOaKitMenu(), setupOaLeaveKit(), setupOaOvertimeKit(), setupOaResignationKit(), setupOaTripKit()
|
||||
|
||||
### Community 41 - "Community 41"
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 0.18
|
||||
Nodes (10): fetchClinicalReceiptConfirmed(), buildConfirmedParams(), buildPendingParams(), handleConfirmedReset(), handleConfirmedSearch(), handlePendingReset(), handlePendingSearch(), loadAll() (+2 more)
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
Cohesion: 0.16
|
||||
Nodes (10): useTable(), useTableImpl(), getColumnChecks(), useTableColumns(), createErrorHandler(), createSmartDebounce(), defaultResponseAdapter(), extractPagination() (+2 more)
|
||||
|
||||
### Community 42 - "Community 42"
|
||||
Cohesion: 0.17
|
||||
Nodes (14): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), handleAdd(), handleBatchDelete(), handleDelete(), handleDropdownCommand() (+6 more)
|
||||
|
||||
### Community 43 - "Community 43"
|
||||
### Community 41 - "Community 41"
|
||||
Cohesion: 0.27
|
||||
Nodes (14): useChart(), useChartComponent(), useChartOps(), colourBlend(), getCssVar(), getDarkColor(), getLightColor(), handleElementThemeColor() (+6 more)
|
||||
|
||||
### Community 44 - "Community 44"
|
||||
Cohesion: 0.33
|
||||
Nodes (13): buildProtocolPaymentFlowSteps(), dash(), financeReceiptTime(), formatAmount(), hasFinanceReceipt(), hasFinanceRefund(), hasReceiptSubmit(), hasRefundSubmit() (+5 more)
|
||||
|
||||
### Community 45 - "Community 45"
|
||||
### Community 42 - "Community 42"
|
||||
Cohesion: 0.19
|
||||
Nodes (6): fetchServiceOrderTxnList(), handleReset(), handleSearch(), handleTabChange(), loadData(), switchTab()
|
||||
|
||||
### Community 46 - "Community 46"
|
||||
### Community 43 - "Community 43"
|
||||
Cohesion: 0.24
|
||||
Nodes (10): buildPrintDocumentHtml(), cleanupPrintIframe(), embedSopImagesAsDataUrls(), getSopA4ContentHeightPx(), getSopA4EditorHeightPx(), normalizeSopCheckboxes(), prepareSopHtmlForPrint(), printSopHtml() (+2 more)
|
||||
|
||||
### Community 48 - "Community 48"
|
||||
Cohesion: 0.42
|
||||
Nodes (9): buildPaymentFlowSteps(), dash(), formatAmount(), hasFinanceReceipt(), hasFinanceRefund(), hasReceiptSubmit(), hasRefundSubmit(), receiptSubmitTime() (+1 more)
|
||||
### Community 45 - "Community 45"
|
||||
Cohesion: 0.33
|
||||
Nodes (7): buildSheetColumns(), downloadOaHistoryExcel(), exportTimestamp(), pad(), sanitizeFilename(), styleHeaderCell(), writeHeaderRows()
|
||||
|
||||
### Community 49 - "Community 49"
|
||||
### Community 46 - "Community 46"
|
||||
Cohesion: 0.31
|
||||
Nodes (4): isSlotAvailable(), resolveStorageSlotRangeFill(), slotFacilityNodeId(), sortBoxSlots()
|
||||
|
||||
### Community 50 - "Community 50"
|
||||
### Community 47 - "Community 47"
|
||||
Cohesion: 0.25
|
||||
Nodes (2): handleTouchEnd(), setTransition()
|
||||
|
||||
### Community 52 - "Community 52"
|
||||
### Community 49 - "Community 49"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): isActiveHistoryInstance(), isUploadPreviewLabel(), previewColumnMinWidth(), syncWorktabTitle()
|
||||
|
||||
### Community 55 - "Community 55"
|
||||
### Community 51 - "Community 51"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): fetchMyProductionMaterialConsumptions(), handleReset(), handleSearch(), loadData()
|
||||
|
||||
### Community 53 - "Community 53"
|
||||
Cohesion: 0.39
|
||||
Nodes (6): isIgnorableScriptError(), normalizeErrorMessage(), registerPromiseErrorHandler(), registerResourceErrorHandler(), scriptErrorHandler(), setupErrorHandle()
|
||||
|
||||
### Community 56 - "Community 56"
|
||||
### Community 54 - "Community 54"
|
||||
Cohesion: 0.36
|
||||
Nodes (1): TableHeightCalculator
|
||||
|
||||
### Community 58 - "Community 58"
|
||||
### Community 56 - "Community 56"
|
||||
Cohesion: 0.33
|
||||
Nodes (2): logApiCryptoBootInfo(), maskSecret()
|
||||
|
||||
### Community 59 - "Community 59"
|
||||
### Community 57 - "Community 57"
|
||||
Cohesion: 0.38
|
||||
Nodes (4): handleFocus(), handleInput(), parseDecimalToNumber(), sanitizeDecimalInput()
|
||||
|
||||
### Community 60 - "Community 60"
|
||||
### Community 58 - "Community 58"
|
||||
Cohesion: 0.29
|
||||
Nodes (1): fetchQcMonthTemplateList()
|
||||
|
||||
### Community 61 - "Community 61"
|
||||
### Community 59 - "Community 59"
|
||||
Cohesion: 0.4
|
||||
Nodes (2): isUnfinishedQcWorkOrder(), resolveQcWorkOrderUnfinishedBadgeCount()
|
||||
|
||||
### Community 62 - "Community 62"
|
||||
### Community 60 - "Community 60"
|
||||
Cohesion: 0.47
|
||||
Nodes (3): isPaymentDueWithinWarnWindow(), parsePaymentDueYmd(), startOfLocalDay()
|
||||
|
||||
### Community 63 - "Community 63"
|
||||
### Community 61 - "Community 61"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): formatProtocolDocDeleteConfirm(), formatProtocolDocSubject()
|
||||
|
||||
### Community 64 - "Community 64"
|
||||
### Community 62 - "Community 62"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): parseEquipmentIds(), syncEquipmentNamesFromOptions()
|
||||
|
||||
### Community 66 - "Community 66"
|
||||
### Community 64 - "Community 64"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): focusInput(), handleKeydown()
|
||||
|
||||
### Community 69 - "Community 69"
|
||||
### Community 67 - "Community 67"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): if(), logSidebarMenu()
|
||||
|
||||
### Community 75 - "Community 75"
|
||||
### Community 73 - "Community 73"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): getSettingDefaults(), resetToDefaults()
|
||||
|
||||
### Community 77 - "Community 77"
|
||||
### Community 75 - "Community 75"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): buildOvertimeKitChildren(), uniqueId()
|
||||
|
||||
### Community 78 - "Community 78"
|
||||
### Community 76 - "Community 76"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): buildLeaveKitChildren(), uniqueId()
|
||||
|
||||
### Community 80 - "Community 80"
|
||||
### Community 78 - "Community 78"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): buildConfirmationKitChildren(), uniqueId()
|
||||
|
||||
### Community 81 - "Community 81"
|
||||
### Community 79 - "Community 79"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): buildResignationKitChildren(), uniqueId()
|
||||
|
||||
### Community 82 - "Community 82"
|
||||
### Community 80 - "Community 80"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): copyTextToClipboard(), copyTextWithFallback()
|
||||
|
||||
### Community 87 - "Community 87"
|
||||
### Community 85 - "Community 85"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): getSourceProtocolAttachment(), normalizeAttachmentFiles()
|
||||
|
||||
### Community 99 - "Community 99"
|
||||
### Community 97 - "Community 97"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): emptyTabCounts(), useProductionWorkOrderTabStepCounts()
|
||||
|
||||
### Community 102 - "Community 102"
|
||||
### Community 100 - "Community 100"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): initialProgress(), useQcWorkOrderAttachmentExport()
|
||||
|
||||
### Community 103 - "Community 103"
|
||||
### Community 101 - "Community 101"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): emptyTabCounts(), useQcWorkOrderTabUnfinishedCounts()
|
||||
|
||||
## Knowledge Gaps
|
||||
- **Thin community `Community 25`** (42 nodes): `WebSocketClient`, `.calculateReconnectDelay()`, `.clearAllTimers()`, `.clearConnectionTimers()`, `.clearTimer()`, `.close()`, `.closeCurrentSocketForReconnect()`, `.connect()`, `.connectionStatusText()`, `.constructor()`, `.destroyInstance()`, `.flushMessageQueue()`, `.getInstance()`, `.getReadyStateText()`, `.handleClose()`, `.handleConnectionTimeout()`, `.handleError()`, `.handleMessage()`, `.handleOpen()`, `.init()`, `.isWebSocketConnected()`, `.reconnect()`, `.resetHeartbeat()`, `.send()`, `.startHeartbeat()`, `.startPing()`, `TableCache`, `.cleanupExpired()`, `.clear()`, `.clearByTags()`, `.clearCurrentSearch()`, `.clearPagination()`, `.constructor()`, `.evictLRU()`, `.generateKey()`, `.generateTags()`, `.get()`, `.getStats()`, `.log()`, `.set()`, `index.ts`, `tableCache.ts`
|
||||
- **Thin community `Community 47`** (9 nodes): `cleanupEventListeners()`, `handleTouchEnd()`, `handleTouchMove()`, `handleTouchStart()`, `handleWheelScroll()`, `if()`, `setTransition()`, `setupEventListeners()`, `index.vue`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 50`** (9 nodes): `cleanupEventListeners()`, `handleTouchEnd()`, `handleTouchMove()`, `handleTouchStart()`, `handleWheelScroll()`, `if()`, `setTransition()`, `setupEventListeners()`, `index.vue`
|
||||
- **Thin community `Community 54`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 56`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
|
||||
- **Thin community `Community 56`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 58`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
|
||||
- **Thin community `Community 58`** (7 nodes): `fetchConfirmQcMonthImport()`, `fetchCreateQcMonthTemplate()`, `fetchDeleteQcMonthTemplate()`, `fetchPreviewQcMonthImport()`, `fetchQcMonthTemplateList()`, `fetchUpdateQcMonthTemplate()`, `qc-material-month-template.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 60`** (7 nodes): `fetchConfirmQcMonthImport()`, `fetchCreateQcMonthTemplate()`, `fetchDeleteQcMonthTemplate()`, `fetchPreviewQcMonthImport()`, `fetchQcMonthTemplateList()`, `fetchUpdateQcMonthTemplate()`, `qc-material-month-template.ts`
|
||||
- **Thin community `Community 59`** (6 nodes): `formatQcWorkOrderUnfinishedBadgeCount()`, `isUnfinishedQcWorkOrder()`, `qcWorkOrderUnfinishedBadgeTitle()`, `resolveQcWorkOrderUnfinishedBadgeCount()`, `sumQcWorkOrderGroupUnfinishedBadgeCount()`, `qc-work-order-unfinished-badge.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 61`** (6 nodes): `formatQcWorkOrderUnfinishedBadgeCount()`, `isUnfinishedQcWorkOrder()`, `qcWorkOrderUnfinishedBadgeTitle()`, `resolveQcWorkOrderUnfinishedBadgeCount()`, `sumQcWorkOrderGroupUnfinishedBadgeCount()`, `qc-work-order-unfinished-badge.ts`
|
||||
- **Thin community `Community 61`** (5 nodes): `formatProtocolDocBatchDeleteConfirm()`, `formatProtocolDocDeleteConfirm()`, `formatProtocolDocSubject()`, `getProtocolDocTypeLabel()`, `protocol-doc-label.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 63`** (5 nodes): `formatProtocolDocBatchDeleteConfirm()`, `formatProtocolDocDeleteConfirm()`, `formatProtocolDocSubject()`, `getProtocolDocTypeLabel()`, `protocol-doc-label.ts`
|
||||
- **Thin community `Community 62`** (5 nodes): `equipmentOptionLabel()`, `joinEquipmentIds()`, `parseEquipmentIds()`, `syncEquipmentNamesFromOptions()`, `equipment-select.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 64`** (5 nodes): `equipmentOptionLabel()`, `joinEquipmentIds()`, `parseEquipmentIds()`, `syncEquipmentNamesFromOptions()`, `equipment-select.ts`
|
||||
- **Thin community `Community 64`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 66`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
|
||||
- **Thin community `Community 67`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 69`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
|
||||
- **Thin community `Community 73`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 75`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
|
||||
- **Thin community `Community 75`** (3 nodes): `buildOvertimeKitChildren()`, `oa-overtime-kit-rule.ts`, `uniqueId()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 77`** (3 nodes): `buildOvertimeKitChildren()`, `oa-overtime-kit-rule.ts`, `uniqueId()`
|
||||
- **Thin community `Community 76`** (3 nodes): `buildLeaveKitChildren()`, `oa-leave-kit-rule.ts`, `uniqueId()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 78`** (3 nodes): `buildLeaveKitChildren()`, `oa-leave-kit-rule.ts`, `uniqueId()`
|
||||
- **Thin community `Community 78`** (3 nodes): `buildConfirmationKitChildren()`, `oa-confirmation-kit-rule.ts`, `uniqueId()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 80`** (3 nodes): `buildConfirmationKitChildren()`, `oa-confirmation-kit-rule.ts`, `uniqueId()`
|
||||
- **Thin community `Community 79`** (3 nodes): `buildResignationKitChildren()`, `oa-resignation-kit-rule.ts`, `uniqueId()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 81`** (3 nodes): `buildResignationKitChildren()`, `oa-resignation-kit-rule.ts`, `uniqueId()`
|
||||
- **Thin community `Community 80`** (3 nodes): `copyTextToClipboard()`, `copyTextWithFallback()`, `copy-text.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 82`** (3 nodes): `copyTextToClipboard()`, `copyTextWithFallback()`, `copy-text.ts`
|
||||
- **Thin community `Community 85`** (3 nodes): `getSourceProtocolAttachment()`, `normalizeAttachmentFiles()`, `protocol-source-attachment.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 87`** (3 nodes): `getSourceProtocolAttachment()`, `normalizeAttachmentFiles()`, `protocol-source-attachment.ts`
|
||||
- **Thin community `Community 97`** (3 nodes): `emptyTabCounts()`, `useProductionWorkOrderTabStepCounts()`, `useProductionWorkOrderTabStepCounts.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 99`** (3 nodes): `emptyTabCounts()`, `useProductionWorkOrderTabStepCounts()`, `useProductionWorkOrderTabStepCounts.ts`
|
||||
- **Thin community `Community 100`** (3 nodes): `initialProgress()`, `useQcWorkOrderAttachmentExport()`, `useQcWorkOrderAttachmentExport.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 102`** (3 nodes): `initialProgress()`, `useQcWorkOrderAttachmentExport()`, `useQcWorkOrderAttachmentExport.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 103`** (3 nodes): `emptyTabCounts()`, `useQcWorkOrderTabUnfinishedCounts()`, `useQcWorkOrderTabUnfinishedCounts.ts`
|
||||
- **Thin community `Community 101`** (3 nodes): `emptyTabCounts()`, `useQcWorkOrderTabUnfinishedCounts()`, `useQcWorkOrderTabUnfinishedCounts.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `push()` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 20`, `Community 22`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 29`, `Community 30`, `Community 32`, `Community 33`, `Community 37`?**
|
||||
_High betweenness centrality (0.335) - this node is a cross-community bridge._
|
||||
- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 7`, `Community 8`, `Community 10`, `Community 11`, `Community 12`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 23`, `Community 24`, `Community 27`, `Community 30`, `Community 31`, `Community 32`, `Community 34`, `Community 35`, `Community 39`, `Community 42`, `Community 45`, `Community 49`, `Community 60`?**
|
||||
_High betweenness centrality (0.277) - this node is a cross-community bridge._
|
||||
- **Why does `computeWorkOrderPlannedStartTimeRange()` connect `Community 24` to `Community 3`?**
|
||||
- **Why does `push()` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 16`, `Community 19`, `Community 20`, `Community 22`, `Community 24`, `Community 25`, `Community 26`, `Community 29`, `Community 31`, `Community 32`, `Community 33`, `Community 36`, `Community 45`?**
|
||||
_High betweenness centrality (0.329) - this node is a cross-community bridge._
|
||||
- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 23`, `Community 26`, `Community 29`, `Community 30`, `Community 32`, `Community 33`, `Community 34`, `Community 39`, `Community 42`, `Community 46`, `Community 51`, `Community 58`?**
|
||||
_High betweenness centrality (0.258) - this node is a cross-community bridge._
|
||||
- **Why does `computeWorkOrderPlannedStartTimeRange()` connect `Community 31` to `Community 3`?**
|
||||
_High betweenness centrality (0.039) - this node is a cross-community bridge._
|
||||
- **Are the 320 inferred relationships involving `get()` (e.g. with `resolveStorageSlotRangeFill()` and `buildNodeCodePathFromFlat()`) actually correct?**
|
||||
_`get()` has 320 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 127 inferred relationships involving `push()` (e.g. with `refreshQcMenuBadges()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
|
||||
_`push()` has 127 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 128 inferred relationships involving `push()` (e.g. with `refreshQcMenuBadges()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
|
||||
_`push()` has 128 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 29 inferred relationships involving `emit()` (e.g. with `handleSubmit()` and `handleDialogClose()`) actually correct?**
|
||||
_`emit()` has 29 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 21 inferred relationships involving `set()` (e.g. with `buildNodeCodePathFromFlat()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
|
||||
|
||||
@@ -57,12 +57,12 @@
|
||||
</div>
|
||||
<div id="legend"></div>
|
||||
</div>
|
||||
<div id="stats">3652 nodes · 5246 edges · 396 communities</div>
|
||||
<div id="stats">3651 nodes · 5243 edges · 396 communities</div>
|
||||
</div>
|
||||
<script>
|
||||
const RAW_NODES = [{"id": "app_vue", "label": "App.vue", "color": {"background": "#9C755F", "border": "#9C755F", "highlight": {"background": "#ffffff", "border": "#9C755F"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "App.vue", "community": 58, "community_name": "Community 58", "source_file": "App.vue", "file_type": "code", "degree": 3}, {"id": "src_app_refreshbuttonautoicons", "label": "refreshButtonAutoIcons()", "color": {"background": "#9C755F", "border": "#9C755F", "highlight": {"background": "#ffffff", "border": "#9C755F"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "refreshButtonAutoIcons()", "community": 58, "community_name": "Community 58", "source_file": "App.vue", "file_type": "code", "degree": 1}, {"id": "src_app_refreshpaneltitleicons", "label": "refreshPanelTitleIcons()", "color": {"background": "#9C755F", "border": "#9C755F", "highlight": {"background": "#ffffff", "border": "#9C755F"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "refreshPanelTitleIcons()", "community": 58, "community_name": "Community 58", "source_file": "App.vue", "file_type": "code", "degree": 1}, {"id": "main_ts", "label": "main.ts", "color": {"background": "#9C755F", "border": "#9C755F", "highlight": {"background": "#ffffff", "border": "#9C755F"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "main.ts", "community": 58, "community_name": "Community 58", "source_file": "main.ts", "file_type": "code", "degree": 4}, {"id": "src_main_masksecret", "label": "maskSecret()", "color": {"background": "#9C755F", "border": "#9C755F", "highlight": {"background": "#ffffff", "border": "#9C755F"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "maskSecret()", "community": 58, "community_name": "Community 58", "source_file": "main.ts", "file_type": "code", "degree": 2}, {"id": "src_main_logapicryptobootinfo", "label": "logApiCryptoBootInfo()", "color": {"background": "#9C755F", "border": "#9C755F", "highlight": {"background": "#ffffff", "border": "#9C755F"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "logApiCryptoBootInfo()", "community": 58, "community_name": "Community 58", "source_file": "main.ts", "file_type": "code", "degree": 2}, {"id": "env_d_ts", "label": "env.d.ts", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "env.d.ts", "community": 151, "community_name": "Community 151", "source_file": "env.d.ts", "file_type": "code", "degree": 0}, {"id": "shims_loose_d_ts", "label": "shims-loose.d.ts", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "shims-loose.d.ts", "community": 152, "community_name": "Community 152", "source_file": "shims-loose.d.ts", "file_type": "code", "degree": 0}, {"id": "directives_index_ts", "label": "index.ts", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "index.ts", "community": 36, "community_name": "Community 36", "source_file": "directives/index.ts", "file_type": "code", "degree": 1}, {"id": "directives_index_setupglobdirectives", "label": "setupGlobDirectives()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "setupGlobDirectives()", "community": 36, "community_name": "Community 36", "source_file": "directives/index.ts", "file_type": "code", "degree": 5}, {"id": "directives_core_roles_ts", "label": "roles.ts", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "roles.ts", "community": 36, "community_name": "Community 36", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 3}, {"id": "core_roles_checkrolepermission", "label": "checkRolePermission()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "checkRolePermission()", "community": 36, "community_name": "Community 36", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 2}, {"id": "core_roles_removeelement", "label": "removeElement()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "removeElement()", "community": 36, "community_name": "Community 36", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 2}, {"id": "core_roles_setuprolesdirective",Line truncated
|
||||
const RAW_EDGES = [{"from": "app_vue", "to": "src_app_refreshbuttonautoicons", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "app_vue", "to": "src_app_refreshpaneltitleicons", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "app_vue", "to": "main_ts", "label": "imports_from", "title": "imports_from [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "main_ts", "to": "utils_sys_console_ts", "label": "imports_from", "title": "imports_from [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "main_ts", "to": "src_main_masksecret", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "main_ts", "to": "src_main_logapicryptobootinfo", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "src_main_masksecret", "to": "src_main_logapicryptobootinfo", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_index_ts", "to": "directives_index_setupglobdirectives", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_index_setupglobdirectives", "to": "core_auth_setupauthdirective", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "directives_index_setupglobdirectives", "to": "core_roles_setuprolesdirective", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "directives_index_setupglobdirectives", "to": "business_highlight_setuphighlightdirective", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "directives_index_setupglobdirectives", "to": "business_ripple_setuprippledirective", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "directives_core_roles_ts", "to": "core_roles_checkrolepermission", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_roles_ts", "to": "core_roles_removeelement", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_roles_ts", "to": "core_roles_setuprolesdirective", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "core_roles_checkrolepermission", "to": "core_roles_removeelement", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_auth_ts", "to": "core_auth_checkauthpermission", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_auth_ts", "to": "core_auth_removeelement", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_auth_ts", "to": "core_auth_setupauthdirective", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "core_auth_checkauthpermission", "to": "core_auth_removeelement", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_business_ripple_ts", "to": "business_ripple_setuprippledirective", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_business_highlight_ts", "to": "business_highlight_highlightcode", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_business_highlight_ts", "to": "business_highlight_insertlinenumbers", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_business_highlight_ts", "to": "business_highlight_addcopybutton", "label": "contains", "title": "contains [EXTRACTED]", "dLine truncated
|
||||
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "Community 0", "count": 501}, {"cid": 1, "color": "#F28E2B", "label": "Community 1", "count": 155}, {"cid": 2, "color": "#E15759", "label": "Community 2", "count": 121}, {"cid": 3, "color": "#76B7B2", "label": "Community 3", "count": 118}, {"cid": 4, "color": "#59A14F", "label": "Community 4", "count": 117}, {"cid": 5, "color": "#EDC948", "label": "Community 5", "count": 110}, {"cid": 6, "color": "#B07AA1", "label": "Community 6", "count": 98}, {"cid": 7, "color": "#FF9DA7", "label": "Community 7", "count": 96}, {"cid": 8, "color": "#9C755F", "label": "Community 8", "count": 93}, {"cid": 9, "color": "#BAB0AC", "label": "Community 9", "count": 93}, {"cid": 10, "color": "#4E79A7", "label": "Community 10", "count": 92}, {"cid": 11, "color": "#F28E2B", "label": "Community 11", "count": 90}, {"cid": 12, "color": "#E15759", "label": "Community 12", "count": 86}, {"cid": 13, "color": "#76B7B2", "label": "Community 13", "count": 82}, {"cid": 14, "color": "#59A14F", "label": "Community 14", "count": 80}, {"cid": 15, "color": "#EDC948", "label": "Community 15", "count": 68}, {"cid": 16, "color": "#B07AA1", "label": "Community 16", "count": 68}, {"cid": 17, "color": "#FF9DA7", "label": "Community 17", "count": 57}, {"cid": 18, "color": "#9C755F", "label": "Community 18", "count": 55}, {"cid": 19, "color": "#BAB0AC", "label": "Community 19", "count": 53}, {"cid": 20, "color": "#4E79A7", "label": "Community 20", "count": 51}, {"cid": 21, "color": "#F28E2B", "label": "Community 21", "count": 49}, {"cid": 22, "color": "#E15759", "label": "Community 22", "count": 47}, {"cid": 23, "color": "#76B7B2", "label": "Community 23", "count": 46}, {"cid": 24, "color": "#59A14F", "label": "Community 24", "count": 43}, {"cid": 25, "color": "#EDC948", "label": "Community 25", "count": 42}, {"cid": 26, "color": "#B07AA1", "label": "Community 26", "count": 41}, {"cid": 27, "color": "#FF9DA7", "label": "Community 27", "count": 40}, {"cid": 28, "color": "#9C755F", "label": "Community 28", "count": 37}, {"cid": 29, "color": "#BAB0AC", "label": "Community 29", "count": 35}, {"cid": 30, "color": "#4E79A7", "label": "Community 30", "count": 35}, {"cid": 31, "color": "#F28E2B", "label": "Community 31", "count": 35}, {"cid": 32, "color": "#E15759", "label": "Community 32", "count": 33}, {"cid": 33, "color": "#76B7B2", "label": "Community 33", "count": 31}, {"cid": 34, "color": "#59A14F", "label": "Community 34", "count": 30}, {"cid": 35, "color": "#EDC948", "label": "Community 35", "count": 24}, {"cid": 36, "color": "#B07AA1", "label": "Community 36", "count": 22}, {"cid": 37, "color": "#FF9DA7", "label": "Community 37", "count": 20}, {"cid": 38, "color": "#9C755F", "label": "Community 38", "count": 19}, {"cid": 39, "color": "#BAB0AC", "label": "Community 39", "count": 19}, {"cid": 40, "color": "#4E79A7", "label": "Community 40", "count": 17}, {"cid": 41, "color": "#F28E2B", "label": "Community 41", "count": 17}, {"cid": 42, "color": "#E15759", "label": "Community 42", "count": 17}, {"cid": 43, "color": "#76B7B2", "label": "Community 43", "count": 16}, {"cid": 44, "color": "#59A14F", "label": "Community 44", "count": 14}, {"cid": 45, "color": "#EDC948", "label": "Community 45", "count": 14}, {"cid": 46, "color": "#B07AA1", "label": "Community 46", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "Community 47", "count": 13}, {"cid": 48, "color": "#9C755F", "label": "Community 48", "count": 10}, {"cid": 49, "color": "#BAB0AC", "label": "Community 49", "count": 9}, {"cid": 50, "color": "#4E79A7", "label": "Community 50", "count": 9}, {"cid": 51, "color": "#F28E2B", "label": "Community 51", "count": 9}, {"cid": 52, "color": "#E15759", "label": "Community 52", "count": 9}, {"cid": 53, "color": "#76B7B2", "label": "Community 53", "count": 9}, {"cid": 54, "color": "#59A14F", "label": "Community 54", "count": 9}, {"cid": 55, "color": "#EDC948", "label": "Community 55", "count": 8}, {"cid": 56, "color": "#B07AA1", "label": "Community 56", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "Community 57", "count": 8}, {"cid": 58, "color": "#9C755F", "label": "Community 58", "count": 7}, {"cid": 59, "color": "#BAB0AC", "label": "Community 59", "count": 7}, {"cid": 60, "color": "#4E79A7", "label": "Community 60", "count": 7}, {"cid": 61, "color": "#F28E2B", "label": "Community 61", "count": 6}, {"cid": 62, "color": "#E15759", "label": "Community 62", "count": 6}, {"cid": 63, "color": "#76B7B2", "label": "Community 63", "count": 5}, {"cid": 64, "color": "#59A14F", "label": "Community 64", "count": 5}, {"cid": 65, "color": "#EDC948", "label": "Community 65", "count": 5}, {"cid": 66, "color": "#B07AA1", "label": "Community 66", "count": 5}, {"cid": 67, "color": "#FF9DA7", "label": "Community 67", "count": 4}, {"cid": 68, "color": "#9C755F", "label": "Community 68", "count": 4}, {"cid": 69, "color": "#BAB0AC", "label": "Community 69", "count": 4}, {"cid": 70, "color": "#4E79A7", "label": "CommunLine truncated
|
||||
const RAW_NODES = [{"id": "app_vue", "label": "App.vue", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "App.vue", "community": 56, "community_name": "Community 56", "source_file": "App.vue", "file_type": "code", "degree": 3}, {"id": "src_app_refreshbuttonautoicons", "label": "refreshButtonAutoIcons()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "refreshButtonAutoIcons()", "community": 56, "community_name": "Community 56", "source_file": "App.vue", "file_type": "code", "degree": 1}, {"id": "src_app_refreshpaneltitleicons", "label": "refreshPanelTitleIcons()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "refreshPanelTitleIcons()", "community": 56, "community_name": "Community 56", "source_file": "App.vue", "file_type": "code", "degree": 1}, {"id": "main_ts", "label": "main.ts", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "main.ts", "community": 56, "community_name": "Community 56", "source_file": "main.ts", "file_type": "code", "degree": 4}, {"id": "src_main_masksecret", "label": "maskSecret()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "maskSecret()", "community": 56, "community_name": "Community 56", "source_file": "main.ts", "file_type": "code", "degree": 2}, {"id": "src_main_logapicryptobootinfo", "label": "logApiCryptoBootInfo()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "logApiCryptoBootInfo()", "community": 56, "community_name": "Community 56", "source_file": "main.ts", "file_type": "code", "degree": 2}, {"id": "env_d_ts", "label": "env.d.ts", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "env.d.ts", "community": 150, "community_name": "Community 150", "source_file": "env.d.ts", "file_type": "code", "degree": 0}, {"id": "shims_loose_d_ts", "label": "shims-loose.d.ts", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "shims-loose.d.ts", "community": 151, "community_name": "Community 151", "source_file": "shims-loose.d.ts", "file_type": "code", "degree": 0}, {"id": "directives_index_ts", "label": "index.ts", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "index.ts", "community": 35, "community_name": "Community 35", "source_file": "directives/index.ts", "file_type": "code", "degree": 1}, {"id": "directives_index_setupglobdirectives", "label": "setupGlobDirectives()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "setupGlobDirectives()", "community": 35, "community_name": "Community 35", "source_file": "directives/index.ts", "file_type": "code", "degree": 5}, {"id": "directives_core_roles_ts", "label": "roles.ts", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "roles.ts", "community": 35, "community_name": "Community 35", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 3}, {"id": "core_roles_checkrolepermission", "label": "checkRolePermission()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "checkRolePermission()", "community": 35, "community_name": "Community 35", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 2}, {"id": "core_roles_removeelement", "label": "removeElement()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "removeElement()", "community": 35, "community_name": "Community 35", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 2}, {"id": "core_roles_setuprolesdirective",Line truncated
|
||||
const RAW_EDGES = [{"from": "app_vue", "to": "src_app_refreshbuttonautoicons", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "app_vue", "to": "src_app_refreshpaneltitleicons", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "app_vue", "to": "main_ts", "label": "imports_from", "title": "imports_from [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "main_ts", "to": "utils_sys_console_ts", "label": "imports_from", "title": "imports_from [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "main_ts", "to": "src_main_masksecret", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "main_ts", "to": "src_main_logapicryptobootinfo", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "src_main_masksecret", "to": "src_main_logapicryptobootinfo", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_index_ts", "to": "directives_index_setupglobdirectives", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_index_setupglobdirectives", "to": "core_auth_setupauthdirective", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "directives_index_setupglobdirectives", "to": "core_roles_setuprolesdirective", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "directives_index_setupglobdirectives", "to": "business_highlight_setuphighlightdirective", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "directives_index_setupglobdirectives", "to": "business_ripple_setuprippledirective", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "directives_core_roles_ts", "to": "core_roles_checkrolepermission", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_roles_ts", "to": "core_roles_removeelement", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_roles_ts", "to": "core_roles_setuprolesdirective", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "core_roles_checkrolepermission", "to": "core_roles_removeelement", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_auth_ts", "to": "core_auth_checkauthpermission", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_auth_ts", "to": "core_auth_removeelement", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_core_auth_ts", "to": "core_auth_setupauthdirective", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "core_auth_checkauthpermission", "to": "core_auth_removeelement", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_business_ripple_ts", "to": "business_ripple_setuprippledirective", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_business_highlight_ts", "to": "business_highlight_highlightcode", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_business_highlight_ts", "to": "business_highlight_insertlinenumbers", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "directives_business_highlight_ts", "to": "business_highlight_addcopybutton", "label": "contains", "title": "contains [EXTRACTED]", "dLine truncated
|
||||
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "Community 0", "count": 587}, {"cid": 1, "color": "#F28E2B", "label": "Community 1", "count": 135}, {"cid": 2, "color": "#E15759", "label": "Community 2", "count": 133}, {"cid": 3, "color": "#76B7B2", "label": "Community 3", "count": 120}, {"cid": 4, "color": "#59A14F", "label": "Community 4", "count": 118}, {"cid": 5, "color": "#EDC948", "label": "Community 5", "count": 110}, {"cid": 6, "color": "#B07AA1", "label": "Community 6", "count": 106}, {"cid": 7, "color": "#FF9DA7", "label": "Community 7", "count": 103}, {"cid": 8, "color": "#9C755F", "label": "Community 8", "count": 94}, {"cid": 9, "color": "#BAB0AC", "label": "Community 9", "count": 86}, {"cid": 10, "color": "#4E79A7", "label": "Community 10", "count": 79}, {"cid": 11, "color": "#F28E2B", "label": "Community 11", "count": 77}, {"cid": 12, "color": "#E15759", "label": "Community 12", "count": 76}, {"cid": 13, "color": "#76B7B2", "label": "Community 13", "count": 75}, {"cid": 14, "color": "#59A14F", "label": "Community 14", "count": 73}, {"cid": 15, "color": "#EDC948", "label": "Community 15", "count": 68}, {"cid": 16, "color": "#B07AA1", "label": "Community 16", "count": 66}, {"cid": 17, "color": "#FF9DA7", "label": "Community 17", "count": 64}, {"cid": 18, "color": "#9C755F", "label": "Community 18", "count": 63}, {"cid": 19, "color": "#BAB0AC", "label": "Community 19", "count": 57}, {"cid": 20, "color": "#4E79A7", "label": "Community 20", "count": 53}, {"cid": 21, "color": "#F28E2B", "label": "Community 21", "count": 49}, {"cid": 22, "color": "#E15759", "label": "Community 22", "count": 47}, {"cid": 23, "color": "#76B7B2", "label": "Community 23", "count": 46}, {"cid": 24, "color": "#59A14F", "label": "Community 24", "count": 45}, {"cid": 25, "color": "#EDC948", "label": "Community 25", "count": 41}, {"cid": 26, "color": "#B07AA1", "label": "Community 26", "count": 39}, {"cid": 27, "color": "#FF9DA7", "label": "Community 27", "count": 37}, {"cid": 28, "color": "#9C755F", "label": "Community 28", "count": 37}, {"cid": 29, "color": "#BAB0AC", "label": "Community 29", "count": 36}, {"cid": 30, "color": "#4E79A7", "label": "Community 30", "count": 35}, {"cid": 31, "color": "#F28E2B", "label": "Community 31", "count": 35}, {"cid": 32, "color": "#E15759", "label": "Community 32", "count": 33}, {"cid": 33, "color": "#76B7B2", "label": "Community 33", "count": 25}, {"cid": 34, "color": "#59A14F", "label": "Community 34", "count": 24}, {"cid": 35, "color": "#EDC948", "label": "Community 35", "count": 22}, {"cid": 36, "color": "#B07AA1", "label": "Community 36", "count": 20}, {"cid": 37, "color": "#FF9DA7", "label": "Community 37", "count": 19}, {"cid": 38, "color": "#9C755F", "label": "Community 38", "count": 17}, {"cid": 39, "color": "#BAB0AC", "label": "Community 39", "count": 17}, {"cid": 40, "color": "#4E79A7", "label": "Community 40", "count": 17}, {"cid": 41, "color": "#F28E2B", "label": "Community 41", "count": 16}, {"cid": 42, "color": "#E15759", "label": "Community 42", "count": 14}, {"cid": 43, "color": "#76B7B2", "label": "Community 43", "count": 13}, {"cid": 44, "color": "#59A14F", "label": "Community 44", "count": 13}, {"cid": 45, "color": "#EDC948", "label": "Community 45", "count": 10}, {"cid": 46, "color": "#B07AA1", "label": "Community 46", "count": 9}, {"cid": 47, "color": "#FF9DA7", "label": "Community 47", "count": 9}, {"cid": 48, "color": "#9C755F", "label": "Community 48", "count": 9}, {"cid": 49, "color": "#BAB0AC", "label": "Community 49", "count": 9}, {"cid": 50, "color": "#4E79A7", "label": "Community 50", "count": 9}, {"cid": 51, "color": "#F28E2B", "label": "Community 51", "count": 9}, {"cid": 52, "color": "#E15759", "label": "Community 52", "count": 9}, {"cid": 53, "color": "#76B7B2", "label": "Community 53", "count": 8}, {"cid": 54, "color": "#59A14F", "label": "Community 54", "count": 8}, {"cid": 55, "color": "#EDC948", "label": "Community 55", "count": 8}, {"cid": 56, "color": "#B07AA1", "label": "Community 56", "count": 7}, {"cid": 57, "color": "#FF9DA7", "label": "Community 57", "count": 7}, {"cid": 58, "color": "#9C755F", "label": "Community 58", "count": 7}, {"cid": 59, "color": "#BAB0AC", "label": "Community 59", "count": 6}, {"cid": 60, "color": "#4E79A7", "label": "Community 60", "count": 6}, {"cid": 61, "color": "#F28E2B", "label": "Community 61", "count": 5}, {"cid": 62, "color": "#E15759", "label": "Community 62", "count": 5}, {"cid": 63, "color": "#76B7B2", "label": "Community 63", "count": 5}, {"cid": 64, "color": "#59A14F", "label": "Community 64", "count": 5}, {"cid": 65, "color": "#EDC948", "label": "Community 65", "count": 4}, {"cid": 66, "color": "#B07AA1", "label": "Community 66", "count": 4}, {"cid": 67, "color": "#FF9DA7", "label": "Community 67", "count": 4}, {"cid": 68, "color": "#9C755F", "label": "Community 68", "count": 4}, {"cid": 69, "color": "#BAB0AC", "label": "Community 69", "count": 4}, {"cid": 70, "color": "#4E79A7", "label": "CommuniLine truncated
|
||||
|
||||
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
|
||||
function esc(s) {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/index.ts": 1781272556.3597305,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/directive/directive.d.ts": 1781272556.3601494,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api/api.d.ts": 1781272556.3606477,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1789911088.6384528,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1789907807.4396596,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1790001353.553724,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1789979798.6372402,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/store/index.ts": 1787887312.735774,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/router/index.ts": 1787887320.8486114,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/composables/useAttachmentActions.ts": 1788162977.6691713,
|
||||
@@ -179,6 +179,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/chinese-datetime-parse.ts": 1782958233.483039,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/production-task-editor-cache.ts": 1782602746.754815,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-pay-mode-switch.ts": 1785145536.399596,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-progress-merge-comments.ts": 1789972067.7570305,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-resignation-kit-runtime.ts": 1785405017.8343015,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-progress-person-lines.ts": 1786600724.5366488,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-form-create-person.ts": 1785385009.8220263,
|
||||
@@ -344,6 +345,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaPostSelect.vue": 1785394386.0894492,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaFlowPreview.vue": 1785392987.9755206,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/oa-condition-fields.ts": 1786002784.7150648,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaCommentFileCards.vue": 1790001352.6569002,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaPersonSelect.vue": 1785388355.4534001,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaOvertimeKit.vue": 1786374594.648936,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue": 1786413858.4258535,
|
||||
@@ -354,11 +356,11 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaFcRow.vue": 1785145511.6907504,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaFormDesignerEmbed.vue": 1785145511.6925569,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaRelatedApproval.vue": 1786415204.9434047,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaProgressChain.vue": 1786600903.7654822,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue": 1789911136.0278683,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaProgressChain.vue": 1790001519.51572,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue": 1789961765.7574906,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaResignationKit.vue": 1785405000.8028867,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaConfirmationKit.vue": 1785403411.176012,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaCommentPanel.vue": 1789911096.5911582,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaCommentPanel.vue": 1790001441.110803,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaOwnerPersonSelect.vue": 1785401010.5990565,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaStartFormDialog.vue": 1786590587.9781795,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaDeptSelect.vue": 1785382264.265851,
|
||||
@@ -419,7 +421,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/api/cell-product.ts": 1785290097.6330564,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/api/sample-sms-notification.ts": 1781272556.666838,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production-waste-output.ts": 1787023802.604376,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/api/oaApproval.ts": 1789910946.122377,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/api/oaApproval.ts": 1790001321.5540254,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/api/material-stock-check.ts": 1781427280.4992144,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/api/research-donation.ts": 1782893816.502791,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-task.ts": 1783327864.9737554,
|
||||
@@ -508,12 +510,13 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/receive-accounts/index.vue": 1785391037.6431305,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/drafts/index.vue": 1785999332.6019115,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/start/index.vue": 1787887346.920489,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/mentions/index.vue": 1789972178.2175956,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/form-designer-spike/index.vue": 1785145511.870818,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/done/index.vue": 1786416827.7349942,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/history/index.vue": 1788163164.291932,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/mine/index.vue": 1786416848.7317674,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/designer/index.vue": 1786415778.485927,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/instances/detail.vue": 1789911134.5706506,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/instances/detail.vue": 1789961755.9750128,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/cc/index.vue": 1786416869.1902819,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/apps/index.vue": 1785993209.2907894,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/views/oa-approval/pending/index.vue": 1786416827.4376934,
|
||||
@@ -746,7 +749,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/RoutePermissionValidator.ts": 1781272559.4235668,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/index.ts": 1781272559.424284,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/IframeRouteManager.ts": 1781272559.425215,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts": 1789716949.8381162,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts": 1789972064.9112961,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/index.ts": 1781272559.5419338,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/exception.ts": 1781272559.549901,
|
||||
"/Users/wuzilong/work/00code/zkxw/web-admin/src/router/routes/staticRoutes.ts": 1781272559.596198,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mergeProgressWithComments } from '../src/utils/oa-progress-merge-comments'
|
||||
|
||||
const progress = [
|
||||
{
|
||||
key: 'submit',
|
||||
kind: 'submit',
|
||||
title: '发起人',
|
||||
status: 'submitted',
|
||||
timestamp: '2026-09-21 10:00:00'
|
||||
},
|
||||
{
|
||||
key: 'task-a',
|
||||
kind: 'task',
|
||||
title: '主管审批',
|
||||
status: 'approved',
|
||||
timestamp: '2026-09-21 10:05:00'
|
||||
},
|
||||
{
|
||||
key: 'task-b',
|
||||
kind: 'task',
|
||||
title: '经理审批',
|
||||
status: 'pending',
|
||||
timestamp: '2026-09-21 10:05:01'
|
||||
},
|
||||
{
|
||||
key: 'future',
|
||||
kind: 'task',
|
||||
title: '财务',
|
||||
status: 'not_reached',
|
||||
timestamp: ''
|
||||
}
|
||||
]
|
||||
|
||||
const comments = [
|
||||
{
|
||||
id: 'c1',
|
||||
content: '请补充材料',
|
||||
attachmentRefs: [],
|
||||
status: 'normal',
|
||||
createBy: 'u1',
|
||||
createByName: '张三',
|
||||
createTime: '2026-09-21 10:06:00',
|
||||
canRevoke: true
|
||||
}
|
||||
]
|
||||
|
||||
const out = mergeProgressWithComments(progress as any, comments as any)
|
||||
assert.equal(
|
||||
out.map((x) => x.key).join(','),
|
||||
'submit,task-a,comment-c1,task-b,future'
|
||||
)
|
||||
assert.equal(out[2].kind, 'comment')
|
||||
assert.equal(out[3].status, 'pending')
|
||||
assert.equal(out[4].status, 'not_reached')
|
||||
console.log('ok: mergeProgressWithComments')
|
||||
@@ -332,12 +332,18 @@ export function fetchOaInstanceDetail(id: string) {
|
||||
}
|
||||
|
||||
export type OaCommentAttachmentRef = {
|
||||
fieldKey: string
|
||||
source?: 'form' | 'upload' | string
|
||||
fieldKey?: string
|
||||
fieldLabel?: string
|
||||
url: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export type OaCommentMention = {
|
||||
userId: string
|
||||
userName: string
|
||||
}
|
||||
|
||||
export type OaInstanceComment = {
|
||||
id: string
|
||||
instanceId?: string
|
||||
@@ -349,6 +355,7 @@ export type OaInstanceComment = {
|
||||
createByName?: string
|
||||
createTime: string
|
||||
canRevoke?: boolean
|
||||
mentions?: OaCommentMention[]
|
||||
}
|
||||
|
||||
export function fetchOaFormAttachments(instanceId: string) {
|
||||
@@ -365,7 +372,11 @@ export function fetchOaInstanceComments(instanceId: string) {
|
||||
|
||||
export function fetchCreateOaComment(
|
||||
instanceId: string,
|
||||
data: { content: string; attachmentRefs?: OaCommentAttachmentRef[] }
|
||||
data: {
|
||||
content?: string
|
||||
attachmentRefs?: OaCommentAttachmentRef[]
|
||||
mentionUserIds?: string[]
|
||||
}
|
||||
) {
|
||||
return request.post<OaInstanceComment>({
|
||||
url: `${BASE}/instances/${instanceId}/comments`,
|
||||
@@ -380,7 +391,7 @@ export function fetchRevokeOaComment(instanceId: string, commentId: string) {
|
||||
}
|
||||
|
||||
export function fetchOaTasks(
|
||||
kind: 'pending' | 'done' | 'mine' | 'cc',
|
||||
kind: 'pending' | 'done' | 'mine' | 'cc' | 'mention',
|
||||
params?: {
|
||||
readStatus?: 'unread' | 'read' | 'all'
|
||||
appName?: string
|
||||
|
||||
@@ -1331,6 +1331,44 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: var(--mint-surface);
|
||||
border: 1px solid var(--mint-border);
|
||||
border-radius: var(--mint-radius);
|
||||
box-shadow: var(--mint-shadow);
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--mint-border);
|
||||
border-radius: var(--mint-radius);
|
||||
background: var(--mint-primary-bg);
|
||||
}
|
||||
|
||||
.detail-hero-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--mint-text);
|
||||
}
|
||||
|
||||
.detail-hero-meta {
|
||||
margin-top: 6px;
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
border-radius: var(--mint-radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 主列表页 + append-to-body 弹窗内嵌列表共用(OpenSpec:弹窗内列表风格一致) */
|
||||
.page-container,
|
||||
.console-container,
|
||||
.el-dialog {
|
||||
.list-toolbar,
|
||||
.toolbar {
|
||||
padding: 0;
|
||||
@@ -1402,39 +1440,6 @@ body {
|
||||
border-top: 1px solid var(--mint-border);
|
||||
background: var(--mint-surface);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: var(--mint-surface);
|
||||
border: 1px solid var(--mint-border);
|
||||
border-radius: var(--mint-radius);
|
||||
box-shadow: var(--mint-shadow);
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--mint-border);
|
||||
border-radius: var(--mint-radius);
|
||||
background: var(--mint-primary-bg);
|
||||
}
|
||||
|
||||
.detail-hero-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--mint-text);
|
||||
}
|
||||
|
||||
.detail-hero-meta {
|
||||
margin-top: 6px;
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
border-radius: var(--mint-radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
@@ -606,20 +606,25 @@ body .el-table thead {
|
||||
// 约定:列表操作列保持 fixed="right",并通过以下规则确保按钮完整展示
|
||||
.page-container .el-table td.el-table__cell:has(.btn-op-item),
|
||||
.console-container .el-table td.el-table__cell:has(.btn-op-item),
|
||||
.el-dialog .el-table td.el-table__cell:has(.btn-op-item),
|
||||
.page-container .el-table td.el-table-fixed-column--right:has(.btn-op-item),
|
||||
.console-container .el-table td.el-table-fixed-column--right:has(.btn-op-item) {
|
||||
.console-container .el-table td.el-table-fixed-column--right:has(.btn-op-item),
|
||||
.el-dialog .el-table td.el-table-fixed-column--right:has(.btn-op-item) {
|
||||
min-width: 260px !important;
|
||||
}
|
||||
|
||||
.page-container .el-table th.el-table-fixed-column--right,
|
||||
.console-container .el-table th.el-table-fixed-column--right {
|
||||
.console-container .el-table th.el-table-fixed-column--right,
|
||||
.el-dialog .el-table th.el-table-fixed-column--right {
|
||||
min-width: 260px !important;
|
||||
}
|
||||
|
||||
.page-container .el-table td.el-table__cell:has(.btn-op-item) > .cell,
|
||||
.console-container .el-table td.el-table__cell:has(.btn-op-item) > .cell,
|
||||
.el-dialog .el-table td.el-table__cell:has(.btn-op-item) > .cell,
|
||||
.page-container .el-table td.el-table-fixed-column--right:has(.btn-op-item) > .cell,
|
||||
.console-container .el-table td.el-table-fixed-column--right:has(.btn-op-item) > .cell {
|
||||
.console-container .el-table td.el-table-fixed-column--right:has(.btn-op-item) > .cell,
|
||||
.el-dialog .el-table td.el-table-fixed-column--right:has(.btn-op-item) > .cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -722,7 +727,8 @@ body .el-table thead {
|
||||
}
|
||||
|
||||
.page-container .el-table,
|
||||
.console-container .el-table {
|
||||
.console-container .el-table,
|
||||
.el-dialog .el-table {
|
||||
--el-table-header-bg-color: var(--mint-table-head);
|
||||
--el-table-border-color: var(--mint-border);
|
||||
--el-table-row-hover-bg-color: var(--mint-row-hover);
|
||||
@@ -732,14 +738,16 @@ body .el-table thead {
|
||||
}
|
||||
|
||||
.page-container .el-table th.el-table__cell,
|
||||
.console-container .el-table th.el-table__cell {
|
||||
.console-container .el-table th.el-table__cell,
|
||||
.el-dialog .el-table th.el-table__cell {
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-container .el-table td.el-table__cell,
|
||||
.console-container .el-table td.el-table__cell {
|
||||
.console-container .el-table td.el-table__cell,
|
||||
.el-dialog .el-table td.el-table__cell {
|
||||
color: var(--mint-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -747,7 +755,8 @@ body .el-table thead {
|
||||
|
||||
// 无操作列主列表:保持与含 btn-op-item 操作列相同的行高;须保留溢出省略(勿用 inline-flex)
|
||||
.page-container .list-table.list-table--no-action-col td.el-table__cell > .cell,
|
||||
.console-container .list-table.list-table--no-action-col td.el-table__cell > .cell {
|
||||
.console-container .list-table.list-table--no-action-col td.el-table__cell > .cell,
|
||||
.el-dialog .list-table.list-table--no-action-col td.el-table__cell > .cell {
|
||||
min-height: 28px;
|
||||
line-height: 28px;
|
||||
overflow: hidden;
|
||||
@@ -1001,7 +1010,8 @@ body .el-table thead {
|
||||
}
|
||||
|
||||
.page-container .btn-op-item.el-button,
|
||||
.console-container .btn-op-item.el-button {
|
||||
.console-container .btn-op-item.el-button,
|
||||
.el-dialog .btn-op-item.el-button {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
@@ -1011,14 +1021,16 @@ body .el-table thead {
|
||||
}
|
||||
|
||||
.page-container .el-button:not(.is-circle):not(.el-button--text),
|
||||
.console-container .el-button:not(.is-circle):not(.el-button--text) {
|
||||
.console-container .el-button:not(.is-circle):not(.el-button--text),
|
||||
.el-dialog .el-button:not(.is-circle):not(.el-button--text) {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-container .btn-op-item.el-button > span,
|
||||
.console-container .btn-op-item.el-button > span {
|
||||
.console-container .btn-op-item.el-button > span,
|
||||
.el-dialog .btn-op-item.el-button > span {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@@ -1048,7 +1060,8 @@ body .el-table thead {
|
||||
}
|
||||
|
||||
.page-container .btn-op-item.primary.el-button,
|
||||
.console-container .btn-op-item.primary.el-button {
|
||||
.console-container .btn-op-item.primary.el-button,
|
||||
.el-dialog .btn-op-item.primary.el-button {
|
||||
color: #fff;
|
||||
border-color: var(--mint-primary);
|
||||
background: var(--mint-primary);
|
||||
@@ -1056,8 +1069,10 @@ body .el-table thead {
|
||||
|
||||
.page-container .list-toolbar-row > .el-button:not(.el-button--text):not(.el-button--link),
|
||||
.console-container .list-toolbar-row > .el-button:not(.el-button--text):not(.el-button--link),
|
||||
.el-dialog .list-toolbar-row > .el-button:not(.el-button--text):not(.el-button--link),
|
||||
.page-container .toolbar-right > .el-button:not(.el-button--text):not(.el-button--link),
|
||||
.console-container .toolbar-right > .el-button:not(.el-button--text):not(.el-button--link) {
|
||||
.console-container .toolbar-right > .el-button:not(.el-button--text):not(.el-button--link),
|
||||
.el-dialog .toolbar-right > .el-button:not(.el-button--text):not(.el-button--link) {
|
||||
color: var(--mint-text);
|
||||
border-color: var(--mint-border);
|
||||
background: var(--mint-surface);
|
||||
@@ -1069,10 +1084,16 @@ body .el-table thead {
|
||||
.console-container
|
||||
.list-toolbar-row
|
||||
> .el-button:not(.el-button--text):not(.el-button--link):first-of-type,
|
||||
.el-dialog
|
||||
.list-toolbar-row
|
||||
> .el-button:not(.el-button--text):not(.el-button--link):first-of-type,
|
||||
.page-container
|
||||
.toolbar-right
|
||||
> .el-button:not(.el-button--text):not(.el-button--link):first-of-type,
|
||||
.console-container
|
||||
.toolbar-right
|
||||
> .el-button:not(.el-button--text):not(.el-button--link):first-of-type,
|
||||
.el-dialog
|
||||
.toolbar-right
|
||||
> .el-button:not(.el-button--text):not(.el-button--link):first-of-type {
|
||||
color: #fff;
|
||||
@@ -1081,7 +1102,8 @@ body .el-table thead {
|
||||
}
|
||||
|
||||
.page-container .el-table__cell .btn-op-item.el-button,
|
||||
.console-container .el-table__cell .btn-op-item.el-button {
|
||||
.console-container .el-table__cell .btn-op-item.el-button,
|
||||
.el-dialog .el-table__cell .btn-op-item.el-button {
|
||||
color: var(--mint-text);
|
||||
border-color: var(--mint-border);
|
||||
background: var(--mint-surface);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<!-- 评论附件缩略图:文字区分「引用 / 上传」;预览/下载/移除为图标 -->
|
||||
<template>
|
||||
<div class="oa-cmt-files">
|
||||
<div v-for="(f, idx) in files" :key="`${f.url}-${idx}`" class="oa-cmt-files__item">
|
||||
<div class="oa-cmt-files__thumb" :title="f.fileName || '附件'">
|
||||
<img v-if="isImage(f)" :src="fullUrl(f.url)" :alt="f.fileName || '附件'" />
|
||||
<el-icon v-else-if="isVideo(f)" :size="16"><VideoPlay /></el-icon>
|
||||
<el-icon v-else :size="16"><Document /></el-icon>
|
||||
<span class="oa-cmt-files__badge">{{ sourceLabel(f) }}</span>
|
||||
<span class="oa-cmt-files__actions">
|
||||
<button type="button" title="预览" aria-label="预览" @click.stop="preview(f)">
|
||||
<el-icon :size="14"><ZoomIn /></el-icon>
|
||||
</button>
|
||||
<button type="button" title="下载" aria-label="下载" @click.stop="download(f)">
|
||||
<el-icon :size="14"><Download /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
title="移除"
|
||||
aria-label="移除"
|
||||
@click.stop="emit('remove', idx)"
|
||||
>
|
||||
<el-icon :size="14"><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="oa-cmt-files__name" :title="f.fileName || '附件'">{{ f.fileName || '附件' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog
|
||||
v-model="previewOpen"
|
||||
title="预览"
|
||||
width="720px"
|
||||
append-to-body
|
||||
align-center
|
||||
modal-class="protocol-dialog-mask"
|
||||
>
|
||||
<img v-if="previewImage" class="oa-cmt-files__preview" :src="previewImage" alt="" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Delete, Document, Download, VideoPlay, ZoomIn } from '@element-plus/icons-vue'
|
||||
import type { OaCommentAttachmentRef } from '@/api/oaApproval'
|
||||
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import { isOaUploadImageFile, isOaUploadVideoFile } from '@/utils/oa-upload-file-type'
|
||||
|
||||
defineOptions({ name: 'OaCommentFileCards' })
|
||||
|
||||
defineProps<{
|
||||
files: OaCommentAttachmentRef[]
|
||||
removable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ remove: [index: number] }>()
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewImage = ref('')
|
||||
|
||||
function asFile(f: OaCommentAttachmentRef) {
|
||||
return { name: f.fileName, url: f.url }
|
||||
}
|
||||
|
||||
function isImage(f: OaCommentAttachmentRef) {
|
||||
return isOaUploadImageFile(asFile(f))
|
||||
}
|
||||
|
||||
function isVideo(f: OaCommentAttachmentRef) {
|
||||
return isOaUploadVideoFile(asFile(f))
|
||||
}
|
||||
|
||||
function fullUrl(url: string) {
|
||||
return attachmentFullUrl(url) || url
|
||||
}
|
||||
|
||||
function sourceLabel(f: OaCommentAttachmentRef) {
|
||||
return f.source === 'upload' ? '上传' : '引用'
|
||||
}
|
||||
|
||||
function preview(f: OaCommentAttachmentRef) {
|
||||
const url = fullUrl(f.url)
|
||||
if (!url) return
|
||||
if (isImage(f)) {
|
||||
previewImage.value = url
|
||||
previewOpen.value = true
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function download(f: OaCommentAttachmentRef) {
|
||||
void downloadAttachmentFile(
|
||||
{ fileUrl: f.url, fileName: f.fileName },
|
||||
{ fullFileUrl: (u) => attachmentFullUrl(u) }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-cmt-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.oa-cmt-files__item {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.oa-cmt-files__thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 88px;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&:hover .oa-cmt-files__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-cmt-files__badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
z-index: 1;
|
||||
padding: 0 4px;
|
||||
color: #334155;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.oa-cmt-files__actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: rgb(15 23 42 / 45%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oa-cmt-files__name {
|
||||
margin-top: 4px;
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.oa-cmt-files__preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +1,81 @@
|
||||
<!-- OA 审批详情 · 实例讨论评论(≠ 审批意见) -->
|
||||
<!-- OA 审批详情 · 评论输入(列表已并入审批进度时间线) -->
|
||||
<template>
|
||||
<section class="oa-comment-panel">
|
||||
<h3 class="oa-comment-panel__title">
|
||||
<el-icon class="oa-comment-panel__title-icon"><ChatDotRound /></el-icon>
|
||||
评论
|
||||
</h3>
|
||||
|
||||
<div v-if="!comments.length" class="oa-comment-panel__empty">暂无评论</div>
|
||||
<ul v-else class="oa-comment-panel__list">
|
||||
<li v-for="c in comments" :key="c.id" class="oa-comment-panel__item">
|
||||
<template v-if="c.status === 'revoked'">
|
||||
<span class="oa-comment-panel__revoked">
|
||||
{{ c.createByName || c.createBy }} 撤回了一条评论
|
||||
<span v-if="c.revokedTime" class="oa-comment-panel__time">{{ c.revokedTime }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="oa-comment-panel__meta">
|
||||
<strong>{{ c.createByName || c.createBy }}</strong>
|
||||
<span class="oa-comment-panel__time">{{ c.createTime }}</span>
|
||||
<el-button
|
||||
v-if="c.canRevoke"
|
||||
link
|
||||
type="danger"
|
||||
:loading="revokingId === c.id"
|
||||
@click="onRevoke(c)"
|
||||
>
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
撤回
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="oa-comment-panel__content">{{ c.content }}</div>
|
||||
<div v-if="c.attachmentRefs?.length" class="oa-comment-panel__refs">
|
||||
<el-tag
|
||||
v-for="(f, idx) in c.attachmentRefs"
|
||||
:key="`${c.id}-${idx}-${f.url}`"
|
||||
class="oa-comment-panel__ref"
|
||||
size="small"
|
||||
effect="plain"
|
||||
@click="openUrl(f.url)"
|
||||
>
|
||||
{{ f.fileName || f.url }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="instanceStatus === 'running'" class="oa-comment-panel__composer">
|
||||
<el-input
|
||||
v-model="draft"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
placeholder="填写评论"
|
||||
/>
|
||||
<div v-if="picked.length" class="oa-comment-panel__picked">
|
||||
<section v-if="instanceStatus === 'running'" class="oa-comment-panel">
|
||||
<div class="oa-comment-panel__composer">
|
||||
<div class="oa-comment-panel__editor">
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="draft"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
placeholder="填写评论,输入 @ 可提到人员"
|
||||
@focus="onDraftFocus"
|
||||
@input="onDraftInput"
|
||||
@keydown="onDraftKeydown"
|
||||
/>
|
||||
<div v-if="atOpen" class="oa-comment-at" v-loading="atLoading">
|
||||
<button
|
||||
v-for="(u, idx) in atOptions"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="oa-comment-at__item"
|
||||
:class="{ 'is-active': idx === atActive }"
|
||||
@mousedown.prevent="pickAtUser(u)"
|
||||
>
|
||||
<span class="oa-comment-at__name">{{ u.name }}</span>
|
||||
</button>
|
||||
<div v-if="!atLoading && !atOptions.length" class="oa-comment-at__empty">未找到人员</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mentionUsers.length" class="oa-comment-panel__picked">
|
||||
<el-tag
|
||||
v-for="(f, idx) in picked"
|
||||
:key="`${f.url}-${idx}`"
|
||||
v-for="u in mentionUsers"
|
||||
:key="u.id"
|
||||
closable
|
||||
size="small"
|
||||
effect="plain"
|
||||
@close="picked.splice(idx, 1)"
|
||||
@close="removeMention(u.id)"
|
||||
>
|
||||
{{ f.fileName || f.url }}
|
||||
@{{ u.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="picked.length" class="oa-comment-panel__files">
|
||||
<OaCommentFileCards :files="picked" removable @remove="removePicked" />
|
||||
</div>
|
||||
<div class="oa-comment-panel__actions">
|
||||
<el-button class="btn-ghost" @click="openMention">
|
||||
<el-icon><User /></el-icon>
|
||||
@人员
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="openAttach">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
引用附件
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :loading="uploading" @click="pickUpload">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传附件
|
||||
</el-button>
|
||||
<el-button type="primary" class="btn-main" :loading="sending" @click="onSend">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="oa-comment-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="OA_UPLOAD_ACCEPT"
|
||||
@change="onUploadFiles"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="attachVisible"
|
||||
title="引用表单附件"
|
||||
width="560px"
|
||||
width="640px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
@@ -95,21 +87,43 @@
|
||||
<div v-if="!attachLoading && !attachGroups.length" class="oa-comment-attach__empty">
|
||||
暂无可引用附件
|
||||
</div>
|
||||
<el-checkbox-group v-else v-model="selectedUrls" class="oa-comment-attach__groups">
|
||||
<div v-else class="oa-comment-attach__groups">
|
||||
<div v-for="g in attachGroups" :key="g.fieldKey" class="oa-comment-attach__group">
|
||||
<div class="oa-comment-attach__label">{{ g.fieldLabel || g.fieldKey }}</div>
|
||||
<div class="oa-comment-attach__files">
|
||||
<el-checkbox
|
||||
<button
|
||||
v-for="f in g.files"
|
||||
:key="f.url"
|
||||
:value="f.url"
|
||||
class="oa-comment-attach__file"
|
||||
type="button"
|
||||
class="oa-comment-attach__card"
|
||||
:class="{ 'is-selected': isSelected(f.url) }"
|
||||
:title="f.fileName || f.url"
|
||||
@click="toggleUrl(f.url)"
|
||||
>
|
||||
{{ f.fileName || f.url }}
|
||||
</el-checkbox>
|
||||
<div class="oa-comment-attach__thumb">
|
||||
<img
|
||||
v-if="isImageRef(f)"
|
||||
:src="thumbSrc(f.url)"
|
||||
:alt="f.fileName || '附件'"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isVideoRef(f)"
|
||||
class="oa-comment-attach__icon oa-comment-attach__icon--video"
|
||||
>
|
||||
<el-icon :size="16"><VideoPlay /></el-icon>
|
||||
</div>
|
||||
<div v-else class="oa-comment-attach__icon oa-comment-attach__icon--doc">
|
||||
<el-icon :size="16"><Document /></el-icon>
|
||||
</div>
|
||||
<span v-if="isSelected(f.url)" class="oa-comment-attach__check">
|
||||
<el-icon :size="12"><Check /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div class="oa-comment-attach__name">{{ f.fileName || f.url }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="attachVisible = false">
|
||||
@@ -127,46 +141,98 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="mentionVisible"
|
||||
title="提到人员"
|
||||
width="480px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-select
|
||||
v-model="mentionDraftIds"
|
||||
multiple
|
||||
filterable
|
||||
:loading="userLoading"
|
||||
placeholder="搜索姓名"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in dialogUserOptions"
|
||||
:key="u.id"
|
||||
:label="u.name"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
<template #footer>
|
||||
<el-button @click="mentionVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="confirmMention">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChatDotRound,
|
||||
Check,
|
||||
Close,
|
||||
Paperclip,
|
||||
Promotion,
|
||||
RefreshLeft
|
||||
} from '@element-plus/icons-vue'
|
||||
import { Check, Close, Document, Paperclip, Promotion, Upload, User, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { fetchUserOptions } from '@/api/admin'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
import {
|
||||
fetchCreateOaComment,
|
||||
fetchOaFormAttachments,
|
||||
fetchRevokeOaComment,
|
||||
type OaCommentAttachmentRef,
|
||||
type OaInstanceComment
|
||||
type OaCommentAttachmentRef
|
||||
} from '@/api/oaApproval'
|
||||
import OaCommentFileCards from '@/components/oa-approval/OaCommentFileCards.vue'
|
||||
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
|
||||
import { isOaUploadImageFile, isOaUploadVideoFile } from '@/utils/oa-upload-file-type'
|
||||
|
||||
defineOptions({ name: 'OaCommentPanel' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instanceId: string
|
||||
instanceStatus: string
|
||||
comments?: OaInstanceComment[]
|
||||
}>(),
|
||||
{
|
||||
comments: () => []
|
||||
}
|
||||
)
|
||||
type MentionUser = { id: string; name: string }
|
||||
|
||||
const props = defineProps<{
|
||||
instanceId: string
|
||||
instanceStatus: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
|
||||
const inputRef = ref<any>(null)
|
||||
const draft = ref('')
|
||||
const picked = ref<OaCommentAttachmentRef[]>([])
|
||||
const sending = ref(false)
|
||||
const revokingId = ref('')
|
||||
const uploading = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const OA_UPLOAD_ACCEPT = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar'
|
||||
const mentionUsers = ref<MentionUser[]>([])
|
||||
const mentionVisible = ref(false)
|
||||
const mentionDraftIds = ref<string[]>([])
|
||||
const userLoading = ref(false)
|
||||
|
||||
/** 租户内启用用户缓存:@ 与弹窗共用,本地过滤,避免每次请求 */
|
||||
const allMentionUsers = ref<MentionUser[]>([])
|
||||
let userCachePromise: Promise<void> | null = null
|
||||
|
||||
const dialogUserOptions = computed(() => {
|
||||
const map = new Map(allMentionUsers.value.map((u) => [u.id, u]))
|
||||
for (const u of mentionUsers.value) map.set(u.id, u)
|
||||
return [...map.values()]
|
||||
})
|
||||
|
||||
const atOpen = ref(false)
|
||||
const atQuery = ref('')
|
||||
const atStart = ref(-1)
|
||||
const atActive = ref(0)
|
||||
const atOptions = ref<MentionUser[]>([])
|
||||
const atLoading = ref(false)
|
||||
|
||||
const attachVisible = ref(false)
|
||||
const attachLoading = ref(false)
|
||||
@@ -175,15 +241,238 @@
|
||||
>([])
|
||||
const selectedUrls = ref<string[]>([])
|
||||
|
||||
function openUrl(url: string) {
|
||||
const full = attachmentFullUrl(url) || url
|
||||
if (full) window.open(full, '_blank')
|
||||
function getTextareaEl(): HTMLTextAreaElement | null {
|
||||
const inst = inputRef.value
|
||||
if (!inst) return null
|
||||
if (inst.textarea instanceof HTMLTextAreaElement) return inst.textarea
|
||||
const el = inst.$el as HTMLElement | undefined
|
||||
return (el?.querySelector?.('textarea') as HTMLTextAreaElement | null) || null
|
||||
}
|
||||
|
||||
async function ensureUserCache() {
|
||||
if (allMentionUsers.value.length) return
|
||||
if (userCachePromise) return userCachePromise
|
||||
userCachePromise = (async () => {
|
||||
try {
|
||||
const res = await fetchUserOptions()
|
||||
const list = Array.isArray(res) ? res : []
|
||||
const next: MentionUser[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const u of list) {
|
||||
const id = String(u?.value || '').trim()
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const name = String(u?.label || id).trim() || id
|
||||
next.push({ id, name })
|
||||
}
|
||||
allMentionUsers.value = next
|
||||
} catch {
|
||||
allMentionUsers.value = []
|
||||
} finally {
|
||||
userCachePromise = null
|
||||
}
|
||||
})()
|
||||
return userCachePromise
|
||||
}
|
||||
|
||||
function filterMentionUsers(keyword: string, excludeIds?: Set<string>): MentionUser[] {
|
||||
const q = (keyword || '').trim().toLowerCase()
|
||||
const out: MentionUser[] = []
|
||||
for (const u of allMentionUsers.value) {
|
||||
if (excludeIds?.has(u.id)) continue
|
||||
if (q && !u.name.toLowerCase().includes(q) && !u.id.toLowerCase().includes(q)) {
|
||||
continue
|
||||
}
|
||||
out.push(u)
|
||||
if (out.length >= 30) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function closeAtSuggest() {
|
||||
atOpen.value = false
|
||||
atQuery.value = ''
|
||||
atStart.value = -1
|
||||
atActive.value = 0
|
||||
atOptions.value = []
|
||||
}
|
||||
|
||||
async function applyAtSuggest(query: string) {
|
||||
const needFetch = !allMentionUsers.value.length
|
||||
if (needFetch) atLoading.value = true
|
||||
try {
|
||||
await ensureUserCache()
|
||||
if (!atOpen.value) return
|
||||
const pickedIds = new Set(mentionUsers.value.map((u) => u.id))
|
||||
atOptions.value = filterMentionUsers(query, pickedIds)
|
||||
if (atActive.value >= atOptions.value.length) {
|
||||
atActive.value = Math.max(0, atOptions.value.length - 1)
|
||||
}
|
||||
} finally {
|
||||
atLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function detectAtSuggest() {
|
||||
const ta = getTextareaEl()
|
||||
if (!ta) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const pos = ta.selectionStart ?? 0
|
||||
const before = draft.value.slice(0, pos)
|
||||
const matched = before.match(/(?:^|[\s\n])(@([^\s@]*))$/)
|
||||
if (!matched) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const token = matched[1] || ''
|
||||
const query = matched[2] || ''
|
||||
atStart.value = before.length - token.length
|
||||
atQuery.value = query
|
||||
atOpen.value = true
|
||||
atActive.value = 0
|
||||
// 有缓存则同步过滤;无缓存才拉一次
|
||||
if (allMentionUsers.value.length) {
|
||||
const pickedIds = new Set(mentionUsers.value.map((u) => u.id))
|
||||
atOptions.value = filterMentionUsers(query, pickedIds)
|
||||
} else {
|
||||
void applyAtSuggest(query)
|
||||
}
|
||||
}
|
||||
|
||||
function onDraftInput() {
|
||||
nextTick(() => detectAtSuggest())
|
||||
}
|
||||
|
||||
function onDraftFocus() {
|
||||
void ensureUserCache()
|
||||
}
|
||||
|
||||
function onDraftKeydown(e: KeyboardEvent) {
|
||||
if (!atOpen.value) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!atOptions.value.length) return
|
||||
atActive.value = (atActive.value + 1) % atOptions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!atOptions.value.length) return
|
||||
atActive.value = (atActive.value - 1 + atOptions.value.length) % atOptions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
const u = atOptions.value[atActive.value]
|
||||
if (!u) return
|
||||
e.preventDefault()
|
||||
pickAtUser(u)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeAtSuggest()
|
||||
}
|
||||
}
|
||||
|
||||
function addMentionUser(u: MentionUser) {
|
||||
if (mentionUsers.value.some((x) => x.id === u.id)) return true
|
||||
if (mentionUsers.value.length >= 20) {
|
||||
ElMessage.warning('一次最多@20人')
|
||||
return false
|
||||
}
|
||||
mentionUsers.value.push(u)
|
||||
return true
|
||||
}
|
||||
|
||||
function pickAtUser(u: MentionUser) {
|
||||
if (!addMentionUser(u)) return
|
||||
const start = atStart.value
|
||||
if (start < 0) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const ta = getTextareaEl()
|
||||
const end = ta?.selectionStart ?? draft.value.length
|
||||
const insert = `@${u.name} `
|
||||
draft.value = `${draft.value.slice(0, start)}${insert}${draft.value.slice(end)}`
|
||||
closeAtSuggest()
|
||||
nextTick(() => {
|
||||
const el = getTextareaEl()
|
||||
if (!el) return
|
||||
const pos = start + insert.length
|
||||
el.focus()
|
||||
el.setSelectionRange(pos, pos)
|
||||
})
|
||||
}
|
||||
|
||||
function fileLike(f: OaCommentAttachmentRef) {
|
||||
return { url: f.url, name: f.fileName }
|
||||
}
|
||||
|
||||
function isImageRef(f: OaCommentAttachmentRef) {
|
||||
return isOaUploadImageFile(fileLike(f))
|
||||
}
|
||||
|
||||
function isVideoRef(f: OaCommentAttachmentRef) {
|
||||
return isOaUploadVideoFile(fileLike(f))
|
||||
}
|
||||
|
||||
function thumbSrc(url: string) {
|
||||
return attachmentFullUrl(url) || url
|
||||
}
|
||||
|
||||
function isSelected(url: string) {
|
||||
return selectedUrls.value.includes(url)
|
||||
}
|
||||
|
||||
function toggleUrl(url: string) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return
|
||||
const idx = selectedUrls.value.indexOf(u)
|
||||
if (idx >= 0) selectedUrls.value.splice(idx, 1)
|
||||
else selectedUrls.value.push(u)
|
||||
}
|
||||
|
||||
function removeMention(id: string) {
|
||||
mentionUsers.value = mentionUsers.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
async function openMention() {
|
||||
closeAtSuggest()
|
||||
mentionDraftIds.value = mentionUsers.value.map((u) => u.id)
|
||||
mentionVisible.value = true
|
||||
userLoading.value = !allMentionUsers.value.length
|
||||
try {
|
||||
await ensureUserCache()
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmMention() {
|
||||
const map = new Map(allMentionUsers.value.map((u) => [u.id, u.name]))
|
||||
for (const u of mentionUsers.value) map.set(u.id, u.name)
|
||||
const next = mentionDraftIds.value
|
||||
.map((id) => ({ id, name: map.get(id) || id }))
|
||||
.slice(0, 20)
|
||||
mentionUsers.value = next
|
||||
for (const u of next) {
|
||||
const token = `@${u.name}`
|
||||
if (!draft.value.includes(token)) {
|
||||
draft.value = `${draft.value.trimEnd()}${draft.value.trim() ? ' ' : ''}${token} `
|
||||
}
|
||||
}
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
async function openAttach() {
|
||||
attachVisible.value = true
|
||||
attachLoading.value = true
|
||||
selectedUrls.value = picked.value.map((f) => f.url)
|
||||
selectedUrls.value = picked.value
|
||||
.filter((f) => f.source !== 'upload')
|
||||
.map((f) => f.url)
|
||||
try {
|
||||
const res = await fetchOaFormAttachments(props.instanceId)
|
||||
const items = res?.items || []
|
||||
@@ -206,29 +495,76 @@
|
||||
if (f?.url) map.set(f.url, f)
|
||||
}
|
||||
}
|
||||
const next: OaCommentAttachmentRef[] = []
|
||||
const cited: OaCommentAttachmentRef[] = []
|
||||
for (const url of selectedUrls.value) {
|
||||
const found = map.get(url) || picked.value.find((p) => p.url === url)
|
||||
if (found) next.push({ ...found })
|
||||
const found = map.get(url) || picked.value.find((p) => p.url === url && p.source !== 'upload')
|
||||
if (found) cited.push({ ...found, source: 'form' })
|
||||
}
|
||||
picked.value = next
|
||||
const uploads = picked.value.filter((f) => f.source === 'upload')
|
||||
picked.value = [...cited, ...uploads]
|
||||
attachVisible.value = false
|
||||
}
|
||||
|
||||
function removePicked(index: number) {
|
||||
picked.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function pickUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onUploadFiles(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const files = Array.from(input.files || [])
|
||||
input.value = ''
|
||||
if (!files.length) return
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('folder', 'oa-approval')
|
||||
const res: any = await fetchUploadAttachment(fd)
|
||||
const url = String(res?.fileUrl || res?.data?.fileUrl || '').trim()
|
||||
if (!url) {
|
||||
ElMessage.error(`${file.name || '文件'} 上传失败`)
|
||||
continue
|
||||
}
|
||||
if (picked.value.some((p) => p.url === url)) continue
|
||||
picked.value.push({
|
||||
source: 'upload',
|
||||
fieldKey: '',
|
||||
fieldLabel: '',
|
||||
url,
|
||||
fileName: String(res?.fileName || res?.data?.fileName || file.name || '').trim() || file.name
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSend() {
|
||||
const content = draft.value.trim()
|
||||
if (!content) {
|
||||
ElMessage.warning('请填写评论')
|
||||
if (!content && !mentionUsers.value.length && !picked.value.length) {
|
||||
ElMessage.warning('请填写评论、@人员或添加附件')
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
await fetchCreateOaComment(props.instanceId, {
|
||||
content,
|
||||
attachmentRefs: picked.value.length ? [...picked.value] : undefined
|
||||
attachmentRefs: picked.value.length ? [...picked.value] : undefined,
|
||||
mentionUserIds: mentionUsers.value.length
|
||||
? mentionUsers.value.map((u) => u.id)
|
||||
: undefined
|
||||
})
|
||||
draft.value = ''
|
||||
picked.value = []
|
||||
mentionUsers.value = []
|
||||
closeAtSuggest()
|
||||
ElMessage.success('已发送')
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
@@ -238,101 +574,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevoke(c: OaInstanceComment) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定撤回该评论吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
revokingId.value = c.id
|
||||
try {
|
||||
await fetchRevokeOaComment(props.instanceId, c.id)
|
||||
ElMessage.success('已撤回')
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '撤回失败')
|
||||
} finally {
|
||||
revokingId.value = ''
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
void ensureUserCache()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-comment-panel {
|
||||
max-width: 960px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__title {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 16px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mint-text, #0f172a);
|
||||
.oa-comment-file-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.oa-comment-panel__title-icon {
|
||||
color: var(--primary, #10b981);
|
||||
font-size: 16px;
|
||||
.oa-comment-panel__files {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__empty {
|
||||
padding: 8px 0 12px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.oa-comment-panel__item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--mint-border, #e2e8f0);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-panel__revoked {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oa-comment-panel__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
strong {
|
||||
color: var(--mint-text-strong, #0f172a);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-panel__time {
|
||||
margin-left: 4px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__content {
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.oa-comment-panel__refs,
|
||||
.oa-comment-panel__picked {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -340,29 +604,65 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__ref {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-dark, #059669);
|
||||
border-color: #a7f3d0;
|
||||
background: var(--primary-bg, #ecfdf5);
|
||||
}
|
||||
.oa-comment-panel__composer {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__composer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--mint-border, #e2e8f0);
|
||||
.oa-comment-panel__editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.oa-comment-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.oa-comment-at {
|
||||
position: absolute;
|
||||
top: calc(100% - 28px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
width: min(320px, 100%);
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.oa-comment-at__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: var(--primary-bg, #ecfdf5);
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-at__name {
|
||||
color: var(--mint-text-strong, #0f172a);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.oa-comment-at__empty {
|
||||
padding: 12px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oa-comment-attach {
|
||||
min-height: 80px;
|
||||
}
|
||||
@@ -376,26 +676,110 @@
|
||||
|
||||
.oa-comment-attach__groups {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oa-comment-attach__label {
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--mint-text-strong, #0f172a);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.oa-comment-attach__files {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.oa-comment-attach__file {
|
||||
margin-right: 0;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
align-items: flex-start;
|
||||
.oa-comment-attach__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 88px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #a7f3d0;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: var(--primary, #10b981);
|
||||
box-shadow: 0 0 0 1px var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-attach__thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-attach__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
|
||||
&--video {
|
||||
color: #7c3aed;
|
||||
background: #ede9fe;
|
||||
}
|
||||
|
||||
&--doc {
|
||||
color: #0284c7;
|
||||
background: #e0f2fe;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-attach__check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #fff;
|
||||
background: var(--primary, #10b981);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.oa-comment-attach__name {
|
||||
display: -webkit-box;
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
overflow: hidden;
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -39,8 +39,11 @@
|
||||
<img
|
||||
class="el-upload-list__item-thumbnail"
|
||||
:src="attachmentFullUrl(file.url || '') || file.url"
|
||||
alt=""
|
||||
:alt="getOaUploadDisplayName(file)"
|
||||
/>
|
||||
<div class="oa-fc-upload-card__name is-on-image" :title="getOaUploadDisplayName(file)">
|
||||
{{ getOaUploadDisplayName(file) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isOaUploadVideoFile(file)">
|
||||
<div class="oa-fc-upload-card__icon oa-fc-upload-card__icon--video">
|
||||
@@ -130,22 +133,64 @@
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
function getFileName(file: string): string {
|
||||
return String(file).split('/').pop() || String(file)
|
||||
function basenameOf(url: string): string {
|
||||
const path = String(url || '').split('?')[0]
|
||||
const last = path.split('/').pop() || ''
|
||||
try {
|
||||
return decodeURIComponent(last)
|
||||
} catch {
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
function parseFile(file: unknown, i: number): UploadUserFile & { is_string?: boolean } {
|
||||
/** 表单值里的地址:上传成功后的服务端 URL,不用本地 blob */
|
||||
function pickStoredUrl(file: any): string {
|
||||
const url = String(file?.url || file?.fileUrl || '').trim()
|
||||
if (!url || url.startsWith('blob:')) return ''
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 回显名优先用户本地原始文件名。
|
||||
* 旧数据只有 URL 时,退回路径最后一段(通常是存储 UUID)。
|
||||
*/
|
||||
function pickStoredName(file: any, url: string): string {
|
||||
const raw = String(file?.name || file?.fileName || file?.raw?.name || '').trim()
|
||||
if (raw && raw !== url && !raw.startsWith('blob:') && !/^https?:\/\//i.test(raw)) {
|
||||
const base = raw.includes('/') ? raw.split('/').pop() || raw : raw
|
||||
if (base) return base
|
||||
}
|
||||
return basenameOf(url) || '未命名文件'
|
||||
}
|
||||
|
||||
function serializeUploadValue(file: any): { url: string; name: string } | undefined {
|
||||
const url = pickStoredUrl(file)
|
||||
if (!url) return undefined
|
||||
return { url, name: pickStoredName(file, url) }
|
||||
}
|
||||
|
||||
function parseFile(file: unknown, i: number): UploadUserFile {
|
||||
if (typeof file === 'string') {
|
||||
const url = file
|
||||
return {
|
||||
url,
|
||||
name: basenameOf(url) || '未命名文件',
|
||||
uid: i,
|
||||
status: 'success'
|
||||
}
|
||||
}
|
||||
if (file && typeof file === 'object') {
|
||||
return file as UploadUserFile
|
||||
}
|
||||
const url = String(file || '')
|
||||
return {
|
||||
url,
|
||||
name: getFileName(url),
|
||||
uid: i,
|
||||
status: 'success',
|
||||
is_string: true
|
||||
const url = pickStoredUrl(file)
|
||||
if (!url) return file as UploadUserFile
|
||||
const src = file as UploadUserFile
|
||||
return {
|
||||
url,
|
||||
name: pickStoredName(file, url),
|
||||
uid: src.uid ?? i,
|
||||
status: src.status || 'success'
|
||||
}
|
||||
}
|
||||
return { url: '', name: '未命名文件', uid: i, status: 'success' }
|
||||
}
|
||||
|
||||
function parseUpload(file: UploadUserFile): UploadUserFile & {
|
||||
@@ -273,10 +318,8 @@
|
||||
{ fullFileUrl: (u) => attachmentFullUrl(u) }
|
||||
)
|
||||
},
|
||||
update(list: Array<UploadUserFile & { is_string?: boolean; value?: unknown; url?: string }>) {
|
||||
const files = list
|
||||
.map((v) => (v.is_string ? v.url : (v.value as any) || v.url))
|
||||
.filter((url) => url !== undefined)
|
||||
update(list: Array<UploadUserFile & { url?: string; name?: string }>) {
|
||||
const files = list.map((v) => serializeUploadValue(v)).filter(Boolean)
|
||||
this.$emit('update:modelValue', files)
|
||||
},
|
||||
handleChange(file: UploadFile, list: UploadFile[]) {
|
||||
@@ -310,6 +353,7 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-fc-upload-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -365,5 +409,17 @@
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
word-break: break-all;
|
||||
|
||||
&.is-on-image {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin-top: 0;
|
||||
padding: 10px 4px 4px;
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0), rgba(15, 23, 42, 0.72));
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,18 +33,20 @@
|
||||
<pre v-else-if="!loading" class="oa-detail-dlg__json">{{ prettyForm }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
<section class="oa-detail-dlg__section">
|
||||
<section class="oa-detail-dlg__section oa-detail-dlg__section--progress">
|
||||
<OaProgressChain
|
||||
v-if="detail?.id"
|
||||
:items="timelineItems"
|
||||
:instance-id="detail.id"
|
||||
@refresh="load"
|
||||
/>
|
||||
<OaCommentPanel
|
||||
v-if="detail?.id"
|
||||
:instance-id="detail.id"
|
||||
:instance-status="detail.status || ''"
|
||||
:comments="detail.comments || []"
|
||||
@refresh="load"
|
||||
/>
|
||||
</section>
|
||||
<section class="oa-detail-dlg__section oa-detail-dlg__section--progress">
|
||||
<OaProgressChain :items="timelineItems" />
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
@@ -62,6 +64,7 @@
|
||||
import { fetchOaInstanceDetail } from '@/api/oaApproval'
|
||||
import OaCommentPanel from '@/components/oa-approval/OaCommentPanel.vue'
|
||||
import OaProgressChain from '@/components/oa-approval/OaProgressChain.vue'
|
||||
import { mergeProgressWithComments } from '@/utils/oa-progress-merge-comments'
|
||||
import {
|
||||
OA_FC_DEFAULT_GUTTER,
|
||||
ensureOaUploadRuleProps
|
||||
@@ -98,8 +101,10 @@
|
||||
|
||||
const timelineItems = computed(() => {
|
||||
const list = detail.value?.progress
|
||||
if (!Array.isArray(list) || !list.length) return []
|
||||
return list
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return mergeProgressWithComments([], detail.value?.comments || [])
|
||||
}
|
||||
const progressMapped = list
|
||||
.filter((p: any) => {
|
||||
const kind = String(p?.kind || '')
|
||||
const status = String(p?.status || '')
|
||||
@@ -132,6 +137,7 @@
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
return mergeProgressWithComments(progressMapped, detail.value?.comments || [])
|
||||
})
|
||||
|
||||
function statusType(s: string) {
|
||||
|
||||
@@ -11,70 +11,105 @@
|
||||
<div class="oa-flow-dot" :class="dotClass(item)" />
|
||||
<div v-if="idx < items.length - 1" class="oa-flow-line" />
|
||||
</div>
|
||||
<div class="oa-flow-card">
|
||||
<div class="oa-flow-card__row">
|
||||
<span class="oa-flow-card__name">{{ item.title }}</span>
|
||||
<span v-if="statusBadge(item)" class="oa-flow-badge" :class="statusBadge(item)">
|
||||
{{ nodeStatusText(item) }}
|
||||
</span>
|
||||
<span v-if="signBadge(item)" class="oa-flow-badge" :class="signBadge(item)">
|
||||
{{ signBadgeText(item) }}
|
||||
</span>
|
||||
<span v-else-if="item.taskType === 'cc'" class="oa-flow-badge cc">抄送</span>
|
||||
<span v-else-if="item.taskType === 'end'" class="oa-flow-badge mute">结束</span>
|
||||
<template v-if="showNodeTimestamp(item)">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="item.timestamp">{{ item.timestamp }}</span>
|
||||
<div class="oa-flow-card" :class="{ 'is-comment': item.kind === 'comment' }">
|
||||
<!-- 讨论评论(钉钉式时间线条目) -->
|
||||
<template v-if="item.kind === 'comment'">
|
||||
<div v-if="item.status === 'revoked'" class="oa-flow-comment-revoked">
|
||||
{{ item.title }} 撤回了一条评论
|
||||
<span v-if="item.revokedTime" class="oa-flow-card__meta">{{ item.revokedTime }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="oa-flow-card__row">
|
||||
<span class="oa-flow-card__name">{{ item.title }}</span>
|
||||
<span class="oa-flow-badge cc">评论</span>
|
||||
<template v-if="showNodeTimestamp(item)">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="item.timestamp">{{ item.timestamp }}</span>
|
||||
</template>
|
||||
<el-button
|
||||
v-if="item.canRevoke && item.commentId"
|
||||
class="oa-flow-comment-revoke"
|
||||
link
|
||||
type="danger"
|
||||
:loading="revokingId === item.commentId"
|
||||
@click="onRevokeComment(item)"
|
||||
>
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
撤回
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="item.commentText" class="oa-flow-card__comment oa-flow-card__comment--body">
|
||||
{{ item.commentText }}
|
||||
</div>
|
||||
<div v-if="mentionText(item)" class="oa-flow-comment-mentions">提到:{{ mentionText(item) }}</div>
|
||||
<OaCommentFileCards v-if="item.attachmentRefs?.length" :files="item.attachmentRefs" />
|
||||
</template>
|
||||
<template v-if="item.resolveSource && item.kind === 'task'">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="resolveSourceText(item.resolveSource)">
|
||||
{{ resolveSourceText(item.resolveSource) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="oa-flow-card__row">
|
||||
<span class="oa-flow-card__name">{{ item.title }}</span>
|
||||
<span v-if="statusBadge(item)" class="oa-flow-badge" :class="statusBadge(item)">
|
||||
{{ nodeStatusText(item) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="displayPeople(item).length && item.kind !== 'terminal'"
|
||||
class="oa-flow-card__people"
|
||||
>
|
||||
<span v-if="signBadge(item)" class="oa-flow-badge" :class="signBadge(item)">
|
||||
{{ signBadgeText(item) }}
|
||||
</span>
|
||||
<span v-else-if="item.taskType === 'cc'" class="oa-flow-badge cc">抄送</span>
|
||||
<span v-else-if="item.taskType === 'end'" class="oa-flow-badge mute">结束</span>
|
||||
<template v-if="showNodeTimestamp(item)">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="item.timestamp">{{ item.timestamp }}</span>
|
||||
</template>
|
||||
<template v-if="item.resolveSource && item.kind === 'task'">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="resolveSourceText(item.resolveSource)">
|
||||
{{ resolveSourceText(item.resolveSource) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-for="p in displayPeople(item)"
|
||||
:key="p.id || p.nickName"
|
||||
class="oa-person-chip"
|
||||
:class="personChipClass(p.status)"
|
||||
:title="personTitle(p)"
|
||||
v-if="displayPeople(item).length && item.kind !== 'terminal'"
|
||||
class="oa-flow-card__people"
|
||||
>
|
||||
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
|
||||
{{ p.nickName }}
|
||||
<span v-if="personStatusHint(p.status)" class="oa-person-chip__st">
|
||||
{{ personStatusHint(p.status) }}
|
||||
<span
|
||||
v-for="p in displayPeople(item)"
|
||||
:key="p.id || p.nickName"
|
||||
class="oa-person-chip"
|
||||
:class="personChipClass(p.status)"
|
||||
:title="personTitle(p)"
|
||||
>
|
||||
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
|
||||
{{ p.nickName }}
|
||||
<span v-if="personStatusHint(p.status)" class="oa-person-chip__st">
|
||||
{{ personStatusHint(p.status) }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="personLines(item).length" class="oa-person-lines">
|
||||
<p
|
||||
v-for="(line, li) in personLines(item)"
|
||||
:key="`${item.key}-pl-${li}`"
|
||||
class="oa-person-line"
|
||||
:class="{
|
||||
'is-ok': line.tone === 'ok',
|
||||
'is-pending': line.tone === 'pending',
|
||||
'is-danger': line.tone === 'danger',
|
||||
'is-mute': line.tone === 'mute'
|
||||
}"
|
||||
</div>
|
||||
<div v-if="personLines(item).length" class="oa-person-lines">
|
||||
<p
|
||||
v-for="(line, li) in personLines(item)"
|
||||
:key="`${item.key}-pl-${li}`"
|
||||
class="oa-person-line"
|
||||
:class="{
|
||||
'is-ok': line.tone === 'ok',
|
||||
'is-pending': line.tone === 'pending',
|
||||
'is-danger': line.tone === 'danger',
|
||||
'is-mute': line.tone === 'mute'
|
||||
}"
|
||||
>
|
||||
{{ line.text }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.kind === 'terminal' && item.status === 'cancelled' && terminalWithdrawText(item)"
|
||||
class="oa-person-lines"
|
||||
>
|
||||
{{ line.text }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.kind === 'terminal' && item.status === 'cancelled' && terminalWithdrawText(item)"
|
||||
class="oa-person-lines"
|
||||
>
|
||||
<p class="oa-person-line is-danger">{{ terminalWithdrawText(item) }}</p>
|
||||
</div>
|
||||
<div v-else-if="showNodeComment(item)" class="oa-flow-card__comment">
|
||||
意见:{{ item.commentText }}
|
||||
</div>
|
||||
<p class="oa-person-line is-danger">{{ terminalWithdrawText(item) }}</p>
|
||||
</div>
|
||||
<div v-else-if="showNodeComment(item)" class="oa-flow-card__comment">
|
||||
意见:{{ item.commentText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,6 +117,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { fetchRevokeOaComment, type OaCommentAttachmentRef } from '@/api/oaApproval'
|
||||
import OaCommentFileCards from '@/components/oa-approval/OaCommentFileCards.vue'
|
||||
import {
|
||||
formatProgressPersonLines,
|
||||
type ProgressPersonLine
|
||||
@@ -112,17 +150,61 @@
|
||||
kind?: string
|
||||
signMode?: string
|
||||
people?: OaProgressPerson[]
|
||||
/** kind=comment */
|
||||
attachmentRefs?: OaCommentAttachmentRef[]
|
||||
canRevoke?: boolean
|
||||
commentId?: string
|
||||
commentStatus?: string
|
||||
revokedTime?: string
|
||||
mentions?: Array<{ userId: string; userName: string }>
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items?: OaProgressChainItem[]
|
||||
/** 用于进度内撤回评论 */
|
||||
instanceId?: string
|
||||
}>(),
|
||||
{
|
||||
items: () => []
|
||||
items: () => [],
|
||||
instanceId: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const revokingId = ref('')
|
||||
|
||||
function mentionText(item: OaProgressChainItem) {
|
||||
const names = (item.mentions || [])
|
||||
.map((m) => String(m.userName || m.userId || '').trim())
|
||||
.filter(Boolean)
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
async function onRevokeComment(item: OaProgressChainItem) {
|
||||
const id = String(item.commentId || '').trim()
|
||||
const instanceId = String(props.instanceId || '').trim()
|
||||
if (!id || !instanceId) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确定撤回该评论吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
revokingId.value = id
|
||||
try {
|
||||
await fetchRevokeOaComment(instanceId, id)
|
||||
ElMessage.success('已撤回')
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '撤回失败')
|
||||
} finally {
|
||||
revokingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
@@ -135,7 +217,9 @@
|
||||
submitted: '已提交',
|
||||
not_reached: '未到达',
|
||||
returned: '已打回',
|
||||
transferred: '已转交'
|
||||
transferred: '已转交',
|
||||
commented: '评论',
|
||||
revoked: '已撤回'
|
||||
}
|
||||
return map[s] || s
|
||||
}
|
||||
@@ -187,7 +271,7 @@
|
||||
}
|
||||
|
||||
function personLines(item: OaProgressChainItem): ProgressPersonLine[] {
|
||||
if (item.kind === 'terminal' || item.kind === 'submit') return []
|
||||
if (item.kind === 'terminal' || item.kind === 'submit' || item.kind === 'comment') return []
|
||||
if (item.taskType === 'cc' || item.signMode === 'cc') return []
|
||||
const list = displayPeople(item)
|
||||
if (!list.length) return []
|
||||
@@ -195,6 +279,7 @@
|
||||
}
|
||||
|
||||
function showNodeComment(item: OaProgressChainItem): boolean {
|
||||
if (item.kind === 'comment') return false
|
||||
// 有按人行时不再展示节点级拼接意见,避免双份
|
||||
if (personLines(item).length) return false
|
||||
if (item.kind === 'terminal' && item.status === 'cancelled') return false
|
||||
@@ -252,6 +337,10 @@
|
||||
|
||||
function dotClass(item: OaProgressChainItem) {
|
||||
const s = item.status || ''
|
||||
if (item.kind === 'comment') {
|
||||
if (s === 'revoked') return ''
|
||||
return 'ok'
|
||||
}
|
||||
if (item.kind !== 'terminal' && s === 'cancelled') return ''
|
||||
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'ok'
|
||||
if (s === 'pending' || s === 'running') return 'pending'
|
||||
@@ -414,6 +503,32 @@
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
|
||||
&--body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-flow-card.is-comment {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.oa-flow-comment-revoked {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oa-flow-comment-revoke {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.oa-flow-comment-mentions {
|
||||
margin-top: 6px;
|
||||
color: var(--primary, #10b981);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oa-person-lines {
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { AppRouteRecord } from '@/types/router'
|
||||
export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. 市场 (menu:market)
|
||||
// 二级菜单:细胞存储、科研捐赠、中试服务、基础实验、市场基础信息
|
||||
// 二级菜单:细胞存储、科研捐赠、市场基础信息、中试服务、基础实验
|
||||
// (主推存储/捐赠在前;中试/基础实验及关联基础信息后置)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
id: 'TOP00000000000000000000000000001',
|
||||
@@ -218,6 +219,228 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
]
|
||||
},
|
||||
|
||||
// ── 市场基础信息(前段:存储/捐赠相关;后段:中试/基础实验相关)──
|
||||
{
|
||||
id: 'MKT00000000000000000000000000001',
|
||||
name: 'MarketInfo',
|
||||
path: 'market-info',
|
||||
component: '',
|
||||
meta: {
|
||||
title: '市场基础信息',
|
||||
icon: 'ri:price-tag-3-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '122',
|
||||
name: 'BizCellSampleType',
|
||||
path: 'cell-sample-type',
|
||||
component: '/basic-info/cell-sample-type/index',
|
||||
meta: {
|
||||
title: '细胞样本基础信息维护',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:cellSampleType:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '125',
|
||||
name: 'BizSampleCode',
|
||||
path: 'sample-code',
|
||||
component: '/basic-info/sample-code/index',
|
||||
meta: {
|
||||
title: '样本编码管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'biz:sampleCode:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '123',
|
||||
name: 'BizCollectionRegion',
|
||||
path: 'collection-region',
|
||||
component: '/basic-info/collection-region/index',
|
||||
meta: {
|
||||
title: '采集地区及医院',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:collectionRegion:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '124',
|
||||
name: 'BizCollectionProtocolNo',
|
||||
path: 'collection-protocol-no',
|
||||
component: '/basic-info/collection-protocol-no/index',
|
||||
meta: {
|
||||
title: '采集协议号管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'biz:collectionProtocolNo:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '40000000000000000000000000000007',
|
||||
name: 'BasicInfoInsuranceCompany',
|
||||
path: 'insurance-company',
|
||||
component: '/basic-info/insurance-company/index',
|
||||
meta: {
|
||||
title: '保险公司及险种价格',
|
||||
keepAlive: true,
|
||||
menuCode: 'basic-info:insurance-company:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '50000000000000000000000000000009',
|
||||
name: 'MarketMaterial',
|
||||
path: 'material',
|
||||
component: '/cell-storage/material/index',
|
||||
meta: {
|
||||
title: '市场物料库',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:material:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '95000000000000000000000000000003',
|
||||
name: 'MarketMyMaterialUsage',
|
||||
path: 'my-material-usage',
|
||||
component: '/cell-storage/my-material-usage/index',
|
||||
meta: {
|
||||
title: '我的物料领用记录',
|
||||
keepAlive: true,
|
||||
menuCode: 'market:material-usage:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '50000000000000000000000000000010',
|
||||
name: 'MarketMaterialStockLog',
|
||||
path: 'material-stock-log',
|
||||
component: '/cell-storage/material-stock-log/index',
|
||||
meta: {
|
||||
title: '市场物料变动记录',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'biz:materialStockLog:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '121',
|
||||
name: 'BizCustomer',
|
||||
path: 'customer',
|
||||
component: '/basic-info/customer/index',
|
||||
meta: { title: '客户管理', keepAlive: true, menuCode: 'biz:customer:list' }
|
||||
},
|
||||
|
||||
{
|
||||
id: '80000000000000000000000000000002',
|
||||
name: 'CellProductType',
|
||||
path: 'cell-product-type',
|
||||
component: '/pilot-service/cell-product-type/index',
|
||||
meta: {
|
||||
title: '细胞产品基础信息维护',
|
||||
keepAlive: true,
|
||||
menuCode: 'pilot-service:cell-product-type:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '80000000000000000000000000000003',
|
||||
name: 'CellProductSpec',
|
||||
path: 'cell-product-spec',
|
||||
component: '/pilot-service/cell-product-spec/index',
|
||||
meta: {
|
||||
title: '细胞产品规格管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'pilot-service:cell-product-spec:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '80000000000000000000000000000004',
|
||||
name: 'CellProductPrice',
|
||||
path: 'cell-product-price',
|
||||
component: '/pilot-service/cell-product-price/index',
|
||||
meta: {
|
||||
title: '细胞产品价格体系管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'pilot-service:cell-product-price:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '80000000000000000000000000000005',
|
||||
name: 'ProjectPriceSystem',
|
||||
path: 'project-price-system',
|
||||
component: '/pilot-service/project-price-system/index',
|
||||
meta: {
|
||||
title: '细胞产品项目价格管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'pilot-service:project-price-system:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '80000000000000000000000000000007',
|
||||
name: 'CheckupInstitution',
|
||||
path: 'checkup-institution',
|
||||
component: '/pilot-service/checkup-institution/index',
|
||||
meta: {
|
||||
title: '体检机构及项目价格',
|
||||
keepAlive: true,
|
||||
menuCode: 'pilot-service:checkup-institution:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '80000000000000000000000000000006',
|
||||
name: 'Channel',
|
||||
path: 'channel',
|
||||
component: '/pilot-service/channel/index',
|
||||
meta: {
|
||||
title: '渠道信息管理',
|
||||
keepAlive: true,
|
||||
menuCode: 'pilot-service:channel:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '80000000000000000000000000000008',
|
||||
name: 'ChannelServiceOrder',
|
||||
path: 'channel-service-order',
|
||||
component: '/pilot-service/channel-service-order/index',
|
||||
meta: {
|
||||
title: '渠道服务购买订单',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'pilot-service:channel-service-order:list'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: '80000000000000000000000000000009',
|
||||
name: 'ChannelServiceOrderFinance',
|
||||
path: 'channel-service-order-finance',
|
||||
component: '/pilot-service/channel-service-order-finance/index',
|
||||
meta: {
|
||||
title: '渠道服务订单(财务确认)',
|
||||
keepAlive: true,
|
||||
menuCode: 'pilot-service:channel-service-order-finance:list'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ── 中试服务 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'PIL00000000000000000000000000001',
|
||||
@@ -334,212 +557,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
props: { bizType: 'basic_experiment' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ── 市场基础信息 ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'MKT00000000000000000000000000001',
|
||||
name: 'MarketInfo',
|
||||
path: 'market-info',
|
||||
component: '',
|
||||
meta: {
|
||||
title: '市场基础信息',
|
||||
icon: 'ri:price-tag-3-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '122',
|
||||
name: 'BizCellSampleType',
|
||||
path: 'cell-sample-type',
|
||||
component: '/basic-info/cell-sample-type/index',
|
||||
meta: {
|
||||
title: '细胞样本基础信息维护',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:cellSampleType:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '125',
|
||||
name: 'BizSampleCode',
|
||||
path: 'sample-code',
|
||||
component: '/basic-info/sample-code/index',
|
||||
meta: {
|
||||
title: '样本编码管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'biz:sampleCode:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '123',
|
||||
name: 'BizCollectionRegion',
|
||||
path: 'collection-region',
|
||||
component: '/basic-info/collection-region/index',
|
||||
meta: {
|
||||
title: '细胞采集基础信息维护',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:collectionRegion:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '124',
|
||||
name: 'BizCollectionProtocolNo',
|
||||
path: 'collection-protocol-no',
|
||||
component: '/basic-info/collection-protocol-no/index',
|
||||
meta: {
|
||||
title: '采集协议号管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'biz:collectionProtocolNo:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000002',
|
||||
name: 'CellProductType',
|
||||
path: 'cell-product-type',
|
||||
component: '/pilot-service/cell-product-type/index',
|
||||
meta: {
|
||||
title: '细胞产品基础信息维护',
|
||||
keepAlive: true,
|
||||
menuCode: 'pilot-service:cell-product-type:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000003',
|
||||
name: 'CellProductSpec',
|
||||
path: 'cell-product-spec',
|
||||
component: '/pilot-service/cell-product-spec/index',
|
||||
meta: {
|
||||
title: '细胞产品规格管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'pilot-service:cell-product-spec:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000004',
|
||||
name: 'CellProductPrice',
|
||||
path: 'cell-product-price',
|
||||
component: '/pilot-service/cell-product-price/index',
|
||||
meta: {
|
||||
title: '细胞产品价格体系管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'pilot-service:cell-product-price:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000005',
|
||||
name: 'ProjectPriceSystem',
|
||||
path: 'project-price-system',
|
||||
component: '/pilot-service/project-price-system/index',
|
||||
meta: {
|
||||
title: '细胞产品项目价格管理',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'pilot-service:project-price-system:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '40000000000000000000000000000007',
|
||||
name: 'BasicInfoInsuranceCompany',
|
||||
path: 'insurance-company',
|
||||
component: '/basic-info/insurance-company/index',
|
||||
meta: {
|
||||
title: '保险公司及险种价格',
|
||||
keepAlive: true,
|
||||
menuCode: 'basic-info:insurance-company:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000007',
|
||||
name: 'CheckupInstitution',
|
||||
path: 'checkup-institution',
|
||||
component: '/pilot-service/checkup-institution/index',
|
||||
meta: {
|
||||
title: '体检机构及项目价格',
|
||||
keepAlive: true,
|
||||
menuCode: 'pilot-service:checkup-institution:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '50000000000000000000000000000009',
|
||||
name: 'MarketMaterial',
|
||||
path: 'material',
|
||||
component: '/cell-storage/material/index',
|
||||
meta: {
|
||||
title: '市场物料库',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:material:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '95000000000000000000000000000003',
|
||||
name: 'MarketMyMaterialUsage',
|
||||
path: 'my-material-usage',
|
||||
component: '/cell-storage/my-material-usage/index',
|
||||
meta: {
|
||||
title: '我的物料领用记录',
|
||||
keepAlive: true,
|
||||
menuCode: 'market:material-usage:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '50000000000000000000000000000010',
|
||||
name: 'MarketMaterialStockLog',
|
||||
path: 'material-stock-log',
|
||||
component: '/cell-storage/material-stock-log/index',
|
||||
meta: {
|
||||
title: '市场物料变动记录',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'biz:materialStockLog:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '121',
|
||||
name: 'BizCustomer',
|
||||
path: 'customer',
|
||||
component: '/basic-info/customer/index',
|
||||
meta: { title: '客户管理', keepAlive: true, menuCode: 'biz:customer:list' }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000006',
|
||||
name: 'Channel',
|
||||
path: 'channel',
|
||||
component: '/pilot-service/channel/index',
|
||||
meta: {
|
||||
title: '渠道信息管理',
|
||||
keepAlive: true,
|
||||
menuCode: 'pilot-service:channel:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000008',
|
||||
name: 'ChannelServiceOrder',
|
||||
path: 'channel-service-order',
|
||||
component: '/pilot-service/channel-service-order/index',
|
||||
meta: {
|
||||
title: '渠道服务购买订单',
|
||||
keepAlive: false,
|
||||
isHide: true,
|
||||
menuCode: 'pilot-service:channel-service-order:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000009',
|
||||
name: 'ChannelServiceOrderFinance',
|
||||
path: 'channel-service-order-finance',
|
||||
component: '/pilot-service/channel-service-order-finance/index',
|
||||
meta: {
|
||||
title: '渠道服务订单(财务确认)',
|
||||
keepAlive: true,
|
||||
menuCode: 'pilot-service:channel-service-order-finance:list'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1512,6 +1529,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
menuCode: 'oa:cc'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'OA000000000000000000000000000110',
|
||||
name: 'OaApprovalMention',
|
||||
path: 'mentions',
|
||||
component: '/oa-approval/mentions/index',
|
||||
meta: {
|
||||
title: '提到我的',
|
||||
keepAlive: true,
|
||||
menuCode: 'oa:mention'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'OA000000000000000000000000000108',
|
||||
name: 'OaApprovalReceiveAccounts',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { OaCommentAttachmentRef, OaInstanceComment } from '@/api/oaApproval'
|
||||
|
||||
/** 进度链条目(与 OaProgressChain 对齐;评论条目带扩展字段) */
|
||||
export type ProgressTimelineItem = {
|
||||
key: string
|
||||
title: string
|
||||
status: string
|
||||
timestamp?: string
|
||||
assignee?: string
|
||||
resolveSource?: string
|
||||
commentText?: string
|
||||
taskType?: string
|
||||
kind?: string
|
||||
signMode?: string
|
||||
people?: Array<Record<string, any>>
|
||||
attachmentRefs?: OaCommentAttachmentRef[]
|
||||
canRevoke?: boolean
|
||||
commentId?: string
|
||||
commentStatus?: string
|
||||
revokedTime?: string
|
||||
mentions?: Array<{ userId: string; userName: string }>
|
||||
}
|
||||
|
||||
/** 当前待审 / 未落地节点:不参与与评论的时间交错,挂在已办节点与评论之后。 */
|
||||
function isNotReached(item: ProgressTimelineItem): boolean {
|
||||
return String(item.status || '') === 'not_reached'
|
||||
}
|
||||
|
||||
function isCurrentPending(item: ProgressTimelineItem): boolean {
|
||||
if (isNotReached(item)) return false
|
||||
const status = String(item.status || '')
|
||||
const kind = String(item.kind || '')
|
||||
return status === 'pending' || status === 'running' || kind === 'pending_node'
|
||||
}
|
||||
|
||||
/**
|
||||
* 将讨论评论按时间插入审批进度(钉钉式)。
|
||||
* - 已办节点(发起/已同意/已拒绝/抄送完成等)与评论按时间交错
|
||||
* - 当前待审节点挂在其后(评论落在「最后一个已办节点」后面)
|
||||
* - 未到达节点仍在轴末
|
||||
*/
|
||||
export function mergeProgressWithComments(
|
||||
progressItems: ProgressTimelineItem[],
|
||||
comments: OaInstanceComment[] | undefined | null
|
||||
): ProgressTimelineItem[] {
|
||||
const base = Array.isArray(progressItems) ? [...progressItems] : []
|
||||
const future = base.filter((p) => isNotReached(p))
|
||||
const current = base.filter((p) => isCurrentPending(p))
|
||||
const done = base.filter((p) => !isNotReached(p) && !isCurrentPending(p))
|
||||
|
||||
const commentRows: ProgressTimelineItem[] = (comments || []).map((c) => ({
|
||||
key: `comment-${c.id}`,
|
||||
kind: 'comment',
|
||||
title: c.createByName || c.createBy || '评论',
|
||||
status: c.status === 'revoked' ? 'revoked' : 'commented',
|
||||
timestamp: c.createTime || '',
|
||||
commentText: c.content || '',
|
||||
assignee: c.createByName || c.createBy || '',
|
||||
attachmentRefs: Array.isArray(c.attachmentRefs) ? c.attachmentRefs : [],
|
||||
canRevoke: !!c.canRevoke,
|
||||
commentId: c.id,
|
||||
commentStatus: c.status || 'normal',
|
||||
revokedTime: c.revokedTime || '',
|
||||
mentions: Array.isArray(c.mentions) ? c.mentions : []
|
||||
}))
|
||||
|
||||
const merged = [...done, ...commentRows].sort((a, b) => {
|
||||
const ta = String(a.timestamp || '')
|
||||
const tb = String(b.timestamp || '')
|
||||
if (ta !== tb) return ta < tb ? -1 : 1
|
||||
const aIsComment = a.kind === 'comment'
|
||||
const bIsComment = b.kind === 'comment'
|
||||
if (aIsComment !== bIsComment) return aIsComment ? 1 : -1
|
||||
return String(a.key || '').localeCompare(String(b.key || ''))
|
||||
})
|
||||
|
||||
return [...merged, ...current, ...future]
|
||||
}
|
||||
@@ -31,77 +31,72 @@
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h2>欢迎登录</h2>
|
||||
<p>微信扫一扫,授权手机号后自动登录</p>
|
||||
<p>{{ loginTab === 'wechat' ? '微信扫一扫,授权手机号后自动登录' : '请输入账号密码登录系统' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="qr-login">
|
||||
<div class="qr-frame">
|
||||
<img v-if="qrImage" class="qr-image" :src="qrImage" alt="登录二维码" />
|
||||
<div v-else class="qr-placeholder">{{ qrStatus === 'creating' ? '生成中…' : '暂无二维码' }}</div>
|
||||
</div>
|
||||
<p class="qr-status" :class="{ error: qrStatus === 'failed' || qrStatus === 'expired' }">
|
||||
{{ qrStatusText }}
|
||||
</p>
|
||||
<el-button :loading="qrLoading" @click="refreshQr">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新二维码
|
||||
</el-button>
|
||||
</div>
|
||||
<el-tabs v-model="loginTab" class="login-tabs" stretch @tab-change="handleLoginTabChange">
|
||||
<el-tab-pane label="微信扫码" name="wechat">
|
||||
<div class="qr-login">
|
||||
<div class="qr-frame">
|
||||
<img v-if="qrImage" class="qr-image" :src="qrImage" alt="登录二维码" />
|
||||
<div v-else class="qr-placeholder">
|
||||
{{ qrStatus === 'creating' ? '生成中…' : '暂无二维码' }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="qr-status" :class="{ error: qrStatus === 'failed' || qrStatus === 'expired' }">
|
||||
{{ qrStatusText }}
|
||||
</p>
|
||||
<el-button :loading="qrLoading" @click="refreshQr">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新二维码
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<div class="password-toggle" @click="showPasswordLogin = !showPasswordLogin">
|
||||
<span class="divider-line"></span>
|
||||
<button type="button" class="toggle-btn">
|
||||
<el-icon>
|
||||
<ArrowDown v-if="!showPasswordLogin" />
|
||||
<ArrowUp v-else />
|
||||
</el-icon>
|
||||
或使用账号密码登录
|
||||
</button>
|
||||
<span class="divider-line"></span>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
v-show="showPasswordLogin"
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
@keyup.enter="handleSubmit"
|
||||
class="login-form"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model.trim="formData.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model.trim="formData.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
@click="handleSubmit"
|
||||
<el-tab-pane label="账号密码" name="password">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
@keyup.enter="handleSubmit"
|
||||
class="login-form"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model.trim="formData.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model.trim="formData.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="form-footer">
|
||||
<span class="copyright">中科兴皖 © {{ currentYear }}</span>
|
||||
@@ -118,7 +113,8 @@
|
||||
import { resolveSafeRedirectTarget } from '@/utils/router'
|
||||
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Refresh, Check, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
||||
import { User, Lock, Refresh, Check } from '@element-plus/icons-vue'
|
||||
import type { TabPaneName } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
@@ -129,7 +125,7 @@
|
||||
const route = useRoute()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const showPasswordLogin = ref(false)
|
||||
const loginTab = ref<'wechat' | 'password'>('wechat')
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
@@ -281,6 +277,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginTabChange = (name: TabPaneName) => {
|
||||
if (name === 'password') {
|
||||
stopPoll()
|
||||
return
|
||||
}
|
||||
if (name === 'wechat') {
|
||||
if (!sessionId.value || qrStatus.value === 'failed' || qrStatus.value === 'expired') {
|
||||
void refreshQr()
|
||||
} else if (qrStatus.value === 'pending' || qrStatus.value === 'scanned') {
|
||||
startPoll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
@@ -415,7 +425,7 @@
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
@@ -432,12 +442,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
font-size: 15px;
|
||||
color: #64748b;
|
||||
|
||||
&:hover {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.qr-frame {
|
||||
@@ -475,39 +518,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 24px 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 16px;
|
||||
padding-top: 4px;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 24px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 旧「采集协议号管理」菜单入口:重定向至细胞采集基础信息维护 -->
|
||||
<!-- 旧「采集协议号管理」菜单入口:重定向至采集地区及医院(历史入口保留) -->
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<el-tabs v-model="activeTab" class="panel-pill-tabs panel-pill-tabs--lg">
|
||||
<el-tab-pane label="采集地区及医院" name="region">
|
||||
<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">
|
||||
@@ -89,15 +91,27 @@
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="工作人员"
|
||||
min-width="160"
|
||||
align="left"
|
||||
min-width="240"
|
||||
align="center"
|
||||
header-align="center"
|
||||
class-name="hospital-info-col"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.staffNames && row.staffNames.length">
|
||||
<div v-for="(name, idx) in row.staffNames" :key="idx" class="hospital-cell-line">
|
||||
{{ name }}
|
||||
<div class="staff-names-grid">
|
||||
<div
|
||||
v-for="(col, colIdx) in splitStaffNameColumns(row.staffNames)"
|
||||
:key="colIdx"
|
||||
class="staff-names-col"
|
||||
>
|
||||
<div
|
||||
v-for="(name, idx) in col"
|
||||
:key="idx"
|
||||
class="hospital-cell-line"
|
||||
>
|
||||
{{ name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="muted">—</span>
|
||||
@@ -257,96 +271,107 @@
|
||||
<div class="hospital-dialog-head">
|
||||
<span class="muted">当前地区:{{ currentRegionName }}</span>
|
||||
</div>
|
||||
<div class="list-toolbar hospital-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ hospitalSelected.length }}</span> 项
|
||||
<section class="panel dialog-nested-list">
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ hospitalSelected.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAddHospital">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增医院
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="hospitalSelected.length === 0"
|
||||
@click="handleBatchDeleteHospital"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAddHospital">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增医院
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="hospitalSelected.length === 0"
|
||||
@click="handleBatchDeleteHospital"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-body list-wrap hospital-table-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="hospitalTable"
|
||||
v-loading="hospitalLoading"
|
||||
stripe
|
||||
row-key="id"
|
||||
max-height="360"
|
||||
@selection-change="onHospitalSelectionChange"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="hospitalName"
|
||||
label="医院名称"
|
||||
min-width="200"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="remark"
|
||||
label="备注"
|
||||
min-width="160"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="hospitalTable"
|
||||
v-loading="hospitalLoading"
|
||||
stripe
|
||||
row-key="id"
|
||||
max-height="360"
|
||||
@selection-change="onHospitalSelectionChange"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.remark">{{ row.remark }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="260"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEditHospital(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDeleteHospital(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="hospitalPagination.page"
|
||||
:page-size="hospitalPagination.pageSize"
|
||||
:total="hospitalPagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="
|
||||
(val) => (
|
||||
(hospitalPagination.page = 1),
|
||||
(hospitalPagination.pageSize = val),
|
||||
loadHospitals()
|
||||
)
|
||||
"
|
||||
@current-change="(val) => ((hospitalPagination.page = val), loadHospitals())"
|
||||
/>
|
||||
<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="
|
||||
(index) =>
|
||||
(hospitalPagination.page - 1) * hospitalPagination.pageSize + index + 1
|
||||
"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="hospitalName"
|
||||
label="医院名称"
|
||||
min-width="200"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="remark"
|
||||
label="备注"
|
||||
min-width="160"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.remark">{{ row.remark }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="260"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEditHospital(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDeleteHospital(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="hospitalPagination.page"
|
||||
:page-size="hospitalPagination.pageSize"
|
||||
:total="hospitalPagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="
|
||||
(val) => (
|
||||
(hospitalPagination.page = 1),
|
||||
(hospitalPagination.pageSize = val),
|
||||
loadHospitals()
|
||||
)
|
||||
"
|
||||
@current-change="(val) => ((hospitalPagination.page = val), loadHospitals())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="hospitalFormVisible"
|
||||
@@ -404,8 +429,8 @@
|
||||
<el-dialog
|
||||
v-model="staffDialogVisible"
|
||||
:title="staffDialogTitle"
|
||||
width="860px"
|
||||
class="dialog-form-layout"
|
||||
width="1100px"
|
||||
class="dialog-form-layout staff-config-dialog"
|
||||
align-center
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
@@ -416,124 +441,127 @@
|
||||
<div class="hospital-dialog-head">
|
||||
<span class="muted">当前地区:{{ currentRegionName }}</span>
|
||||
</div>
|
||||
<div class="list-toolbar hospital-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ staffSelected.length }}</span> 项
|
||||
<section class="panel dialog-nested-list">
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ staffSelected.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAddStaff">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加人员
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="staffSelected.length === 0"
|
||||
@click="handleBatchDeleteStaff"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量移除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAddStaff">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加人员
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="staffSelected.length === 0"
|
||||
@click="handleBatchDeleteStaff"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量移除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-body list-wrap hospital-table-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="staffTable"
|
||||
v-loading="staffLoading"
|
||||
stripe
|
||||
row-key="id"
|
||||
max-height="360"
|
||||
@selection-change="onStaffSelectionChange"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="userName"
|
||||
label="姓名"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="userNo"
|
||||
label="工号"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="deptName"
|
||||
label="部门"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="staffTable"
|
||||
v-loading="staffLoading"
|
||||
stripe
|
||||
row-key="id"
|
||||
max-height="560"
|
||||
@selection-change="onStaffSelectionChange"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.deptName">{{ row.deptName }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="是否负责人"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isLeader" type="success" size="small">负责人</el-tag>
|
||||
<el-tag v-else type="info" size="small">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="260"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="toggleStaffLeader(row)">
|
||||
<el-icon>
|
||||
<StarFilled v-if="row.isLeader" />
|
||||
<Star v-else />
|
||||
</el-icon>
|
||||
{{ row.isLeader ? '取消负责人' : '设为负责人' }}
|
||||
</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDeleteStaff(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
移除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="staffPagination.page"
|
||||
:page-size="staffPagination.pageSize"
|
||||
:total="staffPagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="
|
||||
(val) => (
|
||||
(staffPagination.page = 1), (staffPagination.pageSize = val), loadStaff()
|
||||
)
|
||||
"
|
||||
@current-change="(val) => ((staffPagination.page = val), loadStaff())"
|
||||
/>
|
||||
<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="(index) => (staffPagination.page - 1) * staffPagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="userName"
|
||||
label="姓名"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="userNo"
|
||||
label="工号"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="deptName"
|
||||
label="部门"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.deptName">{{ row.deptName }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="是否负责人"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isLeader" type="success" size="small">负责人</el-tag>
|
||||
<el-tag v-else type="info" size="small">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="260"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="toggleStaffLeader(row)">
|
||||
<el-icon>
|
||||
<StarFilled v-if="row.isLeader" />
|
||||
<Star v-else />
|
||||
</el-icon>
|
||||
{{ row.isLeader ? '取消负责人' : '设为负责人' }}
|
||||
</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDeleteStaff(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
移除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="staffPagination.page"
|
||||
:page-size="staffPagination.pageSize"
|
||||
:total="staffPagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="
|
||||
(val) => (
|
||||
(staffPagination.page = 1), (staffPagination.pageSize = val), loadStaff()
|
||||
)
|
||||
"
|
||||
@current-change="(val) => ((staffPagination.page = val), loadStaff())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="staffAddVisible"
|
||||
title="添加人员"
|
||||
width="520px"
|
||||
width="640px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
@@ -544,13 +572,17 @@
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="系统人员" required>
|
||||
<el-select
|
||||
v-model="staffAddForm.userId"
|
||||
v-model="staffAddForm.userIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:max-collapse-tags="4"
|
||||
:remote-method="remoteSearchUsers"
|
||||
:loading="staffUserLoading"
|
||||
placeholder="搜索姓名/账号"
|
||||
placeholder="搜索并选择一人或多名人员"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
@@ -561,7 +593,11 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设为负责人">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
设为负责人
|
||||
<span class="form-label-tip">勾选后,本次添加的人员均标记为负责人</span>
|
||||
</template>
|
||||
<el-switch v-model="staffAddForm.isLeader" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -596,8 +632,6 @@
|
||||
:meta="viewRow?.createTime ? '创建时间:' + viewRow.createTime : ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -641,7 +675,14 @@
|
||||
} from '@/api/admin'
|
||||
defineOptions({ name: 'BizCollectionRegion' })
|
||||
|
||||
const activeTab = ref('region')
|
||||
/** 工作人员两列展示:左列 ceil(n/2),右列剩余(如 5 人 → 3+2) */
|
||||
function splitStaffNameColumns(names: string[]): [string[], string[]] {
|
||||
const list = names || []
|
||||
if (list.length <= 1) return [list, []]
|
||||
const mid = Math.ceil(list.length / 2)
|
||||
return [list.slice(0, mid), list.slice(mid)]
|
||||
}
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<Api.BizCollectionRegion.RegionItem[]>([])
|
||||
const selectedRows = ref<Api.BizCollectionRegion.RegionItem[]>([])
|
||||
@@ -943,8 +984,8 @@
|
||||
const staffPagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const staffAddVisible = ref(false)
|
||||
const staffAddLoading = ref(false)
|
||||
const staffAddForm = reactive<{ userId: string; isLeader: boolean }>({
|
||||
userId: '',
|
||||
const staffAddForm = reactive<{ userIds: string[]; isLeader: boolean }>({
|
||||
userIds: [],
|
||||
isLeader: false
|
||||
})
|
||||
const staffUserOptions = ref<Api.User.UserItem[]>([])
|
||||
@@ -971,11 +1012,20 @@
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetchRegionStaffList(currentRegionId.value, {
|
||||
page: 1,
|
||||
pageSize: 500
|
||||
})
|
||||
boundStaffUserIds.value = new Set((res.list || []).map((s) => s.userId))
|
||||
// 后端 pageSize 上限为 100,分页拉全量已绑定人员用于「添加人员」下拉去重
|
||||
const pageSize = 100
|
||||
const ids = new Set<string>()
|
||||
let page = 1
|
||||
let total = 0
|
||||
do {
|
||||
const res = await fetchRegionStaffList(currentRegionId.value, { page, pageSize })
|
||||
for (const s of res.list || []) {
|
||||
if (s.userId) ids.add(s.userId)
|
||||
}
|
||||
total = res.total ?? 0
|
||||
page += 1
|
||||
} while (ids.size < total)
|
||||
boundStaffUserIds.value = ids
|
||||
} catch {
|
||||
boundStaffUserIds.value = new Set(staffTable.value.map((s) => s.userId))
|
||||
}
|
||||
@@ -1005,7 +1055,7 @@
|
||||
}
|
||||
|
||||
function handleAddStaff() {
|
||||
staffAddForm.userId = ''
|
||||
staffAddForm.userIds = []
|
||||
staffAddForm.isLeader = false
|
||||
staffUserOptions.value = []
|
||||
staffAddVisible.value = true
|
||||
@@ -1017,36 +1067,52 @@
|
||||
try {
|
||||
const res = await fetchUserList({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 50,
|
||||
keyword: query || undefined,
|
||||
status: '1'
|
||||
})
|
||||
const bound = boundStaffUserIds.value
|
||||
staffUserOptions.value = (res.list || []).filter((u) => !bound.has(u.id))
|
||||
const selectedIds = new Set(staffAddForm.userIds)
|
||||
const keep = staffUserOptions.value.filter((u) => selectedIds.has(u.id))
|
||||
const keepIds = new Set(keep.map((u) => u.id))
|
||||
const searched = (res.list || []).filter((u) => !bound.has(u.id) && !keepIds.has(u.id))
|
||||
staffUserOptions.value = [...keep, ...searched]
|
||||
} finally {
|
||||
staffUserLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAddStaff() {
|
||||
if (!staffAddForm.userId) {
|
||||
const userIds = [...staffAddForm.userIds]
|
||||
if (userIds.length === 0) {
|
||||
ElMessage.warning('请选择系统人员')
|
||||
return
|
||||
}
|
||||
staffAddLoading.value = true
|
||||
try {
|
||||
await fetchCreateRegionStaff(currentRegionId.value, {
|
||||
userId: staffAddForm.userId,
|
||||
isLeader: staffAddForm.isLeader
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
let ok = 0
|
||||
let fail = 0
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await fetchCreateRegionStaff(currentRegionId.value, {
|
||||
userId,
|
||||
isLeader: staffAddForm.isLeader
|
||||
})
|
||||
ok += 1
|
||||
} catch {
|
||||
fail += 1
|
||||
}
|
||||
}
|
||||
if (ok === 0) {
|
||||
ElMessage.error('添加失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(fail > 0 ? `成功添加 ${ok} 人,${fail} 人失败` : `成功添加 ${ok} 人`)
|
||||
staffAddVisible.value = false
|
||||
staffAddForm.userId = ''
|
||||
staffAddForm.userIds = []
|
||||
staffAddForm.isLeader = false
|
||||
await loadStaff()
|
||||
await loadData()
|
||||
} catch {
|
||||
/* */
|
||||
} finally {
|
||||
staffAddLoading.value = false
|
||||
}
|
||||
@@ -1115,12 +1181,10 @@
|
||||
color: var(--mint-text-muted);
|
||||
}
|
||||
|
||||
.hospital-toolbar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hospital-table-wrap {
|
||||
padding-top: 0;
|
||||
/* 弹窗内嵌标准列表面板:工具栏与表格由全局 list-toolbar / list-body 分隔 */
|
||||
.dialog-nested-list {
|
||||
margin-top: 0;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
:deep(.hospital-info-col .cell) {
|
||||
@@ -1128,6 +1192,19 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.staff-names-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.staff-names-col {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hospital-cell-line {
|
||||
line-height: 1.8;
|
||||
}
|
||||
@@ -1140,3 +1217,15 @@
|
||||
color: var(--mint-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* append-to-body 弹窗需非 scoped,才能提高可视高度 */
|
||||
.el-dialog.staff-config-dialog.dialog-form-layout {
|
||||
width: 1100px;
|
||||
max-height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.el-dialog.staff-config-dialog.dialog-form-layout .el-dialog__body {
|
||||
min-height: 560px;
|
||||
}
|
||||
</style>
|
||||
@@ -90,19 +90,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="oa-detail__section">
|
||||
<section class="oa-detail__section oa-detail__section--progress">
|
||||
<OaProgressChain
|
||||
v-if="detail?.id"
|
||||
:items="timelineItems"
|
||||
:instance-id="detail.id"
|
||||
@refresh="load"
|
||||
/>
|
||||
<OaCommentPanel
|
||||
v-if="detail?.id"
|
||||
:instance-id="detail.id"
|
||||
:instance-status="detail.status || ''"
|
||||
:comments="detail.comments || []"
|
||||
@refresh="load"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="oa-detail__section oa-detail__section--progress">
|
||||
<OaProgressChain :items="timelineItems" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
@@ -215,6 +216,7 @@
|
||||
} from '@/api/oaApproval'
|
||||
import OaCommentPanel from '@/components/oa-approval/OaCommentPanel.vue'
|
||||
import OaProgressChain from '@/components/oa-approval/OaProgressChain.vue'
|
||||
import { mergeProgressWithComments } from '@/utils/oa-progress-merge-comments'
|
||||
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
|
||||
@@ -254,11 +256,13 @@
|
||||
|
||||
const prettyForm = computed(() => JSON.stringify(detail.value?.formDataJson || {}, null, 2))
|
||||
|
||||
/** 进度以详情 API progress 投影为准(勿回落无序 tasks) */
|
||||
/** 进度 + 讨论评论按时间交错(钉钉式);未到达节点仍在轴末 */
|
||||
const timelineItems = computed(() => {
|
||||
const list = detail.value?.progress
|
||||
if (!Array.isArray(list) || !list.length) return []
|
||||
return list
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return mergeProgressWithComments([], detail.value?.comments || [])
|
||||
}
|
||||
const progressMapped = list
|
||||
.filter((p: any) => {
|
||||
// 打回只保留审批人节点,过滤独立终态「已打回」行
|
||||
const kind = String(p?.kind || '')
|
||||
@@ -292,6 +296,7 @@
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
return mergeProgressWithComments(progressMapped, detail.value?.comments || [])
|
||||
})
|
||||
|
||||
const currentUserId = computed(() => {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<!-- OA 提到我的 -->
|
||||
<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.appName"
|
||||
placeholder="请输入流程名称"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请人">
|
||||
<el-input
|
||||
v-model="searchForm.initiatorName"
|
||||
placeholder="请输入申请人"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请时间">
|
||||
<el-date-picker
|
||||
v-model="applyTimeRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程状态">
|
||||
<el-select v-model="searchForm.instanceStatus" placeholder="全部" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="进行中" value="running" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
<el-option label="已打回" value="returned" />
|
||||
<el-option label="已撤销" value="cancelled" />
|
||||
</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-body list-wrap">
|
||||
<el-table class="list-table" :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
: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="initiatorUserName"
|
||||
label="申请人"
|
||||
min-width="120"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">{{ row.initiatorUserName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="applyTime"
|
||||
label="申请时间"
|
||||
min-width="170"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">{{ row.applyTime || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="currentNodeName"
|
||||
label="当前节点"
|
||||
min-width="140"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">{{ row.currentNodeName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="currentApproverNames"
|
||||
label="当前审批人"
|
||||
min-width="140"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.currentApproverNames || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="instanceStatus" label="流程状态" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusType(row.instanceStatus)">
|
||||
{{ statusText(row.instanceStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="openDetail(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-op-item"
|
||||
:loading="printingId === row.id"
|
||||
@click="printRow(row)"
|
||||
>
|
||||
<el-icon><Printer /></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="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Printer, RefreshLeft, Search, View } from '@element-plus/icons-vue'
|
||||
import { fetchOaInstanceDetail, fetchOaTasks } from '@/api/oaApproval'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { exportOaApprovalPdf } from '@/utils/oa-approval-print'
|
||||
|
||||
defineOptions({ name: 'OaApprovalMention' })
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const printingId = ref('')
|
||||
const rows = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const applyTimeRange = ref<string[] | null>(null)
|
||||
const searchForm = reactive({
|
||||
appName: '',
|
||||
initiatorName: '',
|
||||
instanceStatus: ''
|
||||
})
|
||||
|
||||
function indexMethod(index: number) {
|
||||
return (page.value - 1) * pageSize.value + index + 1
|
||||
}
|
||||
|
||||
function currentPrinterName() {
|
||||
const info: any = userStore.info || userStore.getUserInfo || {}
|
||||
return String(
|
||||
info.realName || info.nickName || info.userName || info.username || ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function statusType(s: string) {
|
||||
if (s === 'pending' || s === 'running') return 'warning'
|
||||
if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
|
||||
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function statusText(s: string) {
|
||||
if (!s) return '-'
|
||||
const map: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
running: '进行中',
|
||||
approved: '已同意',
|
||||
rejected: '已拒绝',
|
||||
completed: '已完成',
|
||||
cancelled: '已撤销',
|
||||
done: '已结束',
|
||||
returned: '已打回',
|
||||
transferred: '已转交'
|
||||
}
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
function openDetail(row: any) {
|
||||
const id = row.id
|
||||
if (!id) return
|
||||
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
|
||||
}
|
||||
|
||||
async function printRow(row: any) {
|
||||
const id = String(row?.id || '').trim()
|
||||
if (!id || printingId.value) return
|
||||
printingId.value = id
|
||||
let detail: any
|
||||
try {
|
||||
detail = await fetchOaInstanceDetail(id)
|
||||
} catch {
|
||||
printingId.value = ''
|
||||
ElMessage.error('加载审批单失败')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await exportOaApprovalPdf(detail, { printerName: currentPrinterName() })
|
||||
ElMessage.success('PDF 已下载')
|
||||
} catch {
|
||||
ElMessage.error('导出 PDF 失败')
|
||||
} finally {
|
||||
printingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildFilterParams() {
|
||||
const range = applyTimeRange.value
|
||||
return {
|
||||
appName: searchForm.appName,
|
||||
initiatorName: searchForm.initiatorName,
|
||||
instanceStatus: searchForm.instanceStatus,
|
||||
applyTimeFrom: range?.[0] || '',
|
||||
applyTimeTo: range?.[1] || '',
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.appName = ''
|
||||
searchForm.initiatorName = ''
|
||||
searchForm.instanceStatus = ''
|
||||
applyTimeRange.value = null
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchOaTasks('mention', buildFilterParams())
|
||||
rows.value = res?.list || []
|
||||
total.value = res?.total || 0
|
||||
} catch {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
[searchForm, applyTimeRange],
|
||||
() => {
|
||||
page.value = 1
|
||||
void load()
|
||||
},
|
||||
{ debounce: 300, deep: true }
|
||||
)
|
||||
|
||||
onMounted(load)
|
||||
onActivated(load)
|
||||
</script>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Reference in new issue
Block a user