项目里程碑初步优化
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ pyrightconfig.json
|
||||
|
||||
# Node/Vite frontend
|
||||
frontend/node_modules/
|
||||
#frontend/dist/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
+177
-120
@@ -1,11 +1,10 @@
|
||||
import uuid
|
||||
import re
|
||||
from io import BytesIO
|
||||
from datetime import date
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import (
|
||||
@@ -22,7 +21,6 @@ from app.crud import audit as audit_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study_setup_config as study_setup_config_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.services.setup_config_excel import export_setup_config_excel, import_setup_config_excel
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
||||
from app.schemas.member import StudyMemberCreate
|
||||
@@ -81,6 +79,7 @@ def _validate_setup_data(
|
||||
*,
|
||||
project_plan_start: date | None = None,
|
||||
project_plan_end: date | None = None,
|
||||
strict_required: bool = True,
|
||||
) -> None:
|
||||
errors: list[dict[str, str]] = []
|
||||
allowed_milestone_status = {"未开始", "进行中", "已完成", "延期"}
|
||||
@@ -96,13 +95,24 @@ def _validate_setup_data(
|
||||
|
||||
for index, row in enumerate(payload.projectMilestones):
|
||||
row_prefix = f"projectMilestones[{index}]"
|
||||
start_text = (row.startDate or row.planDate or "").strip()
|
||||
end_text = (row.endDate or row.planDate or "").strip()
|
||||
if not row.id:
|
||||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||||
if _is_empty_row([row.name, row.owner, row.remark, row.planDate]):
|
||||
if _is_empty_row([row.name, row.owner, row.remark, start_text, end_text]):
|
||||
continue
|
||||
if not row.name.strip():
|
||||
errors.append({"field": f"{row_prefix}.name", "message": "里程碑名称不能为空"})
|
||||
_parse_date_str(row.planDate, f"{row_prefix}.planDate", errors)
|
||||
start = _parse_date_str(start_text, f"{row_prefix}.startDate", errors)
|
||||
end = _parse_date_str(end_text, f"{row_prefix}.endDate", errors)
|
||||
if not start_text:
|
||||
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写开始日期"})
|
||||
if not end_text:
|
||||
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写结束日期"})
|
||||
if start and end and start > end:
|
||||
errors.append({"field": f"{row_prefix}.endDate", "message": "结束日期不能早于开始日期"})
|
||||
if row.durationDays < 1:
|
||||
errors.append({"field": f"{row_prefix}.durationDays", "message": "耗时天数不能小于1"})
|
||||
if row.status not in allowed_milestone_status:
|
||||
errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"})
|
||||
|
||||
@@ -111,9 +121,9 @@ def _validate_setup_data(
|
||||
errors.append({"field": "enrollmentPlan.totalTarget", "message": "计划总入组例数不能小于0"})
|
||||
enrollment_plan_start = _parse_date_str(plan.startDate, "enrollmentPlan.startDate", errors)
|
||||
enrollment_plan_end = _parse_date_str(plan.endDate, "enrollmentPlan.endDate", errors)
|
||||
if not plan.startDate:
|
||||
if strict_required and not plan.startDate:
|
||||
errors.append({"field": "enrollmentPlan.startDate", "message": "请填写计划开始日期"})
|
||||
if not plan.endDate:
|
||||
if strict_required and not plan.endDate:
|
||||
errors.append({"field": "enrollmentPlan.endDate", "message": "请填写计划结束日期"})
|
||||
if enrollment_plan_start and enrollment_plan_end and enrollment_plan_start > enrollment_plan_end:
|
||||
errors.append({"field": "enrollmentPlan.endDate", "message": "结束日期不能早于开始日期"})
|
||||
@@ -164,9 +174,9 @@ def _validate_setup_data(
|
||||
total_site_target += max(row.target, 0)
|
||||
start = _parse_date_str(row.startDate, f"{row_prefix}.startDate", errors)
|
||||
end = _parse_date_str(row.endDate, f"{row_prefix}.endDate", errors)
|
||||
if not row.startDate:
|
||||
if strict_required and not row.startDate:
|
||||
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写启动日期"})
|
||||
if not row.endDate:
|
||||
if strict_required and not row.endDate:
|
||||
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写完成日期"})
|
||||
if start and end and start > end:
|
||||
errors.append({"field": f"{row_prefix}.endDate", "message": "完成日期不能早于启动日期"})
|
||||
@@ -279,17 +289,18 @@ def _build_projection_audit_detail(
|
||||
warnings: list[str],
|
||||
skipped_items: list[dict[str, str]],
|
||||
) -> str:
|
||||
warning_part = " | ".join(warnings[:5]) if warnings else "-"
|
||||
warning_part = ";".join(warnings[:5]) if warnings else "无"
|
||||
reason_counter = Counter(item.get("reason") for item in skipped_items if item.get("reason"))
|
||||
reason_summary = ", ".join(f"{reason}:{count}" for reason, count in sorted(reason_counter.items())) if reason_counter else "-"
|
||||
reason_summary = ";".join(f"{reason} {count}条" for reason, count in sorted(reason_counter.items())) if reason_counter else "无"
|
||||
projection_status_label = "成功" if projection_status == "success" else "失败" if projection_status == "failed" else projection_status
|
||||
return (
|
||||
"立项配置已发布"
|
||||
f"; projection_status={projection_status}"
|
||||
f"; study_updated={study_updated}"
|
||||
f"; site_updated={site_updated_count}"
|
||||
f"; site_skipped={site_skipped_count}"
|
||||
f"; warnings={warning_part}"
|
||||
f"; skipped_reason_counts={reason_summary}"
|
||||
f"; 发布同步结果:{projection_status_label}"
|
||||
f"; 已同步项目主信息:{'是' if study_updated else '否'}"
|
||||
f"; 已同步中心数:{site_updated_count}"
|
||||
f"; 未同步中心数:{site_skipped_count}"
|
||||
f"; 同步告警:{warning_part}"
|
||||
f"; 未同步原因统计:{reason_summary}"
|
||||
)
|
||||
|
||||
|
||||
@@ -326,14 +337,145 @@ def _to_setup_config_version_read(record, *, published_by_name: str | None = Non
|
||||
)
|
||||
|
||||
|
||||
_SETUP_MODULE_KEYS = (
|
||||
"projectMilestones",
|
||||
"enrollmentPlan",
|
||||
"siteMilestones",
|
||||
"siteEnrollmentPlans",
|
||||
"monitoringStrategies",
|
||||
"centerConfirm",
|
||||
)
|
||||
|
||||
_SETUP_MODULE_LABELS = {
|
||||
"projectMilestones": "项目里程碑",
|
||||
"enrollmentPlan": "项目入组计划",
|
||||
"siteMilestones": "中心里程碑",
|
||||
"siteEnrollmentPlans": "中心入组计划",
|
||||
"monitoringStrategies": "监查策略",
|
||||
"centerConfirm": "中心确认",
|
||||
}
|
||||
|
||||
_SETUP_FIELD_LABELS = {
|
||||
"id": "ID",
|
||||
"name": "名称",
|
||||
"milestone": "里程碑",
|
||||
"planDate": "计划日期",
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"durationDays": "耗时(天)",
|
||||
"owner": "负责人",
|
||||
"remark": "备注",
|
||||
"status": "状态",
|
||||
"totalTarget": "计划总入组例数",
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"monthlyGoalNote": "月度目标说明",
|
||||
"stageBreakdown": "分阶段计划",
|
||||
"siteId": "中心ID",
|
||||
"siteName": "中心名称",
|
||||
"target": "计划例数",
|
||||
"note": "备注",
|
||||
"strategyType": "监查类型",
|
||||
"detail": "策略详情",
|
||||
"frequency": "监查频次",
|
||||
"updatedAt": "更新时间",
|
||||
"enabled": "启用状态",
|
||||
"confirmer": "确认人",
|
||||
"confirmStatus": "确认状态",
|
||||
"confirmDate": "确认日期",
|
||||
}
|
||||
|
||||
|
||||
def _setup_value_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return "未填写"
|
||||
if isinstance(value, bool):
|
||||
return "是" if value else "否"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return "未填写"
|
||||
if len(text) > 80:
|
||||
return f"{text[:80]}..."
|
||||
return text
|
||||
if isinstance(value, list):
|
||||
return f"已配置列表({len(value)}项)"
|
||||
if isinstance(value, dict):
|
||||
return "已配置内容"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _setup_row_identity(module_key: str, row: Any, index: int) -> str:
|
||||
if not isinstance(row, dict):
|
||||
return f"第{index + 1}行"
|
||||
if module_key == "projectMilestones":
|
||||
return str(row.get("name") or "").strip() or f"第{index + 1}行"
|
||||
if module_key == "siteMilestones":
|
||||
return str(row.get("milestone") or "").strip() or f"第{index + 1}行"
|
||||
if module_key in {"siteEnrollmentPlans", "centerConfirm"}:
|
||||
return str(row.get("siteName") or row.get("siteId") or "").strip() or f"第{index + 1}行"
|
||||
if module_key == "monitoringStrategies":
|
||||
return str(row.get("strategyType") or "").strip() or f"第{index + 1}行"
|
||||
return f"第{index + 1}行"
|
||||
|
||||
|
||||
def _setup_collect_diff_lines(
|
||||
module_key: str,
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
path: list[str],
|
||||
lines: list[str],
|
||||
) -> None:
|
||||
if isinstance(old_value, list) or isinstance(new_value, list):
|
||||
old_list = old_value if isinstance(old_value, list) else []
|
||||
new_list = new_value if isinstance(new_value, list) else []
|
||||
for index in range(max(len(old_list), len(new_list))):
|
||||
old_item = old_list[index] if index < len(old_list) else None
|
||||
new_item = new_list[index] if index < len(new_list) else None
|
||||
identity = _setup_row_identity(module_key, new_item if new_item is not None else old_item, index)
|
||||
_setup_collect_diff_lines(module_key, old_item, new_item, [*path, identity], lines)
|
||||
return
|
||||
|
||||
if isinstance(old_value, dict) or isinstance(new_value, dict):
|
||||
old_dict = old_value if isinstance(old_value, dict) else {}
|
||||
new_dict = new_value if isinstance(new_value, dict) else {}
|
||||
keys = sorted(set(old_dict.keys()) | set(new_dict.keys()))
|
||||
for key in keys:
|
||||
_setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines)
|
||||
return
|
||||
|
||||
if old_value == new_value:
|
||||
return
|
||||
|
||||
module_label = _SETUP_MODULE_LABELS.get(module_key, module_key)
|
||||
readable_path = " / ".join(_SETUP_FIELD_LABELS.get(part, part) for part in path if part)
|
||||
field_text = f"{module_label} / {readable_path}" if readable_path else module_label
|
||||
lines.append(f"{field_text}:{_setup_value_text(old_value)} -> {_setup_value_text(new_value)}")
|
||||
|
||||
|
||||
def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
|
||||
old = old or {}
|
||||
new = new or {}
|
||||
changed = []
|
||||
for key in ("projectMilestones", "enrollmentPlan", "siteMilestones", "siteEnrollmentPlans", "monitoringStrategies", "centerConfirm"):
|
||||
if old.get(key) != new.get(key):
|
||||
changed.append(key)
|
||||
return "变更模块:" + (", ".join(changed) if changed else "无")
|
||||
changed_modules: list[str] = []
|
||||
detail_lines: list[str] = []
|
||||
|
||||
for module_key in _SETUP_MODULE_KEYS:
|
||||
if old.get(module_key) == new.get(module_key):
|
||||
continue
|
||||
changed_modules.append(_SETUP_MODULE_LABELS.get(module_key, module_key))
|
||||
_setup_collect_diff_lines(module_key, old.get(module_key), new.get(module_key), [], detail_lines)
|
||||
|
||||
module_summary = "变更模块:" + ("、".join(changed_modules) if changed_modules else "无")
|
||||
if not detail_lines:
|
||||
return module_summary
|
||||
|
||||
max_lines = 20
|
||||
visible_lines = detail_lines[:max_lines]
|
||||
if len(detail_lines) > max_lines:
|
||||
visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略")
|
||||
return f"{module_summary}; 变更明细:" + ";".join(visible_lines)
|
||||
|
||||
|
||||
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
|
||||
@@ -634,16 +776,18 @@ async def upsert_study_setup_config(
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置保存失败")
|
||||
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_setup_config",
|
||||
entity_id=record.id,
|
||||
action="UPDATE_SETUP_CONFIG",
|
||||
detail=_top_level_diff_summary(old_config, record.config),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
has_setup_diff = old_config != (record.config or {})
|
||||
if has_setup_diff:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_setup_config",
|
||||
entity_id=record.id,
|
||||
action="UPDATE_SETUP_CONFIG",
|
||||
detail=_top_level_diff_summary(old_config, record.config),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
saved_by_name = current_user.full_name or current_user.username or current_user.email
|
||||
published_by_name = None
|
||||
@@ -861,90 +1005,3 @@ async def delete_study_setup_config_version(
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{study_id}/setup-config/export-excel",
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def export_study_setup_config_excel(
|
||||
study_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StreamingResponse:
|
||||
config = await get_study_setup_config(study_id=study_id, db=db, current_user=current_user)
|
||||
study = await study_crud.get(db, study_id)
|
||||
file_bytes = export_setup_config_excel(config.data)
|
||||
filename = f"setup-config-{study.code if study else study_id}.xlsx"
|
||||
return StreamingResponse(
|
||||
BytesIO(file_bytes),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{study_id}/setup-config/import-excel",
|
||||
response_model=StudySetupConfigRead,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def import_study_setup_config_excel(
|
||||
study_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
expected_version: int | None = Form(default=None),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StudySetupConfigRead:
|
||||
if not (file.filename or "").lower().endswith(".xlsx"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式文件")
|
||||
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
|
||||
try:
|
||||
payload_data = import_setup_config_excel(await file.read())
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel解析失败,请检查模板格式")
|
||||
|
||||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
|
||||
site_lookup = {str(site.id): site.name or "" for site in sites}
|
||||
_validate_setup_data(
|
||||
payload_data,
|
||||
site_lookup,
|
||||
project_plan_start=getattr(study, "plan_start_date", None),
|
||||
project_plan_end=getattr(study, "plan_end_date", None),
|
||||
)
|
||||
|
||||
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
old_config = dict(existing.config or {}) if existing else {}
|
||||
record, conflict = await study_setup_config_crud.upsert(
|
||||
db,
|
||||
study_id,
|
||||
expected_version=expected_version,
|
||||
data=payload_data,
|
||||
saved_by=current_user.id,
|
||||
)
|
||||
if conflict:
|
||||
_raise_conflict_error()
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置导入失败")
|
||||
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_setup_config",
|
||||
entity_id=record.id,
|
||||
action="IMPORT_SETUP_CONFIG_EXCEL",
|
||||
detail=_top_level_diff_summary(old_config, record.config),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
saved_by_name = current_user.full_name or current_user.username or current_user.email
|
||||
published_by_name = None
|
||||
if record.published_by:
|
||||
user = await user_crud.get_by_id(db, record.published_by)
|
||||
if user:
|
||||
published_by_name = user.full_name or user.username or user.email
|
||||
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name)
|
||||
|
||||
@@ -13,6 +13,9 @@ class ProjectMilestoneItem(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
planDate: str = ""
|
||||
startDate: str = ""
|
||||
endDate: str = ""
|
||||
durationDays: int = 1
|
||||
owner: str = ""
|
||||
remark: str = ""
|
||||
status: MilestoneStatus = "未开始"
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from io import BytesIO
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
from app.schemas.study_setup_config import StudySetupConfigData
|
||||
|
||||
|
||||
def _make_row_id() -> str:
|
||||
return f"{int(datetime.now().timestamp() * 1000)}_{random.randint(1000, 9999)}"
|
||||
|
||||
|
||||
def _is_empty(*values: Any) -> bool:
|
||||
return all(v is None or str(v).strip() == "" for v in values)
|
||||
|
||||
|
||||
def _sheet_headers(sheet, headers: list[str]) -> None:
|
||||
sheet.append(headers)
|
||||
for idx, title in enumerate(headers, start=1):
|
||||
cell = sheet.cell(row=1, column=idx)
|
||||
cell.value = title
|
||||
|
||||
|
||||
def export_setup_config_excel(data: StudySetupConfigData) -> bytes:
|
||||
wb = Workbook()
|
||||
|
||||
ws_guide = wb.active
|
||||
ws_guide.title = "说明"
|
||||
ws_guide.append(["立项配置Excel模板说明"])
|
||||
ws_guide.append(["1. 各业务Sheet首行是表头,不要修改列名。"])
|
||||
ws_guide.append(["2. 可在现有行基础上新增/删除行。"])
|
||||
ws_guide.append(["3. 日期格式建议:YYYY-MM-DD。"])
|
||||
ws_guide.append(["4. 导入后系统会自动校验并回填到立项配置草稿。"])
|
||||
|
||||
ws_pm = wb.create_sheet("项目里程碑")
|
||||
_sheet_headers(ws_pm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"])
|
||||
for row in data.projectMilestones:
|
||||
ws_pm.append([row.id, row.name, row.planDate, row.owner, row.status, row.remark])
|
||||
|
||||
ws_plan = wb.create_sheet("项目入组计划")
|
||||
_sheet_headers(ws_plan, ["计划总入组例数", "计划开始日期", "计划结束日期", "月度目标说明", "分阶段计划"])
|
||||
p = data.enrollmentPlan
|
||||
ws_plan.append([p.totalTarget, p.startDate, p.endDate, p.monthlyGoalNote, p.stageBreakdown])
|
||||
|
||||
ws_sm = wb.create_sheet("中心里程碑")
|
||||
_sheet_headers(ws_sm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"])
|
||||
for row in data.siteMilestones:
|
||||
ws_sm.append([row.id, row.milestone, row.planDate, row.owner, row.status, row.remark])
|
||||
|
||||
ws_sep = wb.create_sheet("中心入组计划")
|
||||
_sheet_headers(ws_sep, ["ID", "中心ID", "中心名称", "计划例数", "启动日期", "完成日期", "备注", "分阶段计划"])
|
||||
for row in data.siteEnrollmentPlans:
|
||||
ws_sep.append([row.id, row.siteId, row.siteName, row.target, row.startDate, row.endDate, row.note, row.stageBreakdown])
|
||||
|
||||
ws_ms = wb.create_sheet("监查计划策略")
|
||||
_sheet_headers(ws_ms, ["ID", "监查类型", "策略详情", "监查次数", "更新时间", "是否启用"])
|
||||
for row in data.monitoringStrategies:
|
||||
ws_ms.append([row.id, row.strategyType, row.detail, row.frequency, row.updatedAt, "是" if row.enabled else "否"])
|
||||
|
||||
ws_cc = wb.create_sheet("中心确认")
|
||||
_sheet_headers(ws_cc, ["ID", "中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"])
|
||||
for row in data.centerConfirm:
|
||||
ws_cc.append([row.id, row.siteId, row.siteName, row.confirmer, row.confirmStatus, row.confirmDate, row.note])
|
||||
|
||||
bio = BytesIO()
|
||||
wb.save(bio)
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def _to_int(value: Any, default: int = 0) -> int:
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
try:
|
||||
return int(float(str(value)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _to_date_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.date().isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _to_datetime_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _to_bool(value: Any, default: bool = True) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
v = str(value).strip().lower()
|
||||
if v in {"1", "true", "yes", "y", "是", "启用"}:
|
||||
return True
|
||||
if v in {"0", "false", "no", "n", "否", "禁用"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def import_setup_config_excel(file_bytes: bytes) -> StudySetupConfigData:
|
||||
wb = load_workbook(filename=BytesIO(file_bytes), data_only=True)
|
||||
|
||||
project_milestones = []
|
||||
ws = wb["项目里程碑"] if "项目里程碑" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, name, plan_date, owner, status, remark = row[:6]
|
||||
if _is_empty(rid, name, plan_date, owner, status, remark):
|
||||
continue
|
||||
project_milestones.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"name": str(name or "").strip(),
|
||||
"planDate": _to_date_str(plan_date),
|
||||
"owner": str(owner or "").strip(),
|
||||
"status": str(status or "未开始").strip() or "未开始",
|
||||
"remark": str(remark or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
enrollment_plan = {
|
||||
"totalTarget": 0,
|
||||
"startDate": "",
|
||||
"endDate": "",
|
||||
"monthlyGoalNote": "",
|
||||
"stageBreakdown": "",
|
||||
}
|
||||
ws = wb["项目入组计划"] if "项目入组计划" in wb.sheetnames else None
|
||||
if ws:
|
||||
first = next(ws.iter_rows(min_row=2, values_only=True), None)
|
||||
if first:
|
||||
enrollment_plan = {
|
||||
"totalTarget": _to_int(first[0], 0),
|
||||
"startDate": _to_date_str(first[1]),
|
||||
"endDate": _to_date_str(first[2]),
|
||||
"monthlyGoalNote": str(first[3] or "").strip(),
|
||||
"stageBreakdown": str(first[4] or "").strip(),
|
||||
}
|
||||
|
||||
site_milestones = []
|
||||
ws = wb["中心里程碑"] if "中心里程碑" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
raw_status = None
|
||||
if len(row) >= 8:
|
||||
rid, site_id, site_name, milestone, plan_date, owner, raw_status, remark = row[:8]
|
||||
_ = (site_id, site_name)
|
||||
elif len(row) == 7:
|
||||
# Backward compatibility: old template has no status column and includes center columns.
|
||||
rid, site_id, site_name, milestone, plan_date, owner, remark = row[:7]
|
||||
_ = (site_id, site_name)
|
||||
else:
|
||||
rid, milestone, plan_date, owner, raw_status, remark = row[:6]
|
||||
if _is_empty(rid, milestone, plan_date, owner, raw_status, remark):
|
||||
continue
|
||||
status = str(raw_status or "未开始").strip() or "未开始"
|
||||
site_milestones.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"milestone": str(milestone or "").strip(),
|
||||
"planDate": _to_date_str(plan_date),
|
||||
"owner": str(owner or "").strip(),
|
||||
"status": status,
|
||||
"remark": str(remark or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
site_enrollment_plans = []
|
||||
ws = wb["中心入组计划"] if "中心入组计划" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown = (list(row[:8]) + [None] * 8)[:8]
|
||||
if _is_empty(rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown):
|
||||
continue
|
||||
site_enrollment_plans.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"siteId": str(site_id or "").strip(),
|
||||
"siteName": str(site_name or "").strip(),
|
||||
"target": _to_int(target, 0),
|
||||
"startDate": _to_date_str(start_date),
|
||||
"endDate": _to_date_str(end_date),
|
||||
"note": str(note or "").strip(),
|
||||
"stageBreakdown": str(stage_breakdown or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
monitoring_strategies = []
|
||||
ws = wb["监查计划策略"] if "监查计划策略" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, strategy_type, detail, frequency, updated_at, enabled = row[:6]
|
||||
if _is_empty(rid, strategy_type, detail, frequency, updated_at, enabled):
|
||||
continue
|
||||
monitoring_strategies.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"strategyType": str(strategy_type or "").strip(),
|
||||
"detail": str(detail or "").strip(),
|
||||
"frequency": str(frequency or "").strip(),
|
||||
"updatedAt": _to_datetime_str(updated_at) or datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"enabled": _to_bool(enabled, True),
|
||||
}
|
||||
)
|
||||
|
||||
center_confirm = []
|
||||
ws = wb["中心确认"] if "中心确认" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, site_id, site_name, confirmer, confirm_status, confirm_date, note = row[:7]
|
||||
if _is_empty(rid, site_id, site_name, confirmer, confirm_status, confirm_date, note):
|
||||
continue
|
||||
center_confirm.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"siteId": str(site_id or "").strip(),
|
||||
"siteName": str(site_name or "").strip(),
|
||||
"confirmer": str(confirmer or "").strip(),
|
||||
"confirmStatus": str(confirm_status or "待确认").strip() or "待确认",
|
||||
"confirmDate": _to_date_str(confirm_date),
|
||||
"note": str(note or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
return StudySetupConfigData.model_validate(
|
||||
{
|
||||
"projectMilestones": project_milestones,
|
||||
"enrollmentPlan": enrollment_plan,
|
||||
"siteMilestones": site_milestones,
|
||||
"siteEnrollmentPlans": site_enrollment_plans,
|
||||
"monitoringStrategies": monitoring_strategies,
|
||||
"centerConfirm": center_confirm,
|
||||
}
|
||||
)
|
||||
@@ -121,14 +121,15 @@ async def _replace_project_milestones(
|
||||
)
|
||||
)
|
||||
for row in setup_data.projectMilestones:
|
||||
if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), (row.planDate or "").strip()]):
|
||||
start_text = (row.startDate or row.planDate or "").strip()
|
||||
if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), start_text]):
|
||||
continue
|
||||
name = (row.name or "").strip()
|
||||
if not name:
|
||||
result.warnings.append(f"project_milestone_name_empty:{row.id}")
|
||||
continue
|
||||
planned_date = _parse_date(row.planDate)
|
||||
if row.planDate and not planned_date:
|
||||
planned_date = _parse_date(start_text)
|
||||
if start_text and not planned_date:
|
||||
result.warnings.append(f"project_milestone_plan_date_invalid:{row.id}")
|
||||
raw_status = (row.status or "").strip()
|
||||
mapped_status = MILESTONE_STATUS_MAP.get(raw_status, "NOT_STARTED")
|
||||
|
||||
@@ -2,10 +2,8 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
|
||||
@@ -40,68 +38,6 @@ def request_json(path: str, *, method: str = "GET", token: str | None = None, pa
|
||||
return exc.code, body_json
|
||||
|
||||
|
||||
def request_bytes(path: str, *, method: str = "GET", token: str | None = None) -> tuple[int, bytes]:
|
||||
url = f"{BASE}{path}"
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.getcode(), resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
|
||||
|
||||
def request_multipart(
|
||||
path: str,
|
||||
*,
|
||||
token: str,
|
||||
file_field: str,
|
||||
file_name: str,
|
||||
file_content: bytes,
|
||||
expected_version: int,
|
||||
) -> tuple[int, dict]:
|
||||
boundary = f"----ctms-boundary-{uuid.uuid4().hex}"
|
||||
chunks: list[bytes] = []
|
||||
chunks.append(
|
||||
(
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="expected_version"\r\n\r\n'
|
||||
f"{expected_version}\r\n"
|
||||
).encode("utf-8")
|
||||
)
|
||||
chunks.append(
|
||||
(
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="{file_field}"; filename="{file_name}"\r\n'
|
||||
f"Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
)
|
||||
chunks.append(file_content)
|
||||
chunks.append(b"\r\n")
|
||||
chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
|
||||
body = b"".join(chunks)
|
||||
|
||||
url = f"{BASE}{path}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
}
|
||||
req = urllib.request.Request(url, method="POST", headers=headers, data=body)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
text = resp.read().decode() or "{}"
|
||||
return resp.getcode(), json.loads(text)
|
||||
except urllib.error.HTTPError as exc:
|
||||
text = exc.read().decode() or "{}"
|
||||
try:
|
||||
body_json = json.loads(text)
|
||||
except Exception:
|
||||
body_json = {"raw": text}
|
||||
return exc.code, body_json
|
||||
|
||||
|
||||
def assert_or_exit(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
print(f"[FAIL] {message}")
|
||||
@@ -440,24 +376,6 @@ def main() -> int:
|
||||
)
|
||||
print("[9/12] rollback_ok")
|
||||
|
||||
status, exported_bytes = request_bytes(f"/api/v1/studies/{study_id}/setup-config/export-excel", token=token)
|
||||
assert_or_exit(status == 200 and len(exported_bytes) > 0, f"导出Excel失败 status={status} bytes={len(exported_bytes)}")
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||
tmp.write(exported_bytes)
|
||||
tmp_path = tmp.name
|
||||
print(f"[10/12] export_excel_ok file={tmp_path}")
|
||||
|
||||
status, imported = request_multipart(
|
||||
f"/api/v1/studies/{study_id}/setup-config/import-excel",
|
||||
token=token,
|
||||
file_field="file",
|
||||
file_name="setup-config-smoke.xlsx",
|
||||
file_content=exported_bytes,
|
||||
expected_version=rolled["version"],
|
||||
)
|
||||
assert_or_exit(status == 200 and isinstance(imported.get("version"), int), f"导入失败 status={status} body={imported}")
|
||||
print(f"[11/12] import_excel_ok version={imported['version']}")
|
||||
|
||||
status, _ = request_json(f"/api/v1/studies/{study_id}/lock", token=token, method="PATCH", payload={})
|
||||
assert_or_exit(status == 200, f"锁定失败 status={status}")
|
||||
try:
|
||||
@@ -465,10 +383,10 @@ def main() -> int:
|
||||
f"/api/v1/studies/{study_id}/setup-config",
|
||||
token=token,
|
||||
method="PUT",
|
||||
payload={"expected_version": imported["version"], "data": imported["data"]},
|
||||
payload={"expected_version": rolled["version"], "data": rolled["data"]},
|
||||
)
|
||||
assert_or_exit(status == 403, f"锁定后写入应403,实际 status={status} body={locked_result}")
|
||||
print("[12/12] lock_403_check_ok")
|
||||
print("[10/10] lock_403_check_ok")
|
||||
finally:
|
||||
unlock_status, unlock_body = request_json(f"/api/v1/studies/{study_id}/unlock", token=token, method="PATCH", payload={})
|
||||
assert_or_exit(unlock_status == 200, f"解锁失败 status={unlock_status} body={unlock_body}")
|
||||
|
||||
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>CTMS</title>
|
||||
<script type="module" crossorigin src="/assets/index-DNEAoWP5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-J1iXJzyy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
+14
-11
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>CTMS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>CTMS</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import router from "../router";
|
||||
import { getToken } from "../utils/auth";
|
||||
@@ -27,7 +27,7 @@ class LockedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
instance.interceptors.request.use((config: ApiRequestConfig) => {
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
|
||||
const session = useSessionStore();
|
||||
const reqUrl = config.url || "";
|
||||
const allowWhileLocked =
|
||||
@@ -41,7 +41,7 @@ instance.interceptors.request.use((config: ApiRequestConfig) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (!config.ignoreIdle) {
|
||||
markNetworkActive();
|
||||
@@ -114,14 +114,14 @@ instance.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
export const apiGet = <T = unknown>(url: string, config?: ApiRequestConfig) => instance.get<T>(url, config);
|
||||
export const apiPost = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
export const apiGet = <T = any>(url: string, config?: ApiRequestConfig) => instance.get<T>(url, config);
|
||||
export const apiPost = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.post<T>(url, data, config);
|
||||
export const apiPatch = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
export const apiPatch = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.patch<T>(url, data, config);
|
||||
export const apiPut = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
export const apiPut = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.put<T>(url, data, config);
|
||||
export const apiDelete = <T = unknown>(url: string, config?: ApiRequestConfig) =>
|
||||
export const apiDelete = <T = any>(url: string, config?: ApiRequestConfig) =>
|
||||
instance.delete<T>(url, config);
|
||||
|
||||
export default instance;
|
||||
|
||||
@@ -42,9 +42,3 @@ export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: nu
|
||||
|
||||
export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const exportStudySetupConfigExcel = (studyId: string) =>
|
||||
apiGet<Blob>(`/api/v1/studies/${studyId}/setup-config/export-excel`, { responseType: "blob", suppressErrorMessage: true });
|
||||
|
||||
export const importStudySetupConfigExcel = (studyId: string, payload: FormData) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/import-excel`, payload, { suppressErrorMessage: true });
|
||||
|
||||
+184
-16
@@ -25,27 +25,184 @@ export interface AuditEvent {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>) => {
|
||||
if (!before || !after) return [];
|
||||
const lines: string[] = [];
|
||||
Object.keys({ ...before, ...after }).forEach((key) => {
|
||||
const prev = before[key];
|
||||
const next = after[key];
|
||||
if (prev === next) return;
|
||||
const entityTypeLabelMap: Record<string, string> = {
|
||||
study_setup_config: "立项配置",
|
||||
study: "项目",
|
||||
subject: "参与者",
|
||||
visit: "访视",
|
||||
ae: "不良事件",
|
||||
finance_contract: "费用合同",
|
||||
finance_special: "费用特殊费用",
|
||||
contract_fee: "合同费用条目",
|
||||
contract_fee_payment: "合同费用回款",
|
||||
special_expense: "特殊费用条目",
|
||||
drug_shipment: "药品流向",
|
||||
startup_feasibility: "立项可行性",
|
||||
startup_ethics: "立项伦理",
|
||||
startup_kickoff: "启动会",
|
||||
training_authorization: "培训授权",
|
||||
knowledge_note: "注意事项",
|
||||
subject_history: "参与者历史",
|
||||
faq_category: "医学咨询分类",
|
||||
faq_item: "医学咨询问题",
|
||||
faq_reply: "医学咨询回复",
|
||||
DOCUMENT: "文档",
|
||||
DOCUMENT_VERSION: "文档版本",
|
||||
DISTRIBUTION: "文档分发",
|
||||
ACKNOWLEDGEMENT: "文档签收",
|
||||
};
|
||||
|
||||
const setupModuleLabelMap: Record<string, string> = {
|
||||
projectMilestones: "项目里程碑",
|
||||
enrollmentPlan: "项目入组计划",
|
||||
siteMilestones: "中心里程碑",
|
||||
siteEnrollmentPlans: "中心入组计划",
|
||||
monitoringStrategies: "监查策略",
|
||||
centerConfirm: "中心确认",
|
||||
};
|
||||
|
||||
const toReadableEntityType = (value?: string): string => {
|
||||
const key = String(value || "").trim();
|
||||
if (!key) return TEXT.audit.targetFallback;
|
||||
return entityTypeLabelMap[key] || key;
|
||||
};
|
||||
|
||||
const normalizeSetupModuleText = (raw: string): string => {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) return "无";
|
||||
return text
|
||||
.split(/[,,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => setupModuleLabelMap[item] || item)
|
||||
.join("、") || "无";
|
||||
};
|
||||
|
||||
const normalizeLegacyDetailLine = (line: string): string => {
|
||||
const text = String(line || "").trim();
|
||||
if (!text) return "";
|
||||
const projectionStatusMatch = text.match(/^projection_status=(.+)$/i);
|
||||
if (projectionStatusMatch) {
|
||||
const status = projectionStatusMatch[1].trim().toLowerCase();
|
||||
const statusLabel = status === "success" ? "成功" : status === "failed" ? "失败" : status;
|
||||
return `发布同步结果:${statusLabel}`;
|
||||
}
|
||||
const studyUpdatedMatch = text.match(/^study_updated=(.+)$/i);
|
||||
if (studyUpdatedMatch) {
|
||||
const rawValue = studyUpdatedMatch[1].trim().toLowerCase();
|
||||
return `已同步项目主信息:${rawValue === "true" ? "是" : rawValue === "false" ? "否" : rawValue}`;
|
||||
}
|
||||
const siteUpdatedMatch = text.match(/^site_updated=(\d+)$/i);
|
||||
if (siteUpdatedMatch) return `已同步中心数:${siteUpdatedMatch[1]}`;
|
||||
const siteSkippedMatch = text.match(/^site_skipped=(\d+)$/i);
|
||||
if (siteSkippedMatch) return `未同步中心数:${siteSkippedMatch[1]}`;
|
||||
const warningsMatch = text.match(/^warnings=(.*)$/i);
|
||||
if (warningsMatch) {
|
||||
const value = warningsMatch[1].trim();
|
||||
return `同步告警:${value === "-" ? "无" : value}`;
|
||||
}
|
||||
const skippedReasonMatch = text.match(/^skipped_reason_counts=(.*)$/i);
|
||||
if (skippedReasonMatch) {
|
||||
const value = skippedReasonMatch[1].trim();
|
||||
return `未同步原因统计:${value === "-" ? "无" : value}`;
|
||||
}
|
||||
const setupModuleMatch = text.match(/^变更模块[::]\s*(.*)$/);
|
||||
if (setupModuleMatch) {
|
||||
return `变更模块:${normalizeSetupModuleText(setupModuleMatch[1])}`;
|
||||
}
|
||||
const detailMatch = text.match(/^变更明细[::]\s*(.*)$/);
|
||||
if (detailMatch) {
|
||||
const detailText = detailMatch[1].trim();
|
||||
return `变更明细:${detailText || "无"}`;
|
||||
}
|
||||
if (text === "立项配置已发布") return "立项配置已发布";
|
||||
if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划");
|
||||
if (text.includes("monitoringStrategies")) return text.replaceAll("monitoringStrategies", "监查策略");
|
||||
return text;
|
||||
};
|
||||
|
||||
const splitAuditDetailSegments = (line: string): string[] => {
|
||||
const text = String(line || "").trim();
|
||||
if (!text) return [];
|
||||
return text
|
||||
.split(/[;;\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const auditFieldLabelMap: Record<string, string> = {
|
||||
status: "状态",
|
||||
is_active: "启用状态",
|
||||
role: "角色",
|
||||
role_in_study: "项目角色",
|
||||
full_name: "姓名",
|
||||
username: "账号",
|
||||
department: "部门",
|
||||
email: "邮箱",
|
||||
name: "名称",
|
||||
code: "编号",
|
||||
site_name: "中心名称",
|
||||
pi_name: "PI",
|
||||
city: "城市",
|
||||
contact: "负责人",
|
||||
phone: "电话",
|
||||
plan_start_date: "计划开始日期",
|
||||
plan_end_date: "计划结束日期",
|
||||
};
|
||||
|
||||
const toReadableFieldLabel = (key: string): string => {
|
||||
const normalized = String(key || "").trim();
|
||||
if (!normalized) return "字段";
|
||||
if (auditFieldLabelMap[normalized]) return auditFieldLabelMap[normalized];
|
||||
return `字段(${normalized})`;
|
||||
};
|
||||
|
||||
const toReadableValue = (key: string, value: any): string => {
|
||||
if (value === null || value === undefined || value === "") return "未填写";
|
||||
if (typeof value === "boolean") {
|
||||
if (key === "is_active") return value ? "启用" : "停用";
|
||||
return value ? "是" : "否";
|
||||
}
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim();
|
||||
if (!text) return "未填写";
|
||||
if (key.toLowerCase().includes("status")) {
|
||||
lines.push(`${TEXT.audit.statusLabel}${getDictLabel(statusDict, prev)} → ${getDictLabel(statusDict, next)}`);
|
||||
} else {
|
||||
lines.push(`${key}:${prev ?? TEXT.audit.emptyValue} → ${next ?? TEXT.audit.emptyValue}`);
|
||||
return getDictLabel(statusDict, text) || text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
if (Array.isArray(value)) return `共${value.length}项`;
|
||||
if (typeof value === "object") {
|
||||
if (typeof value.name === "string" && value.name.trim()) return value.name.trim();
|
||||
if (typeof value.label === "string" && value.label.trim()) return value.label.trim();
|
||||
return "已配置";
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>) => {
|
||||
if (!before && !after) return [];
|
||||
const lines: string[] = [];
|
||||
const prevObj = before || {};
|
||||
const nextObj = after || {};
|
||||
Object.keys({ ...prevObj, ...nextObj }).forEach((key) => {
|
||||
const prev = prevObj[key];
|
||||
const next = nextObj[key];
|
||||
if (prev === next) return;
|
||||
const label = toReadableFieldLabel(key);
|
||||
const prevText = toReadableValue(key, prev);
|
||||
const nextText = toReadableValue(key, next);
|
||||
lines.push(`${label}:${prevText} -> ${nextText}`);
|
||||
});
|
||||
return lines;
|
||||
};
|
||||
|
||||
export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => {
|
||||
const dict = auditDict[raw.action] || {
|
||||
label: TEXT.audit.eventFallback,
|
||||
actionText: TEXT.audit.actionFallback,
|
||||
targetLabel: raw.entity_type || TEXT.audit.targetFallback,
|
||||
label: raw.action ? `${TEXT.audit.eventFallback}(${raw.action})` : TEXT.audit.eventFallback,
|
||||
actionText: raw.action ? `执行了${raw.action}` : TEXT.audit.actionFallback,
|
||||
targetLabel: toReadableEntityType(raw.entity_type),
|
||||
};
|
||||
let detailObj: any = {};
|
||||
if (raw.detail) {
|
||||
@@ -57,7 +214,18 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
}
|
||||
const before = detailObj.before;
|
||||
const after = detailObj.after;
|
||||
const diffText = detailObj.diffText || buildDiffText(before, after);
|
||||
const fallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : "";
|
||||
let diffText = detailObj.diffText || buildDiffText(before, after);
|
||||
if ((!Array.isArray(diffText) || diffText.length === 0) && fallbackDescription) {
|
||||
diffText = fallbackDescription
|
||||
.split(/[;;\n]/)
|
||||
.map((item: string) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
const readableDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : [])
|
||||
.flatMap((line) => splitAuditDetailSegments(String(line)))
|
||||
.map((line) => normalizeLegacyDetailLine(line))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
eventType: raw.action,
|
||||
eventLabel: dict.label,
|
||||
@@ -68,11 +236,11 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
targetType: raw.entity_type || "",
|
||||
targetTypeLabel: dict.targetLabel,
|
||||
targetId: raw.entity_id || "",
|
||||
targetName: detailObj.targetName,
|
||||
targetName: detailObj.targetName || raw.entity_id || "",
|
||||
actionText: dict.actionText,
|
||||
before,
|
||||
after,
|
||||
diffText: Array.isArray(diffText) ? diffText : diffText ? [diffText] : [],
|
||||
diffText: readableDiffText,
|
||||
reason: detailObj.reason,
|
||||
result: detailObj.result || "SUCCESS",
|
||||
resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="isEdit ? TEXT.modules.knowledgeMedicalConsult.editCategory : TEXT.modules.knowledgeMedicalConsult.newCategory" width="480px" @close="close">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :model-value="modelValue" :title="isEdit ? TEXT.modules.knowledgeMedicalConsult.editCategory : TEXT.modules.knowledgeMedicalConsult.newCategory" width="480px" @close="close">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item :label="TEXT.common.fields.name" prop="name">
|
||||
<el-input v-model="form.name" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="isEdit ? TEXT.modules.knowledgeMedicalConsult.editItem : TEXT.modules.knowledgeMedicalConsult.newItem" width="640px" @close="close">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :model-value="modelValue" :title="isEdit ? TEXT.modules.knowledgeMedicalConsult.editItem : TEXT.modules.knowledgeMedicalConsult.newItem" width="640px" @close="close">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item :label="TEXT.common.labels.category" prop="category_id">
|
||||
<el-select v-model="form.category_id" :placeholder="TEXT.common.placeholders.select">
|
||||
|
||||
@@ -37,7 +37,7 @@ const actions = [
|
||||
{ label: TEXT.menu.subjects, path: "/subjects", icon: UserFilled },
|
||||
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", icon: Management },
|
||||
{ label: TEXT.menu.finance, path: "/fees/contracts", icon: Money },
|
||||
{ label: TEXT.menu.knowledge, path: "/knowledge/medical-consult", icon: Collection },
|
||||
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", icon: Collection },
|
||||
];
|
||||
|
||||
const go = (path: string) => router.push(path);
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
|
||||
<div v-if="previewError" class="preview-error">
|
||||
{{ previewError }}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
|
||||
<div v-if="previewError" class="preview-error">
|
||||
{{ previewError }}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
deleteStudySetupConfigVersion,
|
||||
exportStudySetupConfigExcel,
|
||||
fetchStudySetupConfig,
|
||||
fetchStudySetupConfigVersions,
|
||||
importStudySetupConfigExcel,
|
||||
publishStudySetupConfig,
|
||||
rollbackStudySetupConfig,
|
||||
saveStudySetupConfig,
|
||||
@@ -21,8 +19,6 @@ export const useSetupConfig = () => {
|
||||
const listVersions = (studyId: string) => fetchStudySetupConfigVersions(studyId);
|
||||
const rollbackConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => rollbackStudySetupConfig(studyId, payload);
|
||||
const deleteVersion = (studyId: string, targetVersion: number) => deleteStudySetupConfigVersion(studyId, targetVersion);
|
||||
const exportExcel = (studyId: string) => exportStudySetupConfigExcel(studyId);
|
||||
const importExcel = (studyId: string, payload: FormData) => importStudySetupConfigExcel(studyId, payload);
|
||||
|
||||
return {
|
||||
getConfig,
|
||||
@@ -31,8 +27,5 @@ export const useSetupConfig = () => {
|
||||
listVersions,
|
||||
rollbackConfig,
|
||||
deleteVersion,
|
||||
exportExcel,
|
||||
importExcel,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -243,7 +243,6 @@ export const TEXT = {
|
||||
UPDATE_SETUP_CONFIG: { label: "保存立项配置", actionText: "更新了立项配置草稿", targetLabel: "立项配置" },
|
||||
PUBLISH_SETUP_CONFIG: { label: "发布立项配置", actionText: "发布了立项配置", targetLabel: "立项配置" },
|
||||
IMPORT_SETUP_CONFIG: { label: "导入立项配置", actionText: "导入了立项配置", targetLabel: "立项配置" },
|
||||
IMPORT_SETUP_CONFIG_EXCEL: { label: "导入立项配置(Excel)", actionText: "通过 Excel 导入了立项配置", targetLabel: "立项配置" },
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
@@ -338,7 +337,23 @@ export const TEXT = {
|
||||
title: "项目里程碑",
|
||||
subtitle: "里程碑计划与执行跟踪",
|
||||
listTitle: "里程碑列表",
|
||||
emptyDescription: "项目里程碑模块正在建设中,敬请期待。",
|
||||
emptyDescription: "立项配置中暂无可展示的项目里程碑计划。",
|
||||
sourceLabel: "数据来源:",
|
||||
sourcePublished: "立项配置已发布版本",
|
||||
sourceDraft: "立项配置草稿版本",
|
||||
sourceDraftFallback: "立项配置草稿版本(已发布版本为空)",
|
||||
statTotal: "总数 ",
|
||||
statDone: "已完成 ",
|
||||
columns: {
|
||||
name: "里程碑",
|
||||
planDate: "计划日期",
|
||||
adjustedPlanDate: "调整计划时间",
|
||||
actualTime: "实际时间",
|
||||
operations: "操作",
|
||||
owner: "负责人",
|
||||
status: "状态",
|
||||
remark: "备注",
|
||||
},
|
||||
},
|
||||
financeContracts: {
|
||||
title: "合同费用",
|
||||
@@ -948,9 +963,9 @@ export const TEXT = {
|
||||
time: "时间",
|
||||
actor: "操作人",
|
||||
event: "操作类型",
|
||||
action: "操作内容",
|
||||
target: "操作对象",
|
||||
diff: "变更详情",
|
||||
action: "内容",
|
||||
target: "对象",
|
||||
diff: "详情",
|
||||
result: "结果",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -546,6 +546,9 @@ router.beforeEach(async (to, _from, next) => {
|
||||
await studyStore.ensureDefaultStudy();
|
||||
}
|
||||
}
|
||||
if (token && studyStore.currentStudy && auth.user?.email) {
|
||||
studyStore.rememberCurrentStudyForUser(auth.user.email);
|
||||
}
|
||||
if (!to.meta.public && !token) {
|
||||
next({ path: "/login" });
|
||||
return;
|
||||
|
||||
@@ -22,7 +22,10 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
// 避免锁屏态下 fetchMe 被请求拦截
|
||||
useSessionStore().unlock();
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
|
||||
await fetchMeAction();
|
||||
const me = await fetchMeAction();
|
||||
const studyStore = useStudyStore();
|
||||
const userKey = me?.email || email;
|
||||
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.role === "ADMIN" });
|
||||
forceLogin.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -39,11 +42,13 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const studyStore = useStudyStore();
|
||||
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
||||
studyStore.rememberCurrentStudyForUser(userKey);
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
forceLogin.value = false;
|
||||
clearToken();
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
|
||||
@@ -6,12 +6,37 @@ import type { Study } from "../types/api";
|
||||
const STUDY_KEY = "ctms_current_study";
|
||||
const STUDY_ROLE_KEY = "ctms_current_study_role";
|
||||
const SITE_KEY = "ctms_current_site";
|
||||
const LAST_STUDY_BY_USER_KEY = "ctms_last_study_by_user";
|
||||
|
||||
export const useStudyStore = defineStore("study", () => {
|
||||
const currentStudy = ref<Study | null>(null);
|
||||
const currentStudyRole = ref<string | null>(null);
|
||||
const currentSite = ref<any | null>(null);
|
||||
const viewContext = ref<{ siteName?: string } | null>(null);
|
||||
const normalizeUserKey = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
const readLastStudyMap = (): Record<string, Study> => {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_STUDY_BY_USER_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeLastStudyMap = (value: Record<string, Study>) => {
|
||||
localStorage.setItem(LAST_STUDY_BY_USER_KEY, JSON.stringify(value));
|
||||
};
|
||||
|
||||
const rememberStudyForUser = (userKey: string, study: Study) => {
|
||||
const normalized = normalizeUserKey(userKey || "");
|
||||
if (!normalized || !study?.id) return;
|
||||
const map = readLastStudyMap();
|
||||
map[normalized] = study;
|
||||
writeLastStudyMap(map);
|
||||
};
|
||||
|
||||
const setCurrentStudy = (study: Study) => {
|
||||
currentStudy.value = study;
|
||||
@@ -24,6 +49,10 @@ export const useStudyStore = defineStore("study", () => {
|
||||
if (role) {
|
||||
localStorage.setItem(STUDY_ROLE_KEY, role);
|
||||
}
|
||||
const loginEmail = localStorage.getItem("ctms_last_login_email") || "";
|
||||
if (loginEmail) {
|
||||
rememberStudyForUser(loginEmail, study);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCurrentStudy = () => {
|
||||
@@ -89,6 +118,47 @@ export const useStudyStore = defineStore("study", () => {
|
||||
localStorage.removeItem(SITE_KEY);
|
||||
};
|
||||
|
||||
const rememberCurrentStudyForUser = (userKey: string) => {
|
||||
if (!currentStudy.value) return;
|
||||
rememberStudyForUser(userKey, currentStudy.value);
|
||||
};
|
||||
|
||||
const restoreStudyForUser = async (userKey: string, opts?: { preferActive?: boolean }) => {
|
||||
const normalized = normalizeUserKey(userKey || "");
|
||||
if (!normalized) return null;
|
||||
try {
|
||||
const { data } = await fetchStudies();
|
||||
const items = ((data as any).items || []) as Study[];
|
||||
if (!items.length) {
|
||||
clearCurrentStudy();
|
||||
return null;
|
||||
}
|
||||
|
||||
const saved = readLastStudyMap()[normalized];
|
||||
if (saved?.id) {
|
||||
const matched = items.find((item) => item.id === saved.id);
|
||||
if (matched) {
|
||||
setCurrentStudy(matched);
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = opts?.preferActive
|
||||
? items.find((study) => study.status === "ACTIVE" && !study.is_locked) || items[0]
|
||||
: items[0];
|
||||
|
||||
if (fallback) {
|
||||
setCurrentStudy(fallback);
|
||||
return fallback;
|
||||
}
|
||||
clearCurrentStudy();
|
||||
return null;
|
||||
} catch {
|
||||
clearCurrentStudy();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setCurrentStudyRole = (role: string | null) => {
|
||||
currentStudyRole.value = role;
|
||||
if (role) {
|
||||
@@ -120,9 +190,11 @@ export const useStudyStore = defineStore("study", () => {
|
||||
setCurrentStudyRole,
|
||||
setCurrentSite,
|
||||
setViewContext,
|
||||
rememberCurrentStudyForUser,
|
||||
loadCurrentStudy,
|
||||
ensureDefaultStudy,
|
||||
ensureDefaultActiveStudy,
|
||||
restoreStudyForUser,
|
||||
clearCurrentStudy,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -463,4 +463,4 @@ body {
|
||||
|
||||
.text-sm {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ export interface ProjectMilestoneDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
planDate: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
durationDays?: number;
|
||||
owner: string;
|
||||
remark: string;
|
||||
status: "未开始" | "进行中" | "已完成" | string;
|
||||
|
||||
@@ -37,11 +37,7 @@ export const displayUser = (
|
||||
opts?: { users?: Record<string, string>; members?: Record<string, string> }
|
||||
) => {
|
||||
if (!userId) return displayFallback;
|
||||
const name =
|
||||
opts?.users?.[userId] ||
|
||||
opts?.members?.[userId] ||
|
||||
// 兼容成员数据的 user_id 为 key 的情况
|
||||
(opts?.members && Object.values(opts.members).find((_, k) => k === userId));
|
||||
const name = opts?.users?.[userId] || opts?.members?.[userId];
|
||||
return (name as string) || displayFallback;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,11 +13,32 @@ export type SetupModuleLabel = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ParsedEnrollmentPayload = {
|
||||
cycle?: string;
|
||||
valuesByCycle?: {
|
||||
month?: Record<string, unknown>;
|
||||
quarter?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
ACTIVE: "进行中",
|
||||
CLOSED: "已关闭",
|
||||
DRAFT: "草稿",
|
||||
DONE: "已完成",
|
||||
BLOCKED: "阻塞/延期",
|
||||
TODO: "未开始",
|
||||
PENDING: "待处理",
|
||||
};
|
||||
|
||||
const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>> = {
|
||||
projectMilestones: {
|
||||
id: "ID",
|
||||
name: "里程碑",
|
||||
planDate: "计划日期",
|
||||
startDate: "开始日期",
|
||||
endDate: "结束日期",
|
||||
durationDays: "耗时(天)",
|
||||
owner: "负责人",
|
||||
remark: "备注",
|
||||
status: "状态",
|
||||
@@ -35,6 +56,7 @@ const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>>
|
||||
planDate: "计划日期",
|
||||
owner: "负责人",
|
||||
remark: "备注",
|
||||
status: "状态",
|
||||
},
|
||||
siteEnrollmentPlans: {
|
||||
id: "ID",
|
||||
@@ -65,46 +87,171 @@ const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>>
|
||||
},
|
||||
};
|
||||
|
||||
const globalReadableFieldMap: Record<string, string> = {
|
||||
type: "配置类型",
|
||||
valuesByCycle: "分阶段分配明细",
|
||||
month: "按月分配",
|
||||
quarter: "按季度分配",
|
||||
cycle: "统计口径",
|
||||
value: "配置值",
|
||||
};
|
||||
|
||||
const getArrayRowIdentity = (moduleKey: keyof SetupConfigDraft, value: unknown): string => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||||
const row = value as Record<string, unknown>;
|
||||
if (moduleKey === "projectMilestones") return String(row.name || "").trim();
|
||||
if (moduleKey === "siteMilestones") return String(row.milestone || "").trim();
|
||||
if (moduleKey === "siteEnrollmentPlans") return String(row.siteName || row.siteId || "").trim();
|
||||
if (moduleKey === "monitoringStrategies") return String(row.strategyType || "").trim();
|
||||
if (moduleKey === "centerConfirm") return String(row.siteName || row.siteId || "").trim();
|
||||
return "";
|
||||
};
|
||||
|
||||
const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => {
|
||||
const normalized = path.startsWith(`${moduleKey}.`) ? path.slice(moduleKey.length + 1) : path;
|
||||
let normalized = path;
|
||||
if (path === moduleKey) {
|
||||
normalized = "";
|
||||
} else if (path.startsWith(`${moduleKey}.`)) {
|
||||
normalized = path.slice(moduleKey.length + 1);
|
||||
} else if (path.startsWith(`${moduleKey}[`)) {
|
||||
normalized = path.slice(moduleKey.length);
|
||||
if (normalized.startsWith("[")) {
|
||||
normalized = `items${normalized}`;
|
||||
}
|
||||
}
|
||||
if (!normalized || normalized === moduleKey) return "根节点";
|
||||
const parts = normalized.split(".");
|
||||
const labels = parts.map((part, idx) => {
|
||||
const match = part.match(/^([^\[]+)\[(\d+)\]$/);
|
||||
const match = part.match(/^([^\[]+)\[(\d+)(?:\|([^\]]+))?\]$/);
|
||||
if (match) {
|
||||
const fieldKey = match[1];
|
||||
const rowNo = Number(match[2]) + 1;
|
||||
const rowIdentity = String(match[3] || "").trim();
|
||||
if (idx === 0 && (fieldKey === String(moduleKey) || fieldKey === "items")) {
|
||||
return rowIdentity || `第${rowNo}行`;
|
||||
}
|
||||
const label = setupFieldLabelMap[moduleKey][fieldKey] || fieldKey;
|
||||
return idx === 0 ? `${label} 第${rowNo}行` : `第${rowNo}行`;
|
||||
if (idx === 0) {
|
||||
return rowIdentity ? `${label} / ${rowIdentity}` : `${label} 第${rowNo}行`;
|
||||
}
|
||||
return rowIdentity ? rowIdentity : `第${rowNo}行`;
|
||||
}
|
||||
return setupFieldLabelMap[moduleKey][part] || part;
|
||||
return setupFieldLabelMap[moduleKey][part] || globalReadableFieldMap[part] || part;
|
||||
});
|
||||
return labels.join(" / ");
|
||||
};
|
||||
|
||||
export const serializeDiffValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (typeof value === "string") return value || '""';
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
const summarizeEnrollmentValues = (raw: Record<string, unknown> | undefined): { count: number; total: number } => {
|
||||
if (!raw || typeof raw !== "object") return { count: 0, total: 0 };
|
||||
let count = 0;
|
||||
let total = 0;
|
||||
Object.values(raw).forEach((v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return;
|
||||
count += 1;
|
||||
total += Math.floor(n);
|
||||
});
|
||||
return { count, total };
|
||||
};
|
||||
|
||||
const summarizeStructuredPayload = (raw: unknown): string | null => {
|
||||
try {
|
||||
const parsed =
|
||||
typeof raw === "string"
|
||||
? (JSON.parse(raw) as Record<string, unknown>)
|
||||
: ((raw as Record<string, unknown>) || {});
|
||||
const payloadType = String(parsed?.type || "").trim().toLowerCase();
|
||||
const enrollmentTypes = new Set(["enrollment_plan_v2", "site_enrollment_plan_v2"]);
|
||||
if (!enrollmentTypes.has(payloadType)) return null;
|
||||
|
||||
const p = parsed as ParsedEnrollmentPayload;
|
||||
const month = summarizeEnrollmentValues(p.valuesByCycle?.month);
|
||||
const quarter = summarizeEnrollmentValues(p.valuesByCycle?.quarter);
|
||||
const parts = [
|
||||
`按月:${month.count}期,合计${month.total}人`,
|
||||
`按季度:${quarter.count}期,合计${quarter.total}人`,
|
||||
];
|
||||
if (payloadType === "enrollment_plan_v2") {
|
||||
const cycle = String(p.cycle || "").trim();
|
||||
const cycleLabel = cycle === "quarter" ? "当前口径:季度" : "当前口径:月";
|
||||
return `分阶段计划(${cycleLabel})${parts.join(";")}`;
|
||||
}
|
||||
return `中心分阶段计划:${parts.join(";")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (value === null || value === undefined) return "未填写";
|
||||
if (typeof value === "boolean") return value ? "是" : "否";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim();
|
||||
if (!text) return "未填写";
|
||||
const statusLabel = statusLabelMap[text.toUpperCase()];
|
||||
if (statusLabel) return statusLabel;
|
||||
if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
|
||||
const summary = summarizeStructuredPayload(text);
|
||||
if (summary) return summary;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (Array.isArray(parsed)) return `已配置列表(${parsed.length}项)`;
|
||||
if (parsed && typeof parsed === "object") return "已配置内容";
|
||||
} catch {
|
||||
// fall through to raw text
|
||||
}
|
||||
}
|
||||
const normalizedText = text.toLowerCase();
|
||||
if (normalizedText.includes("site_enrollment_plan_v2")) return "中心分阶段计划(已配置)";
|
||||
if (normalizedText.includes("enrollment_plan_v2")) return "分阶段计划(已配置)";
|
||||
return text.length > 80 ? `${text.slice(0, 80)}...` : text;
|
||||
}
|
||||
try {
|
||||
const structuredSummary = isPlainObject(value) ? summarizeStructuredPayload(value) : null;
|
||||
if (structuredSummary) return structuredSummary;
|
||||
if (Array.isArray(value)) return `已配置列表(${value.length}项)`;
|
||||
if (value && typeof value === "object") return "已配置内容";
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const collectDiffRows = (
|
||||
moduleKey: keyof SetupConfigDraft,
|
||||
moduleLabel: string,
|
||||
localValue: unknown,
|
||||
serverValue: unknown,
|
||||
path: string,
|
||||
rows: SetupDiffRow[]
|
||||
) => {
|
||||
const isStructuredBusinessLeaf = path.endsWith(".stageBreakdown") || path.endsWith(".owner");
|
||||
if (isStructuredBusinessLeaf) {
|
||||
if (localValue === serverValue) return;
|
||||
const hasLocal = localValue !== undefined;
|
||||
const hasServer = serverValue !== undefined;
|
||||
const changeType: SetupDiffRow["changeType"] = hasLocal && hasServer ? "修改" : hasLocal ? "新增" : "删除";
|
||||
rows.push({
|
||||
moduleLabel,
|
||||
path: path || "(root)",
|
||||
changeType,
|
||||
localValue: serializeDiffValue(localValue),
|
||||
serverValue: serializeDiffValue(serverValue),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(localValue) || Array.isArray(serverValue)) {
|
||||
const localArr = Array.isArray(localValue) ? localValue : [];
|
||||
const serverArr = Array.isArray(serverValue) ? serverValue : [];
|
||||
const maxLen = Math.max(localArr.length, serverArr.length);
|
||||
for (let i = 0; i < maxLen; i += 1) {
|
||||
collectDiffRows(moduleLabel, localArr[i], serverArr[i], `${path}[${i}]`, rows);
|
||||
const rowIdentity = getArrayRowIdentity(moduleKey, localArr[i] ?? serverArr[i]);
|
||||
const indexLabel = rowIdentity ? `${i}|${rowIdentity}` : `${i}`;
|
||||
collectDiffRows(moduleKey, moduleLabel, localArr[i], serverArr[i], `${path}[${indexLabel}]`, rows);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +262,7 @@ const collectDiffRows = (
|
||||
const keys = Array.from(new Set([...Object.keys(localObj), ...Object.keys(serverObj)]));
|
||||
keys.forEach((key) => {
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
collectDiffRows(moduleLabel, localObj[key], serverObj[key], childPath, rows);
|
||||
collectDiffRows(moduleKey, moduleLabel, localObj[key], serverObj[key], childPath, rows);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -142,14 +289,24 @@ export const buildSetupReadableDiffRows = (
|
||||
if (!localDraft || !serverDraft) return [];
|
||||
const rows: SetupDiffRow[] = [];
|
||||
moduleLabels.forEach((item) => {
|
||||
collectDiffRows(item.label, localDraft[item.key], serverDraft[item.key], String(item.key), rows);
|
||||
});
|
||||
return rows.slice(0, limit).map((row) => {
|
||||
const moduleKey = moduleLabels.find((item) => item.label === row.moduleLabel)?.key;
|
||||
if (!moduleKey) return row;
|
||||
return {
|
||||
...row,
|
||||
path: formatReadablePath(moduleKey, row.path),
|
||||
};
|
||||
collectDiffRows(item.key, item.label, localDraft[item.key], serverDraft[item.key], String(item.key), rows);
|
||||
});
|
||||
return rows
|
||||
.slice(0, limit)
|
||||
.map((row) => {
|
||||
const moduleKey = moduleLabels.find((item) => item.label === row.moduleLabel)?.key;
|
||||
if (!moduleKey) return row;
|
||||
return {
|
||||
...row,
|
||||
path: formatReadablePath(moduleKey, row.path),
|
||||
};
|
||||
})
|
||||
.filter((row) => {
|
||||
// 屏蔽技术噪音字段,优先展示业务可理解差异
|
||||
const p = row.path.trim();
|
||||
if (p === "ID" || p.endsWith(" / ID")) return false;
|
||||
if (p === "中心ID" || p.endsWith(" / 中心ID")) return false;
|
||||
if (p.includes("配置类型") || p.includes("分阶段分配明细")) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="forgot-container">
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.auth.forgotTitle"
|
||||
:list-title="TEXT.modules.auth.forgotTitle"
|
||||
:empty-title="TEXT.modules.auth.forgotTitle"
|
||||
:empty-description="TEXT.modules.auth.forgotDesc"
|
||||
|
||||
@@ -221,16 +221,20 @@ const onSubmit = async () => {
|
||||
} else {
|
||||
clearCachedCredential();
|
||||
}
|
||||
if (auth.user?.role === "ADMIN") {
|
||||
const studyStore = useStudyStore();
|
||||
await studyStore.ensureDefaultActiveStudy();
|
||||
if (studyStore.currentStudy) {
|
||||
router.push("/project/overview");
|
||||
const studyStore = useStudyStore();
|
||||
if (!studyStore.currentStudy) {
|
||||
if (auth.user?.role === "ADMIN") {
|
||||
await studyStore.ensureDefaultActiveStudy();
|
||||
} else {
|
||||
router.push("/admin/users");
|
||||
await studyStore.ensureDefaultStudy();
|
||||
}
|
||||
}
|
||||
if (studyStore.currentStudy) {
|
||||
router.push("/project/overview");
|
||||
} else if (auth.user?.role === "ADMIN") {
|
||||
router.push("/admin/users");
|
||||
} else {
|
||||
router.push("/");
|
||||
router.push("/workbench");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<div class="audit-toolbar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.entityType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEntityType" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in entityTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
@@ -19,7 +14,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 120px">
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" class="filter-select-result">
|
||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
|
||||
</el-select>
|
||||
@@ -38,14 +33,17 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<div class="ctms-action-group">
|
||||
<el-button v-if="isAdmin" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('system')">
|
||||
{{ TEXT.modules.adminAuditLogs.exportSystem }}
|
||||
<el-dropdown trigger="click" @command="handleExportCommand" class="audit-export-dropdown">
|
||||
<el-button plain class="audit-export-btn" :loading="exportLoading">
|
||||
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<el-button v-if="canProjectExport" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('project')">
|
||||
{{ TEXT.modules.adminAuditLogs.exportProject }}
|
||||
</el-button>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="isAdmin" command="system">{{ TEXT.modules.adminAuditLogs.exportSystem }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="unified-section table-section">
|
||||
@@ -56,7 +54,6 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
|
||||
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
|
||||
@@ -67,7 +64,7 @@
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.targetTypeLabel">
|
||||
<strong>{{ scope.row.targetTypeLabel }}</strong>
|
||||
<span class="text-gray-500" v-if="scope.row.targetName">({{ scope.row.targetName }})</span>
|
||||
<span class="text-gray-500" v-if="scope.row.targetName">:{{ scope.row.targetName }}</span>
|
||||
</span>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
@@ -75,7 +72,11 @@
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||||
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
|
||||
<div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ line }}</div>
|
||||
<div class="diff-more-row" v-if="scope.row.diffText.length > DIFF_PREVIEW_LIMIT">
|
||||
<span class="diff-more-text">其余 {{ scope.row.diffText.length - DIFF_PREVIEW_LIMIT }} 项</span>
|
||||
<el-button link type="primary" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
|
||||
</template>
|
||||
@@ -98,6 +99,47 @@
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
direction="rtl"
|
||||
size="560px"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
class="audit-detail-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-title">审计日志详情</div>
|
||||
<el-button @click="detailVisible = false">{{ TEXT.common.actions.close }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="selectedLog">
|
||||
<div class="detail-meta-grid">
|
||||
<div class="meta-item"><span class="meta-label">时间</span><span>{{ displayDateTime(selectedLog.timestamp) }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">操作人</span><span>{{ selectedLog.actorName || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">事件</span><span>{{ selectedLog.eventLabel || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">结果</span><span>{{ selectedLog.resultLabel || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item meta-item-full">
|
||||
<span class="meta-label">对象</span>
|
||||
<span>{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">:{{ selectedLog.targetName }}</span></span>
|
||||
</div>
|
||||
<div class="meta-item meta-item-full"><span class="meta-label">动作</span><span>{{ selectedLog.actionText || TEXT.common.fallback }}</span></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">变更明细</div>
|
||||
<el-scrollbar max-height="58vh">
|
||||
<div v-if="selectedLog.diffText?.length" class="detail-lines">
|
||||
<div v-for="(line, idx) in selectedLog.diffText" :key="idx" class="detail-line-row">
|
||||
<span class="detail-line-index">{{ Number(idx) + 1 }}.</span>
|
||||
<span class="detail-line-text">{{ line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -105,6 +147,7 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ArrowDown } from "@element-plus/icons-vue";
|
||||
import { fetchAuditLogs } from "../../api/auditLogs";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { listMembers } from "../../api/members";
|
||||
@@ -146,9 +189,11 @@ const users = ref<any[]>([]);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const total = ref(0);
|
||||
const detailVisible = ref(false);
|
||||
const selectedLog = ref<any | null>(null);
|
||||
const DIFF_PREVIEW_LIMIT = 2;
|
||||
|
||||
const filters = ref({
|
||||
entityType: "",
|
||||
eventType: "",
|
||||
operatorId: "",
|
||||
result: "",
|
||||
@@ -159,16 +204,11 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||||
value,
|
||||
label: cfg.label,
|
||||
}));
|
||||
const entityTypeOptions = [
|
||||
{ value: "study_setup_config", label: "立项配置" },
|
||||
{ value: "study", label: "项目" },
|
||||
{ value: "subject", label: "参与者" },
|
||||
{ value: "site", label: "中心" },
|
||||
{ value: "finance", label: "费用" },
|
||||
{ value: "drug_shipment", label: "药品流向" },
|
||||
{ value: "audit_log", label: "审计日志" },
|
||||
];
|
||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: u.username, value: u.id })));
|
||||
const resolveUserDisplayName = (u: any): string => {
|
||||
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const canProjectExport = computed(() => !!study.currentStudy && auth.user?.role === "ADMIN");
|
||||
|
||||
@@ -183,14 +223,14 @@ const loadUsers = async () => {
|
||||
try {
|
||||
if (auth.user?.role === "ADMIN") {
|
||||
const { data } = await fetchUsers({ limit: 500 });
|
||||
users.value = (data as any).items || data || [];
|
||||
users.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||
} else if (study.currentStudy) {
|
||||
// 非管理员,尝试加载项目成员作为备选
|
||||
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
users.value = items.map((m: any) => ({
|
||||
id: m.user_id,
|
||||
username: m.user?.display_name || m.user?.full_name || m.user?.username || m.username,
|
||||
full_name: m.user?.display_name || m.user?.full_name || m.user?.username || m.username,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
@@ -205,7 +245,6 @@ const loadLogs = async () => {
|
||||
const params: Record<string, any> = {
|
||||
skip: (page.value - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
entity_type: filters.value.entityType || undefined,
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
@@ -237,10 +276,12 @@ const loadLocalLogs = () => {
|
||||
|
||||
const enrichLogs = () => {
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = cur.username;
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
return acc;
|
||||
}, {});
|
||||
const filtered = rawLogs.value.filter((log) => {
|
||||
if (filters.value.eventType && String(log.action || "") !== filters.value.eventType) return false;
|
||||
if (filters.value.operatorId && String(log.operator_id || "") !== filters.value.operatorId) return false;
|
||||
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
|
||||
if (filters.value.range?.length === 2) {
|
||||
const ts = new Date(log.created_at);
|
||||
@@ -252,7 +293,8 @@ const enrichLogs = () => {
|
||||
});
|
||||
logs.value = filtered
|
||||
.map((log) => normalizeAuditEvent(log, userMap))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, pageSize);
|
||||
if (users.value.length === 0) {
|
||||
const byActor = new Map<string, string>();
|
||||
logs.value.forEach((log) => {
|
||||
@@ -260,7 +302,7 @@ const enrichLogs = () => {
|
||||
byActor.set(log.actorId, log.actorName || log.actorId);
|
||||
}
|
||||
});
|
||||
users.value = Array.from(byActor.entries()).map(([id, username]) => ({ id, username }));
|
||||
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,6 +311,16 @@ const onPageChange = (p: number) => {
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
const getDiffPreview = (log: any) => {
|
||||
const lines = Array.isArray(log?.diffText) ? log.diffText : [];
|
||||
return lines.slice(0, DIFF_PREVIEW_LIMIT);
|
||||
};
|
||||
|
||||
const openDetail = (log: any) => {
|
||||
selectedLog.value = log;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const fetchAllForExport = async () => {
|
||||
if (!study.currentStudy) return [];
|
||||
exportLoading.value = true;
|
||||
@@ -276,7 +328,6 @@ const fetchAllForExport = async () => {
|
||||
const params: Record<string, any> = {
|
||||
skip: 0,
|
||||
limit: 2000,
|
||||
entity_type: filters.value.entityType || undefined,
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
@@ -285,7 +336,7 @@ const fetchAllForExport = async () => {
|
||||
const local = loadLocalLogs();
|
||||
const merged = dedupeLogs([...items, ...local]);
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = cur.username;
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
return acc;
|
||||
}, {});
|
||||
const filtered = merged.filter((log) => {
|
||||
@@ -306,6 +357,10 @@ const fetchAllForExport = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCommand = (command: string) => {
|
||||
confirmExport(command as "system" | "project");
|
||||
};
|
||||
|
||||
const confirmExport = async (scope: "system" | "project") => {
|
||||
const ok = await ElMessageBox.confirm(
|
||||
TEXT.modules.adminAuditLogs.exportConfirm,
|
||||
@@ -351,30 +406,60 @@ onMounted(async () => {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.audit-toolbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 150px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.filter-select-result {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.date-range-picker-comp {
|
||||
width: 280px !important;
|
||||
width: 260px !important;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.audit-export-dropdown {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-export-btn {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
color: #3b6cb5 !important;
|
||||
border-color: #b3cce6 !important;
|
||||
background: #f0f6ff !important;
|
||||
}
|
||||
|
||||
.audit-export-btn:hover,
|
||||
.audit-export-btn:focus {
|
||||
color: #2a5699 !important;
|
||||
border-color: #8cb4de !important;
|
||||
background: #e2edfa !important;
|
||||
}
|
||||
|
||||
.diff-container {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
@@ -386,9 +471,95 @@ onMounted(async () => {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diff-more-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-more-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #11264d;
|
||||
}
|
||||
|
||||
.detail-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e6edf7;
|
||||
border-radius: 8px;
|
||||
background: #fbfdff;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.meta-item-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e3a5f;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.detail-line-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-line-index {
|
||||
color: #94a3b8;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.detail-line-text {
|
||||
color: #475569;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
>
|
||||
回滚版本
|
||||
</el-button>
|
||||
<el-button plain @click="triggerImportConfig">导入Excel</el-button>
|
||||
<el-dropdown trigger="click" class="setup-secondary-menu" @command="handleSecondaryAction">
|
||||
<el-button plain class="secondary-menu-btn">
|
||||
更多
|
||||
@@ -73,17 +72,9 @@
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
中心管理
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="export" :disabled="!canExportSetup">导出Excel</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<input
|
||||
ref="importInputRef"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
||||
class="hidden-file-input"
|
||||
@change="handleImportFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -339,14 +330,14 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划中心数">
|
||||
<el-input-number v-model="form.planned_site_count" :min="0" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.planned_site_count" :min="0" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划入组数">
|
||||
<el-input-number v-model="form.planned_enrollment_count" :min="0" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.planned_enrollment_count" :min="0" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -422,22 +413,22 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitTotal">
|
||||
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
|
||||
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
|
||||
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
|
||||
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -475,14 +466,30 @@
|
||||
<span class="milestone-cell-text">{{ scope.row.name || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划日期" width="180">
|
||||
<el-table-column label="计划时间" width="240">
|
||||
<template #default="scope">
|
||||
<span class="milestone-cell-text">{{ scope.row.planDate || "-" }}</span>
|
||||
<div class="milestone-plan-cell">
|
||||
<div class="milestone-plan-line">
|
||||
<span class="milestone-plan-dot" />
|
||||
<span class="milestone-plan-label">开始:</span>
|
||||
<span>{{ resolveProjectMilestoneStart(scope.row) || "-" }}</span>
|
||||
</div>
|
||||
<div class="milestone-plan-line">
|
||||
<span class="milestone-plan-dot" />
|
||||
<span class="milestone-plan-label">结束:</span>
|
||||
<span>{{ resolveProjectMilestoneEnd(scope.row) || "-" }}</span>
|
||||
</div>
|
||||
<div class="milestone-plan-line">
|
||||
<span class="milestone-plan-dot" />
|
||||
<span class="milestone-plan-label">耗时:</span>
|
||||
<span>{{ formatProjectMilestoneDuration(scope.row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="milestone-cell-text">{{ scope.row.owner || "-" }}</span>
|
||||
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="150">
|
||||
@@ -557,6 +564,7 @@
|
||||
v-model="currentSetupDraft.enrollmentPlan.totalTarget"
|
||||
:min="0"
|
||||
:disabled="!canEditSetup"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="enrollment-inline-value">{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}</span>
|
||||
@@ -575,9 +583,12 @@
|
||||
</div>
|
||||
<div class="enrollment-summary-line">
|
||||
<span class="summary-title">中心计划情况:</span>
|
||||
<span>计划入组数 <b>{{ toDisplayNumber(selectedSitePlanTarget) }}</b> 人</span>
|
||||
<span>已填写计划数 <b>{{ toDisplayNumber(selectedSiteCyclePlannedCount) }}</b> 人</span>
|
||||
<span>待分配 {{ selectedSiteCyclePendingCount }} 人</span>
|
||||
<span>已配置中心 <b>{{ centerConfiguredSiteCount }}</b>/<b>{{ centerActiveSiteCount }}</b> 个</span>
|
||||
<span>中心计划总数 <b>{{ toDisplayNumber(centerPlannedCount) }}</b> 人</span>
|
||||
<span>已填写计划数 <b>{{ toDisplayNumber(centerCyclePlannedCount) }}</b> 人</span>
|
||||
<span v-if="centerCycleOverflowCount > 0" class="summary-danger">已超出 {{ centerCycleOverflowCount }} 人</span>
|
||||
<span v-else-if="centerCyclePendingCount > 0">待分配 {{ centerCyclePendingCount }} 人</span>
|
||||
<span v-else class="summary-success">已完成分配</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -668,7 +679,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="milestone-cell-text">{{ scope.row.owner || "-" }}</span>
|
||||
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="150">
|
||||
@@ -761,6 +772,7 @@
|
||||
v-model="selectedSiteEnrollmentPlan.target"
|
||||
:min="0"
|
||||
:disabled="!canEditSetup"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="enrollment-inline-value">{{ toDisplayNumber(selectedSiteEnrollmentPlan?.target) }}</span>
|
||||
@@ -778,6 +790,7 @@
|
||||
</div>
|
||||
<div class="enrollment-summary-line">
|
||||
<span class="summary-title">中心计划情况:</span>
|
||||
<span class="summary-site-name">{{ selectedSitePlanName }}</span>
|
||||
<span>计划入组数 <b>{{ toDisplayNumber(selectedSitePlanTarget) }}</b> 人</span>
|
||||
<span>已填写计划数 <b>{{ toDisplayNumber(selectedSiteCyclePlannedCount) }}</b> 人</span>
|
||||
<span>待分配 {{ selectedSiteCyclePendingCount }} 人</span>
|
||||
@@ -961,7 +974,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="publishConfirmVisible" title="发布确认" width="920px" top="8vh" class="setup-publish-dialog">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="publishConfirmVisible" title="发布确认" width="920px" top="8vh" class="setup-publish-dialog">
|
||||
<div class="conflict-summary">
|
||||
<div>发布目标:立项配置草稿</div>
|
||||
<div>当前发布版本:{{ setupPublishedVersionText }}</div>
|
||||
@@ -990,9 +1003,14 @@
|
||||
<el-tag v-for="line in publishDiffLines" :key="line" effect="plain" type="warning">{{ line }}</el-tag>
|
||||
</div>
|
||||
<div v-if="publishFieldDiffRows.length" class="conflict-readable-diff">
|
||||
<div class="publish-diff-overview">
|
||||
<el-tag v-for="item in publishDiffModuleSummary" :key="item.module" type="info" effect="plain">
|
||||
{{ item.module }}:{{ item.count }} 项变更
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="publishFieldDiffRows" size="small" border max-height="320">
|
||||
<el-table-column prop="moduleLabel" label="模块" width="140" />
|
||||
<el-table-column prop="path" label="字段路径" min-width="260" />
|
||||
<el-table-column prop="path" label="变更项" min-width="300" />
|
||||
<el-table-column prop="changeType" label="变更类型" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.changeType === '新增' ? 'success' : scope.row.changeType === '删除' ? 'danger' : 'warning'" effect="plain">
|
||||
@@ -1000,8 +1018,8 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="localValue" label="草稿值" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="serverValue" label="已发布值" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="serverValue" label="上一版本值" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="localValue" label="预发布版本值" min-width="220" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -1009,7 +1027,7 @@
|
||||
<el-button type="primary" :loading="publishConfirmLoading" @click="confirmPublishConfig">确认发布</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="rollbackDialogVisible" title="选择回滚版本" width="760px" top="10vh">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="rollbackDialogVisible" title="选择回滚版本" width="760px" top="10vh">
|
||||
<el-table :data="setupVersionHistory" height="360" v-loading="rollbackDialogLoading">
|
||||
<el-table-column prop="version" label="发布版本" width="120">
|
||||
<template #default="scope">v{{ scope.row.display_version || scope.row.version }}</template>
|
||||
@@ -1053,17 +1071,42 @@
|
||||
<el-form-item label="里程碑">
|
||||
<el-input v-model="projectMilestoneEditorForm.name" placeholder="例如:首例入组" />
|
||||
</el-form-item>
|
||||
<el-form-item label="计划日期">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker
|
||||
v-model="projectMilestoneEditorForm.planDate"
|
||||
v-model="projectMilestoneEditorForm.startDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
placeholder="选择日期"
|
||||
placeholder="选择开始日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker
|
||||
v-model="projectMilestoneEditorForm.endDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
placeholder="选择结束日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="耗时">
|
||||
<span>{{ formatProjectMilestoneDuration(projectMilestoneEditorForm) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="projectMilestoneEditorForm.owner" placeholder="负责人" />
|
||||
<el-select
|
||||
v-model="projectMilestoneEditorForm.owner"
|
||||
filterable
|
||||
clearable
|
||||
class="w-full"
|
||||
placeholder="从项目成员中选择负责人"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in projectMilestoneOwnerOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="projectMilestoneEditorForm.status" class="w-full" placeholder="状态">
|
||||
@@ -1112,7 +1155,20 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="siteMilestoneEditorForm.owner" placeholder="负责人" />
|
||||
<el-select
|
||||
v-model="siteMilestoneEditorForm.owner"
|
||||
filterable
|
||||
clearable
|
||||
class="w-full"
|
||||
placeholder="从项目成员中选择负责人"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in siteMilestoneOwnerOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="siteMilestoneEditorForm.status" class="w-full" placeholder="状态">
|
||||
@@ -1160,7 +1216,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划例数">
|
||||
<el-input-number v-model="siteEnrollmentEditorForm.target" :min="0" class="w-full" />
|
||||
<el-input-number v-model="siteEnrollmentEditorForm.target" :min="0" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启动日期">
|
||||
<el-date-picker v-model="siteEnrollmentEditorForm.startDate" type="date" value-format="YYYY-MM-DD" class="w-full" />
|
||||
@@ -1396,13 +1452,12 @@ const {
|
||||
listVersions: fetchSetupVersions,
|
||||
rollbackConfig,
|
||||
deleteVersion: deleteSetupVersion,
|
||||
exportExcel: exportSetupExcel,
|
||||
importExcel: importSetupExcel,
|
||||
} = useSetupConfig();
|
||||
|
||||
const project = ref<Study | null>(null);
|
||||
const siteOptions = ref<Site[]>([]);
|
||||
const memberDisplayMap = ref<Record<string, string>>({});
|
||||
const projectMemberOptions = ref<Array<{ value: string; label: string }>>([]);
|
||||
const activeStep = ref(0);
|
||||
const infoTab = ref<"basic" | "summary">("basic");
|
||||
const step1EditSection = ref<Step1EditSection>("all");
|
||||
@@ -1418,7 +1473,6 @@ const formRef = ref<FormInstance>();
|
||||
const draftReady = ref(false);
|
||||
const suppressDraftWatch = ref(false);
|
||||
const autoSaveTimer = ref<number | undefined>(undefined);
|
||||
const importInputRef = ref<HTMLInputElement | null>(null);
|
||||
const setupDirtySinceLastPersist = ref(false);
|
||||
const projectDirtySinceLastPersist = ref(false);
|
||||
const suppressEnrollmentFieldSync = ref(false);
|
||||
@@ -1480,6 +1534,9 @@ const projectMilestoneEditorForm = reactive<ProjectMilestoneDraft>({
|
||||
id: "",
|
||||
name: "",
|
||||
planDate: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
durationDays: 1,
|
||||
owner: "",
|
||||
remark: "",
|
||||
status: "未开始",
|
||||
@@ -1628,7 +1685,6 @@ const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String
|
||||
const publishedProjectSignatureKey = computed(() => `ctms_setup_published_project_sig_${String(route.params.projectId || "")}`);
|
||||
const setupRole = computed(() => String(project.value?.role_in_study || authStore.user?.role || "").toUpperCase());
|
||||
const canManageSetup = computed(() => ["ADMIN", "PM"].includes(setupRole.value));
|
||||
const canExportSetup = computed(() => ["ADMIN", "PM", "CRA"].includes(setupRole.value));
|
||||
const isPublishedView = computed(() => setupViewMode.value === "published");
|
||||
const currentSetupDraft = computed<SetupConfigDraft>(() => {
|
||||
return setupDraft;
|
||||
@@ -1863,6 +1919,50 @@ const normalizePlanDate = (value: string | null | undefined): string => {
|
||||
if (!value) return "";
|
||||
return String(value);
|
||||
};
|
||||
const parseDateOnlyValue = (value: string | null | undefined): Date | null => {
|
||||
const normalized = normalizePlanDate(value);
|
||||
if (!normalized) return null;
|
||||
const d = new Date(`${normalized}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
};
|
||||
const calculateDurationDays = (startDate: string | null | undefined, endDate: string | null | undefined): number => {
|
||||
const start = parseDateOnlyValue(startDate);
|
||||
const end = parseDateOnlyValue(endDate);
|
||||
if (!start || !end) return 0;
|
||||
if (end < start) return 0;
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
return Math.floor(diffMs / 86400000) + 1;
|
||||
};
|
||||
const resolveProjectMilestoneStart = (row: Partial<ProjectMilestoneDraft> | null | undefined): string =>
|
||||
normalizePlanDate(row?.startDate || row?.planDate || "");
|
||||
const resolveProjectMilestoneEnd = (row: Partial<ProjectMilestoneDraft> | null | undefined): string =>
|
||||
normalizePlanDate(row?.endDate || row?.planDate || "");
|
||||
const resolveProjectMilestoneDurationDays = (row: Partial<ProjectMilestoneDraft> | null | undefined): number => {
|
||||
const rawDays = Number((row as any)?.durationDays);
|
||||
if (Number.isFinite(rawDays) && rawDays > 0) return Math.floor(rawDays);
|
||||
const startDate = resolveProjectMilestoneStart(row);
|
||||
const endDate = resolveProjectMilestoneEnd(row);
|
||||
if (!startDate && !endDate) return 0;
|
||||
return calculateDurationDays(startDate, endDate) || 1;
|
||||
};
|
||||
const formatProjectMilestoneDuration = (row: Partial<ProjectMilestoneDraft> | null | undefined): string => {
|
||||
const days = resolveProjectMilestoneDurationDays(row);
|
||||
return days > 0 ? `${days}天` : "-";
|
||||
};
|
||||
const normalizeProjectMilestoneRows = (rows: ProjectMilestoneDraft[]): ProjectMilestoneDraft[] =>
|
||||
rows.map((row) => {
|
||||
const startDate = resolveProjectMilestoneStart(row);
|
||||
const endDate = resolveProjectMilestoneEnd(row);
|
||||
const durationDays = resolveProjectMilestoneDurationDays({ ...row, startDate, endDate });
|
||||
return {
|
||||
...row,
|
||||
planDate: startDate,
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays: durationDays > 0 ? durationDays : 1,
|
||||
};
|
||||
});
|
||||
const getProjectPlanWindow = (): { start: string; end: string } => ({
|
||||
start: normalizePlanDate(form.value.plan_start_date || project.value?.plan_start_date || ""),
|
||||
end: normalizePlanDate(form.value.plan_end_date || project.value?.plan_end_date || ""),
|
||||
@@ -2200,6 +2300,11 @@ const centerEnrollmentTargetMap = computed(() => {
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const centerActiveSiteCount = computed(() => siteOptions.value.filter((site) => site.is_active !== false).length);
|
||||
const centerConfiguredSiteCount = computed(() => {
|
||||
const plannedSiteIds = centerEnrollmentTargetMap.value;
|
||||
return siteOptions.value.filter((site) => site.is_active !== false && plannedSiteIds.has(String(site.id || "").trim())).length;
|
||||
});
|
||||
const centerPlannedCount = computed(() => {
|
||||
return Array.from(centerEnrollmentTargetMap.value.values()).reduce((sum, target) => sum + target, 0);
|
||||
});
|
||||
@@ -2234,6 +2339,13 @@ const sumSiteEnrollmentTargetForCurrentCycle = (targets: Record<EnrollmentCycle,
|
||||
});
|
||||
return sum;
|
||||
};
|
||||
const centerCyclePlannedCount = computed(() => {
|
||||
return setupDraft.siteEnrollmentPlans.reduce((sum, row) => {
|
||||
return sum + sumSiteEnrollmentTargetForCurrentCycle(getSiteEnrollmentTargetsByCycle(row));
|
||||
}, 0);
|
||||
});
|
||||
const centerCyclePendingCount = computed(() => Math.max(centerPlannedCount.value - centerCyclePlannedCount.value, 0));
|
||||
const centerCycleOverflowCount = computed(() => Math.max(centerCyclePlannedCount.value - centerPlannedCount.value, 0));
|
||||
const getSiteEnrollmentPeriodTarget = (row: SiteEnrollmentPlanDraft, key: string | null): number => {
|
||||
if (!key) return 0;
|
||||
const targets = getSiteEnrollmentTargetsByCycle(row);
|
||||
@@ -2387,7 +2499,11 @@ const isStepCompleted = (stepIndex: number): boolean => {
|
||||
if (stepIndex === 1) {
|
||||
return (
|
||||
setupDraft.projectMilestones.length > 0 &&
|
||||
setupDraft.projectMilestones.every((item) => (item.name || "").trim().length > 0 && (item.planDate || "").length > 0)
|
||||
setupDraft.projectMilestones.every((item) => {
|
||||
const startDate = resolveProjectMilestoneStart(item);
|
||||
const endDate = resolveProjectMilestoneEnd(item);
|
||||
return (item.name || "").trim().length > 0 && !!startDate && !!endDate && startDate <= endDate;
|
||||
})
|
||||
);
|
||||
}
|
||||
if (stepIndex === 2) {
|
||||
@@ -2475,6 +2591,28 @@ const normalizeOwnerDisplay = (value: string | null | undefined): string => {
|
||||
.map((item) => memberDisplayMap.value[item] || item)
|
||||
.join("、") || "-";
|
||||
};
|
||||
const projectMilestoneOwnerOptions = computed(() => {
|
||||
const options = [...projectMemberOptions.value];
|
||||
const currentOwner = String(projectMilestoneEditorForm.owner || "").trim();
|
||||
if (currentOwner && !options.some((option) => option.value === currentOwner)) {
|
||||
options.unshift({
|
||||
value: currentOwner,
|
||||
label: normalizeOwnerDisplay(currentOwner),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const siteMilestoneOwnerOptions = computed(() => {
|
||||
const options = [...projectMemberOptions.value];
|
||||
const currentOwner = String(siteMilestoneEditorForm.owner || "").trim();
|
||||
if (currentOwner && !options.some((option) => option.value === currentOwner)) {
|
||||
options.unshift({
|
||||
value: currentOwner,
|
||||
label: normalizeOwnerDisplay(currentOwner),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const siteEnrollmentPlanMap = computed(() => {
|
||||
const map = new Map<string, SiteEnrollmentPlanDraft>();
|
||||
setupDraft.siteEnrollmentPlans.forEach((row) => {
|
||||
@@ -2533,6 +2671,15 @@ const selectedSiteEnrollmentPlan = computed(() => {
|
||||
if (index < 0) return null;
|
||||
return setupDraft.siteEnrollmentPlans[index] || null;
|
||||
});
|
||||
const selectedSitePlanName = computed(() => {
|
||||
const nameFromPlan = (selectedSiteEnrollmentPlan.value?.siteName || "").trim();
|
||||
if (nameFromPlan) return nameFromPlan;
|
||||
const selectedSiteId = normalizeSiteId(selectedSiteEnrollmentPlanSiteId.value);
|
||||
if (!selectedSiteId) return "当前中心";
|
||||
const matched = siteEnrollmentSelectableSites.value.find((site) => normalizeSiteId(site.id) === selectedSiteId);
|
||||
const name = (matched?.name || "").trim();
|
||||
return name || "当前中心";
|
||||
});
|
||||
const selectedSitePlanTarget = computed(() => normalizeEnrollmentTarget(selectedSiteEnrollmentPlan.value?.target));
|
||||
const selectedSiteCyclePlannedCount = computed(() => {
|
||||
const row = selectedSiteEnrollmentPlan.value;
|
||||
@@ -2630,7 +2777,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
|
||||
|
||||
const applySetupDraft = (draft: SetupConfigDraft) => {
|
||||
suppressDraftWatch.value = true;
|
||||
setupDraft.projectMilestones = clone(draft.projectMilestones);
|
||||
setupDraft.projectMilestones = normalizeProjectMilestoneRows(clone(draft.projectMilestones));
|
||||
setupDraft.enrollmentPlan = clone(draft.enrollmentPlan);
|
||||
setupDraft.siteMilestones = clone(draft.siteMilestones);
|
||||
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
|
||||
@@ -2830,6 +2977,13 @@ const publishDiffLines = computed(() => {
|
||||
return lines;
|
||||
});
|
||||
const publishFieldDiffRows = computed(() => [...buildProjectDiffRows(), ...buildReadableDiffRows(setupDraft, publishCompareBase.value)]);
|
||||
const publishDiffModuleSummary = computed(() => {
|
||||
const grouped = new Map<string, number>();
|
||||
publishFieldDiffRows.value.forEach((row) => {
|
||||
grouped.set(row.moduleLabel, (grouped.get(row.moduleLabel) || 0) + 1);
|
||||
});
|
||||
return Array.from(grouped.entries()).map(([module, count]) => ({ module, count }));
|
||||
});
|
||||
|
||||
const loadPublishedProjectSignatureFromLocal = () => {
|
||||
try {
|
||||
@@ -2906,8 +3060,19 @@ const buildProjectDiffRows = (): DiffRow[] => {
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
const normalizeDiffRowForBusiness = (row: DiffRow): DiffRow => {
|
||||
if (row.path.includes("负责人")) {
|
||||
return {
|
||||
...row,
|
||||
localValue: row.localValue === "未填写" ? row.localValue : normalizeOwnerDisplay(row.localValue),
|
||||
serverValue: row.serverValue === "未填写" ? row.serverValue : normalizeOwnerDisplay(row.serverValue),
|
||||
};
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
const buildReadableDiffRows = (localDraft: SetupConfigDraft | null, serverDraft: SetupConfigDraft | null): DiffRow[] =>
|
||||
buildSetupReadableDiffRows(localDraft, serverDraft, setupModuleLabels);
|
||||
buildSetupReadableDiffRows(localDraft, serverDraft, setupModuleLabels).map(normalizeDiffRowForBusiness);
|
||||
|
||||
const applySetupResponseMeta = (data: StudySetupConfigResponse) => {
|
||||
setupRevision.value = typeof data.version === "number" ? data.version : setupRevision.value;
|
||||
@@ -3151,10 +3316,12 @@ const initializeSetupDraft = async () => {
|
||||
|
||||
const loadMemberDisplayMap = async (studyId: string) => {
|
||||
const nextMap: Record<string, string> = {};
|
||||
const memberOptionMap = new Map<string, string>();
|
||||
try {
|
||||
const { data } = await listMembers(studyId, { limit: 500 });
|
||||
const rows = Array.isArray(data) ? data : (data as any).items || [];
|
||||
rows.forEach((row: any) => {
|
||||
if (row?.is_active === false) return;
|
||||
const userId = String(row?.user_id || row?.userId || "").trim();
|
||||
const memberId = String(row?.id || "").trim();
|
||||
if (!userId) return;
|
||||
@@ -3167,8 +3334,13 @@ const loadMemberDisplayMap = async (studyId: string) => {
|
||||
row?.username ||
|
||||
row?.email ||
|
||||
userId;
|
||||
const roleInStudy = String(row?.role_in_study || row?.roleInStudy || "").trim();
|
||||
const optionLabel = roleInStudy ? `${displayName}(${roleInStudy})` : displayName;
|
||||
nextMap[userId] = displayName;
|
||||
if (memberId) nextMap[memberId] = displayName;
|
||||
if (!memberOptionMap.has(userId)) {
|
||||
memberOptionMap.set(userId, optionLabel);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Ignore member API errors and fallback to users API below.
|
||||
@@ -3185,6 +3357,9 @@ const loadMemberDisplayMap = async (studyId: string) => {
|
||||
// ignore
|
||||
}
|
||||
memberDisplayMap.value = nextMap;
|
||||
projectMemberOptions.value = Array.from(memberOptionMap.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
|
||||
};
|
||||
|
||||
const loadProject = async () => {
|
||||
@@ -3664,68 +3839,6 @@ const confirmPublishConfig = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportConfigNow = async () => {
|
||||
if (!project.value || !canExportSetup.value) return;
|
||||
try {
|
||||
const { data } = await exportSetupExcel(project.value.id);
|
||||
const filename = `setup-config-${project.value.code || project.value.id}.xlsx`;
|
||||
const blob = data instanceof Blob ? data : new Blob([data], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
ElMessage.success("已导出配置Excel");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getApiErrorMessage(e, "导出失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const triggerImportConfig = () => {
|
||||
if (!canManageSetup.value) return;
|
||||
importInputRef.value?.click();
|
||||
};
|
||||
|
||||
const handleImportFile = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file || !project.value || !canEditSetup.value || !canManageSetup.value) return;
|
||||
if (!file.name.toLowerCase().endsWith(".xlsx")) {
|
||||
ElMessage.warning("请上传Excel文件(.xlsx)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = new FormData();
|
||||
payload.append("file", file);
|
||||
if (setupRevision.value) {
|
||||
payload.append("expected_version", String(setupRevision.value));
|
||||
}
|
||||
const { data: imported } = await importSetupExcel(project.value.id, payload);
|
||||
applySetupResponseMeta(imported);
|
||||
clearSetupValidationErrors();
|
||||
applySetupDraft(imported.data);
|
||||
markSetupSynced();
|
||||
setupSaveMeta.value = {
|
||||
updatedAt: formatDisplayTime(imported.updated_at),
|
||||
savedBy: imported.saved_by_name || resolveSavedByDisplay(imported.saved_by),
|
||||
serverSynced: true,
|
||||
};
|
||||
ElMessage.success("Excel已导入并自动填充配置");
|
||||
} catch (e: any) {
|
||||
if (handleValidationFailure(e, "导入失败,请检查Excel内容")) return;
|
||||
if (e?.response?.status === 409) {
|
||||
ElMessage.warning("导入失败:配置已更新,正在刷新最新版本");
|
||||
await refreshSetupDraftFromServer();
|
||||
return;
|
||||
}
|
||||
ElMessage.error(getApiErrorMessage(e, "导入失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const enterProject = () => {
|
||||
if (!project.value) return;
|
||||
studyStore.setCurrentStudy({ ...project.value, role_in_study: project.value.role_in_study || "PM" } as Study);
|
||||
@@ -3752,9 +3865,6 @@ const handleSecondaryAction = (command: string) => {
|
||||
goSites();
|
||||
return;
|
||||
}
|
||||
if (command === "export") {
|
||||
void exportConfigNow();
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
@@ -3774,7 +3884,17 @@ const goStep = (index: number) => {
|
||||
|
||||
const addProjectMilestone = () => {
|
||||
if (!canMutateDraft()) return;
|
||||
setupDraft.projectMilestones.push({ id: makeId(), name: "", planDate: "", owner: "", remark: "", status: "未开始" });
|
||||
setupDraft.projectMilestones.push({
|
||||
id: makeId(),
|
||||
name: "",
|
||||
planDate: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
durationDays: 1,
|
||||
owner: "",
|
||||
remark: "",
|
||||
status: "未开始",
|
||||
});
|
||||
};
|
||||
const ensureStep2Editable = (): boolean => {
|
||||
if (!project.value) return false;
|
||||
@@ -3817,6 +3937,9 @@ const onProjectMilestoneTemplateSelect = async (templateKey: string) => {
|
||||
id: makeId(),
|
||||
name,
|
||||
planDate: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
durationDays: 1,
|
||||
owner: "",
|
||||
remark: "",
|
||||
status: "未开始",
|
||||
@@ -3831,7 +3954,10 @@ const openProjectMilestoneEditor = (index: number) => {
|
||||
openIndexedEditor(setupDraft.projectMilestones, index, projectMilestoneEditor, (row) => {
|
||||
projectMilestoneEditorForm.id = row.id || "";
|
||||
projectMilestoneEditorForm.name = row.name || "";
|
||||
projectMilestoneEditorForm.planDate = row.planDate || "";
|
||||
projectMilestoneEditorForm.startDate = resolveProjectMilestoneStart(row);
|
||||
projectMilestoneEditorForm.endDate = resolveProjectMilestoneEnd(row);
|
||||
projectMilestoneEditorForm.durationDays = resolveProjectMilestoneDurationDays(row) || 1;
|
||||
projectMilestoneEditorForm.planDate = resolveProjectMilestoneStart(row);
|
||||
projectMilestoneEditorForm.owner = row.owner || "";
|
||||
projectMilestoneEditorForm.remark = row.remark || "";
|
||||
projectMilestoneEditorForm.status = row.status || "未开始";
|
||||
@@ -3846,14 +3972,28 @@ const saveProjectMilestoneEditor = () => {
|
||||
ElMessage.warning("请填写里程碑名称");
|
||||
return;
|
||||
}
|
||||
if (!projectMilestoneEditorForm.planDate) {
|
||||
ElMessage.warning("请选择计划日期");
|
||||
const startDate = normalizePlanDate(projectMilestoneEditorForm.startDate || projectMilestoneEditorForm.planDate);
|
||||
const endDate = normalizePlanDate(projectMilestoneEditorForm.endDate || projectMilestoneEditorForm.planDate);
|
||||
if (!startDate) {
|
||||
ElMessage.warning("请选择开始日期");
|
||||
return;
|
||||
}
|
||||
if (!endDate) {
|
||||
ElMessage.warning("请选择结束日期");
|
||||
return;
|
||||
}
|
||||
if (startDate > endDate) {
|
||||
ElMessage.warning("结束日期不能早于开始日期");
|
||||
return;
|
||||
}
|
||||
const durationDays = calculateDurationDays(startDate, endDate);
|
||||
setupDraft.projectMilestones[index] = {
|
||||
id: projectMilestoneEditorForm.id || makeId(),
|
||||
name,
|
||||
planDate: projectMilestoneEditorForm.planDate,
|
||||
planDate: startDate,
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays: durationDays > 0 ? durationDays : 1,
|
||||
owner: (projectMilestoneEditorForm.owner || "").trim(),
|
||||
remark: (projectMilestoneEditorForm.remark || "").trim(),
|
||||
status: projectMilestoneEditorForm.status || "未开始",
|
||||
@@ -4468,7 +4608,14 @@ onBeforeUnmount(() => {
|
||||
.enrollment-inline-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5f7499;
|
||||
color: #4a6283;
|
||||
background: #f3f6fb;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e6edf7;
|
||||
}
|
||||
|
||||
.enrollment-inline-label.required::before {
|
||||
@@ -4478,6 +4625,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.enrollment-date-cell {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.enrollment-date-cell :deep(.el-date-editor) {
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
@@ -4488,12 +4639,18 @@ onBeforeUnmount(() => {
|
||||
.enrollment-inline-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #102a4d;
|
||||
color: #11264d;
|
||||
}
|
||||
|
||||
.enrollment-sep {
|
||||
color: #8da3c7;
|
||||
font-weight: 600;
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.enrollment-plan-summary {
|
||||
@@ -4515,6 +4672,11 @@ onBeforeUnmount(() => {
|
||||
color: #38537b;
|
||||
}
|
||||
|
||||
.summary-site-name {
|
||||
font-weight: 700;
|
||||
color: #11264d;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-weight: 700;
|
||||
color: #284a7b;
|
||||
@@ -4525,6 +4687,11 @@ onBeforeUnmount(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-success {
|
||||
color: #2f7a4d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.enrollment-plan-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4858,13 +5025,15 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.info-group {
|
||||
padding-top: 4px;
|
||||
padding: 16px 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef3fb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(22, 47, 86, 0.03);
|
||||
}
|
||||
|
||||
.info-group + .info-group {
|
||||
margin-top: 2px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eaf1fa;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.info-group-title {
|
||||
@@ -4919,30 +5088,45 @@ onBeforeUnmount(() => {
|
||||
color: #284a7a;
|
||||
}
|
||||
|
||||
.basic-edit-group {
|
||||
padding: 18px 20px 4px;
|
||||
background: #f8fbff;
|
||||
border: 1px solid #e6edf7;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.basic-edit-group + .basic-edit-group {
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eaf1fa;
|
||||
margin-top: 0;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #e6edf7;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
min-height: 28px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #7085ab;
|
||||
color: #5f7499;
|
||||
min-width: 112px;
|
||||
font-size: 14px;
|
||||
background: #f4f8ff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #0f2345;
|
||||
color: #11264d;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.setup-info-tabs {
|
||||
@@ -4959,8 +5143,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.setup-section :deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eef3fb;
|
||||
}
|
||||
|
||||
.setup-section :deep(.el-table th.el-table__cell) {
|
||||
@@ -4998,6 +5183,33 @@ onBeforeUnmount(() => {
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.milestone-plan-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.milestone-plan-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.milestone-plan-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #2f84c6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.milestone-plan-label {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.milestone-actions-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -5115,6 +5327,13 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.publish-diff-overview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.conflict-readable-diff :deep(.el-table th.el-table__cell) {
|
||||
background: #f7faff;
|
||||
color: #2f4672;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
|
||||
<el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules">
|
||||
<el-form-item :label="TEXT.modules.adminProjectMembers.user" prop="user_id">
|
||||
<el-select v-model="newMember.user_id" filterable :placeholder="TEXT.modules.adminProjectMembers.userPlaceholder">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<div class="tip">{{ TEXT.modules.adminSites.sitePrefix }}{{ site?.name }}</div>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item :label="TEXT.modules.adminSites.craSelect">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="site ? TEXT.modules.adminSites.editTitle : TEXT.modules.adminSites.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="site ? TEXT.modules.adminSites.editTitle : TEXT.modules.adminSites.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.siteName" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.siteName" />
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="unified-section site-table-section">
|
||||
<el-table :data="displaySites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="220">
|
||||
<el-table :data="displaySites" v-loading="loading" stripe :row-class-name="siteRowClass" style="width: 100%">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="220">
|
||||
<template #default="scope">
|
||||
<div class="site-name-cell">
|
||||
<span>{{ scope.row.name }}</span>
|
||||
@@ -26,7 +26,7 @@
|
||||
{{ phoneText(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contact" :label="TEXT.common.fields.contact" width="160">
|
||||
<el-table-column prop="contact" :label="TEXT.common.fields.contact" min-width="160">
|
||||
<template #default="scope">
|
||||
{{ contactLabel(scope.row) }}
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.email" prop="email">
|
||||
<el-input v-model="form.email" :disabled="!!user" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email" />
|
||||
@@ -104,7 +104,7 @@ watch(
|
||||
form.email = props.user.email;
|
||||
form.full_name = props.user.full_name;
|
||||
form.department = props.user.department;
|
||||
form.is_active = props.user.is_active;
|
||||
form.is_active = props.user.is_active ?? true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="TEXT.modules.adminUsers.resetTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminUsers.resetTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="110px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.versionNo" prop="version_no">
|
||||
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" />
|
||||
@@ -169,7 +169,7 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="distributeVisible" :title="TEXT.modules.fileVersionManagement.dialog.distributeTitle" width="600px" destroy-on-close class="ctms-dialog">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="distributeVisible" :title="TEXT.modules.fileVersionManagement.dialog.distributeTitle" width="600px" destroy-on-close class="ctms-dialog">
|
||||
<el-form :model="distributeForm" label-width="100px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList">
|
||||
<div class="target-list w-full">
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
</section>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="TEXT.modules.fileVersionManagement.dialog.createTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="createVisible" :title="TEXT.modules.fileVersionManagement.dialog.createTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docNo" prop="doc_no">
|
||||
<el-input v-model="createForm.doc_no" placeholder="e.g. SOP-001" />
|
||||
|
||||
@@ -1,13 +1,728 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.projectMilestones.title"
|
||||
:subtitle="TEXT.modules.projectMilestones.subtitle"
|
||||
:list-title="TEXT.modules.projectMilestones.listTitle"
|
||||
:empty-description="TEXT.modules.projectMilestones.emptyDescription"
|
||||
/>
|
||||
<div class="page">
|
||||
<div v-if="study.currentStudy" class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
|
||||
<div class="card-subtitle">
|
||||
{{ TEXT.modules.projectMilestones.sourceLabel }}{{ dataSourceLabel }}
|
||||
<span class="dot-sep">·</span>
|
||||
{{ TEXT.common.labels.updatedAt }}{{ updatedAtLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-tag size="small" effect="plain">
|
||||
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
|
||||
</el-tag>
|
||||
<el-tag size="small" type="success" effect="plain">
|
||||
{{ TEXT.modules.projectMilestones.statDone }}{{ doneCount }}
|
||||
</el-tag>
|
||||
<el-button size="small" @click="loadMilestones">{{ TEXT.common.actions.refresh }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" class="ctms-table" style="width: 100%">
|
||||
<el-table-column prop="name" :label="TEXT.modules.projectMilestones.columns.name" min-width="140" />
|
||||
<el-table-column prop="planDate" :label="TEXT.modules.projectMilestones.columns.planDate" min-width="170">
|
||||
<template #default="scope">
|
||||
<div class="plan-time-cell">
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-initial" />
|
||||
<span class="plan-time-label">开始:</span>
|
||||
<span>{{ scope.row.startDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-initial" />
|
||||
<span class="plan-time-label">结束:</span>
|
||||
<span>{{ scope.row.endDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-initial" />
|
||||
<span class="plan-time-label">耗时:</span>
|
||||
<span>{{ scope.row.durationText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.projectMilestones.columns.adjustedPlanDate" min-width="170">
|
||||
<template #default="scope">
|
||||
<div class="plan-time-cell">
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-adjusted" />
|
||||
<span class="plan-time-label">开始:</span>
|
||||
<span>{{ scope.row.adjustedStartDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-adjusted" />
|
||||
<span class="plan-time-label">结束:</span>
|
||||
<span>{{ scope.row.adjustedEndDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-adjusted" />
|
||||
<span class="plan-time-label">耗时:</span>
|
||||
<span>{{ scope.row.adjustedDurationText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.projectMilestones.columns.actualTime" min-width="170">
|
||||
<template #default="scope">
|
||||
<div class="plan-time-cell">
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-actual" />
|
||||
<span class="plan-time-label">开始:</span>
|
||||
<span>{{ scope.row.actualStartDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-actual" />
|
||||
<span class="plan-time-label">结束:</span>
|
||||
<span>{{ scope.row.actualEndDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-actual" />
|
||||
<span class="plan-time-label">耗时:</span>
|
||||
<span>{{ scope.row.actualDurationText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.modules.projectMilestones.columns.status" min-width="120">
|
||||
<template #default="scope">
|
||||
<div class="status-cell" :class="`status-${getStatusView(scope.row).tone}`">
|
||||
<div class="status-main">
|
||||
<span class="status-dot" />
|
||||
<span>{{ getStatusView(scope.row).title }}<span v-if="getStatusView(scope.row).detail">,</span></span>
|
||||
</div>
|
||||
<div v-if="getStatusView(scope.row).detail" class="status-detail">{{ getStatusView(scope.row).detail }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.modules.projectMilestones.columns.remark" min-width="100">
|
||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.projectMilestones.columns.operations" width="84" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<StateEmpty
|
||||
v-if="!loading && rows.length === 0"
|
||||
:description="TEXT.modules.projectMilestones.emptyDescription"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||
|
||||
<el-drawer
|
||||
v-model="editorVisible"
|
||||
direction="rtl"
|
||||
size="460px"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
class="milestone-editor-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="time-editor-title">编辑里程碑</div>
|
||||
</template>
|
||||
<el-form label-position="top" class="time-editor-form">
|
||||
<div class="time-editor-group">
|
||||
<div class="time-editor-group-title">
|
||||
<span class="group-dot group-dot-adjusted"></span>
|
||||
调整计划时间
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker v-model="editorForm.adjustedStartDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker v-model="editorForm.adjustedEndDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="duration-inline">
|
||||
<span class="duration-label">耗时</span>
|
||||
<span class="duration-value">{{ toDurationText(calcDurationDays(editorForm.adjustedStartDate, editorForm.adjustedEndDate)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-editor-group">
|
||||
<div class="time-editor-group-title">
|
||||
<span class="group-dot group-dot-actual"></span>
|
||||
实际时间
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker v-model="editorForm.actualStartDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker v-model="editorForm.actualEndDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="duration-inline">
|
||||
<span class="duration-label">耗时</span>
|
||||
<span class="duration-value">{{ toDurationText(calcDurationDays(editorForm.actualStartDate, editorForm.actualEndDate)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-editor-group">
|
||||
<div class="time-editor-group-title">
|
||||
<span class="group-dot group-dot-remark"></span>
|
||||
备注
|
||||
</div>
|
||||
<el-input v-model="editorForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="time-editor-footer">
|
||||
<el-button @click="editorVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" @click="saveEditor">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { fetchStudySetupConfig } from "../../api/studies";
|
||||
import { TEXT } from "../../locales";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import type { ProjectMilestoneDraft, StudySetupConfigResponse } from "../../types/setupConfig";
|
||||
|
||||
interface MilestoneRow {
|
||||
id: string;
|
||||
name: string;
|
||||
planDate: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
durationDays: number | null;
|
||||
durationText: string;
|
||||
adjustedStartDate: string;
|
||||
adjustedEndDate: string;
|
||||
adjustedDurationDays: number | null;
|
||||
adjustedDurationText: string;
|
||||
actualStartDate: string;
|
||||
actualEndDate: string;
|
||||
actualDurationDays: number | null;
|
||||
actualDurationText: string;
|
||||
owner: string;
|
||||
status: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
type MilestoneLocalEdit = {
|
||||
adjustedStartDate?: string;
|
||||
adjustedEndDate?: string;
|
||||
actualStartDate?: string;
|
||||
actualEndDate?: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
const study = useStudyStore();
|
||||
const rows = ref<MilestoneRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourceDraft);
|
||||
const updatedAtLabel = ref<string>(TEXT.common.fallback);
|
||||
const editorVisible = ref(false);
|
||||
const editingRowId = ref("");
|
||||
const localEdits = ref<Record<string, MilestoneLocalEdit>>({});
|
||||
const editorForm = reactive<MilestoneLocalEdit>({
|
||||
adjustedStartDate: "",
|
||||
adjustedEndDate: "",
|
||||
actualStartDate: "",
|
||||
actualEndDate: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length);
|
||||
|
||||
const getLocalEditKey = (studyId: string) => `ctms_project_milestones_edits_${studyId}`;
|
||||
|
||||
const toDateOnly = (value?: string): Date | null => {
|
||||
if (!value) return null;
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
};
|
||||
|
||||
const calcDurationDays = (start?: string, end?: string): number | null => {
|
||||
if (!start || !end) return null;
|
||||
const startDate = toDateOnly(start);
|
||||
const endDate = toDateOnly(end);
|
||||
if (!startDate || !endDate || endDate < startDate) return null;
|
||||
return Math.floor((endDate.getTime() - startDate.getTime()) / 86400000) + 1;
|
||||
};
|
||||
|
||||
const toDurationText = (days: number | null) => (days && days > 0 ? `${days}天` : TEXT.common.fallback);
|
||||
const toPositiveInt = (value: unknown): number | null => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return Math.floor(n);
|
||||
};
|
||||
|
||||
const applyEditToRow = (row: MilestoneRow): MilestoneRow => {
|
||||
const patch = localEdits.value[row.id] || {};
|
||||
const adjustedStartDate = String(patch.adjustedStartDate ?? row.adjustedStartDate ?? "").trim();
|
||||
const adjustedEndDate = String(patch.adjustedEndDate ?? row.adjustedEndDate ?? "").trim();
|
||||
const actualStartDate = String(patch.actualStartDate ?? row.actualStartDate ?? "").trim();
|
||||
const actualEndDate = String(patch.actualEndDate ?? row.actualEndDate ?? "").trim();
|
||||
const adjustedDurationDays = calcDurationDays(adjustedStartDate, adjustedEndDate);
|
||||
const actualDurationDays = calcDurationDays(actualStartDate, actualEndDate);
|
||||
return {
|
||||
...row,
|
||||
adjustedStartDate,
|
||||
adjustedEndDate,
|
||||
adjustedDurationDays,
|
||||
adjustedDurationText: toDurationText(adjustedDurationDays),
|
||||
actualStartDate,
|
||||
actualEndDate,
|
||||
actualDurationDays,
|
||||
actualDurationText: toDurationText(actualDurationDays),
|
||||
remark: String(patch.remark ?? row.remark ?? "").trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const loadLocalEdits = () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(getLocalEditKey(studyId));
|
||||
localEdits.value = raw ? (JSON.parse(raw) as Record<string, MilestoneLocalEdit>) : {};
|
||||
} catch {
|
||||
localEdits.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const persistLocalEdits = () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
localStorage.setItem(getLocalEditKey(studyId), JSON.stringify(localEdits.value));
|
||||
};
|
||||
|
||||
const normalizeRows = (items: ProjectMilestoneDraft[]) =>
|
||||
items
|
||||
.map((item, index) => {
|
||||
const planDate = String(item.planDate || "").trim();
|
||||
const startDate = String(item.startDate || planDate).trim();
|
||||
const endDate = String(item.endDate || planDate).trim();
|
||||
const adjustedStartDate = String((item as any).adjustedStartDate || "").trim();
|
||||
const adjustedEndDate = String((item as any).adjustedEndDate || "").trim();
|
||||
const actualStartDate = String((item as any).actualStartDate || "").trim();
|
||||
const actualEndDate = String((item as any).actualEndDate || "").trim();
|
||||
const rawDays = toPositiveInt(item.durationDays);
|
||||
const adjustedRawDays = toPositiveInt((item as any).adjustedDurationDays) ?? calcDurationDays(adjustedStartDate, adjustedEndDate);
|
||||
const actualRawDays = toPositiveInt((item as any).actualDurationDays) ?? calcDurationDays(actualStartDate, actualEndDate);
|
||||
const durationDays = rawDays ?? (startDate || endDate ? 1 : null);
|
||||
const adjustedDurationDays = adjustedRawDays ?? (adjustedStartDate || adjustedEndDate ? 1 : null);
|
||||
const actualDurationDays = actualRawDays ?? (actualStartDate || actualEndDate ? 1 : null);
|
||||
return {
|
||||
id: item.id || `setup-project-milestone-${index + 1}`,
|
||||
name: String(item.name || "").trim(),
|
||||
planDate,
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays,
|
||||
durationText: durationDays ? `${durationDays}天` : TEXT.common.fallback,
|
||||
adjustedStartDate,
|
||||
adjustedEndDate,
|
||||
adjustedDurationDays,
|
||||
adjustedDurationText: adjustedDurationDays ? `${adjustedDurationDays}天` : TEXT.common.fallback,
|
||||
actualStartDate,
|
||||
actualEndDate,
|
||||
actualDurationDays,
|
||||
actualDurationText: actualDurationDays ? `${actualDurationDays}天` : TEXT.common.fallback,
|
||||
owner: String(item.owner || "").trim(),
|
||||
status: String(item.status || "未开始").trim(),
|
||||
remark: String(item.remark || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name || item.startDate || item.endDate || item.owner || item.remark);
|
||||
|
||||
const pickProjectMilestones = (setup: StudySetupConfigResponse): ProjectMilestoneDraft[] => {
|
||||
const publishedRows = normalizeRows(setup.published_data?.projectMilestones || []);
|
||||
if (publishedRows.length > 0) {
|
||||
dataSourceLabel.value = TEXT.modules.projectMilestones.sourcePublished;
|
||||
return setup.published_data?.projectMilestones || [];
|
||||
}
|
||||
dataSourceLabel.value = setup.published_data ? TEXT.modules.projectMilestones.sourceDraftFallback : TEXT.modules.projectMilestones.sourceDraft;
|
||||
return setup.data?.projectMilestones || [];
|
||||
};
|
||||
|
||||
const loadMilestones = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchStudySetupConfig(studyId);
|
||||
loadLocalEdits();
|
||||
rows.value = normalizeRows(pickProjectMilestones(data)).map(applyEditToRow);
|
||||
updatedAtLabel.value = displayDateTime(data.published_at || data.updated_at);
|
||||
} catch (error: any) {
|
||||
rows.value = [];
|
||||
updatedAtLabel.value = TEXT.common.fallback;
|
||||
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const daysDiff = (start: string, end: string): number => {
|
||||
const a = toDateOnly(start);
|
||||
const b = toDateOnly(end);
|
||||
if (!a || !b) return 0;
|
||||
return Math.floor((b.getTime() - a.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
const todayDateText = () => {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
};
|
||||
|
||||
const getStatusView = (row: MilestoneRow): { title: string; detail: string; tone: "success" | "warning" | "danger" | "info"; completed: boolean } => {
|
||||
const planEnd = row.adjustedEndDate || row.endDate || "";
|
||||
const actualStart = row.actualStartDate || "";
|
||||
const actualEnd = row.actualEndDate || "";
|
||||
const today = todayDateText();
|
||||
|
||||
if (actualEnd) {
|
||||
if (planEnd && actualEnd > planEnd) {
|
||||
const lateDays = daysDiff(planEnd, actualEnd);
|
||||
return {
|
||||
title: "延期完成",
|
||||
detail: lateDays > 0 ? `延迟 ${lateDays} 天` : "",
|
||||
tone: "danger",
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
if (planEnd && actualEnd < planEnd) {
|
||||
const earlyDays = daysDiff(actualEnd, planEnd);
|
||||
return {
|
||||
title: "提前完成",
|
||||
detail: earlyDays > 0 ? `提前 ${earlyDays} 天` : "",
|
||||
tone: "success",
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return { title: "已完成", detail: "", tone: "success", completed: true };
|
||||
}
|
||||
|
||||
if (actualStart) {
|
||||
if (planEnd && today > planEnd) {
|
||||
const lateDays = daysDiff(planEnd, today);
|
||||
return {
|
||||
title: "延期进行中",
|
||||
detail: lateDays > 0 ? `延迟 ${lateDays} 天` : "",
|
||||
tone: "danger",
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
return { title: "进行中", detail: "", tone: "warning", completed: false };
|
||||
}
|
||||
|
||||
if (planEnd && today > planEnd) {
|
||||
const lateDays = daysDiff(planEnd, today);
|
||||
return {
|
||||
title: "延期未开始",
|
||||
detail: lateDays > 0 ? `延迟 ${lateDays} 天` : "",
|
||||
tone: "danger",
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { title: "未开始", detail: "", tone: "info", completed: false };
|
||||
};
|
||||
|
||||
const validateDateRange = (start?: string, end?: string, label?: string) => {
|
||||
if ((start && !end) || (!start && end)) {
|
||||
ElMessage.warning(`${label}开始和结束日期需同时填写`);
|
||||
return false;
|
||||
}
|
||||
if (start && end && start > end) {
|
||||
ElMessage.warning(`${label}结束日期不能早于开始日期`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const openEditor = (row: MilestoneRow) => {
|
||||
editingRowId.value = row.id;
|
||||
editorForm.adjustedStartDate = row.adjustedStartDate || "";
|
||||
editorForm.adjustedEndDate = row.adjustedEndDate || "";
|
||||
editorForm.actualStartDate = row.actualStartDate || "";
|
||||
editorForm.actualEndDate = row.actualEndDate || "";
|
||||
editorForm.remark = row.remark || "";
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const saveEditor = () => {
|
||||
if (!editingRowId.value) return;
|
||||
if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return;
|
||||
if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return;
|
||||
const patch: MilestoneLocalEdit = {
|
||||
adjustedStartDate: editorForm.adjustedStartDate || "",
|
||||
adjustedEndDate: editorForm.adjustedEndDate || "",
|
||||
actualStartDate: editorForm.actualStartDate || "",
|
||||
actualEndDate: editorForm.actualEndDate || "",
|
||||
remark: (editorForm.remark || "").trim(),
|
||||
};
|
||||
localEdits.value = {
|
||||
...localEdits.value,
|
||||
[editingRowId.value]: patch,
|
||||
};
|
||||
persistLocalEdits();
|
||||
rows.value = rows.value.map((item) => (item.id === editingRowId.value ? applyEditToRow(item) : item));
|
||||
editorVisible.value = false;
|
||||
ElMessage.success("里程碑时间已更新");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadMilestones();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => study.currentStudy?.id,
|
||||
() => {
|
||||
rows.value = [];
|
||||
localEdits.value = {};
|
||||
updatedAtLabel.value = TEXT.common.fallback;
|
||||
loadMilestones();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f2345;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.dot-sep {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-time-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.plan-time-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.plan-time-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #2f84c6;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.plan-time-label {
|
||||
color: #4b5563;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.plan-time-dot-initial {
|
||||
border-color: #2f84c6;
|
||||
}
|
||||
|
||||
.plan-time-dot-adjusted {
|
||||
border-color: #f0ad2c;
|
||||
}
|
||||
|
||||
.plan-time-dot-actual {
|
||||
border-color: #67c23a;
|
||||
}
|
||||
|
||||
.time-editor-form {
|
||||
padding-top: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.time-editor-group {
|
||||
border: 1px solid #e8eef6;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.time-editor-group + .time-editor-group {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.time-editor-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.time-editor-group-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: #1a3560;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-dot-adjusted {
|
||||
background: #f0ad2c;
|
||||
}
|
||||
|
||||
.group-dot-actual {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.group-dot-remark {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
.duration-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0 2px;
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a3560;
|
||||
}
|
||||
|
||||
.time-editor-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.time-editor-footer :deep(.el-button--primary) {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time-editor-form :deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.time-editor-form :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4a6283;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
line-height: 1.3;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
padding-left: 14px;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-success .status-dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status-warning .status-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.status-danger .status-dot {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.status-info .status-dot {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
:deep(.ctms-table th.el-table__cell .cell),
|
||||
:deep(.ctms-table td.el-table__cell .cell) {
|
||||
font-size: 11px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<section class="unified-section">
|
||||
<div class="overview-meta-inline">
|
||||
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
|
||||
<div class="updated-at">更新:{{ nowLabel }}</div>
|
||||
<div class="updated-at">更新:{{ updatedAtLabel }}</div>
|
||||
<el-button size="small" @click="loadOverview">刷新</el-button>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
@@ -61,7 +61,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { TEXT } from "../../locales";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
@@ -71,7 +71,7 @@ import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
|
||||
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
|
||||
import { adaptProjectOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
|
||||
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
|
||||
import { overviewMock } from "./project-overview/overview.mock";
|
||||
|
||||
const study = useStudyStore();
|
||||
@@ -79,26 +79,27 @@ const loading = ref(false);
|
||||
const usingDemo = ref(false);
|
||||
const overview = ref<ProjectOverviewViewModel | null>(null);
|
||||
const chartMode = ref<"center" | "month">("center");
|
||||
const updatedAtLabel = ref(displayDateTime(new Date()));
|
||||
|
||||
const preferApi = String(import.meta.env.VITE_USE_OVERVIEW_API || "").toLowerCase() === "true";
|
||||
|
||||
const centers = computed(() => overview.value?.centers || []);
|
||||
const nowLabel = ref(displayDateTime(new Date()));
|
||||
|
||||
let clockTimer: number | undefined;
|
||||
const startClock = () => {
|
||||
if (clockTimer) return;
|
||||
clockTimer = window.setInterval(() => {
|
||||
nowLabel.value = displayDateTime(new Date());
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopClock = () => {
|
||||
if (clockTimer) {
|
||||
window.clearInterval(clockTimer);
|
||||
clockTimer = undefined;
|
||||
}
|
||||
};
|
||||
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
|
||||
siteList.map((site: any) => ({
|
||||
center_id: String(site?.id || ""),
|
||||
center_name: String(site?.name || TEXT.common.fallback),
|
||||
institution_initiation_status: "NOT_STARTED",
|
||||
ethics_status: "NOT_STARTED",
|
||||
contract_sign_status: "NOT_STARTED",
|
||||
startup_status: "NOT_STARTED",
|
||||
enrollment_status: "NOT_STARTED",
|
||||
inspection_status: "NOT_STARTED",
|
||||
closeout_status: "NOT_STARTED",
|
||||
enrollment_target: Number(site?.enrollment_target || 0),
|
||||
enrollment_actual: 0,
|
||||
is_active: site?.is_active !== false,
|
||||
}));
|
||||
|
||||
const enrollmentSummary = computed(() => {
|
||||
const summary = overview.value?.summary;
|
||||
@@ -140,11 +141,21 @@ const loadOverview = async () => {
|
||||
const { data } = await fetchProjectOverview(studyId);
|
||||
overview.value = adaptProjectOverview(data);
|
||||
usingDemo.value = false;
|
||||
updatedAtLabel.value = overview.value.updated_at
|
||||
? displayDateTime(overview.value.updated_at)
|
||||
: displayDateTime(new Date());
|
||||
} else {
|
||||
overview.value = adaptProjectOverview(overviewMock);
|
||||
usingDemo.value = true;
|
||||
updatedAtLabel.value = overview.value.updated_at
|
||||
? displayDateTime(overview.value.updated_at)
|
||||
: displayDateTime(new Date());
|
||||
}
|
||||
|
||||
|
||||
if (overview.value && overview.value.centers.length === 0 && siteList.length > 0) {
|
||||
overview.value.centers = buildFallbackCentersFromSites(siteList);
|
||||
}
|
||||
|
||||
if (overview.value) {
|
||||
overview.value.centers.forEach(c => {
|
||||
if (siteActiveMap.has(c.center_id)) {
|
||||
@@ -157,6 +168,9 @@ const loadOverview = async () => {
|
||||
} catch {
|
||||
overview.value = adaptProjectOverview(overviewMock);
|
||||
usingDemo.value = true;
|
||||
updatedAtLabel.value = overview.value.updated_at
|
||||
? displayDateTime(overview.value.updated_at)
|
||||
: displayDateTime(new Date());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -165,15 +179,11 @@ const loadOverview = async () => {
|
||||
const reset = () => {
|
||||
overview.value = null;
|
||||
usingDemo.value = false;
|
||||
updatedAtLabel.value = displayDateTime(new Date());
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview();
|
||||
startClock();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopClock();
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@@ -77,6 +77,7 @@ export const overviewMock: ProjectOverviewResponse = {
|
||||
center_name: "中心05",
|
||||
institution_initiation_status: "IN_PROGRESS",
|
||||
ethics_status: "IN_PROGRESS",
|
||||
contract_sign_status: "NOT_STARTED",
|
||||
startup_status: "NOT_STARTED",
|
||||
enrollment_status: "NOT_STARTED",
|
||||
inspection_status: "NOT_STARTED",
|
||||
|
||||
@@ -65,7 +65,7 @@ const sites = ref<Site[]>([]);
|
||||
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
||||
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
||||
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
|
||||
const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.value.get(siteId || "") === false;
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_id: "",
|
||||
|
||||
@@ -64,7 +64,7 @@ const sites = ref<Site[]>([]);
|
||||
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
||||
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
||||
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
|
||||
const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.value.get(siteId || "") === false;
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_id: "",
|
||||
|
||||
@@ -426,15 +426,13 @@ const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
onMounted(async () => {
|
||||
if (studyId) {
|
||||
const requests = [listMembers(studyId, { limit: 500 })];
|
||||
if (isAdmin.value) {
|
||||
requests.push(fetchUsers({ limit: 500 }));
|
||||
}
|
||||
const [membersResp, usersResp] = await Promise.all(requests).catch(() => [null, null]);
|
||||
const membersReq = listMembers(studyId, { limit: 500 });
|
||||
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
|
||||
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
|
||||
if (membersResp?.data) {
|
||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||
}
|
||||
if (usersResp?.data) {
|
||||
if (usersResp?.data && !Array.isArray(usersResp.data)) {
|
||||
users.value = usersResp.data.items || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,15 +179,13 @@ const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
onMounted(async () => {
|
||||
if (studyId.value) {
|
||||
const requests = [listMembers(studyId.value, { limit: 500 })];
|
||||
if (isAdmin.value) {
|
||||
requests.push(fetchUsers({ limit: 500 }));
|
||||
}
|
||||
const [membersResp, usersResp] = await Promise.all(requests).catch(() => [null, null]);
|
||||
const membersReq = listMembers(studyId.value, { limit: 500 });
|
||||
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
|
||||
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
|
||||
if (membersResp?.data) {
|
||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||
}
|
||||
if (usersResp?.data) {
|
||||
if (usersResp?.data && !Array.isArray(usersResp.data)) {
|
||||
users.value = usersResp.data.items || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="historyDialogVisible" :title="TEXT.modules.subjectDetail.dialogHistory" width="520px">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="historyDialogVisible" :title="TEXT.modules.subjectDetail.dialogHistory" width="520px">
|
||||
<el-form :model="historyForm" label-width="90px">
|
||||
<el-form-item :label="TEXT.common.fields.recordDate">
|
||||
<el-date-picker v-model="historyForm.record_date" type="date" value-format="YYYY-MM-DD" />
|
||||
@@ -273,7 +273,7 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="visitDialogVisible" :title="TEXT.modules.subjectDetail.dialogVisit" width="560px">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="visitDialogVisible" :title="TEXT.modules.subjectDetail.dialogVisit" width="560px">
|
||||
<el-form :model="visitForm" label-width="100px">
|
||||
<template v-if="!visitEditingId">
|
||||
<el-form-item :label="TEXT.common.fields.visitCode" required>
|
||||
@@ -304,7 +304,7 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px">
|
||||
<el-form :model="aeForm" label-width="100px">
|
||||
<el-form-item :label="TEXT.common.fields.title" required>
|
||||
<el-input v-model="aeForm.term" />
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user