清除“Task”模块

This commit is contained in:
Cheng Zhou
2025-12-19 11:02:21 +08:00
parent 81412fe906
commit b475d4f763
30 changed files with 58 additions and 1090 deletions
+1 -2
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.constants import ae, finance, imp, issue, subject, task from app.constants import ae, finance, imp, issue, subject
from app.core.deps import get_current_user from app.core.deps import get_current_user
router = APIRouter(tags=["FAQ"], dependencies=[Depends(get_current_user)]) router = APIRouter(tags=["FAQ"], dependencies=[Depends(get_current_user)])
@@ -13,7 +13,6 @@ router = APIRouter(tags=["FAQ"], dependencies=[Depends(get_current_user)])
) )
async def get_constants(): async def get_constants():
return { return {
"task": {"status": task.TASK_STATUS, "priority": task.TASK_PRIORITY},
"subject": {"status": subject.SUBJECT_STATUS, "visit_status": subject.VISIT_STATUS}, "subject": {"status": subject.SUBJECT_STATUS, "visit_status": subject.VISIT_STATUS},
"ae": {"seriousness": ae.AE_SERIOUSNESS, "severity": ae.AE_SEVERITY, "status": ae.AE_STATUS}, "ae": {"seriousness": ae.AE_SERIOUSNESS, "severity": ae.AE_SEVERITY, "status": ae.AE_STATUS},
"issue": {"category": issue.ISSUE_CATEGORY, "level": issue.ISSUE_LEVEL, "status": issue.ISSUE_STATUS}, "issue": {"category": issue.ISSUE_CATEGORY, "level": issue.ISSUE_LEVEL, "status": issue.ISSUE_STATUS},
+1 -20
View File
@@ -1,13 +1,10 @@
import uuid import uuid
from datetime import date
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member from app.core.deps import get_db_session, require_study_member
from app.models.milestone import Milestone from app.models.milestone import Milestone
from app.models.task import Task
from app.schemas.progress import StudyProgressRead from app.schemas.progress import StudyProgressRead
router = APIRouter() router = APIRouter()
@@ -18,34 +15,18 @@ async def get_progress(
study_id: uuid.UUID, study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> StudyProgressRead: ) -> StudyProgressRead:
# Milestones
milestone_total_stmt = select(func.count()).select_from(Milestone).where(Milestone.study_id == study_id) milestone_total_stmt = select(func.count()).select_from(Milestone).where(Milestone.study_id == study_id)
milestone_done_stmt = select(func.count()).select_from(Milestone).where( milestone_done_stmt = select(func.count()).select_from(Milestone).where(
Milestone.study_id == study_id, Milestone.status == "DONE" Milestone.study_id == study_id, Milestone.status == "DONE"
) )
task_total_stmt = select(func.count()).select_from(Task).where(Task.study_id == study_id)
task_done_stmt = select(func.count()).select_from(Task).where(Task.study_id == study_id, Task.status == "DONE")
task_overdue_stmt = select(func.count()).select_from(Task).where(
Task.study_id == study_id,
Task.due_date < date.today(),
Task.status != "DONE",
)
milestone_total = (await db.execute(milestone_total_stmt)).scalar_one() milestone_total = (await db.execute(milestone_total_stmt)).scalar_one()
milestone_done = (await db.execute(milestone_done_stmt)).scalar_one() milestone_done = (await db.execute(milestone_done_stmt)).scalar_one()
task_total = (await db.execute(task_total_stmt)).scalar_one()
task_done = (await db.execute(task_done_stmt)).scalar_one()
task_overdue = (await db.execute(task_overdue_stmt)).scalar_one()
total_items = milestone_total + task_total completion_rate = float(milestone_done / milestone_total) if milestone_total else 0.0
done_items = milestone_done + task_done
completion_rate = float(done_items / total_items) if total_items else 0.0
return StudyProgressRead( return StudyProgressRead(
milestones_total=milestone_total, milestones_total=milestone_total,
milestones_done=milestone_done, milestones_done=milestone_done,
tasks_total=task_total,
tasks_done=task_done,
tasks_overdue=task_overdue,
completion_rate=completion_rate, completion_rate=completion_rate,
) )
+1 -2
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -13,7 +13,6 @@ api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entit
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"]) api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"]) api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"]) api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"])
api_router.include_router(tasks.router, prefix="/studies/{study_id}/tasks", tags=["tasks"])
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"]) api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"]) api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"]) api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"])
-147
View File
@@ -1,147 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import milestone as milestone_crud
from app.crud import study as study_crud
from app.crud import task as task_crud
from app.crud import user as user_crud
from app.schemas.task import TaskCreate, TaskRead, TaskUpdate
from app.schemas.user import UserDisplay
router = APIRouter()
ALLOW_ASSIGNEE_UPDATE_STATUS = False
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
async def _validate_milestone(db: AsyncSession, study_id: uuid.UUID, milestone_id: uuid.UUID | None):
if milestone_id is None:
return
milestone = await milestone_crud.get(db, milestone_id)
if not milestone:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
if milestone.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Milestone does not belong to study")
@router.post(
"/",
response_model=TaskRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def create_task(
study_id: uuid.UUID,
task_in: TaskCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TaskRead:
await _ensure_study_exists(db, study_id)
await _validate_milestone(db, study_id, task_in.milestone_id)
task = await task_crud.create(db, study_id, task_in, created_by=current_user.id)
assignee = None
if task.assignee_id:
assignee = await user_crud.get_by_id(db, task.assignee_id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="task",
entity_id=task.id,
action="CREATE_TASK",
detail=f"Task {task.id} created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return _to_task_read(task, assignee)
@router.get(
"/",
response_model=list[TaskRead],
dependencies=[Depends(require_study_member())],
)
async def list_tasks(
study_id: uuid.UUID,
milestone_id: uuid.UUID | None = None,
assignee_id: uuid.UUID | None = None,
status: str | None = None,
db: AsyncSession = Depends(get_db_session),
) -> list[TaskRead]:
await _ensure_study_exists(db, study_id)
tasks = await task_crud.list_tasks(db, study_id, milestone_id=milestone_id, assignee_id=assignee_id, status=status)
assignee_ids = {t.assignee_id for t in tasks if t.assignee_id}
users_map = await user_crud.get_users_by_ids(db, assignee_ids)
result: list[TaskRead] = []
for t in tasks:
user = users_map.get(t.assignee_id)
result.append(_to_task_read(t, user))
return result
@router.patch(
"/{task_id}",
response_model=TaskRead,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def update_task(
study_id: uuid.UUID,
task_id: uuid.UUID,
task_in: TaskUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TaskRead:
await _ensure_study_exists(db, study_id)
task = await task_crud.get(db, task_id)
if not task or task.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
if task_in.milestone_id:
await _validate_milestone(db, study_id, task_in.milestone_id)
old_status = task.status
updated = await task_crud.update(db, task, task_in)
assignee = None
if updated.assignee_id:
assignee = await user_crud.get_by_id(db, updated.assignee_id)
detail = None
if task_in.status and task_in.status != old_status:
detail = f"task {task_id} status {old_status} -> {task_in.status}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="task",
entity_id=task_id,
action="UPDATE_TASK" if not detail else "TASK_STATUS_CHANGE",
detail=detail or "Task updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return _to_task_read(updated, assignee)
def _to_task_read(task, assignee) -> TaskRead:
return TaskRead(
id=task.id,
study_id=task.study_id,
milestone_id=task.milestone_id,
title=task.title,
description=task.description,
assignee_id=task.assignee_id,
assignee=UserDisplay.model_validate(assignee) if assignee else None,
priority=task.priority,
due_date=task.due_date,
status=task.status,
completed_at=task.completed_at,
created_by=task.created_by,
created_at=task.created_at,
updated_at=task.updated_at,
)
-2
View File
@@ -1,2 +0,0 @@
TASK_STATUS = ["TODO", "DOING", "DONE", "BLOCKED"]
TASK_PRIORITY = ["LOW", "MEDIUM", "HIGH"]
-77
View File
@@ -1,77 +0,0 @@
import uuid
from datetime import datetime, timezone
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.task import Task
from app.schemas.task import TaskCreate, TaskUpdate
async def create(
db: AsyncSession,
study_id: uuid.UUID,
task_in: TaskCreate,
*,
created_by: uuid.UUID,
) -> Task:
task = Task(
study_id=study_id,
milestone_id=task_in.milestone_id,
title=task_in.title,
description=task_in.description,
assignee_id=task_in.assignee_id,
priority=task_in.priority,
due_date=task_in.due_date,
status="TODO",
created_by=created_by,
)
db.add(task)
await db.commit()
await db.refresh(task)
return task
async def get(db: AsyncSession, task_id: uuid.UUID) -> Task | None:
result = await db.execute(select(Task).where(Task.id == task_id))
return result.scalar_one_or_none()
async def list_tasks(
db: AsyncSession,
study_id: uuid.UUID,
milestone_id: uuid.UUID | None = None,
assignee_id: uuid.UUID | None = None,
status: str | None = None,
) -> Sequence[Task]:
stmt = select(Task).where(Task.study_id == study_id)
if milestone_id:
stmt = stmt.where(Task.milestone_id == milestone_id)
if assignee_id:
stmt = stmt.where(Task.assignee_id == assignee_id)
if status:
stmt = stmt.where(Task.status == status)
stmt = stmt.order_by(Task.due_date)
result = await db.execute(stmt)
return result.scalars().all()
async def update(db: AsyncSession, task: Task, task_in: TaskUpdate) -> Task:
update_data = task_in.model_dump(exclude_unset=True)
if "status" in update_data:
status_change = update_data["status"]
if status_change == "DONE":
update_data["completed_at"] = datetime.now(timezone.utc)
else:
update_data["completed_at"] = None
if update_data:
await db.execute(
sa_update(Task)
.where(Task.id == task.id)
.values(**update_data)
)
await db.commit()
await db.refresh(task)
return task
-1
View File
@@ -9,7 +9,6 @@ from app.models.comment import Comment # noqa: F401
from app.models.attachment import Attachment # noqa: F401 from app.models.attachment import Attachment # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401 from app.models.audit_log import AuditLog # noqa: F401
from app.models.milestone import Milestone # noqa: F401 from app.models.milestone import Milestone # noqa: F401
from app.models.task import Task # noqa: F401
from app.models.subject import Subject # noqa: F401 from app.models.subject import Subject # noqa: F401
from app.models.visit import Visit # noqa: F401 from app.models.visit import Visit # noqa: F401
from app.models.ae import AdverseEvent # noqa: F401 from app.models.ae import AdverseEvent # noqa: F401
-1
View File
@@ -41,7 +41,6 @@ def create_app() -> FastAPI:
{"name": "attachments", "description": "通用附件"}, {"name": "attachments", "description": "通用附件"},
{"name": "audit-logs", "description": "审计日志"}, {"name": "audit-logs", "description": "审计日志"},
{"name": "milestones", "description": "里程碑"}, {"name": "milestones", "description": "里程碑"},
{"name": "tasks", "description": "任务"},
{"name": "dashboard", "description": "项目总览与统计"}, {"name": "dashboard", "description": "项目总览与统计"},
{"name": "subjects", "description": "受试者"}, {"name": "subjects", "description": "受试者"},
{"name": "visits", "description": "访视"}, {"name": "visits", "description": "访视"},
-28
View File
@@ -1,28 +0,0 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
milestone_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("milestones.id"), nullable=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
assignee_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="MEDIUM")
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="TODO")
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
-3
View File
@@ -4,7 +4,4 @@ from pydantic import BaseModel
class StudyProgressRead(BaseModel): class StudyProgressRead(BaseModel):
milestones_total: int milestones_total: int
milestones_done: int milestones_done: int
tasks_total: int
tasks_done: int
tasks_overdue: int
completion_rate: float completion_rate: float
-45
View File
@@ -1,45 +0,0 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
from app.schemas.user import UserDisplay
class TaskCreate(BaseModel):
milestone_id: Optional[uuid.UUID] = None
title: str
description: Optional[str] = None
assignee_id: Optional[uuid.UUID] = None
priority: str = "MEDIUM"
due_date: Optional[date] = None
class TaskUpdate(BaseModel):
milestone_id: Optional[uuid.UUID] = None
title: Optional[str] = None
description: Optional[str] = None
assignee_id: Optional[uuid.UUID] = None
priority: Optional[str] = None
due_date: Optional[date] = None
status: Optional[str] = None
class TaskRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
milestone_id: Optional[uuid.UUID]
title: str
description: Optional[str]
assignee_id: Optional[uuid.UUID]
assignee: Optional[UserDisplay] = None
priority: str
due_date: Optional[date]
status: str
completed_at: Optional[datetime]
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-24
View File
@@ -405,10 +405,8 @@ CREATE TABLE public.subjects (
ALTER TABLE public.subjects OWNER TO ctms_user; ALTER TABLE public.subjects OWNER TO ctms_user;
-- --
-- Name: tasks; Type: TABLE; Schema: public; Owner: ctms_user
-- --
CREATE TABLE public.tasks (
id uuid NOT NULL, id uuid NOT NULL,
study_id uuid NOT NULL, study_id uuid NOT NULL,
milestone_id uuid, milestone_id uuid,
@@ -425,7 +423,6 @@ CREATE TABLE public.tasks (
); );
ALTER TABLE public.tasks OWNER TO ctms_user;
-- --
-- Name: users; Type: TABLE; Schema: public; Owner: ctms_user -- Name: users; Type: TABLE; Schema: public; Owner: ctms_user
@@ -501,7 +498,6 @@ aaaaaaa1-aaaa-aaaa-aaaa-aaaaaaaaaaa1 44444444-4444-4444-4444-444444444444 555555
-- --
COPY public.attachments (id, study_id, entity_type, entity_id, filename, file_path, file_size, content_type, uploaded_by, uploaded_at, is_deleted) FROM stdin; COPY public.attachments (id, study_id, entity_type, entity_id, filename, file_path, file_size, content_type, uploaded_by, uploaded_at, is_deleted) FROM stdin;
aaaabbbb-cccc-dddd-eeee-ffff00001111 44444444-4444-4444-4444-444444444444 task 77777777-7777-7777-7777-777777777777 .pdf /uploads/demo/ethics.pdf 123456 application/pdf 33333333-3333-3333-3333-333333333333 2025-12-18 00:55:17.568504+00 f
\. \.
@@ -519,7 +515,6 @@ COPY public.audit_logs (id, study_id, entity_type, entity_id, action, detail, op
-- --
COPY public.comments (id, study_id, entity_type, entity_id, content, created_by, created_at, is_deleted) FROM stdin; COPY public.comments (id, study_id, entity_type, entity_id, content, created_by, created_at, is_deleted) FROM stdin;
22223333-4444-5555-6666-777788889999 44444444-4444-4444-4444-444444444444 task 77777777-7777-7777-7777-777777777777 33333333-3333-3333-3333-333333333333 2025-12-18 00:55:17.568504+00 f
\. \.
@@ -652,10 +647,8 @@ COPY public.subjects (id, study_id, site_id, subject_no, status, screening_date,
-- --
-- Data for Name: tasks; Type: TABLE DATA; Schema: public; Owner: ctms_user
-- --
COPY public.tasks (id, study_id, milestone_id, title, description, assignee_id, priority, due_date, status, completed_at, created_by, created_at, updated_at) FROM stdin;
77777777-7777-7777-7777-777777777777 44444444-4444-4444-4444-444444444444 66666666-6666-6666-6666-666666666666 33333333-3333-3333-3333-333333333333 HIGH 2025-01-20 DOING \N 22222222-2222-2222-2222-222222222222 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 77777777-7777-7777-7777-777777777777 44444444-4444-4444-4444-444444444444 66666666-6666-6666-6666-666666666666 33333333-3333-3333-3333-333333333333 HIGH 2025-01-20 DOING \N 22222222-2222-2222-2222-222222222222 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00
\. \.
@@ -834,11 +827,8 @@ ALTER TABLE ONLY public.subjects
-- --
-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user
-- --
ALTER TABLE ONLY public.tasks
ADD CONSTRAINT tasks_pkey PRIMARY KEY (id);
-- --
@@ -1139,10 +1129,8 @@ CREATE INDEX ix_subjects_study_id ON public.subjects USING btree (study_id);
-- --
-- Name: ix_tasks_study_id; Type: INDEX; Schema: public; Owner: ctms_user
-- --
CREATE INDEX ix_tasks_study_id ON public.tasks USING btree (study_id);
-- --
@@ -1573,35 +1561,23 @@ ALTER TABLE ONLY public.subjects
-- --
-- Name: tasks tasks_assignee_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user
-- --
ALTER TABLE ONLY public.tasks
ADD CONSTRAINT tasks_assignee_id_fkey FOREIGN KEY (assignee_id) REFERENCES public.users(id);
-- --
-- Name: tasks tasks_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user
-- --
ALTER TABLE ONLY public.tasks
ADD CONSTRAINT tasks_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id);
-- --
-- Name: tasks tasks_milestone_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user
-- --
ALTER TABLE ONLY public.tasks
ADD CONSTRAINT tasks_milestone_id_fkey FOREIGN KEY (milestone_id) REFERENCES public.milestones(id);
-- --
-- Name: tasks tasks_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user
-- --
ALTER TABLE ONLY public.tasks
ADD CONSTRAINT tasks_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id);
-- --
-1
View File
@@ -1 +0,0 @@
import{d as K,i as g,j as C,k as U,r,c as S,o as s,w as d,a as p,g as c,b as u,l as b,f as m,F as I,h as N,t as B,_ as D}from"./index-BgaJtbRI.js";const T={class:"input-row"},F={key:0,class:"todo-list"},J={key:1,class:"empty"},O=K({__name:"PersonalTodo",props:{storageKey:{}},setup(k){const n=k,l=g(""),t=g([]),_=()=>{if(!n.storageKey)return;const o=localStorage.getItem(n.storageKey);if(o)try{t.value=JSON.parse(o)||[]}catch{t.value=[]}},i=()=>{n.storageKey&&localStorage.setItem(n.storageKey,JSON.stringify(t.value))},v=()=>{const o=l.value.trim();o&&(t.value.unshift({id:crypto.randomUUID?crypto.randomUUID():Date.now().toString(),text:o,done:!1}),l.value="",i())},V=o=>{t.value=t.value.filter(e=>e.id!==o),i()};return C(()=>n.storageKey,()=>_(),{immediate:!0}),U(()=>{_()}),(o,e)=>{const h=r("el-input"),y=r("el-button"),x=r("el-checkbox"),w=r("el-card");return s(),S(w,{shadow:"never",class:"todo-card"},{default:d(()=>[e[3]||(e[3]=p("div",{class:"section-header"},[p("span",null,"个人待办(本地)")],-1)),p("div",T,[u(h,{modelValue:l.value,"onUpdate:modelValue":e[0]||(e[0]=a=>l.value=a),placeholder:"记录一个备忘...",onKeyup:b(v,["enter","native"])},null,8,["modelValue"]),u(y,{type:"primary",onClick:v},{default:d(()=>[...e[1]||(e[1]=[m("添加",-1)])]),_:1})]),t.value.length?(s(),c("div",F,[(s(!0),c(I,null,N(t.value,a=>(s(),c("div",{key:a.id,class:"todo-item"},[u(x,{modelValue:a.done,"onUpdate:modelValue":f=>a.done=f,onChange:i},{default:d(()=>[m(B(a.text),1)]),_:2},1032,["modelValue","onUpdate:modelValue"]),u(y,{link:"",type:"danger",size:"small",onClick:f=>V(a.id)},{default:d(()=>[...e[2]||(e[2]=[m("删除",-1)])]),_:1},8,["onClick"])]))),128))])):(s(),c("div",J,"暂无待办,可添加你的个人备忘"))]),_:1})}}}),j=D(O,[["__scopeId","data-v-addac169"]]);export{j as default};
-1
View File
@@ -1 +0,0 @@
import{d as v,u as h,r as i,c as r,o as s,w as c,a as o,b as _,e as C,t as n,f as m,g as d,F as b,h as x,_ as w}from"./index-BgaJtbRI.js";const B={class:"section-header"},N={key:0,class:"list"},V=["onClick"],S={class:"title"},T={class:"meta"},$={class:"sub"},F={key:1,class:"empty"},z=v({__name:"SectionCard",props:{title:{},items:{},loading:{type:Boolean},emptyText:{}},emits:["more"],setup(t,{emit:D}){const u=h(),p=a=>{a&&u.push(a)};return(a,l)=>{const f=i("el-button"),g=i("el-tag"),k=i("el-skeleton"),y=i("el-card");return s(),r(y,{shadow:"never",class:"section-card"},{default:c(()=>[o("div",B,[o("span",null,n(t.title),1),t.loading?C("",!0):(s(),r(f,{key:0,type:"primary",link:"",onClick:l[0]||(l[0]=e=>a.$emit("more"))},{default:c(()=>[...l[1]||(l[1]=[m("查看更多",-1)])]),_:1}))]),_(k,{loading:t.loading,animated:"",rows:3},{default:c(()=>[t.items&&t.items.length?(s(),d("div",N,[(s(!0),d(b,null,x(t.items.slice(0,5),e=>(s(),d("div",{key:e.title+e.path,class:"row",onClick:E=>p(e.path)},[o("div",S,n(e.title),1),o("div",T,[_(g,{size:"small",type:e.overdue?"danger":"info"},{default:c(()=>[m(n(e.status||"待处理"),1)]),_:2},1032,["type"]),o("span",$,n(e.subtitle),1)])],8,V))),128))])):(s(),d("div",F,n(t.emptyText),1))]),_:1},8,["loading"])]),_:1})}}}),L=w(z,[["__scopeId","data-v-63b635fd"]]);export{L as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS</title> <title>CTMS</title>
<script type="module" crossorigin src="/assets/index-BgaJtbRI.js"></script> <script type="module" crossorigin src="/assets/index-BJ37HHdy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-OHP6VeHj.css"> <link rel="stylesheet" crossorigin href="/assets/index-DNzNq5qy.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -6
View File
@@ -1,5 +1,5 @@
import { apiGet } from "./axios"; import { apiGet } from "./axios";
import type { ApiListResponse, Study, UserInfo } from "../types/api"; import type { ApiListResponse } from "../types/api";
export const fetchProgress = (studyId: string) => export const fetchProgress = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/dashboard/progress`); apiGet(`/api/v1/studies/${studyId}/dashboard/progress`);
@@ -12,8 +12,3 @@ export const fetchOverdueAesCount = (studyId: string) =>
export const fetchOverdueQueriesCount = (studyId: string) => export const fetchOverdueQueriesCount = (studyId: string) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/data-queries/`, { params: { overdue: true, limit: 1 } }); apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/data-queries/`, { params: { overdue: true, limit: 1 } });
export const fetchMyTodoTasks = (studyId: string, assigneeId: string) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/tasks/`, {
params: { assignee_id: assigneeId, status: "TODO" },
});
-11
View File
@@ -1,11 +0,0 @@
import { apiGet, apiPost, apiPatch } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchTasks = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/tasks/`, { params });
export const createTask = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/tasks/`, payload);
export const updateTask = (studyId: string, taskId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/tasks/${taskId}`, payload);
-10
View File
@@ -22,11 +22,6 @@ export const auditDict: Record<
actionText: "重置了用户密码", actionText: "重置了用户密码",
targetLabel: "用户账号", targetLabel: "用户账号",
}, },
TASK_STATUS_CHANGED: {
label: "任务状态变更",
actionText: "更新了任务状态",
targetLabel: "任务",
},
ISSUE_STATUS_CHANGED: { ISSUE_STATUS_CHANGED: {
label: "风险/问题状态变更", label: "风险/问题状态变更",
actionText: "更新了风险/问题状态", actionText: "更新了风险/问题状态",
@@ -67,11 +62,6 @@ export const auditDict: Record<
actionText: "导出了项目审计日志", actionText: "导出了项目审计日志",
targetLabel: "审计日志", targetLabel: "审计日志",
}, },
TASK_COMPLETE: {
label: "任务完成",
actionText: "完成了任务",
targetLabel: "任务",
},
FINANCE_APPROVED: { FINANCE_APPROVED: {
label: "费用审批", label: "费用审批",
actionText: "审批通过费用", actionText: "审批通过费用",
-1
View File
@@ -24,7 +24,6 @@
<template v-if="study.currentStudy"> <template v-if="study.currentStudy">
<el-menu-item index="/study/home">项目概览</el-menu-item> <el-menu-item index="/study/home">项目概览</el-menu-item>
<el-menu-item index="/study/milestones">伦理与启动管理</el-menu-item> <el-menu-item index="/study/milestones">伦理与启动管理</el-menu-item>
<el-menu-item index="/study/tasks">任务</el-menu-item>
<el-menu-item index="/study/subjects">受试者</el-menu-item> <el-menu-item index="/study/subjects">受试者</el-menu-item>
<el-menu-item index="/study/aes">不良事件</el-menu-item> <el-menu-item index="/study/aes">不良事件</el-menu-item>
<el-menu-item index="/study/issues">风险与问题</el-menu-item> <el-menu-item index="/study/issues">风险与问题</el-menu-item>
+1 -1
View File
@@ -14,7 +14,7 @@ import { useRouter } from "vue-router";
const router = useRouter(); const router = useRouter();
const actions = [ const actions = [
{ label: "任务", path: "/study/tasks" }, { label: "伦理与启动", path: "/study/milestones" },
{ label: "受试者", path: "/study/subjects" }, { label: "受试者", path: "/study/subjects" },
{ label: "AE", path: "/study/aes" }, { label: "AE", path: "/study/aes" },
{ label: "数据问题", path: "/study/data-queries" }, { label: "数据问题", path: "/study/data-queries" },
-168
View File
@@ -1,168 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑任务' : '新增任务'" v-model="visible" width="600px" @close="onClose">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
<el-form-item label="里程碑" prop="milestone_id">
<el-select v-model="form.milestone_id" placeholder="请选择">
<el-option v-for="m in milestones" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="负责人" prop="assignee_id">
<el-select v-model="form.assignee_id" placeholder="选择负责人" filterable clearable>
<el-option v-for="m in assigneeOptions" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-select v-model="form.priority" placeholder="请选择">
<el-option v-for="p in priorities" :key="p" :label="priorityLabel(p)" :value="p" />
</el-select>
</el-form-item>
<el-form-item label="截止日期" prop="due_date">
<el-date-picker v-model="form.due_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createTask, updateTask } from "../api/tasks";
import { useStudyStore } from "../store/study";
interface Props {
modelValue: boolean;
task?: Record<string, any>;
milestones: any[];
members?: any[];
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "success"]);
const formRef = ref<FormInstance>();
const study = useStudyStore();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const priorities = ["LOW", "MEDIUM", "HIGH"];
const statuses = ["TODO", "DOING", "DONE", "BLOCKED"];
const priorityLabel = (v: string) => ({ LOW: "低", MEDIUM: "中", HIGH: "高" }[v] || v);
const statusLabel = (v: string) =>
({
TODO: "待处理",
DOING: "进行中",
DONE: "已完成",
BLOCKED: "阻塞",
}[v] || v);
const form = reactive({
title: "",
description: "",
milestone_id: "",
assignee_id: "",
priority: "MEDIUM",
due_date: "",
status: "TODO",
});
const rules: FormRules = {
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
priority: [{ required: true, message: "请选择优先级", trigger: "change" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }],
};
const submitting = ref(false);
const isEdit = computed(() => !!props.task);
const assigneeOptions = computed(() => {
const seen = new Set<string>();
return (props.members || [])
.filter((m: any) => m.is_active !== false)
.map((m: any) => {
const user = m.user || {};
return { value: m.user_id, label: user.display_name || user.username || m.username || "—" };
})
.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
});
watch(
() => props.task,
(val) => {
if (val) {
form.title = val.title || "";
form.description = val.description || "";
form.milestone_id = val.milestone_id || "";
form.assignee_id = val.assignee_id || "";
form.priority = val.priority || "MEDIUM";
form.due_date = val.due_date || "";
form.status = val.status || "TODO";
} else {
form.title = "";
form.description = "";
form.milestone_id = "";
form.assignee_id = "";
form.priority = "MEDIUM";
form.due_date = "";
form.status = "TODO";
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload: Record<string, any> = {};
Object.entries(form).forEach(([k, v]) => {
if (v !== "" && v !== null && v !== undefined) {
payload[k] = v;
}
});
if (isEdit.value && props.task) {
await updateTask(study.currentStudy.id, props.task.id, payload);
} else {
await createTask(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
</script>
-7
View File
@@ -5,7 +5,6 @@ import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue"; import Login from "../views/Login.vue";
import StudyList from "../views/StudyList.vue"; import StudyList from "../views/StudyList.vue";
import StudyHome from "../views/StudyHome.vue"; import StudyHome from "../views/StudyHome.vue";
import Tasks from "../views/Tasks.vue";
import Milestones from "../views/Milestones.vue"; import Milestones from "../views/Milestones.vue";
import MilestoneDetail from "../views/MilestoneDetail.vue"; import MilestoneDetail from "../views/MilestoneDetail.vue";
import Subjects from "../views/Subjects.vue"; import Subjects from "../views/Subjects.vue";
@@ -58,12 +57,6 @@ const routes: RouteRecordRaw[] = [
component: StudyHome, component: StudyHome,
meta: { title: "项目首页", requiresStudy: true }, meta: { title: "项目首页", requiresStudy: true },
}, },
{
path: "study/tasks",
name: "StudyTasks",
component: Tasks,
meta: { title: "任务", requiresStudy: true },
},
{ {
path: "study/milestones", path: "study/milestones",
name: "StudyMilestones", name: "StudyMilestones",
-1
View File
@@ -23,7 +23,6 @@ export const canDoAction = (machine: StateMachine, actionKey: string, currentSta
}; };
export * from "./types"; export * from "./types";
export * from "./task.machine";
export * from "./subject.machine"; export * from "./subject.machine";
export * from "./ae.machine"; export * from "./ae.machine";
export * from "./finance.machine"; export * from "./finance.machine";
@@ -1,16 +0,0 @@
import type { StateMachine } from "./types";
export const taskMachine: StateMachine = {
states: {
TODO: { value: "TODO", label: "待处理", color: "info" },
DONE: { value: "DONE", label: "已完成", color: "success", isTerminal: true },
},
actions: {
complete: {
key: "complete",
label: "完成",
from: ["TODO"],
to: "DONE",
},
},
};
-4
View File
@@ -3,8 +3,6 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
const PERMISSIONS: Record<string, string[]> = { const PERMISSIONS: Record<string, string[]> = {
"task.create": ["ADMIN", "PM"],
"task.edit": ["ADMIN", "PM"],
"milestone.create": ["ADMIN", "PM"], "milestone.create": ["ADMIN", "PM"],
"subject.create": ["ADMIN", "PM", "CRA"], "subject.create": ["ADMIN", "PM", "CRA"],
"subject.enroll": ["ADMIN", "PM", "CRA"], "subject.enroll": ["ADMIN", "PM", "CRA"],
@@ -26,8 +24,6 @@ const PERMISSIONS: Record<string, string[]> = {
}; };
const REASONS: Record<string, string> = { const REASONS: Record<string, string> = {
"task.create": "仅项目负责人可以创建任务",
"task.edit": "仅项目负责人可以编辑任务",
"milestone.create": "仅项目负责人可以维护里程碑", "milestone.create": "仅项目负责人可以维护里程碑",
"subject.enroll": "仅 PM/CRA 可更新受试者状态", "subject.enroll": "仅 PM/CRA 可更新受试者状态",
"subject.complete": "仅 PM/CRA 可更新受试者状态", "subject.complete": "仅 PM/CRA 可更新受试者状态",
+26 -51
View File
@@ -11,10 +11,12 @@
<el-row :gutter="16" class="kpi-row"> <el-row :gutter="16" class="kpi-row">
<el-col :span="6"> <el-col :span="6">
<KpiCard title="任务完成率" :value="formatPercent(progress?.completion_rate)" :loading="loading.progress" /> <KpiCard
</el-col> title="节点完成率"
<el-col :span="6"> :value="milestoneCompletionRate"
<KpiCard title="逾期任务" :value="progress?.tasks_overdue ?? 0" :loading="loading.progress" /> :subtext="milestoneCompletionText"
:loading="loading.progress"
/>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<KpiCard title="逾期 AE" :value="overdueAes" :loading="loading.overdueAes" /> <KpiCard title="逾期 AE" :value="overdueAes" :loading="loading.overdueAes" />
@@ -32,15 +34,12 @@
</el-col> </el-col>
</el-row> </el-row>
<el-card class="section-card" header="我的待办任务"> <el-alert
<el-alert v-if="errors.tasks" type="warning" :title="errors.tasks" show-icon class="mb-8" /> type="info"
<el-table v-else :data="todoTasks" style="width: 100%" v-loading="loading.tasks" @row-click="goTasks"> title="任务模块已并入伦理与启动管理,请在节点下处理相关事项"
<el-table-column prop="title" label="标题" /> show-icon
<el-table-column prop="priority" label="优先级" width="100" /> class="section-card"
<el-table-column prop="status" label="状态" width="120" /> />
<el-table-column prop="due_date" label="截止日期" width="140" />
</el-table>
</el-card>
<QuickActions class="mt-16" /> <QuickActions class="mt-16" />
</div> </div>
@@ -50,39 +49,38 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ElAlert } from "element-plus";
import { useRouter } from "vue-router";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { fetchFinanceSummary, fetchMyTodoTasks, fetchOverdueAesCount, fetchOverdueQueriesCount, fetchProgress } from "../api/dashboard"; import { fetchFinanceSummary, fetchOverdueAesCount, fetchOverdueQueriesCount, fetchProgress } from "../api/dashboard";
import KpiCard from "../components/KpiCard.vue"; import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue"; import QuickActions from "../components/QuickActions.vue";
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const router = useRouter();
const progress = ref<any>(null); const progress = ref<any>(null);
const financeSummary = ref<any>(null); const financeSummary = ref<any>(null);
const todoTasks = ref<any[]>([]);
const overdueAes = ref<number>(0); const overdueAes = ref<number>(0);
const overdueQueries = ref<number>(0); const overdueQueries = ref<number>(0);
const loading = ref({ const loading = ref({
progress: false, progress: false,
finance: false, finance: false,
tasks: false,
overdueAes: false, overdueAes: false,
overdueQueries: false, overdueQueries: false,
}); });
const errors = ref({ const milestoneCompletionRate = computed(() => {
progress: "", const total = progress.value?.milestones_total ?? 0;
finance: "", if (!total) return "-";
tasks: "", return formatPercent((progress.value?.milestones_done ?? 0) / total);
overdueAes: "", });
overdueQueries: "",
const milestoneCompletionText = computed(() => {
const total = progress.value?.milestones_total ?? 0;
const done = progress.value?.milestones_done ?? 0;
return total ? `已完成 ${done}/${total}` : "暂无节点数据";
}); });
const formatPercent = (v?: number | null) => { const formatPercent = (v?: number | null) => {
@@ -90,61 +88,38 @@ const formatPercent = (v?: number | null) => {
return `${(v * 100).toFixed(0)}%`; return `${(v * 100).toFixed(0)}%`;
}; };
const goTasks = () => {
if (study.currentStudy) {
router.push("/study/tasks");
}
};
const loadData = async () => { const loadData = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
const userId = auth.user?.id; if (!studyId) return;
if (!studyId || !userId) return;
loading.value.progress = true; loading.value.progress = true;
loading.value.finance = true; loading.value.finance = true;
loading.value.tasks = true;
loading.value.overdueAes = true; loading.value.overdueAes = true;
loading.value.overdueQueries = true; loading.value.overdueQueries = true;
try { try {
const [pRes, fRes, aesRes, dqRes, tasksRes] = await Promise.allSettled([ const [pRes, fRes, aesRes, dqRes] = await Promise.allSettled([
fetchProgress(studyId), fetchProgress(studyId),
fetchFinanceSummary(studyId), fetchFinanceSummary(studyId),
fetchOverdueAesCount(studyId), fetchOverdueAesCount(studyId),
fetchOverdueQueriesCount(studyId), fetchOverdueQueriesCount(studyId),
fetchMyTodoTasks(studyId, userId),
]); ]);
if (pRes.status === "fulfilled") { if (pRes.status === "fulfilled") {
progress.value = pRes.value.data; progress.value = pRes.value.data;
} else {
errors.value.progress = "进度数据加载失败";
} }
if (fRes.status === "fulfilled") { if (fRes.status === "fulfilled") {
financeSummary.value = fRes.value.data; financeSummary.value = fRes.value.data;
} else {
errors.value.finance = "费用汇总加载失败";
} }
if (aesRes.status === "fulfilled") { if (aesRes.status === "fulfilled") {
overdueAes.value = aesRes.value.data?.total ?? 0; overdueAes.value = aesRes.value.data?.total ?? 0;
} else {
errors.value.overdueAes = "逾期 AE 加载失败";
} }
if (dqRes.status === "fulfilled") { if (dqRes.status === "fulfilled") {
overdueQueries.value = dqRes.value.data?.total ?? 0; overdueQueries.value = dqRes.value.data?.total ?? 0;
} else {
errors.value.overdueQueries = "逾期数据问题加载失败";
}
if (tasksRes.status === "fulfilled") {
todoTasks.value = tasksRes.value.data?.items ?? [];
} else {
errors.value.tasks = "待办任务加载失败";
} }
} finally { } finally {
loading.value.progress = false; loading.value.progress = false;
loading.value.finance = false; loading.value.finance = false;
loading.value.tasks = false;
loading.value.overdueAes = false; loading.value.overdueAes = false;
loading.value.overdueQueries = false; loading.value.overdueQueries = false;
} }
-351
View File
@@ -1,351 +0,0 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadTasks">
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="filters.milestone_id" placeholder="里程碑" clearable @change="loadTasks">
<el-option v-for="m in milestones" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
<el-select
v-model="filters.assignee_id"
placeholder="负责人"
clearable
filterable
style="width: 200px"
@change="loadTasks"
>
<el-option v-for="opt in assigneeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<div class="spacer" />
<PermissionAction action="task.create">
<el-button type="primary" @click="openCreate">新建任务</el-button>
</PermissionAction>
</div>
</el-card>
<el-card>
<el-table :data="tasks" v-loading="loading" style="width: 100%">
<el-table-column prop="title" label="标题" />
<el-table-column prop="milestone_id" label="节点/里程碑">
<template #default="scope">
{{ milestoneName(scope.row.milestone_id) }}
</template>
</el-table-column>
<el-table-column prop="assignee_id" label="负责人" width="160">
<template #default="scope">
{{ assigneeName(scope.row.assignee_id) }}
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="100">
<template #default="scope">
<el-tag :type="priorityColor(scope.row.priority)">{{ priorityLabel(scope.row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="statusColor(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="due_date" label="截止日期" width="140" />
<el-table-column label="操作" width="200">
<template #default="scope">
<PermissionAction action="task.edit">
<el-button type="primary" link size="small" @click="openEdit(scope.row)">编辑</el-button>
</PermissionAction>
<el-button
v-if="showComplete(scope.row)"
type="success"
size="small"
@click.stop="onComplete(scope.row)"
>
完成
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<TaskForm
v-model="showForm"
:task="editingTask"
:milestones="milestones"
:members="members"
@success="loadTasks"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import { ElMessage } from "element-plus";
import { fetchTasks, updateTask } from "../api/tasks";
import { fetchMilestones } from "../api/milestones";
import { listMembers } from "../api/members";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import TaskForm from "../components/TaskForm.vue";
import { getDictColor, getDictLabel, getDictOptions, statusDict, priorityDict } from "../dictionaries";
import { getAvailableActions, taskMachine } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const study = useStudyStore();
const auth = useAuthStore();
const tasks = ref<any[]>([]);
const milestones = ref<any[]>([]);
const members = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const statusOptions = getDictOptions(statusDict).filter((opt) => ["TODO", "DOING", "DONE", "BLOCKED"].includes(opt.value));
const filters = ref({
status: "",
milestone_id: "",
assignee_id: "",
});
const showForm = ref(false);
const editingTask = ref<any | null>(null);
const { can } = usePermission();
const milestoneMap = computed(() =>
milestones.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.name || cur.id;
return acc;
}, {})
);
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {})
);
const assigneeOptions = computed(() => {
const seen = new Set<string>();
return members.value
.filter((m) => m.is_active !== false)
.map((m) => {
const user = m.user || {};
return { value: m.user_id, label: user.display_name || user.username || m.username || "—" };
})
.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
});
const loadMilestones = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchMilestones(study.currentStudy.id);
milestones.value = data.items || data; // 兼容
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "里程碑加载失败");
}
};
const loadMembers = async () => {
if (!study.currentStudy) return;
try {
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const loadTasks = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
};
if (filters.value.status) params.status = filters.value.status;
if (filters.value.milestone_id) params.milestone_id = filters.value.milestone_id;
if (filters.value.assignee_id) params.assignee_id = filters.value.assignee_id;
const { data } = await fetchTasks(study.currentStudy.id, params);
if (Array.isArray(data)) {
tasks.value = data;
total.value = data.length;
} else {
tasks.value = data.items || [];
total.value = data.total || tasks.value.length;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "任务加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
loadTasks();
};
const openCreate = () => {
editingTask.value = null;
showForm.value = true;
};
const openEdit = (row: any) => {
editingTask.value = row;
showForm.value = true;
};
const ensureUser = async () => {
if (!auth.user && auth.token) {
try {
await auth.fetchMe();
} catch {
// ignore, canEdit 将保持 false
}
}
};
const filterKey = computed(() => (study.currentStudy ? `tasks_filter_${study.currentStudy.id}` : null));
const saveFilters = () => {
if (!filterKey.value) return;
localStorage.setItem(filterKey.value, JSON.stringify({ ...filters.value, page: page.value }));
};
const loadSavedFilters = () => {
if (!filterKey.value) return;
const raw = localStorage.getItem(filterKey.value);
if (raw) {
try {
const parsed = JSON.parse(raw);
filters.value.status = parsed.status || "";
filters.value.milestone_id = parsed.milestone_id || "";
filters.value.assignee_id = parsed.assignee_id || "";
page.value = parsed.page || 1;
} catch {
/* ignore */
}
}
};
const statusLabel = (v: string) => getDictLabel(statusDict, v);
const statusColor = (v: string) => getDictColor(statusDict, v) || "info";
const priorityLabel = (v: string) => getDictLabel(priorityDict, v);
const priorityColor = (v: string) => getDictColor(priorityDict, v) || "info";
const milestoneName = (id: string) => milestoneMap.value[id] || id || "-";
const assigneeName = (id: string) => {
const fromPayload = tasks.value.find((t) => t.assignee_id === id)?.assignee;
return (
fromPayload?.display_name ||
fromPayload?.username ||
memberMap.value[id] ||
id ||
"-"
);
};
const showComplete = (row: any) => {
const actions = getAvailableActions(taskMachine, row.status);
const hasComplete = actions.some((a) => a.key === "complete");
if (!hasComplete) return false;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "task.edit",
stateMachine: taskMachine,
currentState: row.status,
actionKey: "complete",
});
return decision.allowed;
};
const onComplete = async (row: any) => {
if (!study.currentStudy) return;
const action = getAvailableActions(taskMachine, row.status).find((a) => a.key === "complete");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "task.edit",
stateMachine: taskMachine,
currentState: row.status,
actionKey: "complete",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.title,
reason: decision.reason,
severity: decision.severity,
});
return;
}
try {
await updateTask(study.currentStudy.id, row.id, { status: action?.to || "DONE" });
ElMessage.success("已完成");
logAudit("TASK_COMPLETE", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "DONE" },
severity: "normal",
});
loadTasks();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
logAudit("TASK_COMPLETE", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "DONE" },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
onMounted(async () => {
await ensureUser();
loadSavedFilters();
loadMilestones();
loadMembers();
loadTasks();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+25 -33
View File
@@ -76,7 +76,6 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { fetchTasks } from "../../api/tasks";
import { fetchAes } from "../../api/aes"; import { fetchAes } from "../../api/aes";
import { fetchFinanceItems } from "../../api/finance"; import { fetchFinanceItems } from "../../api/finance";
import { fetchImpTransactions } from "../../api/impTransactions"; import { fetchImpTransactions } from "../../api/impTransactions";
@@ -113,25 +112,24 @@ const quickActions = computed(() => {
if (!currentStudyId.value) return []; if (!currentStudyId.value) return [];
if (role.value === "CRA") if (role.value === "CRA")
return [ return [
{ label: "新建 AE", path: "/study/aes" }, { label: "伦理与启动", path: "/study/milestones" },
{ label: "我的任务", path: "/study/tasks" }, { label: "AE 列表", path: "/study/aes" },
]; ];
if (role.value === "PM") if (role.value === "PM")
return [ return [
{ label: "派发任务", path: "/study/tasks" }, { label: "伦理与启动", path: "/study/milestones" },
{ label: "发公告", path: "/study/faq" }, { label: "费用审核", path: "/study/finance" },
{ label: "查看 CRA 列表", path: "/study/tasks" }, { label: "公告维护", path: "/study/faq" },
]; ];
if (role.value === "PV") return [{ label: "AE 列表", path: "/study/aes" }]; if (role.value === "PV") return [{ label: "AE 列表", path: "/study/aes" }];
if (role.value === "IMP") return [{ label: "药品台账", path: "/study/imp/transactions" }]; if (role.value === "IMP") return [{ label: "药品台账", path: "/study/imp/transactions" }];
return []; return [{ label: "伦理与启动", path: "/study/milestones" }];
}); });
const todayMorePath = computed(() => (role.value === "CRA" ? "/study/tasks" : "/study/tasks")); const todayMorePath = computed(() => (role.value === "IMP" ? "/study/imp/transactions" : "/study/aes"));
const overdueMorePath = computed(() => (role.value === "CRA" ? "/study/tasks" : "/study/tasks")); const overdueMorePath = computed(() => (role.value === "IMP" ? "/study/imp/transactions" : "/study/aes"));
const actionMorePath = computed(() => { const actionMorePath = computed(() => {
if (role.value === "IMP") return "/study/imp/transactions"; if (role.value === "IMP") return "/study/imp/transactions";
if (role.value === "PV") return "/study/aes";
if (role.value === "PM" || role.value === "ADMIN") return "/study/finance"; if (role.value === "PM" || role.value === "ADMIN") return "/study/finance";
return "/study/aes"; return "/study/aes";
}); });
@@ -141,19 +139,11 @@ const isOverdue = (d?: string | null) => {
return new Date(d) < new Date(todayStr.value); return new Date(d) < new Date(todayStr.value);
}; };
const toTaskItem = (task: any): WorkItem => ({
title: task.title || "任务",
subtitle: study.currentStudy?.name || "",
status: task.due_date || task.status,
overdue: isOverdue(task.due_date),
path: "/study/tasks",
});
const toAeItem = (ae: any): WorkItem => ({ const toAeItem = (ae: any): WorkItem => ({
title: ae.term || "不良事件", title: ae.term || "不良事件",
subtitle: study.currentStudy?.name || "", subtitle: study.currentStudy?.name || "",
status: ae.status, status: ae.status,
overdue: false, overdue: isOverdue(ae.expected_resolution_date || ae.updated_at),
path: `/study/aes/${ae.id}`, path: `/study/aes/${ae.id}`,
}); });
@@ -178,19 +168,17 @@ const loadData = async () => {
loading.value = true; loading.value = true;
try { try {
const studyId = currentStudyId.value; const studyId = currentStudyId.value;
const tasksReq = fetchTasks(studyId, { limit: 200, assignee_id: auth.user?.id });
const aesReq = fetchAes(studyId, { status: "NEW", limit: 50 }); const aesReq = fetchAes(studyId, { status: "NEW", limit: 50 });
const financeReq = fetchFinanceItems(studyId, { status: "SUBMITTED", limit: 50 }); const financeReq = fetchFinanceItems(studyId, { status: "SUBMITTED", limit: 50 });
const impReq = fetchImpTransactions ? fetchImpTransactions(studyId, { limit: 50 }) : Promise.resolve({ data: [] }); const impReq = fetchImpTransactions ? fetchImpTransactions(studyId, { limit: 50 }) : Promise.resolve({ data: [] });
const [tasksRes, aesRes, financeRes, impRes] = await Promise.allSettled([tasksReq, aesReq, financeReq, impReq]); const [aesRes, financeRes, impRes] = await Promise.allSettled([aesReq, financeReq, impReq]);
const tasks = tasksRes.status === "fulfilled" ? (tasksRes.value.data.items || tasksRes.value.data || []) : [];
const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : []; const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : [];
const finances = const finances =
financeRes.status === "fulfilled" ? (financeRes.value.data.items || financeRes.value.data || []) : []; financeRes.status === "fulfilled" ? (financeRes.value.data.items || financeRes.value.data || []) : [];
const impTx = impRes.status === "fulfilled" ? (impRes.value.data.items || impRes.value.data || []) : []; const impTx = impRes.status === "fulfilled" ? (impRes.value.data.items || impRes.value.data || []) : [];
buildSections(tasks, aes, finances, impTx); buildSections(aes, finances, impTx);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "工作台数据加载失败"); ElMessage.error(e?.response?.data?.message || "工作台数据加载失败");
} finally { } finally {
@@ -198,21 +186,25 @@ const loadData = async () => {
} }
}; };
const buildSections = (tasks: any[], aes: any[], finances: any[], impTx: any[]) => { const buildSections = (aes: any[], finances: any[], impTx: any[]) => {
todayList.value = []; todayList.value = [];
overdueList.value = []; overdueList.value = [];
actionList.value = []; actionList.value = [];
const openAes = aes.filter((a) => a.status !== "CLOSED");
const financePending = finances.filter((f) => f.status !== "PAID");
const overdueAes = openAes.filter((a) => isOverdue(a.expected_resolution_date || a.updated_at));
if (role.value === "CRA") { if (role.value === "CRA") {
todayList.value = tasks.filter((t) => t.due_date === todayStr.value).slice(0, 5).map(toTaskItem); todayList.value = openAes.slice(0, 5).map(toAeItem);
overdueList.value = tasks.filter((t) => isOverdue(t.due_date)).slice(0, 5).map(toTaskItem); overdueList.value = overdueAes.slice(0, 5).map(toAeItem);
actionList.value = aes.slice(0, 5).map(toAeItem); actionList.value = openAes.slice(0, 5).map(toAeItem);
} else if (role.value === "PM") { } else if (role.value === "PM" || role.value === "ADMIN") {
todayList.value = tasks.filter((t) => t.due_date === todayStr.value).slice(0, 5).map(toTaskItem); todayList.value = openAes.slice(0, 5).map(toAeItem);
overdueList.value = tasks.filter((t) => isOverdue(t.due_date)).slice(0, 5).map(toTaskItem); overdueList.value = overdueAes.slice(0, 5).map(toAeItem);
const financePending = finances.slice(0, 3).map(toFinanceItem); const financeTodo = financePending.slice(0, 3).map(toFinanceItem);
const aePending = aes.slice(0, 2).map(toAeItem); actionList.value = [...financeTodo, ...openAes.slice(0, 2).map(toAeItem)].slice(0, 5);
actionList.value = [...financePending, ...aePending].slice(0, 5);
} else if (role.value === "PV") { } else if (role.value === "PV") {
const aeOpen = aes.filter((a) => a.status !== "CLOSED"); const aeOpen = aes.filter((a) => a.status !== "CLOSED");
todayList.value = aeOpen.slice(0, 5).map(toAeItem); todayList.value = aeOpen.slice(0, 5).map(toAeItem);