项目里程碑初步优化

This commit is contained in:
Cheng Zhou
2026-02-27 09:06:06 +08:00
parent 8f3f717e48
commit fd7e3fc948
47 changed files with 2029 additions and 783 deletions
+1 -1
View File
@@ -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
View File
@@ -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 = "未开始"
-246
View File
@@ -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 -84
View File
@@ -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}")
-14
View File
@@ -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
View File
@@ -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>
+8 -8
View File
@@ -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;
-6
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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">
+1 -1
View File
@@ -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,
};
};
+20 -5
View File
@@ -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: "结果",
},
},
+3
View File
@@ -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;
+7 -2
View File
@@ -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();
};
+72
View File
@@ -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,
};
});
+1 -1
View File
@@ -463,4 +463,4 @@ body {
.text-sm {
font-size: 13px;
}
}
+3
View File
@@ -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;
+1 -5
View File
@@ -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;
};
+176 -19
View File
@@ -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
View File
@@ -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"
+11 -7
View File
@@ -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;
+210 -39
View File
@@ -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>
+342 -123
View File
@@ -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 -1
View File
@@ -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" />
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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" />
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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" />
+722 -7
View File
@@ -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>
+34 -24
View File
@@ -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",
+1 -1
View File
@@ -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: "",
+4 -6
View File
@@ -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 || [];
}
}
+4 -6
View File
@@ -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" />
+1
View File
@@ -10,6 +10,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ESNext", "DOM"],
"types": ["node", "vite/client"]
},