启动与授权-初步优化

This commit is contained in:
Cheng Zhou
2026-01-15 11:25:13 +08:00
parent 4fc5f0ee9b
commit 05c1f9579a
28 changed files with 1082 additions and 300 deletions
@@ -0,0 +1,69 @@
"""Add site_id to kickoff meetings.
Revision ID: 20250310_01
Revises: 20250218_01
Create Date: 2025-03-10
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
revision = "20250310_01"
down_revision = "20250218_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
columns = {col["name"] for col in inspector.get_columns("kickoff_meetings")}
if "site_id" not in columns:
op.add_column("kickoff_meetings", sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True))
fks = {fk["name"] for fk in inspector.get_foreign_keys("kickoff_meetings")}
if "fk_kickoff_meetings_site_id" not in fks:
op.create_foreign_key(
"fk_kickoff_meetings_site_id",
"kickoff_meetings",
"sites",
["site_id"],
["id"],
ondelete="RESTRICT",
)
indexes = {index["name"] for index in inspector.get_indexes("kickoff_meetings")}
if "ix_kickoff_meetings_site_id" not in indexes:
op.create_index("ix_kickoff_meetings_site_id", "kickoff_meetings", ["site_id"])
uniques = {uc["name"] for uc in inspector.get_unique_constraints("kickoff_meetings")}
if "uq_kickoff_meetings_study_site" not in uniques:
op.create_unique_constraint(
"uq_kickoff_meetings_study_site",
"kickoff_meetings",
["study_id", "site_id"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
uniques = {uc["name"] for uc in inspector.get_unique_constraints("kickoff_meetings")}
if "uq_kickoff_meetings_study_site" in uniques:
op.drop_constraint("uq_kickoff_meetings_study_site", "kickoff_meetings", type_="unique")
indexes = {index["name"] for index in inspector.get_indexes("kickoff_meetings")}
if "ix_kickoff_meetings_site_id" in indexes:
op.drop_index("ix_kickoff_meetings_site_id", table_name="kickoff_meetings")
fks = {fk["name"] for fk in inspector.get_foreign_keys("kickoff_meetings")}
if "fk_kickoff_meetings_site_id" in fks:
op.drop_constraint("fk_kickoff_meetings_site_id", "kickoff_meetings", type_="foreignkey")
columns = {col["name"] for col in inspector.get_columns("kickoff_meetings")}
if "site_id" in columns:
op.drop_column("kickoff_meetings", "site_id")
@@ -0,0 +1,138 @@
"""Migrate fee_attachments into attachments.
Revision ID: 20250310_02
Revises: 20250310_01
Create Date: 2025-03-10
"""
from alembic import op
revision = "20250310_02"
down_revision = "20250310_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO attachments (
id,
study_id,
entity_type,
entity_id,
filename,
file_path,
file_size,
content_type,
uploaded_by,
uploaded_at,
is_deleted
)
SELECT
fa.id,
cf.project_id,
'contract_fee_' || fa.file_type,
fa.entity_id,
fa.filename,
fa.storage_key,
fa.size,
fa.mime_type,
fa.uploaded_by,
fa.uploaded_at,
fa.is_deleted
FROM fee_attachments fa
JOIN contract_fees cf ON fa.entity_type = 'contract_fee' AND fa.entity_id = cf.id
WHERE fa.storage_key IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attachments a WHERE a.id = fa.id)
"""
)
op.execute(
"""
INSERT INTO attachments (
id,
study_id,
entity_type,
entity_id,
filename,
file_path,
file_size,
content_type,
uploaded_by,
uploaded_at,
is_deleted
)
SELECT
fa.id,
cf.project_id,
'contract_payment_' || fa.file_type,
fa.entity_id,
fa.filename,
fa.storage_key,
fa.size,
fa.mime_type,
fa.uploaded_by,
fa.uploaded_at,
fa.is_deleted
FROM fee_attachments fa
JOIN contract_fee_payments cp ON fa.entity_type = 'contract_payment' AND fa.entity_id = cp.id
JOIN contract_fees cf ON cp.contract_fee_id = cf.id
WHERE fa.storage_key IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attachments a WHERE a.id = fa.id)
"""
)
op.execute(
"""
INSERT INTO attachments (
id,
study_id,
entity_type,
entity_id,
filename,
file_path,
file_size,
content_type,
uploaded_by,
uploaded_at,
is_deleted
)
SELECT
fa.id,
se.project_id,
'special_expense_' || fa.file_type,
fa.entity_id,
fa.filename,
fa.storage_key,
fa.size,
fa.mime_type,
fa.uploaded_by,
fa.uploaded_at,
fa.is_deleted
FROM fee_attachments fa
JOIN special_expenses se ON fa.entity_type = 'special_expense' AND fa.entity_id = se.id
WHERE fa.storage_key IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attachments a WHERE a.id = fa.id)
"""
)
def downgrade() -> None:
op.execute(
"""
DELETE FROM attachments a
USING fee_attachments fa
WHERE a.id = fa.id
AND a.entity_type IN (
'contract_fee_contract',
'contract_fee_voucher',
'contract_fee_invoice',
'contract_payment_voucher',
'contract_payment_invoice',
'special_expense_voucher',
'special_expense_invoice',
'special_expense_other'
)
"""
)
+60 -2
View File
@@ -1,6 +1,7 @@
import os import os
import uuid import uuid
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import aiofiles import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
@@ -23,6 +24,12 @@ global_router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads"
def _content_disposition(filename: str, disposition: str = "inline") -> str:
fallback = "".join((ch if ord(ch) < 128 else "_") for ch in filename) or "download"
quoted = quote(filename, safe="")
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quoted}"
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
@@ -79,6 +86,7 @@ async def upload_attachment(
id=attachment.id, id=attachment.id,
filename=attachment.filename, filename=attachment.filename,
file_size=attachment.file_size, file_size=attachment.file_size,
content_type=attachment.content_type,
uploaded_by=UserDisplay.model_validate(current_user) if current_user else None, uploaded_by=UserDisplay.model_validate(current_user) if current_user else None,
uploaded_by_id=attachment.uploaded_by, uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at, uploaded_at=attachment.uploaded_at,
@@ -108,6 +116,7 @@ async def list_attachments(
id=a.id, id=a.id,
filename=a.filename, filename=a.filename,
file_size=a.file_size, file_size=a.file_size,
content_type=a.content_type,
uploaded_by=UserDisplay.model_validate(user) if user else None, uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_by_id=a.uploaded_by, uploaded_by_id=a.uploaded_by,
uploaded_at=a.uploaded_at, uploaded_at=a.uploaded_at,
@@ -137,7 +146,32 @@ async def download_attachment(
path=attachment.file_path, path=attachment.file_path,
filename=attachment.filename, filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream", media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'}, headers={"Content-Disposition": _content_disposition(attachment.filename, disposition="attachment")},
)
@router.get(
"/{attachment_id}/preview",
dependencies=[Depends(require_study_member())],
)
async def preview_attachment(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> FileResponse:
await _ensure_study_exists(db, study_id)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(attachment.filename, disposition="inline")},
) )
@@ -182,7 +216,31 @@ async def global_download_attachment(
path=attachment.file_path, path=attachment.file_path,
filename=attachment.filename, filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream", media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'}, headers={"Content-Disposition": _content_disposition(attachment.filename, disposition="attachment")},
)
@global_router.get(
"/{attachment_id}/preview",
response_class=FileResponse,
)
async def global_preview_attachment(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id)
await _authorize_global(request, db, attachment.study_id)
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(attachment.filename, disposition="inline")},
) )
+18 -9
View File
@@ -4,12 +4,12 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud from app.crud import contract_fee_payment as payment_crud
from app.crud import fee_attachment as fee_attachment_crud
from app.crud import member as member_crud from app.crud import member as member_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import user as user_crud from app.crud import user as user_crud
@@ -23,6 +23,7 @@ from app.schemas.contract_fee_payment import (
from app.schemas.fee_common import FeeApiResponse from app.schemas.fee_common import FeeApiResponse
from app.schemas.fee_attachment import FeeAttachmentRead from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.user import UserDisplay from app.schemas.user import UserDisplay
from app.models.attachment import Attachment
router = APIRouter() router = APIRouter()
@@ -123,7 +124,15 @@ async def get_contract_fee(
await _ensure_project_access(db, contract.project_id, current_user, write=False) await _ensure_project_access(db, contract.project_id, current_user, write=False)
payments = await payment_crud.list_payments(db, contract.id) payments = await payment_crud.list_payments(db, contract.id)
attachments = await fee_attachment_crud.list_attachments(db, entity_type="contract_fee", entity_id=contract.id) attachment_types = ["contract_fee_contract", "contract_fee_voucher", "contract_fee_invoice"]
result = await db.execute(
select(Attachment).where(
Attachment.entity_id == contract.id,
Attachment.entity_type.in_(attachment_types),
Attachment.is_deleted.is_(False),
)
)
attachments = result.scalars().all()
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by} user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
users_map = await user_crud.get_users_by_ids(db, user_ids) users_map = await user_crud.get_users_by_ids(db, user_ids)
@@ -133,20 +142,20 @@ async def get_contract_fee(
"invoice": [], "invoice": [],
} }
for attachment in attachments: for attachment in attachments:
key = attachment.file_type key = attachment.entity_type.replace("contract_fee_", "", 1)
attachments_map.setdefault(key, []) attachments_map.setdefault(key, [])
user = users_map.get(attachment.uploaded_by) user = users_map.get(attachment.uploaded_by)
attachments_map[key].append( attachments_map[key].append(
FeeAttachmentRead( FeeAttachmentRead(
id=attachment.id, id=attachment.id,
entity_type=attachment.entity_type, entity_type="contract_fee",
entity_id=attachment.entity_id, entity_id=attachment.entity_id,
file_type=attachment.file_type, file_type=key,
filename=attachment.filename, filename=attachment.filename,
mime_type=attachment.mime_type, mime_type=attachment.content_type,
size=attachment.size, size=attachment.file_size,
storage_key=attachment.storage_key, storage_key=attachment.file_path,
url=attachment.url, url=None,
uploaded_by_id=attachment.uploaded_by, uploaded_by_id=attachment.uploaded_by,
uploaded_by=UserDisplay.model_validate(user) if user else None, uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_at=attachment.uploaded_at, uploaded_at=attachment.uploaded_at,
-28
View File
@@ -358,34 +358,6 @@ async def update_kickoff(
return KickoffMeetingRead.model_validate(meeting) return KickoffMeetingRead.model_validate(meeting)
@router.delete(
"/kickoff/{meeting_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def delete_kickoff(
study_id: uuid.UUID,
meeting_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
await startup_crud.delete_kickoff(db, meeting)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_kickoff",
entity_id=meeting_id,
action="DELETE_KICKOFF_MEETING",
detail="启动会记录已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
@router.post( @router.post(
"/training-authorizations", "/training-authorizations",
response_model=TrainingAuthorizationRead, response_model=TrainingAuthorizationRead,
+7 -6
View File
@@ -5,7 +5,7 @@ from typing import Sequence
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fee_attachment import FeeAttachment from app.models.attachment import Attachment
from app.models.special_expense import SpecialExpense from app.models.special_expense import SpecialExpense
from app.models.site import Site from app.models.site import Site
from app.schemas.special_expense import SpecialExpenseCreate, SpecialExpenseUpdate from app.schemas.special_expense import SpecialExpenseCreate, SpecialExpenseUpdate
@@ -48,7 +48,8 @@ async def list_special_expenses(
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
) -> Sequence[tuple[SpecialExpense, str | None, int]]: ) -> Sequence[tuple[SpecialExpense, str | None, int]]:
attachment_count = func.count(FeeAttachment.id).label("attachments_count") attachment_count = func.count(Attachment.id).label("attachments_count")
attachment_types = ["special_expense_voucher", "special_expense_invoice", "special_expense_other"]
stmt = ( stmt = (
select( select(
SpecialExpense, SpecialExpense,
@@ -57,11 +58,11 @@ async def list_special_expenses(
) )
.outerjoin(Site, Site.id == SpecialExpense.center_id) .outerjoin(Site, Site.id == SpecialExpense.center_id)
.outerjoin( .outerjoin(
FeeAttachment, Attachment,
and_( and_(
FeeAttachment.entity_type == "special_expense", Attachment.entity_type.in_(attachment_types),
FeeAttachment.entity_id == SpecialExpense.id, Attachment.entity_id == SpecialExpense.id,
FeeAttachment.is_deleted.is_(False), Attachment.is_deleted.is_(False),
), ),
) )
.where(SpecialExpense.project_id == project_id) .where(SpecialExpense.project_id == project_id)
+1 -5
View File
@@ -147,6 +147,7 @@ async def create_kickoff(
) -> KickoffMeeting: ) -> KickoffMeeting:
meeting = KickoffMeeting( meeting = KickoffMeeting(
study_id=study_id, study_id=study_id,
site_id=meeting_in.site_id,
kickoff_date=meeting_in.kickoff_date, kickoff_date=meeting_in.kickoff_date,
attendees=meeting_in.attendees, attendees=meeting_in.attendees,
created_by=created_by, created_by=created_by,
@@ -190,11 +191,6 @@ async def update_kickoff(
return meeting return meeting
async def delete_kickoff(db: AsyncSession, meeting: KickoffMeeting) -> None:
await db.delete(meeting)
await db.commit()
async def create_training_authorization( async def create_training_authorization(
db: AsyncSession, db: AsyncSession,
study_id: uuid.UUID, study_id: uuid.UUID,
+1
View File
@@ -13,6 +13,7 @@ class KickoffMeeting(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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) study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
kickoff_date: Mapped[date | None] = mapped_column(Date, nullable=True) kickoff_date: Mapped[date | None] = mapped_column(Date, nullable=True)
attendees: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) attendees: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
+1
View File
@@ -10,6 +10,7 @@ class AttachmentRead(BaseModel):
id: uuid.UUID id: uuid.UUID
filename: str filename: str
file_size: int = Field(alias="size") file_size: int = Field(alias="size")
content_type: str | None = None
uploaded_by: UserDisplay | None = None uploaded_by: UserDisplay | None = None
uploaded_by_id: uuid.UUID | None = None uploaded_by_id: uuid.UUID | None = None
uploaded_at: datetime uploaded_at: datetime
+2
View File
@@ -71,6 +71,7 @@ class StartupEthicsRead(BaseModel):
class KickoffMeetingCreate(BaseModel): class KickoffMeetingCreate(BaseModel):
site_id: uuid.UUID
kickoff_date: Optional[date] = None kickoff_date: Optional[date] = None
attendees: Optional[list[str]] = None attendees: Optional[list[str]] = None
@@ -83,6 +84,7 @@ class KickoffMeetingUpdate(BaseModel):
class KickoffMeetingRead(BaseModel): class KickoffMeetingRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: uuid.UUID study_id: uuid.UUID
site_id: Optional[uuid.UUID]
kickoff_date: Optional[date] kickoff_date: Optional[date]
attendees: Optional[list[str]] attendees: Optional[list[str]]
created_by: Optional[uuid.UUID] created_by: Optional[uuid.UUID]
+2
View File
@@ -361,12 +361,14 @@ CREATE TABLE IF NOT EXISTS public.startup_ethics (
CREATE TABLE IF NOT EXISTS public.kickoff_meetings ( CREATE TABLE IF NOT EXISTS public.kickoff_meetings (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
study_id uuid NOT NULL, study_id uuid NOT NULL,
site_id uuid,
kickoff_date date, kickoff_date date,
attendees json, attendees json,
created_by uuid, created_by uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(), created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(), updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT fk_kickoff_meetings_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, CONSTRAINT fk_kickoff_meetings_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT,
CONSTRAINT fk_kickoff_meetings_site_id FOREIGN KEY (site_id) REFERENCES public.sites(id) ON DELETE RESTRICT,
CONSTRAINT fk_kickoff_meetings_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT CONSTRAINT fk_kickoff_meetings_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
); );
-32
View File
@@ -1,32 +0,0 @@
import { apiDelete, apiGet, apiPost } from "./axios";
import type { FeeApiResponse } from "../types/api";
export const listFeeAttachments = (entityType: string, entityId: string) =>
apiGet<FeeApiResponse<any[]>>("/api/v1/fees/attachments", { params: { entity_type: entityType, entity_id: entityId } });
export const uploadFeeAttachment = (
entityType: string,
entityId: string,
fileType: string,
file: File,
options?: Record<string, any>
) => {
const formData = new FormData();
formData.append("file", file);
formData.append("entity_type", entityType);
formData.append("entity_id", entityId);
formData.append("file_type", fileType);
return apiPost<FeeApiResponse<any>>("/api/v1/fees/attachments", formData, {
headers: { "Content-Type": "multipart/form-data" },
...options,
});
};
export const deleteFeeAttachment = (attachmentId: string) => apiDelete(`/api/v1/fees/attachments/${attachmentId}`);
export const getFeeAttachmentDownloadUrl = (attachmentId: string) => {
const token = localStorage.getItem("ctms_token");
return token
? `/api/v1/fees/attachments/${attachmentId}/download?token=${token}`
: `/api/v1/fees/attachments/${attachmentId}/download`;
};
-3
View File
@@ -42,9 +42,6 @@ export const createKickoff = (studyId: string, payload: Record<string, any>) =>
export const updateKickoff = (studyId: string, id: string, payload: Record<string, any>) => export const updateKickoff = (studyId: string, id: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/startup/kickoff/${id}`, payload); apiPatch(`/api/v1/studies/${studyId}/startup/kickoff/${id}`, payload);
export const deleteKickoff = (studyId: string, id: string) =>
apiDelete(`/api/v1/studies/${studyId}/startup/kickoff/${id}`);
export const listTrainingAuthorizations = (studyId: string, params?: Record<string, any>) => export const listTrainingAuthorizations = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/startup/training-authorizations`, { params }); apiGet(`/api/v1/studies/${studyId}/startup/training-authorizations`, { params });
@@ -1,7 +1,7 @@
<template> <template>
<el-card> <el-card>
<div class="header"> <div class="header">
<span>{{ TEXT.common.labels.attachments }}</span> <span>{{ headerTitle }}</span>
<AttachmentUploader <AttachmentUploader
:study-id="studyId" :study-id="studyId"
:entity-type="entityType" :entity-type="entityType"
@@ -27,7 +27,7 @@
<el-table-column :label="TEXT.common.labels.actions" width="220"> <el-table-column :label="TEXT.common.labels.actions" width="220">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="preview(scope.row)"> <el-button link type="primary" size="small" @click="preview(scope.row)">
{{ TEXT.common.actions.view }} {{ TEXT.common.labels.preview }}
</el-button> </el-button>
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button> <el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
<el-button <el-button
@@ -59,7 +59,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchAttachments, deleteAttachment } from "../../api/attachments"; import { fetchAttachments, deleteAttachment } from "../../api/attachments";
import AttachmentUploader from "./AttachmentUploader.vue"; import AttachmentUploader from "./AttachmentUploader.vue";
@@ -74,6 +74,7 @@ const props = defineProps<{
studyId: string; studyId: string;
entityType: string; entityType: string;
entityId: string; entityId: string;
title?: string;
}>(); }>();
const attachments = ref<any[]>([]); const attachments = ref<any[]>([]);
@@ -83,9 +84,12 @@ const study = useStudyStore();
const members = ref<any[]>([]); const members = ref<any[]>([]);
const previewVisible = ref(false); const previewVisible = ref(false);
const previewUrl = ref(""); const previewUrl = ref("");
const previewObjectUrl = ref("");
const previewType = ref<"image" | "pdf" | "other">("other"); const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview); const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref(""); const previewError = ref("");
const previewLoading = ref(false);
const headerTitle = computed(() => props.title || TEXT.common.labels.attachments);
const load = async () => { const load = async () => {
if (!props.studyId || !props.entityId) return; if (!props.studyId || !props.entityId) return;
@@ -122,25 +126,72 @@ const uploaderLabel = (row: any) => {
return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap }); return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap });
}; };
const download = (row: any) => { const getDownloadUrl = (row: any) => {
const token = localStorage.getItem("ctms_token"); const token = localStorage.getItem("ctms_token");
const url = token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`; return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
window.open(url, "_blank");
}; };
const getPreviewUrl = (row: any) => {
const token = localStorage.getItem("ctms_token");
return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`;
};
const download = (row: any) => {
const url = getDownloadUrl(row);
const link = document.createElement("a");
link.href = url;
link.download = row?.filename || "download";
link.rel = "noopener";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
const getMimeType = (row: any) => (row?.mime_type || row?.content_type || "").toLowerCase();
const detectPreviewType = (row: any) => { const detectPreviewType = (row: any) => {
const mime = (row?.mime_type || row?.content_type || "").toLowerCase(); const mime = getMimeType(row);
if (mime.startsWith("image/")) return "image"; if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf"; if (mime === "application/pdf") return "pdf";
const name = getFileName(row);
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other"; return "other";
}; };
const previewImage = async (downloadUrl: string) => {
previewLoading.value = true;
try {
const token = localStorage.getItem("ctms_token");
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(downloadUrl, { headers });
if (!response.ok) throw new Error("preview failed");
const blob = await response.blob();
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value);
previewObjectUrl.value = URL.createObjectURL(blob);
previewUrl.value = previewObjectUrl.value;
} catch {
previewUrl.value = downloadUrl;
} finally {
previewLoading.value = false;
}
};
const preview = (row: any) => { const preview = (row: any) => {
previewError.value = ""; previewError.value = "";
previewType.value = detectPreviewType(row); previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview; previewTitle.value = row?.filename || TEXT.common.labels.preview;
const token = localStorage.getItem("ctms_token"); const downloadUrl = getDownloadUrl(row);
previewUrl.value = row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`); if (previewType.value === "image") {
previewUrl.value = "";
previewImage(downloadUrl);
} else if (previewType.value === "pdf") {
previewUrl.value = getPreviewUrl(row);
} else {
previewUrl.value = downloadUrl;
}
if (previewType.value === "other") { if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported; previewError.value = TEXT.common.messages.previewNotSupported;
} }
@@ -172,6 +223,13 @@ onMounted(async () => {
await Promise.all([loadMembers()]); await Promise.all([loadMembers()]);
load(); load();
}); });
watch(previewVisible, (visible) => {
if (!visible && previewObjectUrl.value) {
URL.revokeObjectURL(previewObjectUrl.value);
previewObjectUrl.value = "";
}
});
</script> </script>
<style scoped> <style scoped>
@@ -41,7 +41,7 @@
<el-table-column :label="TEXT.common.labels.actions" width="160"> <el-table-column :label="TEXT.common.labels.actions" width="160">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="preview(scope.row)"> <el-button link type="primary" size="small" @click="preview(scope.row)">
{{ TEXT.common.actions.view }} {{ TEXT.common.labels.preview }}
</el-button> </el-button>
<el-button link type="primary" size="small" @click="download(scope.row)"> <el-button link type="primary" size="small" @click="download(scope.row)">
{{ TEXT.common.actions.download }} {{ TEXT.common.actions.download }}
@@ -79,7 +79,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue"; import { onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { listFeeAttachments, uploadFeeAttachment, deleteFeeAttachment, getFeeAttachmentDownloadUrl } from "../../api/feeAttachments"; import { fetchAttachments, uploadAttachment, deleteAttachment } from "../../api/attachments";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
@@ -112,9 +112,11 @@ const study = useStudyStore();
const members = ref<any[]>([]); const members = ref<any[]>([]);
const previewVisible = ref(false); const previewVisible = ref(false);
const previewUrl = ref(""); const previewUrl = ref("");
const previewObjectUrl = ref("");
const previewType = ref<"image" | "pdf" | "other">("other"); const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview); const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref(""); const previewError = ref("");
const previewLoading = ref(false);
const isUploading = (key: string) => (progressMap[key] || 0) > 0 && (progressMap[key] || 0) < 100; const isUploading = (key: string) => (progressMap[key] || 0) > 0 && (progressMap[key] || 0) < 100;
@@ -130,13 +132,19 @@ const loadMembers = async () => {
}; };
const load = async () => { const load = async () => {
if (!props.entityType || !props.entityId) return; const studyId = study.currentStudy?.id;
if (!studyId || !props.entityType || !props.entityId) return;
loading.value = true; loading.value = true;
try { try {
const { data } = await listFeeAttachments(props.entityType, props.entityId); const results = await Promise.all(
const items = Array.isArray(data?.data) ? data?.data : []; props.groups.map(async (group) => {
props.groups.forEach((group) => { const entityType = `${props.entityType}_${group.key}`;
grouped[group.key] = items.filter((item: any) => item.file_type === group.key || item.fileType === group.key); const { data } = await fetchAttachments(studyId, entityType, props.entityId);
return { key: group.key, items: data?.items || data || [] };
})
);
results.forEach((group) => {
grouped[group.key] = Array.isArray(group.items) ? group.items : [];
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -157,9 +165,24 @@ const uploaderLabel = (row: any) => {
return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap }); return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap });
}; };
const getDownloadUrl = (row: any) => {
const token = localStorage.getItem("ctms_token");
return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
};
const getPreviewUrl = (row: any) => {
const token = localStorage.getItem("ctms_token");
return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`;
};
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
const getMimeType = (row: any) => (row?.mime_type || row?.content_type || "").toLowerCase();
const doUpload = async (fileType: string, options: any) => { const doUpload = async (fileType: string, options: any) => {
const file: File = options.file; const file: File = options.file;
if (!file || props.readonly) return; if (!file || props.readonly) return;
const studyId = study.currentStudy?.id;
if (!studyId) return;
const maxSize = props.maxSizeMb ?? 50; const maxSize = props.maxSizeMb ?? 50;
if (file.size > maxSize * 1024 * 1024) { if (file.size > maxSize * 1024 * 1024) {
ElMessage.error(`${TEXT.common.messages.fileTooLarge} ${maxSize}MB`); ElMessage.error(`${TEXT.common.messages.fileTooLarge} ${maxSize}MB`);
@@ -167,7 +190,8 @@ const doUpload = async (fileType: string, options: any) => {
} }
progressMap[fileType] = 0; progressMap[fileType] = 0;
try { try {
await uploadFeeAttachment(props.entityType, props.entityId, fileType, file, { const entityType = `${props.entityType}_${fileType}`;
await uploadAttachment(studyId, entityType, props.entityId, file, {
onUploadProgress: (evt: ProgressEvent) => { onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) { if (evt.total) {
progressMap[fileType] = Math.round((evt.loaded / evt.total) * 100); progressMap[fileType] = Math.round((evt.loaded / evt.total) * 100);
@@ -184,17 +208,47 @@ const doUpload = async (fileType: string, options: any) => {
}; };
const detectPreviewType = (row: any) => { const detectPreviewType = (row: any) => {
const mime = (row?.mime_type || row?.content_type || "").toLowerCase(); const mime = getMimeType(row);
if (mime.startsWith("image/")) return "image"; if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf"; if (mime === "application/pdf") return "pdf";
const name = getFileName(row);
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other"; return "other";
}; };
const previewImage = async (downloadUrl: string) => {
previewLoading.value = true;
try {
const token = localStorage.getItem("ctms_token");
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(downloadUrl, { headers });
if (!response.ok) throw new Error("preview failed");
const blob = await response.blob();
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value);
previewObjectUrl.value = URL.createObjectURL(blob);
previewUrl.value = previewObjectUrl.value;
} catch {
previewUrl.value = downloadUrl;
} finally {
previewLoading.value = false;
}
};
const preview = (row: any) => { const preview = (row: any) => {
previewError.value = ""; previewError.value = "";
previewType.value = detectPreviewType(row); previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview; previewTitle.value = row?.filename || TEXT.common.labels.preview;
previewUrl.value = row?.url || getFeeAttachmentDownloadUrl(row.id); const downloadUrl = getDownloadUrl(row);
if (previewType.value === "image") {
previewUrl.value = "";
previewImage(downloadUrl);
} else if (previewType.value === "pdf") {
previewUrl.value = getPreviewUrl(row);
} else {
previewUrl.value = downloadUrl;
}
if (previewType.value === "other") { if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported; previewError.value = TEXT.common.messages.previewNotSupported;
} }
@@ -202,11 +256,14 @@ const preview = (row: any) => {
}; };
const download = (row: any) => { const download = (row: any) => {
if (row?.url) { const url = getDownloadUrl(row);
window.open(row.url, "_blank"); const link = document.createElement("a");
return; link.href = url;
} link.download = row?.filename || "download";
window.open(getFeeAttachmentDownloadUrl(row.id), "_blank"); link.rel = "noopener";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}; };
const canDelete = (row: any) => { const canDelete = (row: any) => {
@@ -223,7 +280,7 @@ const remove = async (row: any) => {
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteFeeAttachment(row.id); await deleteAttachment(row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess); ElMessage.success(TEXT.common.messages.deleteSuccess);
load(); load();
} catch (e: any) { } catch (e: any) {
@@ -242,6 +299,13 @@ onMounted(async () => {
await loadMembers(); await loadMembers();
load(); load();
}); });
watch(previewVisible, (visible) => {
if (!visible && previewObjectUrl.value) {
URL.revokeObjectURL(previewObjectUrl.value);
previewObjectUrl.value = "";
}
});
</script> </script>
<style scoped> <style scoped>
+12 -5
View File
@@ -98,7 +98,7 @@ export const TEXT = {
title: "标题", title: "标题",
level: "重要等级", level: "重要等级",
name: "姓名", name: "姓名",
email: "邮箱", email: "帐号",
department: "部门", department: "部门",
avatar: "头像", avatar: "头像",
projectName: "项目名称", projectName: "项目名称",
@@ -570,7 +570,11 @@ export const TEXT = {
kickoffNewTitle: "新增启动会", kickoffNewTitle: "新增启动会",
kickoffEditTitle: "编辑启动会", kickoffEditTitle: "编辑启动会",
kickoffFormSubtitle: "记录启动会日期与参训人员", kickoffFormSubtitle: "记录启动会日期与参训人员",
kickoffUploadHint: "保存后可上传会议件", kickoffUploadHint: "保存后可上传会议纪要、签到表、PPT、其他文件",
kickoffMinutesLabel: "会议纪要",
kickoffSignInLabel: "签到表",
kickoffPptLabel: "PPT",
kickoffOtherLabel: "其他文件",
kickoffDetailTitle: "启动会详情", kickoffDetailTitle: "启动会详情",
kickoffDetailSubtitle: "查看启动会信息与附件", kickoffDetailSubtitle: "查看启动会信息与附件",
attendeesPlaceholder: "每行一个姓名", attendeesPlaceholder: "每行一个姓名",
@@ -580,6 +584,9 @@ export const TEXT = {
trainingUploadHint: "保存后可上传授权附件", trainingUploadHint: "保存后可上传授权附件",
trainingDetailTitle: "培训授权详情", trainingDetailTitle: "培训授权详情",
trainingDetailSubtitle: "查看人员培训与授权信息", trainingDetailSubtitle: "查看人员培训与授权信息",
ownerLabel: "负责人",
statusPending: "未开始",
statusCompleted: "已完成",
}, },
subjectManagement: { subjectManagement: {
title: "参与者管理", title: "参与者管理",
@@ -692,7 +699,7 @@ export const TEXT = {
rejectFailed: "拒绝失败", rejectFailed: "拒绝失败",
}, },
adminUsers: { adminUsers: {
title: "账号理(系统登录账号)", title: "账号理(系统登录账号)",
subtitle: "账号用于登录系统,本身不具备项目或角色权限", subtitle: "账号用于登录系统,本身不具备项目或角色权限",
userLabel: "用户", userLabel: "用户",
createdAt: "创建时间", createdAt: "创建时间",
@@ -770,12 +777,12 @@ export const TEXT = {
subtitle: "同一账号在不同项目可拥有不同角色,角色授权仅在本页进行", subtitle: "同一账号在不同项目可拥有不同角色,角色授权仅在本页进行",
projectPrefix: "项目:", projectPrefix: "项目:",
memberLabel: "成员", memberLabel: "成员",
username: "用户名", username: "名",
projectRole: "项目角色", projectRole: "项目角色",
addedAt: "加入时间", addedAt: "加入时间",
disabledGlobal: "全局已禁用", disabledGlobal: "全局已禁用",
disabled: "已禁用", disabled: "已禁用",
disabledGlobalHint: "请前往【账号理】启用该账号", disabledGlobalHint: "请前往【账号理】启用该账号",
disabledGlobalDesc: "该账号已在系统级被禁用,无法在任何项目中使用", disabledGlobalDesc: "该账号已在系统级被禁用,无法在任何项目中使用",
newTitle: "新增成员", newTitle: "新增成员",
user: "用户", user: "用户",
+3 -3
View File
@@ -10,7 +10,7 @@
<el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button> <el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
</div> </div>
<el-table :data="memberRows" v-loading="loading" stripe> <el-table :data="memberRows" v-loading="loading" stripe>
<el-table-column prop="username" :label="TEXT.modules.adminProjectMembers.username" min-width="160" /> <el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" width="140"> <el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" width="140">
<template #default="scope"> <template #default="scope">
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag> <el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
@@ -71,7 +71,7 @@
<el-option <el-option
v-for="user in availableUsers" v-for="user in availableUsers"
:key="user.id" :key="user.id"
:label="user.username" :label="user.full_name || TEXT.common.fallback"
:value="user.id" :value="user.id"
:disabled="!user.is_active" :disabled="!user.is_active"
/> />
@@ -168,7 +168,7 @@ const memberRows = computed(() =>
const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED"; const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
return { return {
...m, ...m,
username: user?.username || m.user_id, full_name: user?.full_name || user?.username || m.user_id,
effectiveStatus, effectiveStatus,
}; };
}) })
+2 -2
View File
@@ -86,11 +86,11 @@ const rules = reactive<FormRules>({
const craOptions = computed(() => { const craOptions = computed(() => {
const userMap = (props.users || []).reduce<Record<string, string>>((acc, cur: any) => { const userMap = (props.users || []).reduce<Record<string, string>>((acc, cur: any) => {
if (cur?.id) acc[cur.id] = cur.username || cur.id; if (cur?.id) acc[cur.id] = cur.full_name || cur.username || cur.id;
return acc; return acc;
}, {}); }, {});
return (props.members || []).map((m: any) => ({ return (props.members || []).map((m: any) => ({
label: userMap[m.user_id] || m.username || m.user_id, label: userMap[m.user_id] || m.full_name || m.username || m.user_id,
value: m.user_id, value: m.user_id,
})); }));
}); });
+2 -2
View File
@@ -127,10 +127,10 @@ const loadMembers = async () => {
const memberNameMap = computed(() => { const memberNameMap = computed(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
users.value.forEach((u) => { users.value.forEach((u) => {
map[u.id] = u.username || u.id; map[u.id] = u.full_name || u.username || u.id;
}); });
members.value.forEach((m) => { members.value.forEach((m) => {
if (m.user_id && !map[m.user_id]) map[m.user_id] = m.username || m.user_id; if (m.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
}); });
return map; return map;
}); });
+7 -6
View File
@@ -24,7 +24,7 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" /> <el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140"> <el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
@@ -42,11 +42,9 @@
{{ displayDateTime(scope.row.updated_at) }} {{ displayDateTime(scope.row.updated_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button> <el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -99,7 +97,10 @@ const resetFilters = () => {
const goNew = () => router.push("/finance/contracts/new"); const goNew = () => router.push("/finance/contracts/new");
const goDetail = (id: string) => router.push(`/finance/contracts/${id}`); const goDetail = (id: string) => router.push(`/finance/contracts/${id}`);
const goEdit = (id: string) => router.push(`/finance/contracts/${id}/edit`); const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
+7 -6
View File
@@ -29,7 +29,7 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120"> <el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
<template #default="scope"> <template #default="scope">
@@ -52,11 +52,9 @@
{{ displayDateTime(scope.row.updated_at) }} {{ displayDateTime(scope.row.updated_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button> <el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -109,7 +107,10 @@ const resetFilters = () => {
const goNew = () => router.push("/finance/special/new"); const goNew = () => router.push("/finance/special/new");
const goDetail = (id: string) => router.push(`/finance/special/${id}`); const goDetail = (id: string) => router.push(`/finance/special/${id}`);
const goEdit = (id: string) => router.push(`/finance/special/${id}/edit`); const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
+7 -6
View File
@@ -24,18 +24,16 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" /> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" /> <el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" /> <el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180"> <el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button> <el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -88,7 +86,10 @@ const resetFilters = () => {
const goNew = () => router.push("/knowledge/notes/new"); const goNew = () => router.push("/knowledge/notes/new");
const goDetail = (id: string) => router.push(`/knowledge/notes/${id}`); const goDetail = (id: string) => router.push(`/knowledge/notes/${id}`);
const goEdit = (id: string) => router.push(`/knowledge/notes/${id}/edit`); const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
+91 -103
View File
@@ -8,133 +8,126 @@
</div> </div>
<el-card> <el-card>
<el-tabs v-model="activeTab"> <el-table :data="kickoffRows" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onKickoffRowClick">
<el-tab-pane :label="TEXT.modules.startupMeetingAuth.kickoffTab" name="kickoff"> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="180" />
<div class="tab-actions"> <el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
<el-button type="primary" @click="goNewKickoff">{{ TEXT.modules.startupMeetingAuth.kickoffNewTitle }}</el-button> <template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
</div> </el-table-column>
<el-table :data="kickoffItems" v-loading="loadingKickoff" style="width: 100%"> <el-table-column :label="TEXT.modules.startupMeetingAuth.ownerLabel" min-width="160">
<el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160"> <template #default="scope">{{ contactLabel(scope.row) }}</template>
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template> </el-table-column>
</el-table-column> <el-table-column :label="TEXT.common.fields.status" width="140">
<el-table-column prop="attendees" :label="TEXT.common.fields.attendees" min-width="200"> <template #default="scope">
<template #default="scope"> <el-tag :type="scope.row.status === 'COMPLETED' ? 'success' : 'info'">
{{ (scope.row.attendees || []).join("、") || TEXT.common.fallback }} {{ scope.row.status === 'COMPLETED' ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending }}
</template> </el-tag>
</el-table-column> </template>
<el-table-column :label="TEXT.common.labels.actions" width="200"> </el-table-column>
<template #default="scope"> </el-table>
<el-button link type="primary" size="small" @click="goKickoffDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button> <StateEmpty v-if="!loading && kickoffRows.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyKickoff" />
<el-button link type="primary" size="small" @click="goKickoffEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingKickoff && kickoffItems.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyKickoff" />
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.startupMeetingAuth.trainingTab" name="training">
<div class="tab-actions">
<el-button type="primary" @click="goNewTraining">{{ TEXT.modules.startupMeetingAuth.trainingNewTitle }}</el-button>
</div>
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%">
<el-table-column prop="name" :label="TEXT.common.fields.name" min-width="140" />
<el-table-column prop="role" :label="TEXT.common.labels.role" min-width="120" />
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="140" />
<el-table-column :label="TEXT.common.fields.trained" width="120">
<template #default="scope">
<el-checkbox v-model="scope.row.trained" @change="toggleTraining(scope.row)" />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.authorized" width="120">
<template #default="scope">
<el-checkbox v-model="scope.row.authorized" @change="toggleTraining(scope.row)" />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goTrainingDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goTrainingEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingTraining && trainingItems.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyTraining" />
</el-tab-pane>
</el-tabs>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listKickoffs, listTrainingAuthorizations, updateTrainingAuthorization } from "../../api/startup"; import { createKickoff, listKickoffs } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const activeTab = ref("kickoff");
const kickoffItems = ref<any[]>([]); const kickoffItems = ref<any[]>([]);
const trainingItems = ref<any[]>([]); const sites = ref<any[]>([]);
const loadingKickoff = ref(false); const users = ref<any[]>([]);
const loadingTraining = ref(false); const members = ref<any[]>([]);
const loading = ref(false);
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u: any) => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
});
return map;
});
const contactLabel = (row: any) => {
if (!row?.contact) return TEXT.common.fallback;
return String(row.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || TEXT.common.fallback)
.join("、") || TEXT.common.fallback;
};
const kickoffRows = computed(() => {
const kickoffMap = kickoffItems.value.reduce<Record<string, any>>((acc, item) => {
if (item.site_id) acc[item.site_id] = item;
return acc;
}, {});
return sites.value.map((site) => {
const meeting = kickoffMap[site.id];
return {
site_id: site.id,
site_name: site.name || TEXT.common.fallback,
contact: site.contact,
kickoff_date: meeting?.kickoff_date || null,
meeting_id: meeting?.id,
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
};
});
});
const loadKickoffs = async () => { const loadKickoffs = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
loadingKickoff.value = true; loading.value = true;
try { try {
const { data } = await listKickoffs(studyId); const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all([
kickoffItems.value = Array.isArray(data) ? data : data.items || []; fetchSites(studyId, { limit: 500 }),
listKickoffs(studyId),
listMembers(studyId, { limit: 500 }),
fetchUsers({ limit: 500 }),
]);
sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || [];
kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || [];
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
users.value = usersResp.data?.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loadingKickoff.value = false; loading.value = false;
} }
}; };
const loadTraining = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loadingTraining.value = true;
try {
const { data } = await listTrainingAuthorizations(studyId);
trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingTraining.value = false;
}
};
const toggleTraining = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
await updateTrainingAuthorization(studyId, row.id, {
trained: row.trained,
authorized: row.authorized,
});
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
loadTraining();
}
};
const goNewKickoff = () => router.push("/startup/kickoff/new");
const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`); const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`);
const goKickoffEdit = (id: string) => router.push(`/startup/kickoff/${id}/edit`); const onKickoffRowClick = async (row: any) => {
const studyId = study.currentStudy?.id;
const goNewTraining = () => router.push("/startup/training/new"); if (!studyId || !row?.site_id) return;
const goTrainingDetail = (id: string) => router.push(`/startup/training/${id}`); if (row.meeting_id) {
const goTrainingEdit = (id: string) => router.push(`/startup/training/${id}/edit`); goKickoffDetail(row.meeting_id);
return;
}
try {
const { data } = await createKickoff(studyId, { site_id: row.site_id });
goKickoffDetail(data.id);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
}
};
onMounted(() => { onMounted(() => {
loadKickoffs(); loadKickoffs();
loadTraining();
}); });
</script> </script>
@@ -163,9 +156,4 @@ onMounted(() => {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
} }
.tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
</style> </style>
+22 -7
View File
@@ -29,7 +29,7 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
<el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" /> <el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" />
<el-table-column :label="TEXT.common.fields.site" min-width="160"> <el-table-column :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template> <template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
@@ -40,10 +40,9 @@
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140"> <el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template> <template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="160"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button> <el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -55,9 +54,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { fetchSubjects } from "../../api/subjects"; import { deleteSubject, fetchSubjects } from "../../api/subjects";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
@@ -113,7 +112,23 @@ const resetFilters = () => {
const goNew = () => router.push("/subjects/new"); const goNew = () => router.push("/subjects/new");
const goDetail = (id: string) => router.push(`/subjects/${id}`); const goDetail = (id: string) => router.push(`/subjects/${id}`);
const goEdit = (id: string) => router.push(`/subjects/${id}/edit`); const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteSubject(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
onMounted(async () => { onMounted(async () => {
await loadSites(); await loadSites();
+6 -10
View File
@@ -28,16 +28,12 @@
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<div class="ctms-section-card" v-if="recordId"> <AttachmentList
<div class="ctms-section-header"> v-if="recordId"
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div> :study-id="studyId"
</div> entity-type="startup_ethics"
<AttachmentList :entity-id="recordId"
:study-id="studyId" />
entity-type="startup_ethics"
:entity-id="recordId"
/>
</div>
</div> </div>
</template> </template>
@@ -27,16 +27,12 @@
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<div class="ctms-section-card" v-if="recordId"> <AttachmentList
<div class="ctms-section-header"> v-if="recordId"
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div> :study-id="studyId"
</div> entity-type="startup_feasibility"
<AttachmentList :entity-id="recordId"
:study-id="studyId" />
entity-type="startup_feasibility"
:entity-id="recordId"
/>
</div>
</div> </div>
</template> </template>
+382 -16
View File
@@ -6,50 +6,217 @@
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button v-if="!editMode" type="primary" :disabled="loading" @click="startEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-card v-loading="loading"> <el-card v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">{{ displayDate(detail.kickoff_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">
<el-descriptions-item :label="TEXT.common.fields.attendees"> {{ siteInfo.name || TEXT.common.fallback }}
{{ (detail.attendees || []).join("、") || TEXT.common.fallback }} </el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
{{ ownerLabel }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker
v-if="editMode"
v-model="draft.kickoff_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
/>
<span v-else>{{ displayDate(detail.kickoff_date) }}</span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<AttachmentList <el-card>
v-if="meetingId" <div class="training-header">
:study-id="studyId" <div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
entity-type="startup_kickoff" </div>
:entity-id="meetingId" <el-table :data="trainingRows" v-loading="loadingTraining" style="width: 100%" class="ctms-table">
/> <el-table-column prop="name" :label="TEXT.common.fields.name" min-width="140">
<template #default="scope">
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.name" size="small" />
<span v-else>{{ scope.row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="role" :label="TEXT.common.labels.role" min-width="120">
<template #default="scope">
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.role" size="small" />
<span v-else>{{ scope.row.role || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.trained" width="120">
<template #default="scope">
<el-checkbox
v-if="isTrainingEditing(scope.row)"
v-model="trainingRowDraft.trained"
@click.stop
/>
<el-checkbox v-else v-model="scope.row.trained" @change="toggleTraining(scope.row)" @click.stop />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.authorized" width="120">
<template #default="scope">
<el-checkbox
v-if="isTrainingEditing(scope.row)"
v-model="trainingRowDraft.authorized"
@click.stop
/>
<el-checkbox v-else v-model="scope.row.authorized" @change="toggleTraining(scope.row)" @click.stop />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<template v-if="isTrainingEditing(scope.row)">
<el-button link type="primary" size="small" :loading="savingTraining" @click.stop="saveTraining(scope.row)">
{{ TEXT.common.actions.save }}
</el-button>
<el-button link size="small" :disabled="savingTraining" @click.stop="cancelTrainingEdit">
{{ TEXT.common.actions.cancel }}
</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" @click.stop="startTrainingEdit(scope.row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button link type="danger" size="small" @click.stop="removeTraining(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div
class="training-add-row"
:class="{ disabled: newTrainingActive || savingTraining }"
@click="startTrainingAdd"
>
<span class="training-add-icon">+</span>
</div>
<StateEmpty
v-if="!loadingTraining && trainingItems.length === 0 && !newTrainingActive"
:description="TEXT.modules.startupMeetingAuth.emptyTraining"
/>
</el-card>
<div v-if="meetingId" class="attachment-grid">
<AttachmentList
v-for="group in kickoffAttachmentGroups"
:key="group.entityType"
:study-id="studyId"
:entity-type="group.entityType"
:entity-id="meetingId"
:title="group.title"
/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getKickoff } from "../../api/startup"; import {
getKickoff,
updateKickoff,
listTrainingAuthorizations,
createTrainingAuthorization,
updateTrainingAuthorization,
deleteTrainingAuthorization,
} from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT, requiredMessage } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const loadingTraining = ref(false);
const saving = ref(false);
const editMode = ref(false);
const meetingId = route.params.meetingId as string; const meetingId = route.params.meetingId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" });
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
{ entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
{ entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
];
const detail = reactive<any>({ const detail = reactive<any>({
kickoff_date: "", kickoff_date: "",
attendees: [], attendees: [],
}); });
const draft = reactive({
kickoff_date: "",
});
const trainingItems = ref<any[]>([]);
const trainingEditingRowId = ref<string | null>(null);
const newTrainingActive = ref(false);
const savingTraining = ref(false);
const trainingRowDraft = reactive({
name: "",
role: "",
trained: false,
authorized: false,
});
const trainingRows = computed(() => {
if (!newTrainingActive.value) return trainingItems.value;
return [
...trainingItems.value,
{
id: "__new__",
__isNew: true,
name: "",
role: "",
trained: false,
authorized: false,
},
];
});
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u: any) => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
});
return map;
});
const ownerLabel = computed(() => {
if (!siteInfo.contact) return TEXT.common.fallback;
return String(siteInfo.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
});
const statusLabel = computed(() =>
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
);
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
const load = async () => { const load = async () => {
if (!studyId || !meetingId) return; if (!studyId || !meetingId) return;
@@ -57,6 +224,17 @@ const load = async () => {
try { try {
const { data } = await getKickoff(studyId, meetingId); const { data } = await getKickoff(studyId, meetingId);
Object.assign(detail, data); Object.assign(detail, data);
if (!editMode.value) {
draft.kickoff_date = data.kickoff_date || "";
}
if (data?.site_id) {
const { data: sitesData } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
const matched = list.find((site: any) => site.id === data.site_id);
Object.assign(siteInfo, matched || { name: "" });
} else {
Object.assign(siteInfo, { name: "" });
}
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
@@ -64,10 +242,154 @@ const load = async () => {
} }
}; };
const goEdit = () => router.push(`/startup/kickoff/${meetingId}/edit`); const loadTraining = async () => {
if (!studyId) return;
loadingTraining.value = true;
try {
const { data } = await listTrainingAuthorizations(studyId);
trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingTraining.value = false;
}
};
const toggleTraining = async (row: any) => {
if (!studyId) return;
try {
await updateTrainingAuthorization(studyId, row.id, {
trained: row.trained,
authorized: row.authorized,
});
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
loadTraining();
}
};
const resetTrainingDraft = (row?: any) => {
trainingRowDraft.name = row?.name || "";
trainingRowDraft.role = row?.role || "";
trainingRowDraft.trained = !!row?.trained;
trainingRowDraft.authorized = !!row?.authorized;
};
const isTrainingEditing = (row: any) => {
if (row?.__isNew) return newTrainingActive.value;
return trainingEditingRowId.value === row?.id;
};
const startTrainingEdit = (row: any) => {
if (savingTraining.value) return;
newTrainingActive.value = false;
trainingEditingRowId.value = row?.id || null;
resetTrainingDraft(row);
};
const startTrainingAdd = () => {
if (newTrainingActive.value || savingTraining.value) return;
trainingEditingRowId.value = null;
newTrainingActive.value = true;
resetTrainingDraft();
};
const cancelTrainingEdit = () => {
trainingEditingRowId.value = null;
newTrainingActive.value = false;
resetTrainingDraft();
};
const saveTraining = async (row: any) => {
if (!studyId) return;
if (!trainingRowDraft.name.trim()) {
ElMessage.error(requiredMessage(TEXT.common.fields.name));
return;
}
savingTraining.value = true;
try {
const payload = {
name: trainingRowDraft.name.trim(),
role: (trainingRowDraft.role || "").trim() || null,
trained: trainingRowDraft.trained,
authorized: trainingRowDraft.authorized,
};
if (row?.__isNew) {
await createTrainingAuthorization(studyId, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
} else {
await updateTrainingAuthorization(studyId, row.id, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
}
cancelTrainingEdit();
loadTraining();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
savingTraining.value = false;
}
};
const removeTraining = async (row: any) => {
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteTrainingAuthorization(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
loadTraining();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const startEdit = () => {
editMode.value = true;
draft.kickoff_date = detail.kickoff_date || "";
};
const cancelEdit = () => {
editMode.value = false;
draft.kickoff_date = detail.kickoff_date || "";
};
const saveEdit = async () => {
if (!studyId || !meetingId) return;
saving.value = true;
try {
const payload = {
kickoff_date: draft.kickoff_date || null,
};
const { data } = await updateKickoff(studyId, meetingId, payload);
Object.assign(detail, data);
editMode.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/startup/meeting-auth"); const goBack = () => router.push("/startup/meeting-auth");
onMounted(load); onMounted(async () => {
if (studyId) {
const [membersResp, usersResp] = await Promise.all([
listMembers(studyId, { limit: 500 }),
fetchUsers({ limit: 500 }),
]).catch(() => [null, null]);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
}
if (usersResp?.data) {
users.value = usersResp.data.items || [];
}
}
await load();
loadTraining();
});
</script> </script>
<style scoped> <style scoped>
@@ -99,4 +421,48 @@ onMounted(load);
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.training-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.training-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.training-add-row {
border: 1px dashed var(--ctms-border-color);
border-top: none;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctms-text-secondary);
cursor: pointer;
}
.training-add-row:hover {
color: var(--ctms-text-main);
}
.training-add-row.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.training-add-icon {
font-size: 20px;
line-height: 1;
}
.attachment-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
</style> </style>
+89 -14
View File
@@ -10,6 +10,12 @@
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.site">
<el-input v-model="siteInfo.name" disabled />
</el-form-item>
<el-form-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
<el-input :model-value="ownerLabel" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.kickoffDate"> <el-form-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> <el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item> </el-form-item>
@@ -28,12 +34,16 @@
</el-form> </el-form>
</el-card> </el-card>
<AttachmentList <div v-if="isEdit && meetingId" class="attachment-grid">
v-if="isEdit && meetingId" <AttachmentList
:study-id="studyId" v-for="group in kickoffAttachmentGroups"
entity-type="startup_kickoff" :key="group.entityType"
:entity-id="meetingId" :study-id="studyId"
/> :entity-type="group.entityType"
:entity-id="meetingId"
:title="group.title"
/>
</div>
<el-card v-else class="hint-card"> <el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.startupMeetingAuth.kickoffUploadHint" /> <StateEmpty :description="TEXT.modules.startupMeetingAuth.kickoffUploadHint" />
</el-card> </el-card>
@@ -46,6 +56,9 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { createKickoff, getKickoff, updateKickoff } from "../../api/startup"; import { createKickoff, getKickoff, updateKickoff } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -58,6 +71,37 @@ const saving = ref(false);
const meetingId = computed(() => route.params.meetingId as string | undefined); const meetingId = computed(() => route.params.meetingId as string | undefined);
const isEdit = computed(() => !!meetingId.value); const isEdit = computed(() => !!meetingId.value);
const studyId = computed(() => study.currentStudy?.id || ""); const studyId = computed(() => study.currentStudy?.id || "");
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" });
const siteId = ref("");
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u: any) => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
});
return map;
});
const ownerLabel = computed(() => {
if (!siteInfo.contact) return TEXT.common.fallback;
return String(siteInfo.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
});
const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
{ entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
{ entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
];
const form = reactive({ const form = reactive({
kickoff_date: "", kickoff_date: "",
@@ -72,13 +116,24 @@ const parseAttendees = (value: string) => {
}; };
const load = async () => { const load = async () => {
if (!isEdit.value || !studyId.value || !meetingId.value) return; if (!studyId.value) return;
try { try {
const { data } = await getKickoff(studyId.value, meetingId.value); if (isEdit.value && meetingId.value) {
Object.assign(form, { const { data } = await getKickoff(studyId.value, meetingId.value);
kickoff_date: data.kickoff_date || "", Object.assign(form, {
attendees: Array.isArray(data.attendees) ? data.attendees.join("\n") : "", kickoff_date: data.kickoff_date || "",
}); attendees: Array.isArray(data.attendees) ? data.attendees.join("\n") : "",
});
siteId.value = data.site_id || "";
} else {
siteId.value = (route.query.siteId as string) || "";
}
if (siteId.value) {
const { data: sitesData } = await fetchSites(studyId.value, { limit: 500 });
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
const matched = list.find((site: any) => site.id === siteId.value);
Object.assign(siteInfo, matched || { name: "", contact: "" });
}
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} }
@@ -97,7 +152,7 @@ const submit = async () => {
ElMessage.success(TEXT.common.messages.saveSuccess); ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/kickoff/${meetingId.value}`); router.push(`/startup/kickoff/${meetingId.value}`);
} else { } else {
const { data } = await createKickoff(studyId.value, payload); const { data } = await createKickoff(studyId.value, { ...payload, site_id: siteId.value });
ElMessage.success(TEXT.common.messages.createSuccess); ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/kickoff/${data.id}`); router.push(`/startup/kickoff/${data.id}`);
} }
@@ -110,7 +165,21 @@ const submit = async () => {
const goBack = () => router.push("/startup/meeting-auth"); const goBack = () => router.push("/startup/meeting-auth");
onMounted(load); onMounted(async () => {
if (studyId.value) {
const [membersResp, usersResp] = await Promise.all([
listMembers(studyId.value, { limit: 500 }),
fetchUsers({ limit: 500 }),
]).catch(() => [null, null]);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
}
if (usersResp?.data) {
users.value = usersResp.data.items || [];
}
}
load();
});
</script> </script>
<style scoped> <style scoped>
@@ -141,4 +210,10 @@ onMounted(load);
.hint-card { .hint-card {
padding: 12px; padding: 12px;
} }
.attachment-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
</style> </style>