This commit is contained in:
zlwu committed 2026-08-13 14:31:57 +08:00
1 parent 0e1b750457
commit 8c49021d38
93 files changed
+22731 -12107

No files matched your search

+2
View File
@@ -155,6 +155,8 @@ async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)):
path = (m.path or "").strip()
if not path:
continue
if "form-sink" in path.lower() or "水槽" in str(m.menu_name or ""):
continue
if allowed_ids is not None and m.id not in allowed_ids:
continue
colors = _mobile_menu_icon_colors(path)
+2
View File
@@ -1043,6 +1043,7 @@ async def list_oa_tasks(
readStatus: Optional[str] = Query(None, alias="readStatus"),
appName: Optional[str] = Query(None, alias="appName"),
initiatorName: Optional[str] = Query(None, alias="initiatorName"),
keyword: Optional[str] = Query(None, alias="keyword"),
applyTimeFrom: Optional[str] = Query(None, alias="applyTimeFrom"),
applyTimeTo: Optional[str] = Query(None, alias="applyTimeTo"),
instanceStatus: Optional[str] = Query(None, alias="instanceStatus"),
@@ -1063,6 +1064,7 @@ async def list_oa_tasks(
read_status=readStatus,
app_name=appName,
initiator_name=initiatorName,
keyword=keyword,
apply_time_from=applyTimeFrom,
apply_time_to=applyTimeTo,
instance_status=instanceStatus,
+26 -2
View File
@@ -21,6 +21,7 @@
import os
import uuid
import base64
import mimetypes
from io import BytesIO
from datetime import datetime, timedelta
from urllib.parse import quote
@@ -33,6 +34,25 @@ from app.core.config import settings
# HEIC 格式的 MIME 类型
HEIC_TYPES = {"image/heic", "image/heif"}
# 扩展名 → MIME(补齐部分系统 mimetypes 缺失,尤其 SVG)
_EXTRA_CONTENT_TYPES = {
".svg": "image/svg+xml",
".svgz": "image/svg+xml",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
}
def guess_content_type(filename: str) -> str:
"""按文件名推断 Content-Type,供对象存储写入元数据(SVG 回显依赖正确 MIME)。"""
name = (filename or "").strip()
ext = os.path.splitext(name)[1].lower()
if ext in _EXTRA_CONTENT_TYPES:
return _EXTRA_CONTENT_TYPES[ext]
guessed, _ = mimetypes.guess_type(name)
return guessed or "application/octet-stream"
def convert_heic_to_jpeg(content: bytes) -> bytes:
"""将 HEIC/HEIF 格式转换为 JPEG 格式"""
@@ -176,8 +196,9 @@ class QiniuStorage(BaseStorage):
key = self._generate_filename(filename)
token = self.qiniu.upload_token(self.bucket, key, 3600)
mime = guess_content_type(filename)
ret, info = put_data(token, key, file_content)
ret, info = put_data(token, key, file_content, mime_type=mime)
if ret is not None:
return self.get_url(ret["key"])
raise Exception(f"七牛云上传失败: {info}")
@@ -231,7 +252,8 @@ class OSSStorage(BaseStorage):
file_content, filename = process_image_content(file_content, filename)
key = self._generate_filename(filename)
result = self.bucket.put_object(key, file_content)
headers = {"Content-Type": guess_content_type(filename)}
result = self.bucket.put_object(key, file_content, headers=headers)
if result.status == 200:
return self.get_url(key)
raise Exception(f"OSS上传失败: {result.status}")
@@ -294,11 +316,13 @@ class MinIOStorage(BaseStorage):
if folder:
key = f"{folder.strip('/')}/{key}"
data = BytesIO(file_content)
# 必须写入正确 MIME:SVG 若为 octet-stream,浏览器 <img> 会拒绝回显
self.client.put_object(
bucket_name=self.bucket_name,
object_name=key,
data=data,
length=len(file_content),
content_type=guess_content_type(filename),
)
return self.get_url(key)
+15 -8
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import oa_approval as oa_crud
from app.models.models import OaApp, OaAppGroup, OaAppVersion, OaInstanceDraft
from app.services.oa_related_approval import flatten_form_fields_for_summary
def _loads(raw: Any, default: Any) -> Any:
@@ -187,15 +188,21 @@ def list_drafts(
)
items: List[Dict[str, Any]] = []
for d, app, ver, group in rows:
items.append(
row_to_dict(
d,
app_name=(app.app_name if app else "") or "",
version_no=ver.version_no if ver else None,
group_id=(app.group_id if app else "") or "",
group_name=(group.group_name if group else "") or "",
)
item = row_to_dict(
d,
app_name=(app.app_name if app else "") or "",
version_no=ver.version_no if ver else None,
group_id=(app.group_id if app else "") or "",
group_name=(group.group_name if group else "") or "",
)
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
form_data = _loads(d.form_data_json, {}) or {}
preview = [
{"label": f.get("label") or "", "value": f.get("value") or ""}
for f in flatten_form_fields_for_summary(schema, form_data)[:3]
]
item["previewFields"] = preview
items.append(item)
return items, int(total)
+80 -15
View File
@@ -238,16 +238,30 @@ def _fmt_dt(dt: Any) -> str:
return str(dt)
def _terminal_item(status: str, title: str, inst: Any) -> Dict[str, Any]:
def _terminal_item(
status: str,
title: str,
inst: Any,
name_map: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
name_map = name_map or {}
uid = ""
uname = ""
comment = ""
# 撤销终态:归因发起人,勿让任务节点承担「已撤销」语义
if status == "cancelled":
uid = (getattr(inst, "initiator_user_id", None) or "").strip()
uname = name_map.get(uid, "") or uid
comment = "发起人撤销"
return {
"kind": "terminal",
"roundNo": getattr(inst, "current_round", None) or 1,
"title": title,
"status": status,
"assigneeUserId": "",
"assigneeUserName": "",
"assigneeUserId": uid,
"assigneeUserName": uname,
"timestamp": _fmt_dt(getattr(inst, "update_time", None)),
"commentText": "",
"commentText": comment,
"nodeKey": "",
"taskType": "",
}
@@ -682,6 +696,9 @@ def build_progress_items(
for p in ts_src:
if p.get("timestamp") and (not timestamp or p["timestamp"] > timestamp):
timestamp = p["timestamp"]
# 整节点仅系统清待办(cancelled)时勿带时间,避免像审批人操作过
if node_status == "cancelled":
timestamp = ""
comments = [
f"{p['nickName']}:{p['commentText']}"
if len(people) > 1 and p.get("nickName")
@@ -751,11 +768,11 @@ def build_progress_items(
)
if inst.status == "completed":
items.append(_terminal_item("done", "流程结束", inst))
items.append(_terminal_item("done", "流程结束", inst, name_map))
elif inst.status == "rejected":
items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst))
items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst, name_map))
elif inst.status == "cancelled":
items.append(_terminal_item("cancelled", "已撤销", inst))
items.append(_terminal_item("cancelled", "已撤销", inst, name_map))
# returned:打回结果只落在审批任务节点上,不再追加独立终态「已打回」行
return items
@@ -913,6 +930,8 @@ def _enrich_list_rows(
) -> List[Dict[str, Any]]:
if not rows:
return []
from app.services.oa_related_approval import flatten_form_fields_for_summary
inst_by_id = {i.id: i for i in instances}
app_ids = list({i.app_id for i in instances if i.app_id})
apps = (
@@ -928,6 +947,16 @@ def _enrich_list_rows(
)
app_name_by_id = {a.id: (a.app_name or "") for a in apps}
ver_ids = list({i.app_version_id for i in instances if i.app_version_id})
ver_by_id: Dict[str, OaAppVersion] = {}
if ver_ids:
for v in (
db.query(OaAppVersion)
.filter(OaAppVersion.id.in_(ver_ids), OaAppVersion.del_flag == 0)
.all()
):
ver_by_id[v.id] = v
inst_ids = list(inst_by_id.keys())
pending_tasks = (
db.query(OaTask)
@@ -970,8 +999,16 @@ def _enrich_list_rows(
pending_approval_tasks=pending_by_inst.get(inst.id, []),
name_map=name_map,
)
ver = ver_by_id.get(inst.app_version_id or "")
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
form_data = _loads(inst.form_data_json, {}) or {}
preview = [
{"label": f.get("label") or "", "value": f.get("value") or ""}
for f in flatten_form_fields_for_summary(schema, form_data)[:3]
]
merged = dict(row)
merged.update(summary)
merged["previewFields"] = preview
out.append(merged)
return out
@@ -2121,6 +2158,7 @@ def _apply_instance_list_filters(
*,
app_name: Optional[str] = None,
initiator_name: Optional[str] = None,
keyword: Optional[str] = None,
apply_time_from: Optional[str] = None,
apply_time_to: Optional[str] = None,
instance_status: Optional[str] = None,
@@ -2128,26 +2166,49 @@ def _apply_instance_list_filters(
joined_initiator: bool = False,
):
"""在已包含 OaInstance 的 query 上叠加列表筛选。返回 (query, joined_app, joined_initiator)。"""
kw = (app_name or "").strip()
if kw:
combined = (keyword or "").strip()
if combined:
if not joined_app:
q = q.outerjoin(
OaApp,
(OaApp.id == OaInstance.app_id) & (OaApp.del_flag == 0),
)
joined_app = True
like = f"%{kw}%"
q = q.filter(or_(OaApp.app_name.like(like), OaInstance.title.like(like)))
iname = (initiator_name or "").strip()
if iname:
if not joined_initiator:
q = q.outerjoin(
SysUser,
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
)
joined_initiator = True
q = q.filter(SysUser.nick_name.like(f"%{iname}%"))
like = f"%{combined}%"
q = q.filter(
or_(
OaApp.app_name.like(like),
OaInstance.title.like(like),
SysUser.nick_name.like(like),
)
)
else:
kw = (app_name or "").strip()
if kw:
if not joined_app:
q = q.outerjoin(
OaApp,
(OaApp.id == OaInstance.app_id) & (OaApp.del_flag == 0),
)
joined_app = True
like = f"%{kw}%"
q = q.filter(or_(OaApp.app_name.like(like), OaInstance.title.like(like)))
iname = (initiator_name or "").strip()
if iname:
if not joined_initiator:
q = q.outerjoin(
SysUser,
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
)
joined_initiator = True
q = q.filter(SysUser.nick_name.like(f"%{iname}%"))
t0 = _parse_day_start(apply_time_from)
if t0 is not None:
@@ -2172,6 +2233,7 @@ def list_tasks(
read_status: Optional[str] = None,
app_name: Optional[str] = None,
initiator_name: Optional[str] = None,
keyword: Optional[str] = None,
apply_time_from: Optional[str] = None,
apply_time_to: Optional[str] = None,
instance_status: Optional[str] = None,
@@ -2193,6 +2255,7 @@ def list_tasks(
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,
@@ -2244,6 +2307,7 @@ def list_tasks(
need_inst_join = any(
[
(keyword or "").strip(),
(app_name or "").strip(),
(initiator_name or "").strip(),
(apply_time_from or "").strip(),
@@ -2262,6 +2326,7 @@ def list_tasks(
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,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+102 -87
View File
@@ -1,11 +1,11 @@
# Graph Report - app (2026-08-12)
# Graph Report - app (2026-08-13)
## Corpus Check
- Large corpus: 250 files · ~303,290 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
- Large corpus: 250 files · ~303,609 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
## Summary
- 5238 nodes · 28666 edges · 43 communities detected
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 19610 edges (avg confidence: 0.6)
- 5240 nodes · 28677 edges · 46 communities detected
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 19614 edges (avg confidence: 0.6)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
@@ -46,12 +46,15 @@
- [[_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 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 47|Community 47]]
## God Nodes (most connected - your core abstractions)
1. `success_response()` - 736 edges
@@ -70,90 +73,90 @@
core/api_request_log_middleware.py → models/models.py
- `get_menu_list()` --calls--> `success_response()` [INFERRED]
api/menu.py → core/response.py
- `中试服务管理 API(细胞产品类型/规格/价格体系/项目价格、渠道信息、体检机构)` --uses--> `SysUser` [INFERRED]
api/pilot_service.py → models/models.py
- `微信小程序登录业务:openid + 手机号一致性校验。` --uses--> `SysUser` [INFERRED]
services/wechat_login_service.py → models/models.py
- `根据 openid + 本次授权手机号解析可登录员工。 Returns: (user, error_message, error_co` --uses--> `SysUser` [INFERRED]
services/wechat_login_service.py → models/models.py
- `保留匹配节点及其全部上级,供关键词检索后仍能正确组树。` --uses--> `SysDept` [INFERRED]
crud/dept.py → models/models.py
## Communities
### Community 0 - "Community 0"
Cohesion: 0.01
Nodes (1001): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+993 more)
Nodes (1032): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+1024 more)
### Community 1 - "Community 1"
Cohesion: 0.01
Nodes (706): generate_uuid(), allocate_next_biz_no(), _format_biz_no(), _prefix_for(), 业务协议号自动生成:XBCC / JZCC + YYYYMMDD + 6 位序号, 原子分配下一个业务协议号。 须在 create 事务内调用;使用 MySQL LAST_INSERT_ID 保证并发安全。, create_cell_product(), create_cell_sample_type() (+698 more)
Nodes (746): _ensure_customer_for_mother(), api_save_application(), set_current_process_route(), generate_uuid(), allocate_next_biz_no(), _format_biz_no(), _prefix_for(), 业务协议号自动生成:XBCC / JZCC + YYYYMMDD + 6 位序号 (+738 more)
### Community 2 - "Community 2"
Cohesion: 0.01
Nodes (584): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+576 more)
Nodes (599): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+591 more)
### Community 3 - "Community 3"
Cohesion: 0.07
Nodes (410): 环境检测 — 检测项维护 + 登记管理 API, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), 小程序菜单:当前用户在「移动端菜单」树下有权限的入口。, 宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。, 按路由匹配移动端宫格图标(与 mobile-h5 van-icon 字形名对齐)。, ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody (+402 more)
Nodes (476): 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), Base, 按输注计划 ID 批量索引缴费记录(列表页展示用,不做关联 enrich), 输注计划创建时自动调用,生成 dfj 缴费记录, 关联查询申请单、患者、输注计划信息,附加到 dict, 将 BizMaterialUsage 行转换为字典(用于我的领用记录)。, 当前登录人的手动出库领用记录:领用人字段存昵称或用户 ID(与生产/质检一致)。, 获取当前用户的物料领用记录(手动出库记录) 筛选条件: - tenant_no = 当前用户租户 - source_type = 'm (+468 more)
### Community 4 - "Community 4"
Cohesion: 0.06
Nodes (328): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+320 more)
Cohesion: 0.01
Nodes (320): _normalize_date_bound(), _persist_protocol_sub_resources(), batch_delete_research_donations(), change_donation_status(), convert_from_storage(), convert_to_storage(), create_research_donation(), delete_research_donation() (+312 more)
### Community 5 - "Community 5"
Cohesion: 0.02
Nodes (270): _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(), format_date() (+262 more)
Cohesion: 0.03
Nodes (238): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _copied_app_name(), copy_app(), create_app(), create_group(), _create_version() (+230 more)
### Community 6 - "Community 6"
Cohesion: 0.02
Nodes (270): 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() (+262 more)
Nodes (244): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), batch_review(), _dec() (+236 more)
### Community 7 - "Community 7"
Cohesion: 0.02
Nodes (234): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+226 more)
Nodes (146): get_designer_data(), _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+138 more)
### Community 8 - "Community 8"
Cohesion: 0.02
Nodes (142): get_designer_data(), _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+134 more)
Cohesion: 0.08
Nodes (185): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+177 more)
### Community 9 - "Community 9"
Cohesion: 0.03
Nodes (129): get_next_cell_product_code(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes() (+121 more)
Cohesion: 0.02
Nodes (163): get_next_cell_product_code(), build_void_type_label_map(), format_protocol_status_label(), resolve_void_type_label(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain() (+155 more)
### Community 10 - "Community 10"
Cohesion: 0.03
Nodes (110): 小程序鉴权:微信登录 / 绑定 openid。, 微信授权登录:须授权手机号;openid 已绑定也须与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, _resolve_user_id(), wechat_bind(), wechat_login(), _collect_descendants(), _current_user() (+102 more)
Nodes (117): 小程序鉴权:微信登录 / 绑定 openid。, 微信授权登录:须授权手机号;openid 已绑定也须与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, _resolve_user_id(), wechat_bind(), wechat_login(), _collect_descendants(), _current_user() (+109 more)
### Community 11 - "Community 11"
Cohesion: 0.04
Nodes (104): _body_to_payload(), _get_current_user_id(), get_production_reception_by_sample_code(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), put_production_reception(), _resolve_reception_id() (+96 more)
Cohesion: 0.05
Nodes (109): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+101 more)
### Community 12 - "Community 12"
Cohesion: 0.05
Nodes (113): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+105 more)
Cohesion: 0.38
Nodes (78): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+70 more)
### Community 13 - "Community 13"
Cohesion: 0.04
Nodes (102): _filter_material_type(), _amount_for_row(), _batch_map(), _deduct_step_usage_legacy_batch(), _display_remark(), _fmt_date_val(), list_available_requisitions_for_prep(), list_linked_step_usages() (+94 more)
Cohesion: 0.07
Nodes (54): create_leave(), get_leave(), list_leaves(), _row_to_dict(), soft_delete_leave(), _dec(), _fmt_dt(), list_stock_logs() (+46 more)
### Community 14 - "Community 14"
Cohesion: 0.45
Nodes (71): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+63 more)
Cohesion: 0.09
Nodes (53): batch_stock_in(), batch_stock_out(), code_exists(), create_material(), _dec(), _filter_material_type(), _fmt_dt(), get_material() (+45 more)
### Community 15 - "Community 15"
Cohesion: 0.09
Nodes (50): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+42 more)
### Community 16 - "Community 16"
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)
### Community 16 - "Community 16"
Cohesion: 0.13
Nodes (20): 微信消息通知服务 支持微信小程序模板消息推送 使用方式: 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() (+12 more)
### Community 17 - "Community 17"
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)
Nodes (22): save_patient(), batch_create_checkups(), create_checkup(), delete_actual_item(), delete_reserve_item(), ensure_checkup_for_patient(), get_checkup_by_application(), get_checkup_by_id() (+14 more)
### Community 18 - "Community 18"
Cohesion: 0.17
Nodes (19): batch_create_checkups(), create_checkup(), delete_actual_item(), delete_reserve_item(), get_checkup_by_application(), get_checkup_by_id(), list_actual_items(), list_checkups_paginated() (+11 more)
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 19 - "Community 19"
Cohesion: 0.21
@@ -164,132 +167,144 @@ 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 21 - "Community 21"
Cohesion: 0.18
Nodes (18): company_code_exists(), create_company(), get_company(), list_companies(), list_company_options(), _row_to_dict(), soft_delete_company(), update_company() (+10 more)
Cohesion: 0.25
Nodes (18): _amount_for_row(), _batch_map(), _display_remark(), _fmt_date_val(), list_stock_check_requisitions(), list_usages(), _list_usages_query(), mark_requisition_fully_used() (+10 more)
### Community 22 - "Community 22"
Cohesion: 0.27
Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more)
Cohesion: 0.21
Nodes (16): _field_label(), _is_non_empty_str(), _is_required(), _is_valid_receive_account_item(), normalize_account_fields(), _pick(), _pick_order_num(), _pick_str() (+8 more)
### Community 23 - "Community 23"
Cohesion: 0.22
Nodes (15): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐), 遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。 (+7 more)
### Community 24 - "Community 24"
Cohesion: 0.26
Nodes (14): apply_leave_kit_hours(), calc_leave_hours(), _extract_rules(), _kit_child_fields(), _overlap_minutes_on_day(), _parse_dt(), _parse_hm(), 请假套件:双段工时重叠小时(与前端 oa-leave-hours.ts 对齐) (+6 more)
### Community 25 - "Community 25"
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 24 - "Community 24"
### Community 26 - "Community 26"
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 25 - "Community 25"
### Community 27 - "Community 27"
Cohesion: 0.27
Nodes (11): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。 (+3 more)
### Community 28 - "Community 28"
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 26 - "Community 26"
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 27 - "Community 27"
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 28 - "Community 28"
### Community 29 - "Community 29"
Cohesion: 0.29
Nodes (2): BaseModel, SoftDeleteModel
### Community 29 - "Community 29"
### Community 30 - "Community 30"
Cohesion: 0.38
Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。 约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断; 若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
### Community 30 - "Community 30"
### Community 31 - "Community 31"
Cohesion: 0.4
Nodes (4): list_ethnics_for_select(), list_ethnics(), 民族码表 CRUD(全局 sys_ethnic), _row_to_dict()
### Community 32 - "Community 32"
Cohesion: 0.4
Nodes (3): ApiRequestLogCRUD, delete(), get_multi_with_filters()
### Community 33 - "Community 33"
Cohesion: 0.5
Nodes (3): BaseSettings, Config, Settings
### Community 31 - "Community 31"
### Community 34 - "Community 34"
Cohesion: 0.5
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
### Community 32 - "Community 32"
### Community 35 - "Community 35"
Cohesion: 0.67
Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
### Community 33 - "Community 33"
### Community 36 - "Community 36"
Cohesion: 0.67
Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
### Community 34 - "Community 34"
### Community 37 - "Community 37"
Cohesion: 1.0
Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
### Community 35 - "Community 35"
### Community 38 - "Community 38"
Cohesion: 1.0
Nodes (1): 请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
### Community 36 - "Community 36"
### Community 39 - "Community 39"
Cohesion: 1.0
Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
### Community 39 - "Community 39"
### Community 42 - "Community 42"
Cohesion: 1.0
Nodes (1): 发送订单状态通知 Args: openid: 用户openid ord
### Community 40 - "Community 40"
### Community 43 - "Community 43"
Cohesion: 1.0
Nodes (1): 发送支付成功通知 Args: openid: 用户openid ord
### Community 41 - "Community 41"
### Community 44 - "Community 44"
Cohesion: 1.0
Nodes (1): 发送配送通知 Args: openid: 配送员openid task
### Community 42 - "Community 42"
### Community 45 - "Community 45"
Cohesion: 1.0
Nodes (1): 发送配送时效预警 Args: openid: 配送员openid ta
### Community 43 - "Community 43"
### Community 46 - "Community 46"
Cohesion: 1.0
Nodes (1): 发送退款结果通知 Args: openid: 用户openid ord
### Community 44 - "Community 44"
### Community 47 - "Community 47"
Cohesion: 1.0
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
## Knowledge Gaps
- **170 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+165 more)
- **171 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+166 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **Thin community `Community 28`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
- **Thin community `Community 29`** (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 31`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
- **Thin community `Community 34`** (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 32`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
- **Thin community `Community 35`** (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 33`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
- **Thin community `Community 36`** (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 34`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
- **Thin community `Community 37`** (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 35`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
- **Thin community `Community 38`** (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`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
- **Thin community `Community 39`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 39`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
- **Thin community `Community 42`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
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 43`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 41`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
- **Thin community `Community 44`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 42`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
- **Thin community `Community 45`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 43`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
- **Thin community `Community 46`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 44`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
- **Thin community `Community 47`** (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 4`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 12`, `Community 13`, `Community 14`, `Community 23`, `Community 24`?**
_High betweenness centrality (0.114) - this node is a cross-community bridge._
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 19`, `Community 20`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 25`?**
_High betweenness centrality (0.042) - this node is a cross-community bridge._
- **Why does `success_response()` connect `Community 0` to `Community 32`, `Community 2`, `Community 5`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`?**
_High betweenness centrality (0.040) - this node is a cross-community bridge._
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 21`, `Community 25`, `Community 26`?**
_High betweenness centrality (0.109) - this node is a cross-community bridge._
- **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 2`, `Community 35`, `Community 4`, `Community 7`, `Community 9`, `Community 10`, `Community 31`?**
_High betweenness centrality (0.043) - this node is a cross-community bridge._
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 16`, `Community 19`, `Community 20`, `Community 25`, `Community 26`, `Community 27`, `Community 28`?**
_High betweenness centrality (0.030) - this node is a cross-community bridge._
- **Are the 879 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
_`str` has 879 INFERRED edges - model-reasoned connections that need verification._
- **Are the 735 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -20,7 +20,7 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/core/context.py": 1779242263.6923044,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/utils.py": 1780305790.0009735,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/protocol_status_display.py": 1783303208.7618413,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/storage.py": 1782437555.6682527,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/storage.py": 1786527936.515132,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/order_status_compat.py": 1778578258.0426462,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/exceptions.py": 1778578258.0413945,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/data_scope.py": 1785663691.2518938,
@@ -165,7 +165,7 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol_no.py": 1779882001.753651,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_handover.py": 1780455574.0993364,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_facility.py": 1785832360.7565567,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_instance_draft.py": 1785990690.2179956,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_instance_draft.py": 1786527123.364954,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_lab_code.py": 1783308340.5854337,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_sample_type.py": 1781747645.1762104,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_outbound_status_sync.py": 1781595840.6955194,
@@ -184,7 +184,7 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1786374022.5691514,
"/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": 1786520649.8233767,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_menu.py": 1786587944.1214068,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1785207187.402777,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/market.py": 1781522283.6823432,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_production_reception.py": 1782718998.90251,
@@ -199,7 +199,7 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pre_transit_sample.py": 1782204426.7965167,
"/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": 1786416766.968504,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1786527707.997609,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/operations.py": 1785213144.2881503,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1785836159.1492305,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_delivery.py": 1779655584.820777,
@@ -218,7 +218,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": 1786416725.0574527,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1786600784.1269102,
"/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,
+58
View File
@@ -812,6 +812,64 @@ class TestBuildProgress(unittest.TestCase):
len(terminal), expect_n, f"status={status} terminal count"
)
def test_cancelled_terminal_attributes_to_initiator(self):
"""撤销终态归因发起人;任务 cancelled 仍投影为节点,由前端映射「无需处理」。"""
from app.services.oa_engine import build_progress_items
root = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "直接主管",
"child": {"id": "e1", "type": "end"},
},
}
ts = datetime(2026, 8, 13, 12, 47, 46)
t_pending_cancelled = MagicMock(
id="tA",
node_key="a1",
node_name="直接主管",
task_type="approval",
status="cancelled",
assignee_user_id="u2",
order_num=0,
round_no=1,
create_time=datetime(2026, 8, 13, 11, 17, 38),
update_time=ts,
comment_text=None,
resolve_source="direct_supervisor",
sign_mode="and",
)
inst = MagicMock(
status="cancelled",
initiator_user_id="u0",
current_round=1,
create_time=datetime(2026, 8, 13, 11, 17, 38),
update_time=ts,
)
items = build_progress_items(
inst=inst,
flow_root=root,
tasks=[t_pending_cancelled],
name_map={"u0": "吴子龙", "u2": "张主管"},
events=[],
)
task_card = next(x for x in items if x["kind"] == "task")
self.assertEqual(task_card["title"], "直接主管")
self.assertEqual(task_card["status"], "cancelled")
self.assertEqual(task_card["people"][0]["status"], "cancelled")
self.assertEqual(task_card["timestamp"], "", "cancelled 节点勿带撤销时间")
terminal = next(x for x in items if x["kind"] == "terminal")
self.assertEqual(terminal["status"], "cancelled")
self.assertEqual(terminal["title"], "已撤销")
self.assertEqual(terminal["assigneeUserId"], "u0")
self.assertEqual(terminal["assigneeUserName"], "吴子龙")
self.assertEqual(terminal["commentText"], "发起人撤销")
self.assertTrue(terminal["timestamp"])
def test_progress_returned_has_no_kind_return_node(self):
from app.services.oa_engine import build_progress_items
+20
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from types import SimpleNamespace
from app.crud.oa_instance_draft import DraftAccessError, row_to_dict
from app.services.oa_related_approval import flatten_form_fields_for_summary
class TestOaInstanceDraftRowToDict(unittest.TestCase):
@@ -49,6 +50,25 @@ class TestOaInstanceDraftRowToDict(unittest.TestCase):
err = DraftAccessError("草稿不存在或无权访问")
self.assertEqual(str(err), "草稿不存在或无权访问")
def test_preview_fields_first_three(self):
schema = {
"rule": [
{"type": "input", "field": "a", "title": "数字输入框"},
{"type": "input", "field": "b", "title": "出差事由"},
{"type": "input", "field": "c", "title": "出差天数"},
{"type": "input", "field": "d", "title": "备注"},
]
}
data = {"a": "123", "b": "开会", "c": "4", "d": "忽略"}
preview = [
{"label": f.get("label") or "", "value": f.get("value") or ""}
for f in flatten_form_fields_for_summary(schema, data)[:3]
]
self.assertEqual(len(preview), 3)
self.assertEqual(preview[0], {"label": "数字输入框", "value": "123"})
self.assertEqual(preview[1], {"label": "出差事由", "value": "开会"})
self.assertEqual(preview[2], {"label": "出差天数", "value": "4"})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,20 @@
"""storage Content-Type 推断(SVG 回显依赖正确 MIME)"""
import unittest
from app.core.storage import guess_content_type
class TestGuessContentType(unittest.TestCase):
def test_svg(self):
self.assertEqual(guess_content_type("icon.svg"), "image/svg+xml")
self.assertEqual(guess_content_type("a/b/ICON.SVG"), "image/svg+xml")
def test_png(self):
self.assertEqual(guess_content_type("a.png"), "image/png")
def test_unknown(self):
self.assertEqual(guess_content_type("noext"), "application/octet-stream")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,41 @@
# OA Detail Header Stamp Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove duplicate title on mobile OA detail and show a 72px dingding-style status stamp at the form top-right.
**Architecture:** Extract `resolveStamp` to a shared module; add `OaStatusStamp.vue`; overlay it in `OaDetail.vue` over the form host.
**Tech Stack:** Vue 3, mobile-oa-h5, Node assert tests
**Spec:** `docs/superpowers/specs/2026-08-13-oa-detail-header-stamp-design.md`
---
### Task 1: Extract `resolveStamp`
**Files:**
- Create: `mobile-oa-h5/src/oa/oa-status-stamp.js`
- Create: `mobile-oa-h5/src/oa/oa-status-stamp.test.js`
- Modify: `mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js` (re-export / import)
- [x] Write failing tests for stamp mapping
- [x] Implement `resolveStamp`
- [x] Point print module at shared export
### Task 2: `OaStatusStamp` component
**Files:**
- Create: `mobile-oa-h5/src/oa/components/OaStatusStamp.vue`
- [x] Dingding stamp DOM + 72px CSS (demo `.sm`)
- [x] Props: `status` → uses `resolveStamp`
### Task 3: Wire `OaDetail`
**Files:**
- Modify: `mobile-oa-h5/src/views/OaDetail.vue`
- [x] Remove header title + status tag
- [x] Wrap form in relative host; render stamp when `detail.status`
- [x] Drop unused `statusText` / `statusTone` imports if unused
@@ -32,7 +32,7 @@
- Create: `mobile-h5/utils/oa-corner-index.js`
- Create: `mobile-h5/utils/oa-corner-index.test.js`
- [ ] **Step 1: 写失败单测**
- [x] **Step 1: 写失败单测**
```js
// mobile-h5/utils/oa-corner-index.test.js
@@ -0,0 +1,39 @@
# 待办中心批量通过/打回 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 mobile-h5 待办中心支持标题栏管理多选,并批量通过 / 打回。
**Architecture:** 自定义顶栏对齐草稿箱;多选与批量栏对齐抄送/草稿;意见弹层后串行调用现有单条 approve/return API,不新增后端批量接口。
**Tech Stack:** uni-app (mobile-h5)、现有 oa-api / request 封装
---
### Task 1: API 封装
**Files:**
- Modify: `mobile-h5/utils/oa-api.js`
- [x] 增加 `fetchApproveOaTask(taskId, comment)`、`fetchReturnOaTask(taskId, comment)`
### Task 2: 路由自定义导航
**Files:**
- Modify: `mobile-h5/pages.json`(pending)
- [x] `navigationStyle: custom`,保留标题文案与下拉刷新配置
### Task 3: 待办页 UI + 批量逻辑
**Files:**
- Modify: `mobile-h5/pages/oa/pending/index.vue`
- [x] 自定义顶栏(返回 / 标题 / 管理)
- [x] 管理态勾选 + 批量栏
- [x] 意见弹层(通过可空、打回必填)
- [x] 串行提交与结果 toast、刷新列表
### Task 4: 手工验收
- [ ] 对照 spec §5 清单自测关键路径
@@ -0,0 +1,21 @@
# OA 附件:Vant 缩略图美化
> 状态:已确认(2026-08-13)
## 决策
保留 `van-uploader` 交互(格子预览、删除、非图点按下载、详情 disabled 无删除),仅美化样式:
1. 预览格 84px、14px 圆角、浅绿描边、轻阴影
2. 删除钮改为圆形半透明
3. 非图封面:类型色小图标 + 短文件名 + 品牌绿「点击下载」条(不展示「图片/PDF」标签)
4. 添加格:虚线框 + 绿色渐变 + 号
5. **详情只读**(`disabled` / form-create 禁用):不展示添加格、不展示右上角删除;附件仅可预览/下载
## Demo
`mobile-ui/zkxw-mobile-oa-attachment-actions-demo.html`
## 落点
`mobile-oa-h5/src/oa/components/OaUploadField.vue`
@@ -0,0 +1,24 @@
# OA 详情头区:去掉重复标题 + 状态印章
> 状态:已确认(2026-08-13)
## 背景
移动端 OA 详情(`mobile-oa-h5` `OaDetail`,经 `mobile-h5` web-view 打开)内容区顶部重复展示流程标题(顶栏已有),状态为胶囊 Tag,与 web 打印钉钉风印章不一致。
## 决策
1. **去掉**内容区顶部流程标题 `h1`(顶栏已展示)。
2. **状态 Tag 改为** web 打印同款钉钉风圆形印章(结构:外圈 / 上下弧 / `OA审批` / 主文案 / `approval`),`absolute` 叠在表单区域右上角,旋转约 `-18°`。
3. **尺寸 72px**(相对 web 打印 118px 明显缩小,遮挡更少)。
4. **文案/色类**复用 `resolveStamp(status)`(如 `completed` →「已通过」/`ok`)。
5. **范围**:`mobile-oa-h5` 详情页;列表页、关联审批弹层不在本次范围。
## 非目标
- 不改 web-admin 打印印章尺寸
- 不改移动端 PDF/系统打印 HTML 里的简化印章(仍为另一套实现)
## Demo
`mobile-ui/zkxw-mobile-oa-detail-header-stamp-demo.html`
@@ -0,0 +1,70 @@
# 待办中心 · 批量通过 / 打回
| 项 | 内容 |
|----|------|
| 状态 | 已确认 |
| 日期 | 2026-08-13 |
| 范围 | `mobile-h5` 待办中心列表页批量审批 |
| Demo | `mobile-ui/zkxw-mobile-oa-pending-batch-actions-demo.html` |
| 对齐 | 草稿箱自定义顶栏「管理」;抄送多选 / 批量栏交互 |
---
## 1. 目标
在移动端「待办中心」支持勾选多条待办,**批量通过**与**批量打回**,减少逐条进入详情审批的操作成本。
## 2. 非目标
- 不做批量拒绝(降低误操作风险)
- 不新增后端批量 approve/return API(本期前端串行调用现有单条接口)
- 不做跨页 / 全库全选
- 不改造 web-admin 待办页
## 3. 交互(SHALL)
### 3.1 顶栏
- 待办页使用 **自定义导航栏**(`navigationStyle: custom`),结构对齐草稿箱:
- 左侧返回、居中标题「待办中心」、右侧「管理」/「完成」
- 搜索区仅保留关键词检索,**不**再放管理按钮
### 3.2 管理态
- 点「管理」进入多选;点「完成」退出并清空勾选
- 管理态卡片左侧圆形勾选;选中卡片描边高亮
- 管理态点卡片 = 切换勾选;非管理态点卡片 = 进入审批详情(现有 webview)
- 已勾选时展示批量栏:「已选 N 项」+「批量打回」+「批量通过」
- 未勾选时批量按钮禁用或隐藏批量栏(与 Demo 一致:无勾选则不展示栏)
### 3.3 意见弹层
| 动作 | 标题 | 默认意见 | 校验 |
|------|------|----------|------|
| 批量通过 | 批量通过 | 「同意」 | 可改、可空(与单条同意一致) |
| 批量打回 | 批量打回 | 空 | **必填**,否则 toast「打回意见必填」 |
弹层提示:将对 N 条待办执行操作。同一意见应用于本次选中的全部任务。
### 3.4 提交与结果
- 对选中 `taskId` **串行**调用:
- 通过:`POST /web/admin/oa-approval/tasks/{id}/approve`
- 打回:`POST /web/admin/oa-approval/tasks/{id}/return`
- 结束后 toast 汇总:`成功 n,失败 m`(若全部成功可用更短文案如「已通过 n 条」)
- 成功后退出管理态、清空勾选、刷新列表
## 4. 实现要点
- API 封装:`mobile-h5/utils/oa-api.js` 增加 `fetchApproveOaTask` / `fetchReturnOaTask`
- 页面:`mobile-h5/pages/oa/pending/index.vue`(顶栏、多选、批量栏、意见弹层)
- 路由:`mobile-h5/pages.json` 待办页 `navigationStyle: custom`
- 视觉 Token:三墙绿 `#0a9f6d` 等,对齐现有草稿箱 / 抄送页
## 5. 验收
1. 标题栏有「管理」,搜索区无管理按钮
2. 管理态可勾选多条,批量通过默认「同意」且可空提交
3. 批量打回空意见被拦截
4. 部分失败时提示成功/失败数,列表刷新
5. 非管理态点卡片仍进审批详情
@@ -0,0 +1,26 @@
# OA 撤销后进度展示(方案 B)
> 日期:2026-08-13
> Demo:`mobile-ui/zkxw-mobile-oa-progress-withdraw-cancelled-demo.html` 方案 B
> 关联:`docs/superpowers/specs/2026-07-15-oa-approval-progress-timeline-design.md`
> 打印:`web-admin` `buildFlowItems` / 移动端 `exportOaApprovalPdf.buildFlowItems` 须同映射
## 问题
发起人撤销后,引擎将待办任务置为 `cancelled`。进度轴把该状态直接展示为「已撤销」,误挂在「直接主管」等未审批节点上,语义像「主管撤销」。打印 PDF「审批流程」区同样存在该问题。
## 决策(方案 B)
| 位置 | 展示 |
|------|------|
| 任务 `status=cancelled`(清待办) | 节点徽章/打印头「无需处理」、人员行「无需处理」、mute、**不展示时间**(对齐或签 `done`) |
| 实例终态 `kind=terminal` + `cancelled` | 标题/徽章「已撤销」;人员为发起人 + 文案「发起人撤销」+ 撤销时间 |
| 库表 | **不改**:任务仍存 `cancelled`,仅投影/前端映射调整 |
打回时其它人的 `cancelled` 任务同样映射为「无需处理」;打回结论仍落在操作人任务节点。
## 非目标
- 不隐藏已到达的主管节点(方案 C)
- 不改撤回/打回写库逻辑
- 实例印章「已撤销」、列表「已撤销」不变
+3 -2
View File
@@ -75,6 +75,7 @@
{
"path": "pages/oa/drafts/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "草稿箱",
"navigationBarBackgroundColor": "#ecfdf5",
"navigationBarTextStyle": "black",
@@ -86,6 +87,7 @@
{
"path": "pages/oa/pending/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "待办中心",
"navigationBarBackgroundColor": "#ecfdf5",
"navigationBarTextStyle": "black",
@@ -119,9 +121,8 @@
{
"path": "pages/oa/cc/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "抄送我的",
"navigationBarBackgroundColor": "#ecfdf5",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"onReachBottomDistance": 80,
"backgroundTextStyle": "dark"
+101 -15
View File
@@ -58,9 +58,21 @@
<view class="panel">
<view class="panel-hd">
<text class="panel-title">功能菜单</text>
<text class="hint">共 {{ menus.length }} 项</text>
<text class="hint">{{ menusHint }}</text>
</view>
<MenuGrid v-if="menus.length" :menus="menus" @select="onMenuSelect" />
<view v-if="menusLoading" class="home-menus-loading">
<view class="pulse-dots" aria-hidden="true">
<view class="pulse-dot" />
<view class="pulse-dot" />
<view class="pulse-dot" />
</view>
<text class="home-menus-loading__text">加载菜单…</text>
</view>
<MenuGrid
v-else-if="menus.length"
:menus="menus"
@select="onMenuSelect"
/>
<VanEmpty
v-else
description="暂无可用菜单权限"
@@ -91,14 +103,6 @@
icon: 'approve',
path: '/pages/oa/start/index',
iconBg: 'transparent'
},
{
id: 'oa-form-sink',
title: '表单水槽测试',
icon: 'orders-o',
path: '/pages/oa/form-sink/index',
iconBg: '#eef2f1',
iconFg: '#3d524a'
}
]
@@ -118,7 +122,8 @@
userName: '',
postName: '',
deptName: '',
menus: [...FALLBACK_MENUS],
menus: [],
menusLoading: true,
banners: [
{
id: 'banner-video-1',
@@ -128,6 +133,12 @@
]
}
},
computed: {
menusHint() {
if (this.menusLoading) return '加载中'
return `共 ${this.menus.length} 项`
}
},
onShow() {
applyUserToState(this, getUser())
fetchCurrentUser()
@@ -164,18 +175,38 @@
}
},
async loadMenus() {
// 仅首次(尚无菜单)显示加载态,避免二次 onShow 闪一下空宫格
if (!this.menus.length) {
this.menusLoading = true
}
try {
const list = await fetchMyMobileMenus()
if (list && list.length) {
this.menus = list
this.menus = list.filter((m) => {
const path = String((m && m.path) || '')
const id = String((m && m.id) || '')
return (
path.indexOf('form-sink') < 0 &&
id.indexOf('form-sink') < 0 &&
String((m && m.title) || '').indexOf('水槽') < 0
)
})
await this.refreshMenuBadges()
return
}
if (!this.menus.length) {
this.menus = [...FALLBACK_MENUS]
}
await this.refreshMenuBadges()
} catch (e) {
/* 接口未就绪或无权限时用过渡菜单 */
/* 接口未就绪时用过渡菜单,避免首页完全空白 */
if (!this.menus.length) {
this.menus = [...FALLBACK_MENUS]
}
await this.refreshMenuBadges()
} finally {
this.menusLoading = false
}
this.menus = [...FALLBACK_MENUS]
await this.refreshMenuBadges()
},
onMenuSelect(m) {
if (m.path) {
@@ -189,3 +220,58 @@
}
}
</script>
<style lang="scss" scoped>
.home-menus-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28rpx;
min-height: 240rpx;
padding: 48rpx 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: home-menu-pulse 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;
}
.home-menus-loading__text {
font-size: 26rpx;
font-weight: 500;
color: var(--text-muted, #5f7a6d);
line-height: 1.4;
}
@keyframes home-menu-pulse {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-10rpx);
}
}
</style>
+18 -11
View File
@@ -54,11 +54,12 @@
</view>
<view
class="login-submit"
:class="{ 'is-disabled': loading }"
class="login-submit oa-btn-loading"
:class="{ 'is-loading': loading }"
@click="onPasswordLogin"
>
{{ loading ? '登录中…' : '登录' }}
<text class="oa-btn-loading__label">登录</text>
<view v-if="loading" class="oa-btn-loading__spin" aria-hidden="true" />
</view>
<view class="login-agree" @click="toggleAgree">
@@ -78,23 +79,29 @@
<button
v-if="agreed"
class="login-wechat"
:class="{ 'is-disabled': loading }"
class="login-wechat oa-btn-loading"
:class="{ 'is-loading': loading }"
open-type="getPhoneNumber"
:disabled="loading"
@getphonenumber="onGetPhoneNumber"
>
<image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
<text>微信授权登录</text>
<view class="oa-btn-loading__label login-wechat__inner">
<image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
<text>微信授权登录</text>
</view>
<view v-if="loading" class="oa-btn-loading__spin" aria-hidden="true" />
</button>
<view
v-else
class="login-wechat"
:class="{ 'is-disabled': loading }"
class="login-wechat oa-btn-loading"
:class="{ 'is-loading': loading }"
@click="onWechatGate"
>
<image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
<text>微信授权登录</text>
<view class="oa-btn-loading__label login-wechat__inner">
<image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
<text>微信授权登录</text>
</view>
<view v-if="loading" class="oa-btn-loading__spin" aria-hidden="true" />
</view>
<ConfirmDialog
+11 -3
View File
@@ -51,11 +51,19 @@
<view v-if="manageMode && selectedIds.length" class="batch-bar">
<text class="batch-bar__hint">已选 {{ selectedIds.length }} 项</text>
<view
class="batch-bar__btn"
:class="{ 'is-disabled': batchReadDisabled }"
class="batch-bar__btn oa-btn-loading"
:class="{
'is-disabled': batchReadDisabled && !batchMarking,
'is-loading': batchMarking
}"
@click="batchMarkRead"
>
批量已读
<text class="oa-btn-loading__label">批量已读</text>
<view
v-if="batchMarking"
class="oa-btn-loading__spin"
aria-hidden="true"
/>
</view>
</view>
+17 -2
View File
@@ -37,7 +37,18 @@
<view v-if="manageMode && selectedIds.length" class="batch-bar">
<text class="batch-bar__hint">已选 {{ selectedIds.length }} 项</text>
<view class="batch-bar__btn" @click="batchDelete">批量删除</view>
<view
class="batch-bar__btn oa-btn-loading"
:class="{ 'is-loading': batchDeleting }"
@click="batchDelete"
>
<text class="oa-btn-loading__label">批量删除</text>
<view
v-if="batchDeleting"
class="oa-btn-loading__spin"
aria-hidden="true"
/>
</view>
</view>
<view v-if="loading && !list.length" class="state state-loading">
@@ -139,6 +150,7 @@
hasMore: true,
manageMode: false,
selectedIds: [],
batchDeleting: false,
_searchTimer: null
}
},
@@ -355,13 +367,14 @@
},
async batchDelete() {
const ids = (this.selectedIds || []).filter(Boolean)
if (!ids.length) return
if (!ids.length || this.batchDeleting) return
const ok = await this.confirm(
'批量删除',
'确认删除选中的 ' + ids.length + ' 条草稿吗?',
'删除'
)
if (!ok) return
this.batchDeleting = true
try {
await fetchBatchDeleteOaInstanceDrafts(ids)
uni.showToast({ title: '已删除', icon: 'success' })
@@ -374,6 +387,8 @@
title: (e && e.message) || '批量删除失败',
icon: 'none'
})
} finally {
this.batchDeleting = false
}
}
}
+7 -2
View File
@@ -176,10 +176,15 @@
return COPY_RESTART_STATUSES.includes(status) && !!appId
},
submitterText(row) {
const status = this.rowStatus(row)
const names = String((row && row.currentApproverNames) || '').trim()
if (names) return '待' + names + '处理'
// 已结束时后端 currentApproverNames 常为 "-",勿拼成「待-处理」
const inProgress = status === 'pending' || status === 'running'
if (inProgress && names && names !== '-') {
return '待' + names + '处理'
}
const node = String((row && row.currentNodeName) || '').trim()
if (node) return node
if (node && node !== '-') return node
return '我发起的'
},
statusText(s) {
+11 -3
View File
@@ -147,11 +147,19 @@
<view class="sheet__ft">
<view class="sheet__btn is-cancel" @click="closeBatchSheet">取消</view>
<view
class="sheet__btn"
:class="sheetKind === 'return' ? 'is-return' : 'is-ok'"
class="sheet__btn oa-btn-loading"
:class="[
sheetKind === 'return' ? 'is-return' : 'is-ok',
{ 'is-loading': batchActing }
]"
@click="confirmBatch"
>
{{ batchActing ? '处理中…' : '确定' }}
<text class="oa-btn-loading__label">确定</text>
<view
v-if="batchActing"
class="oa-btn-loading__spin"
aria-hidden="true"
/>
</view>
</view>
</view>
+4 -2
View File
@@ -8,6 +8,7 @@ import {
buildOaStartUrl,
takeOaStartTitle
} from '../../../utils/oa-h5.js'
import { navigateBackFromOaHost } from '../../../utils/oa-navigate-back.js'
import { forceRelogin, getToken } from '../../../utils/auth.js'
import { fetchOaTemplateDetail } from '../../../utils/oa-api.js'
@@ -88,8 +89,9 @@ export default {
uni.showToast({ title: msg, icon: 'none', duration: 1200 })
}
setTimeout(() => {
uni.navigateBack({
fail: () => {
navigateBackFromOaHost({
fallbackUrl: '/pages/oa/start/index',
onStuck: () => {
this._closing = false
}
})
+3 -2
View File
@@ -10,6 +10,7 @@ import {
takeOaDetailTitle,
takeOaStartTitle
} from '../../../utils/oa-h5.js'
import { navigateBackFromOaHost } from '../../../utils/oa-navigate-back.js'
import { forceRelogin, getToken } from '../../../utils/auth.js'
import { fetchOaTemplateDetail } from '../../../utils/oa-api.js'
@@ -146,8 +147,8 @@ export default {
uni.showToast({ title: msg, icon: 'none', duration: 1200 })
}
setTimeout(() => {
uni.navigateBack({
fail: () => {
navigateBackFromOaHost({
onStuck: () => {
this._closing = false
}
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+1
View File
@@ -1,4 +1,5 @@
@import './tokens.scss';
@import './components/btn-loading.scss';
page {
@include token-css-vars;
@@ -0,0 +1,40 @@
/* 按钮点击加载:文案淡化 + 居中自绘转圈(对齐 mobile-oa-h5 发起页) */
.oa-btn-loading {
position: relative;
overflow: hidden;
}
.oa-btn-loading.is-loading {
opacity: 1 !important;
pointer-events: none;
}
.oa-btn-loading.is-loading .oa-btn-loading__label {
opacity: 0.25;
}
.oa-btn-loading__spin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.06);
pointer-events: none;
}
.oa-btn-loading__spin::before {
content: '';
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: oa-btn-loading-spin 0.7s linear infinite;
}
@keyframes oa-btn-loading-spin {
to {
transform: rotate(360deg);
}
}
+5 -6
View File
@@ -93,9 +93,6 @@
justify-content: center;
}
.login-submit.is-disabled {
opacity: 0.55;
}
.login-agree {
display: flex;
@@ -179,9 +176,11 @@ button.login-wechat::after {
border: none;
}
.login-wechat.is-disabled {
opacity: 0.45;
pointer-events: none;
.login-wechat__inner {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.login-wechat__icon {
+1 -1
View File
@@ -4,7 +4,7 @@
*
* 微信公众平台须配置 request / 业务域名:www.chengyantek.cn(HTTPS,安全组放行 443)
*/
const USE_REMOTE_TEST = true
const USE_REMOTE_TEST = false
/** 本机联调 */
const LOCAL_API_BASE_URL = 'http://127.0.0.1:8000/api'
+18
View File
@@ -86,6 +86,7 @@ export function fetchSubmitOaInstanceDraft(id, data) {
export function fetchOaTasks(kind, params) {
const p = params || {}
const keyword = (p.keyword || '').trim()
const appName = (p.appName || '').trim()
const initiatorName = (p.initiatorName || '').trim()
const query = {
@@ -95,6 +96,7 @@ export function fetchOaTasks(kind, params) {
if (kind === 'cc') {
query.readStatus = p.readStatus != null && p.readStatus !== '' ? p.readStatus : 'unread'
}
if (keyword) query.keyword = keyword
if (appName) query.appName = appName
if (initiatorName) query.initiatorName = initiatorName
return get(
@@ -126,3 +128,19 @@ export function fetchBatchMarkOaCcTasksRead(ids) {
authHeader()
)
}
export function fetchApproveOaTask(taskId, comment) {
return post(
'/web/admin/oa-approval/tasks/' + encodeURIComponent(taskId) + '/approve',
{ comment: comment || undefined },
authHeader()
)
}
export function fetchReturnOaTask(taskId, comment) {
return post(
'/web/admin/oa-approval/tasks/' + encodeURIComponent(taskId) + '/return',
{ comment: comment || undefined },
authHeader()
)
}
+78
View File
@@ -0,0 +1,78 @@
/**
* H5 上 uni.navigateBack 偶发既不成功也不进 fail;离开 OA web-view 时做兜底。
* @param {{ fallbackUrl?: string, onStuck?: () => void }} [opts]
*/
export function navigateBackFromOaHost(opts) {
const onStuck = opts && opts.onStuck
const currentRoute = () => {
try {
const pages = getCurrentPages()
const cur = pages && pages[pages.length - 1]
return String((cur && (cur.route || (cur.$page && cur.$page.fullPath))) || '')
} catch (_) {
return ''
}
}
const stillOnWebview = () => {
const route = currentRoute()
return (
route.indexOf('oa/start/webview') !== -1 ||
route.indexOf('oa/webview/') !== -1
)
}
const resolveFallbackUrl = () => {
const explicit = String((opts && opts.fallbackUrl) || '').trim()
if (explicit) return explicit
try {
const pages = getCurrentPages()
if (pages && pages.length >= 2) {
const prev = pages[pages.length - 2]
const route = prev && prev.route
if (route) return '/' + String(route).replace(/^\//, '')
}
} catch (_) {
/* ignore */
}
return '/pages/oa/start/index'
}
const fallback = () => {
const fallbackUrl = resolveFallbackUrl()
if (!fallbackUrl) {
if (onStuck) onStuck()
return
}
uni.redirectTo({
url: fallbackUrl,
fail: () => {
uni.reLaunch({
url: fallbackUrl,
fail: () => {
if (onStuck) onStuck()
}
})
}
})
}
try {
const pages = getCurrentPages()
if (pages && pages.length > 1) {
uni.navigateBack({
delta: 1,
fail: () => fallback()
})
// H5:navigateBack 偶发静默失败
setTimeout(() => {
if (stillOnWebview()) fallback()
}, 280)
return
}
} catch (_) {
/* fall through */
}
fallback()
}
+8
View File
@@ -38,6 +38,14 @@ export function uploadAttachment(file) {
})
}
/** 获取带 Content-Disposition 的下载地址(保留原始文件名) */
export function fetchAttachmentDownloadUrl({ fileUrl, fileName } = {}) {
return post('/web/admin/attachment/download-url', {
fileUrl,
fileName: fileName || undefined
})
}
// —— 发起审批运行时(对齐 web-admin oaApproval.ts)——
export function fetchOaGroupList() {
+1
View File
@@ -6,6 +6,7 @@ import App from './App.vue'
import router from './router'
import './styles/tokens.css'
import './styles/oa-form.css'
import './styles/oa-btn-loading.css'
import { registerOaComponents } from './oa/register-components'
import { captureTokenFromUrl } from './utils/auth'
+60
View File
@@ -0,0 +1,60 @@
/**
* 通知宿主(小程序 web-view / uni-app H5 iframe)关闭并返回上一页。
* @param {object} payload
* @param {{ wx?: any, uni?: any, window?: any, showToast?: (msg: string) => void, fallbackMessage?: string }} [deps]
* @returns {'wx' | 'iframe' | 'uni' | 'toast' | 'none'}
*/
export function closeToHost(payload, deps = {}) {
const data = payload || { action: 'oa-close' }
const wxApi = deps.wx !== undefined ? deps.wx : typeof wx !== 'undefined' ? wx : undefined
const uniApi = deps.uni !== undefined ? deps.uni : typeof uni !== 'undefined' ? uni : undefined
const win = deps.window !== undefined ? deps.window : typeof window !== 'undefined' ? window : undefined
const toast =
deps.showToast ||
((msg) => {
/* no-op without toast */
void msg
})
const fallbackMessage = deps.fallbackMessage || '已完成(非小程序环境)'
try {
if (wxApi?.miniProgram?.navigateBack) {
// 小程序宿主不展示 payload.message,须在 web-view 内提示
if (data && data.message) toast(String(data.message))
wxApi.miniProgram.postMessage?.({ data })
wxApi.miniProgram.navigateBack()
return 'wx'
}
} catch (_) {
/* ignore */
}
// H5 嵌入:优先 parent postMessage,避免 uni.webView.navigateBack 空操作后提前 return
// 成功文案由宿主 uni.showToast 展示,此处不再 toast
const inIframe = !!(win && win.parent && win.parent !== win)
if (inIframe) {
try {
win.parent.postMessage({ source: 'zkxw-oa-h5', ...data }, '*')
return 'iframe'
} catch (_) {
/* fall through */
}
}
try {
const uniWv = uniApi?.webView || null
if (uniWv?.postMessage) {
uniWv.postMessage({ data })
}
if (uniWv?.navigateBack) {
if (data && data.message) toast(String(data.message))
uniWv.navigateBack()
return 'uni'
}
} catch (_) {
/* ignore */
}
toast((data && data.message) || fallbackMessage)
return 'toast'
}
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import { closeToHost } from './close-to-host.js'
// 微信小程序:走 wx,不碰 iframe / uni
{
const calls = []
const result = closeToHost(
{ action: 'oa-start-submitted', message: '提交成功' },
{
wx: {
miniProgram: {
postMessage: (p) => calls.push(['post', p]),
navigateBack: () => calls.push(['back'])
}
},
window: { parent: {} },
uni: { webView: { navigateBack: () => calls.push(['uni-back']) } },
showToast: (m) => calls.push(['toast', m])
}
)
assert.equal(result, 'wx')
assert.deepEqual(
calls.map((c) => c[0]),
['toast', 'post', 'back']
)
assert.equal(calls[0][1], '提交成功')
}
// H5 iframe:即使存在 uni.webView.navigateBack,也必须走 parent postMessage(修复空操作提前 return)
{
const calls = []
const parent = {
postMessage: (payload, origin) => calls.push(['parent', payload, origin])
}
const win = { parent }
const result = closeToHost(
{ action: 'oa-start-submitted', message: '提交成功' },
{
wx: undefined,
window: win,
uni: {
webView: {
postMessage: () => calls.push(['uni-post']),
navigateBack: () => calls.push(['uni-back'])
}
},
showToast: (m) => calls.push(['toast', m])
}
)
assert.equal(result, 'iframe')
assert.equal(calls.length, 1)
assert.equal(calls[0][0], 'parent')
assert.equal(calls[0][1].source, 'zkxw-oa-h5')
assert.equal(calls[0][1].action, 'oa-start-submitted')
assert.equal(calls[0][1].message, '提交成功')
assert.equal(calls[0][2], '*')
}
// 非 iframe:走 uni.webView
{
const calls = []
const win = {}
win.parent = win
const result = closeToHost(
{ action: 'oa-start-draft', message: '草稿已保存' },
{
wx: undefined,
window: win,
uni: {
webView: {
postMessage: (p) => calls.push(['uni-post', p]),
navigateBack: () => calls.push(['uni-back'])
}
},
showToast: (m) => calls.push(['toast', m])
}
)
assert.equal(result, 'uni')
assert.deepEqual(
calls.map((c) => c[0]),
['uni-post', 'toast', 'uni-back']
)
assert.equal(calls[1][1], '草稿已保存')
}
// 无宿主:toast 兜底,优先用 payload.message
{
const calls = []
const win = {}
win.parent = win
const result = closeToHost(
{ action: 'oa-start-submitted', message: '提交成功' },
{
wx: undefined,
window: win,
uni: undefined,
showToast: (m) => calls.push(m)
}
)
assert.equal(result, 'toast')
assert.deepEqual(calls, ['提交成功'])
}
console.log('close-to-host.test.js: ok')
@@ -0,0 +1,10 @@
<template>
<span class="oa-btn-loading__label"><slot /></span>
<span v-if="loading" class="oa-btn-loading__spin" aria-hidden="true" />
</template>
<script setup>
defineProps({
loading: { type: Boolean, default: false }
})
</script>
@@ -4,13 +4,13 @@
ref="fieldRef"
:model-value="displayValue"
:placeholder="placeholderText"
:disabled="disabled"
:disabled="isReadonly"
readonly
:border="false"
is-link
:is-link="!isReadonly"
@click="open"
>
<template v-if="clearable && displayValue" #right-icon>
<template v-if="showClear" #right-icon>
<i class="van-badge__wrapper van-icon van-icon-clear van-field__clear" @click.stop="clear" />
</template>
</van-field>
@@ -48,7 +48,7 @@
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, useAttrs, watch } from 'vue'
defineOptions({ name: 'fcDatePicker' })
@@ -67,6 +67,17 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue', 'change', 'fc.el'])
const attrs = useAttrs()
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 show = ref(false)
const fieldRef = ref(null)
@@ -132,6 +143,11 @@ const displayValue = computed(() => {
return String(v)
})
/** 详情只读不展示清除图标 */
const showClear = computed(
() => props.clearable && !!displayValue.value && !isReadonly.value
)
function toDateObj(v) {
if (v == null || v === '') return undefined
if (v instanceof Date && !Number.isNaN(v.getTime())) return v
@@ -219,7 +235,7 @@ function emitValue(val) {
}
function open() {
if (props.disabled) return
if (isReadonly.value) return
const parsed = parseModelValue(props.modelValue)
dateParts.value = parsed.date.slice(0, dateColumnsType.value.length)
timeParts.value = parsed.time.slice(0, timeColumnsType.value.length)
@@ -239,6 +255,7 @@ function onDateTimeConfirm() {
}
function clear() {
if (isReadonly.value) return
emitValue('')
}
@@ -0,0 +1,250 @@
<!-- 关联审批单 · 只读详情底部弹层(对齐 web-admin OaInstanceDetailDialog) -->
<template>
<van-popup
:show="modelValue"
position="bottom"
round
:style="{ height: '88%' }"
teleport="body"
@update:show="onShowUpdate"
>
<div class="oa-detail-sheet">
<div class="oa-detail-sheet__hd">
<span class="oa-detail-sheet__close" @click="close">关闭</span>
<div class="oa-detail-sheet__hd-main">
<strong class="oa-detail-sheet__title">{{ sheetTitle }}</strong>
<span
v-if="detail?.status"
class="oa-detail-sheet__status"
:class="statusTone(detail.status)"
>
{{ statusText(detail.status) }}
</span>
</div>
<span class="oa-detail-sheet__hd-spacer" aria-hidden="true" />
</div>
<div class="oa-detail-sheet__body">
<div v-if="loading" class="oa-detail-sheet__loading">
<OaPulseLoading text="加载审批详情…" />
</div>
<template v-else>
<form-create-mobile
v-if="formRule.length"
v-model="formData"
:rule="formRule"
:option="formOption"
:disabled="true"
/>
<p v-else class="oa-detail-sheet__empty">暂无表单内容</p>
<OaProgressTimeline :items="timelineItems" />
</template>
</div>
</div>
</van-popup>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { showToast } from 'vant'
import { fetchOaInstanceDetail } from '../../api/oa.js'
import { prepareMobileFormSchema } from '../schema-prep.js'
import { statusText, statusTone } from '../status-label.js'
import OaProgressTimeline from './OaProgressTimeline.vue'
import OaPulseLoading from './OaPulseLoading.vue'
defineOptions({ name: 'OaInstanceDetailSheet' })
const props = defineProps({
modelValue: { type: Boolean, default: false },
instanceId: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const loading = ref(false)
const detail = ref(null)
const formData = ref({})
const formRule = ref([])
const formOption = ref({
form: { disabled: true, labelAlign: 'top' },
submitBtn: false,
resetBtn: false
})
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 || ''
}))
: []
}))
})
function parseFormData(d) {
if (d?.formData && typeof d.formData === 'object') return { ...d.formData }
if (d?.formDataJson && typeof d.formDataJson === 'object') return { ...d.formDataJson }
return {}
}
function onShowUpdate(v) {
emit('update:modelValue', v)
}
function close() {
emit('update:modelValue', false)
}
async function loadDetail(id) {
const iid = String(id || '').trim()
if (!iid) return
loading.value = true
detail.value = null
formRule.value = []
formData.value = {}
try {
const d = await fetchOaInstanceDetail(iid)
detail.value = d
const prepared = prepareMobileFormSchema(d?.formSchemaJson)
formRule.value = prepared.rule
formOption.value = {
...prepared.option,
form: {
...(prepared.option?.form || {}),
disabled: true
},
submitBtn: false,
resetBtn: false
}
formData.value = parseFormData(d)
} catch (e) {
showToast(e?.message || '加载失败')
close()
} finally {
loading.value = false
}
}
watch(
() => [props.modelValue, props.instanceId],
([open, id]) => {
if (open && id) loadDetail(id)
}
)
</script>
<style scoped>
.oa-detail-sheet {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-page, #ffffff);
}
.oa-detail-sheet__hd {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid #f1f5f9;
}
.oa-detail-sheet__close {
flex-shrink: 0;
width: 48px;
color: #64748b;
font-size: 15px;
}
.oa-detail-sheet__hd-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.oa-detail-sheet__title {
max-width: 100%;
overflow: hidden;
color: #0f172a;
font-size: 16px;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-detail-sheet__hd-spacer {
flex-shrink: 0;
width: 48px;
}
.oa-detail-sheet__status {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.oa-detail-sheet__status.ok {
color: var(--success, #10b981);
background: var(--brand-green-soft, #ecfdf5);
}
.oa-detail-sheet__status.warn {
color: var(--warning, #d97706);
background: var(--barley-yellow-soft, #fffbeb);
}
.oa-detail-sheet__status.danger {
color: var(--danger, #ef4444);
background: #fef2f2;
}
.oa-detail-sheet__status.mute {
color: var(--text-muted, #5f7a6d);
background: #f1f5f9;
}
.oa-detail-sheet__body {
flex: 1;
overflow: auto;
padding: 12px 12px 24px;
-webkit-overflow-scrolling: touch;
}
.oa-detail-sheet__loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 48px 16px;
}
.oa-detail-sheet__empty {
margin: 12px 0;
padding: 16px 8px;
color: var(--text-muted, #5f7a6d);
font-size: 13px;
text-align: center;
}
</style>
@@ -14,18 +14,99 @@
<span
v-if="item.status"
class="oa-progress-badge"
:class="statusTone(item.status)"
:class="nodeStatusTone(item)"
>
{{ statusText(item.status) }}
{{ nodeStatusText(item) }}
</span>
<span v-if="signBadge(item)" class="oa-progress-badge" :class="signBadge(item)">
{{ signBadgeText(item) }}
</span>
<span v-else-if="item.taskType === 'cc'" class="oa-progress-badge cc">抄送</span>
<span v-else-if="item.taskType === 'end'" class="oa-progress-badge mute">结束</span>
</div>
<div v-if="item.assignee" class="oa-progress-card__meta">
{{ item.assignee }}
<!-- 会签/或签等:按人分行 -->
<div v-if="personLines(item).length" class="oa-person-lines">
<div
v-for="(line, li) in personLines(item)"
:key="`${item.key}-pl-${li}`"
class="oa-person-block"
:class="{
'is-ok': line.tone === 'ok',
'is-pending': line.tone === 'pending',
'is-danger': line.tone === 'danger',
'is-mute': line.tone === 'mute'
}"
>
<div class="oa-person-line">
<span class="oa-person-line__body">
<template v-if="line.timestamp || line.isOpinion">{{ line.name }}</template>
<template v-else>{{ line.body }}</template>
</span>
<span v-if="line.timestamp" class="oa-person-line__time">{{ line.timestamp }}</span>
</div>
<div v-if="line.detail && (line.timestamp || line.isOpinion)" class="oa-person-line__opinion">
<template v-if="line.isOpinion">意见:{{ line.detail }}</template>
<template v-else>{{ line.detail }}</template>
</div>
</div>
</div>
<div v-if="item.timestamp" class="oa-progress-card__meta">
{{ item.timestamp }}
<!-- 抄送:姓名字号/颜色与审批人已处理态一致 -->
<div v-else-if="ccPersonLines(item).length" class="oa-person-lines">
<div
v-for="(line, li) in ccPersonLines(item)"
:key="`${item.key}-cc-${li}`"
class="oa-person-block is-ok"
>
<div class="oa-person-line">
<span class="oa-person-line__body">{{ line.name }}</span>
<span v-if="line.timestamp" class="oa-person-line__time">{{ line.timestamp }}</span>
</div>
</div>
</div>
<div v-if="item.commentText" class="oa-progress-card__comment">
<!-- 发起人:姓名颜色与审批人已处理态一致 -->
<div
v-else-if="item.kind === 'submit' && (item.assignee || item.timestamp)"
class="oa-person-lines"
>
<div class="oa-person-block is-ok">
<div class="oa-person-line">
<span v-if="item.assignee" class="oa-person-line__body">{{ item.assignee }}</span>
<span v-if="item.timestamp" class="oa-person-line__time">{{ item.timestamp }}</span>
</div>
</div>
</div>
<!-- 终态撤销:发起人 +「发起人撤销」+ 时间(勿用「意见:」前缀) -->
<div
v-else-if="item.kind === 'terminal' && item.status === 'cancelled' && (item.assignee || item.commentText || item.timestamp)"
class="oa-person-lines"
>
<div class="oa-person-block is-danger">
<div class="oa-person-line">
<span class="oa-person-line__body">
<template v-if="item.assignee">{{ item.assignee }}</template>
<template v-if="item.commentText">
<template v-if="item.assignee"> </template>{{ item.commentText }}
</template>
</span>
<span v-if="item.timestamp" class="oa-person-line__time">{{ item.timestamp }}</span>
</div>
</div>
</div>
<!-- 无 people:姓名与时间同一行,时间右对齐 -->
<div
v-else-if="item.assignee || item.timestamp"
class="oa-person-line oa-person-line--fallback"
>
<span v-if="item.assignee" class="oa-person-line__body">{{ item.assignee }}</span>
<span v-if="item.timestamp" class="oa-person-line__time">{{ item.timestamp }}</span>
</div>
<div v-if="showNodeComment(item)" class="oa-progress-card__comment">
意见:{{ item.commentText }}
</div>
</div>
@@ -36,13 +117,76 @@
<script setup>
import { statusText, statusTone } from '../status-label.js'
import {
formatCcPersonLines,
formatProgressPersonLines
} from '../oa-progress-person-lines.js'
defineProps({
items: { type: Array, default: () => [] }
})
function displayPeople(item) {
const list = Array.isArray(item?.people)
? item.people.filter((p) => p?.nickName || p?.id)
: []
if (!list.length) return []
return list.map((p) => ({
...p,
nickName: String(p.nickName || p.id || '').trim()
}))
}
function personLines(item) {
if (item?.kind === 'terminal' || item?.kind === 'submit') return []
if (item?.taskType === 'cc' || item?.signMode === 'cc') return []
const list = displayPeople(item)
if (!list.length) return []
return formatProgressPersonLines(list)
}
function ccPersonLines(item) {
return formatCcPersonLines(item)
}
function showNodeComment(item) {
// 有按人行时不再展示节点级拼接意见,避免双份
if (personLines(item).length) return false
// 撤销终态已在人员区展示「发起人撤销」,勿再套「意见:」
if (item?.kind === 'terminal' && item?.status === 'cancelled') return false
return Boolean(item?.commentText)
}
function signBadge(item) {
if (item?.taskType === 'cc' || item?.signMode === 'cc') return 'cc'
if (item?.signMode === 'and') return 'and'
if (item?.signMode === 'or') return 'or'
return ''
}
function signBadgeText(item) {
const k = signBadge(item)
if (k === 'cc') return '抄送'
if (k === 'and') return '会签'
if (k === 'or') return '或签'
return ''
}
/** 任务节点 cancelled=系统清待办 →「无需处理」;终态 cancelled 仍为「已撤销」 */
function nodeStatusText(item) {
const s = String(item?.status || '').trim()
if (item?.kind !== 'terminal' && s === 'cancelled') return '无需处理'
return statusText(s)
}
function nodeStatusTone(item) {
const s = String(item?.status || '').trim()
if (item?.kind !== 'terminal' && s === 'cancelled') return 'mute'
return statusTone(s)
}
function dotClass(item) {
return statusTone(item?.status)
return nodeStatusTone(item)
}
</script>
@@ -82,6 +226,9 @@ function dotClass(item) {
height: 10px;
margin-top: 6px;
border-radius: 50%;
background: #94a3b8;
}
.oa-progress-dot.ok {
background: var(--brand-green, #22c55e);
}
.oa-progress-dot.warn {
@@ -137,11 +284,81 @@ function dotClass(item) {
color: var(--text-muted, #5f7a6d);
background: #f1f5f9;
}
.oa-progress-card__meta {
margin-top: 4px;
.oa-progress-badge.and {
color: #2563eb;
background: #eff6ff;
}
.oa-progress-badge.or {
color: #d97706;
background: #fff7ed;
}
.oa-progress-badge.cc {
color: #0891b2;
background: #ecfeff;
}
.oa-person-lines {
margin-top: 8px;
display: grid;
gap: 10px;
}
.oa-person-block {
min-width: 0;
color: var(--text-primary, #0f291e);
font-size: 12px;
line-height: 1.55;
}
.oa-person-line {
display: flex;
gap: 8px;
align-items: baseline;
justify-content: space-between;
min-width: 0;
}
.oa-person-line--fallback {
margin-top: 6px;
color: var(--text-muted, #5f7a6d);
font-size: 12px;
line-height: 1.4;
line-height: 1.55;
}
.oa-person-line__body {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
font-size: 12px;
font-weight: 500;
}
.oa-person-line__time {
flex: 0 0 auto;
margin-left: auto;
color: var(--text-muted, #5f7a6d);
font-size: 11px;
line-height: 1.55;
text-align: right;
white-space: nowrap;
font-weight: 400;
}
.oa-person-line__opinion {
margin-top: 4px;
padding: 6px 8px;
border-radius: 6px;
background: #f8fafc;
color: var(--text-primary, #0f291e);
font-size: 12px;
font-weight: 400;
line-height: 1.5;
word-break: break-word;
}
.oa-person-block.is-ok .oa-person-line__body {
color: #059669;
}
.oa-person-block.is-pending .oa-person-line__body {
color: #d97706;
}
.oa-person-block.is-danger .oa-person-line__body {
color: #dc2626;
}
.oa-person-block.is-mute .oa-person-line__body {
color: #94a3b8;
}
.oa-progress-card__comment {
margin-top: 6px;
@@ -3,13 +3,14 @@
<van-field
:model-value="summary"
readonly
is-link
:is-link="!disabled"
input-align="left"
:disabled="disabled"
placeholder="请选择收款账户"
@click="openPicker"
/>
<div v-if="normalized.length" class="oa-recv__detail">
<!-- 单账户已在 summary 展示完整行,避免详情/发起页重复两遍 -->
<div v-if="normalized.length > 1" class="oa-recv__detail">
<div v-for="item in normalized" :key="item.id" class="oa-recv__line">
{{ formatReceiveAccountLine(item) }}
</div>
@@ -12,19 +12,34 @@
<div class="oa-rel__head">
<div class="oa-rel__title">
{{ item.unavailable ? '无法查看' : item.appName || '审批单' }}
<template v-if="!item.unavailable && item.applyTime">
· {{ item.applyTime }}
</template>
</div>
<van-button
v-if="!disabled"
size="mini"
type="danger"
plain
@click="removeId(item.id)"
>
删除
</van-button>
<div class="oa-rel__actions">
<button
v-if="!item.unavailable"
type="button"
class="oa-rel__icon-btn"
aria-label="查看详情"
@click.stop="goDetail(item)"
>
<van-icon name="eye-o" />
</button>
<button
v-if="!disabled"
type="button"
class="oa-rel__icon-btn is-danger"
aria-label="删除"
@click.stop="removeId(item.id)"
>
<van-icon name="delete-o" />
</button>
</div>
</div>
<div
v-if="!item.unavailable && item.applyTime"
class="oa-rel__field"
>
<span class="oa-rel__label">申请时间</span>
<span>{{ item.applyTime }}</span>
</div>
<div
v-for="(f, idx) in (item.fields || []).slice(0, 3)"
@@ -128,6 +143,8 @@
</div>
</div>
</van-popup>
<OaInstanceDetailSheet v-model="detailVisible" :instance-id="detailInstanceId" />
</div>
</template>
@@ -135,6 +152,7 @@
import { computed, ref, watch } from 'vue'
import { showToast } from 'vant'
import { fetchRelatedCandidates, fetchRelatedSummaries } from '../../api/oa.js'
import OaInstanceDetailSheet from './OaInstanceDetailSheet.vue'
defineOptions({ name: 'relatedApproval' })
@@ -148,6 +166,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'change'])
const visible = ref(false)
const detailVisible = ref(false)
const detailInstanceId = ref('')
const pickerLoading = ref(false)
const keyword = ref('')
const activeTab = ref('mine')
@@ -194,6 +214,16 @@ function removeId(id) {
emitValue(ids.value.filter((x) => x !== id))
}
function goDetail(item) {
const id = String(item?.id || '').trim()
if (!id || item?.unavailable) {
showToast('无法查看该审批单')
return
}
detailInstanceId.value = id
detailVisible.value = true
}
function openPicker() {
if (props.disabled) return
pendingIds.value = []
@@ -321,7 +351,8 @@ watch(visible, (v) => {
<style scoped>
.oa-rel {
padding: 4px 12px 8px;
/* 与行程明细等块级控件一致:左右贴齐表单项内容区,卡片更宽 */
padding: 4px 0 8px;
}
.oa-rel__empty {
display: flex;
@@ -348,10 +379,43 @@ watch(visible, (v) => {
}
.oa-rel__title {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #0f291e);
}
.oa-rel__actions {
display: flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
}
.oa-rel__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 18px;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.oa-rel__icon-btn:active {
background: #f1f5f9;
}
.oa-rel__icon-btn.is-danger {
color: #ef4444;
}
.oa-rel__icon-btn.is-danger:active {
background: #fef2f2;
}
.oa-rel__field {
display: flex;
gap: 8px;
@@ -0,0 +1,142 @@
<!-- OA 详情状态印章 · 对齐 web 打印钉钉风,移动端 72px -->
<template>
<div
v-if="stampText"
class="oa-stamp"
:class="[stampClass, { 'is-chars3': stampText.length >= 3 }]"
:aria-label="stampText"
>
<div class="oa-stamp__outer" />
<div class="oa-stamp__arc oa-stamp__arc--top" />
<div class="oa-stamp__arc oa-stamp__arc--bottom" />
<div class="oa-stamp__label-top">OA审批</div>
<div class="oa-stamp__main" :class="{ 'is-chars3': stampText.length >= 3 }">
{{ stampText }}
</div>
<div class="oa-stamp__label-bottom">approval</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { resolveStamp } from '../oa-status-stamp.js'
const props = defineProps({
status: { type: String, default: '' }
})
const resolved = computed(() => resolveStamp(props.status))
const stampClass = computed(() => resolved.value.stampClass)
const stampText = computed(() => resolved.value.stampText)
</script>
<style scoped>
.oa-stamp {
--stamp: #3ec9a8;
position: absolute;
top: 8px;
right: 8px;
width: 72px;
height: 72px;
z-index: 5;
pointer-events: none;
transform: rotate(-18deg);
opacity: 0.88;
color: var(--stamp);
}
.oa-stamp.ok {
--stamp: #3ec9a8;
}
.oa-stamp.run {
--stamp: #f0a020;
}
.oa-stamp.rej {
--stamp: #e85d5d;
}
.oa-stamp.ret {
--stamp: #e07070;
}
.oa-stamp.can {
--stamp: #9aa0a6;
}
.oa-stamp__outer {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid currentColor;
box-sizing: border-box;
}
.oa-stamp__arc {
position: absolute;
left: 8px;
right: 8px;
height: 20px;
border: 2px solid currentColor;
opacity: 0.95;
pointer-events: none;
}
.oa-stamp__arc--top {
top: 7px;
border-bottom: none;
border-left-color: transparent;
border-right-color: transparent;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}
.oa-stamp__arc--bottom {
bottom: 7px;
border-top: none;
border-left-color: transparent;
border-right-color: transparent;
border-radius: 0 0 50% 50% / 0 0 100% 100%;
}
.oa-stamp__label-top {
position: absolute;
top: 13px;
left: 0;
right: 0;
text-align: center;
font-size: 7px;
font-weight: 500;
letter-spacing: 1px;
line-height: 1;
color: currentColor;
opacity: 0.95;
}
.oa-stamp__label-bottom {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
text-align: center;
font-size: 6px;
font-weight: 500;
letter-spacing: 0.5px;
line-height: 1;
color: currentColor;
opacity: 0.9;
font-family: Arial, 'Helvetica Neue', sans-serif;
}
.oa-stamp__main {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 800;
letter-spacing: 1px;
line-height: 1;
color: currentColor;
font-family:
'PingFang SC',
'Microsoft YaHei',
'Helvetica Neue',
Arial,
sans-serif;
-webkit-text-stroke: 0.3px currentColor;
}
.oa-stamp__main.is-chars3 {
font-size: 14px;
letter-spacing: 0.5px;
}
</style>
@@ -4,15 +4,15 @@
<div v-for="(leg, idx) in inner" :key="idx" class="oa-trip__leg">
<div class="oa-trip__head">
<strong class="oa-trip__title">行程 {{ idx + 1 }}</strong>
<van-button
<button
v-if="!disabled && inner.length > 1"
size="mini"
type="danger"
plain
type="button"
class="oa-trip__icon-btn is-danger"
aria-label="删除"
@click="remove(idx)"
>
删除
</van-button>
<van-icon name="delete-o" />
</button>
</div>
<van-field
@@ -372,6 +372,36 @@ function onDateConfirm({ selectedValues }) {
font-weight: 600;
}
.oa-trip__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 18px;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.oa-trip__icon-btn:active {
background: #f1f5f9;
}
.oa-trip__icon-btn.is-danger {
color: #ef4444;
}
.oa-trip__icon-btn.is-danger:active {
background: #fef2f2;
}
.oa-trip__row {
display: flex;
gap: 10px;
+365 -14
View File
@@ -1,15 +1,50 @@
<template>
<div class="oa-upload">
<div class="oa-upload" :class="{ 'is-readonly': isReadonly }">
<van-uploader
v-model="fileList"
:disabled="disabled"
:disabled="false"
:readonly="isReadonly"
:deletable="!isReadonly"
:max-count="limit"
:accept="accept"
:capture="captureAttr"
:preview-size="84"
:show-upload="!isReadonly"
:after-read="onAfterRead"
:before-delete="onBeforeDelete"
multiple
/>
@click-preview="onClickPreview"
>
<div class="oa-upload__add">
<div class="oa-upload__add-plus">+</div>
<div class="oa-upload__add-txt">添加</div>
</div>
<template #preview-cover="slotProps">
<div
v-if="!isImageFileItem(slotProps)"
class="oa-upload__file-cover"
>
<div
class="oa-upload__file-ico"
:class="'ft-' + fileKind(slotProps)"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<path d="M14 2v6h6" />
<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__dl-hint">点击下载</div>
</div>
</template>
</van-uploader>
<div v-if="tipText" class="oa-upload__tip">
{{ tipText }}
</div>
@@ -17,14 +52,17 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, useAttrs, watch } from 'vue'
import { showToast } from 'vant'
import { uploadAttachment } from '../../api/oa.js'
import { attachmentFullUrl } from '../../utils/attachment.js'
import { downloadAttachmentFile } from '../../utils/download-attachment.js'
import { getToken } from '../../utils/auth.js'
defineOptions({ name: 'upload' })
const IMAGE_REGEXP = /\.(jpeg|jpg|gif|png|svg|webp|jfif|bmp|dpg|avif)(\?|$)/i
const props = defineProps({
modelValue: { type: [Array, String, Object], default: () => [] },
disabled: { type: Boolean, default: false },
@@ -39,13 +77,97 @@ const props = defineProps({
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs()
/** 详情/只读:隐藏添加与删除,仅保留预览/下载 */
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 captureAttr = computed(() => (props.capture === 'camera' ? 'camera' : undefined))
const tipText = computed(() => {
if (isReadonly.value) return ''
if (!getToken()) return '未携带登录 token,上传可能失败'
return ''
})
/** url → 原始文件名(表单只存 URL,回显时靠此映射) */
const nameByUrl = ref({})
function displayNameFromUrl(url) {
const raw = String(url || '').trim()
if (!raw) return ''
try {
const path = raw.split('?')[0] || raw
const last = path.split('/').pop() || ''
return decodeURIComponent(last) || ''
} catch {
return ''
}
}
function rememberName(url, name) {
const u = String(url || '').trim()
const n = String(name || '').trim()
if (!u || !n) return
nameByUrl.value = { ...nameByUrl.value, [u]: n }
}
function resolveDisplayName(url, fallbackName) {
const u = String(url || '').trim()
return (
(u && nameByUrl.value[u]) ||
String(fallbackName || '').trim() ||
displayNameFromUrl(u) ||
'未命名文件'
)
}
function isImageFileItem(item) {
if (!item) return false
if (item.isImage === true) return true
if (item.file && item.file.type && String(item.file.type).startsWith('image/')) {
return true
}
const name = String((item.file && item.file.name) || item.name || '')
if (IMAGE_REGEXP.test(name)) return true
return IMAGE_REGEXP.test(String(item.url || item.content || ''))
}
function itemName(item) {
const url = String(item?.url || '').trim()
return resolveDisplayName(url, (item?.file && item.file.name) || item?.name)
}
function shortDisplayName(item) {
const name = itemName(item)
if (name.length <= 12) return name
const i = name.lastIndexOf('.')
if (i > 0 && name.length - i <= 5) {
const ext = name.slice(i)
const base = name.slice(0, i)
const keep = Math.max(4, 10 - ext.length)
return base.slice(0, keep) + '…' + ext
}
return name.slice(0, 10) + '…'
}
/** 仅用于类型色图标,不展示「PDF」等文案 */
function fileKind(item) {
const n = String(itemName(item) || item?.url || '').toLowerCase()
if (n.includes('.pdf')) return 'pdf'
if (/\.(xls|xlsx|csv)(\?|$)/.test(n) || n.includes('.xls')) return 'xls'
if (/\.(doc|docx)(\?|$)/.test(n) || n.includes('.doc')) return 'doc'
if (/\.(zip|rar|7z)(\?|$)/.test(n)) return 'zip'
return 'other'
}
function toUrlList(raw) {
if (raw == null || raw === '') return []
if (Array.isArray(raw)) {
@@ -53,7 +175,10 @@ function toUrlList(raw) {
.map((x) => {
if (typeof x === 'string') return attachmentFullUrl(x) || x
if (x && typeof x === 'object') {
return attachmentFullUrl(String(x.url || x.fileUrl || '')) || ''
const u = attachmentFullUrl(String(x.url || x.fileUrl || '')) || ''
const n = String(x.name || x.fileName || '').trim()
if (u && n) rememberName(u, n)
return u
}
return ''
})
@@ -65,18 +190,28 @@ function toUrlList(raw) {
}
if (typeof raw === 'object') {
const u = attachmentFullUrl(String(raw.url || raw.fileUrl || '')) || ''
const n = String(raw.name || raw.fileName || '').trim()
if (u && n) rememberName(u, n)
return u ? [u] : []
}
return []
}
/**
* Vant 非图片预览文案:item.file ? item.file.name : item.url
* 回显只有 url 时必须补 file.name,否则会露出整段 http 地址。
*/
function urlsToFileList(urls) {
return urls.map((url, i) => ({
url,
name: url.split('/').pop() || `file-${i}`,
status: 'done',
isString: true
}))
return urls.map((url, i) => {
const name = resolveDisplayName(url, '') || `file-${i}`
return {
url,
name,
file: { name },
status: 'done',
isString: true
}
})
}
const fileList = ref(urlsToFileList(toUrlList(props.modelValue)))
@@ -114,8 +249,18 @@ async function onAfterRead(item) {
const res = await uploadAttachment(f.file)
const rawUrl = String(res?.fileUrl || res?.data?.fileUrl || '').trim()
if (!rawUrl) throw new Error('未返回文件地址')
// 与 web-admin oa-form-create-upload 一致:表单存完整 URL,便于两端回显
f.url = attachmentFullUrl(rawUrl) || rawUrl
const fullUrl = attachmentFullUrl(rawUrl) || rawUrl
const originalName = String(
(f.file && f.file.name) ||
res?.fileName ||
res?.data?.fileName ||
''
).trim()
f.url = fullUrl
const showName = originalName || displayNameFromUrl(fullUrl) || '未命名文件'
rememberName(fullUrl, showName)
rememberName(rawUrl, showName)
f.name = showName
f.status = 'done'
f.isString = true
} catch (e) {
@@ -131,6 +276,25 @@ function onBeforeDelete() {
setTimeout(() => emitUrls(), 0)
return true
}
async function onClickPreview(item) {
if (isImageFileItem(item)) return
const url = String(item?.url || '').trim()
if (!url) {
showToast('文件地址无效')
return
}
const fileName = resolveDisplayName(url, (item.file && item.file.name) || item.name)
try {
showToast({ message: '开始下载…', duration: 800 })
await downloadAttachmentFile({
fileUrl: url,
fileName
})
} catch (e) {
showToast((e && e.message) || '下载失败')
}
}
</script>
<style scoped>
@@ -138,9 +302,196 @@ function onBeforeDelete() {
padding: 4px 12px 8px;
}
.oa-upload__tip {
margin-top: 4px;
margin-top: 6px;
color: var(--text-muted, #6b7b74);
font-size: 12px;
line-height: 1.4;
}
/* 预览格 */
.oa-upload :deep(.van-uploader__wrapper) {
gap: 0;
}
.oa-upload :deep(.van-uploader__preview) {
margin: 0 10px 10px 0;
}
.oa-upload :deep(.van-uploader__preview-image),
.oa-upload :deep(.van-uploader__file) {
width: 84px !important;
height: 84px !important;
border-radius: 14px;
overflow: hidden;
border: 1px solid #e4eee9;
box-shadow: 0 4px 12px rgba(20, 55, 40, 0.06);
background: #fff;
}
.oa-upload :deep(.van-uploader__file) {
padding: 0;
}
.oa-upload :deep(.van-uploader__file-icon),
.oa-upload :deep(.van-uploader__file-name) {
/* 由 preview-cover 自定义展示 */
opacity: 0;
}
/* 删除:圆形半透明 */
.oa-upload :deep(.van-uploader__preview-delete) {
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.oa-upload :deep(.van-uploader__preview-delete-icon) {
position: static;
transform: none;
font-size: 12px;
color: #fff;
}
/* 非图:自定义封面(图标 + 文件名 + 下载条) */
.oa-upload__file-cover {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 6px 20px;
background: #fff;
border-radius: 14px;
box-sizing: border-box;
pointer-events: none;
}
.oa-upload__file-ico {
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
color: #fff;
margin-bottom: 4px;
box-shadow: 0 3px 8px rgba(15, 40, 30, 0.15);
flex-shrink: 0;
}
.oa-upload__file-ico svg {
width: 15px;
height: 15px;
}
.oa-upload__file-ico.ft-pdf {
background: linear-gradient(145deg, #f07171, #ef4444);
}
.oa-upload__file-ico.ft-xls {
background: linear-gradient(145deg, #4fbf8a, #10b981);
}
.oa-upload__file-ico.ft-doc {
background: linear-gradient(145deg, #6ea8ff, #3b82f6);
}
.oa-upload__file-ico.ft-zip {
background: linear-gradient(145deg, #e4bc6a, #d6a343);
}
.oa-upload__file-ico.ft-other {
background: linear-gradient(145deg, #4ade80, #0a9f6d);
}
.oa-upload__file-name {
font-size: 10px;
font-weight: 600;
color: #4d635a;
text-align: center;
line-height: 1.25;
max-height: 26px;
overflow: hidden;
word-break: break-all;
padding: 0 2px;
}
.oa-upload__dl-hint {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 4px 4px 5px;
font-size: 10px;
font-weight: 650;
letter-spacing: 0.02em;
line-height: 1.2;
text-align: center;
color: #fff;
background: linear-gradient(180deg, rgba(8, 127, 87, 0), rgba(8, 127, 87, 0.92));
pointer-events: none;
}
/* 添加格 */
.oa-upload__add {
width: 84px;
height: 84px;
border-radius: 14px;
border: 1.5px dashed #9ad9be;
background: linear-gradient(145deg, #f7fdf9, #e9f9f2);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
}
.oa-upload__add:active {
border-color: #0a9f6d;
}
.oa-upload__add-plus {
width: 28px;
height: 28px;
border-radius: 9px;
background: linear-gradient(145deg, #4ade80, #0a9f6d);
color: #fff;
display: grid;
place-items: center;
font-size: 18px;
font-weight: 700;
line-height: 1;
box-shadow: 0 4px 10px rgba(10, 159, 109, 0.28);
}
.oa-upload__add-txt {
font-size: 11px;
font-weight: 650;
color: #087f57;
}
/* 默认上传按钮兜底样式(编辑态) */
.oa-upload :deep(.van-uploader__upload) {
margin: 0 10px 10px 0;
width: 84px;
height: 84px;
border-radius: 14px;
background: linear-gradient(145deg, #f7fdf9, #e9f9f2);
border: 1.5px dashed #9ad9be;
}
.oa-upload :deep(.van-uploader__upload-icon) {
color: #0a9f6d;
}
/* 详情只读:不展示添加/删除,且取消 disabled 灰蒙层 */
.oa-upload.is-readonly {
opacity: 1 !important;
filter: none !important;
pointer-events: auto;
}
.oa-upload.is-readonly :deep(.van-uploader__wrapper--disabled),
.oa-upload.is-readonly :deep(.van-uploader__wrapper) {
opacity: 1 !important;
}
.oa-upload.is-readonly :deep(.van-uploader__upload),
.oa-upload.is-readonly :deep(.oa-upload__add) {
display: none !important;
}
.oa-upload.is-readonly :deep(.van-uploader__preview-delete) {
display: none !important;
}
.oa-upload.is-readonly :deep(.van-uploader__mask) {
display: none !important;
}
</style>
@@ -0,0 +1,127 @@
/** 与后端 oa_engine.INITIATOR_CONDITION_FIELD 对齐:不依赖 formData */
const INITIATOR_CONDITION_FIELD = '__initiator__'
const FORMULA_FIELD_RE = /\{([^{}]+)\}/g
function nodeType(node) {
return String((node && (node.type || node.nodeType)) || '').trim()
}
function addField(name, fields, seen) {
const key = String(name || '').trim()
if (!key || seen.has(key)) return
if (key === INITIATOR_CONDITION_FIELD) return
seen.add(key)
fields.push(key)
}
function addFormulaFields(text, fields, seen) {
if (!text) return
FORMULA_FIELD_RE.lastIndex = 0
let m
while ((m = FORMULA_FIELD_RE.exec(String(text)))) {
addField(m[1], fields, seen)
}
}
/**
* 收集流程树中非默认条件分支引用的表单字段(去重保序)。
* 语义对齐 backend `oa_engine._collect_condition_fields`。
*/
export function collectConditionFields(node) {
const fields = []
const seen = new Set()
function walk(n) {
if (!n || typeof n !== 'object') return
if (nodeType(n) === 'condition') {
for (const g of n.conditionGroups || []) {
if (!g || typeof g !== 'object') continue
const rules = g.rules
const when = g.when
if (g.isDefault || (!rules && !when)) {
if (g.child && typeof g.child === 'object') walk(g.child)
continue
}
if (Array.isArray(rules) && rules.length) {
for (const r of rules) {
if (r && typeof r === 'object' && r.field != null) {
addField(r.field, fields, seen)
}
}
} else if (typeof rules === 'string' && rules.trim()) {
addFormulaFields(rules, fields, seen)
} else if (when) {
addFormulaFields(when, fields, seen)
}
if (g.child && typeof g.child === 'object') walk(g.child)
}
if (n.child && typeof n.child === 'object') walk(n.child)
return
}
if (n.child && typeof n.child === 'object') walk(n.child)
for (const g of n.conditionGroups || []) {
if (g && typeof g === 'object' && g.child && typeof g.child === 'object') {
walk(g.child)
}
}
}
walk(node)
return fields
}
function sortKeysDeep(value) {
if (Array.isArray(value)) return value.map(sortKeysDeep)
if (value && typeof value === 'object') {
const out = {}
for (const k of Object.keys(value).sort()) {
out[k] = sortKeysDeep(value[k])
}
return out
}
return value
}
export function pickRelevantFormData(formData, conditionFields) {
const data = formData && typeof formData === 'object' ? formData : {}
const fields = Array.isArray(conditionFields) ? conditionFields : []
const out = {}
for (const f of fields) {
out[f] = data[f]
}
return out
}
/** 稳定序列化:用于判断预览请求是否实质变化 */
export function buildFlowPreviewRequestKey({
formData,
initiatorSelectByNode,
conditionFields
} = {}) {
const payload = {
formData: pickRelevantFormData(formData, conditionFields),
initiatorSelectByNode:
initiatorSelectByNode && typeof initiatorSelectByNode === 'object'
? initiatorSelectByNode
: {}
}
return JSON.stringify(sortKeysDeep(payload))
}
/**
* @param {string|null|undefined} prevKey 上次成功预览的 key
* @param {string} nextKey 本次请求 key
* @param {{ inFlightKey?: string|null }} [opts] 在途请求 key;有在途时仅跳过相同 key,避免回滚竞态
*/
export function shouldSkipFlowPreviewRequest(prevKey, nextKey, opts = {}) {
const inFlightKey = opts.inFlightKey
if (inFlightKey != null) {
return inFlightKey === nextKey
}
return prevKey != null && prevKey === nextKey
}
/** 已有预览结果时静默刷新,避免整区「加载中」闪烁 */
export function shouldShowFlowPreviewSpinner({ hasCompletedPreview } = {}) {
return !hasCompletedPreview
}
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict'
import {
collectConditionFields,
buildFlowPreviewRequestKey,
shouldSkipFlowPreviewRequest,
shouldShowFlowPreviewSpinner
} from './oa-flow-preview-refresh.js'
// 无条件分支:表单无关字段变化不应改变 request key
{
const keyA = buildFlowPreviewRequestKey({
formData: { title: 'a', reason: 'x' },
initiatorSelectByNode: {},
conditionFields: []
})
const keyB = buildFlowPreviewRequestKey({
formData: { title: 'b', reason: 'y' },
initiatorSelectByNode: {},
conditionFields: []
})
assert.equal(keyA, keyB)
assert.equal(shouldSkipFlowPreviewRequest(keyA, keyB), true)
}
// 有条件字段:仅条件字段变化才改变 key
{
const fields = ['amount']
const keyA = buildFlowPreviewRequestKey({
formData: { amount: 100, remark: 'old' },
initiatorSelectByNode: {},
conditionFields: fields
})
const keySame = buildFlowPreviewRequestKey({
formData: { amount: 100, remark: 'new' },
initiatorSelectByNode: {},
conditionFields: fields
})
const keyDiff = buildFlowPreviewRequestKey({
formData: { amount: 200, remark: 'new' },
initiatorSelectByNode: {},
conditionFields: fields
})
assert.equal(keyA, keySame)
assert.notEqual(keyA, keyDiff)
assert.equal(shouldSkipFlowPreviewRequest(keyA, keySame), true)
assert.equal(shouldSkipFlowPreviewRequest(keyA, keyDiff), false)
}
// 自选审批人变化应触发预览
{
const keyA = buildFlowPreviewRequestKey({
formData: {},
initiatorSelectByNode: { n1: ['u1'] },
conditionFields: []
})
const keyB = buildFlowPreviewRequestKey({
formData: {},
initiatorSelectByNode: { n1: ['u2'] },
conditionFields: []
})
assert.notEqual(keyA, keyB)
}
// 从 flowTree 收集条件字段(对齐后端语义)
{
const tree = {
type: 'start',
child: {
type: 'condition',
conditionGroups: [
{
rules: [{ field: 'leaveType', op: 'eq', value: '事假' }],
child: { type: 'approver', id: 'a1' }
},
{
isDefault: true,
child: { type: 'approver', id: 'a2' }
}
],
child: { type: 'end' }
}
}
assert.deepEqual(collectConditionFields(tree), ['leaveType'])
}
// 公式字段 {fieldName}
{
const tree = {
type: 'condition',
conditionGroups: [
{
when: '{days} > 3',
child: { type: 'approver' }
}
]
}
assert.deepEqual(collectConditionFields(tree), ['days'])
}
// 系统伪字段 __initiator__ 不纳入
{
const tree = {
type: 'condition',
conditionGroups: [
{
rules: [{ field: '__initiator__', op: 'eq', value: 'x' }],
child: { type: 'approver' }
}
]
}
assert.deepEqual(collectConditionFields(tree), [])
}
// 仅首次预览展示整区 loading
assert.equal(shouldShowFlowPreviewSpinner({ hasCompletedPreview: false }), true)
assert.equal(shouldShowFlowPreviewSpinner({ hasCompletedPreview: true }), false)
// 有在途请求时:相同 key 跳过,不同 key(含回滚到上次成功值)必须再请求
{
const key100 = buildFlowPreviewRequestKey({
formData: { amount: 100 },
initiatorSelectByNode: {},
conditionFields: ['amount']
})
const key200 = buildFlowPreviewRequestKey({
formData: { amount: 200 },
initiatorSelectByNode: {},
conditionFields: ['amount']
})
assert.equal(
shouldSkipFlowPreviewRequest(key100, key200, { inFlightKey: key200 }),
true
)
assert.equal(
shouldSkipFlowPreviewRequest(key100, key100, { inFlightKey: key200 }),
false
)
}
console.log('oa-flow-preview-refresh.test.js ok')
@@ -0,0 +1,129 @@
/**
* 或签/会签进度人员行格式化(对齐 web-admin oa-progress-person-lines)
* Spec: docs/superpowers/specs/2026-07-23-oa-or-and-sign-display-design.md
*
* 返回字段:
* - name / detail / timestamp:移动端分行展示(姓名+时间同行,意见单独一行)
* - body / text:兼容旧纯文本拼接
*/
const ACTED_STATUS = new Set(['approved', 'rejected', 'returned', 'completed', 'transferred'])
function displayName(p) {
return String(p?.nickName || p?.id || '').trim()
}
function transferTargetName(p) {
return String(p?.transferToUserName || p?.transferToUserId || '').trim()
}
function statusFallbackLabel(status) {
const s = (status || '').trim()
if (s === 'approved') return '已同意'
if (s === 'rejected') return '已拒绝'
if (s === 'returned') return '已打回'
if (s === 'completed') return '已完成'
if (s === 'transferred') return '已转交'
return s || '已处理'
}
function toneForStatus(status) {
const s = (status || '').trim()
if (s === 'approved' || s === 'completed') return 'ok'
if (s === 'pending' || s === 'running' || s === 'not_reached') return 'pending'
if (s === 'rejected' || s === 'returned') return 'danger'
if (s === 'done' || s === 'transferred' || s === 'cancelled') return 'mute'
return ''
}
/**
* @param {Array} out
* @param {string} name
* @param {string} detail 意见/状态文案(可空)
* @param {string} timestamp
* @param {string} tone
* @param {{ isOpinion?: boolean }} [opts] isOpinion=true 时移动端加「意见:」前缀
*/
function pushLine(out, name, detail, timestamp, tone, opts = {}) {
const n = String(name || '').trim()
const d = String(detail || '').trim()
const ts = String(timestamp || '').trim()
const body = d ? `${n} ${d}` : n
out.push({
name: n,
detail: d,
isOpinion: !!opts.isOpinion && !!d,
body,
timestamp: ts,
text: ts ? `${body} ${ts}` : body,
tone
})
}
/**
* 抄送节点人员行:仅姓名 + 时间,样式统一(不套审批态文案/tone)。
* @param {{ taskType?: string, signMode?: string, people?: Array, assignee?: string, timestamp?: string }} item
*/
export function formatCcPersonLines(item) {
if (item?.taskType !== 'cc' && item?.signMode !== 'cc') return []
const fallbackTs = String(item?.timestamp || '').trim()
const people = Array.isArray(item?.people) ? item.people : []
const fromPeople = []
for (const p of people) {
const name = displayName(p)
if (!name) continue
fromPeople.push({
name,
timestamp: String(p?.timestamp || '').trim() || fallbackTs
})
}
if (fromPeople.length) return fromPeople
const assignee = String(item?.assignee || '').trim()
if (!assignee) return []
return [{ name: assignee, timestamp: fallbackTs }]
}
/** 将 progress.people 格式化为人员行 */
export function formatProgressPersonLines(people) {
const list = Array.isArray(people) ? people : []
const out = []
for (const p of list) {
const name = displayName(p)
if (!name) continue
const status = String(p.status || '').trim()
const comment = String(p.commentText || '').trim()
const ts = String(p.timestamp || '').trim()
const tone = toneForStatus(status)
if (status === 'done' || status === 'cancelled') {
pushLine(out, name, '无需处理', '', 'mute')
continue
}
if (status === 'transferred') {
const target = transferTargetName(p)
const mid = target
? comment
? `转交给${target} ${comment}`
: `转交给${target}`
: comment || statusFallbackLabel('transferred')
pushLine(out, name, mid, ts, 'mute', { isOpinion: !!comment && !target })
continue
}
if (status === 'pending' || status === 'running' || status === 'not_reached' || !status) {
if (!status && (comment || ts)) {
const mid = comment || statusFallbackLabel('approved')
pushLine(out, name, mid, ts, 'ok', { isOpinion: !!comment })
continue
}
pushLine(out, name, '待处理', '', 'pending')
continue
}
if (ACTED_STATUS.has(status) || comment || ts) {
const mid = comment || statusFallbackLabel(status)
pushLine(out, name, mid, ts, tone || 'ok', { isOpinion: !!comment })
continue
}
pushLine(out, name, '待处理', '', 'pending')
}
return out
}
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict'
import {
formatCcPersonLines,
formatProgressPersonLines
} from './oa-progress-person-lines.js'
const andPartial = formatProgressPersonLines([
{
nickName: '吴家猛',
status: 'approved',
commentText: '同意',
timestamp: '2026-08-10 14:17:19'
},
{
nickName: '郑德禄',
status: 'approved',
commentText: '同意',
timestamp: '2026-08-10 14:18:00'
},
{ nickName: '丙', status: 'pending' }
])
assert.deepEqual(
andPartial.map((x) => ({
name: x.name,
detail: x.detail,
timestamp: x.timestamp,
isOpinion: x.isOpinion,
tone: x.tone
})),
[
{
name: '吴家猛',
detail: '同意',
timestamp: '2026-08-10 14:17:19',
isOpinion: true,
tone: 'ok'
},
{
name: '郑德禄',
detail: '同意',
timestamp: '2026-08-10 14:18:00',
isOpinion: true,
tone: 'ok'
},
{
name: '丙',
detail: '待处理',
timestamp: '',
isOpinion: false,
tone: 'pending'
}
]
)
const notReached = formatProgressPersonLines([
{ nickName: '张三', status: 'not_reached' },
{ nickName: '李四', status: 'not_reached' }
])
assert.deepEqual(
notReached.map((x) => x.body),
['张三 待处理', '李四 待处理']
)
const orDone = formatProgressPersonLines([
{
nickName: '甲',
status: 'approved',
commentText: '同意,材料齐全',
timestamp: '2026-07-23 10:05:00'
},
{ nickName: '乙', status: 'done' },
{ nickName: '丙', status: 'done' }
])
assert.deepEqual(
orDone.map((x) => x.text),
['甲 同意,材料齐全 2026-07-23 10:05:00', '乙 无需处理', '丙 无需处理']
)
assert.equal(orDone[0].isOpinion, true)
const emptyComment = formatProgressPersonLines([
{
nickName: '甲',
status: 'approved',
commentText: '',
timestamp: '2026-07-23 10:05:00'
}
])
assert.equal(emptyComment[0].name, '甲')
assert.equal(emptyComment[0].detail, '已同意')
assert.equal(emptyComment[0].isOpinion, false)
// 撤销/打回清待办:cancelled 对齐或签 done →「无需处理」(勿挂「已撤销」)
const cancelledTask = formatProgressPersonLines([
{
nickName: '直接主管张三',
status: 'cancelled',
commentText: '',
timestamp: '2026-08-13 12:47:46'
}
])
assert.deepEqual(
cancelledTask.map((x) => ({
name: x.name,
detail: x.detail,
timestamp: x.timestamp,
isOpinion: x.isOpinion,
tone: x.tone
})),
[
{
name: '直接主管张三',
detail: '无需处理',
timestamp: '',
isOpinion: false,
tone: 'mute'
}
]
)
const cancelledNoTs = formatProgressPersonLines([{ nickName: '李四', status: 'cancelled' }])
assert.equal(cancelledNoTs[0].detail, '无需处理')
assert.equal(cancelledNoTs[0].tone, 'mute')
// 抄送:多人只出统一姓名行(无「无需处理」、无审批态 tone)
const ccMulti = formatCcPersonLines({
taskType: 'cc',
timestamp: '2026-08-10 14:27:37',
people: [
{
nickName: '胡倩茜',
status: 'done',
timestamp: '2026-08-10 14:27:37'
},
{ nickName: '周斌倩', status: 'done', timestamp: '2026-08-10 14:27:37' }
]
})
assert.deepEqual(ccMulti, [
{ name: '胡倩茜', timestamp: '2026-08-10 14:27:37' },
{ name: '周斌倩', timestamp: '2026-08-10 14:27:37' }
])
assert.ok(ccMulti.every((x) => x.tone === undefined))
const ccSingle = formatCcPersonLines({
taskType: 'cc',
assignee: '胡圆凯',
timestamp: '2026-08-10 14:30:04',
people: [{ nickName: '胡圆凯', status: 'done', timestamp: '2026-08-10 14:30:04' }]
})
assert.deepEqual(ccSingle, [{ name: '胡圆凯', timestamp: '2026-08-10 14:30:04' }])
const ccAssigneeOnly = formatCcPersonLines({
taskType: 'cc',
assignee: '张三',
timestamp: '2026-08-10 15:00:00',
people: []
})
assert.deepEqual(ccAssigneeOnly, [{ name: '张三', timestamp: '2026-08-10 15:00:00' }])
assert.deepEqual(formatCcPersonLines({ taskType: 'approval', people: [{ nickName: '甲' }] }), [])
console.log('oa-progress-person-lines: all assertions passed')
+18
View File
@@ -0,0 +1,18 @@
/**
* OA 审批状态 → 钉钉风印章文案/色类(与 web-admin resolveStamp 对齐)
* @param {string|null|undefined} status
* @returns {{ stampClass: string, stampText: string }}
*/
export function resolveStamp(status) {
const s = String(status || '').trim()
if (s === 'completed' || s === 'approved' || s === 'done') {
return { stampClass: 'ok', stampText: '已通过' }
}
if (s === 'rejected') return { stampClass: 'rej', stampText: '已拒绝' }
if (s === 'returned') return { stampClass: 'ret', stampText: '已打回' }
if (s === 'cancelled') return { stampClass: 'can', stampText: '已撤销' }
if (s === 'running' || s === 'pending') {
return { stampClass: 'run', stampText: '审批中' }
}
return { stampClass: 'run', stampText: '审批中' }
}
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { resolveStamp } from './oa-status-stamp.js'
assert.deepEqual(resolveStamp('completed'), {
stampClass: 'ok',
stampText: '已通过'
})
assert.deepEqual(resolveStamp('approved'), {
stampClass: 'ok',
stampText: '已通过'
})
assert.deepEqual(resolveStamp('done'), {
stampClass: 'ok',
stampText: '已通过'
})
assert.deepEqual(resolveStamp('rejected'), {
stampClass: 'rej',
stampText: '已拒绝'
})
assert.deepEqual(resolveStamp('returned'), {
stampClass: 'ret',
stampText: '已打回'
})
assert.deepEqual(resolveStamp('cancelled'), {
stampClass: 'can',
stampText: '已撤销'
})
assert.deepEqual(resolveStamp('running'), {
stampClass: 'run',
stampText: '审批中'
})
assert.deepEqual(resolveStamp('pending'), {
stampClass: 'run',
stampText: '审批中'
})
assert.deepEqual(resolveStamp(''), {
stampClass: 'run',
stampText: '审批中'
})
assert.deepEqual(resolveStamp(null), {
stampClass: 'run',
stampText: '审批中'
})
console.log('oa-status-stamp: all assertions passed')
@@ -14,9 +14,9 @@ import {
normalizeReceiveAccountModelValue
} from '../../utils/receive-account-value.js'
import { toRmbUpper } from '../../utils/rmb-upper.js'
import { OA_STATUS_TEXT } from '../status-label.js'
import { resolveStamp } from '../oa-status-stamp.js'
import { buildOaPrintFilename } from './oa-print-filename.js'
import { buildFlowItems } from './oa-print-flow.js'
export { resolveStamp }
@@ -62,9 +62,6 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;')
}
function statusText(s) {
return OA_STATUS_TEXT[s] || s || '-'
}
function formatDateOnly(createTime) {
const raw = String(createTime || '').trim()
@@ -367,171 +364,7 @@ function buildFormRows(detail) {
return out
}
function peopleDisplayNames(p) {
const people = Array.isArray(p?.people) ? p.people : []
const fromPeople = people
.map((x) => String(x?.nickName || x?.id || '').trim())
.filter(Boolean)
if (fromPeople.length) return fromPeople
const name = String(p?.assigneeUserName || p?.assigneeUserId || '').trim()
return name ? [name] : []
}
function formatProgressPersonLines(people) {
const list = Array.isArray(people) ? people : []
const out = []
const ACTED = new Set(['approved', 'rejected', 'returned', 'completed', 'transferred'])
for (const p of list) {
const name = String(p?.nickName || p?.id || '').trim()
if (!name) continue
const status = String(p.status || '').trim()
const comment = String(p.commentText || '').trim()
const ts = String(p.timestamp || '').trim()
const transferTo = String(p.transferToUserName || p.transferToUserId || '').trim()
if (status === 'done') {
out.push({ left: `${name} 无需处理`, right: '' })
continue
}
if (status === 'transferred') {
const mid = transferTo
? comment
? `转交给${transferTo} ${comment}`
: `转交给${transferTo}`
: comment || '已转交'
out.push({ left: `${name} ${mid}`, right: ts })
continue
}
if (status === 'pending' || status === 'running' || status === 'not_reached' || !status) {
if (!status && (comment || ts)) {
out.push({ left: `${name} ${comment || '已同意'}`, right: ts })
continue
}
out.push({ left: `${name} 待处理`, right: '' })
continue
}
if (ACTED.has(status) || comment || ts) {
const mid =
comment ||
(status === 'approved'
? '已同意'
: status === 'rejected'
? '已拒绝'
: status === 'returned'
? '已打回'
: status === 'completed'
? '已完成'
: statusText(status))
out.push({ left: `${name} ${mid}`, right: ts })
continue
}
out.push({ left: `${name} 待处理`, right: '' })
}
return out
}
function isCcProgressItem(p) {
const taskType = String(p?.taskType || p?.task_type || '').trim()
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
const title = String(p?.title || p?.nodeName || '').trim()
return (
taskType === 'cc' ||
signMode === 'cc' ||
title === '抄送人' ||
/^抄送/.test(title)
)
}
export function buildFlowItems(detail) {
const list = Array.isArray(detail?.progress) ? detail.progress : []
const items = []
let ccBuf = null
const flushCc = () => {
if (!ccBuf) return
const uniq = Array.from(new Set(ccBuf.names.map((n) => n.trim()).filter(Boolean)))
items.push({
left: uniq.length ? `抄送: ${uniq.join(', ')}` : '抄送:',
right: ccBuf.ts || ''
})
ccBuf = null
}
for (const p of list) {
const kind = String(p.kind || '')
const title = String(p.title || '-').trim()
const names = peopleDisplayNames(p)
const name = names.join(', ')
const ts = String(p.timestamp || '').trim()
const comment = String(p.commentText || '').trim()
const st = statusText(String(p.status || ''))
const signMode = String(p.signMode || p.sign_mode || '')
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
if (isCcProgressItem(p)) {
if (!ccBuf) ccBuf = { names: [], ts }
if (names.length) ccBuf.names.push(...names)
if (ts) ccBuf.ts = ts
continue
}
flushCc()
if (kind === 'submit') {
items.push({
left: name ? `【${title}】 ${name} ${st}` : `【${title}】 ${st}`,
right: ts,
comment: comment || undefined
})
continue
}
if (kind === 'terminal') {
if (String(p.status || '') === 'returned' || title === '已打回') continue
items.push({ left: title || st, right: ts })
continue
}
const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
const personLines = formatProgressPersonLines(Array.isArray(p?.people) ? p.people : [])
if (personLines.length) {
items.push({
left: `${head} ${st}`,
right: '',
personLines
})
continue
}
items.push({
left: name ? `${head} ${name} ${st}` : `${head} ${st}`,
right: ts,
comment: comment || undefined
})
}
flushCc()
const hasCcLine = items.some((it) => it.left.startsWith('抄送'))
if (!hasCcLine) {
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : []
const ccTasks = tasks.filter((t) => {
const tt = String(t?.taskType || t?.task_type || '').trim()
const sm = String(t?.signMode || t?.sign_mode || '').trim()
return tt === 'cc' || sm === 'cc'
})
if (ccTasks.length) {
const names = ccTasks
.map((t) =>
String(t?.assigneeUserName || t?.assignee_user_name || t?.assigneeUserId || '').trim()
)
.filter(Boolean)
const uniq = Array.from(new Set(names))
let ts = ''
for (const t of ccTasks) {
const tTs = String(t?.updateTime || t?.createTime || '').trim()
if (tTs && (!ts || tTs > ts)) ts = tTs
}
if (uniq.length) items.push({ left: `抄送: ${uniq.join(', ')}`, right: ts })
}
}
return items
}
export { buildFlowItems }
export function buildOaPrintModel(detail, opts = {}) {
const { stampClass, stampText } = resolveStamp(detail?.status)
@@ -671,8 +504,7 @@ function buildPrintStylesContent() {
justify-content: center; font-size: 32px; font-weight: 700; letter-spacing: 2px;
line-height: 1; color: currentColor; }
.oa-stamp .main.is-chars3 { font-size: 26px; letter-spacing: 1px; }
.oa-print-footer { display: flex; flex-wrap: wrap; justify-content: space-between;
gap: 8px; margin-top: 16px; font-size: 12px; color: #646464; }`
.oa-print-footer { display: flex; gap: 28px; margin-top: 14px; font-size: 12px; }`
}
function buildPrintPageContent(model) {
+144
View File
@@ -0,0 +1,144 @@
/**
* 移动端打印「审批流程」行组装(无 html2canvas/API 副作用,便于单测)
* Spec: docs/superpowers/specs/2026-08-13-oa-progress-withdraw-cancelled-display-design.md
*/
import { OA_STATUS_TEXT } from '../status-label.js'
import { formatProgressPersonLines as formatSharedPersonLines } from '../oa-progress-person-lines.js'
function statusText(s) {
return OA_STATUS_TEXT[s] || s || '-'
}
/** 任务节点 cancelled=系统清待办 →「无需处理」;终态 cancelled 仍为「已撤销」 */
function progressNodeStatusText(p) {
const kind = String(p?.kind || '')
const s = String(p?.status || '').trim()
if (kind !== 'terminal' && s === 'cancelled') return '无需处理'
return statusText(s)
}
function peopleDisplayNames(p) {
const people = Array.isArray(p?.people) ? p.people : []
const fromPeople = people
.map((x) => String(x?.nickName || x?.id || '').trim())
.filter(Boolean)
if (fromPeople.length) return fromPeople
const name = String(p?.assigneeUserName || p?.assigneeUserId || '').trim()
return name ? [name] : []
}
function formatProgressPersonLines(people) {
return formatSharedPersonLines(people).map((x) => ({
left: x.body || x.text,
right: x.timestamp || ''
}))
}
function isCcProgressItem(p) {
const taskType = String(p?.taskType || p?.task_type || '').trim()
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
const title = String(p?.title || p?.nodeName || '').trim()
return (
taskType === 'cc' ||
signMode === 'cc' ||
title === '抄送人' ||
/^抄送/.test(title)
)
}
export function buildFlowItems(detail) {
const list = Array.isArray(detail?.progress) ? detail.progress : []
const items = []
let ccBuf = null
const flushCc = () => {
if (!ccBuf) return
const uniq = Array.from(new Set(ccBuf.names.map((n) => n.trim()).filter(Boolean)))
items.push({
left: uniq.length ? `抄送: ${uniq.join(', ')}` : '抄送:',
right: ccBuf.ts || ''
})
ccBuf = null
}
for (const p of list) {
const kind = String(p.kind || '')
const title = String(p.title || '-').trim()
const names = peopleDisplayNames(p)
const name = names.join(', ')
const ts = String(p.timestamp || '').trim()
const comment = String(p.commentText || '').trim()
const st = progressNodeStatusText(p)
const signMode = String(p.signMode || p.sign_mode || '')
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
if (isCcProgressItem(p)) {
if (!ccBuf) ccBuf = { names: [], ts }
if (names.length) ccBuf.names.push(...names)
if (ts) ccBuf.ts = ts
continue
}
flushCc()
if (kind === 'submit') {
items.push({
left: name ? `【${title}】 ${name} ${st}` : `【${title}】 ${st}`,
right: ts,
comment: comment || undefined
})
continue
}
if (kind === 'terminal') {
if (String(p.status || '') === 'returned' || title === '已打回') continue
const withdrawName = String(p.assigneeUserName || p.assignee || '').trim()
const withdrawTip = comment || (String(p.status || '') === 'cancelled' ? '发起人撤销' : '')
const leftExtra =
String(p.status || '') === 'cancelled' && (withdrawName || withdrawTip)
? ` ${[withdrawName, withdrawTip].filter(Boolean).join(' ')}`
: ''
items.push({ left: `${title || st}${leftExtra}`, right: ts })
continue
}
const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
const personLines = formatProgressPersonLines(Array.isArray(p?.people) ? p.people : [])
if (personLines.length) {
items.push({
left: `${head} ${st}`,
right: '',
personLines
})
continue
}
items.push({
left: name ? `${head} ${name} ${st}` : `${head} ${st}`,
right: ts,
comment: comment || undefined
})
}
flushCc()
const hasCcLine = items.some((it) => it.left.startsWith('抄送'))
if (!hasCcLine) {
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : []
const ccTasks = tasks.filter((t) => {
const tt = String(t?.taskType || t?.task_type || '').trim()
const sm = String(t?.signMode || t?.sign_mode || '').trim()
return tt === 'cc' || sm === 'cc'
})
if (ccTasks.length) {
const names = ccTasks
.map((t) =>
String(t?.assigneeUserName || t?.assignee_user_name || t?.assigneeUserId || '').trim()
)
.filter(Boolean)
const uniq = Array.from(new Set(names))
let ts = ''
for (const t of ccTasks) {
const tTs = String(t?.updateTime || t?.createTime || '').trim()
if (tTs && (!ts || tTs > ts)) ts = tTs
}
if (uniq.length) items.push({ left: `抄送: ${uniq.join(', ')}`, right: ts })
}
}
return items
}
@@ -0,0 +1,47 @@
/**
* 移动端打印「审批流程」:cancelled → 无需处理;终态归因发起人
*/
import assert from 'node:assert/strict'
import { buildFlowItems } from './oa-print-flow.js'
const items = buildFlowItems({
progress: [
{
kind: 'submit',
title: '发起人',
status: 'submitted',
assigneeUserName: '吴子龙',
timestamp: '2026-08-13 11:17:38'
},
{
kind: 'task',
title: '直接主管',
status: 'cancelled',
signMode: 'and',
timestamp: '',
people: [
{
nickName: '张主管',
status: 'cancelled',
timestamp: '2026-08-13 12:47:46',
commentText: ''
}
]
},
{
kind: 'terminal',
title: '已撤销',
status: 'cancelled',
assigneeUserName: '吴子龙',
commentText: '发起人撤销',
timestamp: '2026-08-13 12:47:46'
}
]
})
assert.equal(items[1].left, '【直接主管·会签】 无需处理')
assert.deepEqual(items[1].personLines, [{ left: '张主管 无需处理', right: '' }])
assert.equal(items[2].left, '已撤销 吴子龙 发起人撤销')
assert.equal(items[2].right, '2026-08-13 12:47:46')
console.log('oa-print-flow buildFlowItems cancelled: ok')
@@ -0,0 +1,36 @@
/* 按钮点击加载:文案淡化 + 居中自绘转圈(与发起页暂存/提交一致) */
.oa-btn-loading {
position: relative;
overflow: hidden;
}
.oa-btn-loading.is-loading.van-button--disabled {
opacity: 1 !important;
}
.oa-btn-loading.is-loading {
pointer-events: none;
}
.oa-btn-loading.is-loading .oa-btn-loading__label {
opacity: 0.25;
}
.oa-btn-loading__spin {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.06);
pointer-events: none;
}
.oa-btn-loading__spin::before {
content: '';
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: oa-btn-loading-spin 0.7s linear infinite;
}
@keyframes oa-btn-loading-spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const dir = dirname(fileURLToPath(import.meta.url))
const css = readFileSync(join(dir, 'oa-btn-loading.css'), 'utf8')
assert.match(css, /\.oa-btn-loading\.is-loading\s+\.oa-btn-loading__label\s*\{[^}]*opacity:\s*0\.25/)
assert.match(css, /\.oa-btn-loading__spin\s*\{/)
assert.match(css, /oa-btn-loading-spin/)
assert.match(css, /rgba\(0,\s*0,\s*0,\s*0\.06\)/)
assert.match(
css,
/\.oa-btn-loading\.is-loading\.van-button--disabled\s*\{[^}]*opacity:\s*1\s*!important/
)
console.log('oa-btn-loading.css contract ok')
+28 -11
View File
@@ -1,4 +1,14 @@
/* form-create + Vant:上下布局与左对齐兜底 */
.form-create-m {
/*
* 审批/查看等整表 disabled:Vant 默认灰字(text-color-3)难读,
* 改为正文色;仍禁止编辑,仅提升可读性。
*/
--van-field-input-disabled-text-color: var(--text-primary, #0f291e);
--van-field-disabled-text-color: var(--text-primary, #0f291e);
--van-cell-disabled-color: var(--text-primary, #0f291e);
}
.form-create-m .van-field__label--top {
width: 100%;
margin-bottom: 4px;
@@ -13,6 +23,23 @@
font-size: 13px;
min-height: 22px;
line-height: 22px;
color: var(--text-primary, #0f291e);
}
/*
* disabled input 仍可能被 -webkit-text-fill-color 压成灰字,显式覆盖。
*/
.form-create-m .van-field__control:disabled,
.form-create-m .van-field--disabled .van-field__control {
color: var(--text-primary, #0f291e) !important;
-webkit-text-fill-color: var(--text-primary, #0f291e) !important;
opacity: 1;
cursor: default;
}
.form-create-m .van-field--disabled .van-field__label,
.form-create-m .van-cell--disabled {
color: var(--text-primary, #0f291e);
}
.form-create-m .van-field__control::placeholder {
@@ -89,17 +116,7 @@
line-height: 22px;
}
/*
* 归属人 / 归属部门:不可改选时仍用 disabled 防点开,但展示色用正文色(勿灰字难读)。
* Vant disabled 会设 -webkit-text-fill-color,须一并覆盖。
*/
.form-create-m .oa-owner-field.is-locked .van-field__control,
.form-create-m .oa-owner-field.is-locked .van-field__control:disabled {
color: var(--text-primary, #0f291e) !important;
-webkit-text-fill-color: var(--text-primary, #0f291e) !important;
opacity: 1;
}
/* 归属人 / 归属部门锁定:箭头弱化,值色已由上方 disabled 规则统一为正文色 */
.form-create-m .oa-owner-field.is-locked .van-cell__right-icon {
color: #cbd5e1;
}
@@ -0,0 +1,45 @@
/** 附件下载:优先后端预签名 URL,保证跨域场景保留原始文件名 */
import { fetchAttachmentDownloadUrl } from '../api/oa.js'
import { attachmentFullUrl } from './attachment.js'
function openDownloadUrl(href, fileName) {
// 微信 web-view 对 a[download] 支持差;优先新开页打开带 Content-Disposition 的地址
const opened = window.open(href, '_blank')
if (opened) return
const a = document.createElement('a')
a.href = href
a.download = fileName || 'download'
a.target = '_blank'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
/**
* @param {{ fileUrl?: string, url?: string, fileName?: string, name?: string }} file
*/
export async function downloadAttachmentFile(file) {
const fileUrl = String(file?.fileUrl || file?.url || '').trim()
if (!fileUrl) throw new Error('文件地址无效')
const fileName =
String(file?.fileName || file?.name || '').trim() ||
fileUrl.split('/').pop() ||
'download'
try {
const res = await fetchAttachmentDownloadUrl({ fileUrl, fileName })
const downloadUrl = String(res?.downloadUrl || '').trim()
if (downloadUrl) {
openDownloadUrl(downloadUrl, fileName)
return
}
} catch {
// 预签名失败时回退直连
}
const href = attachmentFullUrl(fileUrl) || fileUrl
if (!href) throw new Error('无法下载附件')
openDownloadUrl(href, fileName)
}
+85 -122
View File
@@ -11,26 +11,26 @@
'oa-detail__body--mine-act': (canWithdraw || canCopyRestart) && !canApprove
}"
>
<header class="oa-detail__hd">
<h1 class="oa-detail__title">{{ detail?.title || '审批详情' }}</h1>
<span
v-if="detail?.status"
class="oa-detail__status"
:class="statusTone(detail.status)"
>
{{ statusText(detail.status) }}
</span>
</header>
<form-create-mobile
v-if="formRule.length"
v-model="formData"
:rule="formRule"
:option="formOption"
/>
<p v-else class="oa-detail__empty">暂无表单内容</p>
<div class="oa-detail__stamp-host">
<OaStatusStamp v-if="detail?.status" :status="detail.status" />
<form-create-mobile
v-if="formRule.length"
v-model="formData"
:rule="formRule"
:option="formOption"
:disabled="true"
/>
<p v-else class="oa-detail__empty">暂无表单内容</p>
</div>
<OaProgressTimeline :items="timelineItems" />
</div>
<footer class="oa-detail__ft">
<footer
class="oa-detail__ft"
:class="{
'oa-detail__ft--center': !canApprove && !canWithdraw && !canCopyRestart,
'oa-detail__ft--equal': !canApprove && (canWithdraw || canCopyRestart)
}"
>
<template v-if="canApprove">
<van-button
class="oa-detail__btn oa-detail__btn--more"
@@ -42,43 +42,41 @@
</van-button>
<van-button
v-if="detail?.allowReturn !== false"
class="oa-detail__btn oa-detail__btn--ghost"
:loading="acting"
class="oa-detail__btn oa-detail__btn--ghost oa-btn-loading"
:class="{ 'is-loading': acting }"
:disabled="acting || printing"
@click="openComment('return')"
>
打回
<OaBtnLoadingInner :loading="acting">打回</OaBtnLoadingInner>
</van-button>
<van-button
class="oa-detail__btn oa-detail__btn--primary"
class="oa-detail__btn oa-detail__btn--primary oa-btn-loading"
type="primary"
:loading="acting"
:class="{ 'is-loading': acting }"
:disabled="acting || printing"
@click="openComment('approve')"
>
同意
<OaBtnLoadingInner :loading="acting">同意</OaBtnLoadingInner>
</van-button>
</template>
<template v-else>
<van-button class="oa-detail__btn oa-detail__btn--close" @click="onClose">
关闭
</van-button>
<van-button
class="oa-detail__btn oa-detail__btn--ghost oa-detail__btn--print"
:loading="printing"
class="oa-detail__btn oa-detail__btn--ghost oa-detail__btn--print oa-btn-loading"
:class="{ 'is-loading': printing }"
:disabled="acting || printing"
@click="onPrint"
>
打印
<OaBtnLoadingInner :loading="printing">打印</OaBtnLoadingInner>
</van-button>
<van-button
v-if="canWithdraw"
class="oa-detail__btn oa-detail__btn--ghost"
:loading="acting"
class="oa-detail__btn oa-detail__btn--primary oa-btn-loading"
type="primary"
:class="{ 'is-loading': acting }"
:disabled="acting"
@click="withdraw"
>
撤销
<OaBtnLoadingInner :loading="acting">撤销</OaBtnLoadingInner>
</van-button>
<van-button
v-if="canCopyRestart"
@@ -190,9 +188,10 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirmDialog, showToast } from 'vant'
import { closeToHost } from '../oa/close-to-host.js'
import {
fetchOaInstanceDetail,
fetchCurrentUser,
@@ -204,12 +203,13 @@ import {
fetchWithdrawOaInstance
} from '../api/oa.js'
import { prepareMobileFormSchema } from '../oa/schema-prep.js'
import { statusText, statusTone } from '../oa/status-label.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 OaPulseLoading from '../oa/components/OaPulseLoading.vue'
import OaBtnLoadingInner from '../oa/components/OaBtnLoadingInner.vue'
import OaPersonSelect from '../oa/components/OaPersonSelect.vue'
import OaStatusStamp from '../oa/components/OaStatusStamp.vue'
const route = useRoute()
const router = useRouter()
@@ -268,7 +268,22 @@ const timelineItems = computed(() => {
status: p.status || '',
timestamp: p.timestamp || '',
assignee: p.assigneeUserName || p.assigneeUserId || '',
commentText: p.commentText || ''
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 || ''
}))
: []
}))
})
@@ -334,37 +349,10 @@ const commentPlaceholder = computed(() => {
/** 通知宿主关闭(对齐 OaStart closeToMini) */
function closeToMini(payload) {
const data = payload || { action: 'oa-detail-close' }
try {
if (typeof wx !== 'undefined' && wx.miniProgram?.navigateBack) {
wx.miniProgram.postMessage({ data })
wx.miniProgram.navigateBack()
return
}
} catch (_) {
/* ignore */
}
try {
const uniWv = typeof uni !== 'undefined' ? uni.webView : null
if (uniWv?.postMessage) {
uniWv.postMessage({ data })
}
if (uniWv?.navigateBack) {
uniWv.navigateBack()
return
}
} catch (_) {
/* ignore */
}
try {
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
window.parent.postMessage({ source: 'zkxw-oa-h5', ...data }, '*')
return
}
} catch (_) {
/* ignore */
}
showToast((data && data.message) || '已关闭(非小程序环境)')
closeToHost(payload || { action: 'oa-detail-close' }, {
showToast: (msg) => showToast(msg),
fallbackMessage: '已关闭(非小程序环境)'
})
}
function onClose() {
@@ -410,7 +398,6 @@ async function withdraw() {
acting.value = true
try {
await fetchWithdrawOaInstance(id)
showToast('已撤销')
closeToMini({ action: 'oa-detail-withdraw', message: '已撤销' })
} catch (e) {
showToast(e?.message || '撤销失败')
@@ -493,17 +480,14 @@ async function submitComment() {
if (kind === 'approve') {
await fetchApproveOaTask(task.id, comment || undefined)
commentVisible.value = false
showToast('已同意')
closeToMini({ action: 'oa-detail-done', message: '已同意' })
} else if (kind === 'reject') {
await fetchRejectOaTask(task.id, comment || undefined)
commentVisible.value = false
showToast('已拒绝')
closeToMini({ action: 'oa-detail-done', message: '已拒绝' })
} else if (kind === 'return') {
await fetchReturnOaTask(task.id, comment)
commentVisible.value = false
showToast('已打回')
closeToMini({ action: 'oa-detail-done', message: '已打回' })
}
} catch (e) {
@@ -538,7 +522,6 @@ async function submitAddSign() {
comment: c || undefined
})
addSignVisible.value = false
showToast('已加签')
closeToMini({ action: 'oa-detail-done', message: '已加签' })
} catch (e) {
showToast(e?.message || '加签失败')
@@ -570,7 +553,6 @@ async function submitTransfer() {
comment: c || undefined
})
transferVisible.value = false
showToast('已转交')
closeToMini({ action: 'oa-detail-done', message: '已转交' })
} catch (e) {
showToast(e?.message || '转交失败')
@@ -632,6 +614,12 @@ async function bootstrap() {
onMounted(() => {
bootstrap()
})
watch(instanceId, (next, prev) => {
if (next && next !== prev) {
bootstrap()
}
})
</script>
<style scoped>
@@ -658,43 +646,9 @@ onMounted(() => {
.oa-detail__body--mine-act {
padding-bottom: 120px;
}
.oa-detail__hd {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 12px;
padding: 4px 2px;
}
.oa-detail__title {
margin: 0;
font-size: 17px;
font-weight: 700;
line-height: 1.35;
color: var(--text-primary, #0f291e);
}
.oa-detail__status {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.oa-detail__status.ok {
color: var(--success, #10b981);
background: var(--brand-green-soft, #ecfdf5);
}
.oa-detail__status.warn {
color: var(--warning, #d97706);
background: var(--barley-yellow-soft, #fffbeb);
}
.oa-detail__status.danger {
color: var(--danger, #ef4444);
background: #fef2f2;
}
.oa-detail__status.mute {
color: var(--text-muted, #5f7a6d);
background: #f1f5f9;
.oa-detail__stamp-host {
position: relative;
min-height: 0;
}
.oa-detail__empty {
margin: 12px 0;
@@ -718,22 +672,38 @@ onMounted(() => {
border-top: 1px solid #eef2f6;
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04);
}
.oa-detail__ft--center {
justify-content: center;
}
.oa-detail__ft--center .oa-detail__btn {
flex: 0 0 auto;
}
.oa-detail__ft--center .oa-detail__btn--print {
min-width: 120px;
padding: 0 28px !important;
}
.oa-detail__ft--equal .oa-detail__btn {
flex: 1 1 0;
min-width: 0;
}
.oa-detail__ft--equal .oa-detail__btn--print {
flex: 1 1 0;
min-width: 0;
}
.oa-detail__btn {
position: relative;
margin: 0;
height: 40px;
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
border-radius: 12px !important;
font-size: 14px;
padding: 0 10px !important;
}
.oa-detail__btn--close {
.oa-detail__btn--print {
flex: 0 0 auto;
min-width: 64px;
border: none !important;
background: #f1f5f9 !important;
color: #334155 !important;
font-weight: 600;
}
.oa-detail__btn--more {
flex: 0 0 auto;
@@ -748,13 +718,6 @@ onMounted(() => {
letter-spacing: 1px;
line-height: 1;
}
.oa-detail__btn--close:active {
opacity: 0.85;
}
.oa-detail__btn--print {
flex: 0 0 auto;
min-width: 64px;
}
.oa-detail__btn--ghost {
border: 1px solid #e2e8f0 !important;
background: #fff !important;
+60 -74
View File
@@ -44,22 +44,20 @@
取消
</van-button>
<van-button
class="oa-start__btn oa-start__btn--draft"
class="oa-start__btn oa-start__btn--draft oa-btn-loading"
:class="{ 'is-loading': savingDraft }"
:disabled="savingDraft"
@click="saveDraft"
>
<span class="oa-start__btn-label">暂存草稿</span>
<span v-if="savingDraft" class="oa-start__btn-spin" aria-hidden="true" />
<OaBtnLoadingInner :loading="savingDraft">暂存草稿</OaBtnLoadingInner>
</van-button>
<van-button
class="oa-start__btn oa-start__btn--submit"
class="oa-start__btn oa-start__btn--submit oa-btn-loading"
:class="{ 'is-loading': submitting }"
:disabled="submitting"
@click="submit"
>
<span class="oa-start__btn-label">提交</span>
<span v-if="submitting" class="oa-start__btn-spin" aria-hidden="true" />
<OaBtnLoadingInner :loading="submitting">提交审批</OaBtnLoadingInner>
</van-button>
</footer>
</div>
@@ -69,6 +67,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { closeToHost } from '../oa/close-to-host.js'
import {
fetchOaTemplateDetail,
fetchOaFlowPreview,
@@ -108,6 +107,13 @@ import {
} from '../oa/kit-runtime.js'
import OaFlowPreview from '../oa/components/OaFlowPreview.vue'
import OaPulseLoading from '../oa/components/OaPulseLoading.vue'
import OaBtnLoadingInner from '../oa/components/OaBtnLoadingInner.vue'
import {
buildFlowPreviewRequestKey,
collectConditionFields,
shouldShowFlowPreviewSpinner,
shouldSkipFlowPreviewRequest
} from '../oa/oa-flow-preview-refresh.js'
const route = useRoute()
const router = useRouter()
@@ -130,6 +136,12 @@ const currentUser = ref(null)
let previewTimer = null
let skipPreviewWatch = false
/** 流程条件字段:仅这些 formData 变化才重拉预览 */
let flowConditionFields = []
let lastFlowPreviewRequestKey = null
let inFlightFlowPreviewKey = null
let hasCompletedFlowPreview = false
let flowPreviewReqSeq = 0
let leaveKitSyncing = false
let leaveKitPrevSnapshot = {}
let tripKitSyncing = false
@@ -176,40 +188,10 @@ const tripDaysManualFields = computed(() => {
/** 通知宿主(小程序 web-view / uni-app H5 iframe)关闭并返回发起列表 */
function closeToMini(payload) {
const data = payload || { action: 'oa-start-close' }
try {
// 微信小程序 web-view
if (typeof wx !== 'undefined' && wx.miniProgram?.navigateBack) {
wx.miniProgram.postMessage({ data })
wx.miniProgram.navigateBack()
return
}
} catch (_) {
/* ignore */
}
try {
// uni-app App / 部分 web-view 容器
const uniWv = typeof uni !== 'undefined' ? uni.webView : null
if (uniWv?.postMessage) {
uniWv.postMessage({ data })
}
if (uniWv?.navigateBack) {
uniWv.navigateBack()
return
}
} catch (_) {
/* ignore */
}
try {
// mobile-h5 H5:web-view 为 iframe,由父页 listen 后 uni.navigateBack
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
window.parent.postMessage({ source: 'zkxw-oa-h5', ...data }, '*')
return
}
} catch (_) {
/* ignore */
}
showToast((data && data.message) || '已完成(非小程序环境)')
closeToHost(payload || { action: 'oa-start-close' }, {
showToast: (msg) => showToast(msg),
fallbackMessage: '已完成(非小程序环境)'
})
}
async function loadCurrentUser() {
@@ -261,12 +243,31 @@ function stepsNeedInitiatorSelect(steps) {
async function loadFlowPreview() {
if (!appMeta.value?.id) return
previewLoading.value = true
const requestKey = buildFlowPreviewRequestKey({
formData: formData.value,
initiatorSelectByNode: initiatorSelectByNode.value,
conditionFields: flowConditionFields
})
if (
shouldSkipFlowPreviewRequest(lastFlowPreviewRequestKey, requestKey, {
inFlightKey: inFlightFlowPreviewKey
})
) {
return
}
const seq = ++flowPreviewReqSeq
inFlightFlowPreviewKey = requestKey
const showSpinner = shouldShowFlowPreviewSpinner({
hasCompletedPreview: hasCompletedFlowPreview
})
if (showSpinner) previewLoading.value = true
try {
const preview = await fetchOaFlowPreview(appMeta.value.id, {
formData: { ...formData.value },
initiatorSelectByNode: { ...initiatorSelectByNode.value }
})
if (seq !== flowPreviewReqSeq) return
lastFlowPreviewRequestKey = requestKey
flowResolved.value = preview?.resolved !== false
flowPendingMessage.value =
preview?.message || '必填信息填写后,流程将自动显示'
@@ -275,11 +276,16 @@ async function loadFlowPreview() {
await loadUsers()
}
} catch (e) {
if (seq !== flowPreviewReqSeq) return
showToast(e?.message || '审批流预览失败')
flowSteps.value = []
flowResolved.value = true
} finally {
previewLoading.value = false
if (seq === flowPreviewReqSeq) {
inFlightFlowPreviewKey = null
hasCompletedFlowPreview = true
previewLoading.value = false
}
}
}
@@ -601,6 +607,11 @@ async function loadByAppId(appId, { seedData = null, applyDefaults = true } = {}
const prepared = prepareMobileFormSchema(detail?.formSchemaJson)
formRule.value = prepared.rule
formOption.value = prepared.option
flowConditionFields = collectConditionFields(detail?.flowTreeJson)
lastFlowPreviewRequestKey = null
inFlightFlowPreviewKey = null
hasCompletedFlowPreview = false
flowPreviewReqSeq += 1
if (applyDefaults) {
formData.value = await applyKitDefaults(seedData || {})
await syncOvertimeKitDurations()
@@ -736,8 +747,12 @@ async function saveDraft() {
})
if (created?.id) editingDraftId.value = created.id
}
showToast('草稿已保存')
closeToMini({ action: 'oa-start-draft', draftId: editingDraftId.value })
// 成功提示交给宿主展示,避免 iframe 内 toast + 宿主 toast 叠成两条
closeToMini({
action: 'oa-start-draft',
draftId: editingDraftId.value,
message: '草稿已保存'
})
} catch (e) {
showToast(e?.message || '保存草稿失败')
} finally {
@@ -788,7 +803,7 @@ async function submit() {
...payload
})
}
showToast('提交成功')
// 成功提示交给宿主展示,避免 iframe 内 toast + 宿主 toast 叠成两条
closeToMini({
action: 'oa-start-submitted',
instanceId: data?.id,
@@ -917,33 +932,4 @@ onUnmounted(() => {
font-weight: 700;
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.14);
}
/* G:文字半透明保留,转圈居中叠层(禁用时勿再整钮降透明) */
.oa-start__btn.is-loading.van-button--disabled {
opacity: 1 !important;
}
.oa-start__btn.is-loading .oa-start__btn-label {
opacity: 0.25;
}
.oa-start__btn-spin {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.06);
pointer-events: none;
}
.oa-start__btn-spin::before {
content: '';
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: oa-start-btn-spin 0.7s linear infinite;
}
@keyframes oa-start-btn-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,353 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>OA 附件 · Vant 缩略图美化 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--page-bg: #f5f6f8;
--text: #1f2a26;
--nav-bg: #ecfdf5;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 55%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 920px; margin: 0 auto; padding: 28px 16px 64px; }
h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.lead { margin: 0; color: #64748b; font-size: 14px; max-width: 720px; }
.goals {
margin-top: 14px; padding: 12px 14px; border-radius: 12px;
background: #ecfdf5; border: 1px solid #a7f3d0;
font-size: 13px; color: #065f46;
}
.goals ul { margin: 6px 0 0; padding-left: 18px; }
.grid {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 18px;
}
.card {
background: rgba(255,255,255,.95);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 14px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.card h2 {
margin: 0 0 4px; font-size: 15px;
display: flex; align-items: center; gap: 8px;
}
.card .sub { margin: 0 0 12px; font-size: 12px; color: #64748b; }
.pill {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 10px; font-weight: 700;
}
.pill.now { background: #fff1f2; color: #be123c; border: 1px solid #fecdd3; }
.pill.rec { background: #ecfdf5; color: #047857; border: 1px solid #a7f3d0; }
.phone {
width: 100%; max-width: 360px; margin: 0 auto;
border-radius: 28px; border: 1px solid #cbd5e1; background: #0f172a;
padding: 10px; box-shadow: 0 18px 40px rgba(15,23,42,.12);
}
.screen {
border-radius: 20px; overflow: hidden; background: var(--page-bg); min-height: 420px;
}
.nav {
display: flex; align-items: center; gap: 8px; min-height: 48px;
padding: 10px 12px;
background: linear-gradient(180deg, #d1fae5 0%, var(--nav-bg) 100%);
border-bottom: 1px solid #bbf7d0;
}
.nav__back {
width: 28px; height: 28px; border-radius: 999px; display: grid; place-items: center;
color: var(--brand-dark); font-weight: 700; background: rgba(255,255,255,.55);
}
.nav__title { flex: 1; font-size: 15px; font-weight: 700; color: #064e3b; }
.body { padding: 14px 12px; }
.label { font-size: 13px; font-weight: 650; color: #334155; margin-bottom: 10px; }
.mode {
display: inline-block; margin-bottom: 10px; padding: 3px 8px; border-radius: 6px;
font-size: 11px; font-weight: 650;
}
.mode.edit { background: #eff6ff; color: #1d4ed8; }
.mode.view { background: #f1f5f9; color: #475569; }
/* ===== 现状(默认 Vant 感) ===== */
.now-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.now-tile {
position: relative; width: 80px; height: 80px; border-radius: 8px;
background: #f7f8fa; border: 1px solid #ebedf0; overflow: hidden;
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 6px;
}
.now-tile.img {
background: linear-gradient(145deg, #86efac, #22c55e); color: #fff;
font-size: 11px; font-weight: 700; padding: 0;
}
.now-tile .fname {
font-size: 10px; color: #969799; text-align: center; word-break: break-all;
line-height: 1.2; max-height: 36px; overflow: hidden;
}
.now-tile .dl {
position: absolute; left: 0; right: 0; bottom: 0;
background: rgba(15,23,42,.55); color: #fff; font-size: 9px;
text-align: center; padding: 2px;
}
.now-tile .x {
position: absolute; top: 0; right: 0; width: 14px; height: 14px;
border-radius: 0 0 0 12px; background: rgba(0,0,0,.7); color: #fff;
font-size: 10px; display: grid; place-items: center; line-height: 1;
}
.now-add {
width: 80px; height: 80px; border-radius: 8px; background: #f7f8fa;
display: grid; place-items: center; color: #dcdee0; font-size: 28px;
}
/* ===== 美化后(仍是缩略图格) ===== */
.nice-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.nice-tile {
position: relative;
width: 84px; height: 84px;
border-radius: 14px;
background: #fff;
border: 1px solid #e4eee9;
box-shadow: 0 4px 12px rgba(20, 55, 40, 0.06);
overflow: hidden;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 8px 6px 18px;
}
.nice-tile.img {
padding: 0;
background:
linear-gradient(145deg, rgba(74, 222, 128, 0.35), rgba(10, 159, 109, 0.55)),
#0a9f6d;
color: #fff; font-size: 11px; font-weight: 700;
border-color: #bbf7d0;
}
.nice-tile .file-ico {
width: 28px; height: 28px; border-radius: 8px;
display: grid; place-items: center; color: #fff; margin-bottom: 4px;
box-shadow: 0 3px 8px rgba(15, 40, 30, 0.15);
}
.nice-tile.ft-pdf .file-ico { background: linear-gradient(145deg, #f07171, #ef4444); }
.nice-tile.ft-xls .file-ico { background: linear-gradient(145deg, #4fbf8a, #10b981); }
.nice-tile.ft-doc .file-ico { background: linear-gradient(145deg, #6ea8ff, #3b82f6); }
.nice-tile .file-ico svg { width: 15px; height: 15px; }
.nice-tile .fname {
font-size: 10px; color: #4d635a; text-align: center;
line-height: 1.25; max-height: 26px; overflow: hidden;
word-break: break-all; font-weight: 600;
}
.nice-tile .dl {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 4px 4px 5px;
font-size: 10px; font-weight: 650; letter-spacing: 0.02em;
text-align: center; color: #fff;
background: linear-gradient(180deg, rgba(8, 127, 87, 0.0), rgba(8, 127, 87, 0.92));
}
.nice-tile.img .dl {
background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.55));
font-weight: 600;
}
.nice-tile .x {
position: absolute; top: 4px; right: 4px;
width: 20px; height: 20px; border-radius: 999px;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(4px);
color: #fff; font-size: 12px;
display: grid; place-items: center; line-height: 1;
box-shadow: 0 2px 6px rgba(0,0,0,.18);
}
.nice-add {
width: 84px; height: 84px; border-radius: 14px;
border: 1.5px dashed #9ad9be;
background: linear-gradient(145deg, #f7fdf9, #e9f9f2);
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 4px;
color: var(--brand);
}
.nice-add .plus {
width: 28px; height: 28px; border-radius: 9px;
background: linear-gradient(145deg, #4ade80, #0a9f6d);
color: #fff; display: grid; place-items: center;
font-size: 18px; font-weight: 700;
box-shadow: 0 4px 10px rgba(10, 159, 109, 0.28);
}
.nice-add .txt { font-size: 11px; font-weight: 650; color: var(--brand-dark); }
.tabs { margin-top: 18px; display: flex; gap: 8px; flex-wrap: wrap; }
.tab {
border: 1px solid #cbd5e1; background: #fff; border-radius: 999px;
padding: 8px 14px; font-size: 13px; font-weight: 650; color: #475569; cursor: pointer;
}
.tab.is-on {
background: var(--brand-soft); border-color: #86efac; color: var(--brand-dark);
}
.panel[hidden] { display: none !important; }
.footnote {
margin-top: 18px; font-size: 12px; color: #64748b;
padding: 12px 14px; border-radius: 12px; background: #fff; border: 1px solid #e2e8f0;
}
</style>
</head>
<body>
<div class="stage">
<h1>附件上传 · 保留 Vant 缩略图,只美化样式</h1>
<p class="lead">
交互不变:格子预览、右上角删除、非图点按下载、详情禁用删除。仅圆角、阴影、删除钮、下载条、添加格做三墙风格打磨。
</p>
<div class="goals">
<strong>美化点</strong>
<ul>
<li>预览格:14px 圆角 + 浅绿描边/轻阴影</li>
<li>删除:圆形半透明钮(替代直角小三角)</li>
<li>下载条:品牌绿渐变底 + 更清晰「点击下载」</li>
<li>添加格:虚线框 + 绿色渐变 + 号</li>
</ul>
</div>
<div class="tabs">
<button class="tab is-on" type="button" data-panel="edit">发起 / 编辑</button>
<button class="tab" type="button" data-panel="view">详情(无删除)</button>
</div>
<div class="panel grid" id="panel-edit">
<section class="card">
<h2>现状 <span class="pill now">修改前</span></h2>
<p class="sub">默认 Vant 灰格,删除钮生硬,下载条发黑。</p>
<div class="phone"><div class="screen">
<div class="nav"><div class="nav__back">‹</div><div class="nav__title">发起 · 附件</div></div>
<div class="body">
<div class="mode edit">编辑态</div>
<div class="label">相关附件</div>
<div class="now-grid">
<div class="now-tile img">现场照<span class="x">×</span></div>
<div class="now-tile">
<div class="fname">报销单.pdf</div>
<div class="dl">点击下载</div>
<span class="x">×</span>
</div>
<div class="now-tile">
<div class="fname">费用.xlsx</div>
<div class="dl">点击下载</div>
<span class="x">×</span>
</div>
<div class="now-add">+</div>
</div>
</div>
</div></div>
</section>
<section class="card">
<h2>美化后 <span class="pill rec">推荐</span></h2>
<p class="sub">仍是缩略图格;类型小图标 + 文件名更易认。</p>
<div class="phone"><div class="screen">
<div class="nav"><div class="nav__back">‹</div><div class="nav__title">发起 · 附件</div></div>
<div class="body">
<div class="mode edit">编辑态</div>
<div class="label">相关附件</div>
<div class="nice-grid">
<div class="nice-tile img">现场照<span class="x">×</span></div>
<div class="nice-tile ft-pdf">
<div class="file-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
</div>
<div class="fname">报销单.pdf</div>
<div class="dl">点击下载</div>
<span class="x">×</span>
</div>
<div class="nice-tile ft-xls">
<div class="file-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h5"/></svg>
</div>
<div class="fname">费用.xlsx</div>
<div class="dl">点击下载</div>
<span class="x">×</span>
</div>
<div class="nice-add"><div class="plus">+</div><div class="txt">添加</div></div>
</div>
</div>
</div></div>
</section>
</div>
<div class="panel grid" id="panel-view" hidden>
<section class="card">
<h2>现状 · 详情</h2>
<p class="sub">无删除;下载条仍偏黑。</p>
<div class="phone"><div class="screen">
<div class="nav"><div class="nav__back">‹</div><div class="nav__title">详情 · 附件</div></div>
<div class="body">
<div class="mode view">只读</div>
<div class="label">相关附件</div>
<div class="now-grid">
<div class="now-tile img">现场照</div>
<div class="now-tile"><div class="fname">报销单.pdf</div><div class="dl">点击下载</div></div>
<div class="now-tile"><div class="fname">费用.xlsx</div><div class="dl">点击下载</div></div>
</div>
</div>
</div></div>
</section>
<section class="card">
<h2>美化后 · 详情 <span class="pill rec">推荐</span></h2>
<p class="sub">无删除钮;保留下载条。</p>
<div class="phone"><div class="screen">
<div class="nav"><div class="nav__back">‹</div><div class="nav__title">详情 · 附件</div></div>
<div class="body">
<div class="mode view">只读</div>
<div class="label">相关附件</div>
<div class="nice-grid">
<div class="nice-tile img">现场照</div>
<div class="nice-tile ft-pdf">
<div class="file-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
</div>
<div class="fname">报销单.pdf</div>
<div class="dl">点击下载</div>
</div>
<div class="nice-tile ft-xls">
<div class="file-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h5"/></svg>
</div>
<div class="fname">费用.xlsx</div>
<div class="dl">点击下载</div>
</div>
</div>
</div>
</div></div>
</section>
</div>
<div class="footnote">
实现方式:继续用 <code>van-uploader</code>,用 scoped <code>:deep</code> 覆盖预览格 / 删除钮 / 上传触发区,并增强 <code>preview-cover</code> 下载条。
不改成文件行卡。
</div>
</div>
<script>
document.querySelectorAll('.tab').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((b) => b.classList.remove('is-on'))
btn.classList.add('is-on')
const id = btn.getAttribute('data-panel')
document.getElementById('panel-edit').hidden = id !== 'edit'
document.getElementById('panel-view').hidden = id !== 'view'
})
})
</script>
</body>
</html>
@@ -0,0 +1,564 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>mobile-oa-h5 · 详情头区去掉标题 + 打印印章 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--page-bg: #f5f6f8;
--text: #1f2a26;
--text-muted: #6b7b74;
--nav-bg: #ecfdf5;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
radial-gradient(700px 380px at 92% 0%, rgba(214, 163, 67, 0.1), transparent 50%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1280px; margin: 0 auto; padding: 28px 16px 56px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 820px; }
.hero .note {
margin-top: 12px; display: inline-block; padding: 8px 12px;
border-radius: 10px; background: #fff; border: 1px solid #e2e8f0;
font-size: 12px; color: #64748b;
}
.hero .note strong { color: #0f766e; }
.goals {
margin-top: 14px; padding: 12px 14px; border-radius: 12px;
background: #ecfdf5; border: 1px solid #a7f3d0;
font-size: 13px; color: #065f46;
}
.goals ul { margin: 6px 0 0; padding-left: 18px; }
.grid {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
align-items: start;
}
.card {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 14px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.card h2 {
margin: 0 0 4px;
font-size: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.card .sub { margin: 0 0 12px; font-size: 12px; color: #64748b; }
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
}
.pill.now { background: #fff1f2; color: #be123c; border: 1px solid #fecdd3; }
.pill.rec { background: #ecfdf5; color: #047857; border: 1px solid #a7f3d0; }
.pill.alt { background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; }
/* phone frame */
.phone {
width: 100%;
max-width: 360px;
margin: 0 auto;
border-radius: 28px;
border: 1px solid #cbd5e1;
background: #0f172a;
padding: 10px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
.phone__screen {
border-radius: 20px;
overflow: hidden;
background: var(--page-bg);
min-height: 520px;
display: flex;
flex-direction: column;
}
.nav {
display: flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 10px 12px;
background: linear-gradient(180deg, #d1fae5 0%, var(--nav-bg) 100%);
border-bottom: 1px solid #bbf7d0;
}
.nav__back {
width: 28px; height: 28px; border-radius: 999px;
display: grid; place-items: center;
color: var(--brand-dark); font-weight: 700; font-size: 16px;
background: rgba(255,255,255,.55);
}
.nav__title {
flex: 1; min-width: 0;
font-size: 15px; font-weight: 700;
color: #064e3b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.body {
position: relative;
flex: 1;
padding: 12px 12px 20px;
}
.body--stamp-pad {
padding-top: 8px;
}
/* current header: title + tag */
.hd {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 12px;
padding: 4px 2px;
}
.hd__title {
margin: 0;
font-size: 17px;
font-weight: 700;
line-height: 1.35;
color: #0f291e;
}
.tag {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: #10b981;
background: #ecfdf5;
}
.dup-hint {
outline: 2px dashed #f43f5e;
outline-offset: 2px;
border-radius: 4px;
background: rgba(244, 63, 94, 0.06);
}
/* form fake */
.form {
background: #fff;
border: 1px solid #e8edf2;
border-radius: 12px;
overflow: hidden;
}
.form__row {
display: grid;
grid-template-columns: 88px 1fr;
border-bottom: 1px solid #f1f5f9;
min-height: 44px;
}
.form__row:last-child { border-bottom: 0; }
.form__k {
padding: 10px 12px;
font-size: 12px;
color: #64748b;
background: #fafbfc;
border-right: 1px solid #f1f5f9;
display: flex; align-items: center;
}
.form__v {
padding: 10px 12px;
font-size: 13px;
color: #0f172a;
display: flex; align-items: center;
}
.tl {
margin-top: 12px;
background: #fff;
border: 1px solid #e8edf2;
border-radius: 12px;
padding: 12px;
}
.tl h3 { margin: 0 0 8px; font-size: 13px; color: #334155; }
.tl p { margin: 0; font-size: 12px; color: #64748b; }
/* ===== 印章 v4(对齐 web-admin OaApprovalPrintSheet) ===== */
.stamp-host {
position: relative;
min-height: 0;
}
.oa-stamp {
--stamp: #3ec9a8;
position: absolute;
top: -2px;
right: 4px;
width: 96px;
height: 96px;
z-index: 5;
pointer-events: none;
transform: rotate(-18deg);
opacity: 0.88;
color: var(--stamp);
}
.oa-stamp.ok { --stamp: #3ec9a8; }
.oa-stamp.run { --stamp: #f0a020; }
.oa-stamp.rej { --stamp: #e85d5d; }
.oa-stamp.ret { --stamp: #e07070; }
.oa-stamp.can { --stamp: #9aa0a6; }
.oa-stamp .outer {
position: absolute;
inset: 0;
border-radius: 50%;
border: 4px solid currentColor;
box-sizing: border-box;
}
.oa-stamp .arc-top,
.oa-stamp .arc-bottom {
position: absolute;
left: 12px;
right: 12px;
border: 2px solid currentColor;
opacity: 0.95;
pointer-events: none;
}
.oa-stamp .arc-top {
top: 9px;
height: 28px;
border-bottom: none;
border-left-color: transparent;
border-right-color: transparent;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}
.oa-stamp .arc-bottom {
bottom: 9px;
height: 28px;
border-top: none;
border-left-color: transparent;
border-right-color: transparent;
border-radius: 0 0 50% 50% / 0 0 100% 100%;
}
.oa-stamp .label-top {
position: absolute;
top: 18px;
left: 0; right: 0;
text-align: center;
font-size: 9px;
font-weight: 500;
letter-spacing: 1.5px;
line-height: 1;
color: currentColor;
opacity: 0.95;
}
.oa-stamp .label-bottom {
position: absolute;
bottom: 16px;
left: 0; right: 0;
text-align: center;
font-size: 8px;
font-weight: 500;
letter-spacing: 0.5px;
line-height: 1;
color: currentColor;
opacity: 0.9;
font-family: Arial, "Helvetica Neue", sans-serif;
}
.oa-stamp .main {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 800;
letter-spacing: 1px;
line-height: 1;
color: currentColor;
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
-webkit-text-stroke: 0.3px currentColor;
}
.oa-stamp .main.chars-3 {
font-size: 20px;
letter-spacing: 0.5px;
}
/* A2: 略小印章浮在表单右上(已选 72px) */
.oa-stamp.sm {
width: 72px;
height: 72px;
top: 8px;
right: 8px;
}
.oa-stamp.sm .outer { border-width: 3px; }
.oa-stamp.sm .main { font-size: 17px; }
.oa-stamp.sm .main.chars-3 { font-size: 14px; }
.oa-stamp.sm .label-top { top: 13px; font-size: 7px; letter-spacing: 1px; }
.oa-stamp.sm .label-bottom { bottom: 12px; font-size: 6px; }
.oa-stamp.sm .arc-top { top: 7px; height: 20px; left: 8px; right: 8px; }
.oa-stamp.sm .arc-bottom { bottom: 7px; height: 20px; left: 8px; right: 8px; }
/* 对照:上一版 84px */
.oa-stamp.md {
width: 84px;
height: 84px;
top: 6px;
right: 8px;
}
.oa-stamp.md .main { font-size: 20px; }
.oa-stamp.md .main.chars-3 { font-size: 17px; }
.oa-stamp.md .label-top { top: 15px; font-size: 8px; }
.oa-stamp.md .label-bottom { bottom: 14px; font-size: 7px; }
.oa-stamp.md .arc-top { top: 8px; height: 24px; left: 10px; right: 10px; }
.oa-stamp.md .arc-bottom { bottom: 8px; height: 24px; left: 10px; right: 10px; }
.stamp-row {
margin-top: 18px;
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
padding: 16px;
background: #fff;
border: 1px dashed #cbd5e1;
border-radius: 14px;
}
.stamp-row .item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 110px;
}
.stamp-row .item span {
font-size: 11px;
color: #64748b;
}
.stamp-static {
position: relative;
width: 96px;
height: 96px;
}
.stamp-static .oa-stamp {
position: absolute;
top: 0; right: 0;
transform: rotate(-18deg);
}
.legend {
margin-top: 18px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0;
font-size: 13px;
color: #475569;
}
.legend h2 { margin: 0 0 8px; font-size: 15px; color: var(--text); }
.legend ol { margin: 0; padding-left: 18px; }
.legend li { margin: 4px 0; }
.legend code {
font-size: 12px;
background: #f1f5f9;
padding: 1px 6px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="stage">
<header class="hero">
<h1>OA 详情 · 去掉内容区流程标题 + 右上角打印印章</h1>
<p>
对比当前「标题 + 已完成 Tag」与目标态「顶栏已有标题,内容区不再重复标题;状态改为对齐 web 打印的钉钉风印章,叠在右上角」。
改动落点:<code>mobile-oa-h5/src/views/OaDetail.vue</code>(webview 内嵌页)。
</p>
<div class="note">本地打开本文件即可预览 · 文件:<strong>mobile-ui/zkxw-mobile-oa-detail-header-stamp-demo.html</strong></div>
<div class="goals">
<strong>目标</strong>
<ul>
<li>去掉内容区顶部流程名称(与顶栏标题重复)</li>
<li>「已完成」等状态 Tag → web 打印同款圆形印章,放在界面右上角</li>
<li>文案对齐打印:completed →「已通过」(非「已完成」)</li>
</ul>
</div>
</header>
<div class="grid">
<!-- 现状 -->
<section class="card">
<h2><span class="pill now">现状</span> 标题 + Tag</h2>
<p class="sub">顶栏与内容区标题重复(红框);状态为小胶囊 Tag。</p>
<div class="phone">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">差旅报销申请</div>
</div>
<div class="body">
<header class="hd">
<h1 class="hd__title dup-hint">差旅报销申请</h1>
<span class="tag">已完成</span>
</header>
<div class="form">
<div class="form__row"><div class="form__k">申请人</div><div class="form__v">张三</div></div>
<div class="form__row"><div class="form__k">金额</div><div class="form__v">¥ 1,280.00</div></div>
<div class="form__row"><div class="form__k">事由</div><div class="form__v">客户拜访差旅</div></div>
</div>
<div class="tl">
<h3>审批进度</h3>
<p>发起 → 部门负责人(已同意)→ 结束</p>
</div>
</div>
</div>
</div>
</section>
<!-- 选定:A 布局 + 略小印章 -->
<section class="card">
<h2><span class="pill rec">已选</span> A 布局 · 印章 72px</h2>
<p class="sub">去掉内容区标题;钉钉风印章叠在表单右上(相对 web 打印 118px / 上一版 84px 再小一档)。</p>
<div class="phone">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">差旅报销申请</div>
</div>
<div class="body body--stamp-pad">
<div class="stamp-host">
<div class="oa-stamp ok sm" aria-label="已通过">
<div class="outer"></div>
<div class="arc-top"></div>
<div class="arc-bottom"></div>
<div class="label-top">OA审批</div>
<div class="main">已通过</div>
<div class="label-bottom">approval</div>
</div>
<div class="form">
<div class="form__row"><div class="form__k">申请人</div><div class="form__v">张三</div></div>
<div class="form__row"><div class="form__k">金额</div><div class="form__v">¥ 1,280.00</div></div>
<div class="form__row"><div class="form__k">事由</div><div class="form__v">客户拜访差旅</div></div>
<div class="form__row"><div class="form__k">备注</div><div class="form__v">含市内交通</div></div>
</div>
</div>
<div class="tl">
<h3>审批进度</h3>
<p>发起 → 部门负责人(已同意)→ 结束</p>
</div>
</div>
</div>
</div>
</section>
<!-- 对照:大一号 -->
<section class="card">
<h2><span class="pill alt">对照</span> 若用 84px(未选)</h2>
<p class="sub">仅尺寸对照;落地用左侧 72px。</p>
<div class="phone">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">差旅报销申请</div>
</div>
<div class="body body--stamp-pad">
<div class="stamp-host">
<div class="oa-stamp ok md" aria-label="已通过">
<div class="outer"></div>
<div class="arc-top"></div>
<div class="arc-bottom"></div>
<div class="label-top">OA审批</div>
<div class="main">已通过</div>
<div class="label-bottom">approval</div>
</div>
<div class="form">
<div class="form__row"><div class="form__k">申请人</div><div class="form__v">张三</div></div>
<div class="form__row"><div class="form__k">金额</div><div class="form__v">¥ 1,280.00</div></div>
<div class="form__row"><div class="form__k">事由</div><div class="form__v">客户拜访差旅</div></div>
<div class="form__row"><div class="form__k">备注</div><div class="form__v">含市内交通</div></div>
</div>
</div>
<div class="tl">
<h3>审批进度</h3>
<p>发起 → 部门负责人(已同意)→ 结束</p>
</div>
</div>
</div>
</div>
</section>
</div>
<section class="legend">
<h2>各状态印章色(与 web 打印一致)</h2>
<div class="stamp-row">
<div class="item">
<div class="stamp-static">
<div class="oa-stamp ok">
<div class="outer"></div><div class="arc-top"></div><div class="arc-bottom"></div>
<div class="label-top">OA审批</div><div class="main">已通过</div><div class="label-bottom">approval</div>
</div>
</div>
<span>completed / approved → ok</span>
</div>
<div class="item">
<div class="stamp-static">
<div class="oa-stamp run">
<div class="outer"></div><div class="arc-top"></div><div class="arc-bottom"></div>
<div class="label-top">OA审批</div><div class="main">审批中</div><div class="label-bottom">approval</div>
</div>
</div>
<span>running / pending → run</span>
</div>
<div class="item">
<div class="stamp-static">
<div class="oa-stamp rej">
<div class="outer"></div><div class="arc-top"></div><div class="arc-bottom"></div>
<div class="label-top">OA审批</div><div class="main">已拒绝</div><div class="label-bottom">approval</div>
</div>
</div>
<span>rejected → rej</span>
</div>
<div class="item">
<div class="stamp-static">
<div class="oa-stamp ret">
<div class="outer"></div><div class="arc-top"></div><div class="arc-bottom"></div>
<div class="label-top">OA审批</div><div class="main">已打回</div><div class="label-bottom">approval</div>
</div>
</div>
<span>returned → ret</span>
</div>
<div class="item">
<div class="stamp-static">
<div class="oa-stamp can">
<div class="outer"></div><div class="arc-top"></div><div class="arc-bottom"></div>
<div class="label-top">OA审批</div><div class="main">已撤销</div><div class="label-bottom">approval</div>
</div>
</div>
<span>cancelled → can</span>
</div>
</div>
</section>
<section class="legend">
<h2>当前共识</h2>
<ol>
<li>去掉内容区流程标题(顶栏已有)</li>
<li>状态改为 web 打印同款印章,叠在表单区域右上角</li>
<li>尺寸用 <strong>72px</strong>(A 布局 + 再小一档),文案用打印 <code>resolveStamp</code>(如 completed →「已通过」)</li>
</ol>
<p style="margin:10px 0 0">若 72px 仍偏大/偏小,直接说目标像素即可;否则回复「按此设计」我写 spec 并实现。</p>
</section>
</div>
</body>
</html>
@@ -0,0 +1,678 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>草稿箱 · 模式 A 美化 Demo(管理位 + 卡片 + 勾选对齐)</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--warn: #d97706;
--warn-soft: #fff7e8;
--danger: #ef4444;
--text: #1f2a26;
--text-muted: #6b7b74;
--border: #dcebe4;
--page-bg: #f5f8f7;
--surface: #fff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.14), transparent 55%),
radial-gradient(700px 380px at 92% 0%, rgba(214, 163, 67, 0.1), transparent 50%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 45%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1320px; margin: 0 auto; padding: 28px 16px 64px; }
.hero h1 { margin: 0 0 8px; font-size: 24px; font-weight: 720; letter-spacing: -0.02em; }
.hero p { margin: 0; max-width: 820px; color: #64748b; font-size: 14px; }
.hero .note {
margin-top: 12px; display: inline-block; padding: 8px 12px;
border-radius: 10px; background: #fff; border: 1px solid #e2e8f0;
font-size: 12px; color: #64748b;
}
.hero .note strong { color: #0f766e; }
.pain {
margin: 18px 0 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.pain-item {
background: #fff;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 10px 12px;
font-size: 12px;
color: #7f1d1d;
}
.pain-item b { color: #b91c1c; }
.tabs {
display: flex; flex-wrap: wrap; gap: 10px; margin: 22px 0 18px;
}
.tab {
appearance: none; border: 1px solid #e2e8f0; background: #fff;
border-radius: 999px; padding: 10px 16px; font-size: 13px;
font-weight: 600; color: #475569; cursor: pointer;
transition: .15s border-color, .15s box-shadow, .15s color, .15s background;
}
.tab:hover { border-color: #94a3b8; }
.tab.is-active {
color: var(--brand-dark); border-color: var(--brand);
background: var(--brand-soft);
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.18);
}
.tab .badge {
margin-left: 6px; font-size: 10px; font-weight: 700;
padding: 2px 6px; border-radius: 999px;
background: #ecfdf5; color: #047857;
}
.layout {
display: grid;
grid-template-columns: minmax(260px, 340px) 1fr;
gap: 22px;
align-items: start;
}
@media (max-width: 960px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,.92);
border: 1px solid #e2e8f0;
border-radius: 18px;
padding: 18px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
}
.panel h2 { margin: 0 0 4px; font-size: 16px; }
.panel .sub { margin: 0 0 12px; font-size: 12px; color: #64748b; }
.panel ul { margin: 0; padding-left: 18px; font-size: 13px; color: #475569; }
.panel li { margin: 6px 0; }
.panel .pick {
margin-top: 14px; padding: 12px; border-radius: 12px;
background: var(--brand-soft); border: 1px solid #c6f0dc;
font-size: 12px; color: #166534;
}
.phones {
display: flex; flex-wrap: wrap; gap: 20px; justify-content: center;
}
.phone {
width: 375px; background: #fff; border-radius: 28px;
box-shadow: 0 0 0 1px rgb(15 23 42 / 6%), 0 18px 40px rgb(15 23 42 / 12%);
overflow: hidden;
}
.phone-bar {
height: 44px; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #0a9f6d, #087f57);
color: #fff; font-size: 16px; font-weight: 600;
}
.phone-cap {
padding: 8px 14px; font-size: 11px; font-weight: 650;
color: #64748b; background: #f8fafc; border-bottom: 1px solid #eef2f0;
text-align: center;
}
.page { background: var(--page-bg); min-height: 620px; padding-bottom: 18px; }
.style-block { display: none; }
.style-block.is-active { display: block; }
/* ========== 共用搜索 ========== */
.search {
height: 36px; display: flex; align-items: center; gap: 8px;
padding: 0 12px; border-radius: 10px; border: 1px solid #e8eaed;
background: #fafafa; font-size: 13px; color: #94a3b8; width: 100%;
}
.search::before {
content: ""; width: 12px; height: 12px; border: 1.5px solid #94a3b8;
border-radius: 50%; flex-shrink: 0;
}
/* ========== 共用卡片(美化后的模式 A) ========== */
.card {
width: 100%;
background: var(--surface);
border: 1px solid #e2ebe6;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.card-body { padding: 14px 14px 12px; }
.card-top {
display: flex; align-items: flex-start; justify-content: space-between; gap: 10px;
}
.card-title {
font-size: 15px; font-weight: 700; color: #1f2a26; line-height: 1.35;
min-width: 0; flex: 1;
}
.card-status {
flex-shrink: 0; font-size: 11px; font-weight: 700;
padding: 3px 9px; border-radius: 999px;
background: var(--warn-soft); color: var(--warn);
}
.card-meta { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.card-meta-line {
display: flex; gap: 10px; font-size: 12px; line-height: 1.4;
}
.card-meta-line .lab { color: #94a3b8; min-width: 56px; flex-shrink: 0; }
.card-meta-line .val { color: #475569; min-width: 0; }
.card-footer {
display: flex; gap: 8px; padding: 10px 12px 12px;
border-top: 1px dashed rgba(140, 170, 155, 0.28);
}
.card-footer button {
flex: 1; min-width: 0; height: 34px; border-radius: 10px;
border: 1px solid #d8e3de; background: #fff;
font-size: 12px; font-weight: 650; color: #334155; cursor: pointer;
}
.card-footer .p {
background: var(--brand); border-color: var(--brand); color: #fff;
}
.card-footer .d {
color: var(--danger); border-color: rgba(239,68,68,.35);
background: rgba(239,68,68,.06);
}
.list { padding: 10px 12px 0; display: flex; flex-direction: column; gap: 10px; }
.end { text-align: center; padding: 14px 0 6px; font-size: 12px; color: #94a3b8; }
/* —— 卡片变体:更精致 —— */
.card.v-soft {
border-color: transparent;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.card.v-accent {
border-color: #e8eeeb;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
position: relative;
}
.card.v-accent::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: linear-gradient(180deg, #f59e0b, #d6a343);
}
.card.v-accent .card-body { padding-left: 16px; }
.card.v-flat {
border: 0;
background: #fff;
box-shadow: none;
border-radius: 12px;
outline: 1px solid #e8eeeb;
}
.card.v-meta-inline .card-meta {
flex-direction: row; flex-wrap: wrap; gap: 4px 12px;
}
.card.v-meta-inline .card-meta-line .lab { min-width: 0; }
.card.v-meta-inline .card-meta-line .lab::after { content: " "; }
.card.v-meta-inline .sep {
color: #cbd5e1; font-size: 12px;
}
/* ========== A0 现状对照 ========== */
.a0-toolbar {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; background: #fff; border-bottom: 1px solid #f1f5f9;
}
.a0-manage {
flex-shrink: 0; font-size: 14px; font-weight: 600; color: #166534;
padding: 0 2px; white-space: nowrap;
}
.a0-row {
display: flex; align-items: flex-start; gap: 8px; width: 100%;
}
.a0-check {
padding-top: 20px; flex-shrink: 0;
}
.a0-check span {
display: flex; width: 20px; height: 20px; border-radius: 50%;
border: 1.5px solid #cbd5e1; background: #fff;
align-items: center; justify-content: center;
font-size: 11px; color: #fff; font-weight: 700;
}
.a0-check span.on { background: #22c55e; border-color: #22c55e; }
.a0-row .card { flex: 1; min-width: 0; }
/* ========== 方案 1:搜索独占 + 列表头「共N条 | 管理」 ========== */
.s1-toolbar { padding: 10px 12px 8px; background: #fff; border-bottom: 1px solid #f1f5f9; }
.s1-head {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px 2px;
}
.s1-count { font-size: 12px; color: var(--text-muted); }
.s1-count strong { color: var(--brand-dark); font-weight: 700; }
.s1-link {
font-size: 13px; font-weight: 650; color: var(--brand-dark);
padding: 4px 2px; cursor: pointer; user-select: none;
}
.s1-link.is-done { color: #64748b; }
/* ========== 方案 2:顶栏右侧胶囊「管理」 ========== */
.s2-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; background: #fff; border-bottom: 1px solid #f1f5f9;
}
.s2-chip {
flex-shrink: 0; height: 36px; padding: 0 14px;
border-radius: 999px; border: 1px solid #d8e3de;
background: #fff; font-size: 13px; font-weight: 650;
color: var(--brand-dark); cursor: pointer;
display: inline-flex; align-items: center; gap: 4px;
}
.s2-chip.is-on {
background: #f1f5f9; border-color: #e2e8f0; color: #64748b;
}
.s2-chip svg { width: 14px; height: 14px; }
/* ========== 方案 3:搜索下方次级工具条 ========== */
.s3-toolbar { padding: 10px 12px 0; background: #fff; border-bottom: 1px solid #f1f5f9; }
.s3-sub {
display: flex; align-items: center; justify-content: space-between;
margin-top: 10px; padding: 8px 2px 10px;
}
.s3-hint { font-size: 12px; color: #94a3b8; }
.s3-btn {
height: 28px; padding: 0 12px; border-radius: 8px;
border: 1px solid #c6f0dc; background: var(--brand-soft);
color: var(--brand-dark); font-size: 12px; font-weight: 650; cursor: pointer;
}
.s3-btn.is-on {
background: #f1f5f9; border-color: #e2e8f0; color: #64748b;
}
/* ========== 方案 4:底栏管理(列表干净) ========== */
.s4-page { position: relative; padding-bottom: 64px; }
.s4-toolbar { padding: 10px 12px; background: #fff; border-bottom: 1px solid #f1f5f9; }
.s4-dock {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(255,255,255,.96);
border-top: 1px solid #e8eeeb;
display: flex; align-items: center; justify-content: space-between;
backdrop-filter: blur(8px);
}
.s4-dock .hint { font-size: 12px; color: var(--text-muted); }
.s4-dock .btn {
height: 36px; padding: 0 18px; border-radius: 10px;
border: 0; background: var(--brand); color: #fff;
font-size: 13px; font-weight: 700; cursor: pointer;
}
.s4-dock.is-manage .btn {
background: #f1f5f9; color: #64748b;
}
.s4-dock .danger {
height: 36px; padding: 0 16px; border-radius: 10px;
border: 1px solid rgba(239,68,68,.35);
background: rgba(239,68,68,.06); color: var(--danger);
font-size: 13px; font-weight: 650; cursor: pointer;
}
/* ========== 管理态:勾选垂直居中 ========== */
.row {
display: flex; align-items: center; gap: 10px; width: 100%;
}
.row .card { flex: 1; min-width: 0; margin: 0; }
.check {
flex-shrink: 0; width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
}
.check span {
display: flex; width: 20px; height: 20px; border-radius: 50%;
border: 1.5px solid #cbd5e1; background: #fff;
align-items: center; justify-content: center;
font-size: 11px; color: #fff; font-weight: 700;
}
.check span.on { background: #22c55e; border-color: #22c55e; }
.row.is-on .card {
border-color: rgba(34, 197, 94, 0.45);
box-shadow: 0 8px 20px rgba(22, 163, 74, 0.12);
}
.batch {
margin: 10px 12px 0; padding: 10px 12px; border-radius: 12px;
background: #fff; border: 1px solid #c6f0dc;
display: flex; align-items: center; justify-content: space-between;
font-size: 13px; color: #166534;
}
.batch button {
height: 30px; padding: 0 12px; border-radius: 8px;
border: 1px solid rgba(239,68,68,.35);
background: rgba(239,68,68,.06); color: var(--danger);
font-size: 12px; font-weight: 650; cursor: pointer;
}
/* 内嵌勾选(方案变体) */
.card-check-in {
display: flex; gap: 10px; align-items: flex-start;
}
.card-check-in .check { margin-top: 2px; }
.meta-inline {
margin-top: 8px; font-size: 12px; color: #64748b;
}
.meta-inline .dot { margin: 0 6px; color: #cbd5e1; }
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>草稿箱 · 模式 A 美化选型</h1>
<p>
保留现有信息结构(标题 / 草稿态 / 最近保存 / 版本 / 编辑·提交·删除),针对你反馈的三点做方案对比:
<strong>管理入口不突兀</strong>、<strong>勾选与卡片垂直居中</strong>、<strong>卡片更好看</strong>。
</p>
<div class="note">请选 <strong>S1 / S2 / S3 / S4</strong>(卡片可再指定 soft / accent / flat)。选好后我落到 <code>mobile-h5</code>。</div>
<div class="pain">
<div class="pain-item"><b>痛点 1</b> 「管理」挤在搜索框右侧,突兀</div>
<div class="pain-item"><b>痛点 2</b> 管理态勾选与卡片未垂直居中</div>
<div class="pain-item"><b>痛点 3</b> 卡片观感偏平、不好看</div>
</div>
</div>
<div class="tabs" id="tabs">
<button class="tab is-active" data-k="a0">A0 现状对照</button>
<button class="tab" data-k="s1">S1 列表头管理<span class="badge">推荐</span></button>
<button class="tab" data-k="s2">S2 胶囊按钮</button>
<button class="tab" data-k="s3">S3 次级工具条</button>
<button class="tab" data-k="s4">S4 底栏管理</button>
<button class="tab" data-k="cards">卡片三款</button>
</div>
<div class="layout">
<div class="panel" id="desc"></div>
<div class="phones" id="phones"></div>
</div>
</div>
<script>
const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg>';
function cardHtml(title, time, ver, opts) {
opts = opts || {};
var cls = "card" + (opts.variant ? " " + opts.variant : "");
var footer = opts.noActions ? "" : (
'<div class="card-footer">' +
'<button class="p">编辑</button>' +
'<button>提交审批</button>' +
'<button class="d">删除</button>' +
'</div>'
);
var meta = opts.inlineMeta
? ('<div class="meta-inline">' + time + '<span class="dot">·</span>版本 ' + ver + '</div>')
: (
'<div class="card-meta">' +
'<div class="card-meta-line"><span class="lab">最近保存</span><span class="val">' + time + '</span></div>' +
'<div class="card-meta-line"><span class="lab">版本</span><span class="val">' + ver + '</span></div>' +
'</div>'
);
return (
'<div class="' + cls + '">' +
'<div class="card-body">' +
'<div class="card-top">' +
'<div class="card-title">' + title + '</div>' +
'<div class="card-status">草稿</div>' +
'</div>' +
meta +
'</div>' +
footer +
'</div>'
);
}
const ROWS = [
["入帐单", "2026-08-12 17:03:42", "v5"],
["出差申请", "2026-08-11 09:18:00", "v3"],
["采购报销", "2026-08-10 17:05:11", "v2"]
];
function phone(cap, bodyHtml) {
return (
'<div class="phone">' +
'<div class="phone-bar">草稿箱</div>' +
(cap ? '<div class="phone-cap">' + cap + '</div>' : '') +
'<div class="page">' + bodyHtml + '</div>' +
'</div>'
);
}
const DESCS = {
a0: {
title: "A0 · 现状对照",
sub: "复现当前问题,方便并排对比。",
items: [
"「管理」文字直接贴在搜索框右侧,视觉突兀",
"管理态勾选 padding-top 硬对齐,无法跟卡片垂直居中",
"卡片可用,但边框/按钮节奏偏旧"
],
pick: "仅作对照,不建议落地。"
},
s1: {
title: "S1 · 列表头管理(推荐)",
sub: "搜索独占一行;「共 N 条」与「管理」放在列表上方次级行。",
items: [
"搜索框不再被挤窄,输入更舒适",
"管理入口与列表语义绑定,不再像外挂按钮",
"管理态:勾选与卡片 align-items:center 垂直居中",
"卡片:轻阴影 + 等分按钮(现状结构保留)"
],
pick: "最推荐:改动小、符合列表页习惯(微信/钉钉类)。"
},
s2: {
title: "S2 · 搜索旁胶囊按钮",
sub: "仍在顶栏,但「管理」做成等高胶囊,降低突兀感。",
items: [
"与搜索同高的描边胶囊,比纯文字更规整",
"可选左侧小图标强化「管理模式」",
"勾选垂直居中;完成态胶囊变灰"
],
pick: "想少改布局、只润色顶栏时选 S2。"
},
s3: {
title: "S3 · 搜索下次级工具条",
sub: "搜索下方增加一行提示 + 浅绿小按钮。",
items: [
"主搜索区干净,管理降为次级操作",
"可用文案提示「可批量删除草稿」",
"管理态同样垂直居中勾选"
],
pick: "需要教育用户「可批量管理」时选 S3。"
},
s4: {
title: "S4 · 底栏管理",
sub: "列表区完全干净;管理/批量删除沉到底部固定栏。",
items: [
"顶栏只留搜索,最清爽",
"进入管理后底栏切换为「已选 N · 批量删除 · 完成」",
"适合单手操作、强调批量删除场景"
],
pick: "草稿常需批量清理时选 S4;会占一点底部空间。"
},
cards: {
title: "卡片三款细调",
sub: "同一信息结构,只换卡片「皮」;可与 S1–S4 任意组合。",
items: [
"Soft:无描边、更柔阴影",
"Accent:左侧琥珀竖条强调草稿",
"Flat:细线扁平,信息更密(元信息可一行)"
],
pick: "建议:S1 布局 + Soft 或 Accent 卡片。"
}
};
function renderA0() {
var normal = phone("普通态 · 管理突兀",
'<div class="a0-toolbar"><div class="search" style="flex:1">搜索流程名称</div><div class="a0-manage">管理</div></div>' +
'<div class="list">' +
ROWS.map(function (r) { return cardHtml(r[0], r[1], r[2]); }).join("") +
'<div class="end">已经到底了</div></div>'
);
var manage = phone("管理态 · 勾选未居中",
'<div class="a0-toolbar"><div class="search" style="flex:1">搜索流程名称</div><div class="a0-manage" style="color:#64748b">完成</div></div>' +
'<div class="batch"><span>已选 1 项</span><button>批量删除</button></div>' +
'<div class="list">' +
'<div class="a0-row"><div class="a0-check"><span class="on">✓</span></div>' + cardHtml(ROWS[0][0], ROWS[0][1], ROWS[0][2], { noActions: true }) + '</div>' +
'<div class="a0-row"><div class="a0-check"><span></span></div>' + cardHtml(ROWS[1][0], ROWS[1][1], ROWS[1][2], { noActions: true }) + '</div>' +
'<div class="a0-row"><div class="a0-check"><span></span></div>' + cardHtml(ROWS[2][0], ROWS[2][1], ROWS[2][2], { noActions: true }) + '</div>' +
'</div>'
);
return normal + manage;
}
function renderS1() {
var normal = phone("普通态",
'<div class="s1-toolbar"><div class="search">搜索流程名称</div></div>' +
'<div class="s1-head"><div class="s1-count">共 <strong>3</strong> 条草稿</div><div class="s1-link">管理</div></div>' +
'<div class="list">' +
ROWS.map(function (r) { return cardHtml(r[0], r[1], r[2], { variant: "v-soft" }); }).join("") +
'<div class="end">已经到底了</div></div>'
);
var manage = phone("管理态 · 勾选居中",
'<div class="s1-toolbar"><div class="search">搜索流程名称</div></div>' +
'<div class="s1-head"><div class="s1-count">已选 <strong>1</strong> / 3</div><div class="s1-link is-done">完成</div></div>' +
'<div class="batch"><span>已选 1 项</span><button>批量删除</button></div>' +
'<div class="list">' +
'<div class="row is-on"><div class="check"><span class="on">✓</span></div>' + cardHtml(ROWS[0][0], ROWS[0][1], ROWS[0][2], { noActions: true, variant: "v-soft" }) + '</div>' +
'<div class="row"><div class="check"><span></span></div>' + cardHtml(ROWS[1][0], ROWS[1][1], ROWS[1][2], { noActions: true, variant: "v-soft" }) + '</div>' +
'<div class="row"><div class="check"><span></span></div>' + cardHtml(ROWS[2][0], ROWS[2][1], ROWS[2][2], { noActions: true, variant: "v-soft" }) + '</div>' +
'</div>'
);
return normal + manage;
}
function renderS2() {
var normal = phone("普通态 · 胶囊",
'<div class="s2-toolbar"><div class="search" style="flex:1">搜索流程名称</div>' +
'<button class="s2-chip">' + ICON_GEAR + '管理</button></div>' +
'<div class="list">' +
ROWS.map(function (r) { return cardHtml(r[0], r[1], r[2], { variant: "v-accent" }); }).join("") +
'<div class="end">已经到底了</div></div>'
);
var manage = phone("管理态 · 勾选居中",
'<div class="s2-toolbar"><div class="search" style="flex:1">搜索流程名称</div>' +
'<button class="s2-chip is-on">完成</button></div>' +
'<div class="batch"><span>已选 1 项</span><button>批量删除</button></div>' +
'<div class="list">' +
'<div class="row is-on"><div class="check"><span class="on">✓</span></div>' + cardHtml(ROWS[0][0], ROWS[0][1], ROWS[0][2], { noActions: true, variant: "v-accent" }) + '</div>' +
'<div class="row"><div class="check"><span></span></div>' + cardHtml(ROWS[1][0], ROWS[1][1], ROWS[1][2], { noActions: true, variant: "v-accent" }) + '</div>' +
'<div class="row"><div class="check"><span></span></div>' + cardHtml(ROWS[2][0], ROWS[2][1], ROWS[2][2], { noActions: true, variant: "v-accent" }) + '</div>' +
'</div>'
);
return normal + manage;
}
function renderS3() {
var normal = phone("普通态 · 次级条",
'<div class="s3-toolbar"><div class="search">搜索流程名称</div>' +
'<div class="s3-sub"><span class="s3-hint">可批量删除不用的草稿</span><button class="s3-btn">管理</button></div></div>' +
'<div class="list">' +
ROWS.map(function (r) { return cardHtml(r[0], r[1], r[2]); }).join("") +
'<div class="end">已经到底了</div></div>'
);
var manage = phone("管理态 · 勾选居中",
'<div class="s3-toolbar"><div class="search">搜索流程名称</div>' +
'<div class="s3-sub"><span class="s3-hint">点选草稿后可批量删除</span><button class="s3-btn is-on">完成</button></div></div>' +
'<div class="batch"><span>已选 1 项</span><button>批量删除</button></div>' +
'<div class="list">' +
'<div class="row is-on"><div class="check"><span class="on">✓</span></div>' + cardHtml(ROWS[0][0], ROWS[0][1], ROWS[0][2], { noActions: true }) + '</div>' +
'<div class="row"><div class="check"><span></span></div>' + cardHtml(ROWS[1][0], ROWS[1][1], ROWS[1][2], { noActions: true }) + '</div>' +
'<div class="row"><div class="check"><span></span></div>' + cardHtml(ROWS[2][0], ROWS[2][1], ROWS[2][2], { noActions: true }) + '</div>' +
'</div>'
);
return normal + manage;
}
function renderS4() {
var normal = phone("普通态 · 底栏",
'<div class="s4-page">' +
'<div class="s4-toolbar"><div class="search">搜索流程名称</div></div>' +
'<div class="list">' +
ROWS.map(function (r) { return cardHtml(r[0], r[1], r[2], { variant: "v-soft", inlineMeta: true }); }).join("") +
'<div class="end">已经到底了</div></div>' +
'<div class="s4-dock"><span class="hint">共 3 条草稿</span><button class="btn">管理</button></div>' +
'</div>'
);
var manage = phone("管理态 · 底栏批量",
'<div class="s4-page">' +
'<div class="s4-toolbar"><div class="search">搜索流程名称</div></div>' +
'<div class="list">' +
'<div class="row is-on"><div class="check"><span class="on">✓</span></div>' + cardHtml(ROWS[0][0], ROWS[0][1], ROWS[0][2], { noActions: true, variant: "v-soft", inlineMeta: true }) + '</div>' +
'<div class="row"><div class="check"><span></span></div>' + cardHtml(ROWS[1][0], ROWS[1][1], ROWS[1][2], { noActions: true, variant: "v-soft", inlineMeta: true }) + '</div>' +
'<div class="row"><div class="check"><span></span></div>' + cardHtml(ROWS[2][0], ROWS[2][1], ROWS[2][2], { noActions: true, variant: "v-soft", inlineMeta: true }) + '</div>' +
'</div>' +
'<div class="s4-dock is-manage">' +
'<span class="hint">已选 1 项</span>' +
'<div style="display:flex;gap:8px">' +
'<button class="danger">批量删除</button>' +
'<button class="btn">完成</button>' +
'</div>' +
'</div>' +
'</div>'
);
return normal + manage;
}
function renderCards() {
return (
phone("Soft · 柔阴影",
'<div class="s1-toolbar"><div class="search">搜索流程名称</div></div>' +
'<div class="s1-head"><div class="s1-count">共 <strong>3</strong> 条</div><div class="s1-link">管理</div></div>' +
'<div class="list">' + ROWS.map(function (r) { return cardHtml(r[0], r[1], r[2], { variant: "v-soft" }); }).join("") + '</div>'
) +
phone("Accent · 左色条",
'<div class="s1-toolbar"><div class="search">搜索流程名称</div></div>' +
'<div class="s1-head"><div class="s1-count">共 <strong>3</strong> 条</div><div class="s1-link">管理</div></div>' +
'<div class="list">' + ROWS.map(function (r) { return cardHtml(r[0], r[1], r[2], { variant: "v-accent" }); }).join("") + '</div>'
) +
phone("Flat · 扁平一行元信息",
'<div class="s1-toolbar"><div class="search">搜索流程名称</div></div>' +
'<div class="s1-head"><div class="s1-count">共 <strong>3</strong> 条</div><div class="s1-link">管理</div></div>' +
'<div class="list">' + ROWS.map(function (r) { return cardHtml(r[0], r[1], r[2], { variant: "v-flat", inlineMeta: true }); }).join("") + '</div>'
)
);
}
const RENDER = {
a0: renderA0,
s1: renderS1,
s2: renderS2,
s3: renderS3,
s4: renderS4,
cards: renderCards
};
const descEl = document.getElementById("desc");
const phonesEl = document.getElementById("phones");
const tabs = document.getElementById("tabs");
function show(key) {
var d = DESCS[key];
descEl.innerHTML =
"<h2>" + d.title + "</h2>" +
'<p class="sub">' + d.sub + "</p>" +
"<ul>" + d.items.map(function (i) { return "<li>" + i + "</li>"; }).join("") + "</ul>" +
'<div class="pick">' + d.pick + "</div>";
phonesEl.innerHTML = RENDER[key]();
}
tabs.addEventListener("click", function (e) {
var btn = e.target.closest(".tab");
if (!btn) return;
tabs.querySelectorAll(".tab").forEach(function (t) {
t.classList.toggle("is-active", t === btn);
});
show(btn.getAttribute("data-k"));
});
show("a0");
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,426 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>草稿箱 · 标题栏管理 + 列表是否显示按钮</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--warn: #d97706;
--warn-soft: #fff7e8;
--danger: #ef4444;
--nav-bg: #ecfdf5;
--page-bg: #f5f8f7;
--text: #1f2a26;
--text-muted: #6b7b74;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1200px; margin: 0 auto; padding: 28px 16px 56px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 780px; }
.hero .note {
margin-top: 12px; display: inline-block; padding: 8px 12px;
border-radius: 10px; background: #fff; border: 1px solid #e2e8f0;
font-size: 12px; color: #64748b;
}
.hero .note strong { color: #0f766e; }
.rec {
margin: 16px 0 0;
padding: 14px 16px;
border-radius: 14px;
background: var(--brand-soft);
border: 1px solid #b7ebd4;
font-size: 13px;
color: #166534;
line-height: 1.55;
}
.rec b { color: var(--brand-dark); }
.tabs { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0 16px; }
.tab {
appearance: none; border: 1px solid #e2e8f0; background: #fff;
border-radius: 999px; padding: 10px 16px; font-size: 13px;
font-weight: 600; color: #475569; cursor: pointer;
}
.tab.is-active {
color: var(--brand-dark); border-color: var(--brand);
background: var(--brand-soft);
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.16);
}
.tab .badge {
margin-left: 6px; font-size: 10px; padding: 2px 6px;
border-radius: 999px; background: #ecfdf5; color: #047857; font-weight: 700;
}
.layout {
display: grid;
grid-template-columns: minmax(240px, 300px) 1fr;
gap: 20px; align-items: start;
}
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0; border-radius: 16px; padding: 16px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.panel h2 { margin: 0 0 4px; font-size: 15px; }
.panel .sub { margin: 0 0 10px; font-size: 12px; color: #64748b; }
.panel ul { margin: 0; padding-left: 18px; font-size: 13px; color: #475569; }
.panel li { margin: 5px 0; }
.panel .pick {
margin-top: 12px; padding: 10px 12px; border-radius: 10px;
background: #fff7e8; border: 1px solid #fde68a;
font-size: 12px; color: #92400e;
}
.phones { display: flex; flex-wrap: wrap; gap: 18px; justify-content: center; }
.phone {
width: 375px; background: #fff; border-radius: 28px; overflow: hidden;
box-shadow: 0 0 0 1px rgb(15 23 42 / 6%), 0 18px 40px rgb(15 23 42 / 12%);
}
.cap {
text-align: center; font-size: 11px; font-weight: 650; color: #64748b;
padding: 8px; background: #f8fafc; border-bottom: 1px solid #eef2f0;
}
/* 模拟系统标题栏(对齐 pages.json #ecfdf5) */
.nav {
height: 44px; display: flex; align-items: center; padding: 0 8px 0 4px;
background: var(--nav-bg); border-bottom: 1px solid #d8f3e5;
position: relative;
}
.nav-back {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
color: #0f291e; flex-shrink: 0;
}
.nav-back::before {
content: ""; width: 10px; height: 10px;
border-left: 2px solid #0f291e; border-bottom: 2px solid #0f291e;
transform: rotate(45deg); margin-left: 4px;
}
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 16px; font-weight: 650; color: #0f291e;
}
.nav-right {
margin-left: auto; padding: 0 10px; height: 36px;
display: inline-flex; align-items: center;
font-size: 14px; font-weight: 650; color: var(--brand-dark);
cursor: pointer; user-select: none;
}
.nav-right.is-done { color: #64748b; }
.page { background: var(--page-bg); min-height: 560px; padding-bottom: 16px; }
.search-wrap { padding: 10px 12px; background: #fff; border-bottom: 1px solid #f1f5f9; }
.search {
height: 36px; display: flex; align-items: center; gap: 8px;
padding: 0 12px; border-radius: 10px; border: 1px solid #e8eaed;
background: #fafafa; font-size: 13px; color: #94a3b8;
}
.search::before {
content: ""; width: 12px; height: 12px; border: 1.5px solid #94a3b8;
border-radius: 50%; flex-shrink: 0;
}
.list { padding: 10px 12px 0; display: flex; flex-direction: column; gap: 10px; }
.end { text-align: center; padding: 12px 0; font-size: 12px; color: #94a3b8; }
.card {
width: 100%; background: #fff; border-radius: 14px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.05);
overflow: hidden; border: 1px solid transparent;
}
.card-body { padding: 14px; }
.card-top { display: flex; align-items: flex-start; gap: 10px; }
.card-title { flex: 1; min-width: 0; font-size: 15px; font-weight: 700; line-height: 1.35; }
.card-status {
flex-shrink: 0; font-size: 11px; font-weight: 700;
padding: 3px 9px; border-radius: 999px;
background: var(--warn-soft); color: var(--warn);
}
.meta {
margin-top: 8px; font-size: 12px; color: #64748b;
}
.meta .dot { margin: 0 6px; color: #cbd5e1; }
.hint-tap {
margin-top: 8px; font-size: 11px; color: #94a3b8;
}
.card-footer {
display: flex; gap: 8px; padding: 10px 12px 12px;
border-top: 1px dashed rgba(140, 170, 155, 0.28);
}
.card-footer button {
flex: 1; height: 34px; border-radius: 10px; border: 1px solid #d8e3de;
background: #fff; font-size: 12px; font-weight: 650; color: #334155;
}
.card-footer .p { background: var(--brand); border-color: var(--brand); color: #fff; }
.card-footer .d {
color: var(--danger); border-color: rgba(239,68,68,.35);
background: rgba(239,68,68,.06);
}
.row { display: flex; align-items: center; gap: 10px; }
.row .card { flex: 1; min-width: 0; }
.check {
width: 20px; height: 20px; border-radius: 50%; border: 1.5px solid #cbd5e1;
background: #fff; display: flex; align-items: center; justify-content: center;
font-size: 11px; color: #fff; font-weight: 700; flex-shrink: 0;
}
.check.on { background: #22c55e; border-color: #22c55e; }
.row.is-on .card {
border-color: rgba(34, 197, 94, 0.45);
box-shadow: 0 8px 20px rgba(22, 163, 74, 0.12);
}
.batch {
margin: 10px 12px 0; padding: 10px 12px; border-radius: 12px;
background: #fff; border: 1px solid #c6f0dc;
display: flex; align-items: center; justify-content: space-between;
font-size: 13px; color: #166534;
}
.batch button {
height: 30px; padding: 0 12px; border-radius: 8px;
border: 1px solid rgba(239,68,68,.35);
background: rgba(239,68,68,.06); color: var(--danger);
font-size: 12px; font-weight: 650;
}
/* 详情底栏示意 */
.detail {
display: flex; flex-direction: column; min-height: 560px;
background: var(--page-bg);
}
.detail-body { flex: 1; padding: 12px; }
.detail-sheet {
background: #fff; border-radius: 14px; padding: 16px;
box-shadow: 0 8px 22px rgba(15,23,42,.05);
}
.detail-sheet h3 { margin: 0 0 12px; font-size: 17px; }
.detail-sheet .line {
display: flex; gap: 10px; font-size: 13px; margin: 8px 0; color: #475569;
}
.detail-sheet .line span:first-child { color: #94a3b8; min-width: 64px; }
.detail-foot {
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(255,255,255,.96);
border-top: 1px solid #e8eeeb;
display: flex; gap: 8px;
}
.detail-foot button {
height: 44px; border-radius: 12px; border: 0;
font-size: 15px; font-weight: 700; cursor: pointer;
}
.detail-foot .ghost { flex: 0 0 72px; background: #f1f5f9; color: #64748b; }
.detail-foot .main { flex: 1; background: linear-gradient(135deg, #0a9f6d, #087f57); color: #fff; }
.detail-foot .danger { flex: 0 0 72px; background: rgba(239,68,68,.08); color: var(--danger); }
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>草稿箱 · 标题栏「管理」+ 列表是否放按钮</h1>
<p>
回应两点:①「管理」放到导航栏右侧;②列表卡片是否还要「编辑 / 提交 / 删除」。
右侧每个方案可左右滑动对比普通态 / 管理态(或详情底栏)。
</p>
<div class="note">落地时草稿页可用系统导航栏右侧文字按钮,或自定义顶栏(与工作台一致)。</div>
<div class="rec">
<b>建议:</b>标题栏右侧「管理」+ <b>列表不展示操作按钮</b>,点卡片进入编辑/详情,
「提交审批 / 删除」放详情(或编辑页)底部。列表更干净,也符合多数 OA / 邮件 App 习惯。
</div>
</div>
<div class="tabs" id="tabs">
<button class="tab is-active" data-k="nav_clean">标题栏管理 · 列表无按钮<span class="badge">推荐</span></button>
<button class="tab" data-k="nav_btns">标题栏管理 · 列表仍有按钮</button>
<button class="tab" data-k="detail">详情底栏操作示意</button>
</div>
<div class="layout">
<div class="panel" id="desc"></div>
<div class="phones" id="phones"></div>
</div>
</div>
<script>
const ROWS = [
["入帐单", "08-12 17:03", "v5"],
["出差申请", "08-11 09:18", "v3"],
["采购报销", "08-10 17:05", "v2"]
];
function nav(rightText, rightDone) {
return (
'<div class="nav">' +
'<div class="nav-back"></div>' +
'<div class="nav-title">草稿箱</div>' +
'<div class="nav-right' + (rightDone ? ' is-done' : '') + '">' + rightText + '</div>' +
'</div>'
);
}
function card(title, time, ver, withBtns) {
return (
'<div class="card">' +
'<div class="card-body">' +
'<div class="card-top">' +
'<div class="card-title">' + title + '</div>' +
'<div class="card-status">草稿</div>' +
'</div>' +
'<div class="meta">' + time + '<span class="dot">·</span>版本 ' + ver + '</div>' +
(withBtns ? '' : '<div class="hint-tap">点击继续编辑</div>') +
'</div>' +
(withBtns
? '<div class="card-footer"><button class="p">编辑</button><button>提交审批</button><button class="d">删除</button></div>'
: '') +
'</div>'
);
}
function phone(cap, html) {
return '<div class="phone"><div class="cap">' + cap + '</div>' + html + '</div>';
}
const DESCS = {
nav_clean: {
title: "推荐 · 标题栏管理 + 列表无按钮",
sub: "顶栏右侧「管理」;卡片只展示信息,整卡可点。",
items: [
"「管理」进导航栏右侧,搜索区干净",
"列表无编辑/提交/删除,一屏信息更疏朗",
"点卡片 → 进入编辑(现有逻辑已支持)",
"删除用「管理」批量;提交放详情/编辑底栏",
"管理态:勾选与卡片垂直居中"
],
pick: "与钉钉/飞书/企业微信草稿、邮件列表习惯一致。"
},
nav_btns: {
title: "标题栏管理 · 列表保留按钮",
sub: "管理上移后,卡片仍保留三按钮(当前能力不丢)。",
items: [
"管理入口问题已解决",
"列表仍可一键提交/删除,少一次跳转",
"代价:卡片更高、更挤,尤其短流程名时按钮区抢戏"
],
pick: "若强依赖「列表内直接提交」,可选此方案。"
},
detail: {
title: "详情 / 编辑页底部操作",
sub: "列表无按钮时,动作收口到详情底栏。",
items: [
"主按钮:提交审批(最高优先级)",
"次按钮:删除(或更多里放删除)",
"返回即退出编辑;草稿自动保存可沿用现逻辑"
],
pick: "与「列表无按钮」配套使用。"
}
};
function renderClean() {
var list = ROWS.map(function (r) { return card(r[0], r[1], r[2], false); }).join("");
var normal = phone("普通态",
nav("管理", false) +
'<div class="page"><div class="search-wrap"><div class="search">搜索流程名称</div></div>' +
'<div class="list">' + list + '<div class="end">已经到底了</div></div></div>'
);
var manage = phone("管理态 · 勾选居中",
nav("完成", true) +
'<div class="page"><div class="search-wrap"><div class="search">搜索流程名称</div></div>' +
'<div class="batch"><span>已选 1 项</span><button>批量删除</button></div>' +
'<div class="list">' +
'<div class="row is-on"><div class="check on">✓</div>' + card(ROWS[0][0], ROWS[0][1], ROWS[0][2], false).replace('hint-tap">点击继续编辑', 'hint-tap" style="display:none">') + '</div>' +
'<div class="row"><div class="check"></div>' + card(ROWS[1][0], ROWS[1][1], ROWS[1][2], false).replace('hint-tap">点击继续编辑', 'hint-tap" style="display:none">') + '</div>' +
'<div class="row"><div class="check"></div>' + card(ROWS[2][0], ROWS[2][1], ROWS[2][2], false).replace('hint-tap">点击继续编辑', 'hint-tap" style="display:none">') + '</div>' +
'</div></div>'
);
return normal + manage;
}
function renderBtns() {
var list = ROWS.map(function (r) { return card(r[0], r[1], r[2], true); }).join("");
var normal = phone("普通态 · 仍有按钮",
nav("管理", false) +
'<div class="page"><div class="search-wrap"><div class="search">搜索流程名称</div></div>' +
'<div class="list">' + list + '<div class="end">已经到底了</div></div></div>'
);
var manage = phone("管理态",
nav("完成", true) +
'<div class="page"><div class="search-wrap"><div class="search">搜索流程名称</div></div>' +
'<div class="batch"><span>已选 1 项</span><button>批量删除</button></div>' +
'<div class="list">' +
'<div class="row is-on"><div class="check on">✓</div>' + card(ROWS[0][0], ROWS[0][1], ROWS[0][2], false) + '</div>' +
'<div class="row"><div class="check"></div>' + card(ROWS[1][0], ROWS[1][1], ROWS[1][2], false) + '</div>' +
'</div></div>'
);
return normal + manage;
}
function renderDetail() {
return phone("编辑 / 详情 · 底栏",
nav("", false).replace('nav-right">', 'nav-right" style="display:none">') +
'<div class="detail">' +
'<div class="detail-body">' +
'<div class="detail-sheet">' +
'<h3>入帐单</h3>' +
'<div class="line"><span>状态</span><span>草稿</span></div>' +
'<div class="line"><span>最近保存</span><span>2026-08-12 17:03:42</span></div>' +
'<div class="line"><span>版本</span><span>v5</span></div>' +
'<div class="line"><span>说明</span><span>表单正文区域…</span></div>' +
'</div>' +
'</div>' +
'<div class="detail-foot">' +
'<button class="danger">删除</button>' +
'<button class="main">提交审批</button>' +
'</div>' +
'</div>'
) + phone("列表侧(无按钮)",
nav("管理", false) +
'<div class="page"><div class="search-wrap"><div class="search">搜索流程名称</div></div>' +
'<div class="list">' +
ROWS.map(function (r) { return card(r[0], r[1], r[2], false); }).join("") +
'</div></div>'
);
}
const RENDER = { nav_clean: renderClean, nav_btns: renderBtns, detail: renderDetail };
const descEl = document.getElementById("desc");
const phonesEl = document.getElementById("phones");
const tabs = document.getElementById("tabs");
function show(k) {
var d = DESCS[k];
descEl.innerHTML =
"<h2>" + d.title + "</h2>" +
'<p class="sub">' + d.sub + "</p>" +
"<ul>" + d.items.map(function (i) { return "<li>" + i + "</li>"; }).join("") + "</ul>" +
'<div class="pick">' + d.pick + "</div>";
phonesEl.innerHTML = RENDER[k]();
}
tabs.addEventListener("click", function (e) {
var btn = e.target.closest(".tab");
if (!btn) return;
tabs.querySelectorAll(".tab").forEach(function (t) {
t.classList.toggle("is-active", t === btn);
});
show(btn.getAttribute("data-k"));
});
show("nav_clean");
</script>
</body>
</html>
@@ -0,0 +1,593 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>OA 五列表 · 序号圆圈背景 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--warn: #d97706;
--danger: #ef4444;
--nav-bg: #ecfdf5;
--page-bg: #f5f6f8;
--text: #1f2329;
--text-muted: #8f959e;
--border: #e8eaed;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1180px; margin: 0 auto; padding: 28px 16px 64px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 920px; }
.rec {
margin: 16px 0 0;
padding: 14px 16px;
border-radius: 14px;
background: var(--brand-soft);
border: 1px solid #b7ebd4;
font-size: 13px;
color: #166534;
line-height: 1.55;
}
.rec b { color: var(--brand-dark); }
.tabs {
display: flex; flex-wrap: wrap; gap: 10px;
margin: 20px 0 18px;
}
.tab {
appearance: none; border: 1px solid #e2e8f0; background: #fff;
border-radius: 999px; padding: 10px 16px; font-size: 13px;
font-weight: 600; color: #475569; cursor: pointer;
}
.tab.is-active {
color: var(--brand-dark); border-color: var(--brand);
background: var(--brand-soft);
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.16);
}
.tab .badge {
display: inline-block; margin-left: 6px;
padding: 1px 7px; border-radius: 999px;
font-size: 11px; font-weight: 700;
background: #fef3c7; color: #b45309;
}
.tab.is-active .badge {
background: #fff; color: var(--brand-dark);
}
.layout {
display: grid;
grid-template-columns: minmax(240px, 300px) 1fr;
gap: 20px; align-items: start;
}
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0; border-radius: 16px; padding: 16px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.panel h2 { margin: 0 0 4px; font-size: 15px; }
.panel .sub { margin: 0 0 10px; font-size: 12px; color: #64748b; }
.panel ul { margin: 0; padding-left: 18px; font-size: 13px; color: #475569; }
.panel li { margin: 6px 0; }
.panel .ok {
margin-top: 12px; padding: 10px 12px; border-radius: 10px;
background: #ecfdf5; border: 1px solid #a7f3d0;
font-size: 12px; color: #047857;
}
.panel .pick {
margin-top: 10px; padding: 10px 12px; border-radius: 10px;
background: #fff7e8; border: 1px solid #fde68a;
font-size: 12px; color: #92400e;
}
.phones { display: flex; flex-wrap: wrap; gap: 18px; justify-content: center; }
.phone {
width: 375px; background: #fff; border-radius: 28px; overflow: hidden;
box-shadow: 0 0 0 1px rgb(15 23 42 / 6%), 0 18px 40px rgb(15 23 42 / 12%);
}
.cap {
text-align: center; font-size: 11px; font-weight: 650; color: #64748b;
padding: 8px; background: #f8fafc; border-bottom: 1px solid #eef2f0;
}
.nav {
height: 44px; display: flex; align-items: center; padding: 0 8px 0 4px;
background: var(--nav-bg); border-bottom: 1px solid #d8f3e5;
position: relative;
}
.nav-back {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
color: #0f291e; flex-shrink: 0;
}
.nav-back::before {
content: ""; width: 10px; height: 10px;
border-left: 2px solid #0f291e; border-bottom: 2px solid #0f291e;
transform: rotate(45deg); margin-left: 4px;
}
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 16px; font-weight: 650; color: #0f291e;
}
.page {
min-height: 640px;
background: var(--page-bg);
padding-bottom: 24px;
}
.toolbar {
padding: 8px 12px;
background: #fff;
border-bottom: 1px solid #f1f5f9;
}
.search-box {
height: 36px;
display: flex; align-items: center; gap: 8px;
padding: 0 12px; border-radius: 10px;
background: #fafafa; border: 1px solid #e8eaed;
}
.search-ico {
width: 13px; height: 13px; border: 1.5px solid #94a3b8; border-radius: 50%;
position: relative; flex-shrink: 0;
}
.search-ico::after {
content: ""; position: absolute; width: 6px; height: 1.5px;
right: -4px; bottom: -2px; background: #94a3b8; border-radius: 2px;
transform: rotate(45deg);
}
.search-ph { font-size: 13px; color: #b0b7c3; }
.list {
padding: 10px 12px 0;
display: flex; flex-direction: column; gap: 10px;
}
.dd-card {
position: relative;
width: 100%;
background: #fff;
border-radius: 14px;
padding: 14px 14px 12px;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
overflow: hidden;
}
.dd-card__head {
display: flex; align-items: flex-start; gap: 8px;
}
.dd-card__title {
flex: 1; min-width: 0;
font-size: 15px; font-weight: 700; line-height: 1.4; color: #1f2329;
}
.dd-card__time {
flex-shrink: 0; font-size: 12px; line-height: 1.4; color: #8f959e; padding-top: 2px;
}
.dd-card__meta { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.dd-card__meta-line {
font-size: 13px; line-height: 1.45; color: #8f959e;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dd-card__foot {
margin-top: 12px; display: flex; align-items: center;
justify-content: space-between; gap: 8px;
}
.dd-card__hint-tag {
max-width: 70%;
font-size: 11px; font-weight: 650; line-height: 1.2;
padding: 4px 9px; border-radius: 999px;
background: rgba(217, 119, 6, 0.12); color: #d97706;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.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__status {
flex-shrink: 0; font-size: 12px; font-weight: 600; color: #d97706;
}
.dd-card__status.is-success { color: #0a9f6d; }
.dd-card__status.is-danger { color: #ef4444; }
/* ---------- 0:现状折角 ---------- */
.style-0 .idx {
position: absolute; top: 0; left: 0;
width: 0; height: 0;
border-style: solid;
border-width: 34px 34px 0 0;
border-color: var(--brand) transparent transparent transparent;
pointer-events: none;
}
.style-0 .idx span {
position: absolute; top: -30px; left: 4px;
font-size: 10px; font-weight: 800; color: #fff;
line-height: 1; letter-spacing: -0.02em;
}
/* 圆圈共用:左上绝对定位 */
.circle-idx {
position: absolute;
top: 10px;
left: 10px;
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
pointer-events: none;
z-index: 1;
}
.style-a .dd-card,
.style-b .dd-card,
.style-c .dd-card,
.style-d .dd-card {
padding-left: 42px;
}
/* A:实心品牌绿圆(推荐) */
.style-a .circle-idx {
color: #fff;
background: var(--brand);
box-shadow: 0 2px 6px rgba(10, 159, 109, 0.28);
}
/* B:浅绿底 + 深绿字 */
.style-b .circle-idx {
color: var(--brand-dark);
background: var(--brand-soft);
border: 1px solid #b7ebd4;
}
/* C:描边空心圆 */
.style-c .circle-idx {
color: var(--brand-dark);
background: #fff;
border: 1.5px solid var(--brand);
}
/* D:贴角半出入圆(更像「角标」) */
.style-d .dd-card { overflow: visible; }
.style-d .circle-idx {
top: -6px;
left: -6px;
width: 28px;
height: 28px;
font-size: 11px;
color: #fff;
background: linear-gradient(145deg, #12b981 0%, #0a9f6d 55%, #087f57 100%);
box-shadow: 0 4px 10px rgba(10, 159, 109, 0.28);
border: 2px solid #fff;
}
.style-d .dd-card { padding-left: 14px; }
.swatches {
margin-top: 18px;
display: flex; flex-wrap: wrap; gap: 10px;
}
.swatch {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 12px; border-radius: 999px;
background: #fff; border: 1px solid #e2e8f0;
font-size: 12px; color: #475569;
}
.swatch .dot {
width: 22px; height: 22px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 800;
}
.swatch .dot.a { color: #fff; background: var(--brand); }
.swatch .dot.b { color: var(--brand-dark); background: var(--brand-soft); border: 1px solid #b7ebd4; }
.swatch .dot.c { color: var(--brand-dark); background: #fff; border: 1.5px solid var(--brand); }
.swatch .dot.d {
color: #fff;
background: linear-gradient(145deg, #12b981, #087f57);
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(10, 159, 109, 0.25);
}
.legend {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.legend .box {
background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
padding: 12px 14px; font-size: 12px; color: #475569;
}
.legend h3 { margin: 0 0 6px; font-size: 13px; color: #0f291e; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; background: #f1f5f9; padding: 1px 5px; border-radius: 4px;
}
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>OA 五列表 · 序号改为圆圈背景</h1>
<p>
覆盖草稿箱 / 待办 / 已办 / 我发起的 / 抄送我的。现状为左上三角折角;
本 Demo 对比「圆圈背景」几种落法。序号规则不变:<code>01</code>、<code>02</code>…跨页连续。
</p>
<div class="rec">
<b>推荐:A 实心绿圆</b> —— 仍在卡片左上,品牌绿实心圆 + 白字两位序号;
只改 <code>.dd-card__corner</code> 样式,五页 DOM 可基本不动。
</div>
</div>
<div class="tabs" id="tabs">
<button type="button" class="tab" data-k="0">0 现状折角</button>
<button type="button" class="tab is-active" data-k="a">A 实心绿圆<span class="badge">推荐</span></button>
<button type="button" class="tab" data-k="b">B 浅绿圆</button>
<button type="button" class="tab" data-k="c">C 描边圆</button>
<button type="button" class="tab" data-k="d">D 贴角出入圆</button>
</div>
<div class="swatches" aria-hidden="true">
<span class="swatch"><span class="dot a">01</span>A 实心</span>
<span class="swatch"><span class="dot b">02</span>B 浅绿</span>
<span class="swatch"><span class="dot c">03</span>C 描边</span>
<span class="swatch"><span class="dot d">04</span>D 贴角</span>
</div>
<div class="layout" style="margin-top: 18px;">
<aside class="panel" id="side"></aside>
<div class="phones">
<div class="phone">
<div class="cap" id="cap">A 实心绿圆 · 推荐 · 375px</div>
<div class="nav">
<div class="nav-back" aria-hidden="true"></div>
<div class="nav-title">待办中心</div>
</div>
<div class="page">
<div class="toolbar">
<div class="search-box">
<span class="search-ico" aria-hidden="true"></span>
<span class="search-ph">搜索流程名称</span>
</div>
</div>
<div class="list style-a" id="list"></div>
</div>
</div>
</div>
</div>
<div class="legend">
<div class="box">
<h3>落地建议</h3>
仅改 <code>oa-task-list.scss</code> 中 <code>.dd-card__corner</code> /
<code>.dd-card__corner-num</code>;五页模板结构可保留,避免重复改 Vue。
</div>
<div class="box">
<h3>序号规则</h3>
仍用 <code>formatCornerIndex</code>:&lt;10 补零;
跨页 <code>(page-1)*pageSize + index + 1</code>。
</div>
<div class="box">
<h3>注意</h3>
A/B/C 需给标题区留左内边距(约 42px),避免圆圈压住标题;
D 贴角方案可不加左 padding,但卡片需 <code>overflow: visible</code>。
</div>
</div>
</div>
<script>
const ROWS = [
{
title: '费用报销',
time: '09:12',
meta: ['报销金额:1280.00', '事由:差旅交通'],
hint: '待张敏处理',
status: '进行中',
tone: 'warn'
},
{
title: '请假申请',
time: '昨天',
meta: ['请假类型:事假', '请假天数:1'],
hint: '待李华处理',
status: '进行中',
tone: 'warn'
},
{
title: '采购申请',
time: '前天',
meta: ['采购金额:8600.00', '供应商:华东物资'],
hint: '审批完成',
status: '已完成',
tone: 'success'
},
{
title: '出差申请',
time: '3天前',
meta: ['目的地:上海', '天数:2'],
hint: '已打回',
status: '已打回',
tone: 'danger'
},
{
title: '用章申请 · 两位数序号样例',
time: '2026.08.01',
meta: ['用章类型:公章', '份数:2'],
hint: '抄送我的',
status: '已撤销',
tone: 'mute',
forceN: 12
}
]
const SIDE = {
'0': {
title: '0 现状折角',
sub: '三角折角 + 白字',
items: [
'当前五列表已上线样式',
'视觉强,但标题区被三角切角',
'两位数在三角里偏挤'
],
note: 'pick',
noteText: '将被圆圈方案替换。'
},
a: {
title: 'A 实心绿圆',
sub: '左上 24px 圆 · 白字',
items: [
'品牌绿实心,辨识度接近折角',
'圆内两位序号更规整',
'卡片加左 padding,标题不被遮'
],
note: 'ok',
noteText: '推荐默认方案;改动面最小。'
},
b: {
title: 'B 浅绿圆',
sub: '浅绿底 + 深绿字',
items: [
'更轻、更「信息角标」',
'与三墙 soft 色一致',
'在白卡片上对比略弱于 A'
],
note: 'ok',
noteText: '若希望序号不抢眼,选 B。'
},
c: {
title: 'C 描边圆',
sub: '白底 + 绿描边',
items: [
'最轻量',
'适合信息密度高的列表',
'远看不如实心醒目'
],
note: 'pick',
noteText: '备选;偏「弱序号」。'
},
d: {
title: 'D 贴角出入圆',
sub: '压住左上圆角 · 半出入',
items: [
'更像「角标徽章」',
'不挤标题左 padding',
'需 overflow:visible,注意阴影裁切'
],
note: 'ok',
noteText: '想保留「角标感」时优先于 A。'
}
}
const CAP = {
'0': '0 现状折角 · 375px',
a: 'A 实心绿圆 · 推荐 · 375px',
b: 'B 浅绿圆 · 375px',
c: 'C 描边圆 · 375px',
d: 'D 贴角出入圆 · 375px'
}
function pad(n) {
return n < 10 ? '0' + n : String(n)
}
function render(style) {
const list = document.getElementById('list')
list.className = 'list style-' + style
list.innerHTML = ROWS.map((row, i) => {
const n = row.forceN != null ? row.forceN : i + 1
const hintClass =
'dd-card__hint-tag' +
(row.tone === 'success' ? ' is-success' : '') +
(row.tone === 'danger' ? ' is-danger' : '')
const statusClass =
'dd-card__status' +
(row.tone === 'success' ? ' is-success' : '') +
(row.tone === 'danger' ? ' is-danger' : '')
let badge = ''
if (style === '0') {
badge = '<div class="idx" aria-hidden="true"><span>' + pad(n) + '</span></div>'
} else {
badge = '<div class="circle-idx" aria-hidden="true">' + pad(n) + '</div>'
}
const meta = row.meta
.map((m) => '<span class="dd-card__meta-line">' + m + '</span>')
.join('')
return (
'<div class="dd-card">' +
badge +
'<div class="dd-card__head">' +
'<div class="dd-card__title">' +
row.title +
'</div>' +
'<div class="dd-card__time">' +
row.time +
'</div>' +
'</div>' +
'<div class="dd-card__meta">' +
meta +
'</div>' +
'<div class="dd-card__foot">' +
'<span class="' +
hintClass +
'">' +
row.hint +
'</span>' +
'<span class="' +
statusClass +
'">' +
row.status +
'</span>' +
'</div>' +
'</div>'
)
}).join('')
const s = SIDE[style]
document.getElementById('side').innerHTML =
'<h2>' +
s.title +
'</h2>' +
'<p class="sub">' +
s.sub +
'</p>' +
'<ul>' +
s.items.map((t) => '<li>' + t + '</li>').join('') +
'</ul>' +
'<div class="' +
s.note +
'">' +
s.noteText +
'</div>'
document.getElementById('cap').textContent = CAP[style]
}
document.getElementById('tabs').addEventListener('click', (e) => {
const btn = e.target.closest('.tab')
if (!btn) return
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('is-active'))
btn.classList.add('is-active')
render(btn.dataset.k)
})
render('a')
</script>
</body>
</html>
@@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>mobile-h5 · OA 列表空态 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--warn: #d97706;
--warn-soft: #fff7e8;
--nav-bg: #ecfdf5;
--page-bg: #f5f6f8;
--text: #1f2a26;
--text-muted: #6b7b74;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
radial-gradient(700px 380px at 92% 0%, rgba(214, 163, 67, 0.1), transparent 50%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1280px; margin: 0 auto; padding: 28px 16px 56px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 800px; }
.hero .note {
margin-top: 12px; display: inline-block; padding: 8px 12px;
border-radius: 10px; background: #fff; border: 1px solid #e2e8f0;
font-size: 12px; color: #64748b;
}
.hero .note strong { color: #0f766e; }
.pain {
margin-top: 14px; padding: 12px 14px; border-radius: 12px;
background: #fff1f2; border: 1px solid #fecdd3;
font-size: 13px; color: #9f1239;
}
.tabs { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0 16px; }
.tab {
appearance: none; border: 1px solid #e2e8f0; background: #fff;
border-radius: 999px; padding: 10px 16px; font-size: 13px;
font-weight: 600; color: #475569; cursor: pointer;
}
.tab.is-active {
color: var(--brand-dark); border-color: var(--brand);
background: var(--brand-soft);
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.16);
}
.tab .badge {
margin-left: 6px; font-size: 10px; padding: 2px 6px;
border-radius: 999px; background: #ecfdf5; color: #047857; font-weight: 700;
}
.layout {
display: grid;
grid-template-columns: minmax(240px, 300px) 1fr;
gap: 20px; align-items: start;
}
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0; border-radius: 16px; padding: 16px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.panel h2 { margin: 0 0 4px; font-size: 15px; }
.panel .sub { margin: 0 0 10px; font-size: 12px; color: #64748b; }
.panel ul { margin: 0; padding-left: 18px; font-size: 13px; color: #475569; }
.panel li { margin: 5px 0; }
.panel .pick {
margin-top: 12px; padding: 10px 12px; border-radius: 10px;
background: var(--brand-soft); border: 1px solid #b7ebd4;
font-size: 12px; color: #166534;
}
.phones { display: flex; flex-wrap: wrap; gap: 18px; justify-content: center; }
.phone {
width: 375px; background: #fff; border-radius: 28px; overflow: hidden;
box-shadow: 0 0 0 1px rgb(15 23 42 / 6%), 0 18px 40px rgb(15 23 42 / 12%);
}
.cap {
text-align: center; font-size: 11px; font-weight: 650; color: #64748b;
padding: 8px; background: #f8fafc; border-bottom: 1px solid #eef2f0;
}
.nav {
height: 44px; display: flex; align-items: center; padding: 0 8px 0 4px;
background: var(--nav-bg); border-bottom: 1px solid #d8f3e5; position: relative;
}
.nav-back {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
}
.nav-back::before {
content: ""; width: 10px; height: 10px;
border-left: 2px solid #0f291e; border-bottom: 2px solid #0f291e;
transform: rotate(45deg); margin-left: 4px;
}
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 16px; font-weight: 650; color: #0f291e;
}
.nav-right {
margin-left: auto; padding: 0 10px; font-size: 14px;
font-weight: 650; color: var(--brand-dark);
}
.page { background: var(--page-bg); min-height: 520px; }
.search-wrap { padding: 10px 12px; background: #fff; border-bottom: 1px solid #f1f5f9; }
.search {
height: 36px; display: flex; align-items: center; gap: 8px;
padding: 0 12px; border-radius: 10px; border: 1px solid #e8eaed;
background: #fafafa; font-size: 13px; color: #94a3b8;
}
.search::before {
content: ""; width: 12px; height: 12px; border: 1.5px solid #94a3b8;
border-radius: 50%; flex-shrink: 0;
}
/* —— A0 现状 —— */
.e0 {
padding: 72px 16px; text-align: center;
font-size: 14px; color: #5f7a6d; letter-spacing: 0.04em;
}
/* —— E1 插画 + 主副文案 —— */
.e1 {
display: flex; flex-direction: column; align-items: center;
padding: 56px 28px 40px; text-align: center;
}
.e1-art {
width: 120px; height: 120px; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #d8f3e7 72%);
display: flex; align-items: center; justify-content: center;
margin-bottom: 18px;
box-shadow: 0 10px 28px rgba(10, 159, 109, 0.12);
}
.e1-art svg { width: 56px; height: 56px; }
.e1-title { font-size: 16px; font-weight: 700; color: #1f2a26; margin-bottom: 8px; }
.e1-desc { font-size: 13px; color: #6b7b74; line-height: 1.55; max-width: 240px; }
/* —— E2 虚线软卡(对齐首页 VanEmpty) —— */
.e2-wrap { padding: 28px 16px; }
.e2 {
display: flex; flex-direction: column; align-items: center;
padding: 36px 20px 28px; text-align: center;
border-radius: 16px;
background: linear-gradient(165deg, #eef9f3 0%, #ffffff 55%);
border: 1.5px dashed #b7e5d0;
}
.e2-blob {
width: 72px; height: 72px; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #d8f3e7 70%);
display: flex; align-items: center; justify-content: center;
margin-bottom: 12px;
}
.e2-blob svg { width: 32px; height: 32px; }
.e2-title { font-size: 14px; font-weight: 650; color: #5d746a; margin-bottom: 6px; }
.e2-desc { font-size: 12px; color: #8aa198; line-height: 1.5; }
/* —— E3 带主按钮 CTA —— */
.e3 {
display: flex; flex-direction: column; align-items: center;
padding: 48px 28px 36px; text-align: center;
}
.e3-ico {
width: 88px; height: 88px; border-radius: 28px;
background: linear-gradient(145deg, #e9f9f2, #d1fae5);
border: 1px solid #b7ebd4;
display: flex; align-items: center; justify-content: center;
margin-bottom: 16px;
box-shadow: 0 8px 22px rgba(10, 159, 109, 0.12);
}
.e3-ico svg { width: 40px; height: 40px; }
.e3-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
.e3-desc { font-size: 13px; color: #6b7b74; margin-bottom: 20px; max-width: 250px; line-height: 1.55; }
.e3-btn {
height: 44px; padding: 0 28px; border: 0; border-radius: 12px;
background: linear-gradient(135deg, #0a9f6d, #087f57); color: #fff;
font-size: 15px; font-weight: 700; cursor: pointer;
box-shadow: 0 8px 18px rgba(10, 159, 109, 0.28);
}
.e3-link {
margin-top: 14px; font-size: 13px; color: var(--brand-dark);
font-weight: 650; cursor: pointer;
}
/* —— E4 场景插画卡片 —— */
.e4-wrap { padding: 24px 16px; }
.e4 {
background: #fff; border-radius: 18px; padding: 28px 20px 24px;
text-align: center;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
}
.e4-scene {
height: 110px; margin-bottom: 16px; position: relative;
display: flex; align-items: flex-end; justify-content: center;
}
.e4-desk {
width: 160px; height: 14px; border-radius: 8px;
background: #e2e8e6; position: absolute; bottom: 8px;
}
.e4-paper {
width: 72px; height: 88px; border-radius: 10px;
background: #fff; border: 2px solid #c6f0dc;
box-shadow: 0 8px 16px rgba(15,23,42,.06);
position: relative; z-index: 1;
display: flex; flex-direction: column; gap: 8px; padding: 14px 12px;
}
.e4-paper i {
display: block; height: 6px; border-radius: 3px; background: #d8f3e5;
}
.e4-paper i:nth-child(2) { width: 70%; }
.e4-paper i:nth-child(3) { width: 45%; }
.e4-badge {
position: absolute; right: 88px; top: 10px; z-index: 2;
width: 36px; height: 36px; border-radius: 50%;
background: var(--warn-soft); border: 2px solid #f0d9a8;
color: var(--warn); font-size: 18px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.e4-title { font-size: 15px; font-weight: 700; margin-bottom: 6px; }
.e4-desc { font-size: 12px; color: #6b7b74; line-height: 1.5; margin-bottom: 16px; }
.e4-btn {
height: 40px; padding: 0 22px; border-radius: 10px; border: 0;
background: var(--brand); color: #fff; font-size: 13px; font-weight: 700;
cursor: pointer;
}
/* —— E5 搜索无结果变体 —— */
.e5 {
display: flex; flex-direction: column; align-items: center;
padding: 48px 28px; text-align: center;
}
.e5-ico {
width: 80px; height: 80px; border-radius: 50%;
background: #f1f5f9;
display: flex; align-items: center; justify-content: center;
margin-bottom: 14px;
}
.e5-ico svg { width: 36px; height: 36px; }
.e5-title { font-size: 15px; font-weight: 700; margin-bottom: 6px; }
.e5-desc { font-size: 13px; color: #6b7b74; margin-bottom: 16px; }
.e5-chip {
height: 34px; padding: 0 16px; border-radius: 999px;
border: 1px solid #d8e3de; background: #fff;
font-size: 13px; font-weight: 650; color: var(--brand-dark);
cursor: pointer;
}
/* —— E6 极简大字 + 微插画 —— */
.e6 {
padding: 64px 24px; text-align: center;
}
.e6-emoji {
width: 64px; height: 64px; margin: 0 auto 14px;
border-radius: 20px; background: var(--brand-soft);
display: flex; align-items: center; justify-content: center;
}
.e6-emoji svg { width: 30px; height: 30px; }
.e6-title { font-size: 15px; font-weight: 650; color: #334155; margin-bottom: 6px; }
.e6-desc { font-size: 12px; color: #94a3b8; }
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>OA 列表空态 · 风格选型</h1>
<p>
针对草稿箱 / 待办 / 已办 / 抄送等列表页。现状仅为灰色「暂无草稿」一行字。
Token 对齐三墙绿;可与首页已有虚线软卡空态统一或做列表专用加强版。
</p>
<div class="note">请选 <strong>E1–E6</strong>(或组合:如 E3 布局 + E4 插画)。选定后落到 <code>mobile-h5</code>。</div>
<div class="pain">现状痛点:信息弱、无引导、与钉钉风列表不协调。</div>
</div>
<div class="tabs" id="tabs">
<button class="tab is-active" data-k="e0">E0 现状</button>
<button class="tab" data-k="e1">E1 圆形插画</button>
<button class="tab" data-k="e2">E2 虚线软卡</button>
<button class="tab" data-k="e3">E3 带 CTA<span class="badge">推荐</span></button>
<button class="tab" data-k="e4">E4 场景卡片</button>
<button class="tab" data-k="e5">E5 搜索无结果</button>
<button class="tab" data-k="e6">E6 极简加强</button>
</div>
<div class="layout">
<div class="panel" id="desc"></div>
<div class="phones" id="phones"></div>
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="#0a9f6d" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
</symbol>
<symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="#0a9f6d" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="13" y2="17"/>
</symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="#0a9f6d" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</symbol>
</svg>
<script>
const DESCS = {
e0: {
title: "E0 · 现状对照",
sub: "OA 列表页当前实现:一行灰字。",
items: ["无插画、无说明、无下一步引导", "与钉钉风卡片列表反差大", "仅作对照"],
pick: "不建议保留。"
},
e1: {
title: "E1 · 圆形插画",
sub: "大圆形浅绿底 + 图标 + 主副文案,居中留白。",
items: ["视觉重心明确", "无按钮,适合纯浏览空列表", "实现成本低"],
pick: "适合已办/抄送等「没有也正常」的列表。"
},
e2: {
title: "E2 · 虚线软卡",
sub: "与首页 VanEmpty(方案 B)一致。",
items: ["全站空态统一", "虚线卡略像「占位块」", "可直接复用组件"],
pick: "想和首页空态完全一致时选 E2。"
},
e3: {
title: "E3 · 插画 + 主按钮(推荐)",
sub: "说明原因,并给出「去发起审批」主行动。",
items: ["解决「空了之后做什么」", "主按钮视觉符合三墙绿", "可加次级文字链(如下拉刷新说明)"],
pick: "草稿箱 / 我发起的 最推荐。"
},
e4: {
title: "E4 · 场景卡片",
sub: "白卡片内嵌简易场景插画(纸张 + 草稿标记)。",
items: ["更有「产品感」", "CSS 绘制、无额外图片依赖", "带一个主按钮"],
pick: "想要比 E3 更精致一点时选 E4。"
},
e5: {
title: "E5 · 搜索无结果",
sub: "有关键词但搜不到时的专用空态。",
items: ["文案强调「未找到」", "提供「清空搜索」胶囊按钮", "与「真的没有数据」区分"],
pick: "建议与 E3/E4 搭配:无数据用 E3,搜索空用 E5。"
},
e6: {
title: "E6 · 极简加强",
sub: "比现状多一个图标底板,仍保持克制。",
items: ["改动最小", "仍偏素", "适合不想加按钮的页面"],
pick: "过渡方案;长期更建议 E3/E4。"
}
};
function shell(cap, body, opts) {
opts = opts || {};
var kw = opts.keyword || "";
return (
'<div class="phone"><div class="cap">' + cap + '</div>' +
'<div class="nav"><div class="nav-back"></div><div class="nav-title">草稿箱</div><div class="nav-right">管理</div></div>' +
'<div class="page"><div class="search-wrap"><div class="search">' +
(kw || "搜索流程名称") +
'</div></div>' + body + '</div></div>'
);
}
const RENDER = {
e0: function () {
return shell("现状 · 纯文字", '<div class="e0">暂无草稿</div>');
},
e1: function () {
return shell("圆形插画",
'<div class="e1">' +
'<div class="e1-art"><svg><use href="#i-inbox"/></svg></div>' +
'<div class="e1-title">暂无草稿</div>' +
'<div class="e1-desc">发起审批时保存的草稿会显示在这里</div>' +
'</div>'
) + shell("待办变体",
'<div class="e1">' +
'<div class="e1-art"><svg><use href="#i-check"/></svg></div>' +
'<div class="e1-title">暂无待办</div>' +
'<div class="e1-desc">当前没有需要你处理的审批</div>' +
'</div>'
);
},
e2: function () {
return shell("虚线软卡 · 对齐首页",
'<div class="e2-wrap"><div class="e2">' +
'<div class="e2-blob"><svg><use href="#i-inbox"/></svg></div>' +
'<div class="e2-title">暂无草稿</div>' +
'<div class="e2-desc">保存过的审批草稿会出现在这里</div>' +
'</div></div>'
);
},
e3: function () {
return shell("推荐 · 带主按钮",
'<div class="e3">' +
'<div class="e3-ico"><svg><use href="#i-doc"/></svg></div>' +
'<div class="e3-title">暂无草稿</div>' +
'<div class="e3-desc">填写审批时可随时保存为草稿,方便下次继续</div>' +
'<button class="e3-btn">去发起审批</button>' +
'<div class="e3-link">了解如何使用草稿</div>' +
'</div>'
) + shell("待办 · 无 CTA 也可",
'<div class="e3">' +
'<div class="e3-ico"><svg><use href="#i-check"/></svg></div>' +
'<div class="e3-title">暂无待办</div>' +
'<div class="e3-desc">真棒,当前没有待你处理的审批</div>' +
'</div>'
);
},
e4: function () {
return shell("场景插画卡",
'<div class="e4-wrap"><div class="e4">' +
'<div class="e4-scene">' +
'<div class="e4-badge">!</div>' +
'<div class="e4-paper"><i></i><i></i><i></i></div>' +
'<div class="e4-desk"></div>' +
'</div>' +
'<div class="e4-title">还没有草稿</div>' +
'<div class="e4-desc">从发起审批开始,未提交的内容会自动出现在这里</div>' +
'<button class="e4-btn">去发起审批</button>' +
'</div></div>'
);
},
e5: function () {
return shell("搜索无结果",
'<div class="e5">' +
'<div class="e5-ico"><svg><use href="#i-search"/></svg></div>' +
'<div class="e5-title">未找到相关草稿</div>' +
'<div class="e5-desc">没有匹配「入帐」的结果,试试其他关键词</div>' +
'<button class="e5-chip">清空搜索</button>' +
'</div>',
{ keyword: "入帐" }
);
},
e6: function () {
return shell("极简加强",
'<div class="e6">' +
'<div class="e6-emoji"><svg><use href="#i-inbox"/></svg></div>' +
'<div class="e6-title">暂无草稿</div>' +
'<div class="e6-desc">保存的草稿会显示在这里</div>' +
'</div>'
);
}
};
const descEl = document.getElementById("desc");
const phonesEl = document.getElementById("phones");
const tabs = document.getElementById("tabs");
function show(k) {
var d = DESCS[k];
descEl.innerHTML =
"<h2>" + d.title + "</h2>" +
'<p class="sub">' + d.sub + "</p>" +
"<ul>" + d.items.map(function (i) { return "<li>" + i + "</li>"; }).join("") + "</ul>" +
'<div class="pick">' + d.pick + "</div>";
phonesEl.innerHTML = RENDER[k]();
}
tabs.addEventListener("click", function (e) {
var btn = e.target.closest(".tab");
if (!btn) return;
tabs.querySelectorAll(".tab").forEach(function (t) {
t.classList.toggle("is-active", t === btn);
});
show(btn.getAttribute("data-k"));
});
show("e0");
</script>
</body>
</html>
@@ -0,0 +1,695 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>待办中心 · 批量通过 / 打回 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--warn: #d97706;
--warn-soft: #fff7e8;
--danger: #ef4444;
--danger-soft: #fef2f2;
--nav-bg: #ecfdf5;
--page-bg: #f5f8f7;
--text: #1f2a26;
--text-muted: #6b7b74;
--border: #e8eaed;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1180px; margin: 0 auto; padding: 28px 16px 64px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 820px; }
.rec {
margin: 16px 0 0;
padding: 14px 16px;
border-radius: 14px;
background: var(--brand-soft);
border: 1px solid #b7ebd4;
font-size: 13px;
color: #166534;
line-height: 1.55;
}
.rec b { color: var(--brand-dark); }
.tabs {
display: flex; flex-wrap: wrap; gap: 10px;
margin: 20px 0 18px;
}
.tab {
appearance: none; border: 1px solid #e2e8f0; background: #fff;
border-radius: 999px; padding: 10px 16px; font-size: 13px;
font-weight: 600; color: #475569; cursor: pointer;
}
.tab.is-active {
color: var(--brand-dark); border-color: var(--brand);
background: var(--brand-soft);
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.16);
}
.layout {
display: grid;
grid-template-columns: minmax(240px, 300px) 1fr;
gap: 20px; align-items: start;
}
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0; border-radius: 16px; padding: 16px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.panel h2 { margin: 0 0 4px; font-size: 15px; }
.panel .sub { margin: 0 0 10px; font-size: 12px; color: #64748b; }
.panel ul { margin: 0; padding-left: 18px; font-size: 13px; color: #475569; }
.panel li { margin: 6px 0; }
.panel .pick {
margin-top: 12px; padding: 10px 12px; border-radius: 10px;
background: #fff7e8; border: 1px solid #fde68a;
font-size: 12px; color: #92400e;
}
.panel .ok {
margin-top: 10px; padding: 10px 12px; border-radius: 10px;
background: #ecfdf5; border: 1px solid #a7f3d0;
font-size: 12px; color: #047857;
}
.phones { display: flex; flex-wrap: wrap; gap: 18px; justify-content: center; }
.phone {
width: 375px; background: #fff; border-radius: 28px; overflow: hidden;
box-shadow: 0 0 0 1px rgb(15 23 42 / 6%), 0 18px 40px rgb(15 23 42 / 12%);
position: relative;
}
.cap {
text-align: center; font-size: 11px; font-weight: 650; color: #64748b;
padding: 8px; background: #f8fafc; border-bottom: 1px solid #eef2f0;
}
.nav {
height: 44px; display: flex; align-items: center; padding: 0 8px 0 4px;
background: var(--nav-bg); border-bottom: 1px solid #d8f3e5;
position: relative;
}
.nav-back {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
color: #0f291e; flex-shrink: 0;
}
.nav-back::before {
content: ""; width: 10px; height: 10px;
border-left: 2px solid #0f291e; border-bottom: 2px solid #0f291e;
transform: rotate(45deg); margin-left: 4px;
}
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 16px; font-weight: 650; color: #0f291e;
}
.nav-action {
margin-left: auto;
min-width: 48px;
height: 36px;
padding: 0 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 650;
color: var(--brand-dark);
cursor: pointer;
user-select: none;
}
.nav-action.is-done { color: #64748b; }
.nav-action:active { opacity: 0.72; }
.page {
min-height: 640px;
background: var(--page-bg);
padding-bottom: 24px;
position: relative;
}
.toolbar {
padding: 10px 12px;
background: #fff;
border-bottom: 1px solid #f1f5f9;
}
.search-box {
height: 36px;
display: flex; align-items: center; gap: 8px;
padding: 0 10px; border-radius: 10px;
background: #f1f5f9; border: 1px solid transparent;
}
.search-ico {
width: 13px; height: 13px; border: 1.5px solid #94a3b8; border-radius: 50%;
position: relative; flex-shrink: 0;
}
.search-ico::after {
content: ""; position: absolute; width: 6px; height: 1.5px;
right: -4px; bottom: -2px; background: #94a3b8; border-radius: 2px;
transform: rotate(45deg);
}
.search-ph { font-size: 13px; color: #b0b7c3; }
.batch-bar {
display: none;
align-items: center; justify-content: space-between; gap: 8px;
margin: 8px 12px 0; padding: 10px 12px;
border-radius: 10px; background: #fff;
border: 1px solid var(--border);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.batch-bar.is-show { display: flex; }
.batch-bar__hint { font-size: 13px; color: #646a73; flex-shrink: 0; }
.batch-bar__actions { display: flex; gap: 8px; margin-left: auto; }
.batch-bar__btn {
min-height: 32px; padding: 0 14px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; font-size: 13px; font-weight: 600;
border: none; cursor: pointer;
}
.batch-bar__btn.is-ok {
color: #fff; background: var(--brand);
}
.batch-bar__btn.is-return {
color: var(--warn); background: var(--warn-soft);
border: 1px solid #fde68a;
}
.batch-bar__btn:disabled {
opacity: 0.45; cursor: not-allowed;
}
.list {
padding: 10px 12px 0;
display: flex; flex-direction: column; gap: 10px;
}
.row {
display: flex; align-items: stretch; gap: 0;
}
.check {
display: none; align-items: flex-start;
padding: 18px 0 0 2px; margin-right: 6px; flex-shrink: 0;
cursor: pointer;
}
.page.is-manage .check { display: flex; }
.check__box {
width: 20px; height: 20px; border-radius: 50%;
border: 1.5px solid #cbd5e1; background: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 12px; color: #fff; font-weight: 700;
}
.check__box.on {
background: var(--brand); border-color: var(--brand);
}
.card {
flex: 1; min-width: 0;
background: #fff; border-radius: 14px;
border: 1px solid #d8f3e5;
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.07);
padding: 12px 14px; cursor: pointer;
}
.card.is-selected {
border-color: #86efac;
box-shadow: 0 0 0 2px rgba(10, 159, 109, 0.18);
}
.card__head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;
}
.card__title {
font-size: 15px; font-weight: 700; color: #0f291e; line-height: 1.35;
}
.card__time { flex-shrink: 0; font-size: 11px; color: #94a3b8; margin-top: 2px; }
.card__meta { margin-top: 8px; }
.card__meta-line {
display: block; font-size: 12px; color: var(--text-muted); line-height: 1.5;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card__foot {
margin-top: 10px; display: flex; align-items: center; justify-content: space-between;
}
.card__user { display: flex; align-items: center; gap: 6px; min-width: 0; }
.card__avatar {
width: 22px; height: 22px; border-radius: 6px;
background: var(--brand); color: #fff;
font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.card__user-text { font-size: 12px; color: #64748b; }
.card__status {
font-size: 11px; font-weight: 650; padding: 3px 8px; border-radius: 999px;
background: rgba(217, 119, 6, 0.12); color: var(--warn);
}
/* 底部意见弹层 */
.sheet-mask {
display: none; position: absolute; inset: 0;
background: rgba(15, 23, 42, 0.45); z-index: 20;
}
.sheet-mask.is-show { display: block; }
.sheet {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 21;
background: #fff; border-radius: 16px 16px 0 0;
padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px));
transform: translateY(110%);
transition: transform .22s ease;
box-shadow: 0 -8px 28px rgba(15, 23, 42, 0.12);
}
.sheet.is-show { transform: translateY(0); }
.sheet__hd {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.sheet__title { font-size: 16px; font-weight: 700; }
.sheet__close {
border: none; background: transparent; color: #64748b;
font-size: 13px; cursor: pointer; padding: 4px 0;
}
.sheet__hint {
margin: 0 0 8px; font-size: 12px; color: #64748b;
}
.sheet__hint strong { color: var(--brand-dark); }
.sheet__ta {
width: 100%; min-height: 96px; resize: none;
border: 1px solid #e2e8f0; border-radius: 10px;
padding: 10px 12px; font: inherit; font-size: 14px;
color: var(--text); outline: none;
background: #f8fafc;
}
.sheet__ta:focus {
border-color: #86efac; background: #fff;
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.12);
}
.sheet__ft {
margin-top: 12px; display: flex; gap: 10px;
}
.sheet__btn {
flex: 1; height: 40px; border-radius: 10px;
border: none; font-size: 15px; font-weight: 650; cursor: pointer;
}
.sheet__btn.is-cancel {
background: #f1f5f9; color: #475569;
}
.sheet__btn.is-ok {
background: var(--brand); color: #fff;
}
.sheet__btn.is-return {
background: var(--warn); color: #fff;
}
.toast {
position: absolute; left: 50%; bottom: 88px; z-index: 30;
transform: translateX(-50%);
padding: 8px 14px; border-radius: 8px;
background: rgba(15, 23, 42, 0.88); color: #fff;
font-size: 13px; white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity .2s;
}
.toast.is-show { opacity: 1; }
.legend {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.legend .box {
background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
padding: 12px 14px; font-size: 12px; color: #475569;
}
.legend h3 { margin: 0 0 6px; font-size: 13px; color: #0f291e; }
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>待办中心 · 批量通过 / 打回</h1>
<p>
交互对齐抄送「管理 + 多选 + 底栏批量操作」。右侧手机可点击演示:进入管理 → 勾选 → 批量通过 / 打回 → 填写统一意见。
</p>
<div class="rec">
<b>推荐方案(本 Demo):</b>
<b>标题栏右侧「管理 / 完成」</b>(对齐草稿箱自定义顶栏);底栏「批量通过」「批量打回」;
统一意见框(通过默认「同意」可空,打回必填);前端串行调现有单条接口,汇总成功/失败。不含批量拒绝。
</div>
</div>
<div class="tabs" id="sceneTabs">
<button type="button" class="tab is-active" data-scene="browse">① 浏览态</button>
<button type="button" class="tab" data-scene="manage">② 管理多选</button>
<button type="button" class="tab" data-scene="approve">③ 批量通过弹层</button>
<button type="button" class="tab" data-scene="return">④ 批量打回弹层</button>
</div>
<div class="layout">
<aside class="panel" id="sidePanel">
<h2>① 浏览态</h2>
<p class="sub">日常查看 / 点卡片进详情审批</p>
<ul>
<li>标题栏右侧「管理」(对齐草稿箱自定义顶栏)</li>
<li>搜索区仅保留检索,不再放管理按钮</li>
<li>点卡片仍进入审批详情(webview)</li>
</ul>
<div class="pick">右侧手机可点标题栏「管理」亲自走一遍流程。</div>
</aside>
<div class="phones">
<div class="phone">
<div class="cap">可交互 Demo · 375px</div>
<div class="nav">
<div class="nav-back" aria-hidden="true"></div>
<div class="nav-title">待办中心</div>
<div class="nav-action" id="manageBtn">管理</div>
</div>
<div class="page" id="page">
<div class="toolbar">
<div class="search-box">
<span class="search-ico" aria-hidden="true"></span>
<span class="search-ph">搜索流程名称、申请人</span>
</div>
</div>
<div class="batch-bar" id="batchBar">
<span class="batch-bar__hint" id="batchHint">已选 0 项</span>
<div class="batch-bar__actions">
<button type="button" class="batch-bar__btn is-return" id="btnReturn">批量打回</button>
<button type="button" class="batch-bar__btn is-ok" id="btnApprove">批量通过</button>
</div>
</div>
<div class="list" id="list"></div>
<div class="sheet-mask" id="sheetMask"></div>
<div class="sheet" id="sheet">
<div class="sheet__hd">
<div class="sheet__title" id="sheetTitle">批量通过</div>
<button type="button" class="sheet__close" id="sheetClose">取消</button>
</div>
<p class="sheet__hint" id="sheetHint">将对 <strong>2</strong> 条待办执行操作</p>
<textarea class="sheet__ta" id="sheetInput" placeholder="审批意见"></textarea>
<div class="sheet__ft">
<button type="button" class="sheet__btn is-cancel" id="sheetCancel">取消</button>
<button type="button" class="sheet__btn is-ok" id="sheetConfirm">确定</button>
</div>
</div>
<div class="toast" id="toast"></div>
</div>
</div>
</div>
</div>
<div class="legend">
<div class="box">
<h3>意见规则</h3>
批量通过:默认填入「同意」,可改,可空(与单条一致)。<br />
批量打回:意见必填,占位「请填写打回意见(必填)」。
</div>
<div class="box">
<h3>接口策略</h3>
暂无批量 API:前端对选中 taskId 串行调用<br />
<code>POST /tasks/{id}/approve</code>、<code>/return</code>。<br />
结束后 toast:「成功 n,失败 m」。
</div>
<div class="box">
<h3>边界</h3>
仅当前页已加载列表可勾选;不包含「拒绝」。<br />
若流程关闭打回(allowReturn=false),单条失败计入失败数。
</div>
</div>
</div>
<script>
const TASKS = [
{
id: 't1',
title: '请假申请',
time: '今天 09:12',
meta: ['请假类型:事假', '请假天数:1'],
user: '王品龙',
avatar: '王品'
},
{
id: 't2',
title: '费用报销',
time: '昨天 18:40',
meta: ['报销金额:1280.00', '事由:差旅交通'],
user: '吴家猛',
avatar: '吴家'
},
{
id: 't3',
title: '加班申请',
time: '08-10',
meta: ['加班时长:3小时', '加班日期:2026-08-09'],
user: '郑德禄',
avatar: '郑德'
},
{
id: 't4',
title: '出差申请',
time: '08-09',
meta: ['目的地:合肥', '出差天数:2'],
user: '李明',
avatar: '李明'
}
]
const state = {
manage: false,
selected: new Set(),
sheetKind: null // approve | return
}
const page = document.getElementById('page')
const listEl = document.getElementById('list')
const manageBtn = document.getElementById('manageBtn')
const batchBar = document.getElementById('batchBar')
const batchHint = document.getElementById('batchHint')
const btnApprove = document.getElementById('btnApprove')
const btnReturn = document.getElementById('btnReturn')
const sheet = document.getElementById('sheet')
const sheetMask = document.getElementById('sheetMask')
const sheetTitle = document.getElementById('sheetTitle')
const sheetHint = document.getElementById('sheetHint')
const sheetInput = document.getElementById('sheetInput')
const sheetConfirm = document.getElementById('sheetConfirm')
const toastEl = document.getElementById('toast')
const sidePanel = document.getElementById('sidePanel')
const SCENE_COPY = {
browse: {
title: '① 浏览态',
sub: '日常查看 / 点卡片进详情审批',
items: [
'标题栏右侧「管理」(对齐草稿箱自定义顶栏)',
'搜索区仅保留检索,不再放管理按钮',
'点卡片仍进入审批详情(webview)'
],
tip: '右侧手机可点标题栏「管理」亲自走一遍流程。',
tipClass: 'pick'
},
manage: {
title: '② 管理多选',
sub: '勾选多条待办,底栏出现批量操作',
items: [
'标题栏按钮变为「完成」,点按退出并清空勾选',
'左侧圆形勾选,选中卡片高亮描边',
'底栏:已选 N 项 + 批量打回 + 批量通过',
'未勾选时两个按钮禁用'
],
tip: '主行动为「批量通过」(品牌绿);打回为次要警告色。',
tipClass: 'ok'
},
approve: {
title: '③ 批量通过弹层',
sub: '统一意见,一次确认',
items: [
'标题「批量通过」,提示将对 N 条执行',
'默认意见「同意」,可修改或清空',
'确定后串行 approve,刷新列表'
],
tip: '与详情页单条「同意」语义一致。',
tipClass: 'ok'
},
return: {
title: '④ 批量打回弹层',
sub: '打回意见必填',
items: [
'标题「批量打回」,主按钮琥珀警告色',
'意见为空时 toast「打回意见必填」',
'部分失败时提示成功/失败条数'
],
tip: '不在此 Demo 做「批量拒绝」,避免误操作风险。',
tipClass: 'pick'
}
}
function renderSide(scene) {
const c = SCENE_COPY[scene]
sidePanel.innerHTML =
'<h2>' + c.title + '</h2>' +
'<p class="sub">' + c.sub + '</p>' +
'<ul>' + c.items.map((x) => '<li>' + x + '</li>').join('') + '</ul>' +
'<div class="' + c.tipClass + '">' + c.tip + '</div>'
}
function renderList() {
listEl.innerHTML = TASKS.map((t) => {
const on = state.selected.has(t.id)
return (
'<div class="row" data-id="' + t.id + '">' +
'<div class="check" data-check="' + t.id + '">' +
'<div class="check__box' + (on ? ' on' : '') + '">' + (on ? '✓' : '') + '</div>' +
'</div>' +
'<div class="card' + (on ? ' is-selected' : '') + '" data-card="' + t.id + '">' +
'<div class="card__head">' +
'<div class="card__title">' + t.title + '</div>' +
'<div class="card__time">' + t.time + '</div>' +
'</div>' +
'<div class="card__meta">' +
t.meta.map((m) => '<span class="card__meta-line">' + m + '</span>').join('') +
'</div>' +
'<div class="card__foot">' +
'<div class="card__user">' +
'<div class="card__avatar">' + t.avatar + '</div>' +
'<div class="card__user-text">由' + t.user + '提交</div>' +
'</div>' +
'<div class="card__status">进行中</div>' +
'</div>' +
'</div>' +
'</div>'
)
}).join('')
}
function syncChrome() {
page.classList.toggle('is-manage', state.manage)
manageBtn.textContent = state.manage ? '完成' : '管理'
manageBtn.classList.toggle('is-done', state.manage)
const n = state.selected.size
batchBar.classList.toggle('is-show', state.manage && n > 0)
batchHint.textContent = '已选 ' + n + ' 项'
btnApprove.disabled = n === 0
btnReturn.disabled = n === 0
}
function toast(msg) {
toastEl.textContent = msg
toastEl.classList.add('is-show')
clearTimeout(toast._t)
toast._t = setTimeout(() => toastEl.classList.remove('is-show'), 1800)
}
function openSheet(kind) {
const n = state.selected.size
if (!n) return
state.sheetKind = kind
sheetTitle.textContent = kind === 'approve' ? '批量通过' : '批量打回'
sheetHint.innerHTML = '将对 <strong>' + n + '</strong> 条待办执行操作'
sheetInput.value = kind === 'approve' ? '同意' : ''
sheetInput.placeholder =
kind === 'approve' ? '审批意见(可选)' : '请填写打回意见(必填)'
sheetConfirm.textContent = '确定'
sheetConfirm.className =
'sheet__btn ' + (kind === 'approve' ? 'is-ok' : 'is-return')
sheetMask.classList.add('is-show')
sheet.classList.add('is-show')
setTimeout(() => sheetInput.focus(), 80)
}
function closeSheet() {
state.sheetKind = null
sheetMask.classList.remove('is-show')
sheet.classList.remove('is-show')
}
function setManage(on) {
state.manage = on
if (!on) state.selected.clear()
renderList()
syncChrome()
}
function applyScene(scene) {
document.querySelectorAll('#sceneTabs .tab').forEach((el) => {
el.classList.toggle('is-active', el.dataset.scene === scene)
})
renderSide(scene)
closeSheet()
if (scene === 'browse') {
setManage(false)
} else if (scene === 'manage') {
state.selected = new Set(['t1', 't2'])
setManage(true)
} else if (scene === 'approve') {
state.selected = new Set(['t1', 't2'])
setManage(true)
openSheet('approve')
} else if (scene === 'return') {
state.selected = new Set(['t1', 't3'])
setManage(true)
openSheet('return')
}
}
manageBtn.addEventListener('click', () => setManage(!state.manage))
listEl.addEventListener('click', (e) => {
const check = e.target.closest('[data-check]')
const card = e.target.closest('[data-card]')
const id = (check && check.dataset.check) || (card && card.dataset.card)
if (!id) return
if (state.manage) {
if (state.selected.has(id)) state.selected.delete(id)
else state.selected.add(id)
renderList()
syncChrome()
return
}
if (card) toast('进入审批详情(Demo)')
})
btnApprove.addEventListener('click', () => openSheet('approve'))
btnReturn.addEventListener('click', () => openSheet('return'))
sheetMask.addEventListener('click', closeSheet)
document.getElementById('sheetClose').addEventListener('click', closeSheet)
document.getElementById('sheetCancel').addEventListener('click', closeSheet)
sheetConfirm.addEventListener('click', () => {
const kind = state.sheetKind
const comment = String(sheetInput.value || '').trim()
const n = state.selected.size
if (kind === 'return' && !comment) {
toast('打回意见必填')
return
}
closeSheet()
const label = kind === 'approve' ? '通过' : '打回'
toast('已' + label + ' ' + n + ' 条(Demo)')
state.selected.clear()
setManage(false)
})
document.getElementById('sceneTabs').addEventListener('click', (e) => {
const tab = e.target.closest('.tab')
if (!tab) return
applyScene(tab.dataset.scene)
})
renderList()
syncChrome()
renderSide('browse')
</script>
</body>
</html>
@@ -0,0 +1,694 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>mobile-oa-h5 · 审批进度 Tag 配色 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--page-bg: #f5f8f7;
--text: #1f2a26;
--text-muted: #6b7b74;
--border: #dcebe4;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
radial-gradient(700px 380px at 92% 0%, rgba(214, 163, 67, 0.1), transparent 50%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1280px; margin: 0 auto; padding: 28px 16px 64px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 900px; }
.goals {
margin-top: 14px; padding: 12px 14px; border-radius: 12px;
background: #ecfdf5; border: 1px solid #a7f3d0;
font-size: 13px; color: #065f46;
}
.goals ul { margin: 6px 0 0; padding-left: 18px; }
.grid {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 18px;
align-items: start;
}
.card {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 14px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.card h2 {
margin: 0 0 4px;
font-size: 15px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.card .sub { margin: 0 0 12px; font-size: 12px; color: #64748b; }
.pill {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 10px; font-weight: 700; letter-spacing: 0.02em;
}
.pill.now { background: #fff1f2; color: #be123c; border: 1px solid #fecdd3; }
.pill.rec { background: #ecfdf5; color: #047857; border: 1px solid #a7f3d0; }
.pill.alt { background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; }
.phone {
width: 100%; max-width: 360px; margin: 0 auto;
border-radius: 28px; border: 1px solid #cbd5e1; background: #0f172a;
padding: 10px; box-shadow: 0 18px 40px rgba(15,23,42,.12);
}
.phone__screen {
border-radius: 20px; overflow: hidden; background: var(--page-bg);
min-height: 420px; display: flex; flex-direction: column;
}
.nav {
display: flex; align-items: center; gap: 8px; min-height: 48px;
padding: 10px 12px;
background: linear-gradient(180deg, #d1fae5 0%, #ecfdf5 100%);
border-bottom: 1px solid #bbf7d0;
}
.nav__back {
width: 28px; height: 28px; border-radius: 999px;
display: grid; place-items: center;
color: var(--brand-dark); font-weight: 700; font-size: 16px;
background: rgba(255,255,255,.55);
}
.nav__title { flex: 1; font-size: 15px; font-weight: 700; color: #064e3b; }
.body { padding: 12px; display: grid; gap: 10px; }
.progress {
background: #fff; border: 1px solid var(--border);
border-radius: 12px; padding: 12px;
}
.progress__title {
margin: 0 0 10px; font-size: 15px; font-weight: 700; color: #0f291e;
}
.step { display: flex; gap: 10px; min-height: 52px; }
.rail { display: flex; flex-direction: column; align-items: center; width: 16px; }
.dot {
width: 10px; height: 10px; margin-top: 6px; border-radius: 50%;
background: #94a3b8;
}
.dot.ok { background: #189b65; }
.dot.warn { background: #cf8a19; }
.dot.danger { background: #d14343; }
.dot.mute { background: #94a3b8; }
.line { flex: 1; width: 2px; margin: 4px 0 0; background: #e2e8f0; min-height: 28px; }
.step-card { flex: 1; min-width: 0; padding-bottom: 12px; }
.row {
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
}
.name { font-size: 14px; font-weight: 600; color: #0f291e; }
.person {
margin-top: 6px; font-size: 12px; color: #5f7a6d;
display: flex; justify-content: space-between; gap: 8px;
}
.person strong { color: #1f2a26; font-weight: 600; }
/* ===== badge base ===== */
.badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
line-height: 1.45;
border: 1px solid transparent;
}
/* —— 现状 —— */
.now .badge.ok { color: #10b981; background: #ecfdf5; }
.now .badge.warn { color: #d97706; background: #fffbeb; }
.now .badge.danger { color: #ef4444; background: #fef2f2; }
.now .badge.mute { color: #5f7a6d; background: #f1f5f9; }
.now .badge.and { color: #2563eb; background: #eff6ff; }
.now .badge.or { color: #d97706; background: #fff7ed; }
.now .badge.cc { color: #0891b2; background: #ecfeff; }
.now .badge.end { color: #5f7a6d; background: #f1f5f9; }
/* —— 方案 A:三墙语义 + 类型独立色相(浅底实心) —— */
.a .badge.ok { color: #087f57; background: #e9f9f2; }
.a .badge.warn { color: #a66b10; background: #fff7e8; }
.a .badge.danger { color: #b42318; background: #fef3f2; }
.a .badge.mute { color: #5f7a6d; background: #f1f5f3; }
.a .badge.and { color: #4338ca; background: #eef2ff; } /* 会签·靛蓝 */
.a .badge.or { color: #0e7490; background: #ecfeff; } /* 或签·青蓝,避开琥珀冲突 */
.a .badge.cc { color: #6d28d9; background: #f5f3ff; } /* 抄送·淡紫 */
.a .badge.end { color: #64748b; background: #f1f5f9; }
/* —— 方案 B:状态浅底 / 类型描边(推荐视觉区分) —— */
.b .badge.ok { color: #087f57; background: #e9f9f2; }
.b .badge.warn { color: #a66b10; background: #fff7e8; }
.b .badge.danger { color: #b42318; background: #fef3f2; }
.b .badge.mute { color: #5f7a6d; background: #f1f5f3; }
.b .badge.and {
color: #3730a3; background: #fff; border-color: #c7d2fe;
}
.b .badge.or {
color: #0e7490; background: #fff; border-color: #a5f3fc;
}
.b .badge.cc {
color: #6d28d9; background: #fff; border-color: #ddd6fe;
}
.b .badge.end {
color: #64748b; background: #fff; border-color: #e2e8f0;
}
/* —— 方案 C:低饱和克制 —— */
.c .badge.ok { color: #0f766e; background: #f0fdfa; }
.c .badge.warn { color: #92400e; background: #fffbeb; }
.c .badge.danger { color: #9f1239; background: #fff1f2; }
.c .badge.mute { color: #64748b; background: #f8fafc; }
.c .badge.and { color: #475569; background: #f1f5f9; border-color: #cbd5e1; }
.c .badge.or { color: #475569; background: #f8fafc; border-color: #e2e8f0; }
.c .badge.cc { color: #64748b; background: #f8fafc; border-color: #e2e8f0; }
.c .badge.end { color: #94a3b8; background: #f8fafc; }
.swatch-block { margin-top: 12px; }
.swatch-block h3 {
margin: 0 0 8px; font-size: 12px; color: #64748b; font-weight: 600;
}
.swatches { display: flex; flex-wrap: wrap; gap: 6px; }
.legend {
margin-top: 10px; padding: 8px 10px; border-radius: 8px;
background: #f8fafc; border: 1px solid #e2e8f0;
font-size: 11px; color: #64748b;
}
.legend strong { color: #334155; }
.pick-bar {
position: sticky; bottom: 0; z-index: 5;
margin-top: 28px; padding: 14px 16px;
background: rgba(255,255,255,.92);
backdrop-filter: blur(8px);
border: 1px solid #e2e8f0; border-radius: 14px;
box-shadow: 0 -8px 24px rgba(15,23,42,.06);
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
.pick-bar span { font-size: 13px; color: #475569; margin-right: 4px; }
.pick-bar button {
appearance: none; border: 1px solid #cbd5e1; background: #fff;
border-radius: 999px; padding: 8px 14px; font-size: 13px;
color: #334155; cursor: pointer; font-weight: 600;
}
.pick-bar button.rec {
border-color: #6ee7b7; background: #ecfdf5; color: #047857;
}
.pick-bar button:hover { border-color: #0a9f6d; color: #087f57; }
.pick-note {
width: 100%; margin: 0; font-size: 12px; color: #94a3b8;
}
.palette-table {
margin-top: 22px;
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 14px 16px 18px;
overflow-x: auto;
}
.palette-table h2 { margin: 0 0 8px; font-size: 15px; }
.palette-table p { margin: 0 0 12px; font-size: 12px; color: #64748b; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td {
border-bottom: 1px solid #eef2f7; padding: 8px 10px;
text-align: left; vertical-align: middle;
}
th { color: #64748b; font-weight: 600; background: #f8fafc; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; color: #475569;
}
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>审批进度 Tag 配色 Demo</h1>
<p>
对比「现状」与 3 套方案。目标:状态语义清晰、会签/或签/抄送不与「待处理」琥珀撞色、
整体对齐三墙 Token(品牌绿 / 大麦黄 / 语义色),避免同屏高饱和堆叠。
</p>
<div class="goals">
<strong>设计约束</strong>
<ul>
<li>状态 tag:成功绿 / 待办琥珀 / 危险红 / 中性灰(语义优先)</li>
<li>类型 tag:会签 / 或签 / 抄送 / 结束 — 与状态区分,避免「或签」与「待处理」同色</li>
<li>同一节点可同时出现「状态 + 类型」两枚 tag</li>
</ul>
</div>
</div>
<div class="grid">
<!-- 现状 -->
<section class="card">
<h2>现状 <span class="pill now">NOW</span></h2>
<p class="sub">会签蓝偏艳;或签与待处理同系琥珀;成功色偏 web-admin mint(#10b981)</p>
<div class="phone now">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">审批进度 · 现状</div>
</div>
<div class="body">
<div class="progress">
<div class="progress__title">审批进度</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">发起申请</span>
<span class="badge ok">已提交</span>
</div>
<div class="person"><strong>张三</strong><span>03-12 09:20</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">部门审批</span>
<span class="badge ok">已同意</span>
<span class="badge and">会签</span>
</div>
<div class="person"><strong>李四 · 王五</strong><span>03-12 14:01</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot warn"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">分管领导</span>
<span class="badge warn">待处理</span>
<span class="badge or">或签</span>
</div>
<div class="person"><strong>赵六 / 钱七</strong><span>—</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">相关知会</span>
<span class="badge mute">未到达</span>
<span class="badge cc">抄送</span>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div></div>
<div class="step-card">
<div class="row">
<span class="name">流程结束</span>
<span class="badge mute">已结束</span>
<span class="badge end">结束</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="swatch-block">
<h3>全量色板</h3>
<div class="swatches">
<span class="badge ok">已同意</span>
<span class="badge ok">已完成</span>
<span class="badge ok">已提交</span>
<span class="badge warn">待处理</span>
<span class="badge warn">进行中</span>
<span class="badge danger">已拒绝</span>
<span class="badge danger">已撤销</span>
<span class="badge danger">已打回</span>
<span class="badge mute">已结束</span>
<span class="badge mute">未到达</span>
<span class="badge mute">已转交</span>
<span class="badge and">会签</span>
<span class="badge or">或签</span>
<span class="badge cc">抄送</span>
<span class="badge end">结束</span>
</div>
</div>
<div class="legend">问题:<strong>或签 ≈ 待处理</strong> 同屏难辨;会签蓝过亮;与三墙主色链不完全一致。</div>
</section>
<!-- 方案 A -->
<section class="card">
<h2>方案 A · 浅底实心 <span class="pill alt">ALT</span></h2>
<p class="sub">状态对齐三墙语义色;类型用「靛蓝 / 青蓝 / 淡紫」拉开色相,全部浅底实心</p>
<div class="phone a">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">审批进度 · 方案 A</div>
</div>
<div class="body">
<div class="progress">
<div class="progress__title">审批进度</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">发起申请</span>
<span class="badge ok">已提交</span>
</div>
<div class="person"><strong>张三</strong><span>03-12 09:20</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">部门审批</span>
<span class="badge ok">已同意</span>
<span class="badge and">会签</span>
</div>
<div class="person"><strong>李四 · 王五</strong><span>03-12 14:01</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot warn"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">分管领导</span>
<span class="badge warn">待处理</span>
<span class="badge or">或签</span>
</div>
<div class="person"><strong>赵六 / 钱七</strong><span>—</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">相关知会</span>
<span class="badge mute">未到达</span>
<span class="badge cc">抄送</span>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div></div>
<div class="step-card">
<div class="row">
<span class="name">流程结束</span>
<span class="badge mute">已结束</span>
<span class="badge end">结束</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="swatch-block">
<h3>全量色板</h3>
<div class="swatches">
<span class="badge ok">已同意</span>
<span class="badge ok">已完成</span>
<span class="badge ok">已提交</span>
<span class="badge warn">待处理</span>
<span class="badge warn">进行中</span>
<span class="badge danger">已拒绝</span>
<span class="badge danger">已撤销</span>
<span class="badge danger">已打回</span>
<span class="badge mute">已结束</span>
<span class="badge mute">未到达</span>
<span class="badge mute">已转交</span>
<span class="badge and">会签</span>
<span class="badge or">或签</span>
<span class="badge cc">抄送</span>
<span class="badge end">结束</span>
</div>
</div>
<div class="legend">优点:实现简单、与现状结构一致。注意:类型仍是实心,同排两枚 tag 略抢眼。</div>
</section>
<!-- 方案 B -->
<section class="card">
<h2>方案 B · 状态实心 / 类型描边 <span class="pill rec">推荐</span></h2>
<p class="sub">状态保留浅底语义;类型改为白底描边,一眼区分「结果态」与「节点属性」</p>
<div class="phone b">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">审批进度 · 方案 B</div>
</div>
<div class="body">
<div class="progress">
<div class="progress__title">审批进度</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">发起申请</span>
<span class="badge ok">已提交</span>
</div>
<div class="person"><strong>张三</strong><span>03-12 09:20</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">部门审批</span>
<span class="badge ok">已同意</span>
<span class="badge and">会签</span>
</div>
<div class="person"><strong>李四 · 王五</strong><span>03-12 14:01</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot warn"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">分管领导</span>
<span class="badge warn">待处理</span>
<span class="badge or">或签</span>
</div>
<div class="person"><strong>赵六 / 钱七</strong><span>—</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">相关知会</span>
<span class="badge mute">未到达</span>
<span class="badge cc">抄送</span>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div></div>
<div class="step-card">
<div class="row">
<span class="name">流程结束</span>
<span class="badge mute">已结束</span>
<span class="badge end">结束</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="swatch-block">
<h3>全量色板</h3>
<div class="swatches">
<span class="badge ok">已同意</span>
<span class="badge ok">已完成</span>
<span class="badge ok">已提交</span>
<span class="badge warn">待处理</span>
<span class="badge warn">进行中</span>
<span class="badge danger">已拒绝</span>
<span class="badge danger">已撤销</span>
<span class="badge danger">已打回</span>
<span class="badge mute">已结束</span>
<span class="badge mute">未到达</span>
<span class="badge mute">已转交</span>
<span class="badge and">会签</span>
<span class="badge or">或签</span>
<span class="badge cc">抄送</span>
<span class="badge end">结束</span>
</div>
</div>
<div class="legend">推荐理由:状态更抢眼、类型更克制;「待处理 + 或签」同排不再撞色糊成一团。</div>
</section>
<!-- 方案 C -->
<section class="card">
<h2>方案 C · 低饱和克制 <span class="pill alt">ALT</span></h2>
<p class="sub">类型几乎中性灰描边,仅状态保留语义色;最克制,但会签/或签/抄送辨识变弱</p>
<div class="phone c">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">审批进度 · 方案 C</div>
</div>
<div class="body">
<div class="progress">
<div class="progress__title">审批进度</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">发起申请</span>
<span class="badge ok">已提交</span>
</div>
<div class="person"><strong>张三</strong><span>03-12 09:20</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">部门审批</span>
<span class="badge ok">已同意</span>
<span class="badge and">会签</span>
</div>
<div class="person"><strong>李四 · 王五</strong><span>03-12 14:01</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot warn"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">分管领导</span>
<span class="badge warn">待处理</span>
<span class="badge or">或签</span>
</div>
<div class="person"><strong>赵六 / 钱七</strong><span>—</span></div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">相关知会</span>
<span class="badge mute">未到达</span>
<span class="badge cc">抄送</span>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div></div>
<div class="step-card">
<div class="row">
<span class="name">流程结束</span>
<span class="badge mute">已结束</span>
<span class="badge end">结束</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="swatch-block">
<h3>全量色板</h3>
<div class="swatches">
<span class="badge ok">已同意</span>
<span class="badge ok">已完成</span>
<span class="badge ok">已提交</span>
<span class="badge warn">待处理</span>
<span class="badge warn">进行中</span>
<span class="badge danger">已拒绝</span>
<span class="badge danger">已撤销</span>
<span class="badge danger">已打回</span>
<span class="badge mute">已结束</span>
<span class="badge mute">未到达</span>
<span class="badge mute">已转交</span>
<span class="badge and">会签</span>
<span class="badge or">或签</span>
<span class="badge cc">抄送</span>
<span class="badge end">结束</span>
</div>
</div>
<div class="legend">适合极简审美;代价是类型 tag 几乎靠文字区分,色相辨识弱。</div>
</section>
</div>
<section class="palette-table">
<h2>方案 B 色值明细(推荐落地稿)</h2>
<p>状态 = 浅底实心;类型 = 白底 + 1px 描边。字号 11px / 圆角 4px / padding 1×6,与现状结构一致。</p>
<table>
<thead>
<tr>
<th>类别</th>
<th>文案</th>
<th>class</th>
<th>文字色</th>
<th>背景</th>
<th>边框</th>
</tr>
</thead>
<tbody>
<tr>
<td>状态</td><td>已同意 / 已完成 / 已提交</td><td><code>ok</code></td>
<td><code>#087f57</code></td><td><code>#e9f9f2</code></td><td>—</td>
</tr>
<tr>
<td>状态</td><td>待处理 / 进行中</td><td><code>warn</code></td>
<td><code>#a66b10</code></td><td><code>#fff7e8</code></td><td>—</td>
</tr>
<tr>
<td>状态</td><td>已拒绝 / 已撤销 / 已打回</td><td><code>danger</code></td>
<td><code>#b42318</code></td><td><code>#fef3f2</code></td><td>—</td>
</tr>
<tr>
<td>状态</td><td>未到达 / 已转交 / 已结束</td><td><code>mute</code></td>
<td><code>#5f7a6d</code></td><td><code>#f1f5f3</code></td><td>—</td>
</tr>
<tr>
<td>类型</td><td>会签</td><td><code>and</code></td>
<td><code>#3730a3</code></td><td><code>#fff</code></td><td><code>#c7d2fe</code></td>
</tr>
<tr>
<td>类型</td><td>或签</td><td><code>or</code></td>
<td><code>#0e7490</code></td><td><code>#fff</code></td><td><code>#a5f3fc</code></td>
</tr>
<tr>
<td>类型</td><td>抄送</td><td><code>cc</code></td>
<td><code>#6d28d9</code></td><td><code>#fff</code></td><td><code>#ddd6fe</code></td>
</tr>
<tr>
<td>类型</td><td>结束</td><td><code>end</code></td>
<td><code>#64748b</code></td><td><code>#fff</code></td><td><code>#e2e8f0</code></td>
</tr>
</tbody>
</table>
</section>
<div class="pick-bar">
<span>选一套方案继续细化:</span>
<button type="button" data-choice="A">方案 A 浅底实心</button>
<button type="button" class="rec" data-choice="B">方案 B 双轨(推荐)</button>
<button type="button" data-choice="C">方案 C 低饱和</button>
<p class="pick-note" id="pickNote">直接回复「选 B」或点按钮即可。也可说「A + 会签改成某某色」微调。</p>
</div>
</div>
<script>
document.querySelectorAll('.pick-bar button').forEach((btn) => {
btn.addEventListener('click', () => {
const choice = btn.getAttribute('data-choice')
const note = document.getElementById('pickNote')
note.textContent = '已记录偏好:方案 ' + choice + '。请回到对话里告诉我,或直接说「就用 ' + choice + '」。'
note.style.color = '#047857'
})
})
</script>
</body>
</html>
@@ -0,0 +1,534 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>mobile-oa-h5 · 撤销后审批进度展示 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--page-bg: #f5f8f7;
--text: #1f2a26;
--text-muted: #6b7b74;
--border: #dcebe4;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
radial-gradient(700px 380px at 92% 0%, rgba(214, 163, 67, 0.1), transparent 50%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1320px; margin: 0 auto; padding: 28px 16px 64px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 960px; }
.goals {
margin-top: 14px; padding: 12px 14px; border-radius: 12px;
background: #ecfdf5; border: 1px solid #a7f3d0;
font-size: 13px; color: #065f46;
}
.goals ul { margin: 6px 0 0; padding-left: 18px; }
.scene {
margin-top: 16px; padding: 12px 14px; border-radius: 12px;
background: #fff; border: 1px solid #e2e8f0; font-size: 13px; color: #334155;
}
.scene code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; background: #f1f5f9; padding: 1px 6px; border-radius: 4px;
}
.grid {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 18px;
align-items: start;
}
.card {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 14px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.card h2 {
margin: 0 0 4px;
font-size: 15px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.card .sub { margin: 0 0 12px; font-size: 12px; color: #64748b; }
.pill {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 10px; font-weight: 700; letter-spacing: 0.02em;
}
.pill.now { background: #fff1f2; color: #be123c; border: 1px solid #fecdd3; }
.pill.rec { background: #ecfdf5; color: #047857; border: 1px solid #a7f3d0; }
.pill.alt { background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; }
.phone {
width: 100%; max-width: 360px; margin: 0 auto;
border-radius: 28px; border: 1px solid #cbd5e1; background: #0f172a;
padding: 10px; box-shadow: 0 18px 40px rgba(15,23,42,.12);
}
.phone__screen {
border-radius: 20px; overflow: hidden; background: var(--page-bg);
min-height: 460px; display: flex; flex-direction: column;
}
.nav {
display: flex; align-items: center; gap: 8px; min-height: 48px;
padding: 10px 12px;
background: linear-gradient(180deg, #d1fae5 0%, #ecfdf5 100%);
border-bottom: 1px solid #bbf7d0;
}
.nav__back {
width: 28px; height: 28px; border-radius: 999px;
display: grid; place-items: center;
color: var(--brand-dark); font-weight: 700; font-size: 16px;
background: rgba(255,255,255,.55);
}
.nav__title { flex: 1; font-size: 15px; font-weight: 700; color: #064e3b; }
.body { padding: 12px; display: grid; gap: 10px; }
.stamp-row {
position: relative;
background: #fff; border: 1px solid var(--border);
border-radius: 12px; padding: 12px 14px;
overflow: hidden;
}
.stamp-row__meta { font-size: 12px; color: var(--text-muted); }
.stamp-row__title { margin-top: 4px; font-size: 15px; font-weight: 700; }
.stamp {
position: absolute; right: 10px; top: 8px;
width: 64px; height: 64px; border-radius: 50%;
border: 2px solid #be123c; color: #be123c;
display: grid; place-items: center;
font-size: 13px; font-weight: 800;
transform: rotate(-18deg);
opacity: .85;
background: rgba(255,241,242,.55);
letter-spacing: 0.04em;
}
.progress {
background: #fff; border: 1px solid var(--border);
border-radius: 12px; padding: 12px;
}
.progress__title {
margin: 0 0 10px; font-size: 15px; font-weight: 700; color: #0f291e;
}
.step { display: flex; gap: 10px; min-height: 52px; }
.rail { display: flex; flex-direction: column; align-items: center; width: 16px; }
.dot {
width: 10px; height: 10px; margin-top: 6px; border-radius: 50%;
background: #94a3b8;
}
.dot.ok { background: #189b65; }
.dot.warn { background: #cf8a19; }
.dot.danger { background: #d14343; }
.dot.mute { background: #94a3b8; }
.line { flex: 1; width: 2px; margin: 4px 0 0; background: #e2e8f0; min-height: 28px; }
.step-card { flex: 1; min-width: 0; padding-bottom: 12px; }
.step:last-child .step-card { padding-bottom: 0; }
.row {
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
}
.name { font-size: 14px; font-weight: 600; color: #0f291e; }
.person-block { margin-top: 6px; }
.person {
font-size: 12px; color: #5f7a6d;
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
}
.person strong { color: #1f2a26; font-weight: 600; }
.person.is-ok strong { color: #189b65; }
.person.is-danger strong { color: #d14343; }
.person.is-mute strong,
.person.is-mute .detail { color: #94a3b8; }
.person .time { color: #94a3b8; white-space: nowrap; font-size: 11px; }
.person .detail { margin-left: 4px; }
.badge {
display: inline-flex; align-items: center;
padding: 1px 7px; border-radius: 999px;
font-size: 11px; font-weight: 650; line-height: 18px;
border: 1px solid transparent;
}
.badge.ok { color: #047857; background: #ecfdf5; border-color: #a7f3d0; }
.badge.warn { color: #b45309; background: #fffbeb; border-color: #fde68a; }
.badge.danger { color: #b91c1c; background: #fef2f2; border-color: #fecaca; }
.badge.mute { color: #64748b; background: #f8fafc; border-color: #e2e8f0; }
.badge.and { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
.callout {
margin-top: 10px; padding: 8px 10px; border-radius: 8px;
font-size: 12px; line-height: 1.5;
}
.callout.bad { background: #fff1f2; color: #9f1239; border: 1px solid #fecdd3; }
.callout.good { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.callout.neutral { background: #f8fafc; color: #475569; border: 1px solid #e2e8f0; }
.legend {
margin-top: 28px; padding: 14px 16px; border-radius: 14px;
background: rgba(255,255,255,.94); border: 1px solid #e2e8f0;
font-size: 13px; color: #334155;
}
.legend h3 { margin: 0 0 8px; font-size: 14px; }
.legend table { width: 100%; border-collapse: collapse; font-size: 12px; }
.legend th, .legend td {
text-align: left; padding: 8px 6px; border-bottom: 1px solid #f1f5f9;
vertical-align: top;
}
.legend th { color: #64748b; font-weight: 600; }
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>撤销后 · 审批进度怎么展示?</h1>
<p>
场景:发起人提交后,在「直接主管」尚未审批时撤销。库表里该节点任务
<code>status=cancelled</code> 是引擎为清掉待办写的,不等于主管做了「撤销」。
「已撤销」语义应归发起人/实例终态,不应挂在主管节点上。
</p>
</div>
<div class="goals">
<strong>设计目标</strong>
<ul>
<li>不把发起人撤销误读成「直接主管撤销」</li>
<li>「已撤销」只出现在实例终态(印章 / 进度末行)</li>
<li>被系统取消的待办节点,展示应对齐或签免处理(「无需处理」)或弱化</li>
</ul>
</div>
<div class="scene">
<strong>样例数据</strong>:实例
<code>E40F649F…</code> · 11:17 提交 → 12:47 发起人
<code>withdraw</code> · 「直接主管」任务从未审批,仅被系统改为
<code>cancelled</code>
</div>
<div class="grid">
<!-- A: 现状问题 -->
<section class="card">
<h2>
A. 现状(有问题)
<span class="pill now">当前</span>
</h2>
<p class="sub">节点徽章 + 人员行都写「已撤销」→ 像是主管撤销了</p>
<div class="phone">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">审批详情</div>
</div>
<div class="body">
<div class="stamp-row">
<div class="stamp-row__meta">差旅报销 · 我发起的</div>
<div class="stamp-row__title">出差申请单</div>
<div class="stamp">已撤销</div>
</div>
<div class="progress">
<div class="progress__title">审批进度</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">发起人</span>
<span class="badge ok">已提交</span>
</div>
<div class="person-block">
<div class="person is-ok">
<span><strong>吴子龙</strong></span>
<span class="time">08-13 11:17</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot danger"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">直接主管</span>
<span class="badge danger">已撤销</span>
<span class="badge and">会签</span>
</div>
<div class="person-block">
<div class="person is-danger">
<span><strong>张主管</strong><span class="detail">已撤销</span></span>
<span class="time">08-13 12:47</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot danger"></div></div>
<div class="step-card">
<div class="row">
<span class="name">已撤销</span>
<span class="badge danger">已撤销</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="callout bad">
✗ 主管节点挂了「已撤销」,语义错位;终态行再写一次,重复且误导。
</div>
</section>
<!-- B: 推荐 -->
<section class="card">
<h2>
B. 推荐:主管「无需处理」
<span class="pill rec">推荐</span>
</h2>
<p class="sub">对齐或签免处理:系统取消的待办 = 无需处理;「已撤销」只在终态</p>
<div class="phone">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">审批详情</div>
</div>
<div class="body">
<div class="stamp-row">
<div class="stamp-row__meta">差旅报销 · 我发起的</div>
<div class="stamp-row__title">出差申请单</div>
<div class="stamp">已撤销</div>
</div>
<div class="progress">
<div class="progress__title">审批进度</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">发起人</span>
<span class="badge ok">已提交</span>
</div>
<div class="person-block">
<div class="person is-ok">
<span><strong>吴子龙</strong></span>
<span class="time">08-13 11:17</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">直接主管</span>
<span class="badge mute">无需处理</span>
<span class="badge and">会签</span>
</div>
<div class="person-block">
<div class="person is-mute">
<span><strong>张主管</strong><span class="detail">无需处理</span></span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot danger"></div></div>
<div class="step-card">
<div class="row">
<span class="name">已撤销</span>
<span class="badge danger">已撤销</span>
</div>
<div class="person-block">
<div class="person is-danger">
<span><strong>吴子龙</strong><span class="detail">发起人撤销</span></span>
<span class="time">08-13 12:47</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="callout good">
✓ 主管只是「未轮到 / 被取消」;谁撤销、何时撤销在终态说清楚。
</div>
</section>
<!-- C: 备选:不展示取消节点 -->
<section class="card">
<h2>
C. 备选:不展示未批节点
<span class="pill alt">备选</span>
</h2>
<p class="sub">进度只保留「已发生」动作:提交 + 终态撤销;主管卡不出现</p>
<div class="phone">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">审批详情</div>
</div>
<div class="body">
<div class="stamp-row">
<div class="stamp-row__meta">差旅报销 · 我发起的</div>
<div class="stamp-row__title">出差申请单</div>
<div class="stamp">已撤销</div>
</div>
<div class="progress">
<div class="progress__title">审批进度</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">发起人</span>
<span class="badge ok">已提交</span>
</div>
<div class="person-block">
<div class="person is-ok">
<span><strong>吴子龙</strong></span>
<span class="time">08-13 11:17</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot danger"></div></div>
<div class="step-card">
<div class="row">
<span class="name">已撤销</span>
<span class="badge danger">已撤销</span>
</div>
<div class="person-block">
<div class="person is-danger">
<span><strong>吴子龙</strong><span class="detail">发起人撤销</span></span>
<span class="time">08-13 12:47</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="callout neutral">
○ 最干净,但看不到「曾经到过主管节点」;多人会签部分已批、部分 cancelled 时更难统一。
</div>
</section>
<!-- D: 备选:节点无状态,仅姓名 -->
<section class="card">
<h2>
D. 备选:节点弱化、无徽章
<span class="pill alt">备选</span>
</h2>
<p class="sub">主管卡只留姓名灰显,不写「已撤销/无需处理」;语义全靠终态</p>
<div class="phone">
<div class="phone__screen">
<div class="nav">
<div class="nav__back">‹</div>
<div class="nav__title">审批详情</div>
</div>
<div class="body">
<div class="stamp-row">
<div class="stamp-row__meta">差旅报销 · 我发起的</div>
<div class="stamp-row__title">出差申请单</div>
<div class="stamp">已撤销</div>
</div>
<div class="progress">
<div class="progress__title">审批进度</div>
<div class="step">
<div class="rail"><div class="dot ok"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">发起人</span>
<span class="badge ok">已提交</span>
</div>
<div class="person-block">
<div class="person is-ok">
<span><strong>吴子龙</strong></span>
<span class="time">08-13 11:17</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot mute"></div><div class="line"></div></div>
<div class="step-card">
<div class="row">
<span class="name">直接主管</span>
<span class="badge and">会签</span>
</div>
<div class="person-block">
<div class="person is-mute">
<span><strong>张主管</strong></span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="rail"><div class="dot danger"></div></div>
<div class="step-card">
<div class="row">
<span class="name">已撤销</span>
<span class="badge danger">已撤销</span>
</div>
<div class="person-block">
<div class="person is-danger">
<span><strong>吴子龙</strong><span class="detail">发起人撤销</span></span>
<span class="time">08-13 12:47</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="callout neutral">
○ 不误导,但主管行为态不明确;用户可能疑惑「卡在主管了吗」。
</div>
</section>
</div>
<div class="legend">
<h3>映射建议(若选 B)</h3>
<table>
<thead>
<tr>
<th>库表 / API</th>
<th>节点徽章</th>
<th>人员行</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>任务 <code>cancelled</code>(撤销/打回清待办)</td>
<td><span class="badge mute">无需处理</span></td>
<td>姓名 +「无需处理」(无时间,mute)</td>
<td>与或签 <code>done</code> 同文案;勿写「已撤销」</td>
</tr>
<tr>
<td>实例 <code>cancelled</code> + 终态项</td>
<td><span class="badge danger">已撤销</span></td>
<td>发起人 +「发起人撤销」+ 撤销时间</td>
<td>唯一表达「谁撤销了本单」</td>
</tr>
<tr>
<td>打回时其它人的 <code>cancelled</code></td>
<td>同上「无需处理」</td>
<td>同上</td>
<td>打回结论落在操作人任务上,不落在这些取消任务上</td>
</tr>
</tbody>
</table>
<p style="margin:12px 0 0;color:#64748b;font-size:12px;">
打开方式:用浏览器直接打开本文件即可对比。选定方案后告诉我,再落设计说明与实现。
</p>
</div>
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+152 -152
View File
@@ -1,11 +1,11 @@
# Graph Report - src (2026-08-12)
# Graph Report - src (2026-08-13)
## Corpus Check
- Large corpus: 967 files · ~631,821 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
- Large corpus: 972 files · ~633,368 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
## Summary
- 3417 nodes · 4865 edges · 71 communities detected
- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 1076 edges (avg confidence: 0.8)
- 3436 nodes · 4893 edges · 71 communities detected
- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 1084 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
@@ -50,60 +50,60 @@
- [[_COMMUNITY_Community 38|Community 38]]
- [[_COMMUNITY_Community 39|Community 39]]
- [[_COMMUNITY_Community 40|Community 40]]
- [[_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 47|Community 47]]
- [[_COMMUNITY_Community 50|Community 50]]
- [[_COMMUNITY_Community 48|Community 48]]
- [[_COMMUNITY_Community 52|Community 52]]
- [[_COMMUNITY_Community 53|Community 53]]
- [[_COMMUNITY_Community 55|Community 55]]
- [[_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 62|Community 62]]
- [[_COMMUNITY_Community 61|Community 61]]
- [[_COMMUNITY_Community 63|Community 63]]
- [[_COMMUNITY_Community 66|Community 66]]
- [[_COMMUNITY_Community 68|Community 68]]
- [[_COMMUNITY_Community 64|Community 64]]
- [[_COMMUNITY_Community 67|Community 67]]
- [[_COMMUNITY_Community 69|Community 69]]
- [[_COMMUNITY_Community 74|Community 74]]
- [[_COMMUNITY_Community 76|Community 76]]
- [[_COMMUNITY_Community 77|Community 77]]
- [[_COMMUNITY_Community 78|Community 78]]
- [[_COMMUNITY_Community 79|Community 79]]
- [[_COMMUNITY_Community 80|Community 80]]
- [[_COMMUNITY_Community 81|Community 81]]
- [[_COMMUNITY_Community 86|Community 86]]
- [[_COMMUNITY_Community 84|Community 84]]
- [[_COMMUNITY_Community 97|Community 97]]
- [[_COMMUNITY_Community 99|Community 99]]
- [[_COMMUNITY_Community 100|Community 100]]
## God Nodes (most connected - your core abstractions)
1. `get()` - 306 edges
2. `push()` - 119 edges
2. `push()` - 120 edges
3. `emit()` - 30 edges
4. `WebSocketClient` - 26 edges
5. `set()` - 21 edges
6. `handleDynamicRoutes()` - 19 edges
7. `parseProtocolStandardImportText()` - 17 edges
8. `walkRules()` - 16 edges
9. `onPick()` - 16 edges
8. `onPick()` - 17 edges
9. `walkRules()` - 16 edges
10. `scopeQuery()` - 16 edges
## Surprising Connections (you probably didn't know these)
- `handleBatchDelete()` --calls--> `withFullscreenLoading()` [INFERRED]
views/basic-info/customer/index.vue → utils/fullscreen-loading.ts
- `onPick()` --calls--> `ensureOvertimeKitRuleProps()` [INFERRED]
components/oa-approval/OaStartFormDialog.vue → utils/oa-overtime-kit-runtime.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
- `formatDetectMethods()` --calls--> `push()` [INFERRED]
utils/qc-work-order-card-meta.ts → views/operations/storage-facility/index.vue
- `buildCellStorageGroupContext()` --calls--> `push()` [INFERRED]
utils/qc-work-order-card-meta.ts → views/operations/storage-facility/index.vue
- `buildDonationGroupContext()` --calls--> `push()` [INFERRED]
utils/qc-work-order-card-meta.ts → views/operations/storage-facility/index.vue
- `buildOutboundPilotGroupContext()` --calls--> `push()` [INFERRED]
utils/qc-work-order-card-meta.ts → views/operations/storage-facility/index.vue
- `buildOutboundBasicExperimentGroupContext()` --calls--> `push()` [INFERRED]
utils/qc-work-order-card-meta.ts → views/operations/storage-facility/index.vue
## Communities
@@ -112,270 +112,270 @@ Cohesion: 0.0
Nodes (200): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchChannelAccount() (+192 more)
### Community 1 - "Community 1"
Cohesion: 0.03
Nodes (86): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchPilotCheckupActualItems(), fetchPilotCheckupDetail(), fetchPilotCheckupList() (+78 more)
Cohesion: 0.02
Nodes (170): fetchDeptTree(), fetchUploadAttachment(), fetchOaFlowPreview(), fetchOaRelatedSummaries(), fetchOaTemplateDetail(), handleReportUpload(), handleReportUpload(), walk() (+162 more)
### Community 2 - "Community 2"
Cohesion: 0.03
Nodes (74): fetchUploadAttachment(), FireworkSystem, handleReportUpload(), handleReportUpload(), walk(), if(), async(), for() (+66 more)
Nodes (88): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchPilotCheckupDetail(), fetchPilotCheckupList(), markPilotCheckupChecked() (+80 more)
### Community 3 - "Community 3"
Cohesion: 0.03
Nodes (79): fetchEthnicList(), formatNowDatetime(), applyDonationBundleToForm(), createEmptyTransport(), ensureEthnicOptionInList(), handleOpenForAdd(), if(), loadEthnicOptions() (+71 more)
Cohesion: 0.02
Nodes (72): fetchMyProductionTasks(), fetchMyQcTaskList(), fetchQcTaskDetail(), fetchQcTaskList(), fetchMineTasksForWorkOrders(), useInboundSiblingNavigation(), shouldApplyTriggerSourceFilter(), useQcWorkOrderTabPage() (+64 more)
### Community 4 - "Community 4"
Cohesion: 0.03
Nodes (62): fetchBatchDeleteOaReceiveAccounts(), fetchBatchMarkOaCcTasksRead(), fetchCreateOaReceiveAccount(), fetchDeleteOaReceiveAccount(), fetchMarkOaCcTaskRead(), fetchOaAppDraft(), fetchOaCcUnreadCount(), fetchOaInstanceDetail() (+54 more)
Nodes (79): fetchEthnicList(), applyDonationBundleToForm(), createEmptyTransport(), ensureEthnicOptionInList(), handleOpenForAdd(), if(), loadEthnicOptions(), normalizeSampleRow() (+71 more)
### Community 5 - "Community 5"
Cohesion: 0.03
Nodes (34): fetchGetMenuList(), ComponentLoader, IframeRouteManager, MenuProcessor, RoutePermissionValidator, RouteRegistry, RouteTransformer, useAppMode() (+26 more)
Nodes (64): fetchCheckupInstitutionInvoices(), fetchCheckupInstitutionProjects(), fetchCreateCheckupInstitution(), fetchCreateCheckupInstitutionInvoice(), fetchCreateCheckupInstitutionProject(), fetchUpdateCheckupInstitution(), fetchUpdateCheckupInstitutionInvoice(), fetchUpdateCheckupInstitutionProject() (+56 more)
### Community 6 - "Community 6"
Cohesion: 0.03
Nodes (63): fetchCheckupInstitutionInvoices(), fetchCheckupInstitutionProjects(), fetchCreateCheckupInstitution(), fetchCreateCheckupInstitutionInvoice(), fetchCreateCheckupInstitutionProject(), fetchUpdateCheckupInstitution(), fetchUpdateCheckupInstitutionInvoice(), fetchUpdateCheckupInstitutionProject() (+55 more)
Nodes (62): fetchBatchDeleteOaReceiveAccounts(), fetchBatchMarkOaCcTasksRead(), fetchCreateOaReceiveAccount(), fetchDeleteOaReceiveAccount(), fetchMarkOaCcTaskRead(), fetchOaAppDraft(), fetchOaCcUnreadCount(), fetchOaInstanceDetail() (+54 more)
### Community 7 - "Community 7"
Cohesion: 0.03
Nodes (57): buildInboundBasicSection(), buildInboundCellStorageViewSections(), buildInboundResearchDonationViewSections(), buildReceptionSection(), buildTransportSection(), resolveInboundProtocolStatus(), buildBaseFields(), buildFeeSection() (+49 more)
Nodes (34): fetchGetMenuList(), ComponentLoader, IframeRouteManager, MenuProcessor, RoutePermissionValidator, RouteRegistry, RouteTransformer, useAppMode() (+26 more)
### Community 8 - "Community 8"
Cohesion: 0.03
Nodes (56): fetchMyProductionTasks(), fetchMyQcTaskList(), fetchQcTaskDetail(), fetchQcTaskList(), fetchMineTasksForWorkOrders(), useInboundSiblingNavigation(), shouldApplyTriggerSourceFilter(), useQcWorkOrderTabPage() (+48 more)
Nodes (47): handleBlur(), handleFocus(), handleInput(), fetchClinicalPaymentDetail(), fetchClinicalPayments(), fetchClinicalReceiptConfirmed(), fetchClinicalReceiptPending(), fetchClinicalRefundConfirmed() (+39 more)
### Community 9 - "Community 9"
Cohesion: 0.03
Nodes (57): fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolDetail(), fetchCollectionProtocolList(), fetchCreateProtocolInvoiceRequest(), fetchInvoiceEligiblePayments(), fetchPaymentDetailList() (+49 more)
Nodes (55): buildInboundBasicSection(), buildInboundCellStorageViewSections(), buildInboundResearchDonationViewSections(), buildReceptionSection(), buildTemporaryViewSections(), buildTransportSection(), resolveInboundProtocolStatus(), buildInsertedSiblingCard() (+47 more)
### Community 10 - "Community 10"
Cohesion: 0.03
Nodes (41): fetchProductionReceptionBySampleCode(), fetchProductionReceptionSubmit(), fetchProductionReceptionUpdate(), handleDialogClose(), nodeSummary(), onAdd(), onAddInGroup(), createDefaultFlow() (+33 more)
Nodes (59): fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolDetail(), fetchCollectionProtocolList(), fetchCreateProtocolInvoiceRequest(), fetchInvoiceEligiblePayments(), fetchPaymentDetailList() (+51 more)
### Community 11 - "Community 11"
Cohesion: 0.04
Nodes (49): buildTemporaryViewSections(), buildInsertedSiblingCard(), resolveShowProtocolPaymentAtGroup(), appendTaskResultSections(), batchTasksForPanelSlot(), buildConfiguredOnlySection(), for(), for() (+41 more)
Cohesion: 0.03
Nodes (57): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), updateInfusionScheduleStatus(), handleDialogClose(), handleAdd(), handleDelete() (+49 more)
### Community 12 - "Community 12"
Cohesion: 0.03
Nodes (51): useSettingsHandlers(), useSettingsPanel(), useSettingsState(), useCeremony(), useTheme(), if(), branchConditionLines(), collectConditionReferencedFields() (+43 more)
Cohesion: 0.04
Nodes (42): buildBaseFields(), buildFeeSection(), buildInboundBasicSection(), buildInboundCellStorageProductionViewSections(), buildInboundResearchDonationProductionViewSections(), buildOutboundBasicExperimentProductionViewSections(), buildOutboundClinicalProductionViewSections(), buildOutboundDetail() (+34 more)
### Community 13 - "Community 13"
Cohesion: 0.04
Nodes (53): fetchDeptTree(), fetchOaFlowPreview(), fetchOaTemplateDetail(), chipTitle(), confirmPicker(), memberOptionLabel(), removeUser(), resolveUser() (+45 more)
### Community 14 - "Community 14"
Cohesion: 0.05
Nodes (53): fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchRefreshToken(), fetchUpdateProfile(), fetchUploadProfileAvatar(), toLogin(), fetchUserInfo() (+45 more)
### Community 14 - "Community 14"
Cohesion: 0.04
Nodes (43): fetchCellSampleTypeList(), fetchCreateCellSampleType(), fetchCreateMenu(), fetchDeleteCellSampleType(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateCellSampleType(), fetchUpdateMenu() (+35 more)
### Community 15 - "Community 15"
Cohesion: 0.03
Nodes (22): fetchProductionWorkOrderQcInfo(), fetchQcMaterialBatchDetail(), fetchQcMaterialBatchList(), fetchQcMaterialBatchOptions(), fetchQcMaterialList(), fetchQcMaterialOptions(), fetchUpdateQcMaterialBatch(), dash() (+14 more)
Cohesion: 0.06
Nodes (38): attachmentFullUrl(), dash(), handleAttachmentDownload(), handleAttachmentPreview(), buildPaymentFlowSteps(), dash(), formatAmount(), hasFinanceReceipt() (+30 more)
### Community 16 - "Community 16"
Cohesion: 0.05
Nodes (33): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), fetchMarkQcMaterialUsageFullyUsed(), collectIds(), postorder(), preorder() (+25 more)
Nodes (26): fetchUserList(), fetchProductionReceptionBySampleCode(), fetchProductionReceptionSubmit(), fetchProductionReceptionUpdate(), formatHandoverUserLabel(), formatNowDatetime(), fullFileUrl(), handlePreviewAttachment() (+18 more)
### Community 17 - "Community 17"
Cohesion: 0.06
Nodes (49): fetchOaRelatedSummaries(), buildFlowItems(), buildFormRows(), buildOaPrintFilename(), buildOaPrintModel(), canvasToPdf(), ensurePlainObject(), exportOaApprovalPdf() (+41 more)
### Community 18 - "Community 18"
Cohesion: 0.06
Nodes (41): completePilotApplication(), deletePilotApplication(), fetchPilotApplicationList(), startPilotApplication(), voidPilotApplication(), fetchClinicalRefundConfirmed(), buildConfirmedParams(), buildPendingParams() (+33 more)
### Community 19 - "Community 19"
Cohesion: 0.05
Nodes (23): handleBlur(), handleFocus(), handleInput(), fetchClinicalReceiptConfirmed(), buildConfirmedParams(), buildPendingParams(), formatAmount(), handleConfirmedReset() (+15 more)
### Community 20 - "Community 20"
Cohesion: 0.06
Nodes (38): fetchUserList(), fetchAddTaskStep(), fetchCopyTaskStep(), fetchDeleteTaskStep(), fetchDispatchProductionTaskSteps(), fetchForwardProductionTask(), fetchProductionTaskDetail(), fetchProductionTaskEditorInit() (+30 more)
### Community 21 - "Community 21"
Cohesion: 0.07
Nodes (39): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), updateInfusionScheduleStatus(), handleAdd(), handleBatchDelete(), handleDelete() (+31 more)
### Community 22 - "Community 22"
Cohesion: 0.08
Nodes (37): formatDatetimeToMinute(), handleChineseDatetimePaste(), parseChineseDatetimeText(), parseBirthOrder(), parseChineseNumeral(), addSampleHint(), earliestCollectTime(), isMaternalBloodEmpty() (+29 more)
### Community 23 - "Community 23"
### Community 18 - "Community 18"
Cohesion: 0.06
Nodes (21): fetchQcMaterialBatchDetail(), fetchQcMaterialBatchList(), fetchQcMaterialBatchOptions(), fetchQcMaterialList(), fetchQcMaterialOptions(), fetchUpdateQcMaterialBatch(), fetchMarkQcMaterialUsageFullyUsed(), financeAttachmentsToUrlString() (+13 more)
### Community 19 - "Community 19"
Cohesion: 0.1
Nodes (2): WebSocketClient, TableCache
### Community 24 - "Community 24"
### Community 20 - "Community 20"
Cohesion: 0.06
Nodes (13): fetchProductionWorkOrderQcInfo(), dash(), fmtMoney(), auditorDisplay(), dash(), ensureUnitsInOptions(), expressName(), formatYm() (+5 more)
### Community 21 - "Community 21"
Cohesion: 0.09
Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
### Community 25 - "Community 25"
### Community 22 - "Community 22"
Cohesion: 0.08
Nodes (20): createEmptySnapshots(), normalizeConfiguredReagents(), normalizeConsumables(), normalizeDataCollections(), normalizeReagents(), normalizeStepOutputs(), serializeAttachments(), serializeEquipmentIds() (+12 more)
### Community 26 - "Community 26"
### Community 23 - "Community 23"
Cohesion: 0.09
Nodes (30): fetchAddTaskStep(), fetchCopyTaskStep(), fetchDeleteTaskStep(), fetchDispatchProductionTaskSteps(), fetchForwardProductionTask(), fetchProductionTaskDetail(), fetchProductionTaskEditorInit(), fetchProductionTaskList() (+22 more)
### Community 24 - "Community 24"
Cohesion: 0.07
Nodes (19): fetchDeleteMaterialBatch(), fetchMaterialBatchList(), fetchMaterialBatchOptions(), fetchMaterialList(), fetchMaterialOptions(), fetchUpdateMaterialBatch(), fetchMaterialUsageList(), fetchMyMaterialUsageList() (+11 more)
### Community 27 - "Community 27"
### Community 25 - "Community 25"
Cohesion: 0.09
Nodes (22): fetchMineProductionWorkOrderTabStepCounts(), fetchMineProductionWorkOrderTabStepCountsDeduped(), fetchProductionWorkOrderCellProductOptions(), fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), fetchProductionWorkOrderQcDispatchOptions(), fetchProductionWorkOrderTabStepCounts(), fetchProductionWorkOrderTabStepCountsDeduped() (+14 more)
### Community 28 - "Community 28"
### Community 26 - "Community 26"
Cohesion: 0.09
Nodes (22): fetchOaAppList(), fetchOaGroupList(), fetchPublishOaApp(), loadData(), loadGroups(), resetForm(), onSuccess(), goBack() (+14 more)
### Community 27 - "Community 27"
Cohesion: 0.1
Nodes (21): displayPeople(), nodeStatusText(), personLines(), personStatusHint(), personTitle(), showNodeComment(), signBadge(), signBadgeText() (+13 more)
### Community 28 - "Community 28"
Cohesion: 0.11
Nodes (24): completePilotApplication(), deletePilotApplication(), fetchPilotApplicationList(), startPilotApplication(), voidPilotApplication(), handleBatchComplete(), handleBatchStart(), handleBatchVoid() (+16 more)
### Community 29 - "Community 29"
Cohesion: 0.09
Nodes (12): fetchMineQcWorkOrderTabUnfinishedCounts(), fetchOutboundQcDetail(), fetchQcWorkOrderDetail(), fetchQcWorkOrderList(), fetchQcWorkOrdersBySample(), fetchQcWorkOrderTabUnfinishedCounts(), reload(), reloadTabCounts() (+4 more)
### Community 30 - "Community 30"
Cohesion: 0.18
Nodes (22): buildPaymentFlowSteps(), dash(), formatAmount(), hasFinanceReceipt(), hasFinanceRefund(), hasReceiptSubmit(), hasRefundSubmit(), receiptSubmitTime() (+14 more)
### Community 31 - "Community 31"
Cohesion: 0.1
Nodes (12): fetchOutboundReleaseMaterial(), fetchOutboundReleaseReviewDetail(), fetchOutboundReleaseReviewList(), fetchReleaseReviewList(), handleReset(), handleSearch(), handleTabChange(), handleToggleSelect() (+4 more)
### Community 32 - "Community 32"
### Community 31 - "Community 31"
Cohesion: 0.12
Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
### Community 33 - "Community 33"
Cohesion: 0.13
Nodes (18): fetchCellSampleTypeList(), fetchCreateCellSampleType(), fetchDeleteCellSampleType(), fetchUpdateCellSampleType(), handleAdd(), handleDelete(), handleDropdownCommand(), handleEdit() (+10 more)
### Community 34 - "Community 34"
### Community 32 - "Community 32"
Cohesion: 0.14
Nodes (13): displayPeople(), personLines(), personTitle(), showNodeComment(), signBadge(), signBadgeText(), statusText(), displayName() (+5 more)
Nodes (14): if(), calcTripLegDays(), dayIndex(), emptyTripLeg(), endOffset(), startOffset(), sumTripLegDays(), buildTripKitChildren() (+6 more)
### Community 35 - "Community 35"
### Community 33 - "Community 33"
Cohesion: 0.19
Nodes (15): fetchAttachmentDownloadUrl(), attachmentFullUrl(), ensureUniqueZipEntryName(), fetchAttachmentBlob(), getAttachmentExtension(), resolveAttachmentFileName(), sanitizeZipPathSegment(), applyInboundLabStorageCodePrefix() (+7 more)
### Community 36 - "Community 36"
### Community 34 - "Community 34"
Cohesion: 0.13
Nodes (8): chipTitle(), confirmPicker(), memberOptionLabel(), removeUser(), resolveUser(), signBadge(), signBadgeText(), updateSelect()
### Community 35 - "Community 35"
Cohesion: 0.2
Nodes (14): applyFlagToRule(), applySchema(), countRules(), getActiveRule(), getDesigner(), getSetupState(), onMessage(), readFlag() (+6 more)
### Community 37 - "Community 37"
Cohesion: 0.12
Nodes (12): fetchApproveTask(), fetchBusinessAllTasks(), fetchCompletedTasks(), fetchDesignerData(), fetchInstanceDetail(), fetchMyInstances(), fetchPendingTasks(), fetchProcessDefList() (+4 more)
### Community 38 - "Community 38"
### Community 36 - "Community 36"
Cohesion: 0.12
Nodes (6): setupOaConfirmationKit(), ensureOaKitMenu(), setupOaLeaveKit(), setupOaOvertimeKit(), setupOaResignationKit(), setupOaTripKit()
### Community 39 - "Community 39"
Cohesion: 0.12
Nodes (6): attachmentFullUrl(), dash(), handleAttachmentDownload(), handleAttachmentPreview(), ensureOaUploadRuleProps(), resolveUploadResponseUrl()
### Community 40 - "Community 40"
### Community 37 - "Community 37"
Cohesion: 0.16
Nodes (10): useTable(), useTableImpl(), getColumnChecks(), useTableColumns(), createErrorHandler(), createSmartDebounce(), defaultResponseAdapter(), extractPagination() (+2 more)
### Community 41 - "Community 41"
### Community 38 - "Community 38"
Cohesion: 0.27
Nodes (14): useChart(), useChartComponent(), useChartOps(), colourBlend(), getCssVar(), getDarkColor(), getLightColor(), handleElementThemeColor() (+6 more)
### Community 42 - "Community 42"
Cohesion: 0.22
Nodes (9): fullFileUrl(), handleDownloadAttachment(), handlePreviewAttachment(), isVideoFile(), normalizeDisplayValue(), resolveReviewCellType(), resolveReviewQuantity(), resolveReviewSpecText() (+1 more)
### Community 39 - "Community 39"
Cohesion: 0.14
Nodes (8): useSettingsHandlers(), useSettingsPanel(), useSettingsState(), useCeremony(), useTheme(), themeAnimation(), toggleTheme(), toggleTransition()
### Community 43 - "Community 43"
### Community 40 - "Community 40"
Cohesion: 0.19
Nodes (6): fetchServiceOrderTxnList(), handleReset(), handleSearch(), handleTabChange(), loadData(), switchTab()
### Community 45 - "Community 45"
### Community 42 - "Community 42"
Cohesion: 0.15
Nodes (4): fetchNextDonationSampleCode(), fetchResearchDonationAuditList(), fetchResearchDonationDetail(), fetchResearchDonationList()
### Community 46 - "Community 46"
### Community 43 - "Community 43"
Cohesion: 0.24
Nodes (8): fullFileUrl(), handlePreviewAttachment(), isVideoFile(), normalizeDisplayValue(), resolveReviewCellType(), resolveReviewQuantity(), resolveReviewSpecText(), uniqueJoined()
### Community 44 - "Community 44"
Cohesion: 0.31
Nodes (4): isSlotAvailable(), resolveStorageSlotRangeFill(), slotFacilityNodeId(), sortBoxSlots()
### Community 45 - "Community 45"
Cohesion: 0.39
Nodes (8): formatReceiveAccountDisplay(), formatReceiveAccountLine(), isReceiveAccountSnapshot(), isReceiveAccountType(), normalizeReceiveAccountModelValue(), optionLabelFromRow(), snapshotFromRow(), trimSnapshot()
### Community 46 - "Community 46"
Cohesion: 0.44
Nodes (6): fileNameHint(), fileUrlHint(), getOaUploadDisplayName(), isOaUploadImageFile(), isOaUploadVideoFile(), mimeHint()
### Community 47 - "Community 47"
Cohesion: 0.25
Nodes (2): handleTouchEnd(), setTransition()
### Community 50 - "Community 50"
### Community 48 - "Community 48"
Cohesion: 0.28
Nodes (4): fetchMyProductionMaterialConsumptions(), handleReset(), handleSearch(), loadData()
Nodes (1): FireworkSystem
### Community 52 - "Community 52"
Cohesion: 0.39
Nodes (6): isIgnorableScriptError(), normalizeErrorMessage(), registerPromiseErrorHandler(), registerResourceErrorHandler(), scriptErrorHandler(), setupErrorHandle()
Nodes (6): assignQcItemParamRowKeys(), formatQcParamOptionText(), hydrateQcItemParamsForForm(), parseQcParamOptions(), serializeQcItemParamsForSave(), validateQcItemParamsBeforeSave()
### Community 53 - "Community 53"
Cohesion: 0.39
Nodes (6): isIgnorableScriptError(), normalizeErrorMessage(), registerPromiseErrorHandler(), registerResourceErrorHandler(), scriptErrorHandler(), setupErrorHandle()
### Community 54 - "Community 54"
Cohesion: 0.36
Nodes (1): TableHeightCalculator
### Community 55 - "Community 55"
### Community 56 - "Community 56"
Cohesion: 0.33
Nodes (2): logApiCryptoBootInfo(), maskSecret()
### Community 56 - "Community 56"
### Community 57 - "Community 57"
Cohesion: 0.38
Nodes (4): handleFocus(), handleInput(), parseDecimalToNumber(), sanitizeDecimalInput()
### Community 57 - "Community 57"
### Community 58 - "Community 58"
Cohesion: 0.4
Nodes (2): isUnfinishedQcWorkOrder(), resolveQcWorkOrderUnfinishedBadgeCount()
### Community 58 - "Community 58"
### Community 59 - "Community 59"
Cohesion: 0.47
Nodes (3): isPaymentDueWithinWarnWindow(), parsePaymentDueYmd(), startOfLocalDay()
### Community 59 - "Community 59"
### Community 60 - "Community 60"
Cohesion: 0.5
Nodes (2): formatProtocolDocDeleteConfirm(), formatProtocolDocSubject()
### Community 60 - "Community 60"
### Community 61 - "Community 61"
Cohesion: 0.5
Nodes (2): parseEquipmentIds(), syncEquipmentNamesFromOptions()
### Community 62 - "Community 62"
### Community 63 - "Community 63"
Cohesion: 0.5
Nodes (2): focusInput(), handleKeydown()
### Community 63 - "Community 63"
### Community 64 - "Community 64"
Cohesion: 0.4
Nodes (1): fetchQcMaterialAmountAuthUsers()
### Community 66 - "Community 66"
### Community 67 - "Community 67"
Cohesion: 0.67
Nodes (2): if(), logSidebarMenu()
### Community 68 - "Community 68"
Cohesion: 0.5
Nodes (1): fetchSampleSmsNotificationList()
### Community 69 - "Community 69"
Cohesion: 0.5
Nodes (1): fetchQcSmsList()
Nodes (1): fetchStorageEntrySmsList()
### Community 74 - "Community 74"
Cohesion: 1.0
Nodes (2): getSettingDefaults(), resetToDefaults()
### Community 77 - "Community 77"
### Community 76 - "Community 76"
Cohesion: 1.0
Nodes (2): buildOvertimeKitChildren(), uniqueId()
### Community 78 - "Community 78"
### Community 77 - "Community 77"
Cohesion: 1.0
Nodes (2): buildLeaveKitChildren(), uniqueId()
### Community 79 - "Community 79"
### Community 78 - "Community 78"
Cohesion: 1.0
Nodes (2): buildConfirmationKitChildren(), uniqueId()
### Community 80 - "Community 80"
### Community 79 - "Community 79"
Cohesion: 1.0
Nodes (2): buildResignationKitChildren(), uniqueId()
### Community 81 - "Community 81"
### Community 80 - "Community 80"
Cohesion: 1.0
Nodes (2): copyTextToClipboard(), copyTextWithFallback()
### Community 86 - "Community 86"
### Community 84 - "Community 84"
Cohesion: 1.0
Nodes (2): getSourceProtocolAttachment(), normalizeAttachmentFiles()
@@ -392,43 +392,43 @@ Cohesion: 1.0
Nodes (2): emptyTabCounts(), useQcWorkOrderTabUnfinishedCounts()
## Knowledge Gaps
- **Thin community `Community 23`** (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 19`** (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`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **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 53`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
- **Thin community `Community 48`** (9 nodes): `FireworkSystem`, `.constructor()`, `.createFirework()`, `.createParticle()`, `.getAvailableParticle()`, `.initializePool()`, `.preloadAllImages()`, `.preloadImage()`, `index.vue`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 55`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
- **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 57`** (6 nodes): `formatQcWorkOrderUnfinishedBadgeCount()`, `isUnfinishedQcWorkOrder()`, `qcWorkOrderUnfinishedBadgeTitle()`, `resolveQcWorkOrderUnfinishedBadgeCount()`, `sumQcWorkOrderGroupUnfinishedBadgeCount()`, `qc-work-order-unfinished-badge.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 59`** (5 nodes): `formatProtocolDocBatchDeleteConfirm()`, `formatProtocolDocDeleteConfirm()`, `formatProtocolDocSubject()`, `getProtocolDocTypeLabel()`, `protocol-doc-label.ts`
- **Thin community `Community 58`** (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 60`** (5 nodes): `equipmentOptionLabel()`, `joinEquipmentIds()`, `parseEquipmentIds()`, `syncEquipmentNamesFromOptions()`, `equipment-select.ts`
- **Thin community `Community 60`** (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 62`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
- **Thin community `Community 61`** (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 63`** (5 nodes): `fetchBatchRevokeQcMaterialAmountAuth()`, `fetchGrantQcMaterialAmountAuth()`, `fetchQcMaterialAmountAuthUsers()`, `fetchRevokeQcMaterialAmountAuth()`, `qc-material-amount-auth.ts`
- **Thin community `Community 63`** (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`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
- **Thin community `Community 64`** (5 nodes): `fetchBatchRevokeQcMaterialAmountAuth()`, `fetchGrantQcMaterialAmountAuth()`, `fetchQcMaterialAmountAuthUsers()`, `fetchRevokeQcMaterialAmountAuth()`, `qc-material-amount-auth.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 68`** (4 nodes): `fetchGenerateSms()`, `fetchSampleSmsNotificationList()`, `fetchToggleSmsNotified()`, `sample-sms-notification.ts`
- **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): `fetchGenerateQcSms()`, `fetchQcSmsList()`, `fetchToggleQcSmsNotified()`, `qc-sms-notification.ts`
- **Thin community `Community 69`** (4 nodes): `fetchGenerateStorageEntrySms()`, `fetchStorageEntrySmsList()`, `fetchToggleStorageEntrySmsNotified()`, `storage-entry-sms-notification.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 74`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
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): `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 78`** (3 nodes): `buildLeaveKitChildren()`, `oa-leave-kit-rule.ts`, `uniqueId()`
- **Thin community `Community 77`** (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 79`** (3 nodes): `buildConfirmationKitChildren()`, `oa-confirmation-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): `buildResignationKitChildren()`, `oa-resignation-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): `copyTextToClipboard()`, `copyTextWithFallback()`, `copy-text.ts`
- **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 86`** (3 nodes): `getSourceProtocolAttachment()`, `normalizeAttachmentFiles()`, `protocol-source-attachment.ts`
- **Thin community `Community 84`** (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 97`** (3 nodes): `emptyTabCounts()`, `useProductionWorkOrderTabStepCounts()`, `useProductionWorkOrderTabStepCounts.ts`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -440,16 +440,16 @@ Nodes (2): emptyTabCounts(), useQcWorkOrderTabUnfinishedCounts()
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `push()` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 20`, `Community 22`, `Community 23`, `Community 25`, `Community 27`, `Community 28`, `Community 29`, `Community 34`, `Community 35`?**
_High betweenness centrality (0.299) - this node is a cross-community bridge._
- **Why does `get()` connect `Community 0` to `Community 1`, `Community 3`, `Community 4`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 31`, `Community 33`, `Community 37`, `Community 43`, `Community 45`, `Community 46`, `Community 50`, `Community 63`, `Community 68`, `Community 69`?**
_High betweenness centrality (0.256) - this node is a cross-community bridge._
- **Why does `handleSubmit()` connect `Community 9` to `Community 2`, `Community 18`, `Community 6`?**
_High betweenness centrality (0.029) - this node is a cross-community bridge._
- **Why does `push()` connect `Community 1` to `Community 0`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 9`, `Community 10`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 19`, `Community 20`, `Community 22`, `Community 23`, `Community 25`, `Community 26`, `Community 27`, `Community 29`, `Community 32`, `Community 33`, `Community 34`, `Community 45`, `Community 48`?**
_High betweenness centrality (0.295) - this node is a cross-community bridge._
- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 16`, `Community 18`, `Community 20`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 28`, `Community 29`, `Community 30`, `Community 40`, `Community 42`, `Community 44`, `Community 64`, `Community 69`?**
_High betweenness centrality (0.220) - this node is a cross-community bridge._
- **Why does `fetchNotDeletedRegions()` connect `Community 0` to `Community 1`?**
_High betweenness centrality (0.033) - this node is a cross-community bridge._
- **Are the 305 inferred relationships involving `get()` (e.g. with `resolveStorageSlotRangeFill()` and `buildNodeCodePathFromFlat()`) actually correct?**
_`get()` has 305 INFERRED edges - model-reasoned connections that need verification._
- **Are the 118 inferred relationships involving `push()` (e.g. with `refreshQcMenuBadges()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
_`push()` has 118 INFERRED edges - model-reasoned connections that need verification._
- **Are the 119 inferred relationships involving `push()` (e.g. with `refreshQcMenuBadges()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
_`push()` has 119 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 20 inferred relationships involving `set()` (e.g. with `buildNodeCodePathFromFlat()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
+4 -4
View File
@@ -57,12 +57,12 @@
</div>
<div id="legend"></div>
</div>
<div id="stats">3417 nodes &middot; 4865 edges &middot; 386 communities</div>
<div id="stats">3436 nodes &middot; 4893 edges &middot; 389 communities</div>
</div>
<script>
const RAW_NODES = [{"id": "app_vue", "label": "App.vue", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "App.vue", "community": 55, "community_name": "Community 55", "source_file": "App.vue", "file_type": "code", "degree": 3}, {"id": "src_app_refreshbuttonautoicons", "label": "refreshButtonAutoIcons()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "refreshButtonAutoIcons()", "community": 55, "community_name": "Community 55", "source_file": "App.vue", "file_type": "code", "degree": 1}, {"id": "src_app_refreshpaneltitleicons", "label": "refreshPanelTitleIcons()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "refreshPanelTitleIcons()", "community": 55, "community_name": "Community 55", "source_file": "App.vue", "file_type": "code", "degree": 1}, {"id": "main_ts", "label": "main.ts", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "main.ts", "community": 55, "community_name": "Community 55", "source_file": "main.ts", "file_type": "code", "degree": 4}, {"id": "src_main_masksecret", "label": "maskSecret()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "maskSecret()", "community": 55, "community_name": "Community 55", "source_file": "main.ts", "file_type": "code", "degree": 2}, {"id": "src_main_logapicryptobootinfo", "label": "logApiCryptoBootInfo()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "logApiCryptoBootInfo()", "community": 55, "community_name": "Community 55", "source_file": "main.ts", "file_type": "code", "degree": 2}, {"id": "env_d_ts", "label": "env.d.ts", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "env.d.ts", "community": 146, "community_name": "Community 146", "source_file": "env.d.ts", "file_type": "code", "degree": 0}, {"id": "shims_loose_d_ts", "label": "shims-loose.d.ts", "color": {"background": "#FF9DA7", "border": "#FF9DA7", "highlight": {"background": "#ffffff", "border": "#FF9DA7"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "shims-loose.d.ts", "community": 147, "community_name": "Community 147", "source_file": "shims-loose.d.ts", "file_type": "code", "degree": 0}, {"id": "directives_index_ts", "label": "index.ts", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "index.ts", "community": 32, "community_name": "Community 32", "source_file": "directives/index.ts", "file_type": "code", "degree": 1}, {"id": "directives_index_setupglobdirectives", "label": "setupGlobDirectives()", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "setupGlobDirectives()", "community": 32, "community_name": "Community 32", "source_file": "directives/index.ts", "file_type": "code", "degree": 5}, {"id": "directives_core_roles_ts", "label": "roles.ts", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "roles.ts", "community": 32, "community_name": "Community 32", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 3}, {"id": "core_roles_checkrolepermission", "label": "checkRolePermission()", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "checkRolePermission()", "community": 32, "community_name": "Community 32", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 2}, {"id": "core_roles_removeelement", "label": "removeElement()", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "removeElement()", "community": 32, "community_name": "Community 32", "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": 552}, {"cid": 1, "color": "#F28E2B", "label": "Community 1", "count": 117}, {"cid": 2, "color": "#E15759", "label": "Community 2", "count": 110}, {"cid": 3, "color": "#76B7B2", "label": "Community 3", "count": 110}, {"cid": 4, "color": "#59A14F", "label": "Community 4", "count": 107}, {"cid": 5, "color": "#EDC948", "label": "Community 5", "count": 102}, {"cid": 6, "color": "#B07AA1", "label": "Community 6", "count": 100}, {"cid": 7, "color": "#FF9DA7", "label": "Community 7", "count": 95}, {"cid": 8, "color": "#9C755F", "label": "Community 8", "count": 89}, {"cid": 9, "color": "#BAB0AC", "label": "Community 9", "count": 88}, {"cid": 10, "color": "#4E79A7", "label": "Community 10", "count": 84}, {"cid": 11, "color": "#F28E2B", "label": "Community 11", "count": 82}, {"cid": 12, "color": "#E15759", "label": "Community 12", "count": 79}, {"cid": 13, "color": "#76B7B2", "label": "Community 13", "count": 74}, {"cid": 14, "color": "#59A14F", "label": "Community 14", "count": 69}, {"cid": 15, "color": "#EDC948", "label": "Community 15", "count": 69}, {"cid": 16, "color": "#B07AA1", "label": "Community 16", "count": 62}, {"cid": 17, "color": "#FF9DA7", "label": "Community 17", "count": 59}, {"cid": 18, "color": "#9C755F", "label": "Community 18", "count": 54}, {"cid": 19, "color": "#BAB0AC", "label": "Community 19", "count": 52}, {"cid": 20, "color": "#4E79A7", "label": "Community 20", "count": 51}, {"cid": 21, "color": "#F28E2B", "label": "Community 21", "count": 50}, {"cid": 22, "color": "#E15759", "label": "Community 22", "count": 47}, {"cid": 23, "color": "#76B7B2", "label": "Community 23", "count": 42}, {"cid": 24, "color": "#59A14F", "label": "Community 24", "count": 37}, {"cid": 25, "color": "#EDC948", "label": "Community 25", "count": 36}, {"cid": 26, "color": "#B07AA1", "label": "Community 26", "count": 35}, {"cid": 27, "color": "#FF9DA7", "label": "Community 27", "count": 34}, {"cid": 28, "color": "#9C755F", "label": "Community 28", "count": 33}, {"cid": 29, "color": "#BAB0AC", "label": "Community 29", "count": 25}, {"cid": 30, "color": "#4E79A7", "label": "Community 30", "count": 24}, {"cid": 31, "color": "#F28E2B", "label": "Community 31", "count": 24}, {"cid": 32, "color": "#E15759", "label": "Community 32", "count": 22}, {"cid": 33, "color": "#76B7B2", "label": "Community 33", "count": 22}, {"cid": 34, "color": "#59A14F", "label": "Community 34", "count": 21}, {"cid": 35, "color": "#EDC948", "label": "Community 35", "count": 20}, {"cid": 36, "color": "#B07AA1", "label": "Community 36", "count": 19}, {"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": 14}, {"cid": 44, "color": "#59A14F", "label": "Community 44", "count": 13}, {"cid": 45, "color": "#EDC948", "label": "Community 45", "count": 13}, {"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": 8}, {"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": 7}, {"cid": 56, "color": "#B07AA1", "label": "Community 56", "count": 7}, {"cid": 57, "color": "#FF9DA7", "label": "Community 57", "count": 6}, {"cid": 58, "color": "#9C755F", "label": "Community 58", "count": 6}, {"cid": 59, "color": "#BAB0AC", "label": "Community 59", "count": 5}, {"cid": 60, "color": "#4E79A7", "label": "Community 60", "count": 5}, {"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": "CommunitLine 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": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "env.d.ts", "community": 146, "community_name": "Community 146", "source_file": "env.d.ts", "file_type": "code", "degree": 0}, {"id": "shims_loose_d_ts", "label": "shims-loose.d.ts", "color": {"background": "#FF9DA7", "border": "#FF9DA7", "highlight": {"background": "#ffffff", "border": "#FF9DA7"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "shims-loose.d.ts", "community": 147, "community_name": "Community 147", "source_file": "shims-loose.d.ts", "file_type": "code", "degree": 0}, {"id": "directives_index_ts", "label": "index.ts", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 10.1, "font": {"size": 0, "color": "#ffffff"}, "title": "index.ts", "community": 31, "community_name": "Community 31", "source_file": "directives/index.ts", "file_type": "code", "degree": 1}, {"id": "directives_index_setupglobdirectives", "label": "setupGlobDirectives()", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "setupGlobDirectives()", "community": 31, "community_name": "Community 31", "source_file": "directives/index.ts", "file_type": "code", "degree": 5}, {"id": "directives_core_roles_ts", "label": "roles.ts", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "roles.ts", "community": 31, "community_name": "Community 31", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 3}, {"id": "core_roles_checkrolepermission", "label": "checkRolePermission()", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "checkRolePermission()", "community": 31, "community_name": "Community 31", "source_file": "directives/core/roles.ts", "file_type": "code", "degree": 2}, {"id": "core_roles_removeelement", "label": "removeElement()", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 10.2, "font": {"size": 0, "color": "#ffffff"}, "title": "removeElement()", "community": 31, "community_name": "Community 31", "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": 546}, {"cid": 1, "color": "#F28E2B", "label": "Community 1", "count": 221}, {"cid": 2, "color": "#E15759", "label": "Community 2", "count": 124}, {"cid": 3, "color": "#76B7B2", "label": "Community 3", "count": 118}, {"cid": 4, "color": "#59A14F", "label": "Community 4", "count": 110}, {"cid": 5, "color": "#EDC948", "label": "Community 5", "count": 108}, {"cid": 6, "color": "#B07AA1", "label": "Community 6", "count": 107}, {"cid": 7, "color": "#FF9DA7", "label": "Community 7", "count": 101}, {"cid": 8, "color": "#9C755F", "label": "Community 8", "count": 96}, {"cid": 9, "color": "#BAB0AC", "label": "Community 9", "count": 90}, {"cid": 10, "color": "#4E79A7", "label": "Community 10", "count": 90}, {"cid": 11, "color": "#F28E2B", "label": "Community 11", "count": 88}, {"cid": 12, "color": "#E15759", "label": "Community 12", "count": 74}, {"cid": 13, "color": "#76B7B2", "label": "Community 13", "count": 69}, {"cid": 14, "color": "#59A14F", "label": "Community 14", "count": 69}, {"cid": 15, "color": "#EDC948", "label": "Community 15", "count": 55}, {"cid": 16, "color": "#B07AA1", "label": "Community 16", "count": 51}, {"cid": 17, "color": "#FF9DA7", "label": "Community 17", "count": 47}, {"cid": 18, "color": "#9C755F", "label": "Community 18", "count": 43}, {"cid": 19, "color": "#BAB0AC", "label": "Community 19", "count": 42}, {"cid": 20, "color": "#4E79A7", "label": "Community 20", "count": 41}, {"cid": 21, "color": "#F28E2B", "label": "Community 21", "count": 37}, {"cid": 22, "color": "#E15759", "label": "Community 22", "count": 36}, {"cid": 23, "color": "#76B7B2", "label": "Community 23", "count": 36}, {"cid": 24, "color": "#59A14F", "label": "Community 24", "count": 35}, {"cid": 25, "color": "#EDC948", "label": "Community 25", "count": 34}, {"cid": 26, "color": "#B07AA1", "label": "Community 26", "count": 33}, {"cid": 27, "color": "#FF9DA7", "label": "Community 27", "count": 31}, {"cid": 28, "color": "#9C755F", "label": "Community 28", "count": 29}, {"cid": 29, "color": "#BAB0AC", "label": "Community 29", "count": 25}, {"cid": 30, "color": "#4E79A7", "label": "Community 30", "count": 24}, {"cid": 31, "color": "#F28E2B", "label": "Community 31", "count": 22}, {"cid": 32, "color": "#E15759", "label": "Community 32", "count": 21}, {"cid": 33, "color": "#76B7B2", "label": "Community 33", "count": 20}, {"cid": 34, "color": "#59A14F", "label": "Community 34", "count": 20}, {"cid": 35, "color": "#EDC948", "label": "Community 35", "count": 19}, {"cid": 36, "color": "#B07AA1", "label": "Community 36", "count": 17}, {"cid": 37, "color": "#FF9DA7", "label": "Community 37", "count": 17}, {"cid": 38, "color": "#9C755F", "label": "Community 38", "count": 16}, {"cid": 39, "color": "#BAB0AC", "label": "Community 39", "count": 15}, {"cid": 40, "color": "#4E79A7", "label": "Community 40", "count": 14}, {"cid": 41, "color": "#F28E2B", "label": "Community 41", "count": 13}, {"cid": 42, "color": "#E15759", "label": "Community 42", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "Community 43", "count": 13}, {"cid": 44, "color": "#59A14F", "label": "Community 44", "count": 9}, {"cid": 45, "color": "#EDC948", "label": "Community 45", "count": 9}, {"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": 8}, {"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": 6}, {"cid": 59, "color": "#BAB0AC", "label": "Community 59", "count": 6}, {"cid": 60, "color": "#4E79A7", "label": "Community 60", "count": 5}, {"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": 5}, {"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": "CommunityLine truncated
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
function esc(s) {
File diff suppressed because it is too large. Load diff
+11 -6
View File
@@ -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": 1786506822.9277327,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1786506719.2191331,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1786601057.3350704,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1786601019.5323057,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/store/index.ts": 1781272556.364766,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/router/index.ts": 1782394569.8806834,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/composables/useAttachmentActions.ts": 1783921717.4015942,
@@ -52,6 +52,7 @@
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/fullscreen-loading.ts": 1781272556.3849921,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/chinese-numeral-parse.ts": 1782960575.662593,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sample-production-reception-fields.ts": 1782806741.4880455,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-flow-preview-refresh.ts": 1786590578.7629158,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-upload-file-type.ts": 1786413851.5280254,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-owner-person-select-rule.ts": 1785401001.9203484,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-form-create-post.ts": 1785394335.3053484,
@@ -76,6 +77,7 @@
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-overtime-hours.test.ts": 1786373618.5292711,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/expected-cell-count.ts": 1782705039.2515786,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-payment-labels.ts": 1782893910.9937537,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-approval-print-flow.ts": 1786601489.1530275,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/status-tag-registry.ts": 1785206796.3246918,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/production-work-order-card-meta.ts": 1785209080.8034942,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/fetch-paged-list.ts": 1781272556.3903377,
@@ -98,6 +100,7 @@
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/outbound-qc-pilot-view-sections.ts": 1781566346.7618802,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-app-icons.ts": 1785992369.9430938,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-resignation-kit-rule.ts": 1785488987.364553,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-approval-print-flow.test.ts": 1786601522.7362416,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-person-select-rule.ts": 1785385076.8703918,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/qc-work-order-groups.ts": 1782400129.6394563,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/qc-inspection-batch.ts": 1782569790.8550596,
@@ -118,7 +121,7 @@
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-confirmation-date.ts": 1785403063.4649312,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sample-reception-view-sections.ts": 1783389099.9667985,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/production-labels.ts": 1783306730.912153,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-approval-print.ts": 1786413856.470563,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-approval-print.ts": 1786601519.3637354,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-owner-dept-select-rule.ts": 1785401003.0360808,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/inbound-sample-special-mark.ts": 1782889894.1376855,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-receive-account-value.test.ts": 1785390443.3642433,
@@ -164,13 +167,15 @@
"/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-resignation-kit-runtime.ts": 1785405017.8343015,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-progress-person-lines.ts": 1786375203.836954,
"/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,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-post-select-rule.ts": 1785394334.9268312,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-payment-installment.ts": 1783329873.2829163,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-source-attachment.ts": 1783921716.666595,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/download-attachment.ts": 1781272556.4160507,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-flow-preview-refresh.test.ts": 1786590563.254121,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-form-create-dept.ts": 1785148503.14446,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/oa-progress-person-lines.test.ts": 1786600948.2912912,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/ui/colors.ts": 1781272556.3798137,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/ui/emojo.ts": 1781272556.3802173,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/ui/ep-icon-map.ts": 1781272556.38063,
@@ -335,12 +340,12 @@
"/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": 1785835679.6218681,
"/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": 1786415214.1541882,
"/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/OaOwnerPersonSelect.vue": 1785401010.5990565,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaStartFormDialog.vue": 1786374809.2291305,
"/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,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaTripItinerary.vue": 1785489392.2403016,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/components/oa-approval/OaMoneyField.vue": 1785382264.3025115,
@@ -15,14 +15,14 @@
<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)">
{{ statusText(item.status) }}
{{ 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="item.timestamp">
<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>
@@ -66,6 +66,12 @@
{{ 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>
@@ -134,9 +140,17 @@
return map[s] || s
}
/** 任务节点 cancelled=系统清待办 →「无需处理」;终态 cancelled 仍为「已撤销」 */
function nodeStatusText(item: OaProgressChainItem) {
const s = item.status || ''
if (item.kind !== 'terminal' && s === 'cancelled') return '无需处理'
return statusText(s)
}
/** 徽章色:与 Fresh Mint 语义一致 */
function statusBadge(item: OaProgressChainItem) {
const s = item.status || ''
if (item.kind !== 'terminal' && s === 'cancelled') return 'mute'
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'ok'
if (s === 'pending' || s === 'running') return 'or'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
@@ -183,15 +197,31 @@
function showNodeComment(item: OaProgressChainItem): boolean {
// 有按人行时不再展示节点级拼接意见,避免双份
if (personLines(item).length) return false
if (item.kind === 'terminal' && item.status === 'cancelled') return false
return Boolean(item.commentText)
}
function terminalWithdrawText(item: OaProgressChainItem): string {
const name = (item.assignee || '').trim()
const tip = (item.commentText || '').trim() || '发起人撤销'
return name ? `${name} ${tip}` : tip
}
/** 任务节点 cancelled 为系统清待办,不展示时间(时间归终态「已撤销」) */
function showNodeTimestamp(item: OaProgressChainItem): boolean {
const ts = (item.timestamp || '').trim()
if (!ts) return false
if (item.kind !== 'terminal' && item.status === 'cancelled') return false
return true
}
function personChipClass(status?: string) {
const s = status || ''
if (s === 'approved' || s === 'completed') return 'is-ok'
if (s === 'pending' || s === 'running') return 'is-pending'
if (s === 'rejected' || s === 'returned' || s === 'cancelled') return 'is-danger'
if (s === 'done' || s === 'not_reached' || s === 'transferred') return 'is-mute'
if (s === 'rejected' || s === 'returned') return 'is-danger'
if (s === 'done' || s === 'not_reached' || s === 'transferred' || s === 'cancelled')
return 'is-mute'
return ''
}
@@ -201,7 +231,7 @@
if (s === 'rejected') return '已拒绝'
if (s === 'returned') return '已打回'
if (s === 'pending' || s === 'running') return '待处理'
if (s === 'done') return '无需处理'
if (s === 'done' || s === 'cancelled') return '无需处理'
if (s === 'not_reached') return '未到达'
if (s === 'transferred') return '已转交'
return ''
@@ -212,13 +242,17 @@
const statusLabel =
p.status === 'transferred' && target
? `转交给${target}`
: statusText(p.status || '')
const parts = [p.nickName || '', statusLabel, p.timestamp || ''].filter(Boolean)
: personStatusHint(p.status) || statusText(p.status || '')
// cancelled/done 人员行不展示操作时间
const ts =
p.status === 'cancelled' || p.status === 'done' ? '' : String(p.timestamp || '').trim()
const parts = [p.nickName || '', statusLabel, ts].filter(Boolean)
return parts.join(' · ')
}
function dotClass(item: OaProgressChainItem) {
const s = item.status || ''
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'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
@@ -129,6 +129,12 @@
listTripKits,
TRIP_MANUAL_PREFIX
} from '@/utils/oa-trip-kit-runtime'
import {
buildFlowPreviewRequestKey,
collectConditionFields,
shouldShowFlowPreviewSpinner,
shouldSkipFlowPreviewRequest
} from '@/utils/oa-flow-preview-refresh'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'OaStartFormDialog' })
@@ -162,6 +168,12 @@
const flowPendingMessage = ref('必填信息填写后,流程将自动显示')
const previewLoading = ref(false)
let previewTimer: ReturnType<typeof setTimeout> | null = null
/** 流程条件字段:仅这些 formData 变化才重拉预览 */
let flowConditionFields: string[] = []
let lastFlowPreviewRequestKey: string | null = null
let inFlightFlowPreviewKey: string | null = null
let hasCompletedFlowPreview = false
let flowPreviewReqSeq = 0
/** 避免时长自动回写再次触发手改判定 */
let leaveKitSyncing = false
let leaveKitPrevSnapshot: Record<string, unknown> = {}
@@ -242,23 +254,56 @@
}
async function loadFlowPreview(appId: string) {
previewLoading.value = true
if (!appId) return
const requestKey = buildFlowPreviewRequestKey({
formData: formData.value,
initiatorSelectByNode: initiatorSelectByNode.value,
conditionFields: flowConditionFields
})
if (
shouldSkipFlowPreviewRequest(lastFlowPreviewRequestKey, requestKey, {
inFlightKey: inFlightFlowPreviewKey
})
) {
return
}
const seq = ++flowPreviewReqSeq
inFlightFlowPreviewKey = requestKey
const showSpinner = shouldShowFlowPreviewSpinner({
hasCompletedPreview: hasCompletedFlowPreview
})
if (showSpinner) previewLoading.value = true
try {
const preview = await fetchOaFlowPreview(appId, {
formData: { ...formData.value },
initiatorSelectByNode: { ...initiatorSelectByNode.value }
})
if (seq !== flowPreviewReqSeq) return
lastFlowPreviewRequestKey = requestKey
flowResolved.value = preview?.resolved !== false
flowPendingMessage.value = preview?.message || '必填信息填写后,流程将自动显示'
flowSteps.value = flowResolved.value ? preview?.steps || [] : []
} catch {
if (seq !== flowPreviewReqSeq) return
flowSteps.value = []
flowResolved.value = true
} finally {
previewLoading.value = false
if (seq === flowPreviewReqSeq) {
inFlightFlowPreviewKey = null
hasCompletedFlowPreview = true
previewLoading.value = false
}
}
}
function resetFlowPreviewState(flowRoot: unknown) {
flowConditionFields = collectConditionFields(flowRoot)
lastFlowPreviewRequestKey = null
inFlightFlowPreviewKey = null
hasCompletedFlowPreview = false
flowPreviewReqSeq += 1
}
function scheduleFlowPreview() {
if (!dlgVisible.value || !picked.value?.id) return
if (previewTimer) clearTimeout(previewTimer)
@@ -335,6 +380,7 @@
void syncOvertimeKitDurations()
const flow = detail?.flowTreeJson || {}
const root = flow.root || flow
resetFlowPreviewState(root)
needInitiatorSelect.value = flowNeedsInitiatorSelect(root)
if (needInitiatorSelect.value && !userOptions.value.length) {
await loadUsers()
@@ -0,0 +1,52 @@
/**
* 打印「审批流程」:撤销清待办 cancelled → 无需处理;终态归因发起人
* Spec: docs/superpowers/specs/2026-08-13-oa-progress-withdraw-cancelled-display-design.md
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildFlowItems } from './oa-approval-print-flow'
describe('buildFlowItems · withdraw cancelled', () => {
it('maps cancelled approval task to 无需处理 and terminal to 发起人撤销', () => {
const items = buildFlowItems({
progress: [
{
kind: 'submit',
title: '发起人',
status: 'submitted',
assigneeUserName: '吴子龙',
timestamp: '2026-08-13 11:17:38'
},
{
kind: 'task',
title: '直接主管',
status: 'cancelled',
signMode: 'and',
timestamp: '',
people: [
{
nickName: '张主管',
status: 'cancelled',
timestamp: '2026-08-13 12:47:46',
commentText: ''
}
]
},
{
kind: 'terminal',
title: '已撤销',
status: 'cancelled',
assigneeUserName: '吴子龙',
commentText: '发起人撤销',
timestamp: '2026-08-13 12:47:46'
}
]
})
assert.equal(items[0].left.includes('已提交'), true)
assert.equal(items[1].left, '【直接主管·会签】 无需处理')
assert.deepEqual(items[1].personLines, [{ left: '张主管 无需处理', right: '' }])
assert.equal(items[2].left, '已撤销 吴子龙 发起人撤销')
assert.equal(items[2].right, '2026-08-13 12:47:46')
})
})
@@ -0,0 +1,199 @@
/**
* OA 打印「审批流程」行组装(无 Vue/canvas 副作用,便于单测)
* Spec: docs/superpowers/specs/2026-08-13-oa-progress-withdraw-cancelled-display-design.md
*/
import { formatProgressPersonLines } from '@/utils/oa-progress-person-lines'
export type OaPrintPersonLine = {
left: string
right: string
}
export type OaPrintFlowItem = {
left: string
right: string
/** @deprecated 多人节点改用 personLines;单人/发起人可保留 */
comment?: string
/** 或签/会签按人行:左侧文案 + 右侧时间(右对齐) */
personLines?: OaPrintPersonLine[]
}
const STATUS_TEXT: Record<string, string> = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
submitted: '已提交',
not_reached: '未到达',
returned: '已打回',
transferred: '已转交'
}
function statusText(s: string) {
return STATUS_TEXT[s] || s || '-'
}
/** 任务节点 cancelled=系统清待办 →「无需处理」;终态 cancelled 仍为「已撤销」 */
function progressNodeStatusText(p: any): string {
const kind = String(p?.kind || '')
const s = String(p?.status || '').trim()
if (kind !== 'terminal' && s === 'cancelled') return '无需处理'
return statusText(s)
}
function peopleDisplayNames(p: any): string[] {
const people = Array.isArray(p?.people) ? p.people : []
const fromPeople = people
.map((x: any) => String(x?.nickName || x?.id || '').trim())
.filter(Boolean)
if (fromPeople.length) return fromPeople
const name = String(p?.assigneeUserName || p?.assigneeUserId || '').trim()
return name ? [name] : []
}
function progressPersonLinesFromItem(p: any): OaPrintPersonLine[] {
const people = Array.isArray(p?.people) ? p.people : []
if (!people.length) return []
return formatProgressPersonLines(people).map((x) => ({
left: x.body || x.text,
right: x.timestamp || ''
}))
}
function isCcProgressItem(p: any): boolean {
const taskType = String(p?.taskType || p?.task_type || '').trim()
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
const title = String(p?.title || p?.nodeName || '').trim()
return (
taskType === 'cc' ||
signMode === 'cc' ||
title === '抄送人' ||
/^抄送/.test(title)
)
}
/**
* 从 progress 组装审批流程行;抄送节点聚合为「抄送A, B, C」对齐钉钉导出。
* 若 progress 漏抄送,则用 tasks 兜底补一行。
*/
export function buildFlowItems(detail: any): OaPrintFlowItem[] {
const list = Array.isArray(detail?.progress) ? detail.progress : []
const items: OaPrintFlowItem[] = []
let ccBuf: { names: string[]; ts: string } | null = null
const flushCc = () => {
if (!ccBuf) return
const uniq = Array.from(new Set(ccBuf.names.map((n) => n.trim()).filter(Boolean)))
items.push({
left: uniq.length ? `抄送: ${uniq.join(', ')}` : '抄送:',
right: ccBuf.ts || ''
})
ccBuf = null
}
for (const p of list) {
const kind = String(p.kind || '')
const title = String(p.title || '-').trim()
const names = peopleDisplayNames(p)
const name = names.join(', ')
const ts = String(p.timestamp || '').trim()
const comment = String(p.commentText || '').trim()
const st = progressNodeStatusText(p)
const signMode = String(p.signMode || p.sign_mode || '')
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
if (isCcProgressItem(p)) {
if (!ccBuf) ccBuf = { names: [], ts }
if (names.length) ccBuf.names.push(...names)
if (ts) ccBuf.ts = ts
continue
}
flushCc()
if (kind === 'submit') {
items.push({
left: name ? `【${title}】 ${name} ${st}` : `【${title}】 ${st}`,
right: ts,
comment: comment || undefined
})
continue
}
if (kind === 'terminal') {
if (String(p.status || '') === 'returned' || title === '已打回') {
continue
}
const withdrawName = String(p.assigneeUserName || p.assignee || '').trim()
const withdrawTip = comment || (String(p.status || '') === 'cancelled' ? '发起人撤销' : '')
const leftExtra =
String(p.status || '') === 'cancelled' && (withdrawName || withdrawTip)
? ` ${[withdrawName, withdrawTip].filter(Boolean).join(' ')}`
: ''
items.push({
left: `${title || st}${leftExtra}`,
right: ts
})
continue
}
if (kind === 'pending_node') {
const personLines = progressPersonLinesFromItem(p)
const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
items.push({
left: personLines.length
? `${head} ${st}`
: name
? `${head} ${name} ${st}`
: `${head} ${st}`,
right: personLines.length ? '' : ts,
personLines: personLines.length ? personLines : undefined
})
continue
}
const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
const personLines = progressPersonLinesFromItem(p)
if (personLines.length) {
items.push({
left: `${head} ${st}`,
right: '',
personLines
})
continue
}
items.push({
left: name ? `${head} ${name} ${st}` : `${head} ${st}`,
right: ts,
comment: comment || undefined
})
}
flushCc()
const hasCcLine = items.some((it) => it.left.startsWith('抄送'))
if (!hasCcLine) {
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : []
const ccTasks = tasks.filter((t: any) => {
const tt = String(t?.taskType || t?.task_type || '').trim()
const sm = String(t?.signMode || t?.sign_mode || '').trim()
return tt === 'cc' || sm === 'cc'
})
if (ccTasks.length) {
const names = ccTasks
.map((t: any) =>
String(t?.assigneeUserName || t?.assignee_user_name || t?.assigneeUserId || '').trim()
)
.filter(Boolean)
const uniq = Array.from(new Set(names))
let ts = ''
for (const t of ccTasks) {
const tTs = String(t?.updateTime || t?.createTime || '').trim()
if (tTs && (!ts || tTs > ts)) ts = tTs
}
if (uniq.length) {
items.push({ left: `抄送: ${uniq.join(', ')}`, right: ts })
}
}
}
return items
}
+7 -186
View File
@@ -11,7 +11,6 @@ import {
normalizeRelatedIdsFromFormValue,
type RelatedPrintBlock
} from '@/utils/oa-approval-print-related'
import { formatProgressPersonLines } from '@/utils/oa-progress-person-lines'
import { formatDeptDisplay } from '@/utils/oa-dept-value'
import { formatPersonDisplay } from '@/utils/oa-person-value'
import { formatPostDisplay } from '@/utils/oa-post-value'
@@ -23,6 +22,11 @@ import {
} from '@/utils/oa-form-value-display'
import { toRmbUpper } from '@/utils/rmb-upper'
import { formatOaUploadPathsForPrint } from '@/utils/oa-upload-file-type'
import {
buildFlowItems,
type OaPrintFlowItem,
type OaPrintPersonLine
} from '@/utils/oa-approval-print-flow'
export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
@@ -40,19 +44,8 @@ export type OaPrintFieldRow = {
blocks?: RelatedPrintBlock[]
}
export type OaPrintPersonLine = {
left: string
right: string
}
export type OaPrintFlowItem = {
left: string
right: string
/** @deprecated 多人节点改用 personLines;单人/发起人可保留 */
comment?: string
/** 或签/会签按人行:左侧文案 + 右侧时间(右对齐) */
personLines?: OaPrintPersonLine[]
}
export type { OaPrintPersonLine, OaPrintFlowItem }
export { buildFlowItems }
export type OaPrintModel = {
title: string
@@ -67,20 +60,6 @@ export type OaPrintModel = {
watermarkName: string
}
const STATUS_TEXT: Record<string, string> = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
submitted: '已提交',
not_reached: '未到达',
returned: '已打回',
transferred: '已转交'
}
const UPLOAD_TYPES = new Set([
'upload',
'fcUpload',
@@ -130,10 +109,6 @@ export function resolveStamp(status: string | undefined | null): {
return { stampClass: 'run', stampText: '审批中' }
}
function statusText(s: string) {
return STATUS_TEXT[s] || s || '-'
}
function formatDateOnly(createTime: string | undefined | null): string {
const raw = String(createTime || '').trim()
if (!raw) return '-'
@@ -392,160 +367,6 @@ async function buildFormRows(detail: any): Promise<OaPrintFieldRow[]> {
return out
}
/** 钉钉风格:人名不加状态括号,状态放在整行末尾 */
function peopleDisplayNames(p: any): string[] {
const people = Array.isArray(p?.people) ? p.people : []
const fromPeople = people
.map((x: any) => String(x?.nickName || x?.id || '').trim())
.filter(Boolean)
if (fromPeople.length) return fromPeople
const name = String(p?.assigneeUserName || p?.assigneeUserId || '').trim()
return name ? [name] : []
}
function progressPersonLinesFromItem(p: any): OaPrintPersonLine[] {
const people = Array.isArray(p?.people) ? p.people : []
if (!people.length) return []
return formatProgressPersonLines(people).map((x) => ({
left: x.body || x.text,
right: x.timestamp || ''
}))
}
function isCcProgressItem(p: any): boolean {
const taskType = String(p?.taskType || p?.task_type || '').trim()
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
const title = String(p?.title || p?.nodeName || '').trim()
// 主判据:taskType / signMode;标题兜底仅匹配默认「抄送人」或「抄送…」节点名
return (
taskType === 'cc' ||
signMode === 'cc' ||
title === '抄送人' ||
/^抄送/.test(title)
)
}
/**
* 从 progress 组装审批流程行;抄送节点聚合为「抄送A, B, C」对齐钉钉导出。
* 若 progress 漏抄送,则用 tasks 兜底补一行。
*/
export function buildFlowItems(detail: any): OaPrintFlowItem[] {
const list = Array.isArray(detail?.progress) ? detail.progress : []
const items: OaPrintFlowItem[] = []
// 聚合连续抄送任务,对齐钉钉「抄送A, B, C」
let ccBuf: { names: string[]; ts: string } | null = null
const flushCc = () => {
if (!ccBuf) return
const uniq = Array.from(new Set(ccBuf.names.map((n) => n.trim()).filter(Boolean)))
items.push({
left: uniq.length ? `抄送: ${uniq.join(', ')}` : '抄送:',
right: ccBuf.ts || ''
})
ccBuf = null
}
for (const p of list) {
const kind = String(p.kind || '')
const title = String(p.title || '-').trim()
const names = peopleDisplayNames(p)
const name = names.join(', ')
const ts = String(p.timestamp || '').trim()
const comment = String(p.commentText || '').trim()
const st = statusText(String(p.status || ''))
const signMode = String(p.signMode || p.sign_mode || '')
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
if (isCcProgressItem(p)) {
if (!ccBuf) ccBuf = { names: [], ts }
if (names.length) ccBuf.names.push(...names)
if (ts) ccBuf.ts = ts
continue
}
flushCc()
if (kind === 'submit') {
items.push({
left: name ? `【${title}】 ${name} ${st}` : `【${title}】 ${st}`,
right: ts,
comment: comment || undefined
})
continue
}
if (kind === 'terminal') {
// 打回结果已落在审批人节点,不单独输出终态「已打回」行
if (String(p.status || '') === 'returned' || title === '已打回') {
continue
}
items.push({
left: title || st,
right: ts
})
continue
}
if (kind === 'pending_node') {
const personLines = progressPersonLinesFromItem(p)
const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
items.push({
left: personLines.length
? `${head} ${st}`
: name
? `${head} ${name} ${st}`
: `${head} ${st}`,
right: personLines.length ? '' : ts,
personLines: personLines.length ? personLines : undefined
})
continue
}
// task(或签/会签同节点多人已合并)
const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
const personLines = progressPersonLinesFromItem(p)
if (personLines.length) {
items.push({
left: `${head} ${st}`,
right: '',
personLines
})
continue
}
items.push({
left: name ? `${head} ${name} ${st}` : `${head} ${st}`,
right: ts,
comment: comment || undefined
})
}
flushCc()
// progress 未带抄送时,用 tasks 兜底(对齐钉钉:流程末展示抄送人)
const hasCcLine = items.some((it) => it.left.startsWith('抄送'))
if (!hasCcLine) {
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : []
const ccTasks = tasks.filter((t: any) => {
const tt = String(t?.taskType || t?.task_type || '').trim()
const sm = String(t?.signMode || t?.sign_mode || '').trim()
return tt === 'cc' || sm === 'cc'
})
if (ccTasks.length) {
const names = ccTasks
.map((t: any) =>
String(t?.assigneeUserName || t?.assignee_user_name || t?.assigneeUserId || '').trim()
)
.filter(Boolean)
const uniq = Array.from(new Set(names))
let ts = ''
for (const t of ccTasks) {
const tTs = String(t?.updateTime || t?.createTime || '').trim()
if (tTs && (!ts || tTs > ts)) ts = tTs
}
if (uniq.length) {
items.push({ left: `抄送: ${uniq.join(', ')}`, right: ts })
}
}
}
return items
}
export async function buildOaPrintModel(
detail: any,
opts: { printerName: string; printTime?: string }
@@ -0,0 +1,123 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
buildFlowPreviewRequestKey,
collectConditionFields,
shouldShowFlowPreviewSpinner,
shouldSkipFlowPreviewRequest
} from './oa-flow-preview-refresh'
describe('oa-flow-preview-refresh', () => {
it('ignores unrelated form fields when no condition fields', () => {
const keyA = buildFlowPreviewRequestKey({
formData: { title: 'a', reason: 'x' },
initiatorSelectByNode: {},
conditionFields: []
})
const keyB = buildFlowPreviewRequestKey({
formData: { title: 'b', reason: 'y' },
initiatorSelectByNode: {},
conditionFields: []
})
assert.equal(keyA, keyB)
assert.equal(shouldSkipFlowPreviewRequest(keyA, keyB), true)
})
it('only condition fields affect request key', () => {
const fields = ['amount']
const keyA = buildFlowPreviewRequestKey({
formData: { amount: 100, remark: 'old' },
initiatorSelectByNode: {},
conditionFields: fields
})
const keySame = buildFlowPreviewRequestKey({
formData: { amount: 100, remark: 'new' },
initiatorSelectByNode: {},
conditionFields: fields
})
const keyDiff = buildFlowPreviewRequestKey({
formData: { amount: 200, remark: 'new' },
initiatorSelectByNode: {},
conditionFields: fields
})
assert.equal(keyA, keySame)
assert.notEqual(keyA, keyDiff)
})
it('initiator select changes request key', () => {
const keyA = buildFlowPreviewRequestKey({
formData: {},
initiatorSelectByNode: { n1: ['u1'] },
conditionFields: []
})
const keyB = buildFlowPreviewRequestKey({
formData: {},
initiatorSelectByNode: { n1: ['u2'] },
conditionFields: []
})
assert.notEqual(keyA, keyB)
})
it('collects condition fields from flow tree', () => {
const tree = {
type: 'start',
child: {
type: 'condition',
conditionGroups: [
{
rules: [{ field: 'leaveType', op: 'eq', value: '事假' }],
child: { type: 'approver', id: 'a1' }
},
{
isDefault: true,
child: { type: 'approver', id: 'a2' }
}
],
child: { type: 'end' }
}
}
assert.deepEqual(collectConditionFields(tree), ['leaveType'])
})
it('collects formula fields and skips __initiator__', () => {
assert.deepEqual(
collectConditionFields({
type: 'condition',
conditionGroups: [{ when: '{days} > 3', child: { type: 'approver' } }]
}),
['days']
)
assert.deepEqual(
collectConditionFields({
type: 'condition',
conditionGroups: [
{
rules: [{ field: '__initiator__', op: 'eq', value: 'x' }],
child: { type: 'approver' }
}
]
}),
[]
)
})
it('shows spinner only before first completed preview', () => {
assert.equal(shouldShowFlowPreviewSpinner({ hasCompletedPreview: false }), true)
assert.equal(shouldShowFlowPreviewSpinner({ hasCompletedPreview: true }), false)
})
it('with in-flight key, allows rollback to previous successful key', () => {
const key100 = buildFlowPreviewRequestKey({
formData: { amount: 100 },
initiatorSelectByNode: {},
conditionFields: ['amount']
})
const key200 = buildFlowPreviewRequestKey({
formData: { amount: 200 },
initiatorSelectByNode: {},
conditionFields: ['amount']
})
assert.equal(shouldSkipFlowPreviewRequest(key100, key200, { inFlightKey: key200 }), true)
assert.equal(shouldSkipFlowPreviewRequest(key100, key100, { inFlightKey: key200 }), false)
})
})
@@ -0,0 +1,130 @@
/** 与后端 oa_engine.INITIATOR_CONDITION_FIELD 对齐:不依赖 formData */
const INITIATOR_CONDITION_FIELD = '__initiator__'
const FORMULA_FIELD_RE = /\{([^{}]+)\}/g
function nodeType(node: any): string {
return String((node && (node.type || node.nodeType)) || '').trim()
}
function addField(name: unknown, fields: string[], seen: Set<string>) {
const key = String(name || '').trim()
if (!key || seen.has(key)) return
if (key === INITIATOR_CONDITION_FIELD) return
seen.add(key)
fields.push(key)
}
function addFormulaFields(text: unknown, fields: string[], seen: Set<string>) {
if (!text) return
FORMULA_FIELD_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = FORMULA_FIELD_RE.exec(String(text)))) {
addField(m[1], fields, seen)
}
}
/**
* 收集流程树中非默认条件分支引用的表单字段(去重保序)。
* 语义对齐 backend `oa_engine._collect_condition_fields`。
*/
export function collectConditionFields(node: unknown): string[] {
const fields: string[] = []
const seen = new Set<string>()
function walk(n: any) {
if (!n || typeof n !== 'object') return
if (nodeType(n) === 'condition') {
for (const g of n.conditionGroups || []) {
if (!g || typeof g !== 'object') continue
const rules = g.rules
const when = g.when
if (g.isDefault || (!rules && !when)) {
if (g.child && typeof g.child === 'object') walk(g.child)
continue
}
if (Array.isArray(rules) && rules.length) {
for (const r of rules) {
if (r && typeof r === 'object' && r.field != null) {
addField(r.field, fields, seen)
}
}
} else if (typeof rules === 'string' && rules.trim()) {
addFormulaFields(rules, fields, seen)
} else if (when) {
addFormulaFields(when, fields, seen)
}
if (g.child && typeof g.child === 'object') walk(g.child)
}
if (n.child && typeof n.child === 'object') walk(n.child)
return
}
if (n.child && typeof n.child === 'object') walk(n.child)
for (const g of n.conditionGroups || []) {
if (g && typeof g === 'object' && g.child && typeof g.child === 'object') {
walk(g.child)
}
}
}
walk(node)
return fields
}
function sortKeysDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeysDeep)
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
out[k] = sortKeysDeep((value as Record<string, unknown>)[k])
}
return out
}
return value
}
export function pickRelevantFormData(
formData: Record<string, unknown> | null | undefined,
conditionFields: string[] | null | undefined
): Record<string, unknown> {
const data = formData && typeof formData === 'object' ? formData : {}
const fields = Array.isArray(conditionFields) ? conditionFields : []
const out: Record<string, unknown> = {}
for (const f of fields) {
out[f] = data[f]
}
return out
}
export function buildFlowPreviewRequestKey(opts?: {
formData?: Record<string, unknown> | null
initiatorSelectByNode?: Record<string, string[]> | null
conditionFields?: string[] | null
}): string {
const payload = {
formData: pickRelevantFormData(opts?.formData, opts?.conditionFields),
initiatorSelectByNode:
opts?.initiatorSelectByNode && typeof opts.initiatorSelectByNode === 'object'
? opts.initiatorSelectByNode
: {}
}
return JSON.stringify(sortKeysDeep(payload))
}
export function shouldSkipFlowPreviewRequest(
prevKey: string | null | undefined,
nextKey: string,
opts: { inFlightKey?: string | null } = {}
): boolean {
const inFlightKey = opts.inFlightKey
if (inFlightKey != null) {
return inFlightKey === nextKey
}
return prevKey != null && prevKey === nextKey
}
/** 已有预览结果时静默刷新,避免整区 loading 闪烁 */
export function shouldShowFlowPreviewSpinner(opts?: {
hasCompletedPreview?: boolean
}): boolean {
return !opts?.hasCompletedPreview
}
@@ -0,0 +1,28 @@
/**
* OA 进度人员行:cancelled(撤销/打回清待办)对齐或签 done →「无需处理」
* Spec: docs/superpowers/specs/2026-08-13-oa-progress-withdraw-cancelled-display-design.md
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { formatProgressPersonLines } from './oa-progress-person-lines'
describe('formatProgressPersonLines · cancelled', () => {
it('maps cancelled to 无需处理 (mute, no timestamp)', () => {
const lines = formatProgressPersonLines([
{
nickName: '张主管',
status: 'cancelled',
commentText: '',
timestamp: '2026-08-13 12:47:46'
}
])
assert.deepEqual(lines, [
{
body: '张主管 无需处理',
timestamp: '',
text: '张主管 无需处理',
tone: 'mute'
}
])
})
})
@@ -52,8 +52,8 @@ function toneForStatus(status: string): PersonLineTone {
const s = (status || '').trim()
if (s === 'approved' || s === 'completed') return 'ok'
if (s === 'pending' || s === 'running' || s === 'not_reached') return 'pending'
if (s === 'rejected' || s === 'returned' || s === 'cancelled') return 'danger'
if (s === 'done' || s === 'transferred') return 'mute'
if (s === 'rejected' || s === 'returned') return 'danger'
if (s === 'done' || s === 'transferred' || s === 'cancelled') return 'mute'
return ''
}
@@ -86,7 +86,7 @@ export function formatProgressPersonLines(
const ts = String(p.timestamp || '').trim()
const tone = toneForStatus(status)
if (status === 'done') {
if (status === 'done' || status === 'cancelled') {
pushLine(out, `${name} 无需处理`, '', 'mute')
continue
}