迁移合同费用通用附件

1、删除旧费用附件 API、CRUD、模型和专用前端面板,统一改用通用附件能力。

2、调整合同费用接口为 study_id 语义,并补充合同、凭证、发票等附件类型的权限映射。

3、将合同费用新建和编辑改为抽屉流程,详情页与列表页复用同一编辑组件。

4、补充数据库迁移、附件列表、合同费用抽屉和权限场景测试,覆盖旧权限移除与新流程。
This commit is contained in:
Cheng Zhou
2026-06-04 11:07:13 +08:00
parent ab4f0d93ed
commit 720c98765f
36 changed files with 2576 additions and 1875 deletions
@@ -0,0 +1,25 @@
"""drop legacy fee attachments
Revision ID: 20260528_01
Revises: 20260527_09
Create Date: 2026-05-28 17:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260528_01"
down_revision: Union[str, None] = "20260527_09"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("DROP TABLE IF EXISTS fee_attachments")
def downgrade() -> None:
pass
@@ -0,0 +1,132 @@
"""remove contract fee attachment create and read permissions
Revision ID: 20260529_02
Revises: 20260529_01
Create Date: 2026-05-29 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260529_02"
down_revision: Union[str, None] = "20260529_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REMOVED_KEYS = (
"fees_contracts_attachments:create",
"fees_contracts_attachments:read",
)
RESTORE_FROM_PARENT = {
"fees_contracts_attachments:create": "fees_contracts:create",
"fees_contracts_attachments:read": "fees_contracts:read",
}
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_permission_rows() -> None:
quoted_keys = ", ".join(f"'{key}'" for key in REMOVED_KEYS)
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
def _remove_template_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - '{key}')
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{key}'
)
"""
)
def _restore_permission_rows(new_key: str, source_key: str) -> None:
op.execute(
f"""
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW()
FROM api_endpoint_permissions AS source
WHERE source.endpoint_key = '{source_key}'
AND NOT EXISTS (
SELECT 1
FROM api_endpoint_permissions AS target
WHERE target.study_id = source.study_id
AND target.role = source.role
AND target.endpoint_key = '{new_key}'
)
"""
)
def _restore_template_key(table_name: str, new_key: str, source_key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN role_permissions ? '{source_key}' AND NOT role_permissions ? '{new_key}'
THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{source_key}')
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{source_key}'
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
_remove_permission_rows()
if _table_exists(inspector, "permission_templates"):
for key in REMOVED_KEYS:
_remove_template_key("permission_templates", key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for key in REMOVED_KEYS:
_remove_template_key("permission_template_versions", key)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_permission_rows(new_key, source_key)
if _table_exists(inspector, "permission_templates"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_template_key("permission_templates", new_key, source_key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_template_key("permission_template_versions", new_key, source_key)
@@ -0,0 +1,139 @@
"""remove business attachment create and read permissions
Revision ID: 20260529_03
Revises: 20260529_02
Create Date: 2026-05-29 15:12:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260529_03"
down_revision: Union[str, None] = "20260529_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
RESTORE_FROM_PARENT = {
"startup_initiation_attachments:create": "startup_initiation:create",
"startup_initiation_attachments:read": "startup_initiation:read",
"startup_ethics_attachments:create": "startup_ethics:create",
"startup_ethics_attachments:read": "startup_ethics:read",
"startup_auth_attachments:create": "startup_auth:create",
"startup_auth_attachments:read": "startup_auth:read",
"drug_shipments_attachments:create": "drug_shipments:create",
"drug_shipments_attachments:read": "drug_shipments:read",
"precautions_attachments:create": "precautions:create",
"precautions_attachments:read": "precautions:read",
"faq_attachments:create": "faq_reply:create",
"faq_attachments:read": "faq:read",
}
REMOVED_KEYS = tuple(RESTORE_FROM_PARENT)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_permission_rows() -> None:
quoted_keys = ", ".join(f"'{key}'" for key in REMOVED_KEYS)
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
def _remove_template_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - '{key}')
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{key}'
)
"""
)
def _restore_permission_rows(new_key: str, source_key: str) -> None:
op.execute(
f"""
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW()
FROM api_endpoint_permissions AS source
WHERE source.endpoint_key = '{source_key}'
AND NOT EXISTS (
SELECT 1
FROM api_endpoint_permissions AS target
WHERE target.study_id = source.study_id
AND target.role = source.role
AND target.endpoint_key = '{new_key}'
)
"""
)
def _restore_template_key(table_name: str, new_key: str, source_key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN role_permissions ? '{source_key}' AND NOT role_permissions ? '{new_key}'
THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{source_key}')
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{source_key}'
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
_remove_permission_rows()
if _table_exists(inspector, "permission_templates"):
for key in REMOVED_KEYS:
_remove_template_key("permission_templates", key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for key in REMOVED_KEYS:
_remove_template_key("permission_template_versions", key)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_permission_rows(new_key, source_key)
if _table_exists(inspector, "permission_templates"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_template_key("permission_templates", new_key, source_key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_template_key("permission_template_versions", new_key, source_key)
@@ -0,0 +1,119 @@
"""add material equipment attachment delete permission
Revision ID: 20260529_04
Revises: 20260529_03
Create Date: 2026-05-29 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260529_04"
down_revision: Union[str, None] = "20260529_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SOURCE_KEY = "material_equipments:update"
TARGET_KEY = "material_equipments_attachments:delete"
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _copy_permission_rows() -> None:
op.execute(
f"""
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{TARGET_KEY}', source.allowed, NOW()
FROM api_endpoint_permissions AS source
WHERE source.endpoint_key = '{SOURCE_KEY}'
AND NOT EXISTS (
SELECT 1
FROM api_endpoint_permissions AS target
WHERE target.study_id = source.study_id
AND target.role = source.role
AND target.endpoint_key = '{TARGET_KEY}'
)
"""
)
def _remove_permission_rows() -> None:
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key = '{TARGET_KEY}'")
def _copy_template_key(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN role_permissions ? '{SOURCE_KEY}' AND NOT role_permissions ? '{TARGET_KEY}'
THEN role_permissions || jsonb_build_object('{TARGET_KEY}', role_permissions -> '{SOURCE_KEY}')
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{SOURCE_KEY}'
)
"""
)
def _remove_template_key(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - '{TARGET_KEY}')
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{TARGET_KEY}'
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
_copy_permission_rows()
if _table_exists(inspector, "permission_templates"):
_copy_template_key("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_copy_template_key("permission_template_versions")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
_remove_permission_rows()
if _table_exists(inspector, "permission_templates"):
_remove_template_key("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_remove_template_key("permission_template_versions")
+60 -46
View File
@@ -15,6 +15,7 @@ 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 drug_shipment as drug_shipment_crud from app.crud import drug_shipment as drug_shipment_crud
from app.crud import faq_reply as faq_reply_crud from app.crud import faq_reply as faq_reply_crud
from app.crud import material_equipment as material_equipment_crud
from app.crud import precaution as precaution_crud from app.crud import precaution as precaution_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.crud import startup as startup_crud from app.crud import startup as startup_crud
@@ -33,6 +34,49 @@ FEE_ATTACHMENT_ENTITY_TYPES = {
"contract_fee_voucher", "contract_fee_voucher",
"contract_fee_invoice", "contract_fee_invoice",
} }
FEE_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "fees_contracts:create",
"read": "fees_contracts:read",
"delete": "fees_contracts_attachments:delete",
}
STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_initiation:create",
"read": "startup_initiation:read",
"delete": "startup_initiation_attachments:delete",
}
STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_ethics:create",
"read": "startup_ethics:read",
"delete": "startup_ethics_attachments:delete",
}
STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_auth:create",
"read": "startup_auth:read",
"delete": "startup_auth_attachments:delete",
}
DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "drug_shipments:create",
"read": "drug_shipments:read",
"delete": "drug_shipments_attachments:delete",
}
MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES = {
"material_equipment",
}
MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "material_equipments:update",
"read": "material_equipments:read",
"delete": "material_equipments_attachments:delete",
}
PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "precautions:create",
"read": "precautions:read",
"delete": "precautions_attachments:delete",
}
FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "faq_reply:create",
"read": "faq:read",
"delete": "faq_attachments:delete",
}
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = { STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"startup_kickoff", "startup_kickoff",
"startup_kickoff_minutes", "startup_kickoff_minutes",
@@ -41,23 +85,6 @@ STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"training_authorization", "training_authorization",
} }
ATTACHMENT_PERMISSION_MODULES = {
"contract_fee_contract": "fees_contracts_attachments",
"contract_fee_voucher": "fees_contracts_attachments",
"contract_fee_invoice": "fees_contracts_attachments",
"startup_feasibility": "startup_initiation_attachments",
"startup_ethics": "startup_ethics_attachments",
"startup_kickoff": "startup_auth_attachments",
"startup_kickoff_minutes": "startup_auth_attachments",
"startup_kickoff_signin": "startup_auth_attachments",
"startup_kickoff_ppt": "startup_auth_attachments",
"training_authorization": "startup_auth_attachments",
"drug_shipment": "drug_shipments_attachments",
"precaution": "precautions_attachments",
"faq_replies": "faq_attachments",
}
def _content_disposition(filename: str, disposition: str = "inline") -> str: def _content_disposition(filename: str, disposition: str = "inline") -> str:
fallback = "".join((ch if ord(ch) < 128 else "_") for ch in filename) or "download" fallback = "".join((ch if ord(ch) < 128 else "_") for ch in filename) or "download"
quoted = quote(filename, safe="") quoted = quote(filename, safe="")
@@ -84,25 +111,25 @@ async def _resolve_attachment_parent_permission(
entity_id: uuid.UUID, entity_id: uuid.UUID,
action: str, action: str,
) -> str | None: ) -> str | None:
parent_action = "read" if action == "read" else "update" operation = _attachment_operation(action)
if entity_type in FEE_ATTACHMENT_ENTITY_TYPES: if entity_type in FEE_ATTACHMENT_ENTITY_TYPES:
contract = await contract_fee_crud.get_contract_fee(db, entity_id) contract = await contract_fee_crud.get_contract_fee(db, entity_id)
if not contract or contract.project_id != study_id: if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return "fees_contracts:read" if action == "read" else "fees_contracts:update" return FEE_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "startup_feasibility": if entity_type == "startup_feasibility":
record = await startup_crud.get_feasibility(db, entity_id) record = await startup_crud.get_feasibility(db, entity_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
return f"startup_initiation:{parent_action}" return STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "startup_ethics": if entity_type == "startup_ethics":
record = await startup_crud.get_ethics(db, entity_id) record = await startup_crud.get_ethics(db, entity_id)
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
return f"startup_ethics:{parent_action}" return STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type in STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES: if entity_type in STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES:
if entity_type == "training_authorization": if entity_type == "training_authorization":
@@ -113,29 +140,31 @@ async def _resolve_attachment_parent_permission(
missing_detail = "启动会不存在" missing_detail = "启动会不存在"
if not record or record.study_id != study_id: if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=missing_detail) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=missing_detail)
return "startup_auth:read" if action == "read" else "startup_auth:update" return STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "drug_shipment": if entity_type == "drug_shipment":
shipment = await drug_shipment_crud.get_shipment(db, entity_id) shipment = await drug_shipment_crud.get_shipment(db, entity_id)
if not shipment or shipment.study_id != study_id: if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="药物发货不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="药物发货不存在")
return "drug_shipments:read" if action == "read" else "drug_shipments:update" return DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type in MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES:
equipment = await material_equipment_crud.get_equipment(db, entity_id)
if not equipment or equipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
return MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "precaution": if entity_type == "precaution":
precaution = await precaution_crud.get_precaution(db, entity_id) precaution = await precaution_crud.get_precaution(db, entity_id)
if not precaution or precaution.study_id != study_id: if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
return "precautions:read" if action == "read" else "precautions:update" return PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "faq_replies": if entity_type == "faq_replies":
reply = await faq_reply_crud.get_reply(db, entity_id) reply = await faq_reply_crud.get_reply(db, entity_id)
if not reply or reply.study_id != study_id: if not reply or reply.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ回复不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ回复不存在")
if action == "read": return FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION[operation]
return "faq:read"
if action == "delete":
return "faq_reply:delete"
return "faq_reply:create"
return None return None
@@ -158,23 +187,8 @@ async def _ensure_attachment_permission(
if not membership or not membership.is_active: if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
operation = _attachment_operation(action)
module_prefix = ATTACHMENT_PERMISSION_MODULES.get(entity_type)
if not module_prefix:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="附件类型未配置权限")
attachment_allowed = await role_has_api_permission(
db,
study_id,
membership.role_in_study,
f"{module_prefix}:{operation}",
check_prerequisites=False,
)
if not attachment_allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足")
if not parent_permission: if not parent_permission:
return raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="附件类型未配置权限")
allowed = await role_has_api_permission( allowed = await role_has_api_permission(
db, db,
-255
View File
@@ -1,255 +0,0 @@
import os
import uuid
from pathlib import Path
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request, Form
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.core.security import decode_token
from app.crud import audit as audit_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 fee_attachment as fee_attachment_crud
from app.crud import member as member_crud
from app.crud import user as user_crud
from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.fee_common import FeeApiResponse
from app.schemas.user import UserDisplay
router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees"
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment"}
ALLOWED_FILE_TYPES = {
"contract_fee": {"contract", "voucher", "invoice"},
"contract_payment": {"voucher", "invoice"},
}
async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> uuid.UUID:
if entity_type == "contract_fee":
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.project_id
if entity_type == "contract_payment":
payment = await payment_crud.get_payment(db, entity_id)
if not payment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.project_id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
async def _authorize_download_user(request: Request, db: AsyncSession):
token = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1]
if not token:
token = request.query_params.get("token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
payload = decode_token(token)
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
return user
@router.post(
"/attachments",
response_model=FeeApiResponse[FeeAttachmentRead],
status_code=status.HTTP_201_CREATED,
dependencies=[
Depends(require_api_permission("fees_contracts:update")),
Depends(require_study_not_locked())
],
)
async def upload_fee_attachment(
entity_type: str = Form(...),
entity_id: uuid.UUID = Form(...),
file_type: str = Form(...),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[FeeAttachmentRead]:
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id)
dest_dir = UPLOAD_ROOT / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
dest_path = dest_dir / unique_name
content = await file.read()
async with aiofiles.open(dest_path, "wb") as out_file:
await out_file.write(content)
attachment = await fee_attachment_crud.create_attachment(
db,
entity_type=entity_type,
entity_id=entity_id,
file_type=file_type,
filename=file.filename,
mime_type=file.content_type,
size=len(content),
storage_key=str(dest_path),
url=None,
uploaded_by=current_user.id,
)
await audit_crud.log_action(
db,
study_id=project_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FEE_ATTACHMENT",
detail=f"附件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(
data=FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
uploaded_by=UserDisplay.model_validate(current_user),
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
)
@router.get(
"/attachments",
response_model=FeeApiResponse[list[FeeAttachmentRead]],
dependencies=[Depends(require_api_permission("fees_contracts:read"))],
)
async def list_fee_attachments(
entity_type: str,
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[FeeAttachmentRead]]:
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id)
attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=entity_id)
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)
items: list[FeeAttachmentRead] = []
for attachment in attachments:
user = users_map.get(attachment.uploaded_by)
items.append(
FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
)
return FeeApiResponse(data=items, meta={"total": len(items)})
@router.get(
"/attachments/{attachment_id}/download",
response_class=FileResponse,
)
async def download_fee_attachment(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
current_user = await _authorize_download_user(request, db)
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != "ADMIN":
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_api_permission(
db, project_id, membership.role_in_study, "fees_contracts:read", check_prerequisites=False
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
if not attachment.storage_key or not os.path.exists(attachment.storage_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.storage_key,
filename=attachment.filename,
media_type=attachment.mime_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
)
@router.delete(
"/attachments/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[
Depends(require_api_permission("fees_contracts:update")),
Depends(require_study_not_locked())
],
)
async def delete_fee_attachment(
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
membership = None
if role_value != "ADMIN":
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
can_delete = (
role_value == "ADMIN"
or attachment.uploaded_by == current_user.id
or membership is not None
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await fee_attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action(
db,
study_id=project_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_FEE_ATTACHMENT",
detail=f"附件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
+66 -41
View File
@@ -2,7 +2,7 @@ import uuid
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -29,29 +29,52 @@ from app.models.attachment import Attachment
router = APIRouter() router = APIRouter()
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, endpoint_key: str): async def _ensure_study_access(db: AsyncSession, study_id: uuid.UUID, current_user, endpoint_key: str):
study = await study_crud.get(db, project_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if is_system_admin(current_user): if is_system_admin(current_user):
return None return None
membership = await member_crud.get_member(db, project_id, current_user.id) membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_api_permission(db, project_id, membership.role_in_study, endpoint_key) allowed = await role_has_api_permission(db, study_id, membership.role_in_study, endpoint_key)
if not allowed: if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足")
return membership return membership
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None): async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id: if not center_id:
return return
site = await site_crud.get_site(db, center_id) site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active: if not site or site.study_id != study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None:
if not value:
return None
try:
return uuid.UUID(str(value))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail) from exc
def _resolve_contract_fee_list_query(
request: Request,
study_id: uuid.UUID | None,
center_id: uuid.UUID | None,
) -> tuple[uuid.UUID, uuid.UUID | None]:
query = request.query_params
resolved_study_id = study_id or _parse_optional_uuid(query.get("projectId"), "项目 ID 格式错误")
if not resolved_study_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="缺少项目 ID")
resolved_center_id = center_id or _parse_optional_uuid(query.get("centerId"), "中心 ID 格式错误")
return resolved_study_id, resolved_center_id
def _to_decimal(value: Any) -> Decimal: def _to_decimal(value: Any) -> Decimal:
if isinstance(value, Decimal): if isinstance(value, Decimal):
return value return value
@@ -78,21 +101,23 @@ def _validate_payment_rules(data: ContractFeePaymentCreate | ContractFeePaymentU
dependencies=[Depends(get_current_user)], dependencies=[Depends(get_current_user)],
) )
async def list_contract_fees( async def list_contract_fees(
project_id: uuid.UUID = Query(..., alias="projectId"), request: Request,
center_id: uuid.UUID | None = Query(None, alias="centerId"), study_id: uuid.UUID | None = None,
center_id: uuid.UUID | None = None,
q: str | None = None, q: str | None = None,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> FeeApiResponse[list[ContractFeeListItem]]: ) -> FeeApiResponse[list[ContractFeeListItem]]:
await _ensure_project_access(db, project_id, current_user, "fees_contracts:read") resolved_study_id, resolved_center_id = _resolve_contract_fee_list_query(request, study_id, center_id)
cra_scope = await get_cra_site_scope(db, project_id, current_user) await _ensure_study_access(db, resolved_study_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, resolved_study_id, current_user)
center_ids = cra_scope[0] if cra_scope else None center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids: if resolved_center_id and center_ids is not None and resolved_center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0}) return FeeApiResponse(data=[], meta={"total": 0})
rows = await contract_fee_crud.list_contract_fees( rows = await contract_fee_crud.list_contract_fees(
db, db,
project_id, resolved_study_id,
center_id=center_id, center_id=resolved_center_id,
center_ids=center_ids, center_ids=center_ids,
q=q, q=q,
) )
@@ -106,7 +131,7 @@ async def list_contract_fees(
items.append( items.append(
ContractFeeListItem( ContractFeeListItem(
id=contract.id, id=contract.id,
project_id=contract.project_id, study_id=contract.study_id,
center_id=contract.center_id, center_id=contract.center_id,
contract_no=contract.contract_no, contract_no=contract.contract_no,
signed_date=contract.signed_date, signed_date=contract.signed_date,
@@ -144,8 +169,8 @@ async def get_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id) contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:read") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, contract.project_id, current_user) cra_scope = await get_cra_site_scope(db, contract.study_id, current_user)
if cra_scope and contract.center_id not in cra_scope[0]: if cra_scope and contract.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -187,7 +212,7 @@ async def get_contract_fee(
detail = ContractFeeDetail( detail = ContractFeeDetail(
id=contract.id, id=contract.id,
project_id=contract.project_id, study_id=contract.study_id,
center_id=contract.center_id, center_id=contract.center_id,
contract_no=contract.contract_no, contract_no=contract.contract_no,
signed_date=contract.signed_date, signed_date=contract.signed_date,
@@ -218,27 +243,27 @@ async def create_contract_fee(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeeRead]: ) -> FeeApiResponse[ContractFeeRead]:
await _ensure_project_access(db, contract_in.project_id, current_user, "fees_contracts:create") await _ensure_study_access(db, contract_in.study_id, current_user, "fees_contracts:create")
existing = await contract_fee_crud.get_contract_fee_by_project_center( existing = await contract_fee_crud.get_contract_fee_by_study_center(
db, contract_in.project_id, contract_in.center_id db, contract_in.study_id, contract_in.center_id
) )
if existing: if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
site = await site_crud.get_site(db, contract_in.center_id) site = await site_crud.get_site(db, contract_in.center_id)
if not site or site.study_id != contract_in.project_id or not site.is_active: if not site or site.study_id != contract_in.study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
contract = await contract_fee_crud.create_contract_fee(db, contract_in) contract = await contract_fee_crud.create_contract_fee(db, contract_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=contract.project_id, study_id=contract.study_id,
entity_type="contract_fee", entity_type="contract_fee",
entity_id=contract.id, entity_id=contract.id,
action="CREATE_CONTRACT_FEE", action="CREATE_CONTRACT_FEE",
detail="合同费用已创建", detail="合同费用已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -257,18 +282,18 @@ async def update_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id) contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:update") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
await _ensure_center_active(db, contract.project_id, contract.center_id) await _ensure_center_active(db, contract.study_id, contract.center_id)
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in) contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=contract.project_id, study_id=contract.study_id,
entity_type="contract_fee", entity_type="contract_fee",
entity_id=contract_id, entity_id=contract_id,
action="UPDATE_CONTRACT_FEE", action="UPDATE_CONTRACT_FEE",
detail="合同费用已更新", detail="合同费用已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -286,18 +311,18 @@ async def delete_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id) contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:delete") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete")
await _ensure_center_active(db, contract.project_id, contract.center_id) await _ensure_center_active(db, contract.study_id, contract.center_id)
await contract_fee_crud.delete_contract_fee(db, contract) await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=contract.project_id, study_id=contract.study_id,
entity_type="contract_fee", entity_type="contract_fee",
entity_id=contract_id, entity_id=contract_id,
action="DELETE_CONTRACT_FEE", action="DELETE_CONTRACT_FEE",
detail="合同费用已删除", detail="合同费用已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
@@ -316,18 +341,18 @@ async def create_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, contract_id) contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:update") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
_validate_payment_rules(payment_in) _validate_payment_rules(payment_in)
payment = await payment_crud.create_payment(db, contract_id, payment_in) payment = await payment_crud.create_payment(db, contract_id, payment_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=contract.project_id, study_id=contract.study_id,
entity_type="contract_fee_payment", entity_type="contract_fee_payment",
entity_id=payment.id, entity_id=payment.id,
action="CREATE_CONTRACT_FEE_PAYMENT", action="CREATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已创建", detail="合同费用分期已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -349,7 +374,7 @@ async def update_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:update") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
merged_payment = ContractFeePaymentCreate( merged_payment = ContractFeePaymentCreate(
amount=payment_in.amount if payment_in.amount is not None else payment.amount, amount=payment_in.amount if payment_in.amount is not None else payment.amount,
paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date, paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date,
@@ -362,13 +387,13 @@ async def update_contract_payment(
payment = await payment_crud.update_payment(db, payment, payment_in) payment = await payment_crud.update_payment(db, payment, payment_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=contract.project_id, study_id=contract.study_id,
entity_type="contract_fee_payment", entity_type="contract_fee_payment",
entity_id=payment_id, entity_id=payment_id,
action="UPDATE_CONTRACT_FEE_PAYMENT", action="UPDATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已更新", detail="合同费用分期已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -389,16 +414,16 @@ async def delete_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:update") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
await payment_crud.delete_payment(db, payment) await payment_crud.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id) await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=contract.project_id, study_id=contract.study_id,
entity_type="contract_fee_payment", entity_type="contract_fee_payment",
entity_id=payment_id, entity_id=payment_id,
action="DELETE_CONTRACT_FEE_PAYMENT", action="DELETE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已删除", detail="合同费用分期已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, contract.study_id, current_user),
) )
+37 -1
View File
@@ -323,9 +323,14 @@ def require_study_not_locked():
from app.crud import study as study_crud from app.crud import study as study_crud
from app.crud import faq_category as faq_category_crud from app.crud import faq_category as faq_category_crud
from app.crud import faq_item as faq_item_crud from app.crud import faq_item as faq_item_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as contract_fee_payment_crud
async def resolve_study_id(request: Request, db: AsyncSession) -> uuid.UUID: async def resolve_study_id(request: Request, db: AsyncSession) -> uuid.UUID:
raw_study_id = request.path_params.get("study_id") or request.query_params.get("study_id") raw_study_id = (
request.path_params.get("study_id")
or request.query_params.get("study_id")
)
if raw_study_id: if raw_study_id:
try: try:
return uuid.UUID(str(raw_study_id)) return uuid.UUID(str(raw_study_id))
@@ -376,6 +381,37 @@ def require_study_not_locked():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
return item.study_id return item.study_id
raw_contract_id = request.path_params.get("contract_id")
if raw_contract_id:
try:
contract_id = uuid.UUID(str(raw_contract_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="合同费用 ID 格式错误",
) from exc
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract or not contract.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.study_id
raw_payment_id = request.path_params.get("payment_id")
if raw_payment_id:
try:
payment_id = uuid.UUID(str(raw_payment_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="分期记录 ID 格式错误",
) from exc
payment = await contract_fee_payment_crud.get_payment(db, payment_id)
if not payment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract or not contract.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.study_id
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="缺少项目 ID", detail="缺少项目 ID",
+6 -6
View File
@@ -16,7 +16,7 @@ async def create_contract_fee(
contract_in: ContractFeeCreate, contract_in: ContractFeeCreate,
) -> ContractFee: ) -> ContractFee:
contract = ContractFee( contract = ContractFee(
project_id=contract_in.project_id, project_id=contract_in.study_id,
center_id=contract_in.center_id, center_id=contract_in.center_id,
contract_no=contract_in.contract_no, contract_no=contract_in.contract_no,
signed_date=contract_in.signed_date, signed_date=contract_in.signed_date,
@@ -39,18 +39,18 @@ async def get_contract_fee(db: AsyncSession, contract_id: uuid.UUID) -> Contract
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_contract_fee_by_project_center( async def get_contract_fee_by_study_center(
db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID
) -> ContractFee | None: ) -> ContractFee | None:
result = await db.execute( result = await db.execute(
select(ContractFee).where(ContractFee.project_id == project_id, ContractFee.center_id == center_id) select(ContractFee).where(ContractFee.project_id == study_id, ContractFee.center_id == center_id)
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def list_contract_fees( async def list_contract_fees(
db: AsyncSession, db: AsyncSession,
project_id: uuid.UUID, study_id: uuid.UUID,
center_id: uuid.UUID | None = None, center_id: uuid.UUID | None = None,
center_ids: set[uuid.UUID] | None = None, center_ids: set[uuid.UUID] | None = None,
q: str | None = None, q: str | None = None,
@@ -81,7 +81,7 @@ async def list_contract_fees(
) )
.join(Site, Site.id == ContractFee.center_id) .join(Site, Site.id == ContractFee.center_id)
.outerjoin(ContractFeePayment, ContractFeePayment.contract_fee_id == ContractFee.id) .outerjoin(ContractFeePayment, ContractFeePayment.contract_fee_id == ContractFee.id)
.where(ContractFee.project_id == project_id) .where(ContractFee.project_id == study_id)
.group_by(ContractFee.id, Site.name) .group_by(ContractFee.id, Site.name)
) )
-70
View File
@@ -1,70 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fee_attachment import FeeAttachment
async def create_attachment(
db: AsyncSession,
*,
entity_type: str,
entity_id: uuid.UUID,
file_type: str,
filename: str,
mime_type: str | None,
size: int,
storage_key: str | None,
url: str | None,
uploaded_by: uuid.UUID,
) -> FeeAttachment:
attachment = FeeAttachment(
entity_type=entity_type,
entity_id=entity_id,
file_type=file_type,
filename=filename,
mime_type=mime_type,
size=size,
storage_key=storage_key,
url=url,
uploaded_by=uploaded_by,
)
db.add(attachment)
await db.commit()
await db.refresh(attachment)
return attachment
async def list_attachments(
db: AsyncSession,
*,
entity_type: str,
entity_id: uuid.UUID,
) -> Sequence[FeeAttachment]:
result = await db.execute(
select(FeeAttachment)
.where(
FeeAttachment.entity_type == entity_type,
FeeAttachment.entity_id == entity_id,
FeeAttachment.is_deleted.is_(False),
)
.order_by(FeeAttachment.uploaded_at.desc())
)
return result.scalars().all()
async def get_attachment(db: AsyncSession, attachment_id: uuid.UUID) -> FeeAttachment | None:
result = await db.execute(
select(FeeAttachment).where(FeeAttachment.id == attachment_id, FeeAttachment.is_deleted.is_(False))
)
return result.scalar_one_or_none()
async def soft_delete_attachment(db: AsyncSession, attachment: FeeAttachment) -> FeeAttachment:
attachment.is_deleted = True
db.add(attachment)
await db.commit()
await db.refresh(attachment)
return attachment
-1
View File
@@ -20,7 +20,6 @@ from app.models.ae import AdverseEvent # noqa: F401
from app.models.finance import FinanceItem # noqa: F401 from app.models.finance import FinanceItem # noqa: F401
from app.models.contract_fee import ContractFee # noqa: F401 from app.models.contract_fee import ContractFee # noqa: F401
from app.models.contract_fee_payment import ContractFeePayment # noqa: F401 from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
from app.models.fee_attachment import FeeAttachment # noqa: F401
from app.models.drug_shipment import DrugShipment # noqa: F401 from app.models.drug_shipment import DrugShipment # noqa: F401
from app.models.material_equipment import MaterialEquipment # noqa: F401 from app.models.material_equipment import MaterialEquipment # noqa: F401
from app.models.monitoring_visit_issue import MonitoringVisitIssue # noqa: F401 from app.models.monitoring_visit_issue import MonitoringVisitIssue # noqa: F401
+4
View File
@@ -34,3 +34,7 @@ class ContractFee(Base):
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
) )
@property
def study_id(self) -> uuid.UUID:
return self.project_id
-28
View File
@@ -1,28 +0,0 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class FeeAttachment(Base):
__tablename__ = "fee_attachments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
file_type: Mapped[str] = mapped_column(String(30), nullable=False)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
size: Mapped[int] = mapped_column(Integer, nullable=False)
storage_key: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
uploaded_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
+4 -4
View File
@@ -5,12 +5,12 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.schemas.attachment import AttachmentRead
from app.schemas.contract_fee_payment import ContractFeePaymentRead from app.schemas.contract_fee_payment import ContractFeePaymentRead
from app.schemas.fee_attachment import FeeAttachmentRead
class ContractFeeCreate(BaseModel): class ContractFeeCreate(BaseModel):
project_id: uuid.UUID study_id: uuid.UUID
center_id: uuid.UUID center_id: uuid.UUID
contract_no: Optional[str] = None contract_no: Optional[str] = None
signed_date: Optional[date] = None signed_date: Optional[date] = None
@@ -37,7 +37,7 @@ class ContractFeeUpdate(BaseModel):
class ContractFeeRead(BaseModel): class ContractFeeRead(BaseModel):
id: uuid.UUID id: uuid.UUID
project_id: uuid.UUID study_id: uuid.UUID
center_id: uuid.UUID center_id: uuid.UUID
contract_no: Optional[str] contract_no: Optional[str]
signed_date: Optional[date] signed_date: Optional[date]
@@ -66,4 +66,4 @@ class ContractFeeListItem(ContractFeeRead):
class ContractFeeDetail(ContractFeeRead): class ContractFeeDetail(ContractFeeRead):
payments: list[ContractFeePaymentRead] payments: list[ContractFeePaymentRead]
attachments: dict[str, list[FeeAttachmentRead]] attachments: dict[str, list[AttachmentRead]]
-24
View File
@@ -1,24 +0,0 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
from app.schemas.user import UserDisplay
class FeeAttachmentRead(BaseModel):
id: uuid.UUID
entity_type: str
entity_id: uuid.UUID
file_type: str
filename: str
mime_type: Optional[str]
size: int
storage_key: Optional[str]
url: Optional[str]
uploaded_by: Optional[UserDisplay]
uploaded_by_id: Optional[uuid.UUID] = None
uploaded_at: datetime
model_config = ConfigDict(from_attributes=True)
@@ -4,17 +4,49 @@ from datetime import date
from decimal import Decimal from decimal import Decimal
import uuid import uuid
from starlette.requests import Request
from app.api.v1 import fees_contracts
from app.models.contract_fee import ContractFee from app.models.contract_fee import ContractFee
from app.schemas.contract_fee import ContractFeeCreate, ContractFeeRead, ContractFeeUpdate from app.schemas.contract_fee import ContractFeeCreate, ContractFeeRead, ContractFeeUpdate
def _request_with_query(query: str) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/api/v1/fees/contracts",
"query_string": query.encode(),
"headers": [],
}
)
def test_contract_fee_list_query_accepts_legacy_project_params():
"""合同费用列表应兼容旧 projectId/centerId 查询参数。"""
study_id = uuid.uuid4()
center_id = uuid.uuid4()
resolver = getattr(fees_contracts, "_resolve_contract_fee_list_query", None)
assert resolver is not None
resolved_study_id, resolved_center_id = resolver(
_request_with_query(f"projectId={study_id}&centerId={center_id}"),
None,
None,
)
assert resolved_study_id == study_id
assert resolved_center_id == center_id
def test_contract_fee_schema_includes_contract_basic_fields(): def test_contract_fee_schema_includes_contract_basic_fields():
"""合同费用应包含合同基础信息字段。""" """合同费用应包含合同基础信息字段。"""
project_id = uuid.uuid4() study_id = uuid.uuid4()
center_id = uuid.uuid4() center_id = uuid.uuid4()
payload = ContractFeeCreate( payload = ContractFeeCreate(
project_id=project_id, study_id=study_id,
center_id=center_id, center_id=center_id,
contract_no="CT-001", contract_no="CT-001",
signed_date=date(2026, 5, 27), signed_date=date(2026, 5, 27),
@@ -28,6 +60,8 @@ def test_contract_fee_schema_includes_contract_basic_fields():
assert payload.signed_date == date(2026, 5, 27) assert payload.signed_date == date(2026, 5, 27)
assert payload.currency == "CNY" assert payload.currency == "CNY"
assert payload.remark == "首版合同" assert payload.remark == "首版合同"
assert payload.study_id == study_id
assert "project_id" not in ContractFeeCreate.model_fields
update_payload = ContractFeeUpdate(contract_no="CT-002", currency="USD", remark="变更合同信息") update_payload = ContractFeeUpdate(contract_no="CT-002", currency="USD", remark="变更合同信息")
assert update_payload.model_dump(exclude_unset=True) == { assert update_payload.model_dump(exclude_unset=True) == {
@@ -38,6 +72,8 @@ def test_contract_fee_schema_includes_contract_basic_fields():
for field in ("contract_no", "signed_date", "currency", "remark"): for field in ("contract_no", "signed_date", "currency", "remark"):
assert field in ContractFeeRead.model_fields assert field in ContractFeeRead.model_fields
assert "study_id" in ContractFeeRead.model_fields
assert "project_id" not in ContractFeeRead.model_fields
def test_contract_fee_model_includes_contract_basic_columns(): def test_contract_fee_model_includes_contract_basic_columns():
@@ -23,6 +23,16 @@ class _FaqItem:
self.study_id = study_id self.study_id = study_id
class _ContractFee:
def __init__(self, study_id: uuid.UUID | None):
self.study_id = study_id
class _ContractPayment:
def __init__(self, contract_fee_id: uuid.UUID):
self.contract_fee_id = contract_fee_id
def _make_request(*, method: str = "POST", path: str = "/", query_string: bytes = b"", json_body: bytes = b"", path_params=None): def _make_request(*, method: str = "POST", path: str = "/", query_string: bytes = b"", json_body: bytes = b"", path_params=None):
async def receive(): async def receive():
return {"type": "http.request", "body": json_body, "more_body": False} return {"type": "http.request", "body": json_body, "more_body": False}
@@ -80,6 +90,64 @@ async def test_require_study_not_locked_resolves_study_id_from_category_path(mon
assert study.id == study_id assert study.id == study_id
@pytest.mark.asyncio
async def test_require_study_not_locked_resolves_study_id_from_contract_path(monkeypatch):
study_id = uuid.uuid4()
contract_id = uuid.uuid4()
async def fake_get_contract(_db, requested_contract_id):
assert requested_contract_id == contract_id
return _ContractFee(study_id)
async def fake_get_study(_db, requested_study_id):
return _Study(requested_study_id, is_locked=False)
monkeypatch.setattr("app.crud.contract_fee.get_contract_fee", fake_get_contract)
monkeypatch.setattr("app.crud.study.get", fake_get_study)
dependency = deps.require_study_not_locked()
request = _make_request(
path=f"/api/v1/fees/contracts/{contract_id}",
path_params={"contract_id": str(contract_id)},
)
study = await dependency(request=request, db=object())
assert study.id == study_id
@pytest.mark.asyncio
async def test_require_study_not_locked_resolves_study_id_from_contract_payment_path(monkeypatch):
study_id = uuid.uuid4()
contract_id = uuid.uuid4()
payment_id = uuid.uuid4()
async def fake_get_payment(_db, requested_payment_id):
assert requested_payment_id == payment_id
return _ContractPayment(contract_id)
async def fake_get_contract(_db, requested_contract_id):
assert requested_contract_id == contract_id
return _ContractFee(study_id)
async def fake_get_study(_db, requested_study_id):
return _Study(requested_study_id, is_locked=False)
monkeypatch.setattr("app.crud.contract_fee_payment.get_payment", fake_get_payment)
monkeypatch.setattr("app.crud.contract_fee.get_contract_fee", fake_get_contract)
monkeypatch.setattr("app.crud.study.get", fake_get_study)
dependency = deps.require_study_not_locked()
request = _make_request(
path=f"/api/v1/fees/payments/{payment_id}",
path_params={"payment_id": str(payment_id)},
)
study = await dependency(request=request, db=object())
assert study.id == study_id
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_require_study_not_locked_rejects_locked_study_resolved_from_item_path(monkeypatch): async def test_require_study_not_locked_rejects_locked_study_resolved_from_item_path(monkeypatch):
study_id = uuid.uuid4() study_id = uuid.uuid4()
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./feeContracts.ts"), "utf8");
describe("feeContracts API naming", () => {
it("uses study_id for contract fee project identity", () => {
const source = readSource();
expect(source).toContain("study_id: string");
expect(source).toContain("params: { study_id: string; center_id?: string; q?: string }");
expect(source).not.toContain("project_id: string");
expect(source).not.toContain("projectId");
expect(source).not.toContain("centerId");
});
it("does not expose currency in contract fee payloads because amounts are fixed to ten-thousand yuan", () => {
const source = readSource();
expect(source).not.toContain("currency?: string");
});
});
+2 -4
View File
@@ -2,12 +2,11 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { FeeApiResponse } from "../types/api"; import type { FeeApiResponse } from "../types/api";
export interface ContractFeePayload { export interface ContractFeePayload {
project_id: string; study_id: string;
center_id: string; center_id: string;
contract_no?: string | null; contract_no?: string | null;
signed_date?: string | null; signed_date?: string | null;
contract_amount: number; contract_amount: number;
currency?: string;
remark?: string | null; remark?: string | null;
contract_cases: number; contract_cases: number;
actual_cases?: number | null; actual_cases?: number | null;
@@ -17,7 +16,6 @@ export interface ContractFeeUpdatePayload {
contract_no?: string | null; contract_no?: string | null;
signed_date?: string | null; signed_date?: string | null;
contract_amount?: number; contract_amount?: number;
currency?: string;
remark?: string | null; remark?: string | null;
contract_cases?: number; contract_cases?: number;
actual_cases?: number | null; actual_cases?: number | null;
@@ -32,7 +30,7 @@ export interface ContractPaymentPayload {
remark?: string | null; remark?: string | null;
} }
export const listContractFees = (params: { projectId: string; centerId?: string; q?: string }) => export const listContractFees = (params: { study_id: string; center_id?: string; q?: string }) =>
apiGet<FeeApiResponse<any[]>>("/api/v1/fees/contracts", { params }); apiGet<FeeApiResponse<any[]>>("/api/v1/fees/contracts", { params });
export const getContractFee = (contractId: string) => apiGet<FeeApiResponse<any>>(`/api/v1/fees/contracts/${contractId}`); export const getContractFee = (contractId: string) => apiGet<FeeApiResponse<any>>(`/api/v1/fees/contracts/${contractId}`);
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readAttachmentList = () => readFileSync(resolve(__dirname, "./AttachmentList.vue"), "utf8");
describe("AttachmentList permissions", () => {
it("does not require project member list permission to render uploaders", () => {
const source = readAttachmentList();
expect(source).not.toContain("listMembers");
});
it("matches the contract-fee attachment table layout", () => {
const source = readAttachmentList();
expect(source).toContain('prop="filename" :label="TEXT.common.labels.filename" min-width="360" show-overflow-tooltip');
expect(source).toContain(':label="TEXT.common.labels.size" min-width="180"');
expect(source).toContain(':label="TEXT.common.labels.uploader" min-width="180"');
expect(source).toContain('prop="uploaded_at" :label="TEXT.common.labels.uploadedAt" min-width="180" show-overflow-tooltip');
expect(source).toContain(':label="TEXT.common.labels.actions" min-width="180"');
expect(source).not.toContain('width="160"');
expect(source).toContain('class="attachment-actions"');
expect(source).toContain("flex-wrap: nowrap;");
expect(source).toContain("gap: 4px;");
});
it("can aggregate multiple attachment entity types into one table with an attachment type column", () => {
const source = readAttachmentList();
expect(source).toContain("entityGroups?: AttachmentEntityGroup[]");
expect(source).toContain('v-if="hasEntityGroups"');
expect(source).toContain('label="附件类型"');
expect(source).toContain("scope.row.attachment_type_label");
expect(source).toContain("fetchAttachments(props.studyId, group.entityType, props.entityId)");
expect(source).toContain("attachment_type_label: group.label");
expect(source).toContain("attachment_entity_type: group.entityType");
});
it("can reload attachments when the parent refresh key changes", () => {
const source = readAttachmentList();
expect(source).toContain("refreshKey?: number");
expect(source).toContain("() => props.refreshKey");
expect(source).toContain("load();");
});
it("can hide the uploader while keeping the attachment table visible", () => {
const source = readAttachmentList();
expect(source).toContain("hideUploader?: boolean");
expect(source).toContain("canShowUploader");
expect(source).toContain("!props.hideUploader && canCreate.value");
});
it("hides the default attachment title row when uploader is hidden and no explicit title is provided", () => {
const source = readAttachmentList();
expect(source).toContain("const showHeader = computed(() => !!props.title || canShowUploader.value)");
expect(source).toContain('<div v-if="showHeader" class="header">');
});
it("uses the shared upload implementation for table-mode immediate uploads", () => {
const source = readAttachmentList();
expect(source).not.toContain("AttachmentUploader");
expect(source).toContain(':http-request="uploadImmediate"');
expect(source).toContain("const uploadImmediate = async");
expect(source).toContain("await uploadFile(group, file, props.entityId)");
expect(source).toContain("ElMessage.success(TEXT.common.messages.uploadSuccess)");
expect(source).toContain("load();");
});
it("supports upload-card mode without rendering the table header", () => {
const source = readAttachmentList();
expect(source).toContain('mode?: "table" | "upload"');
expect(source).toContain("displayMode");
expect(source).toContain('v-if="displayMode === \'table\'"');
expect(source).toContain("v-else");
expect(source).toContain('class="attachment-upload-card"');
expect(source).toContain(':auto-upload="false"');
expect(source).toContain("queuePendingUpload(group.key, file)");
expect(source).toContain("点击上传文件");
});
it("allows create forms to choose attachments before the entity id exists", () => {
const source = readAttachmentList();
expect(source).toContain("const pendingSnapshot = () =>");
expect(source).toContain("const uploadPending = async");
expect(source).toContain("const targetEntityId = entityId || props.entityId");
expect(source).toContain("defineExpose({");
expect(source).toContain("pendingSnapshot");
expect(source).toContain("uploadPending");
expect(source).toContain("pendingMap[group.key] = remainItems");
expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)");
});
it("fails pending upload when selected files have no saved entity id", () => {
const source = readAttachmentList();
expect(source).toContain("const hasPendingUploads = computed");
expect(source).toContain("if (!targetEntityId && hasPendingUploads.value)");
expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)");
});
it("uses entity groups as upload groups when an editor has multiple attachment types", () => {
const source = readAttachmentList();
expect(source).toContain("const uploadGroups = computed");
expect(source).toContain("props.entityGroups.map((group)");
expect(source).toContain("key: group.entityType");
expect(source).toContain("entityType: group.entityType");
expect(source).toContain("uploadAttachment(props.studyId, group.entityType, targetEntityId, file");
});
});
@@ -1,32 +1,42 @@
<template> <template>
<div class="attachment-list-panel unified-shell"> <div class="attachment-list-panel unified-shell">
<div class="header"> <template v-if="displayMode === 'table'">
<div v-if="showHeader" class="header">
<span>{{ headerTitle }}</span> <span>{{ headerTitle }}</span>
<AttachmentUploader <div v-if="canShowUploader && tableUploadGroup" class="immediate-upload">
v-if="canCreate" <el-upload
:study-id="studyId" :http-request="uploadImmediate"
:entity-type="entityType" :show-file-list="false"
:entity-id="entityId" :limit="1"
@uploaded="load" :disabled="isImmediateUploading"
/> :auto-upload="true"
>
<el-button type="primary" :loading="isImmediateUploading">{{ TEXT.common.actions.upload }}</el-button>
</el-upload>
<el-progress v-if="tableProgress > 0 && tableProgress < 100" :percentage="tableProgress" :stroke-width="6" />
</div>
</div> </div>
<el-table :data="attachments" v-loading="loading" style="width: 100%" class="attachment-table" table-layout="fixed"> <el-table :data="attachments" v-loading="loading" style="width: 100%" class="attachment-table" table-layout="fixed">
<el-table-column prop="filename" :label="TEXT.common.labels.filename" show-overflow-tooltip /> <el-table-column v-if="hasEntityGroups" label="附件类型" min-width="180" show-overflow-tooltip>
<el-table-column :label="TEXT.common.labels.size"> <template #default="scope">{{ scope.row.attachment_type_label || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="filename" :label="TEXT.common.labels.filename" min-width="360" show-overflow-tooltip />
<el-table-column :label="TEXT.common.labels.size" min-width="180">
<template #default="scope">{{ formatFileSize(scope.row.file_size ?? scope.row.size) }}</template> <template #default="scope">{{ formatFileSize(scope.row.file_size ?? scope.row.size) }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.uploader" show-overflow-tooltip> <el-table-column :label="TEXT.common.labels.uploader" min-width="180" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
{{ uploaderLabel(scope.row) }} {{ uploaderLabel(scope.row) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="uploaded_at" :label="TEXT.common.labels.uploadedAt" show-overflow-tooltip> <el-table-column prop="uploaded_at" :label="TEXT.common.labels.uploadedAt" min-width="180" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
{{ displayDateTime(scope.row.uploaded_at) }} {{ displayDateTime(scope.row.uploaded_at) }}
</template> </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" min-width="180">
<template #default="scope"> <template #default="scope">
<div class="attachment-actions">
<el-button link type="primary" size="small" @click="preview(scope.row)"> <el-button link type="primary" size="small" @click="preview(scope.row)">
{{ TEXT.common.labels.preview }} {{ TEXT.common.labels.preview }}
</el-button> </el-button>
@@ -40,9 +50,49 @@
> >
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
</el-button> </el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</template>
<div v-else class="upload-grid">
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-card">
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
<el-upload
:show-file-list="false"
multiple
:disabled="!canUploadGroup(group) || isUploading(group.key)"
:auto-upload="false"
:on-change="(file: any) => queuePendingUpload(group.key, file)"
>
<div class="upload-trigger" :class="{ 'is-disabled': !canUploadGroup(group) }">
<el-icon class="upload-icon"><Document /></el-icon>
<span class="upload-text">点击上传文件</span>
</div>
</el-upload>
<div v-if="pendingMap[group.key]?.length" class="pending-list">
<div
v-for="item in pendingMap[group.key]"
:key="pendingFileKey(item.file)"
class="pending-file"
:class="{ 'is-failed': item.status === 'failed' }"
>
<span class="pending-file-name">{{ item.file.name }}</span>
<span v-if="item.status === 'failed'" class="pending-file-status">
{{ item.error || TEXT.common.messages.uploadFailed }}
</span>
<el-button link type="danger" size="small" @click="removePendingUpload(group.key, item)">
{{ TEXT.common.actions.remove }}
</el-button>
</div>
</div>
<el-progress
v-if="progressMap[group.key] && progressMap[group.key] < 100"
:percentage="progressMap[group.key]"
:stroke-width="6"
/>
</div>
</div>
</div> </div>
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false"> <el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
@@ -60,33 +110,57 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchAttachments, deleteAttachment } from "../../api/attachments"; import { Document } from "@element-plus/icons-vue";
import AttachmentUploader from "./AttachmentUploader.vue"; import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments";
import { formatFileSize } from "./attachmentUtils"; import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { isSystemAdmin } from "../../utils/roles"; import { isSystemAdmin } from "../../utils/roles";
import { listMembers } from "../../api/members"; import { displayDateTime, getUserDisplayName } from "../../utils/display";
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions"; import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
type AttachmentEntityGroup = {
entityType: string;
label: string;
};
type UploadGroup = {
key: string;
label: string;
entityType: string;
};
type PendingUploadStatus = "pending" | "uploading" | "failed";
type PendingUploadItem = {
file: File;
status: PendingUploadStatus;
error?: string;
};
const props = defineProps<{ const props = defineProps<{
studyId: string; studyId: string;
entityType: string; entityType: string;
entityId: string; entityId: string;
entityGroups?: AttachmentEntityGroup[];
title?: string; title?: string;
readonly?: boolean; readonly?: boolean;
hideUploader?: boolean;
mode?: "table" | "upload";
maxSizeMb?: number;
refreshKey?: number;
}>(); }>();
const attachments = ref<any[]>([]); const attachments = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const progressMap = reactive<Record<string, number>>({});
const pendingMap = reactive<Record<string, PendingUploadItem[]>>({});
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const members = ref<any[]>([]);
const previewVisible = ref(false); const previewVisible = ref(false);
const previewUrl = ref(""); const previewUrl = ref("");
const previewObjectUrl = ref(""); const previewObjectUrl = ref("");
@@ -95,27 +169,159 @@ const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref(""); const previewError = ref("");
const previewLoading = ref(false); const previewLoading = ref(false);
const headerTitle = computed(() => props.title || TEXT.common.labels.attachments); const headerTitle = computed(() => props.title || TEXT.common.labels.attachments);
const displayMode = computed(() => props.mode || "table");
const maxSize = computed(() => props.maxSizeMb ?? 50);
const hasEntityGroups = computed(() => !!props.entityGroups?.length);
const uploadGroups = computed<UploadGroup[]>(() => {
if (props.entityGroups?.length) {
return props.entityGroups.map((group) => ({
key: group.entityType,
label: group.label,
entityType: group.entityType,
}));
}
return [{ key: props.entityType, label: headerTitle.value, entityType: props.entityType }];
});
const showUploadCardLabels = computed(() => uploadGroups.value.length > 1);
const currentRolePermissions = computed(() => { const currentRolePermissions = computed(() => {
const role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study; const role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
return role ? study.currentPermissions?.[role] : null; return role ? study.currentPermissions?.[role] : null;
}); });
const canUseAttachmentPermission = (action: "create" | "read" | "delete") => { const canUseAttachmentPermission = (action: "create" | "read" | "delete", entityType = props.entityType) => {
if (isSystemAdmin(auth.user)) return true; if (isSystemAdmin(auth.user)) return true;
const operationKey = getAttachmentPermissionKey(props.entityType, action); const operationKey = getAttachmentPermissionKey(entityType, action);
if (!operationKey) return false; if (!operationKey) return false;
return isApiPermissionAllowed(currentRolePermissions.value?.[operationKey]); return isApiPermissionAllowed(currentRolePermissions.value?.[operationKey]);
}; };
const canCreate = computed(() => !props.readonly && canUseAttachmentPermission("create")); const canCreate = computed(() => {
if (props.readonly) return false;
if (displayMode.value === "upload") {
return uploadGroups.value.some((group) => canUseAttachmentPermission("create", group.entityType));
}
return !hasEntityGroups.value && canUseAttachmentPermission("create");
});
const canShowUploader = computed(() => !props.hideUploader && canCreate.value);
const showHeader = computed(() => !!props.title || canShowUploader.value);
const canUploadGroup = (group: UploadGroup) => canShowUploader.value && canUseAttachmentPermission("create", group.entityType);
const isUploading = (key: string) => (progressMap[key] || 0) > 0 && (progressMap[key] || 0) < 100;
const tableUploadGroup = computed(() => uploadGroups.value[0] || null);
const tableProgress = computed(() => {
const group = tableUploadGroup.value;
return group ? progressMap[group.key] || 0 : 0;
});
const isImmediateUploading = computed(() => tableProgress.value > 0 && tableProgress.value < 100);
const validateFile = (file: File) => {
if (file.size > maxSize.value * 1024 * 1024) {
ElMessage.error(`${TEXT.common.messages.fileTooLarge} ${maxSize.value}MB`);
return false;
}
return true;
};
const pendingFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
const queuePendingUpload = (fileType: string, uploadFile: any) => {
const group = uploadGroups.value.find((item) => item.key === fileType);
if (!group || !canUploadGroup(group)) return;
const file = uploadFile?.raw as File | undefined;
if (!file || !validateFile(file)) return;
const next = pendingMap[fileType] || [];
if (!next.some((item) => pendingFileKey(item.file) === pendingFileKey(file))) {
pendingMap[fileType] = [...next, { file, status: "pending" }];
}
};
const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
};
const pendingSnapshot = () =>
uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`));
const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0));
const uploadFile = async (group: UploadGroup, file: File, targetEntityId: string) => {
if (!props.studyId || !targetEntityId) return;
if (!validateFile(file)) throw new Error("invalid file");
progressMap[group.key] = 0;
await uploadAttachment(props.studyId, group.entityType, targetEntityId, file, {
onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) {
progressMap[group.key] = Math.round((evt.loaded / evt.total) * 100);
}
},
});
};
const uploadPending = async (entityId?: string) => {
const targetEntityId = entityId || props.entityId;
if (!targetEntityId && hasPendingUploads.value) {
throw new Error(TEXT.common.messages.uploadFailed);
}
if (!targetEntityId) return;
let hasFailure = false;
for (const group of uploadGroups.value) {
const items = [...(pendingMap[group.key] || [])];
const remainItems: PendingUploadItem[] = [];
for (const item of items) {
try {
item.status = "uploading";
item.error = "";
await uploadFile(group, item.file, targetEntityId);
} catch (e: any) {
hasFailure = true;
item.status = "failed";
item.error = e?.response?.data?.message || TEXT.common.messages.uploadFailed;
remainItems.push(item);
} finally {
progressMap[group.key] = 0;
}
}
pendingMap[group.key] = remainItems;
}
if (hasFailure) throw new Error(TEXT.common.messages.uploadFailed);
};
const uploadImmediate = async (options: any) => {
const file = options?.file as File | undefined;
const group = tableUploadGroup.value;
if (!file || !group || !props.entityId) return;
if (!validateFile(file)) return;
try {
await uploadFile(group, file, props.entityId);
ElMessage.success(TEXT.common.messages.uploadSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.uploadFailed);
} finally {
progressMap[group.key] = 0;
}
};
const load = async () => { const load = async () => {
if (!props.studyId || !props.entityId) return; if (!props.studyId || !props.entityId) return;
loading.value = true; loading.value = true;
try { try {
if (hasEntityGroups.value && props.entityGroups) {
const results = await Promise.all(
props.entityGroups.map(async (group) => {
const { data } = await fetchAttachments(props.studyId, group.entityType, props.entityId);
const items = data.items || data || [];
return (Array.isArray(items) ? items : []).map((item: any) => ({
...item,
attachment_type_label: group.label,
attachment_entity_type: group.entityType,
}));
})
);
attachments.value = results.flat();
} else {
const { data } = await fetchAttachments(props.studyId, props.entityType, props.entityId); const { data } = await fetchAttachments(props.studyId, props.entityType, props.entityId);
attachments.value = data.items || data || []; attachments.value = data.items || data || [];
}
} 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 {
@@ -123,26 +329,11 @@ const load = async () => {
} }
}; };
const loadMembers = async () => {
if (!props.studyId) return;
try {
const { data } = await listMembers(props.studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const uploaderLabel = (row: any) => { const uploaderLabel = (row: any) => {
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
return acc;
}, {});
if (row?.uploaded_by && typeof row.uploaded_by === "object") { if (row?.uploaded_by && typeof row.uploaded_by === "object") {
return getUserDisplayName(row.uploaded_by) || row.uploaded_by.id || TEXT.common.fallback; return getUserDisplayName(row.uploaded_by) || row.uploaded_by.id || TEXT.common.fallback;
} }
return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap }); return row.uploaded_by_id || row.uploaded_by || TEXT.common.fallback;
}; };
const getDownloadUrl = (row: any) => { const getDownloadUrl = (row: any) => {
@@ -219,7 +410,7 @@ const preview = (row: any) => {
const canDelete = (row: any) => { const canDelete = (row: any) => {
if (props.readonly) return false; if (props.readonly) return false;
return canUseAttachmentPermission("delete"); return canUseAttachmentPermission("delete", row?.attachment_entity_type || props.entityType);
}; };
const remove = async (row: any) => { const remove = async (row: any) => {
@@ -234,8 +425,7 @@ const remove = async (row: any) => {
} }
}; };
onMounted(async () => { onMounted(() => {
await Promise.all([loadMembers()]);
load(); load();
}); });
@@ -245,6 +435,18 @@ watch(previewVisible, (visible) => {
previewObjectUrl.value = ""; previewObjectUrl.value = "";
} }
}); });
watch(
() => props.refreshKey,
() => {
load();
}
);
defineExpose({
pendingSnapshot,
uploadPending,
});
</script> </script>
<style scoped> <style scoped>
@@ -267,6 +469,120 @@ watch(previewVisible, (visible) => {
display: none; display: none;
} }
.attachment-actions {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
white-space: nowrap;
}
.attachment-actions :deep(.el-button) {
margin-left: 0;
padding: 0 2px;
}
.immediate-upload {
display: flex;
gap: 8px;
align-items: center;
}
.upload-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.attachment-upload-card {
min-height: 72px;
padding: 14px 16px;
border: 1px dashed #d0dced;
border-radius: 8px;
background: #ffffff;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.attachment-upload-card:hover {
border-color: var(--ctms-primary);
background: #f8faff;
}
.upload-card-label {
margin-bottom: 10px;
color: #4a6283;
font-size: 13px;
font-weight: 600;
}
.upload-trigger {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
cursor: pointer;
}
.upload-trigger.is-disabled {
cursor: not-allowed;
opacity: 0.62;
}
.upload-icon {
font-size: 18px;
line-height: 1;
}
.upload-text {
color: var(--ctms-text-secondary);
font-size: 13px;
}
.upload-trigger:not(.is-disabled):hover .upload-text {
color: var(--ctms-primary);
}
.pending-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.pending-file {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--ctms-text-regular);
font-size: 12px;
}
.pending-file.is-failed {
color: var(--ctms-danger, #f56c6c);
}
.pending-file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pending-file-status {
flex: 0 0 auto;
color: var(--ctms-danger, #f56c6c);
font-size: 12px;
white-space: nowrap;
}
@media (max-width: 640px) {
.upload-grid {
grid-template-columns: 1fr;
}
}
.preview-frame { .preview-frame {
width: 100%; width: 100%;
height: 520px; height: 520px;
@@ -1,68 +0,0 @@
<template>
<div class="uploader">
<el-upload
:http-request="doUpload"
:show-file-list="false"
:limit="1"
:disabled="loading"
:auto-upload="true"
>
<el-button type="primary" :loading="loading">{{ TEXT.common.actions.upload }}</el-button>
</el-upload>
<el-progress v-if="progress > 0 && progress < 100" :percentage="progress" :stroke-width="6" />
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { uploadAttachment } from "../../api/attachments";
import { TEXT } from "../../locales";
const props = defineProps<{
studyId: string;
entityType: string;
entityId: string;
maxSizeMb?: number;
}>();
const emit = defineEmits(["uploaded"]);
const progress = ref(0);
const loading = ref(false);
const maxSize = props.maxSizeMb ?? 50;
const doUpload = async (options: any) => {
const file: File = options.file;
if (!file) return;
if (file.size > maxSize * 1024 * 1024) {
ElMessage.error(`${TEXT.common.messages.fileTooLarge} ${maxSize}MB`);
return;
}
progress.value = 0;
loading.value = true;
try {
await uploadAttachment(props.studyId, props.entityType, props.entityId, file, {
onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) {
progress.value = Math.round((evt.loaded / evt.total) * 100);
}
},
});
ElMessage.success(TEXT.common.messages.uploadSuccess);
emit("uploaded");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.uploadFailed);
} finally {
progress.value = 0;
loading.value = false;
}
};
</script>
<style scoped>
.uploader {
display: flex;
gap: 8px;
align-items: center;
}
</style>
@@ -1,410 +0,0 @@
<template>
<div class="fee-attachments">
<div v-for="group in groups" :key="group.key" class="group-card unified-shell">
<div class="group-header">
<div>
<div class="group-title">{{ group.label }}</div>
<div v-if="group.description" class="group-desc">{{ group.description }}</div>
</div>
<el-upload
v-if="canCreate"
:http-request="(options: any) => doUpload(group.key, options)"
:show-file-list="false"
:limit="1"
:disabled="readonly || isUploading(group.key)"
:auto-upload="true"
>
<el-button size="small" type="primary" :loading="isUploading(group.key)" :disabled="readonly">
{{ TEXT.common.actions.upload }}
</el-button>
</el-upload>
</div>
<el-progress
v-if="progressMap[group.key] && progressMap[group.key] < 100"
:percentage="progressMap[group.key]"
:stroke-width="6"
/>
<StateLoading v-if="loading" :rows="3" />
<template v-else>
<div v-if="(grouped[group.key] || []).length === 0" class="group-empty">
<StateEmpty :description="group.emptyText" />
</div>
<el-table v-else :data="grouped[group.key]" style="width: 100%" class="group-table" table-layout="fixed">
<el-table-column prop="filename" :label="TEXT.common.labels.filename" show-overflow-tooltip />
<el-table-column :label="TEXT.common.labels.size">
<template #default="scope">{{ formatFileSize(scope.row.size || scope.row.file_size) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.uploader" show-overflow-tooltip>
<template #default="scope">
{{ uploaderLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="160">
<template #default="scope">
<el-button link type="primary" size="small" @click="preview(scope.row)">
{{ TEXT.common.labels.preview }}
</el-button>
<el-button link type="primary" size="small" @click="download(scope.row)">
{{ TEXT.common.actions.download }}
</el-button>
<el-button
v-if="canDelete(scope.row)"
link
type="danger"
size="small"
@click="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
</div>
<el-dialog append-to=".layout-main .content-wrapper" v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" class="preview-frame" />
<div v-else class="preview-error">
{{ TEXT.common.messages.previewNotSupported }}
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchAttachments, uploadAttachment, deleteAttachment } from "../../api/attachments";
import { listMembers } from "../../api/members";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { isSystemAdmin } from "../../utils/roles";
import { formatFileSize } from "../attachments/attachmentUtils";
import { displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import StateEmpty from "../StateEmpty.vue";
import StateLoading from "../StateLoading.vue";
import { TEXT } from "../../locales";
interface AttachmentGroup {
key: string;
label: string;
description?: string;
emptyText?: string;
}
const props = defineProps<{
entityType: string;
entityId: string;
groups: AttachmentGroup[];
readonly?: boolean;
maxSizeMb?: number;
}>();
const loading = ref(false);
const grouped = reactive<Record<string, any[]>>({});
const progressMap = reactive<Record<string, number>>({});
const auth = useAuthStore();
const study = useStudyStore();
const members = ref<any[]>([]);
const previewVisible = ref(false);
const previewUrl = ref("");
const previewObjectUrl = ref("");
const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref("");
const previewLoading = ref(false);
const isUploading = (key: string) => (progressMap[key] || 0) > 0 && (progressMap[key] || 0) < 100;
const currentRolePermissions = computed(() => {
const role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
return role ? study.currentPermissions?.[role] : null;
});
const canUseAttachmentPermission = (action: "create" | "read" | "delete") => {
if (isSystemAdmin(auth.user)) return true;
const operationKey = getAttachmentPermissionKey(`${props.entityType}_contract`, action);
if (!operationKey) return false;
return isApiPermissionAllowed(currentRolePermissions.value?.[operationKey]);
};
const canCreate = computed(() => !props.readonly && canUseAttachmentPermission("create"));
const loadMembers = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await listMembers(studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const load = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !props.entityType || !props.entityId) return;
loading.value = true;
try {
const results = await Promise.all(
props.groups.map(async (group) => {
const entityType = `${props.entityType}_${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) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const uploaderLabel = (row: any) => {
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
return acc;
}, {});
if (row?.uploaded_by && typeof row.uploaded_by === "object") {
return getUserDisplayName(row.uploaded_by) || row.uploaded_by.id || TEXT.common.fallback;
}
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 file: File = options.file;
if (!file || props.readonly) return;
const studyId = study.currentStudy?.id;
if (!studyId) return;
const maxSize = props.maxSizeMb ?? 50;
if (file.size > maxSize * 1024 * 1024) {
ElMessage.error(`${TEXT.common.messages.fileTooLarge} ${maxSize}MB`);
return;
}
progressMap[fileType] = 0;
try {
const entityType = `${props.entityType}_${fileType}`;
await uploadAttachment(studyId, entityType, props.entityId, file, {
onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) {
progressMap[fileType] = Math.round((evt.loaded / evt.total) * 100);
}
},
});
ElMessage.success(TEXT.common.messages.uploadSuccess);
await load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.uploadFailed);
} finally {
progressMap[fileType] = 0;
}
};
const detectPreviewType = (row: any) => {
const mime = getMimeType(row);
if (mime.startsWith("image/")) return "image";
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";
};
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) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview;
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") {
previewError.value = TEXT.common.messages.previewNotSupported;
}
previewVisible.value = true;
};
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 canDelete = (row: any) => {
if (props.readonly) return false;
return canUseAttachmentPermission("delete");
};
const remove = async (row: any) => {
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteAttachment(row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
watch(
() => [props.entityType, props.entityId],
() => {
load();
}
);
onMounted(async () => {
await loadMembers();
load();
});
watch(previewVisible, (visible) => {
if (!visible && previewObjectUrl.value) {
URL.revokeObjectURL(previewObjectUrl.value);
previewObjectUrl.value = "";
}
});
</script>
<style scoped>
.fee-attachments {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-card {
background: #ffffff;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: hidden;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid #eaf0f8;
}
.group-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.group-desc {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.group-empty {
padding: 12px 16px 0;
}
.group-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
/* Compact Empty State Override */
.group-empty :deep(.state) {
padding: 16px 0 !important;
min-height: auto !important;
flex-direction: row;
gap: 8px;
}
.group-empty :deep(.state-icon) {
font-size: 20px !important;
margin-bottom: 0 !important;
}
.group-empty :deep(.state-title) {
font-size: 13px !important;
font-weight: normal !important;
margin: 0 !important;
}
.group-empty :deep(.state-desc) {
display: none; /* Hide description in compact mode if desired, or keep it small */
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
display: block;
margin: 0 auto;
}
.preview-error {
color: var(--ctms-text-secondary);
font-size: 13px;
padding: 12px 0;
text-align: center;
}
</style>
+5 -6
View File
@@ -387,6 +387,7 @@ export const TEXT = {
lastVerified: "最近核销", lastVerified: "最近核销",
paymentTitle: "分期付款", paymentTitle: "分期付款",
paymentEmpty: "暂无分期付款记录", paymentEmpty: "暂无分期付款记录",
paymentSeqColumn: "付款期次",
paymentSeq: "第", paymentSeq: "第",
paymentSeqUnit: "笔", paymentSeqUnit: "笔",
paidFlag: "打款状态", paidFlag: "打款状态",
@@ -398,15 +399,12 @@ export const TEXT = {
verifiedDateRequired: "请填写核销日期", verifiedDateRequired: "请填写核销日期",
verifyRequiresPaid: "核销前需先打款", verifyRequiresPaid: "核销前需先打款",
paymentValidationFailed: "请完善分期付款信息", paymentValidationFailed: "请完善分期付款信息",
attachmentTitle: "合同附件", attachmentTitle: "附件",
attachmentContract: "合同", attachmentContract: "合同",
attachmentContractDesc: "上传合同扫描件或签署页",
attachmentContractEmpty: "暂无合同附件", attachmentContractEmpty: "暂无合同附件",
attachmentVoucher: "凭证", attachmentVoucher: "凭证",
attachmentVoucherDesc: "上传打款凭证或收据",
attachmentVoucherEmpty: "暂无凭证附件", attachmentVoucherEmpty: "暂无凭证附件",
attachmentInvoice: "发票", attachmentInvoice: "发票",
attachmentInvoiceDesc: "上传发票或税务凭证",
attachmentInvoiceEmpty: "暂无发票附件", attachmentInvoiceEmpty: "暂无发票附件",
uploadHint: "保存后可上传合同/凭证/发票附件", uploadHint: "保存后可上传合同/凭证/发票附件",
}, },
@@ -427,6 +425,8 @@ export const TEXT = {
title: "设备管理", title: "设备管理",
subtitle: "设备台账与状态跟踪", subtitle: "设备台账与状态跟踪",
listTitle: "设备列表", listTitle: "设备列表",
detailTitle: "设备详情",
detailSubtitle: "查看设备基础信息、资质文件与校准设置",
emptyDescription: "设备管理模块正在建设中,敬请期待。", emptyDescription: "设备管理模块正在建设中,敬请期待。",
}, },
fileVersionManagement: { fileVersionManagement: {
@@ -654,6 +654,7 @@ export const TEXT = {
detailTitle: "医学咨询详情", detailTitle: "医学咨询详情",
newCategory: "新增分类", newCategory: "新增分类",
editCategory: "编辑分类", editCategory: "编辑分类",
categoryName: "分类名称",
newItem: "新建咨询", newItem: "新建咨询",
editItem: "编辑咨询", editItem: "编辑咨询",
confirmDeleteCategory: "确认删除分类「{name}」?", confirmDeleteCategory: "确认删除分类「{name}」?",
@@ -1041,12 +1042,10 @@ export const TEXT = {
: "待寄出", : "待寄出",
: "运输中", : "运输中",
: "已签收", : "已签收",
: "已回收",
: "异常", : "异常",
PENDING: "待寄出", PENDING: "待寄出",
IN_TRANSIT: "运输中", IN_TRANSIT: "运输中",
SIGNED: "已签收", SIGNED: "已签收",
RETURNED: "已回收",
EXCEPTION: "异常", EXCEPTION: "异常",
}, },
projectStatus: { projectStatus: {
@@ -3,14 +3,30 @@ import { getAttachmentPermissionKey } from "./attachmentPermissions";
describe("attachment permission mapping", () => { describe("attachment permission mapping", () => {
it("maps attachment entity types to module attachment permissions", () => { it("maps attachment entity types to module attachment permissions", () => {
expect(getAttachmentPermissionKey("contract_fee_contract", "create")).toBe("fees_contracts:create");
expect(getAttachmentPermissionKey("contract_fee_contract", "read")).toBe("fees_contracts:read");
expect(getAttachmentPermissionKey("contract_fee_contract", "delete")).toBe("fees_contracts_attachments:delete"); expect(getAttachmentPermissionKey("contract_fee_contract", "delete")).toBe("fees_contracts_attachments:delete");
expect(getAttachmentPermissionKey("startup_feasibility", "read")).toBe("startup_initiation_attachments:read"); expect(getAttachmentPermissionKey("startup_feasibility", "create")).toBe("startup_initiation:create");
expect(getAttachmentPermissionKey("startup_ethics", "create")).toBe("startup_ethics_attachments:create"); expect(getAttachmentPermissionKey("startup_feasibility", "read")).toBe("startup_initiation:read");
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "read")).toBe("startup_auth_attachments:read"); expect(getAttachmentPermissionKey("startup_feasibility", "delete")).toBe("startup_initiation_attachments:delete");
expect(getAttachmentPermissionKey("startup_ethics", "create")).toBe("startup_ethics:create");
expect(getAttachmentPermissionKey("startup_ethics", "read")).toBe("startup_ethics:read");
expect(getAttachmentPermissionKey("startup_ethics", "delete")).toBe("startup_ethics_attachments:delete");
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "create")).toBe("startup_auth:create");
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "read")).toBe("startup_auth:read");
expect(getAttachmentPermissionKey("training_authorization", "delete")).toBe("startup_auth_attachments:delete"); expect(getAttachmentPermissionKey("training_authorization", "delete")).toBe("startup_auth_attachments:delete");
expect(getAttachmentPermissionKey("drug_shipment", "read")).toBe("drug_shipments_attachments:read"); expect(getAttachmentPermissionKey("drug_shipment", "create")).toBe("drug_shipments:create");
expect(getAttachmentPermissionKey("precaution", "create")).toBe("precautions_attachments:create"); expect(getAttachmentPermissionKey("drug_shipment", "read")).toBe("drug_shipments:read");
expect(getAttachmentPermissionKey("faq_replies", "create")).toBe("faq_attachments:create"); expect(getAttachmentPermissionKey("drug_shipment", "delete")).toBe("drug_shipments_attachments:delete");
expect(getAttachmentPermissionKey("material_equipment", "create")).toBe("material_equipments:update");
expect(getAttachmentPermissionKey("material_equipment", "read")).toBe("material_equipments:read");
expect(getAttachmentPermissionKey("material_equipment", "delete")).toBe("material_equipments_attachments:delete");
expect(getAttachmentPermissionKey("precaution", "create")).toBe("precautions:create");
expect(getAttachmentPermissionKey("precaution", "read")).toBe("precautions:read");
expect(getAttachmentPermissionKey("precaution", "delete")).toBe("precautions_attachments:delete");
expect(getAttachmentPermissionKey("faq_replies", "create")).toBe("faq_reply:create");
expect(getAttachmentPermissionKey("faq_replies", "read")).toBe("faq:read");
expect(getAttachmentPermissionKey("faq_replies", "delete")).toBe("faq_attachments:delete");
}); });
it("does not return generic attachments permissions", () => { it("does not return generic attachments permissions", () => {
+72 -16
View File
@@ -1,25 +1,81 @@
export type AttachmentPermissionAction = "create" | "read" | "delete"; export type AttachmentPermissionAction = "create" | "read" | "delete";
const ATTACHMENT_PERMISSION_PREFIXES: Record<string, string> = { const PERMISSIONS_BY_ENTITY_ACTION: Record<string, Record<AttachmentPermissionAction, string>> = {
contract_fee_contract: "fees_contracts_attachments", contract_fee_contract: {
contract_fee_voucher: "fees_contracts_attachments", create: "fees_contracts:create",
contract_fee_invoice: "fees_contracts_attachments", read: "fees_contracts:read",
startup_feasibility: "startup_initiation_attachments", delete: "fees_contracts_attachments:delete",
startup_ethics: "startup_ethics_attachments", },
startup_kickoff: "startup_auth_attachments", contract_fee_voucher: {
startup_kickoff_minutes: "startup_auth_attachments", create: "fees_contracts:create",
startup_kickoff_signin: "startup_auth_attachments", read: "fees_contracts:read",
startup_kickoff_ppt: "startup_auth_attachments", delete: "fees_contracts_attachments:delete",
training_authorization: "startup_auth_attachments", },
drug_shipment: "drug_shipments_attachments", contract_fee_invoice: {
precaution: "precautions_attachments", create: "fees_contracts:create",
faq_replies: "faq_attachments", read: "fees_contracts:read",
delete: "fees_contracts_attachments:delete",
},
startup_feasibility: {
create: "startup_initiation:create",
read: "startup_initiation:read",
delete: "startup_initiation_attachments:delete",
},
startup_ethics: {
create: "startup_ethics:create",
read: "startup_ethics:read",
delete: "startup_ethics_attachments:delete",
},
startup_kickoff: {
create: "startup_auth:create",
read: "startup_auth:read",
delete: "startup_auth_attachments:delete",
},
startup_kickoff_minutes: {
create: "startup_auth:create",
read: "startup_auth:read",
delete: "startup_auth_attachments:delete",
},
startup_kickoff_signin: {
create: "startup_auth:create",
read: "startup_auth:read",
delete: "startup_auth_attachments:delete",
},
startup_kickoff_ppt: {
create: "startup_auth:create",
read: "startup_auth:read",
delete: "startup_auth_attachments:delete",
},
training_authorization: {
create: "startup_auth:create",
read: "startup_auth:read",
delete: "startup_auth_attachments:delete",
},
drug_shipment: {
create: "drug_shipments:create",
read: "drug_shipments:read",
delete: "drug_shipments_attachments:delete",
},
material_equipment: {
create: "material_equipments:update",
read: "material_equipments:read",
delete: "material_equipments_attachments:delete",
},
precaution: {
create: "precautions:create",
read: "precautions:read",
delete: "precautions_attachments:delete",
},
faq_replies: {
create: "faq_reply:create",
read: "faq:read",
delete: "faq_attachments:delete",
},
}; };
export const getAttachmentPermissionKey = ( export const getAttachmentPermissionKey = (
entityType: string, entityType: string,
action: AttachmentPermissionAction action: AttachmentPermissionAction
): string | null => { ): string | null => {
const prefix = ATTACHMENT_PERMISSION_PREFIXES[entityType]; return PERMISSIONS_BY_ENTITY_ACTION[entityType]?.[action] || null;
return prefix ? `${prefix}:${action}` : null;
}; };
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readDetail = () => readFileSync(resolve(__dirname, "./ContractFeeDetail.vue"), "utf8");
describe("ContractFeeDetail permissions", () => {
it("suppresses optional site lookup permission errors on the detail page", () => {
const source = readDetail();
expect(source).toContain('fetchSites(studyId.value, { limit: 500 }, { suppressErrorMessage: true })');
});
it("opens the edit drawer on the detail page without navigating back to the list", () => {
const source = readDetail();
expect(source).toContain("<ContractFeeEditorDrawer");
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).not.toContain('router.push({ path: "/fees/contracts"');
expect(source).not.toContain("editContractId");
});
it("renders contract fee attachments as one aggregated table with attachment type labels", () => {
const source = readDetail();
expect(source).toContain("<AttachmentList");
expect(source).toContain(":entity-groups=\"attachmentGroups\"");
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
expect(source).toContain("attachmentRefreshKey.value += 1");
expect(source).not.toContain("<FeeAttachmentPanel");
expect(source).toContain('entityType: "contract_fee_contract"');
expect(source).toContain('entityType: "contract_fee_voucher"');
expect(source).toContain('entityType: "contract_fee_invoice"');
});
it("does not show attachment group descriptions on the detail page", () => {
const source = readDetail();
expect(source).not.toContain("attachmentContractDesc");
expect(source).not.toContain("attachmentVoucherDesc");
expect(source).not.toContain("attachmentInvoiceDesc");
});
it("keeps payment table columns evenly distributed with clear labels", () => {
const source = readDetail();
expect(source).toContain(':label="TEXT.modules.feeContracts.paymentSeqColumn"');
expect(source).not.toContain('width="20%"');
expect(source.match(/min-width="180"/g)).toHaveLength(5);
expect(source).not.toContain(".payment-table :deep(col:nth-child(-n + 5))");
expect(source).toContain(':label="TEXT.common.fields.amount" align="left" header-align="left" min-width="180"');
});
it("uses ten-thousand yuan as the only amount unit and hides currency", () => {
const source = readDetail();
expect(source).toContain("formatAmountWan(detail.contract_amount)");
expect(source).toContain("formatAmountWan(scope.row.amount)");
expect(source).toContain('<span class="amount-unit">万</span>');
expect(source).not.toContain("TEXT.common.fields.currency");
expect(source).not.toContain("detail.currency");
expect(source).not.toContain("currencyDefault");
});
});
+159 -75
View File
@@ -14,42 +14,43 @@
<div class="card-header actions-header"> <div class="card-header actions-header">
<span class="header-title">{{ TEXT.modules.feeContracts.contractInfoTitle }}</span> <span class="header-title">{{ TEXT.modules.feeContracts.contractInfoTitle }}</span>
<div class="actions"> <div class="actions">
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn"> <el-button v-if="canWrite" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
<el-icon class="el-icon--left"><Edit /></el-icon> <el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
<el-button @click="goBack" class="header-action-btn">
{{ TEXT.common.actions.back }}
</el-button>
</div> </div>
</div> </div>
</template> </template>
<el-descriptions :column="4" border class="custom-descriptions"> <div class="info-grid">
<el-descriptions-item :label="TEXT.common.fields.site" label-class-name="desc-label"> <div class="info-item info-item--highlight">
{{ centerName || TEXT.common.fallback }} <span class="info-label">{{ TEXT.modules.feeContracts.contractAmount }}</span>
</el-descriptions-item> <span class="info-value amount-value">{{ formatAmountWan(detail.contract_amount) }}<span class="amount-unit"></span></span>
<el-descriptions-item :label="TEXT.common.fields.contractNo" label-class-name="desc-label"> </div>
{{ detail.contract_no || TEXT.common.fallback }} <div class="info-item">
</el-descriptions-item> <span class="info-label">{{ TEXT.common.fields.site }}</span>
<el-descriptions-item :label="TEXT.common.fields.signedDate" label-class-name="desc-label"> <span class="info-value">{{ centerName || TEXT.common.fallback }}</span>
{{ displayDate(detail.signed_date) }} </div>
</el-descriptions-item> <div class="info-item">
<el-descriptions-item :label="TEXT.modules.feeContracts.contractAmount" label-class-name="desc-label"> <span class="info-label">{{ TEXT.common.fields.contractNo }}</span>
<span class="amount-text">{{ formatAmountWan(detail.contract_amount) }}</span> <span class="info-value">{{ detail.contract_no || TEXT.common.fallback }}</span>
</el-descriptions-item> </div>
<el-descriptions-item :label="TEXT.modules.feeContracts.contractCases" label-class-name="desc-label"> <div class="info-item">
{{ detail.contract_cases ?? TEXT.common.fallback }} <span class="info-label">{{ TEXT.common.fields.signedDate }}</span>
</el-descriptions-item> <span class="info-value">{{ displayDate(detail.signed_date) }}</span>
<el-descriptions-item :label="TEXT.modules.feeContracts.actualCases" label-class-name="desc-label"> </div>
{{ detail.actual_cases ?? TEXT.common.fallback }} <div class="info-item">
</el-descriptions-item> <span class="info-label">{{ TEXT.modules.feeContracts.contractCases }}</span>
<el-descriptions-item :label="TEXT.common.fields.currency" label-class-name="desc-label"> <span class="info-value">{{ detail.contract_cases ?? TEXT.common.fallback }}</span>
{{ detail.currency || TEXT.common.fields.currencyDefault }} </div>
</el-descriptions-item> <div class="info-item">
<el-descriptions-item :label="TEXT.common.fields.remark" label-class-name="desc-label" :span="4"> <span class="info-label">{{ TEXT.modules.feeContracts.actualCases }}</span>
{{ detail.remark || TEXT.common.fallback }} <span class="info-value">{{ detail.actual_cases ?? TEXT.common.fallback }}</span>
</el-descriptions-item> </div>
</el-descriptions> <div class="info-item info-item--full">
<span class="info-label">{{ TEXT.common.fields.remark }}</span>
<span class="info-value">{{ detail.remark || TEXT.common.fallback }}</span>
</div>
</div>
</el-card> </el-card>
<el-card class="section-card unified-shell" shadow="never"> <el-card class="section-card unified-shell" shadow="never">
@@ -59,17 +60,17 @@
</div> </div>
</template> </template>
<el-table :data="detail.payments" style="width: 100%" :header-cell-style="{ background: '#f8f9fb' }" table-layout="fixed"> <el-table :data="detail.payments" style="width: 100%" :header-cell-style="{ background: '#f8f9fb' }" table-layout="fixed">
<el-table-column :label="TEXT.modules.feeContracts.paymentSeq"> <el-table-column :label="TEXT.modules.feeContracts.paymentSeqColumn" min-width="180">
<template #default="scope"> <template #default="scope">
<span class="seq-tag">{{ TEXT.modules.feeContracts.paymentSeq }}{{ scope.row.seq }}{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span> <span class="seq-tag">{{ TEXT.modules.feeContracts.paymentSeq }}{{ scope.row.seq }}{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.amount" align="right"> <el-table-column :label="TEXT.common.fields.amount" align="left" header-align="left" min-width="180">
<template #default="scope"> <template #default="scope">
<span class="amount-text">{{ formatAmountWan(scope.row.amount) }}</span> <span class="amount-text">{{ formatAmountWan(scope.row.amount) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.paidFlag"> <el-table-column :label="TEXT.modules.feeContracts.paidFlag" min-width="180">
<template #default="scope"> <template #default="scope">
<div v-if="scope.row.is_paid" class="status-cell"> <div v-if="scope.row.is_paid" class="status-cell">
<el-tag type="success" size="small">{{ TEXT.modules.feeContracts.isPaid }}</el-tag> <el-tag type="success" size="small">{{ TEXT.modules.feeContracts.isPaid }}</el-tag>
@@ -78,7 +79,7 @@
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span> <span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifiedFlag"> <el-table-column :label="TEXT.modules.feeContracts.verifiedFlag" min-width="180">
<template #default="scope"> <template #default="scope">
<div v-if="scope.row.is_verified" class="status-cell"> <div v-if="scope.row.is_verified" class="status-cell">
<el-tag type="info" size="small">{{ TEXT.modules.feeContracts.isVerified }}</el-tag> <el-tag type="info" size="small">{{ TEXT.modules.feeContracts.isVerified }}</el-tag>
@@ -87,7 +88,7 @@
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span> <span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.remark" show-overflow-tooltip> <el-table-column :label="TEXT.common.fields.remark" min-width="180" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template> <template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
@@ -102,21 +103,31 @@
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span> <span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
</div> </div>
</template> </template>
<FeeAttachmentPanel <AttachmentList
v-if="contractId" v-if="contractId"
:entity-type="'contract_fee'" :study-id="studyId"
entity-type="contract_fee"
:entity-id="contractId" :entity-id="contractId"
:groups="attachmentGroups" :entity-groups="attachmentGroups"
:readonly="isReadOnly" :readonly="isReadOnly"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/> />
</el-card> </el-card>
</template> </template>
<ContractFeeEditorDrawer
v-model="editorVisible"
:contract-id="contractId"
:sites="sites"
@saved="handleEditorSaved"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue"; import { Edit } from "@element-plus/icons-vue";
import { getContractFee } from "../../api/feeContracts"; import { getContractFee } from "../../api/feeContracts";
@@ -126,23 +137,25 @@ import { usePermission } from "../../utils/permission";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import StateError from "../../components/StateError.vue"; import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission(); const { can } = usePermission();
const canWrite = computed(() => can("fees.contract.update")); const canWrite = computed(() => can("fees.contract.update"));
const contractId = route.params.contractId as string; const contractId = route.params.contractId as string;
const studyId = computed(() => study.currentStudy?.id || "");
const loading = ref(false); const loading = ref(false);
const editorVisible = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const attachmentRefreshKey = ref(0);
const detail = reactive<any>({ const detail = reactive<any>({
contract_no: "", contract_no: "",
signed_date: "", signed_date: "",
contract_amount: 0, contract_amount: 0,
currency: "CNY",
remark: "", remark: "",
contract_cases: 0, contract_cases: 0,
actual_cases: null, actual_cases: null,
@@ -162,20 +175,20 @@ const attachmentGroups = [
{ {
key: "contract", key: "contract",
label: TEXT.modules.feeContracts.attachmentContract, label: TEXT.modules.feeContracts.attachmentContract,
description: TEXT.modules.feeContracts.attachmentContractDesc,
emptyText: TEXT.modules.feeContracts.attachmentContractEmpty, emptyText: TEXT.modules.feeContracts.attachmentContractEmpty,
entityType: "contract_fee_contract",
}, },
{ {
key: "voucher", key: "voucher",
label: TEXT.modules.feeContracts.attachmentVoucher, label: TEXT.modules.feeContracts.attachmentVoucher,
description: TEXT.modules.feeContracts.attachmentVoucherDesc,
emptyText: TEXT.modules.feeContracts.attachmentVoucherEmpty, emptyText: TEXT.modules.feeContracts.attachmentVoucherEmpty,
entityType: "contract_fee_voucher",
}, },
{ {
key: "invoice", key: "invoice",
label: TEXT.modules.feeContracts.attachmentInvoice, label: TEXT.modules.feeContracts.attachmentInvoice,
description: TEXT.modules.feeContracts.attachmentInvoiceDesc,
emptyText: TEXT.modules.feeContracts.attachmentInvoiceEmpty, emptyText: TEXT.modules.feeContracts.attachmentInvoiceEmpty,
entityType: "contract_fee_invoice",
}, },
]; ];
@@ -185,10 +198,9 @@ const centerName = computed(() => {
}); });
const loadSites = async () => { const loadSites = async () => {
const studyId = study.currentStudy?.id; if (!studyId.value) return;
if (!studyId) return;
try { try {
const { data } = await fetchSites(studyId, { limit: 500 }); const { data } = await fetchSites(studyId.value, { limit: 500 }, { suppressErrorMessage: true });
sites.value = Array.isArray(data) ? data : data.items || []; sites.value = Array.isArray(data) ? data : data.items || [];
} catch { } catch {
sites.value = []; sites.value = [];
@@ -203,11 +215,11 @@ const load = async () => {
const { data } = await getContractFee(contractId); const { data } = await getContractFee(contractId);
Object.assign(detail, data?.data || {}); Object.assign(detail, data?.data || {});
if (!Array.isArray(detail.payments)) detail.payments = []; if (!Array.isArray(detail.payments)) detail.payments = [];
const siteName = centerName.value || TEXT.common.fallback;
// 同步中心名称到面包屑 study.setViewContext({
if (centerName.value) { siteName,
study.setViewContext({ siteName: centerName.value }); pageTitle: detail.contract_no || TEXT.menu.feeContracts,
} });
} catch (e: any) { } catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed; errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally { } finally {
@@ -222,13 +234,21 @@ const formatAmountWan = (value: any) => {
}; };
const goEdit = () => { const goEdit = () => {
if (!canWrite.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) { if (isReadOnly.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
} }
router.push(`/fees/contracts/${contractId}/edit`); editorVisible.value = true;
};
const handleEditorSaved = () => {
load();
attachmentRefreshKey.value += 1;
}; };
const goBack = () => router.push("/fees/contracts");
onMounted(async () => { onMounted(async () => {
await loadSites(); await loadSites();
@@ -240,7 +260,8 @@ onMounted(async () => {
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 16px;
padding: 4px 0;
} }
.actions { .actions {
@@ -249,14 +270,14 @@ onMounted(async () => {
} }
.header-action-btn { .header-action-btn {
height: 36px; height: 34px;
border-radius: 8px; border-radius: 8px;
padding: 0 16px; padding: 0 16px;
} }
.detail-card, .section-card { .detail-card, .section-card {
border-radius: 12px; border-radius: 14px;
border: 1px solid var(--ctms-border-color); border: 1px solid var(--unified-shell-divider, #eaf0f8);
overflow: hidden; overflow: hidden;
} }
@@ -275,23 +296,72 @@ onMounted(async () => {
} }
.header-title { .header-title {
font-size: 16px; font-size: 15px;
font-weight: 600; font-weight: 700;
color: var(--ctms-text-main); color: var(--unified-title-color, #0f2345);
border-left: 4px solid var(--el-color-primary); border-left: 3px solid var(--el-color-primary);
padding-left: 12px; padding-left: 10px;
} }
.custom-descriptions :deep(.desc-label) { .info-grid {
background-color: var(--ctms-bg-color-page) !important; display: grid;
color: var(--ctms-text-secondary); grid-template-columns: repeat(4, 1fr);
width: 150px; gap: 0;
padding: 20px 24px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border-radius: 8px;
transition: background 0.15s ease;
}
.info-item:hover {
background: #f8fafc;
}
.info-item--highlight {
background: linear-gradient(135deg, #f0f6ff 0%, #eef3fb 100%);
border-radius: 10px;
}
.info-item--highlight:hover {
background: linear-gradient(135deg, #e8f1ff 0%, #e6edfa 100%);
}
.info-item--full {
grid-column: 1 / -1;
}
.info-label {
font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--unified-muted-color, #6f84a8);
letter-spacing: 0.02em;
} }
.amount-text { .info-value {
font-family: var(--el-font-family); font-size: 14px;
font-weight: 600; font-weight: 500;
color: var(--ctms-text-main);
word-break: break-all;
}
.amount-value {
font-size: 22px;
font-weight: 700;
color: var(--ctms-primary);
font-variant-numeric: tabular-nums;
}
.amount-unit {
font-size: 13px;
font-weight: 500;
color: var(--unified-muted-color, #6f84a8);
margin-left: 2px;
} }
.seq-tag { .seq-tag {
@@ -313,17 +383,31 @@ onMounted(async () => {
} }
.text-muted { .text-muted {
color: var(--ctms-text-placeholder); color: var(--ctms-text-placeholder, #94a3b8);
font-size: 13px; font-size: 13px;
} }
.table-empty { .table-empty {
min-height: 220px; min-height: 120px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #8a97ab; color: #8a97ab;
font-size: 14px; font-size: 13px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; }
@media (max-width: 960px) {
.info-grid {
grid-template-columns: repeat(2, 1fr);
padding: 16px;
}
}
@media (max-width: 640px) {
.info-grid {
grid-template-columns: 1fr;
padding: 12px;
}
} }
</style> </style>
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readDrawer = () => readFileSync(resolve(__dirname, "./ContractFeeEditorDrawer.vue"), "utf8");
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
describe("ContractFeeEditorDrawer attachments", () => {
it("uses upload-card mode for attachments instead of the detail table", () => {
const source = readDrawer();
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain("<AttachmentList");
expect(source).toContain(":entity-groups=\"attachmentGroups\"");
expect(source).not.toContain("PendingAttachmentUpload");
expect(source).not.toContain("FeeAttachmentPanel");
expect(source).not.toContain("attachmentContractDesc");
expect(source).not.toContain("attachmentVoucherDesc");
expect(source).not.toContain("attachmentInvoiceDesc");
});
it("uploads selected attachments only after the form save succeeds", () => {
const source = readDrawer();
expect(source).toContain("const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null)");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source.indexOf("await attachmentPanelRef.value?.uploadPending(savedId)")).toBeLessThan(
source.indexOf('emit("update:modelValue", false)')
);
});
it("shows attachment upload cards while creating a new contract fee", () => {
const source = readDrawer();
expect(source).toContain('class="form-group attachment-form-group"');
expect(source).toContain(':entity-id="form.id"');
expect(source).toContain("<AttachmentList");
expect(source).not.toContain('<div v-if="form.id" class="form-group">');
});
it("labels the section as attachments instead of contract attachments", () => {
const source = readLocale();
expect(source).toContain('attachmentTitle: "附件"');
expect(source).not.toContain('attachmentTitle: "合同附件"');
});
it("uses a payment grid that keeps date pickers inside their columns", () => {
const source = readDrawer();
expect(source).toContain('class="payment-grid"');
expect(source).toContain('class="payment-field payment-field--amount"');
expect(source).toContain('class="payment-field payment-field--status"');
expect(source).toContain('class="payment-field payment-field--remark"');
expect(source).toContain("grid-template-columns: minmax(180px, 0.9fr) repeat(2, minmax(220px, 1fr));");
expect(source).toContain("min-width: 0;");
expect(source).toContain(".status-picker :deep(.el-date-editor.el-input)");
});
it("uses ten-thousand yuan as the only amount unit and removes currency selection", () => {
const source = readDrawer();
expect(source).toContain(':label="`${TEXT.modules.feeContracts.contractAmount}(万元)`"');
expect(source).toContain(':label="`${TEXT.common.fields.amount}(万元)`"');
expect(source).toContain("contract_amount: Number(detail.contract_amount || 0) / 10000");
expect(source).toContain("amount: Number(payment.amount || 0) / 10000");
expect(source).toContain("contract_amount: Number(form.contract_amount || 0) * 10000");
expect(source).toContain("amount: Number(payment.amount || 0) * 10000");
expect(source).not.toContain("prop=\"currency\"");
expect(source).not.toContain("form.currency");
expect(source).not.toContain("payload.currency");
expect(source).not.toContain("currency:");
});
});
@@ -0,0 +1,795 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="720px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="contract-fee-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ contractId ? TEXT.modules.feeContracts.editTitle : TEXT.modules.feeContracts.newTitle }}</div>
</div>
</template>
<StateError v-if="drawerErrorMessage" :description="drawerErrorMessage">
<template #action>
<el-button type="primary" @click="reloadEditingDetail">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="drawerLoading" :rows="6" />
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" class="contract-fee-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.feeContracts.contractInfoTitle }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select
v-model="form.center_id"
:disabled="!!contractId || isFormReadOnly"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no">
<el-input v-model="form.contract_no" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.signedDate" prop="signed_date">
<el-date-picker
v-model="form.signed_date"
:disabled="isFormReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item :label="`${TEXT.modules.feeContracts.contractAmount}(万元)`" prop="contract_amount">
<el-input-number
v-model="form.contract_amount"
:disabled="isFormReadOnly"
:min="0"
:precision="2"
:step="1"
class="full-width"
controls-position="right"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases">
<el-input-number v-model="form.contract_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
<el-input-number v-model="form.actual_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" :disabled="isFormReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
<div class="form-group">
<div class="form-group-title action-title">
<span>
<span class="group-dot group-dot-payment"></span>
{{ TEXT.modules.feeContracts.paymentTitle }}
</span>
<el-button type="primary" size="small" :disabled="isFormReadOnly" @click="addPayment">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}
</el-button>
</div>
<div v-if="payments.length === 0" class="section-empty">
{{ TEXT.modules.feeContracts.paymentEmpty }}
</div>
<div v-else class="payment-list">
<div v-for="(payment, index) in payments" :key="payment.tempId" class="payment-item">
<div class="payment-item-header">
<div class="payment-seq">
<span class="seq-circle">{{ index + 1 }}</span>
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</div>
<el-button link type="danger" :disabled="isFormReadOnly" @click="removePayment(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="payment-grid">
<div class="payment-field payment-field--amount">
<el-form-item :label="`${TEXT.common.fields.amount}(万元)`" :error="paymentErrors[index]?.amount">
<el-input-number v-model="payment.amount" :disabled="isFormReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item>
</div>
<div class="payment-field payment-field--status">
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
<div class="status-control">
<el-checkbox v-model="payment.is_paid" :disabled="isFormReadOnly" @change="() => onPaidToggle(payment)">
{{ TEXT.modules.feeContracts.isPaid }}
</el-checkbox>
<el-date-picker
v-if="payment.is_paid"
v-model="payment.paid_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isFormReadOnly"
class="status-picker"
size="small"
/>
</div>
</el-form-item>
</div>
<div class="payment-field payment-field--status">
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
<div class="status-control">
<el-checkbox v-model="payment.is_verified" :disabled="isFormReadOnly" @change="() => onVerifiedToggle(payment)">
{{ TEXT.modules.feeContracts.isVerified }}
</el-checkbox>
<el-date-picker
v-if="payment.is_verified"
v-model="payment.verified_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isFormReadOnly"
class="status-picker"
size="small"
/>
</div>
</el-form-item>
</div>
<div class="payment-field payment-field--remark">
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
<el-input v-model="payment.remark" :disabled="isFormReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</div>
</div>
<div v-if="paymentErrors[index]?.verification" class="payment-error">
{{ paymentErrors[index].verification }}
</div>
</div>
</div>
</div>
<div class="form-group attachment-form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.modules.feeContracts.attachmentTitle }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="currentStudyId"
:entity-type="'contract_fee'"
:entity-id="form.id"
:entity-groups="attachmentGroups"
:mode="'upload'"
:readonly="isFormReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly || drawerLoading" @click="saveForm">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { Delete, Plus } from "@element-plus/icons-vue";
import {
createContractFee,
createContractPayment,
deleteContractPayment,
getContractFee,
updateContractFee,
updateContractPayment,
} from "../../api/feeContracts";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { TEXT } from "../../locales";
type ContractFormModel = {
id: string;
study_id: string;
center_id: string;
contract_no: string;
signed_date: string;
contract_amount: number;
remark: string;
contract_cases: number;
actual_cases: number | null;
};
type PaymentFormModel = {
id?: string;
tempId: string;
amount: number;
paid_date: string;
verified_date: string;
is_paid: boolean;
is_verified: boolean;
remark: string;
};
const props = defineProps<{
modelValue: boolean;
contractId?: string;
sites: any[];
initialCenterId?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
saved: [contractId: string];
}>();
const study = useStudyStore();
const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update"));
const currentStudyId = computed(() => study.currentStudy?.id || "");
const drawerLoading = ref(false);
const drawerErrorMessage = ref("");
const saving = ref(false);
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
props.sites.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const defaultForm: ContractFormModel = {
id: "",
study_id: "",
center_id: "",
contract_no: "",
signed_date: "",
contract_amount: 0,
remark: "",
contract_cases: 0,
actual_cases: null,
};
const form = reactive<ContractFormModel>({ ...defaultForm });
const payments = ref<PaymentFormModel[]>([]);
const removedPaymentIds = ref<string[]>([]);
const paymentErrors = ref<Record<string, string>[]>([]);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
payments: payments.value,
removedPaymentIds: removedPaymentIds.value,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const selectedSiteInactive = computed(() => !!props.contractId && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const isFormReadOnly = computed(() => (props.contractId ? !canUpdate.value : !canCreate.value) || selectedSiteInactive.value);
const attachmentGroups = [
{
entityType: "contract_fee_contract",
label: TEXT.modules.feeContracts.attachmentContract,
},
{
entityType: "contract_fee_voucher",
label: TEXT.modules.feeContracts.attachmentVoucher,
},
{
entityType: "contract_fee_invoice",
label: TEXT.modules.feeContracts.attachmentInvoice,
},
];
const rules: FormRules<ContractFormModel> = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
contract_amount: [{ required: true, type: "number", message: TEXT.common.messages.required, trigger: "change" }],
contract_cases: [{ required: true, type: "number", message: TEXT.common.messages.required, trigger: "change" }],
};
const resetForm = () => {
Object.assign(form, { ...defaultForm, study_id: study.currentStudy?.id || "", center_id: props.initialCenterId || "" });
payments.value = [];
removedPaymentIds.value = [];
paymentErrors.value = [];
drawerErrorMessage.value = "";
formRef.value?.clearValidate();
};
const normalizePayment = (payment: any): PaymentFormModel => ({
id: payment.id,
tempId: payment.id || `${Date.now()}-${Math.random()}`,
amount: Number(payment.amount || 0) / 10000,
paid_date: payment.paid_date || "",
verified_date: payment.verified_date || "",
is_paid: !!payment.is_paid,
is_verified: !!payment.is_verified,
remark: payment.remark || "",
});
const loadEditingDetail = async (id: string) => {
drawerLoading.value = true;
drawerErrorMessage.value = "";
try {
const { data } = await getContractFee(id);
const detail = data?.data || {};
Object.assign(form, {
id: detail.id || id,
study_id: detail.study_id || study.currentStudy?.id || "",
center_id: detail.center_id || "",
contract_no: detail.contract_no || "",
signed_date: detail.signed_date || "",
contract_amount: Number(detail.contract_amount || 0) / 10000,
remark: detail.remark || "",
contract_cases: Number(detail.contract_cases || 0),
actual_cases: detail.actual_cases ?? null,
});
payments.value = (detail.payments || []).map(normalizePayment);
removedPaymentIds.value = [];
paymentErrors.value = [];
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
drawerErrorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
drawerLoading.value = false;
}
};
const reloadEditingDetail = () => {
if (props.contractId) loadEditingDetail(props.contractId);
};
const addPayment = () => {
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
payments.value.push({
tempId: `${Date.now()}-${Math.random()}`,
amount: 0,
paid_date: "",
verified_date: "",
is_paid: false,
is_verified: false,
remark: "",
});
};
const removePayment = (index: number) => {
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
const payment = payments.value[index];
if (payment?.id) {
removedPaymentIds.value.push(payment.id);
}
payments.value.splice(index, 1);
};
const onPaidToggle = (payment: PaymentFormModel) => {
if (isFormReadOnly.value) return;
if (!payment.is_paid) {
payment.paid_date = "";
payment.is_verified = false;
payment.verified_date = "";
}
};
const onVerifiedToggle = (payment: PaymentFormModel) => {
if (isFormReadOnly.value) return;
if (payment.is_verified && !payment.is_paid) {
payment.is_paid = true;
}
if (!payment.is_verified) {
payment.verified_date = "";
}
};
const validatePayments = () => {
const errors: Record<string, string>[] = [];
let ok = true;
payments.value.forEach((payment, index) => {
const entry: Record<string, string> = {};
if (payment.amount === null || payment.amount === undefined || Number(payment.amount) < 0) {
entry.amount = TEXT.modules.feeContracts.amountInvalid;
ok = false;
}
if (payment.is_paid && !payment.paid_date) {
entry.paid_date = TEXT.modules.feeContracts.paidDateRequired;
ok = false;
}
if (payment.is_verified && !payment.verified_date) {
entry.verified_date = TEXT.modules.feeContracts.verifiedDateRequired;
ok = false;
}
if (payment.is_verified && !payment.is_paid) {
entry.verification = TEXT.modules.feeContracts.verifyRequiresPaid;
ok = false;
}
errors[index] = entry;
});
paymentErrors.value = errors;
return ok;
};
const saveForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
ElMessage.warning("中心已停用");
return;
}
const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return;
if (!validatePayments()) {
ElMessage.error(TEXT.modules.feeContracts.paymentValidationFailed);
return;
}
saving.value = true;
try {
const payload = {
study_id: studyId,
center_id: form.center_id,
contract_no: form.contract_no || null,
signed_date: form.signed_date || null,
contract_amount: Number(form.contract_amount || 0) * 10000,
remark: form.remark || null,
contract_cases: Number(form.contract_cases || 0),
actual_cases: form.actual_cases === null ? null : Number(form.actual_cases),
};
const updatePayload = {
contract_amount: payload.contract_amount,
contract_no: payload.contract_no,
signed_date: payload.signed_date,
remark: payload.remark,
contract_cases: payload.contract_cases,
actual_cases: payload.actual_cases,
};
let savedId = props.contractId || form.id;
if (props.contractId && savedId) {
await updateContractFee(savedId, updatePayload);
} else {
const { data } = await createContractFee(payload);
savedId = data?.data?.id;
form.id = savedId || "";
}
if (savedId) {
for (const paymentId of removedPaymentIds.value) {
await deleteContractPayment(paymentId);
}
removedPaymentIds.value = [];
for (const payment of payments.value) {
const paymentPayload = {
amount: Number(payment.amount || 0) * 10000,
paid_date: payment.paid_date || null,
verified_date: payment.verified_date || null,
is_paid: !!payment.is_paid,
is_verified: !!payment.is_verified,
remark: payment.remark || null,
};
if (payment.id) {
await updateContractPayment(payment.id, paymentPayload);
} else {
const { data } = await createContractPayment(savedId, paymentPayload);
payment.id = data?.data?.id;
payment.tempId = payment.id || payment.tempId;
}
}
await attachmentPanelRef.value?.uploadPending(savedId);
}
drawerDirtyGuard.syncBaseline();
emit("update:modelValue", false);
emit("saved", savedId || "");
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => props.modelValue,
async (visible) => {
if (!visible) return;
resetForm();
if (props.contractId) {
await loadEditingDetail(props.contractId);
} else {
drawerDirtyGuard.syncBaseline();
}
},
);
</script>
<style scoped>
:deep(.contract-fee-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.contract-fee-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px;
}
.editor-header {
display: flex;
flex-direction: column;
}
.editor-title {
font-size: 20px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
}
.contract-fee-form {
padding: 0 4px 0 0;
}
.form-group {
border: 1px solid #e8eef6;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
}
.form-group:hover {
border-color: #d0dced;
}
.form-group + .form-group {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex;
align-items: center;
gap: 8px;
}
.action-title {
justify-content: space-between;
}
.action-title > span {
display: flex;
align-items: center;
gap: 8px;
}
.group-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.group-dot-basic {
background: #3b82f6;
}
.group-dot-payment {
background: #f0ad2c;
}
.group-dot-attachment {
background: #22c55e;
}
.inactive-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #fff7d6;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-bottom: 8px;
}
.hint-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #f59e0b;
color: #ffffff;
font-size: 12px;
line-height: 1;
}
.full-width {
width: 100%;
}
.section-empty {
padding: 28px 0;
display: flex;
justify-content: center;
color: #8a97ab;
font-size: 14px;
line-height: 1.6;
}
.payment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.payment-item {
border: 1px solid #e8eef6;
border-radius: 8px;
padding: 18px 20px 16px;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow: 0 8px 20px rgba(38, 73, 119, 0.05);
}
.payment-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.payment-seq {
display: flex;
align-items: center;
gap: 8px;
}
.seq-circle {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: #3f5f7a;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
}
.seq-text {
font-size: 18px;
font-weight: 700;
color: var(--ctms-text-main);
}
.payment-grid {
display: grid;
grid-template-columns: minmax(180px, 0.9fr) repeat(2, minmax(220px, 1fr));
gap: 14px 18px;
align-items: start;
}
.payment-field {
min-width: 0;
}
.payment-field--remark {
grid-column: 1 / -1;
}
.status-control {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.status-picker {
width: 100%;
min-width: 0;
}
.status-picker :deep(.el-date-editor.el-input),
.status-picker :deep(.el-input__wrapper) {
width: 100%;
min-width: 0;
}
.payment-error {
color: var(--ctms-danger);
font-size: 12px;
margin-top: 8px;
}
.mb-0 {
margin-bottom: 0 !important;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.contract-fee-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.contract-fee-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
padding-bottom: 4px;
}
@media (max-width: 760px) {
.payment-grid {
grid-template-columns: 1fr;
}
.payment-field--remark {
grid-column: auto;
}
}
</style>
@@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./ContractFeeForm.vue"), "utf8");
describe("ContractFeeForm.vue", () => {
it("keeps contract fields in a four-column contract info section", () => {
const source = readSource();
expect(source).toContain("contractInfoTitle");
expect(source).not.toContain("feeSettlementTitle");
expect(source).not.toContain("settlement_amount");
expect(source).not.toContain("final_payment_amount");
expect(source).toContain(':lg="6"');
expect(source).toContain('prop="contract_no"');
expect(source).toContain('prop="signed_date"');
expect(source).toContain('prop="currency"');
expect(source).toContain('prop="remark"');
expect(source).toContain("form.contract_no");
expect(source).toContain("form.signed_date");
expect(source).toContain("form.currency");
expect(source).toContain("form.remark");
});
it("allows system admins to edit without project permission matrix entries", () => {
const source = readSource();
expect(source).toContain("useAuthStore");
expect(source).toContain("isSystemAdmin");
expect(source).toContain("if (isAdmin.value) return true;");
});
});
-693
View File
@@ -1,693 +0,0 @@
<template>
<div class="page">
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="loadDetail">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px" label-position="top">
<el-card class="form-card unified-shell" shadow="never">
<template #header>
<div class="card-header actions-header">
<span class="header-title">{{ TEXT.modules.feeContracts.contractInfoTitle }}</span>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</template>
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item :label="TEXT.common.fields.site" prop="center_id" required>
<el-select
v-model="form.center_id"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no">
<el-input v-model="form.contract_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item :label="TEXT.common.fields.signedDate" prop="signed_date">
<el-date-picker
v-model="form.signed_date"
:disabled="isReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item :label="TEXT.modules.feeContracts.contractAmount" prop="contract_amount" required>
<el-input-number
v-model="form.contract_amount"
:disabled="isReadOnly"
:min="0"
:precision="2"
:step="1000"
class="full-width"
controls-position="right"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases" required>
<el-input-number v-model="form.contract_cases" :disabled="isReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
<el-input-number v-model="form.actual_cases" :disabled="isReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item :label="TEXT.common.fields.currency" prop="currency">
<el-select v-model="form.currency" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option label="CNY" value="CNY" />
<el-option label="USD" value="USD" />
<el-option label="EUR" value="EUR" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-card class="form-card section-margin attachment-card unified-shell" shadow="never">
<template #header>
<div class="card-header actions-header">
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
<el-button type="primary" :disabled="isReadOnly" @click="addPayment">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}
</el-button>
</div>
</template>
<div v-if="payments.length === 0" class="section-empty">
{{ TEXT.modules.feeContracts.paymentEmpty }}
</div>
<div v-else class="payment-list">
<transition-group name="list">
<div v-for="(payment, index) in payments" :key="payment.tempId" class="payment-item">
<div class="payment-item-header">
<div class="payment-seq">
<span class="seq-circle">{{ index + 1 }}</span>
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</div>
<el-button link type="danger" :disabled="isReadOnly" @click="removePayment(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item :label="TEXT.common.fields.amount" :error="paymentErrors[index]?.amount">
<el-input-number v-model="payment.amount" :disabled="isReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
<div class="status-control">
<el-checkbox v-model="payment.is_paid" :disabled="isReadOnly" @change="() => onPaidToggle(payment)">
{{ TEXT.modules.feeContracts.isPaid }}
</el-checkbox>
<el-date-picker
v-if="payment.is_paid"
v-model="payment.paid_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
class="status-picker"
size="small"
/>
</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
<div class="status-control">
<el-checkbox v-model="payment.is_verified" :disabled="isReadOnly" @change="() => onVerifiedToggle(payment)">
{{ TEXT.modules.feeContracts.isVerified }}
</el-checkbox>
<el-date-picker
v-if="payment.is_verified"
v-model="payment.verified_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
class="status-picker"
size="small"
/>
</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
<el-input v-model="payment.remark" :disabled="isReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<div v-if="paymentErrors[index]?.verification" class="payment-error">
{{ paymentErrors[index].verification }}
</div>
</div>
</transition-group>
</div>
</el-card>
<el-card class="form-card section-margin unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
</div>
</template>
<FeeAttachmentPanel
v-if="form.id"
:entity-type="'contract_fee'"
:entity-id="form.id"
:groups="attachmentGroups"
:readonly="isReadOnly"
/>
<StateEmpty v-else :description="TEXT.modules.feeContracts.uploadHint" class="compact-empty" />
</el-card>
<div class="footer-actions">
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit" class="save-btn">
{{ TEXT.common.actions.save }}
</el-button>
</div>
</el-form>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Plus, Delete } from "@element-plus/icons-vue";
import {
createContractFee,
createContractPayment,
deleteContractPayment,
getContractFee,
updateContractFee,
updateContractPayment,
} from "../../api/feeContracts";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import StateEmpty from "../../components/StateEmpty.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
import { TEXT } from "../../locales";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const formRef = ref();
const form = reactive({
id: "",
project_id: "",
center_id: "",
contract_no: "",
signed_date: "",
contract_amount: 0,
currency: "CNY",
remark: "",
contract_cases: 0,
actual_cases: null as number | null,
});
const payments = ref<any[]>([]);
const removedPaymentIds = ref<string[]>([]);
const paymentErrors = ref<Record<string, string>[]>([]);
const contractId = computed(() => route.params.contractId as string | undefined);
const isEdit = computed(() => !!contractId.value);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const isAdmin = computed(() => isSystemAdmin(auth.user));
const canMutate = computed(() => {
if (isAdmin.value) return true;
const operationKey = isEdit.value ? "fees_contracts:update" : "fees_contracts:create";
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
});
const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false));
const attachmentGroups = [
{
key: "contract",
label: TEXT.modules.feeContracts.attachmentContract,
description: TEXT.modules.feeContracts.attachmentContractDesc,
emptyText: TEXT.modules.feeContracts.attachmentContractEmpty,
},
{
key: "voucher",
label: TEXT.modules.feeContracts.attachmentVoucher,
description: TEXT.modules.feeContracts.attachmentVoucherDesc,
emptyText: TEXT.modules.feeContracts.attachmentVoucherEmpty,
},
{
key: "invoice",
label: TEXT.modules.feeContracts.attachmentInvoice,
description: TEXT.modules.feeContracts.attachmentInvoiceDesc,
emptyText: TEXT.modules.feeContracts.attachmentInvoiceEmpty,
},
];
const rules = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
contract_amount: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
contract_cases: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
};
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadDetail = async () => {
if (!contractId.value) return;
loading.value = true;
errorMessage.value = "";
removedPaymentIds.value = [];
paymentErrors.value = [];
try {
const { data } = await getContractFee(contractId.value);
const detail = data?.data || {};
Object.assign(form, {
id: detail.id || contractId.value,
project_id: detail.project_id || study.currentStudy?.id || "",
center_id: detail.center_id || "",
contract_no: detail.contract_no || "",
signed_date: detail.signed_date || "",
contract_amount: Number(detail.contract_amount || 0) / 10000,
currency: detail.currency || "CNY",
remark: detail.remark || "",
contract_cases: Number(detail.contract_cases || 0),
actual_cases: detail.actual_cases ?? null,
});
payments.value = (detail.payments || []).map((payment: any) => ({
id: payment.id,
tempId: payment.id,
amount: Number(payment.amount || 0),
paid_date: payment.paid_date || "",
verified_date: payment.verified_date || "",
is_paid: !!payment.is_paid,
is_verified: !!payment.is_verified,
remark: payment.remark || "",
}));
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const addPayment = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
payments.value.push({
tempId: `${Date.now()}-${Math.random()}`,
amount: 0,
paid_date: "",
verified_date: "",
is_paid: false,
is_verified: false,
remark: "",
});
};
const removePayment = (index: number) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
const payment = payments.value[index];
if (payment?.id) {
removedPaymentIds.value.push(payment.id);
}
payments.value.splice(index, 1);
};
const onPaidToggle = (payment: any) => {
if (isReadOnly.value) return;
if (!payment.is_paid) {
payment.paid_date = "";
payment.is_verified = false;
payment.verified_date = "";
}
};
const onVerifiedToggle = (payment: any) => {
if (isReadOnly.value) return;
if (payment.is_verified && !payment.is_paid) {
payment.is_paid = true;
}
if (!payment.is_verified) {
payment.verified_date = "";
}
};
const validatePayments = () => {
const errors: Record<string, string>[] = [];
let ok = true;
payments.value.forEach((payment, index) => {
const entry: Record<string, string> = {};
if (payment.amount === null || payment.amount === undefined || Number(payment.amount) < 0) {
entry.amount = TEXT.modules.feeContracts.amountInvalid;
ok = false;
}
if (payment.is_paid && !payment.paid_date) {
entry.paid_date = TEXT.modules.feeContracts.paidDateRequired;
ok = false;
}
if (payment.is_verified && !payment.verified_date) {
entry.verified_date = TEXT.modules.feeContracts.verifiedDateRequired;
ok = false;
}
if (payment.is_verified && !payment.is_paid) {
entry.verification = TEXT.modules.feeContracts.verifyRequiresPaid;
ok = false;
}
errors[index] = entry;
});
paymentErrors.value = errors;
return ok;
};
const submit = async () => {
if (!study.currentStudy?.id) return;
if (!canMutate.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
ElMessage.warning("中心已停用");
return;
}
const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return;
if (!validatePayments()) {
ElMessage.error(TEXT.modules.feeContracts.paymentValidationFailed);
return;
}
saving.value = true;
try {
const payload = {
project_id: study.currentStudy.id,
center_id: form.center_id,
contract_no: form.contract_no || null,
signed_date: form.signed_date || null,
contract_amount: Number(form.contract_amount || 0) * 10000,
currency: form.currency || "CNY",
remark: form.remark || null,
contract_cases: Number(form.contract_cases || 0),
actual_cases: form.actual_cases === null ? null : Number(form.actual_cases),
};
const updatePayload = {
contract_amount: payload.contract_amount,
contract_no: payload.contract_no,
signed_date: payload.signed_date,
currency: payload.currency,
remark: payload.remark,
contract_cases: payload.contract_cases,
actual_cases: payload.actual_cases,
};
let savedId = contractId.value || form.id;
if (isEdit.value && savedId) {
await updateContractFee(savedId, updatePayload);
} else {
const { data } = await createContractFee(payload);
savedId = data?.data?.id;
form.id = savedId || "";
}
if (savedId) {
for (const paymentId of removedPaymentIds.value) {
await deleteContractPayment(paymentId);
}
removedPaymentIds.value = [];
for (const payment of payments.value) {
const paymentPayload = {
amount: Number(payment.amount || 0),
paid_date: payment.paid_date || null,
verified_date: payment.verified_date || null,
is_paid: !!payment.is_paid,
is_verified: !!payment.is_verified,
remark: payment.remark || null,
};
if (payment.id) {
await updateContractPayment(payment.id, paymentPayload);
} else {
const { data } = await createContractPayment(savedId, paymentPayload);
payment.id = data?.data?.id;
}
}
}
ElMessage.success(TEXT.common.messages.saveSuccess);
if (savedId) {
router.push(`/fees/contracts/${savedId}`);
} else {
goBack();
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/fees/contracts");
onMounted(async () => {
await loadSites();
if (isEdit.value) {
await loadDetail();
} else {
form.project_id = study.currentStudy?.id || "";
}
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
}
.form-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.section-margin {
margin-top: 24px;
}
.attachment-card :deep(.el-card__body) {
padding: 0;
}
.card-header {
padding: 4px 0;
}
.actions-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
}
.full-width {
width: 100%;
}
.section-empty {
padding: 28px 0;
display: flex;
justify-content: center;
color: #8a97ab;
font-size: 14px;
line-height: 1.6;
}
.payment-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.payment-item {
border: 1px solid var(--ctms-border-color);
border-radius: 8px;
padding: 16px;
background-color: var(--ctms-bg-color-page);
transition: all 0.3s;
}
.payment-item:hover {
border-color: var(--ctms-border-color-hover);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.payment-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.payment-seq {
display: flex;
align-items: center;
gap: 8px;
}
.seq-circle {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: var(--el-color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.seq-text {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
}
.status-control {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-picker {
width: 100%;
}
.payment-error {
color: var(--ctms-danger);
font-size: 12px;
margin-top: 8px;
}
.mb-0 {
margin-bottom: 0 !important;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-bottom: 32px;
}
.save-btn {
padding: 0 32px;
}
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.compact-empty :deep(.state) {
padding: 24px 0 !important;
flex-direction: row;
gap: 12px;
}
.compact-empty :deep(.state-icon) {
font-size: 24px !important;
margin-bottom: 0 !important;
}
.compact-empty :deep(.state-title) {
font-size: 14px !important;
font-weight: normal !important;
margin: 0 !important;
}
.compact-empty :deep(.state-desc) {
margin: 0 !important;
font-size: 14px !important;
}
</style>
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { TEXT } from "../../locales";
describe("contract fee attachment labels", () => {
it("uses a concise attachment section title", () => {
expect(TEXT.modules.feeContracts.attachmentTitle).toBe("附件");
});
it("uses a clear payment sequence column label", () => {
expect(TEXT.modules.feeContracts.paymentSeqColumn).toBe("付款期次");
expect(TEXT.modules.feeContracts.paymentSeq).toBe("第");
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
describe("Contract fee project permissions", () => {
it("hides list create/delete controls when the matching backend operation is not allowed", () => {
const source = read("./ContractFees.vue");
expect(source).toContain('v-if="canCreate"');
expect(source).toContain('v-if="canDelete"');
expect(source).toContain("if (!canCreate.value)");
expect(source).toContain("if (!canDelete.value)");
expect(source).not.toContain(':disabled="!canCreate"');
expect(source).not.toContain(':disabled="!canDelete || isInactiveSite(scope.row.center_id)"');
});
it("hides detail edit controls and blocks direct navigation without update permission", () => {
const source = read("./ContractFeeDetail.vue");
expect(source).toContain('v-if="canWrite"');
expect(source).toContain("if (!canWrite.value)");
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./ContractFees.vue"), "utf8");
describe("ContractFees.vue", () => {
it("keeps the contract fee table full-width with evenly distributed columns", () => {
const source = readSource();
expect(source).toContain('style="width: 100%"');
expect(source).toContain('table-layout="fixed"');
expect(source).toContain('prop="center_name" :label="TEXT.common.fields.site" show-overflow-tooltip');
expect(source).toContain(':label="TEXT.modules.feeContracts.contractAmount" align="left"');
expect(source).not.toContain(':label="TEXT.modules.feeContracts.contractAmount" align="right"');
expect(source).toContain(':label="TEXT.modules.feeContracts.caseProgress" align="center"');
expect(source).toContain(':label="TEXT.modules.feeContracts.paidSummary"');
expect(source).toContain(':label="TEXT.modules.feeContracts.verifySummary"');
expect(source).toContain(':label="TEXT.modules.feeContracts.recentDates"');
expect(source).toContain(':label="TEXT.common.labels.actions" align="center"');
expect(source).not.toContain('width="170"');
expect(source).not.toContain('width="150"');
expect(source).not.toContain('width="190"');
expect(source).not.toContain('width="110"');
});
it("uses the material drawer pattern for creating and editing contract fees", () => {
const source = readSource();
expect(source).toContain("<ContractFeeEditorDrawer");
expect(source).toContain('import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue"');
expect(source).toContain('@click="openCreate"');
expect(source).toContain('@click.stop="openEdit(scope.row)"');
expect(source).not.toContain("useDrawerDirtyGuard");
expect(source).not.toContain('router.push("/fees/contracts/new")');
expect(source).not.toContain("`/fees/contracts/${contractId.value}`");
});
});
+79 -14
View File
@@ -80,7 +80,7 @@
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div> </div>
<div class="filter-spacer"></div> <div class="filter-spacer"></div>
<el-button type="primary" :disabled="!canCreate" @click="goNew" class="header-action-btn"> <el-button v-if="canCreate" type="primary" @click="openCreate" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon> <el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeContracts.newTitle }} {{ TEXT.modules.feeContracts.newTitle }}
</el-button> </el-button>
@@ -95,26 +95,26 @@
class="contract-table" class="contract-table"
table-layout="fixed" table-layout="fixed"
> >
<el-table-column prop="center_name" :label="TEXT.common.fields.site" width="170" show-overflow-tooltip> <el-table-column prop="center_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<div class="site-cell"> <div class="site-cell">
<span class="site-name">{{ scope.row.center_name || TEXT.common.fallback }}</span> <span class="site-name">{{ scope.row.center_name || TEXT.common.fallback }}</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.contractAmount" width="150" align="right"> <el-table-column :label="TEXT.modules.feeContracts.contractAmount" align="left">
<template #default="scope"> <template #default="scope">
<span class="amount-text">{{ formatAmountWan(scope.row.contract_amount) }}</span> <span class="amount-text">{{ formatAmountWan(scope.row.contract_amount) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.caseProgress" width="170" align="center"> <el-table-column :label="TEXT.modules.feeContracts.caseProgress" align="center">
<template #default="scope"> <template #default="scope">
<el-tag effect="plain" type="info"> <el-tag effect="plain" type="info">
{{ displayCases(scope.row.contract_cases, scope.row.actual_cases) }} {{ displayCases(scope.row.contract_cases, scope.row.actual_cases) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" width="190"> <el-table-column :label="TEXT.modules.feeContracts.paidSummary">
<template #default="scope"> <template #default="scope">
<div class="summary-line"> <div class="summary-line">
<span>{{ TEXT.modules.feeContracts.paidTotal }}</span> <span>{{ TEXT.modules.feeContracts.paidTotal }}</span>
@@ -127,7 +127,7 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" width="190"> <el-table-column :label="TEXT.modules.feeContracts.verifySummary">
<template #default="scope"> <template #default="scope">
<div class="summary-line"> <div class="summary-line">
<span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span> <span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span>
@@ -140,7 +140,7 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.recentDates" width="170"> <el-table-column :label="TEXT.modules.feeContracts.recentDates">
<template #default="scope"> <template #default="scope">
<div class="date-line"> <div class="date-line">
<span class="date-label">{{ TEXT.modules.feeContracts.lastPaid }}</span> <span class="date-label">{{ TEXT.modules.feeContracts.lastPaid }}</span>
@@ -152,13 +152,24 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="110"> <el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" align="center">
<template #default="scope"> <template #default="scope">
<el-button <el-button
v-if="canUpdate"
link
type="primary"
size="small"
:disabled="isInactiveSite(scope.row.center_id)"
@click.stop="openEdit(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDelete"
link link
type="danger" type="danger"
size="small" size="small"
:disabled="!canDelete || isInactiveSite(scope.row.center_id)" :disabled="isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)" @click.stop="remove(scope.row)"
> >
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
@@ -171,6 +182,14 @@
</el-table> </el-table>
</section> </section>
</div> </div>
<ContractFeeEditorDrawer
v-model="drawerVisible"
:contract-id="editingId || undefined"
:sites="sites"
:initial-center-id="study.currentSite?.id || ''"
@saved="handleEditorSaved"
/>
</div> </div>
</template> </template>
@@ -187,18 +206,22 @@ import { displayDate } from "../../utils/display";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import StateError from "../../components/StateError.vue"; import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue"; import KpiCard from "../../components/KpiCard.vue";
import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission(); const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create")); const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update"));
const canDelete = computed(() => can("fees.contract.delete")); const canDelete = computed(() => can("fees.contract.delete"));
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const contracts = ref<any[]>([]); const contracts = ref<any[]>([]);
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const drawerVisible = ref(false);
const editingId = ref("");
const siteActiveMap = computed(() => { const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
sites.value.forEach((site) => { sites.value.forEach((site) => {
@@ -269,12 +292,12 @@ const loadSites = async () => {
}; };
const load = async () => { const load = async () => {
const projectId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!projectId) return; if (!studyId) return;
loading.value = true; loading.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { try {
const { data } = await listContractFees({ projectId }); const { data } = await listContractFees({ study_id: studyId });
contracts.value = data?.data || []; contracts.value = data?.data || [];
} catch (e: any) { } catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed; errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
@@ -288,7 +311,29 @@ const resetFilters = () => {
filters.q = ""; filters.q = "";
}; };
const goNew = () => router.push("/fees/contracts/new"); const openCreate = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = "";
drawerVisible.value = true;
};
const openEdit = async (row: any) => {
if (!row?.id) return;
if (!canUpdate.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用");
return;
}
editingId.value = row.id;
drawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/fees/contracts/${id}`); const goDetail = (id: string) => router.push(`/fees/contracts/${id}`);
const onRowClick = (row: any) => { const onRowClick = (row: any) => {
if (!row?.id) return; if (!row?.id) return;
@@ -296,7 +341,11 @@ const onRowClick = (row: any) => {
}; };
const remove = async (row: any) => { const remove = async (row: any) => {
if (!row?.id || !canDelete.value) return; if (!row?.id) return;
if (!canDelete.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(row?.center_id)) { if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
@@ -312,6 +361,10 @@ const remove = async (row: any) => {
} }
}; };
const handleEditorSaved = async () => {
await load();
};
const displayCases = (contractCases: any, actualCases: any) => { const displayCases = (contractCases: any, actualCases: any) => {
const contractValue = Number(contractCases ?? 0); const contractValue = Number(contractCases ?? 0);
const actualValue = Number(actualCases ?? 0); const actualValue = Number(actualCases ?? 0);
@@ -340,6 +393,17 @@ watch(() => study.currentSite, (newSite: any) => {
filters.centerId = newSite?.id || ""; filters.centerId = newSite?.id || "";
}); });
watch(
() => study.currentStudy?.id,
async () => {
filters.centerId = study.currentSite?.id || "";
filters.q = "";
drawerVisible.value = false;
await loadSites();
await load();
}
);
onMounted(async () => { onMounted(async () => {
await loadSites(); await loadSites();
load(); load();
@@ -457,6 +521,7 @@ onMounted(async () => {
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
</style> </style>
<style> <style>