抽屉化物资与启动授权流程

1、将药物发货新建和编辑从独立页面迁移到抽屉,详情页支持权限控制和停用中心只读状态。

2、补充药物发货状态字段校验,统一 PENDING、IN_TRANSIT、SIGNED、EXCEPTION 状态及演示数据。

3、为物资设备增加详情页和编辑抽屉,复用通用附件上传并按项目权限控制操作入口。

4、将启动会和培训授权编辑迁移到抽屉,详情页按中心过滤培训记录并移除旧独立编辑路由。
This commit is contained in:
Cheng Zhou
2026-06-04 11:08:43 +08:00
parent 720c98765f
commit 6a2e43f829
31 changed files with 3773 additions and 1009 deletions
+20 -1
View File
@@ -8,7 +8,12 @@ from app.crud import audit as audit_crud
from app.crud import drug_shipment as shipment_crud from app.crud import drug_shipment as shipment_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate from app.schemas.drug_shipment import (
DrugShipmentCreate,
DrugShipmentRead,
DrugShipmentUpdate,
validate_drug_shipment_required_fields,
)
router = APIRouter() router = APIRouter()
@@ -143,6 +148,20 @@ async def update_shipment(
shipment_in = shipment_in.model_copy(update={"site_name": site.name}) shipment_in = shipment_in.model_copy(update={"site_name": site.name})
else: else:
await _ensure_center_active(db, study_id, shipment.center_id) await _ensure_center_active(db, study_id, shipment.center_id)
update_data = shipment_in.model_dump(exclude_unset=True)
try:
validate_drug_shipment_required_fields(
update_data.get("status", shipment.status),
update_data.get("ship_date", shipment.ship_date),
update_data.get("receive_date", shipment.receive_date),
update_data.get("quantity", shipment.quantity),
update_data.get("batch_no", shipment.batch_no),
update_data.get("carrier", shipment.carrier),
update_data.get("tracking_no", shipment.tracking_no),
update_data.get("remark", shipment.remark),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in) shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+2 -1
View File
@@ -468,13 +468,14 @@ async def list_training_authorizations(
study_id: uuid.UUID, study_id: uuid.UUID,
skip: int = 0, skip: int = 0,
limit: int = 200, limit: int = 200,
site_name: 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),
) -> list[TrainingAuthorizationRead]: ) -> list[TrainingAuthorizationRead]:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user) cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None site_names = cra_scope[1] if cra_scope else None
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names) items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names, site_name=site_name)
return [TrainingAuthorizationRead.model_validate(item) for item in items] return [TrainingAuthorizationRead.model_validate(item) for item in items]
+3
View File
@@ -243,6 +243,7 @@ async def list_training_authorizations(
skip: int = 0, skip: int = 0,
limit: int = 200, limit: int = 200,
site_names: set[str] | None = None, site_names: set[str] | None = None,
site_name: str | None = None,
) -> Sequence[TrainingAuthorization]: ) -> Sequence[TrainingAuthorization]:
if site_names is not None and not site_names: if site_names is not None and not site_names:
return [] return []
@@ -255,6 +256,8 @@ async def list_training_authorizations(
) )
if site_names is not None: if site_names is not None:
stmt = stmt.where(TrainingAuthorization.site_name.in_(site_names)) stmt = stmt.where(TrainingAuthorization.site_name.in_(site_names))
if site_name:
stmt = stmt.where(TrainingAuthorization.site_name == site_name)
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalars().all() return result.scalars().all()
+74 -8
View File
@@ -1,22 +1,88 @@
from __future__ import annotations
import uuid import uuid
from datetime import date, datetime from datetime import date, datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, model_validator
SHIPMENT_STATUSES = {"PENDING", "IN_TRANSIT", "SIGNED", "EXCEPTION"}
COMPLETED_SHIPMENT_STATUS = "SIGNED"
EXCEPTION_SHIPMENT_STATUS = "EXCEPTION"
PENDING_SHIPMENT_STATUS = "PENDING"
def shipment_requires_execution_details(status: str | None) -> bool:
return status != PENDING_SHIPMENT_STATUS
def shipment_requires_receive_date(status: str | None) -> bool:
return status == COMPLETED_SHIPMENT_STATUS
def shipment_requires_remark(status: str | None) -> bool:
return status == EXCEPTION_SHIPMENT_STATUS
def validate_drug_shipment_required_fields(
status: str | None,
ship_date: date | None,
receive_date: date | None,
quantity: int | None,
batch_no: str | None,
carrier: str | None,
tracking_no: str | None,
remark: str | None,
) -> None:
if status not in SHIPMENT_STATUSES:
raise ValueError("无效状态")
if shipment_requires_execution_details(status):
missing_execution_fields = [
label
for label, value in (
("寄出/回收日期", ship_date),
("数量", quantity),
("批号", batch_no),
("快递公司", carrier),
("单号", tracking_no),
)
if value is None or (isinstance(value, str) and not value.strip())
]
if missing_execution_fields:
raise ValueError(f"发运信息缺失:{', '.join(missing_execution_fields)}")
if shipment_requires_receive_date(status) and receive_date is None:
raise ValueError("已签收时必须填写接收日期")
if shipment_requires_remark(status) and not (remark or "").strip():
raise ValueError("异常状态必须填写备注")
class DrugShipmentCreate(BaseModel): class DrugShipmentCreate(BaseModel):
direction: str direction: str
center_id: uuid.UUID center_id: uuid.UUID
site_name: Optional[str] = None site_name: Optional[str] = None
ship_date: date ship_date: Optional[date] = None
receive_date: date receive_date: Optional[date] = None
quantity: int quantity: Optional[int] = None
batch_no: str batch_no: Optional[str] = None
carrier: str carrier: Optional[str] = None
tracking_no: str tracking_no: Optional[str] = None
status: str status: str
remark: str remark: Optional[str] = None
@model_validator(mode="after")
def validate_status_required_fields(self):
validate_drug_shipment_required_fields(
self.status,
self.ship_date,
self.receive_date,
self.quantity,
self.batch_no,
self.carrier,
self.tracking_no,
self.remark,
)
return self
class DrugShipmentUpdate(BaseModel): class DrugShipmentUpdate(BaseModel):
@@ -0,0 +1,81 @@
import uuid
from datetime import date
import pytest
from pydantic import ValidationError
from app.schemas.drug_shipment import DrugShipmentCreate
def shipment_payload(**overrides):
payload = {
"direction": "SEND",
"center_id": uuid.uuid4(),
"ship_date": date(2026, 6, 4),
"receive_date": None,
"quantity": 1,
"batch_no": "DP-001",
"carrier": "顺丰",
"tracking_no": "SF100003",
"status": "IN_TRANSIT",
"remark": None,
}
payload.update(overrides)
return payload
def test_create_allows_pending_shipment_execution_fields_to_be_empty():
shipment = DrugShipmentCreate.model_validate(
shipment_payload(
status="PENDING",
ship_date=None,
quantity=None,
batch_no=None,
carrier=None,
tracking_no=None,
receive_date=None,
remark=None,
)
)
assert shipment.ship_date is None
assert shipment.quantity is None
assert shipment.batch_no is None
assert shipment.carrier is None
assert shipment.tracking_no is None
@pytest.mark.parametrize("field_name", ["ship_date", "quantity", "batch_no", "carrier", "tracking_no"])
def test_create_requires_shipment_execution_fields_after_pending(field_name):
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="IN_TRANSIT", **{field_name: None}))
assert "发运信息" in str(exc_info.value)
def test_create_allows_pending_receipt_fields_to_be_empty():
shipment = DrugShipmentCreate.model_validate(shipment_payload())
assert shipment.receive_date is None
assert shipment.remark is None
def test_create_requires_receive_date_when_signed():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="SIGNED", receive_date=None))
assert "接收日期" in str(exc_info.value)
def test_create_rejects_removed_returned_status():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="RETURNED", receive_date=date(2026, 6, 5)))
assert "无效状态" in str(exc_info.value)
def test_create_requires_remark_when_exceptional():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="EXCEPTION", remark=""))
assert "备注" in str(exc_info.value)
+8 -44
View File
@@ -184,27 +184,15 @@ CREATE TABLE IF NOT EXISTS public.adverse_events (
CONSTRAINT fk_adverse_events_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT CONSTRAINT fk_adverse_events_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
); );
CREATE TABLE IF NOT EXISTS public.finance_contracts (
id uuid PRIMARY KEY,
study_id uuid NOT NULL,
site_name character varying(255) NOT NULL,
contract_no character varying(100) NOT NULL,
signed_date date,
amount numeric(12, 2) NOT NULL,
currency character varying(10) NOT NULL DEFAULT 'CNY',
remark text,
created_by uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT fk_finance_contracts_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT,
CONSTRAINT fk_finance_contracts_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS public.contract_fees ( CREATE TABLE IF NOT EXISTS public.contract_fees (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
project_id uuid NOT NULL, project_id uuid NOT NULL,
center_id uuid NOT NULL, center_id uuid NOT NULL,
contract_no character varying(100),
signed_date date,
contract_amount numeric(12, 2) NOT NULL, contract_amount numeric(12, 2) NOT NULL,
currency character varying(10) NOT NULL DEFAULT 'CNY',
remark text,
contract_cases integer NOT NULL, contract_cases integer NOT NULL,
actual_cases integer, actual_cases integer,
settlement_amount numeric(12, 2), settlement_amount numeric(12, 2),
@@ -231,22 +219,6 @@ CREATE TABLE IF NOT EXISTS public.contract_fee_payments (
CONSTRAINT fk_contract_fee_payments_contract_fee_id FOREIGN KEY (contract_fee_id) REFERENCES public.contract_fees(id) ON DELETE CASCADE CONSTRAINT fk_contract_fee_payments_contract_fee_id FOREIGN KEY (contract_fee_id) REFERENCES public.contract_fees(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS public.fee_attachments (
id uuid PRIMARY KEY,
entity_type character varying(50) NOT NULL,
entity_id uuid NOT NULL,
file_type character varying(30) NOT NULL,
filename character varying(255) NOT NULL,
mime_type character varying(100),
size integer NOT NULL,
storage_key character varying(500),
url text,
uploaded_by uuid NOT NULL,
uploaded_at timestamp with time zone NOT NULL DEFAULT now(),
is_deleted boolean NOT NULL DEFAULT false,
CONSTRAINT fk_fee_attachments_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES public.users(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS public.finance_items ( CREATE TABLE IF NOT EXISTS public.finance_items (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
study_id uuid NOT NULL, study_id uuid NOT NULL,
@@ -498,7 +470,6 @@ CREATE INDEX IF NOT EXISTS ix_visits_subject_id ON public.visits (subject_id);
CREATE INDEX IF NOT EXISTS ix_adverse_events_study_id ON public.adverse_events (study_id); CREATE INDEX IF NOT EXISTS ix_adverse_events_study_id ON public.adverse_events (study_id);
CREATE INDEX IF NOT EXISTS ix_adverse_events_site_id ON public.adverse_events (site_id); CREATE INDEX IF NOT EXISTS ix_adverse_events_site_id ON public.adverse_events (site_id);
CREATE INDEX IF NOT EXISTS ix_adverse_events_subject_id ON public.adverse_events (subject_id); CREATE INDEX IF NOT EXISTS ix_adverse_events_subject_id ON public.adverse_events (subject_id);
CREATE INDEX IF NOT EXISTS ix_finance_contracts_study_id ON public.finance_contracts (study_id);
CREATE INDEX IF NOT EXISTS ix_finance_items_study_id ON public.finance_items (study_id); CREATE INDEX IF NOT EXISTS ix_finance_items_study_id ON public.finance_items (study_id);
CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (site_id); CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (site_id);
CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id); CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id);
@@ -590,13 +561,6 @@ INSERT INTO public.milestones (
('20202020-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'LPI', '首例入组', '2025-02-01', NULL, 'NOT_STARTED', 'cccccccc-cccc-cccc-cccc-cccccccccccc', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '待完成', '2025-01-06 12:10:00+00', '2025-01-06 12:10:00+00') ('20202020-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'LPI', '首例入组', '2025-02-01', NULL, 'NOT_STARTED', 'cccccccc-cccc-cccc-cccc-cccccccccccc', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '待完成', '2025-01-06 12:10:00+00', '2025-01-06 12:10:00+00')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO public.finance_contracts (
id, study_id, site_name, contract_no, signed_date, amount, currency, remark, created_by, created_at, updated_at
) VALUES
('77777777-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', 'CON-001', '2025-01-08', 1200000.00, 'CNY', '首批合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-08 09:00:00+00', '2025-01-08 09:00:00+00'),
('77777777-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', 'CON-002', '2025-01-10', 980000.00, 'CNY', '分中心合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-10 09:00:00+00', '2025-01-10 09:00:00+00')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.finance_items ( INSERT INTO public.finance_items (
id, study_id, site_id, subject_id, visit_id, category, title, description, currency, amount, occur_date, status, submitted_at, approved_at, rejected_at, paid_at, approver_id, payer_id, reject_reason, created_by, created_at, updated_at id, study_id, site_id, subject_id, visit_id, category, title, description, currency, amount, occur_date, status, submitted_at, approved_at, rejected_at, paid_at, approver_id, payer_id, reject_reason, created_by, created_at, updated_at
) VALUES ) VALUES
@@ -607,10 +571,10 @@ ON CONFLICT (id) DO NOTHING;
INSERT INTO public.drug_shipments ( INSERT INTO public.drug_shipments (
id, study_id, direction, center_id, site_name, ship_date, receive_date, quantity, batch_no, carrier, tracking_no, status, remark, created_by, created_at, updated_at id, study_id, direction, center_id, site_name, ship_date, receive_date, quantity, batch_no, carrier, tracking_no, status, remark, created_by, created_at, updated_at
) VALUES ) VALUES
('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-01-12', NULL, 200, 'DP-001', '顺丰', 'SF100001', '运输中', '首批寄送:DP-001 片剂 50mg × 200 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'), ('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-01-12', NULL, 200, 'DP-001', '顺丰', 'SF100001', 'IN_TRANSIT', '首批寄送:DP-001 片剂 50mg × 200 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'),
('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-01-13', '2025-01-14', 150, 'DP-001', '京东物流', 'JD200002', '已签收', '分中心补货:DP-001 片剂 50mg × 150 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'), ('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-01-13', '2025-01-14', 150, 'DP-001', '京东物流', 'JD200002', 'SIGNED', '分中心补货:DP-001 片剂 50mg × 150 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'),
('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-02-05', '2025-02-06', NULL, 'DP-001', '顺丰', 'SF100003', '已回收', '首批回收:空药盒及剩余药品', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'), ('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-02-05', '2025-02-06', 1, 'DP-001', '顺丰', 'SF100003', 'SIGNED', '首批回收:空药盒及剩余药品', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'),
('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-02-08', NULL, NULL, 'DP-001', '德邦', 'DB300004', '运输中', '回收途中:冷链箱回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00') ('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-02-08', NULL, 1, 'DP-001', '德邦', 'DB300004', 'IN_TRANSIT', '回收途中:冷链箱回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO public.startup_feasibility ( INSERT INTO public.startup_feasibility (
+21
View File
@@ -65,6 +65,27 @@ describe("admin project route permissions", () => {
expect(source).not.toContain("finance/contracts"); expect(source).not.toContain("finance/contracts");
}); });
it("does not register standalone contract fee create or edit pages", () => {
const source = readRouter();
expect(source).not.toContain("FeeContractForm");
expect(source).not.toContain('name: "FeeContractNew"');
expect(source).not.toContain('name: "FeeContractEdit"');
expect(source).not.toContain('path: "fees/contracts/new"');
expect(source).not.toContain('path: "fees/contracts/:contractId/edit"');
});
it("does not register standalone startup auth edit pages", () => {
const source = readRouter();
expect(source).not.toContain('name: "StartupKickoffEdit"');
expect(source).not.toContain('name: "StartupTrainingEdit"');
expect(source).not.toContain('name: "StartupTrainingNew"');
expect(source).not.toContain('path: "startup/kickoff/:meetingId/edit"');
expect(source).not.toContain('path: "startup/training/new"');
expect(source).not.toContain('path: "startup/training/:recordId/edit"');
});
it("uses precautions routes instead of knowledge note routes", () => { it("uses precautions routes instead of knowledge note routes", () => {
const source = readRouter(); const source = readRouter();
+13 -51
View File
@@ -23,10 +23,11 @@ import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue"; import ProjectOverview from "../views/ia/ProjectOverview.vue";
import ProjectMilestones from "../views/ia/ProjectMilestones.vue"; import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
import FeeContracts from "../views/fees/ContractFees.vue"; import FeeContracts from "../views/fees/ContractFees.vue";
import FeeContractForm from "../views/fees/ContractFeeForm.vue";
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue"; import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
import DrugShipments from "../views/ia/DrugShipments.vue"; import DrugShipments from "../views/ia/DrugShipments.vue";
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
import MaterialEquipment from "../views/ia/MaterialEquipment.vue"; import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
import FileVersionManagement from "../views/ia/FileVersionManagement.vue"; import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
import DocumentList from "../views/documents/DocumentList.vue"; import DocumentList from "../views/documents/DocumentList.vue";
import DocumentDetail from "../views/documents/DocumentDetail.vue"; import DocumentDetail from "../views/documents/DocumentDetail.vue";
@@ -41,15 +42,12 @@ import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
import Precautions from "../views/ia/Precautions.vue"; import Precautions from "../views/ia/Precautions.vue";
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue"; import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue"; import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
import ShipmentForm from "../views/drug/ShipmentForm.vue";
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
import FeasibilityForm from "../views/startup/FeasibilityForm.vue"; import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue"; import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
import EthicsForm from "../views/startup/EthicsForm.vue"; import EthicsForm from "../views/startup/EthicsForm.vue";
import EthicsDetail from "../views/startup/EthicsDetail.vue"; import EthicsDetail from "../views/startup/EthicsDetail.vue";
import KickoffForm from "../views/startup/KickoffForm.vue"; import KickoffForm from "../views/startup/KickoffForm.vue";
import KickoffDetail from "../views/startup/KickoffDetail.vue"; import KickoffDetail from "../views/startup/KickoffDetail.vue";
import TrainingForm from "../views/startup/TrainingForm.vue";
import TrainingDetail from "../views/startup/TrainingDetail.vue"; import TrainingDetail from "../views/startup/TrainingDetail.vue";
import PrecautionForm from "../views/knowledge/PrecautionForm.vue"; import PrecautionForm from "../views/knowledge/PrecautionForm.vue";
import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue"; import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
@@ -109,42 +107,18 @@ const routes: RouteRecordRaw[] = [
component: FeeContracts, component: FeeContracts,
meta: { title: TEXT.menu.feeContracts, requiresStudy: true }, meta: { title: TEXT.menu.feeContracts, requiresStudy: true },
}, },
{
path: "fees/contracts/new",
name: "FeeContractNew",
component: FeeContractForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.feeContracts, requiresStudy: true },
},
{ {
path: "fees/contracts/:contractId", path: "fees/contracts/:contractId",
name: "FeeContractDetail", name: "FeeContractDetail",
component: FeeContractDetail, component: FeeContractDetail,
meta: { title: TEXT.modules.feeContracts.detailTitle, requiresStudy: true }, meta: { title: TEXT.modules.feeContracts.detailTitle, requiresStudy: true },
}, },
{
path: "fees/contracts/:contractId/edit",
name: "FeeContractEdit",
component: FeeContractForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.feeContracts, requiresStudy: true },
},
{ {
path: "drug/shipments", path: "drug/shipments",
name: "DrugShipments", name: "DrugShipments",
component: DrugShipments, component: DrugShipments,
meta: { title: TEXT.menu.drugShipments, requiresStudy: true }, meta: { title: TEXT.menu.drugShipments, requiresStudy: true },
}, },
{
path: "materials/equipment",
name: "MaterialEquipment",
component: MaterialEquipment,
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
},
{
path: "drug/shipments/new",
name: "DrugShipmentNew",
component: ShipmentForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.drugShipments, requiresStudy: true },
},
{ {
path: "drug/shipments/:shipmentId", path: "drug/shipments/:shipmentId",
name: "DrugShipmentDetail", name: "DrugShipmentDetail",
@@ -152,10 +126,16 @@ const routes: RouteRecordRaw[] = [
meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true }, meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true },
}, },
{ {
path: "drug/shipments/:shipmentId/edit", path: "materials/equipment",
name: "DrugShipmentEdit", name: "MaterialEquipment",
component: ShipmentForm, component: MaterialEquipment,
meta: { title: TEXT.common.actions.edit + TEXT.menu.drugShipments, requiresStudy: true }, meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
},
{
path: "materials/equipment/:equipmentId",
name: "MaterialEquipmentDetail",
component: MaterialEquipmentDetail,
meta: { title: TEXT.modules.materialEquipment.detailTitle, requiresStudy: true },
}, },
{ {
path: "file-versions", path: "file-versions",
@@ -235,30 +215,12 @@ const routes: RouteRecordRaw[] = [
component: KickoffDetail, component: KickoffDetail,
meta: { title: TEXT.modules.startupMeetingAuth.kickoffDetailTitle, requiresStudy: true }, meta: { title: TEXT.modules.startupMeetingAuth.kickoffDetailTitle, requiresStudy: true },
}, },
{
path: "startup/kickoff/:meetingId/edit",
name: "StartupKickoffEdit",
component: KickoffForm,
meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.kickoffTab, requiresStudy: true },
},
{
path: "startup/training/new",
name: "StartupTrainingNew",
component: TrainingForm,
meta: { title: TEXT.common.actions.add + TEXT.common.fields.person, requiresStudy: true },
},
{ {
path: "startup/training/:recordId", path: "startup/training/:recordId",
name: "StartupTrainingDetail", name: "StartupTrainingDetail",
component: TrainingDetail, component: TrainingDetail,
meta: { title: TEXT.modules.startupMeetingAuth.trainingDetailTitle, requiresStudy: true }, meta: { title: TEXT.modules.startupMeetingAuth.trainingDetailTitle, requiresStudy: true },
}, },
{
path: "startup/training/:recordId/edit",
name: "StartupTrainingEdit",
component: TrainingForm,
meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.trainingTab, requiresStudy: true },
},
{ {
path: "subjects", path: "subjects",
name: "SubjectManagement", name: "SubjectManagement",
@@ -447,7 +409,7 @@ const router = createRouter({
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = { const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
audit_export: { read: "audit_logs:read", write: "audit_logs:export" }, audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
project_members: { read: "project_members:list", write: "project_members:update" }, project_members: { read: "project_members:read", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" }, sites: { read: "sites:read", write: "sites:update" },
}; };
+2 -2
View File
@@ -13,7 +13,7 @@ export const useStudyStore = defineStore("study", () => {
const currentStudy = ref<Study | null>(null); const currentStudy = ref<Study | null>(null);
const currentStudyRole = ref<string | null>(null); const currentStudyRole = ref<string | null>(null);
const currentSite = ref<any | null>(null); const currentSite = ref<any | null>(null);
const viewContext = ref<{ siteName?: string } | null>(null); const viewContext = ref<{ siteName?: string; pageTitle?: string } | null>(null);
const currentPermissions = ref<any | null>(null); const currentPermissions = ref<any | null>(null);
const normalizeUserKey = (value: string) => value.trim().toLowerCase(); const normalizeUserKey = (value: string) => value.trim().toLowerCase();
@@ -229,7 +229,7 @@ export const useStudyStore = defineStore("study", () => {
} }
}; };
const setViewContext = (ctx: { siteName?: string } | null) => { const setViewContext = (ctx: { siteName?: string; pageTitle?: string } | null) => {
viewContext.value = ctx; viewContext.value = ctx;
}; };
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipmentEditorDrawer.vue"), "utf8");
describe("DrugShipmentEditorDrawer attachments", () => {
it("uses a compact upload card instead of the full attachment table", () => {
const source = readSource();
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="drug_shipment"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)");
expect(source).toContain("uploadPending");
expect(source).not.toContain("uploadAttachment");
expect(source).not.toContain("pendingFiles");
expect(source).not.toContain("upload-card-label");
expect(source).not.toContain("<el-table");
});
});
describe("DrugShipmentEditorDrawer shipment validation", () => {
it("keeps shipment execution fields optional while status is pending", () => {
const source = readSource();
expect(source).toContain("requiresShipmentDetails");
expect(source).toContain('status !== "PENDING"');
expect(source).not.toContain('ship_date: [{ required: true');
expect(source).not.toContain('batch_no: [{ required: true');
expect(source).not.toContain('carrier: [{ required: true');
expect(source).not.toContain('tracking_no: [{ required: true');
});
it("does not expose the removed returned status", () => {
const source = readSource();
expect(source).not.toContain("RETURNED");
expect(source).not.toContain("已回收");
});
it("requires receive date when shipment is signed", () => {
const source = readSource();
expect(source).toContain("requiresReceiveDate");
expect(source).toContain('status === "SIGNED"');
expect(source).not.toContain('receive_date: [{ required: true');
});
it("keeps remark optional unless shipment is exceptional", () => {
const source = readSource();
expect(source).toContain("requiresRemark");
expect(source).toContain('status === "EXCEPTION"');
expect(source).not.toContain('remark: [{ required: true');
});
});
@@ -0,0 +1,462 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="shipment-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ TEXT.modules.drugShipments.editTitle }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="shipment-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.common.labels.basicInfo }}
</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" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<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.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<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">
<span class="group-dot group-dot-record"></span>
{{ TEXT.modules.drugShipments.recordLabel }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date" :required="requiresShipmentDetails(form.status)">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date" :required="requiresReceiveDate(form.status)">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity" :required="requiresShipmentDetails(form.status)">
<el-input-number v-model="form.quantity" :min="0" :controls="false" class="full-width" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-remark"></span>
{{ TEXT.common.fields.remark }}
</div>
<el-form-item prop="remark" :required="requiresRemark(form.status)">
<el-input v-model="form.remark" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.remark" :disabled="isFormReadOnly" />
</el-form-item>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
: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" @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 { getDrugShipment, updateDrugShipment } from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
type ShipmentDirection = "SEND" | "RETURN";
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
type FormModel = {
center_id: string;
direction: ShipmentDirection;
ship_date: string;
receive_date: string;
quantity: number | null;
batch_no: string;
carrier: string;
tracking_no: string;
status: ShipmentStatus;
remark: string;
};
const props = defineProps<{ modelValue: boolean; shipmentId: string; readonly?: boolean }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "saved"): void;
}>();
const study = useStudyStore();
const formRef = ref<FormInstance>();
const saving = ref(false);
const sites = ref<any[]>([]);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive<FormModel>({
center_id: "",
direction: "SEND",
ship_date: "",
receive_date: "",
quantity: null,
batch_no: "",
carrier: "",
tracking_no: "",
status: "PENDING",
remark: "",
});
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const siteActiveMap = computed(() =>
sites.value.reduce((acc: Record<string, boolean>, site: any) => {
if (site?.id) acc[site.id] = !!site.is_active;
return acc;
}, {})
);
const selectedSiteInactive = computed(() => !!form.center_id && siteActiveMap.value[form.center_id] === false);
const isFormReadOnly = computed(() => !!props.readonly || selectedSiteInactive.value);
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
const validateShipmentDetail = (_rule: unknown, value: string | number | null, callback: (error?: Error) => void) => {
if (requiresShipmentDetails(form.status) && (value === null || value === undefined || value === "")) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateShipmentTextDetail = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresShipmentDetails(form.status) && !value?.trim()) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateReceiveDate = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresReceiveDate(form.status) && !value) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateRemark = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresRemark(form.status) && !value?.trim()) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const rules: FormRules<FormModel> = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
ship_date: [{ validator: validateShipmentDetail, trigger: "change" }],
receive_date: [{ validator: validateReceiveDate, trigger: "change" }],
quantity: [{ validator: validateShipmentDetail, trigger: "change" }],
batch_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
carrier: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
tracking_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
remark: [{ validator: validateRemark, trigger: "blur" }],
};
const loadSites = async () => {
if (!studyId.value) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadShipment = async () => {
if (!studyId.value || !props.shipmentId) return;
try {
const { data } = await getDrugShipment(studyId.value, props.shipmentId);
Object.assign(form, {
center_id: data.center_id || "",
direction: data.direction || "SEND",
ship_date: data.ship_date || "",
receive_date: data.receive_date || "",
quantity: data.quantity ?? null,
batch_no: data.batch_no || "",
carrier: data.carrier || "",
tracking_no: data.tracking_no || "",
status: data.status || "PENDING",
remark: data.remark || "",
});
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const load = async () => {
await loadSites();
await loadShipment();
};
const saveForm = async () => {
if (!studyId.value || !props.shipmentId) return;
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
saving.value = true;
try {
await updateDrugShipment(studyId.value, props.shipmentId, {
center_id: form.center_id,
direction: form.direction,
ship_date: form.ship_date || null,
receive_date: form.receive_date || null,
quantity: form.quantity,
batch_no: form.batch_no.trim() || null,
carrier: form.carrier.trim() || null,
tracking_no: form.tracking_no.trim() || null,
status: form.status,
remark: form.remark.trim() || null,
});
await attachmentPanelRef.value?.uploadPending(props.shipmentId);
drawerDirtyGuard.syncBaseline();
emit("saved");
emit("update:modelValue", false);
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => [props.modelValue, props.shipmentId, studyId.value] as const,
([visible]) => {
if (visible) load();
},
{ immediate: true }
);
</script>
<style scoped>
:deep(.shipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.shipment-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;
}
.shipment-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;
}
.group-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.group-dot-basic {
background: #3b82f6;
}
.group-dot-record {
background: #f0ad2c;
}
.group-dot-remark {
background: #22c55e;
}
.group-dot-attachment {
background: #8b5cf6;
}
.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%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.shipment-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.shipment-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./ShipmentDetail.vue"), "utf8");
describe("ShipmentDetail edit drawer", () => {
it("shows an edit action and opens the shipment editor drawer on the detail page", () => {
const source = readSource();
expect(source).toContain("<DrugShipmentEditorDrawer");
expect(source).toContain('v-if="canUpdateShipment"');
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).toContain('"drug_shipments:update"');
expect(source).toContain(":readonly=\"isReadOnly\"");
});
it("hides the upload button in the detail attachment table", () => {
const source = readSource();
expect(source).toContain("<AttachmentList");
expect(source).toContain(":hide-uploader=\"true\"");
});
});
+156 -25
View File
@@ -1,29 +1,36 @@
<template> <template>
<div class="page"> <div class="page">
<div class="unified-shell"> <div class="unified-shell">
<section class="unified-section"> <section v-loading="loading" class="unified-section">
<div class="unified-section-header"> <div class="unified-section-header">
<div>
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div> <div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
<div class="ctms-section-actions"> <div class="detail-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</div>
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> </div>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <div class="actions">
<el-button v-if="canUpdateShipment" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div> </div>
</div> </div>
<div class="section-title-row"> <div class="section-title-row">
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span> <span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
<el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'"> <el-tag :type="statusType(detail.status)" effect="plain" round>
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }} {{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
</el-tag> </el-tag>
</div> </div>
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.direction"> <el-descriptions-item :label="TEXT.common.fields.direction">
<el-tag :type="detail.direction === 'SEND' ? 'primary' : 'warning'" size="small"> <el-tag :class="['type-tag', detail.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }} {{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</section> </section>
<section class="unified-section"> <section class="unified-section">
<div class="ctms-section-title section-title-inline">{{ TEXT.modules.drugShipments.recordLabel }}</div> <div class="ctms-section-title section-title-inline">{{ TEXT.modules.drugShipments.recordLabel }}</div>
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
@@ -36,70 +43,162 @@
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</section> </section>
<section class="unified-section attachment-section"> <section class="unified-section attachment-section">
<AttachmentList <AttachmentList
v-if="shipmentId" v-if="shipmentId && studyId"
:study-id="studyId" :study-id="studyId"
entity-type="drug_shipment" entity-type="drug_shipment"
:entity-id="shipmentId" :entity-id="shipmentId"
:readonly="isReadOnly"
:hide-uploader="true"
/> />
</section> </section>
</div> </div>
<DrugShipmentEditorDrawer
v-model="editorVisible"
:shipment-id="shipmentId || ''"
:readonly="isReadOnly"
@saved="handleEditorSaved"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref, watch } 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 { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getDrugShipment } from "../../api/drugShipments"; import { getDrugShipment } from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import DrugShipmentEditorDrawer from "./DrugShipmentEditorDrawer.vue";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const shipmentId = route.params.shipmentId as string; const editorVisible = ref(false);
const studyId = study.currentStudy?.id || ""; const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<any[]>([]);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canUpdateShipment = computed(() => isSystemAdmin(auth.user) || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:update"]));
const siteActiveMap = computed(() =>
sites.value.reduce((acc: Record<string, boolean>, site: any) => {
if (site?.id) acc[site.id] = !!site.is_active;
return acc;
}, {})
);
const detail = reactive<any>({ const detail = reactive({
center_id: "",
direction: "", direction: "",
status: "", status: "",
site_name: "", site_name: "",
ship_date: "", ship_date: "",
receive_date: "", receive_date: "",
quantity: null, quantity: null as number | null,
batch_no: "", batch_no: "",
carrier: "", carrier: "",
tracking_no: "", tracking_no: "",
remark: "", remark: "",
}); });
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
const loadSites = async () => {
if (!studyId.value) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => { const load = async () => {
if (!studyId || !shipmentId) return; if (!studyId.value || !shipmentId.value) return;
loading.value = true; loading.value = true;
try { try {
const { data } = await getDrugShipment(studyId, shipmentId); const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(detail, data); Object.assign(detail, {
center_id: data.center_id || "",
direction: data.direction || "",
status: data.status || "",
site_name: data.site_name || "",
ship_date: data.ship_date || "",
receive_date: data.receive_date || "",
quantity: data.quantity ?? null,
batch_no: data.batch_no || "",
carrier: data.carrier || "",
tracking_no: data.tracking_no || "",
remark: data.remark || "",
});
// 同步中心名称到面包屑 study.setViewContext({
if (detail.site_name) { siteName: detail.site_name || TEXT.common.fallback,
study.setViewContext({ siteName: detail.site_name }); pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle,
} });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const goEdit = () => router.push(`/drug/shipments/${shipmentId}/edit`); const goEdit = () => {
const goBack = () => router.push("/drug/shipments"); if (!canUpdateShipment.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
editorVisible.value = true;
};
onMounted(load); const handleEditorSaved = () => {
load();
};
const statusType = (status: string) => {
switch (status) {
case "PENDING":
return "info";
case "IN_TRANSIT":
return "primary";
case "SIGNED":
return "success";
case "EXCEPTION":
return "danger";
default:
return "info";
}
};
watch(
() => [studyId.value, shipmentId.value],
async () => {
await loadSites();
load();
}
);
onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
@@ -109,10 +208,27 @@ onMounted(load);
gap: 0; gap: 0;
} }
.detail-subtitle {
margin-top: 4px;
color: var(--ctms-text-secondary);
font-size: 13px;
}
.attachment-section { .attachment-section {
padding: 0; padding: 0;
} }
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
}
.section-title-row { .section-title-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -123,4 +239,19 @@ onMounted(load);
.section-title-inline { .section-title-inline {
margin-bottom: 12px; margin-bottom: 12px;
} }
.type-tag {
font-weight: 600;
border: none;
}
.type-send {
background-color: #fff7ed !important;
color: #ea580c !important;
}
.type-return {
background-color: #fefce8 !important;
color: #ca8a04 !important;
}
</style> </style>
-273
View File
@@ -1,273 +0,0 @@
<template>
<div class="page">
<div class="unified-shell">
<section class="unified-section">
<div class="unified-section-header">
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</div>
<div class="ctms-section-actions">
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<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" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
</el-form-item>
<el-form-item>
<div class="actions">
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</div>
</el-form-item>
</el-form>
</section>
<section class="unified-section">
<AttachmentList
v-if="isEdit && shipmentId"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
/>
<StateEmpty v-else :description="TEXT.modules.drugShipments.uploadHint" />
</section>
</div>
</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 { useStudyStore } from "../../store/study";
import { createDrugShipment, getDrugShipment, updateDrugShipment } from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const formRef = 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 shipmentId = computed(() => route.params.shipmentId as string | undefined);
const isEdit = computed(() => !!shipmentId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canMutate = computed(() => {
const operationKey = isEdit.value ? "drug_shipments:update" : "drug_shipments: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 form = reactive({
center_id: "",
direction: "SEND",
ship_date: "",
receive_date: "",
quantity: null as number | null,
batch_no: "",
carrier: "",
tracking_no: "",
status: "PENDING",
remark: "",
});
const rules = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
ship_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
receive_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
quantity: [{ required: true, type: "number", message: TEXT.common.messages.required, trigger: "change" }],
batch_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
carrier: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
tracking_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
remark: [{ 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 load = async () => {
if (!isEdit.value || !studyId.value || !shipmentId.value) return;
try {
const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(form, {
direction: data.direction || "SEND",
center_id: data.center_id || "",
ship_date: data.ship_date || "",
receive_date: data.receive_date || "",
quantity: data.quantity ?? null,
batch_no: data.batch_no || "",
carrier: data.carrier || "",
tracking_no: data.tracking_no || "",
status: data.status || "PENDING",
remark: data.remark || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!canMutate.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId.value) return;
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
saving.value = true;
try {
const payload = {
center_id: form.center_id,
direction: form.direction,
ship_date: form.ship_date || null,
receive_date: form.receive_date || null,
quantity: form.quantity,
batch_no: form.batch_no,
carrier: form.carrier || null,
tracking_no: form.tracking_no || null,
status: form.status,
remark: form.remark || null,
};
if (isEdit.value && shipmentId.value) {
await updateDrugShipment(studyId.value, shipmentId.value, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/drug/shipments/${shipmentId.value}`);
} else {
const { data } = await createDrugShipment(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/drug/shipments/${data.id}`);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/drug/shipments");
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.section-title {
margin: 0 0 24px;
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-primary);
border-left: 4px solid var(--ctms-primary);
padding-left: 12px;
}
.actions {
display: flex;
gap: 12px;
}
</style>
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipments.vue"), "utf8");
describe("DrugShipments project permissions", () => {
it("hides edit and delete actions when the role lacks matching backend operation permissions", () => {
const source = readSource();
expect(source).toContain('["drug_shipments:create"]');
expect(source).toContain('["drug_shipments:update"]');
expect(source).toContain('["drug_shipments:delete"]');
expect(source).toContain('v-if="canUpdate"');
expect(source).toContain('v-if="canDelete"');
expect(source).toContain("if (!canUpdate.value)");
expect(source).toContain("if (!canDelete.value)");
});
it("uses flexible data columns so the table fills the available page width", () => {
const source = readSource();
expect(source).toContain('class="ctms-table shipment-table"');
expect(source).toContain('style="width: 100%"');
expect(source).toContain('table-layout="fixed"');
expect(source).toContain(':label="TEXT.common.labels.actions" width="130"');
expect(source).not.toMatch(/prop="(?:site_name|direction|ship_date|receive_date|quantity|batch_no|status|remark)"[^>]+(?:min-width|width)=/);
});
it("keeps edit and delete actions on one line", () => {
const source = readSource();
expect(source).toContain('class="shipment-actions"');
expect(source).toContain(".shipment-actions");
expect(source).toContain("white-space: nowrap;");
});
it("uses the compact pending upload area in the create/edit drawer", () => {
const source = readSource();
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="drug_shipment"');
expect(source).toContain(':entity-id="editingId"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(shipmentId)");
expect(source).toContain("uploadPending");
expect(source).toContain("group-dot-attachment");
expect(source).not.toContain("uploadAttachment");
expect(source).not.toContain("pendingFiles");
expect(source).not.toContain("upload-card-label");
});
it("keeps shipment execution fields optional while status is pending", () => {
const source = readSource();
expect(source).toContain("requiresShipmentDetails");
expect(source).toContain('status !== "PENDING"');
expect(source).not.toContain('ship_date: [{ required: true');
expect(source).not.toContain('batch_no: [{ required: true');
expect(source).not.toContain('carrier: [{ required: true');
expect(source).not.toContain('tracking_no: [{ required: true');
});
it("does not expose the removed returned status", () => {
const source = readSource();
expect(source).not.toContain("RETURNED");
expect(source).not.toContain("已回收");
});
it("requires receive date when shipment is signed", () => {
const source = readSource();
expect(source).toContain("requiresReceiveDate");
expect(source).toContain('status === "SIGNED"');
expect(source).not.toContain('receive_date: [{ required: true');
});
it("keeps remark optional unless shipment is exceptional", () => {
const source = readSource();
expect(source).toContain("requiresRemark");
expect(source).toContain('status === "EXCEPTION"');
expect(source).not.toContain('remark: [{ required: true');
});
});
+659 -137
View File
@@ -1,14 +1,11 @@
<template> <template>
<div class="page page--flush"> <div class="page page--flush">
<div v-if="study.currentStudy" class="unified-shell">
<div class="main-content-flat unified-shell"> <section class="unified-section section--flush">
<div class="filter-container unified-action-bar"> <div class="filter-row">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters">
<div class="filter-item"> <el-form-item :label="TEXT.common.fields.site">
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.fields.site" class="filter-select"> <el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option <el-option
v-for="site in sites" v-for="site in sites"
:key="site.id" :key="site.id"
@@ -16,181 +13,559 @@
:value="site.id" :value="site.id"
/> />
</el-select> </el-select>
</div> </el-form-item>
<div class="filter-item"> <el-form-item :label="TEXT.common.fields.direction">
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.fields.direction" class="filter-select"> <el-select v-model="filters.direction" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
<template #prefix>
<el-icon><Menu /></el-icon>
</template>
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" /> <el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" /> <el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select> </el-select>
</div> </el-form-item>
<div class="filter-item"> <el-form-item :label="TEXT.common.fields.status">
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.fields.status" class="filter-select"> <el-select v-model="filters.status" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" /> <el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" /> <el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" /> <el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" /> <el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select> </el-select>
</div> </el-form-item>
<div class="filter-actions"> <el-form-item>
<el-button type="primary" @click="handleSearch">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div> </el-form-item>
<div class="filter-spacer"></div>
<el-button type="primary" @click="goNew">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.drugShipments.newTitle }}
</el-button>
</el-form> </el-form>
<el-button v-if="canCreate" type="primary" @click="openCreate">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
</div> </div>
<div class="unified-section table-section section--flush-x section--flush-top section--flush-bottom">
<el-table <el-table
:data="sortedItems"
v-loading="loading" v-loading="loading"
:data="sortedItems"
class="ctms-table shipment-table"
style="width: 100%" style="width: 100%"
class="shipment-table" table-layout="fixed"
:row-class-name="shipmentRowClass" :row-class-name="shipmentRowClass"
@row-click="onRowClick" @row-click="onRowClick"
table-layout="fixed"
> >
<el-table-column prop="site_name" :label="TEXT.common.fields.site" width="150" show-overflow-tooltip> <el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template> <template #default="{ row }">{{ row.site_name || TEXT.common.fallback }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120"> <el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="scope"> <template #default="{ row }">
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light"> <el-tag :class="['type-tag', row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }} {{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="165"> <el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template> <template #default="{ row }">{{ displayDate(row.ship_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="145"> <el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template> <template #default="{ row }">{{ displayDate(row.receive_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="100" align="right"> <el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template> <template #default="{ row }">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" width="120" show-overflow-tooltip> <el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template> <template #default="{ row }">{{ row.batch_no || TEXT.common.fallback }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120"> <el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope"> <template #default="{ row }">
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag"> <el-tag :type="statusType(row.status)" effect="plain" round size="small" class="status-tag">
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }} {{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" width="160" show-overflow-tooltip> <el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template> <template #default="{ row }">{{ row.remark || TEXT.common.fallback }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="110" align="center"> <el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" width="130">
<template #default="scope"> <template #default="{ row }">
<div class="shipment-actions">
<el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button <el-button
v-if="canDelete"
link link
type="danger" type="danger"
size="small" :disabled="isInactiveSite(row.center_id)"
:disabled="isInactiveSite(scope.row.center_id)" @click.stop="remove(row)"
@click.stop="remove(scope.row)"
> >
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
</el-button> </el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
<div v-if="!loading" class="table-empty">{{ TEXT.modules.drugShipments.empty }}</div> <div v-if="!loading" class="table-empty">{{ TEXT.modules.drugShipments.empty }}</div>
</template> </template>
</el-table> </el-table>
</section>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<el-drawer
v-if="drawerVisible"
v-model="drawerVisible"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="shipment-editor-drawer"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ editingId ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="shipment-form">
<!-- 基本信息分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.common.labels.basicInfo }}
</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" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<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.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div> </div>
</div> </div>
<!-- 运输记录分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-record"></span>
{{ TEXT.modules.drugShipments.recordLabel }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date" :required="requiresShipmentDetails(form.status)">
<el-date-picker
v-model="form.ship_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
:disabled="isFormReadOnly"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date" :required="requiresReceiveDate(form.status)">
<el-date-picker
v-model="form.receive_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
:disabled="isFormReadOnly"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity" :required="requiresShipmentDetails(form.status)">
<el-input-number v-model="form.quantity" :min="0" :controls="false" class="full-width" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 备注分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-remark"></span>
{{ TEXT.common.fields.remark }}
</div>
<el-form-item prop="remark" :required="requiresRemark(form.status)">
<el-input
v-model="form.remark"
type="textarea"
:rows="4"
:placeholder="TEXT.common.placeholders.input + TEXT.common.fields.remark"
:disabled="isFormReadOnly"
/>
</el-form-item>
</div>
<!-- 附件分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="study.currentStudy?.id || ''"
entity-type="drug_shipment"
:entity-id="editingId"
:mode="'upload'"
:readonly="isFormReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Location, Menu, CircleCheck, Plus } from "@element-plus/icons-vue"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments"; import {
createDrugShipment,
deleteDrugShipment,
listDrugShipments,
updateDrugShipment,
} from "../../api/drugShipments";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { displayDate, displayEnum } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
type ShipmentDirection = "SEND" | "RETURN";
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
interface ShipmentRow {
id: string;
center_id: string;
site_name: string;
direction: ShipmentDirection;
ship_date: string;
receive_date: string;
quantity: number | null;
batch_no: string;
carrier: string;
tracking_no: string;
status: ShipmentStatus;
remark: string;
}
type FormModel = Omit<ShipmentRow, "id" | "site_name">;
const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false); const loading = ref(false);
const items = ref<any[]>([]); const saving = ref(false);
const items = ref<ShipmentRow[]>([]);
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const siteActiveMap = ref<Record<string, boolean>>({}); const drawerVisible = ref(false);
const editingId = ref("");
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const filters = reactive({ const filters = reactive({
center_id: study.currentSite?.id || "", center_id: study.currentSite?.id || "",
direction: "", direction: "" as "" | ShipmentDirection,
status: "", status: "" as "" | ShipmentStatus,
});
const defaultForm: FormModel = {
center_id: "",
direction: "SEND",
ship_date: "",
receive_date: "",
quantity: null,
batch_no: "",
carrier: "",
tracking_no: "",
status: "PENDING",
remark: "",
};
const form = reactive<FormModel>({ ...defaultForm });
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const isAdmin = computed(() => isSystemAdmin(auth.user));
const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:create"]));
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:delete"]));
const siteActiveMap = computed(() =>
sites.value.reduce((acc: Record<string, boolean>, site: any) => {
if (site?.id) acc[site.id] = !!site.is_active;
return acc;
}, {})
);
const selectedSiteInactive = computed(() => !!editingId.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value) || selectedSiteInactive.value);
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
const validateShipmentDetail = (_rule: unknown, value: string | number | null, callback: (error?: Error) => void) => {
if (requiresShipmentDetails(form.status) && (value === null || value === undefined || value === "")) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateShipmentTextDetail = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresShipmentDetails(form.status) && !value?.trim()) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateReceiveDate = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresReceiveDate(form.status) && !value) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateRemark = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresRemark(form.status) && !value?.trim()) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const rules: FormRules<FormModel> = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
ship_date: [{ validator: validateShipmentDetail, trigger: "change" }],
receive_date: [{ validator: validateReceiveDate, trigger: "change" }],
quantity: [{ validator: validateShipmentDetail, trigger: "change" }],
batch_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
carrier: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
tracking_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
remark: [{ validator: validateRemark, trigger: "blur" }],
};
const normalizeShipment = (item: any): ShipmentRow => ({
id: item.id,
center_id: item.center_id || "",
site_name: item.site_name || "",
direction: item.direction || "SEND",
ship_date: item.ship_date || "",
receive_date: item.receive_date || "",
quantity: item.quantity ?? null,
batch_no: item.batch_no || "",
carrier: item.carrier || "",
tracking_no: item.tracking_no || "",
status: item.status || "PENDING",
remark: item.remark || "",
}); });
const loadSites = async () => { const loadSites = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) {
sites.value = [];
return;
}
try { try {
const { data } = await fetchSites(studyId, { limit: 500 }); const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || []; sites.value = Array.isArray(data) ? data : data.items || [];
siteActiveMap.value = sites.value.reduce((acc: Record<string, boolean>, site: any) => {
acc[site.id] = !!site.is_active;
return acc;
}, {});
} catch { } catch {
sites.value = []; sites.value = [];
siteActiveMap.value = {};
} }
}; };
const load = async () => { const load = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) {
items.value = [];
return;
}
loading.value = true; loading.value = true;
try { try {
const { data } = await listDrugShipments(studyId, {}); const { data } = await listDrugShipments(studyId, {
items.value = Array.isArray(data) ? data : data?.items || []; center_id: filters.center_id || undefined,
direction: filters.direction || undefined,
status: filters.status || undefined,
limit: 500,
});
const list = Array.isArray(data) ? data : data?.items || [];
items.value = list.map(normalizeShipment);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); items.value = [];
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const resetForm = () => {
Object.assign(form, defaultForm);
formRef.value?.clearValidate();
};
const openCreate = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = "";
resetForm();
if (study.currentSite?.id && siteActiveMap.value[study.currentSite.id] !== false) {
form.center_id = study.currentSite.id;
}
drawerDirtyGuard.syncBaseline();
drawerVisible.value = true;
};
const openEdit = (row: ShipmentRow) => {
if (!canUpdate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = row.id;
Object.assign(form, {
center_id: row.center_id,
direction: row.direction,
ship_date: row.ship_date,
receive_date: row.receive_date,
quantity: row.quantity,
batch_no: row.batch_no,
carrier: row.carrier,
tracking_no: row.tracking_no,
status: row.status,
remark: row.remark,
});
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = true;
};
const saveForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const payload = {
center_id: form.center_id,
direction: form.direction,
ship_date: form.ship_date || null,
receive_date: form.receive_date || null,
quantity: form.quantity,
batch_no: form.batch_no.trim() || null,
carrier: form.carrier.trim() || null,
tracking_no: form.tracking_no.trim() || null,
status: form.status,
remark: form.remark.trim() || null,
};
saving.value = true;
try {
let shipmentId = editingId.value;
if (editingId.value) {
await updateDrugShipment(studyId, editingId.value, payload);
} else {
const { data } = (await createDrugShipment(studyId, payload)) as any;
shipmentId = data?.id || "";
if (shipmentId) editingId.value = shipmentId;
}
await attachmentPanelRef.value?.uploadPending(shipmentId);
await load();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const resetFilters = () => { const resetFilters = () => {
filters.center_id = ""; filters.center_id = "";
filters.direction = ""; filters.direction = "";
filters.status = ""; filters.status = "";
study.setCurrentSite(null);
load();
}; };
const goNew = () => router.push("/drug/shipments/new"); const handleSearch = () => {
const goDetail = (id: string) => router.push(`/drug/shipments/${id}`); load();
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
}; };
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false; const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: any }) => const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim(); `${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const filteredItems = computed(() => const onRowClick = (row: ShipmentRow) => {
items.value.filter((item) => { if (!row?.id) return;
if (filters.center_id && item?.center_id !== filters.center_id) return false; router.push(`/drug/shipments/${row.id}`);
if (filters.direction && item?.direction !== filters.direction) return false; };
if (filters.status && item?.status !== filters.status) return false;
return true;
})
);
const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const statusType = (status: string) => { const statusType = (status: string) => {
switch (status) { switch (status) {
@@ -200,8 +575,6 @@ const statusType = (status: string) => {
return "primary"; return "primary";
case "SIGNED": case "SIGNED":
return "success"; return "success";
case "RETURNED":
return "warning";
case "EXCEPTION": case "EXCEPTION":
return "danger"; return "danger";
default: default:
@@ -209,40 +582,62 @@ const statusType = (status: string) => {
} }
}; };
const remove = async (row: any) => { const remove = async (row: ShipmentRow) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (!canDelete.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(row?.center_id)) { if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
} }
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips, { type: "warning" }).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteDrugShipment(studyId, row.id); await deleteDrugShipment(studyId, row.id);
await load();
ElMessage.success(TEXT.common.messages.deleteSuccess); ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} }
}; };
watch(() => filters.center_id, (val: string) => { watch(
() => filters.center_id,
(val: string) => {
if (val) { if (val) {
const matched = sites.value.find(s => s.id === val); const matched = sites.value.find((site) => site.id === val);
if (matched) study.setCurrentSite(matched); if (matched) study.setCurrentSite(matched);
} else { } else {
study.setCurrentSite(null); study.setCurrentSite(null);
} }
}); }
);
watch(() => study.currentSite, (newSite: any) => { watch(
() => study.currentSite,
(newSite: any) => {
filters.center_id = newSite?.id || ""; filters.center_id = newSite?.id || "";
}); }
);
watch(
() => study.currentStudy?.id,
async () => {
filters.center_id = study.currentSite?.id || "";
filters.direction = "";
filters.status = "";
drawerVisible.value = false;
await loadSites();
await load();
}
);
onMounted(async () => { onMounted(async () => {
await loadSites(); await loadSites();
load(); await load();
}); });
</script> </script>
@@ -252,46 +647,176 @@ onMounted(async () => {
flex-direction: column; flex-direction: column;
} }
.filter-form { .filter-row {
display: flex; display: flex;
width: 100%;
gap: 8px;
align-items: center; align-items: center;
} justify-content: space-between;
margin-bottom: 12px;
.filter-item { gap: 12px;
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-actions {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-actions :deep(.el-form-item__content) {
align-items: center;
}
.filter-actions :deep(.el-button) {
height: 32px;
line-height: 32px;
padding: 0 12px;
border-radius: 10px;
} }
.filter-select { .filter-select {
width: 140px; width: 150px;
} }
.filter-spacer { /* ========== 抽屉头部 ========== */
flex: 1; :deep(.shipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.shipment-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;
}
/* ========== 表单整体 ========== */
.shipment-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;
}
.group-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* 基本信息 - 蓝色 */
.group-dot-basic {
background: #3b82f6;
}
/* 运输记录 - 琥珀色 */
.group-dot-record {
background: #f0ad2c;
}
/* 备注 - 绿色 */
.group-dot-remark {
background: #22c55e;
}
/* 附件 - 紫色 */
.group-dot-attachment {
background: #8b5cf6;
}
.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%;
}
/* ========== 底部按钮 ========== */
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ========== 表单元素微调 ========== */
.shipment-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.shipment-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
padding-bottom: 4px;
} }
.shipment-table :deep(.el-table__inner-wrapper::before) { .shipment-table :deep(.el-table__inner-wrapper::before) {
display: none; display: none;
} }
/* ========== 表格居中 ========== */
.shipment-table :deep(th.el-table__cell .cell),
.shipment-table :deep(td.el-table__cell .cell) {
text-align: center;
}
.shipment-actions {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
white-space: nowrap;
}
.shipment-actions :deep(.el-button) {
height: auto;
padding: 0;
}
.shipment-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.type-tag { .type-tag {
font-weight: 600; font-weight: 600;
border: none; border: none;
@@ -311,9 +836,6 @@ onMounted(async () => {
font-weight: 500; font-weight: 500;
} }
.text-muted {
color: var(--ctms-text-placeholder);
}
.table-empty { .table-empty {
min-height: 220px; min-height: 220px;
display: flex; display: flex;
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
describe("MaterialEquipment project permissions", () => {
it("projects material equipment create update and delete permissions into all write actions", () => {
const source = readSource();
expect(source).toContain("useAuthStore");
expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain('["material_equipments:create"]');
expect(source).toContain('["material_equipments:update"]');
expect(source).toContain('["material_equipments:delete"]');
expect(source).toContain('v-if="canCreate"');
expect(source).toContain('v-if="canUpdate"');
expect(source).toContain('v-if="canDelete"');
expect(source).toContain("if (!canDelete.value)");
});
it("keeps the visible table columns evenly distributed", () => {
const source = readSource();
expect(source).toContain('class="ctms-table equipment-table"');
expect(source).toContain('style="width: 100%"');
expect(source).toContain('table-layout="fixed"');
expect(source).toContain('label="操作"');
expect(source).not.toMatch(/<el-table-column[^>]+label="操作"[^>]+(?:width|min-width)=/);
});
it("opens the detail page from row clicks instead of a detail action button", () => {
const source = readSource();
expect(source).toContain('@row-click="onRowClick"');
expect(source).toContain(':row-class-name="equipmentRowClass"');
expect(source).toContain('name: "MaterialEquipmentDetail"');
expect(source).toContain("equipmentId: row.id");
expect(source).not.toContain(">详情</el-button>");
expect(source).toContain('@click.stop="openEdit(row)"');
expect(source).toContain('@click.stop="removeRow(row)"');
});
it("uploads qualification files as equipment attachments after saving the drawer form", () => {
const source = readSource();
const calibrationIndex = source.indexOf("校准设置");
const qualificationIndex = source.indexOf("附件");
expect(calibrationIndex).toBeGreaterThan(-1);
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':entity-id="editingId"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)");
expect(source).toContain("uploadPending");
expect(source).toContain("material_equipment");
expect(source).not.toContain("uploadAttachment");
expect(source).not.toContain("pendingFiles");
expect(source).not.toContain("qualificationRows");
expect(source).not.toContain("material_equipment_production_permit");
expect(source).not.toContain("material_equipment_tech_index");
expect(source).not.toContain("资质文件");
expect(source).not.toContain("生产许可证");
expect(source).not.toContain("技术指标");
expect(source).not.toContain("handleUploadChange");
});
});
+98 -154
View File
@@ -12,10 +12,18 @@
<el-button @click="resetFilters">重置</el-button> <el-button @click="resetFilters">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-button type="primary" @click="openCreate">新建</el-button> <el-button v-if="canCreate" type="primary" @click="openCreate">新建</el-button>
</div> </div>
<el-table v-loading="loading" :data="rows" class="ctms-table equipment-table" style="width: 100%" table-layout="fixed"> <el-table
v-loading="loading"
:data="rows"
class="ctms-table equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip /> <el-table-column prop="name" label="设备名称" show-overflow-tooltip />
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip /> <el-table-column prop="specModel" label="规格型号" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" show-overflow-tooltip /> <el-table-column prop="unit" label="单位" show-overflow-tooltip />
@@ -25,10 +33,10 @@
{{ row.needCalibration ? "是" : "否" }} {{ row.needCalibration ? "是" : "否" }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120"> <el-table-column label="操作">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeRow(row)">删除</el-button> <el-button v-if="canDelete" link type="danger" @click.stop="removeRow(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
@@ -44,14 +52,14 @@
v-model="drawerVisible" v-model="drawerVisible"
direction="rtl" direction="rtl"
size="620px" size="620px"
:close-on-click-modal="false" :close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false" :show-close="false"
class="equipment-editor-drawer" class="equipment-editor-drawer"
> >
<template #header> <template #header>
<div class="editor-header"> <div class="editor-header">
<div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div> <div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div>
<div class="editor-subtitle">{{ editingId ? "修改设备基本信息与校准配置" : "添加新的设备记录到设备台账" }}</div>
</div> </div>
</template> </template>
@@ -93,49 +101,6 @@
</el-row> </el-row>
</div> </div>
<!-- 资质文件分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-file"></span>
资质文件
</div>
<el-row :gutter="16">
<el-col :span="12">
<div class="upload-card">
<div class="upload-card-label">生产许可证</div>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onProductionPermitChange">
<div v-if="!form.productionPermitFileName" class="upload-trigger">
<span class="upload-icon">📄</span>
<span class="upload-text">点击上传文件</span>
</div>
</el-upload>
<div v-if="form.productionPermitFileName" class="upload-result">
<span class="upload-result-icon"></span>
<span class="upload-result-name">{{ form.productionPermitFileName }}</span>
<el-button link type="danger" size="small" @click="form.productionPermitFileName = ''">移除</el-button>
</div>
</div>
</el-col>
<el-col :span="12">
<div class="upload-card">
<div class="upload-card-label">技术指标</div>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onTechIndexChange">
<div v-if="!form.techIndexFileName" class="upload-trigger">
<span class="upload-icon">📄</span>
<span class="upload-text">点击上传文件</span>
</div>
</el-upload>
<div v-if="form.techIndexFileName" class="upload-result">
<span class="upload-result-icon"></span>
<span class="upload-result-name">{{ form.techIndexFileName }}</span>
<el-button link type="danger" size="small" @click="form.techIndexFileName = ''">移除</el-button>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 校准设置分组 -->
<div class="form-group"> <div class="form-group">
<div class="form-group-title"> <div class="form-group-title">
<span class="group-dot group-dot-calibration"></span> <span class="group-dot group-dot-calibration"></span>
@@ -161,12 +126,27 @@
<span>该设备无需定期校准</span> <span>该设备无需定期校准</span>
</div> </div>
</div> </div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-file"></span>
附件
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="study.currentStudy?.id || ''"
entity-type="material_equipment"
:entity-id="editingId"
:mode="'upload'"
:readonly="isFormReadOnly"
/>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="drawer-footer"> <div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button> <el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="saveForm">保存</el-button> <el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">保存</el-button>
</div> </div>
</template> </template>
</el-drawer> </el-drawer>
@@ -174,16 +154,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from "element-plus"; import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { import {
createMaterialEquipment, createMaterialEquipment,
deleteMaterialEquipment, deleteMaterialEquipment,
listMaterialEquipments, listMaterialEquipments,
updateMaterialEquipment, updateMaterialEquipment,
} from "../../api/materialEquipments"; } from "../../api/materialEquipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
interface EquipmentRow { interface EquipmentRow {
@@ -193,8 +179,6 @@ interface EquipmentRow {
unit: string; unit: string;
brand: string; brand: string;
origin: string; origin: string;
productionPermitFileName: string;
techIndexFileName: string;
needCalibration: boolean; needCalibration: boolean;
calibrationCycleDays: number | null; calibrationCycleDays: number | null;
} }
@@ -202,12 +186,16 @@ interface EquipmentRow {
type FormModel = Omit<EquipmentRow, "id">; type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const filters = reactive({ name: "" }); const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]); const rows = ref<EquipmentRow[]>([]);
const loading = ref(false); const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false); const drawerVisible = ref(false);
const editingId = ref(""); const editingId = ref("");
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const defaultForm: FormModel = { const defaultForm: FormModel = {
name: "", name: "",
@@ -215,13 +203,21 @@ const defaultForm: FormModel = {
unit: "", unit: "",
brand: "", brand: "",
origin: "", origin: "",
productionPermitFileName: "",
techIndexFileName: "",
needCalibration: true, needCalibration: true,
calibrationCycleDays: 30, calibrationCycleDays: 30,
}; };
const form = reactive<FormModel>({ ...defaultForm }); const form = reactive<FormModel>({ ...defaultForm });
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const isAdmin = computed(() => isSystemAdmin(auth.user));
const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:create"]));
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value));
const rules: FormRules<FormModel> = { const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }], name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
@@ -262,8 +258,6 @@ const loadRows = async () => {
unit: item.unit || "", unit: item.unit || "",
brand: item.brand || "", brand: item.brand || "",
origin: item.origin || "", origin: item.origin || "",
productionPermitFileName: item.production_permit_file_name || "",
techIndexFileName: item.tech_index_file_name || "",
needCalibration: !!item.need_calibration, needCalibration: !!item.need_calibration,
calibrationCycleDays: item.calibration_cycle_days ?? null, calibrationCycleDays: item.calibration_cycle_days ?? null,
})); }));
@@ -281,12 +275,21 @@ const resetForm = () => {
}; };
const openCreate = () => { const openCreate = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = ""; editingId.value = "";
resetForm(); resetForm();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = true; drawerVisible.value = true;
}; };
const openEdit = (row: EquipmentRow) => { const openEdit = (row: EquipmentRow) => {
if (!canUpdate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = row.id; editingId.value = row.id;
Object.assign(form, { Object.assign(form, {
name: row.name, name: row.name,
@@ -294,18 +297,28 @@ const openEdit = (row: EquipmentRow) => {
unit: row.unit, unit: row.unit,
brand: row.brand, brand: row.brand,
origin: row.origin, origin: row.origin,
productionPermitFileName: row.productionPermitFileName,
techIndexFileName: row.techIndexFileName,
needCalibration: row.needCalibration, needCalibration: row.needCalibration,
calibrationCycleDays: row.calibrationCycleDays, calibrationCycleDays: row.calibrationCycleDays,
}); });
formRef.value?.clearValidate(); formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = true; drawerVisible.value = true;
}; };
const equipmentRowClass = ({ row }: { row: EquipmentRow }) => (row?.id ? "clickable-row" : "");
const onRowClick = (row: EquipmentRow) => {
if (!row?.id) return;
router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: row.id } });
};
const saveForm = async () => { const saveForm = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (isFormReadOnly.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await formRef.value?.validate().catch(() => false); const ok = await formRef.value?.validate().catch(() => false);
if (!ok) return; if (!ok) return;
const payload = { const payload = {
@@ -314,28 +327,37 @@ const saveForm = async () => {
unit: form.unit.trim() || null, unit: form.unit.trim() || null,
brand: form.brand.trim(), brand: form.brand.trim(),
origin: form.origin.trim() || null, origin: form.origin.trim() || null,
production_permit_file_name: form.productionPermitFileName || null,
tech_index_file_name: form.techIndexFileName || null,
need_calibration: !!form.needCalibration, need_calibration: !!form.needCalibration,
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null, calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
}; };
saving.value = true;
try { try {
let equipmentId = editingId.value;
if (editingId.value) { if (editingId.value) {
await updateMaterialEquipment(studyId, editingId.value, payload); await updateMaterialEquipment(studyId, editingId.value, payload);
} else { } else {
await createMaterialEquipment(studyId, payload); const { data } = (await createMaterialEquipment(studyId, payload)) as any;
equipmentId = data?.id || "";
} }
await attachmentPanelRef.value?.uploadPending(equipmentId);
await loadRows(); await loadRows();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = false; drawerVisible.value = false;
ElMessage.success("保存成功"); ElMessage.success("保存成功");
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed); ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
} }
}; };
const removeRow = async (row: EquipmentRow) => { const removeRow = async (row: EquipmentRow) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (!canDelete.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null); const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
@@ -347,18 +369,6 @@ const removeRow = async (row: EquipmentRow) => {
} }
}; };
const handleUploadChange = (file: UploadFile, field: "productionPermitFileName" | "techIndexFileName") => {
form[field] = file.name;
};
const onProductionPermitChange = (file: UploadFile) => {
handleUploadChange(file, "productionPermitFileName");
};
const onTechIndexChange = (file: UploadFile) => {
handleUploadChange(file, "techIndexFileName");
};
const handleSearch = () => { const handleSearch = () => {
loadRows(); loadRows();
}; };
@@ -406,10 +416,18 @@ watch(
} }
/* ========== 抽屉头部 ========== */ /* ========== 抽屉头部 ========== */
:deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.equipment-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px;
}
.editor-header { .editor-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
} }
.editor-title { .editor-title {
@@ -419,15 +437,9 @@ watch(
line-height: 1.2; line-height: 1.2;
} }
.editor-subtitle {
font-size: 13px;
color: var(--ctms-text-secondary);
font-weight: 400;
}
/* ========== 表单整体 ========== */ /* ========== 表单整体 ========== */
.equipment-form { .equipment-form {
padding: 4px 4px 0 0; padding: 0 4px 0 0;
} }
/* ========== 分组卡片 ========== */ /* ========== 分组卡片 ========== */
@@ -469,7 +481,7 @@ watch(
background: #3b82f6; background: #3b82f6;
} }
/* 资质文件 - 琥珀色 */ /* 件 - 琥珀色 */
.group-dot-file { .group-dot-file {
background: #f0ad2c; background: #f0ad2c;
} }
@@ -479,74 +491,6 @@ watch(
background: #22c55e; background: #22c55e;
} }
/* ========== 上传卡片 ========== */
.upload-card {
border: 1px dashed #d0dced;
border-radius: 8px;
padding: 12px 14px;
background: #ffffff;
transition: all 0.2s ease;
min-height: 60px;
}
.upload-card:hover {
border-color: var(--ctms-primary);
background: #f8faff;
}
.upload-card-label {
font-size: 12px;
font-weight: 600;
color: #4a6283;
margin-bottom: 8px;
}
.upload-trigger {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 0;
}
.upload-icon {
font-size: 18px;
line-height: 1;
}
.upload-text {
font-size: 13px;
color: var(--ctms-text-secondary);
transition: color 0.2s ease;
}
.upload-trigger:hover .upload-text {
color: var(--ctms-primary);
}
.upload-result {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
}
.upload-result-icon {
font-size: 14px;
line-height: 1;
}
.upload-result-name {
font-size: 12px;
color: var(--ctms-text-regular);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
/* ========== 校准提示 ========== */ /* ========== 校准提示 ========== */
.calibration-hint { .calibration-hint {
display: flex; display: flex;
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./StartupMeetingAuth.vue"), "utf8");
describe("StartupMeetingAuth permissions", () => {
it("does not load project members unless the role can read project members", () => {
const source = readSource();
expect(source).toContain('can("project.members.list")');
expect(source).toContain("if (canReadMembers.value)");
});
it("does not create kickoff records from row click unless startup auth create permission is granted", () => {
const source = readSource();
expect(source).toContain('can("startup.auth.create")');
expect(source).toContain("canCreateAuth");
expect(source).toContain("if (!canCreateAuth.value)");
});
});
+18 -5
View File
@@ -45,17 +45,22 @@ import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users"; import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission();
const kickoffItems = ref<any[]>([]); const kickoffItems = ref<any[]>([]);
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const users = ref<any[]>([]); const users = ref<any[]>([]);
const members = ref<any[]>([]); const members = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const isAdmin = computed(() => auth.user?.is_admin); const isAdmin = computed(() => isSystemAdmin(auth.user));
const canReadMembers = computed(() => can("project.members.list"));
const canCreateAuth = computed(() => can("startup.auth.create"));
const memberNameMap = computed(() => { const memberNameMap = computed(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
@@ -85,7 +90,7 @@ const contactLabel = (row: any) => {
.split(",") .split(",")
.map((c) => c.trim()) .map((c) => c.trim())
.filter(Boolean) .filter(Boolean)
.map((c) => memberNameMap.value[c] || TEXT.common.fallback) .map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback; .join("、") || TEXT.common.fallback;
}; };
@@ -118,15 +123,19 @@ const loadKickoffs = async () => {
const requests = [ const requests = [
fetchSites(studyId, { limit: 500 }), fetchSites(studyId, { limit: 500 }),
listKickoffs(studyId), listKickoffs(studyId),
listMembers(studyId, { limit: 500 }),
]; ];
if (canReadMembers.value) {
requests.push(listMembers(studyId, { limit: 500 }));
}
if (isAdmin.value) { if (isAdmin.value) {
requests.push(fetchUsers({ limit: 500 })); requests.push(fetchUsers({ limit: 500 }));
} }
const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all(requests) as any[]; const [sitesResp, kickoffResp, ...optionalResponses] = await Promise.all(requests) as any[];
sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || []; sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || [];
kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || []; kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || [];
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || []; const membersResp = canReadMembers.value ? optionalResponses.shift() : null;
members.value = membersResp?.data ? (Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || []) : [];
const usersResp = isAdmin.value ? optionalResponses.shift() : null;
users.value = usersResp?.data?.items || []; users.value = usersResp?.data?.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -147,6 +156,10 @@ const onKickoffRowClick = async (row: any) => {
goKickoffDetail(row.meeting_id); goKickoffDetail(row.meeting_id);
return; return;
} }
if (!canCreateAuth.value) {
ElMessage.warning("权限不足");
return;
}
try { try {
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any; const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any;
goKickoffDetail(data.id); goKickoffDetail(data.id);
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentDetail.vue"), "utf8");
describe("MaterialEquipmentDetail edit drawer", () => {
it("shows an edit action and opens the equipment editor drawer on the detail page", () => {
const source = readSource();
expect(source).toContain("<MaterialEquipmentEditorDrawer");
expect(source).toContain('v-if="canUpdateEquipment"');
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).toContain('"material_equipments:update"');
});
it("places attachments below calibration settings and renders them as one attachment table", () => {
const source = readSource();
const calibrationIndex = source.indexOf("校准设置");
const qualificationIndex = source.indexOf("TEXT.common.labels.attachments");
expect(calibrationIndex).toBeGreaterThan(-1);
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
expect(source).toContain("<AttachmentList");
expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':hide-uploader="true"');
expect(source).toContain(':refresh-key="attachmentRefreshKey"');
expect(source).toContain("attachmentRefreshKey.value += 1");
expect(source).not.toContain("fetchAttachments");
expect(source).not.toContain("deleteAttachment");
expect(source).not.toContain("formatFileSize");
expect(source).not.toContain("qualificationAttachments");
expect(source).not.toContain('class="attachment-table qualification-table"');
expect(source).not.toContain("previewVisible");
expect(source).not.toContain("getDownloadUrl");
expect(source).not.toContain("removeAttachment");
expect(source).toContain("material_equipment");
expect(source).not.toContain("material_equipment_production_permit");
expect(source).not.toContain("material_equipment_tech_index");
expect(source).not.toContain("资质文件");
expect(source).not.toContain("生产许可证");
expect(source).not.toContain("技术指标");
expect(source).not.toContain('label="生产许可证">{{ displayValue(detail.production_permit_file_name) }}');
expect(source).not.toContain('label="技术指标">{{ displayValue(detail.tech_index_file_name) }}');
});
});
@@ -0,0 +1,190 @@
<template>
<div class="page">
<div v-if="study.currentStudy" class="unified-shell">
<section v-loading="loading" class="unified-section">
<div class="unified-section-header">
<div>
<div class="ctms-section-title">{{ TEXT.modules.materialEquipment.detailTitle }}</div>
<div class="detail-subtitle">{{ TEXT.modules.materialEquipment.detailSubtitle }}</div>
</div>
<div class="actions">
<el-button v-if="canUpdateEquipment" type="primary" @click="goEdit" class="header-action-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
</div>
<div class="section-title-row">
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
<el-tag :type="detail.need_calibration ? 'warning' : 'info'" effect="plain" round>
{{ detail.need_calibration ? "需要校准" : "无需校准" }}
</el-tag>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="设备名称">{{ displayValue(detail.name) }}</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ displayValue(detail.spec_model) }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ displayValue(detail.unit) }}</el-descriptions-item>
<el-descriptions-item label="品牌">{{ displayValue(detail.brand) }}</el-descriptions-item>
<el-descriptions-item label="产地">{{ displayValue(detail.origin) }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section">
<div class="ctms-section-title section-title-inline">校准设置</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="是否需要校准">
{{ detail.need_calibration ? TEXT.common.labels.yes : TEXT.common.labels.no }}
</el-descriptions-item>
<el-descriptions-item label="校准周期(天)">
{{ detail.need_calibration ? displayValue(detail.calibration_cycle_days) : TEXT.common.fallback }}
</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section">
<div class="ctms-section-title section-title-inline">{{ TEXT.common.labels.attachments }}</div>
<AttachmentList
v-if="equipmentId && studyId"
:study-id="studyId"
entity-type="material_equipment"
:entity-id="equipmentId"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</section>
<MaterialEquipmentEditorDrawer
v-model="editorVisible"
:equipment-id="equipmentId || ''"
:readonly="!canUpdateEquipment"
@saved="handleEditorSaved"
/>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import { getMaterialEquipment } from "../../api/materialEquipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import MaterialEquipmentEditorDrawer from "./MaterialEquipmentEditorDrawer.vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
const route = useRoute();
const auth = useAuthStore();
const study = useStudyStore();
const loading = ref(false);
const editorVisible = ref(false);
const attachmentRefreshKey = ref(0);
const equipmentId = computed(() => route.params.equipmentId as string | undefined);
const studyId = computed(() => study.currentStudy?.id || "");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canUpdateEquipment = computed(() => isSystemAdmin(auth.user) || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const detail = reactive({
name: "",
spec_model: "",
unit: "",
brand: "",
origin: "",
need_calibration: false,
calibration_cycle_days: null as number | null,
});
const displayValue = (value?: string | number | null) => {
if (value === null || value === undefined || value === "") return TEXT.common.fallback;
return value;
};
const goEdit = () => {
if (!canUpdateEquipment.value) {
ElMessage.warning("权限不足");
return;
}
editorVisible.value = true;
};
const handleEditorSaved = () => {
load();
attachmentRefreshKey.value += 1;
};
const load = async () => {
if (!studyId.value || !equipmentId.value) return;
loading.value = true;
try {
const { data } = await getMaterialEquipment(studyId.value, equipmentId.value);
Object.assign(detail, {
name: data.name || "",
spec_model: data.spec_model || "",
unit: data.unit || "",
brand: data.brand || "",
origin: data.origin || "",
need_calibration: !!data.need_calibration,
calibration_cycle_days: data.calibration_cycle_days ?? null,
});
study.setViewContext({
pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
watch(
() => [studyId.value, equipmentId.value],
() => {
load();
}
);
onMounted(load);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.detail-subtitle {
margin-top: 4px;
color: var(--ctms-text-secondary);
font-size: 13px;
}
.section-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
}
.section-title-inline {
margin-bottom: 12px;
}
</style>
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentEditorDrawer.vue"), "utf8");
describe("MaterialEquipmentEditorDrawer attachments", () => {
it("queues qualification files and uploads them as business attachments on save", () => {
const source = readSource();
const calibrationIndex = source.indexOf("校准设置");
const qualificationIndex = source.indexOf("附件");
expect(calibrationIndex).toBeGreaterThan(-1);
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.equipmentId)");
expect(source).toContain("uploadPending");
expect(source).toContain("material_equipment");
expect(source).not.toContain("uploadAttachment");
expect(source).not.toContain("pendingFiles");
expect(source).not.toContain("qualificationRows");
expect(source).not.toContain("material_equipment_production_permit");
expect(source).not.toContain("material_equipment_tech_index");
expect(source).not.toContain("资质文件");
expect(source).not.toContain("生产许可证");
expect(source).not.toContain("技术指标");
expect(source).not.toContain("handleUploadChange");
});
});
@@ -0,0 +1,344 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="equipment-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">编辑设备</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="equipment-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
基本信息
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" :disabled="readonly" placeholder="请输入设备名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="specModel">
<el-input v-model="form.specModel" :disabled="readonly" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" :disabled="readonly" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="品牌" prop="brand">
<el-input v-model="form.brand" :disabled="readonly" placeholder="请输入品牌" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="产地" prop="origin">
<el-input v-model="form.origin" :disabled="readonly" placeholder="请输入产地" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-calibration"></span>
校准设置
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="是否需要校准" prop="needCalibration">
<el-select v-model="form.needCalibration" :disabled="readonly" placeholder="请选择" class="full-width">
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="form.needCalibration" :span="12">
<el-form-item label="校准周期(天)" prop="calibrationCycleDays">
<el-input-number v-model="form.calibrationCycleDays" :disabled="readonly" :min="1" :precision="0" class="full-width" />
</el-form-item>
</el-col>
</el-row>
<div v-if="!form.needCalibration" class="calibration-hint">该设备无需定期校准</div>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-file"></span>
附件
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="material_equipment"
:entity-id="equipmentId"
:mode="'upload'"
:readonly="readonly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="readonly" @click="saveForm">保存</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 { getMaterialEquipment, updateMaterialEquipment } from "../../api/materialEquipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { useStudyStore } from "../../store/study";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
type FormModel = {
name: string;
specModel: string;
unit: string;
brand: string;
origin: string;
needCalibration: boolean;
calibrationCycleDays: number | null;
};
const props = defineProps<{ modelValue: boolean; equipmentId: string; readonly?: boolean }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "saved"): void;
}>();
const study = useStudyStore();
const formRef = ref<FormInstance>();
const saving = ref(false);
const readonly = ref(false);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive<FormModel>({
name: "",
specModel: "",
unit: "",
brand: "",
origin: "",
needCalibration: true,
calibrationCycleDays: 30,
});
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
specModel: [{ required: true, message: "请输入规格型号", trigger: "blur" }],
brand: [{ required: true, message: "请输入品牌", trigger: "blur" }],
needCalibration: [{ required: true, message: "请选择是否需要校准", trigger: "change" }],
calibrationCycleDays: [
{
validator: (_rule, value, callback) => {
if (form.needCalibration && (!value || value < 1)) {
callback(new Error("请填写校准周期"));
return;
}
callback();
},
trigger: "change",
},
],
};
const load = async () => {
if (!studyId.value || !props.equipmentId) return;
readonly.value = !!props.readonly;
try {
const { data } = await getMaterialEquipment(studyId.value, props.equipmentId);
Object.assign(form, {
name: data.name || "",
specModel: data.spec_model || "",
unit: data.unit || "",
brand: data.brand || "",
origin: data.origin || "",
needCalibration: !!data.need_calibration,
calibrationCycleDays: data.calibration_cycle_days ?? null,
});
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
}
};
const saveForm = async () => {
if (!studyId.value || !props.equipmentId) return;
if (readonly.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await formRef.value?.validate().catch(() => false);
if (!ok) return;
saving.value = true;
try {
await updateMaterialEquipment(studyId.value, props.equipmentId, {
name: form.name.trim(),
spec_model: form.specModel.trim(),
unit: form.unit.trim() || null,
brand: form.brand.trim(),
origin: form.origin.trim() || null,
need_calibration: !!form.needCalibration,
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
});
await attachmentPanelRef.value?.uploadPending(props.equipmentId);
drawerDirtyGuard.syncBaseline();
emit("saved");
emit("update:modelValue", false);
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => [props.modelValue, props.equipmentId, props.readonly] as const,
([visible]) => {
readonly.value = !!props.readonly;
if (visible) load();
},
{ immediate: true }
);
watch(
() => form.needCalibration,
(need) => {
if (!need) form.calibrationCycleDays = null;
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
}
);
</script>
<style scoped>
:deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.equipment-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;
}
.equipment-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;
}
.group-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.group-dot-basic {
background: #3b82f6;
}
.group-dot-file {
background: #f0ad2c;
}
.group-dot-calibration {
background: #22c55e;
}
.calibration-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 13px;
color: #166534;
margin-bottom: 8px;
}
.full-width {
width: 100%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.equipment-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.equipment-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
padding-bottom: 4px;
}
</style>
+116 -252
View File
@@ -6,16 +6,13 @@
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div> <div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div>
<div class="ctms-section-actions"> <div class="ctms-section-actions">
<el-button <el-button
v-if="!editMode" v-if="canUpdateAuth"
type="primary" type="primary"
:disabled="loading || isReadOnly" :disabled="loading || isReadOnly"
@click="startEdit" @click="startEdit"
> >
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
@@ -29,14 +26,7 @@
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag> <el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.kickoffDate"> <el-descriptions-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker {{ displayDate(detail.kickoff_date) }}
v-if="editMode"
v-model="draft.kickoff_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
/>
<span v-else>{{ displayDate(detail.kickoff_date) }}</span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</section> </section>
@@ -44,116 +34,96 @@
<section class="unified-section training-section"> <section class="unified-section training-section">
<div class="training-header"> <div class="training-header">
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div> <div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
<el-button v-if="canCreateAuth" type="primary" size="small" :disabled="isReadOnly" @click="startTrainingAdd">
{{ TEXT.common.actions.add }}
</el-button>
</div> </div>
<el-table :data="trainingRows" v-loading="loadingTraining" style="width: 100%" class="ctms-table" table-layout="fixed"> <el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%" class="ctms-table" table-layout="fixed">
<el-table-column prop="name" :label="TEXT.common.fields.name" show-overflow-tooltip> <el-table-column prop="name" :label="TEXT.common.fields.name" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.name" size="small" /> {{ scope.row.name || TEXT.common.fallback }}
<span v-else>{{ scope.row.name || TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip> <el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.role" size="small" /> {{ scope.row.role || TEXT.common.fallback }}
<span v-else>{{ scope.row.role || TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.trained"> <el-table-column :label="TEXT.common.fields.trained">
<template #default="scope"> <template #default="scope">
<el-checkbox <div class="status-with-date">
v-if="isTrainingEditing(scope.row)" <span>{{ trainingStatusLabel(scope.row) }}</span>
v-model="trainingRowDraft.trained" <span v-if="scope.row.trained && scope.row.trained_date" class="status-date-small">
:disabled="isReadOnly" {{ displayDate(scope.row.trained_date) }}
@click.stop </span>
/> </div>
<el-checkbox
v-else
v-model="scope.row.trained"
:disabled="isReadOnly"
@change="toggleTraining(scope.row)"
@click.stop
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.authorized"> <el-table-column :label="TEXT.common.fields.authorized">
<template #default="scope"> <template #default="scope">
<el-checkbox <div class="status-with-date">
v-if="isTrainingEditing(scope.row)" <span>{{ authorizationStatusLabel(scope.row) }}</span>
v-model="trainingRowDraft.authorized" <span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date-small">
:disabled="isReadOnly" {{ displayDate(scope.row.authorized_date) }}
@click.stop </span>
/> </div>
<el-checkbox
v-else
v-model="scope.row.authorized"
:disabled="isReadOnly"
@change="toggleTraining(scope.row)"
@click.stop
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="140"> <el-table-column v-if="canCreateAuth || canUpdateAuth || canDeleteAuth" :label="TEXT.common.labels.actions" width="140">
<template #default="scope"> <template #default="scope">
<template v-if="isTrainingEditing(scope.row)"> <el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
<el-button link type="primary" size="small" :loading="savingTraining" @click.stop="saveTraining(scope.row)">
{{ TEXT.common.actions.save }}
</el-button>
<el-button link size="small" :disabled="savingTraining" @click.stop="cancelTrainingEdit">
{{ TEXT.common.actions.cancel }}
</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
<el-button link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)"> <el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
</el-button> </el-button>
</template> </template>
</template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div
class="training-add-row"
:class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
@click="startTrainingAdd"
>
<span class="training-add-icon">+</span>
</div>
<StateEmpty
v-if="!loadingTraining && trainingItems.length === 0 && !newTrainingActive"
:description="TEXT.modules.startupMeetingAuth.emptyTraining"
/>
</section> </section>
<section v-if="meetingId" class="unified-section attachment-section"> <section v-if="meetingId" class="unified-section attachment-section">
<div class="attachment-grid">
<AttachmentList <AttachmentList
v-for="group in kickoffAttachmentGroups"
:key="group.entityType"
:study-id="studyId" :study-id="studyId"
:entity-type="group.entityType" entity-type="startup_kickoff"
:entity-id="meetingId" :entity-id="meetingId"
:title="group.title" :entity-groups="kickoffAttachmentGroups"
:title="TEXT.common.labels.attachments"
:readonly="true"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/> />
</div>
</section> </section>
</div> </div>
<KickoffEditorDrawer
v-model="editorVisible"
:meeting-id="meetingId"
:study-id="studyId"
:site-name="siteInfo.name"
:owner-label="ownerLabel"
:readonly="isReadOnly"
@saved="handleEditorSaved"
/>
<TrainingEditorDrawer
v-model="trainingEditorVisible"
:record-id="editingTrainingId"
:study-id="studyId"
:sites="sites"
:initial-site-name="siteInfo.name"
@saved="handleTrainingEditorSaved"
/>
</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, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { import {
getKickoff, getKickoff,
updateKickoff,
listTrainingAuthorizations, listTrainingAuthorizations,
createTrainingAuthorization,
updateTrainingAuthorization,
deleteTrainingAuthorization, deleteTrainingAuthorization,
} from "../../api/startup"; } from "../../api/startup";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
@@ -161,62 +131,43 @@ import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users"; import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales";
import { TEXT, requiredMessage } from "../../locales"; import { usePermission } from "../../utils/permission";
import KickoffEditorDrawer from "./KickoffEditorDrawer.vue";
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false); const loading = ref(false);
const loadingTraining = ref(false); const loadingTraining = ref(false);
const saving = ref(false); const editorVisible = ref(false);
const editMode = ref(false); const trainingEditorVisible = ref(false);
const editingTrainingId = ref<string | undefined>();
const attachmentRefreshKey = ref(0);
const meetingId = route.params.meetingId as string; const meetingId = route.params.meetingId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" }); const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
const sites = ref<any[]>([]);
const users = ref<any[]>([]); const users = ref<any[]>([]);
const members = ref<any[]>([]); const members = ref<any[]>([]);
const isAdmin = computed(() => auth.user?.is_admin); const isAdmin = computed(() => auth.user?.is_admin);
const canReadMembers = computed(() => can("project.members.list"));
const canCreateAuth = computed(() => can("startup.auth.create"));
const canUpdateAuth = computed(() => can("startup.auth.update"));
const canDeleteAuth = computed(() => can("startup.auth.delete"));
const kickoffAttachmentGroups = [ const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel }, { entityType: "startup_kickoff_minutes", label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel }, { entityType: "startup_kickoff_signin", label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
{ entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel }, { entityType: "startup_kickoff_ppt", label: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
{ entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel }, { entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
]; ];
const detail = reactive<any>({ const detail = reactive<any>({
kickoff_date: "", kickoff_date: "",
attendees: [],
});
const draft = reactive({
kickoff_date: "",
}); });
const trainingItems = ref<any[]>([]); const trainingItems = ref<any[]>([]);
const trainingEditingRowId = ref<string | null>(null);
const newTrainingActive = ref(false);
const savingTraining = ref(false);
const trainingRowDraft = reactive({
name: "",
role: "",
trained: false,
authorized: false,
});
const trainingRows = computed(() => {
if (!newTrainingActive.value) return trainingItems.value;
return [
...trainingItems.value,
{
id: "__new__",
__isNew: true,
name: "",
role: "",
trained: false,
authorized: false,
},
];
});
const memberNameMap = computed(() => { const memberNameMap = computed(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
@@ -247,6 +198,8 @@ const statusLabel = computed(() =>
); );
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info")); const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
const isReadOnly = computed(() => siteInfo.is_active === false); const isReadOnly = computed(() => siteInfo.is_active === false);
const trainingStatusLabel = (row: any) => (row?.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
const authorizationStatusLabel = (row: any) => (row?.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
const load = async () => { const load = async () => {
if (!studyId || !meetingId) return; if (!studyId || !meetingId) return;
@@ -254,17 +207,20 @@ const load = async () => {
try { try {
const { data } = await getKickoff(studyId, meetingId); const { data } = await getKickoff(studyId, meetingId);
Object.assign(detail, data); Object.assign(detail, data);
if (!editMode.value) {
draft.kickoff_date = data.kickoff_date || "";
}
if (data?.site_id) { if (data?.site_id) {
const { data: sitesData } = await fetchSites(studyId, { limit: 500 }); const { data: sitesData } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || []; const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
sites.value = list;
const matched = list.find((site: any) => site.id === data.site_id); const matched = list.find((site: any) => site.id === data.site_id);
Object.assign(siteInfo, matched || { name: "" }); Object.assign(siteInfo, matched || { name: "" });
} else { } else {
Object.assign(siteInfo, { name: "" }); Object.assign(siteInfo, { name: "" });
} }
const siteName = siteInfo.name || TEXT.common.fallback;
study.setViewContext({
siteName,
pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle,
});
} 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 {
@@ -273,10 +229,10 @@ const load = async () => {
}; };
const loadTraining = async () => { const loadTraining = async () => {
if (!studyId) return; if (!studyId || !siteInfo.name) return;
loadingTraining.value = true; loadingTraining.value = true;
try { try {
const { data } = await listTrainingAuthorizations(studyId); const { data } = await listTrainingAuthorizations(studyId, { site_name: siteInfo.name });
trainingItems.value = Array.isArray(data) ? data : data.items || []; trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -285,88 +241,24 @@ const loadTraining = async () => {
} }
}; };
const toggleTraining = async (row: any) => { const startTrainingEdit = (row: any) => {
if (isReadOnly.value) { if (isReadOnly.value) return;
ElMessage.warning("中心已停用"); if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return; return;
} }
if (!studyId) return; editingTrainingId.value = row?.id || undefined;
try { trainingEditorVisible.value = true;
await updateTrainingAuthorization(studyId, row.id, {
trained: row.trained,
authorized: row.authorized,
});
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
loadTraining();
}
};
const resetTrainingDraft = (row?: any) => {
trainingRowDraft.name = row?.name || "";
trainingRowDraft.role = row?.role || "";
trainingRowDraft.trained = !!row?.trained;
trainingRowDraft.authorized = !!row?.authorized;
};
const isTrainingEditing = (row: any) => {
if (row?.__isNew) return newTrainingActive.value;
return trainingEditingRowId.value === row?.id;
};
const startTrainingEdit = (row: any) => {
if (savingTraining.value || isReadOnly.value) return;
newTrainingActive.value = false;
trainingEditingRowId.value = row?.id || null;
resetTrainingDraft(row);
}; };
const startTrainingAdd = () => { const startTrainingAdd = () => {
if (newTrainingActive.value || savingTraining.value || isReadOnly.value) return; if (isReadOnly.value) return;
trainingEditingRowId.value = null; if (!canCreateAuth.value) {
newTrainingActive.value = true; ElMessage.warning("权限不足");
resetTrainingDraft();
};
const cancelTrainingEdit = () => {
trainingEditingRowId.value = null;
newTrainingActive.value = false;
resetTrainingDraft();
};
const saveTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return; return;
} }
if (!studyId) return; editingTrainingId.value = undefined;
if (!trainingRowDraft.name.trim()) { trainingEditorVisible.value = true;
ElMessage.error(requiredMessage(TEXT.common.fields.name));
return;
}
savingTraining.value = true;
try {
const payload = {
name: trainingRowDraft.name.trim(),
role: (trainingRowDraft.role || "").trim() || null,
trained: trainingRowDraft.trained,
authorized: trainingRowDraft.authorized,
};
if (row?.__isNew) {
await createTrainingAuthorization(studyId, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
} else {
await updateTrainingAuthorization(studyId, row.id, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
}
cancelTrainingEdit();
loadTraining();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
savingTraining.value = false;
}
}; };
const removeTraining = async (row: any) => { const removeTraining = async (row: any) => {
@@ -374,6 +266,10 @@ const removeTraining = async (row: any) => {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
} }
if (!canDeleteAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
@@ -391,43 +287,29 @@ const startEdit = () => {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
} }
editMode.value = true; if (!canUpdateAuth.value) {
draft.kickoff_date = detail.kickoff_date || ""; ElMessage.warning("权限不足");
};
const cancelEdit = () => {
editMode.value = false;
draft.kickoff_date = detail.kickoff_date || "";
};
const saveEdit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return; return;
} }
if (!studyId || !meetingId) return; editorVisible.value = true;
saving.value = true;
try {
const payload = {
kickoff_date: draft.kickoff_date || null,
};
const { data } = await updateKickoff(studyId, meetingId, payload);
Object.assign(detail, data);
editMode.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
}; };
const goBack = () => router.push("/startup/meeting-auth"); const handleEditorSaved = () => {
load();
attachmentRefreshKey.value += 1;
};
const handleTrainingEditorSaved = () => {
loadTraining();
};
onMounted(async () => { onMounted(async () => {
if (studyId) { if (studyId) {
const membersReq = listMembers(studyId, { limit: 500 });
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null); const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
let membersReq: Promise<any> = Promise.resolve(null);
if (canReadMembers.value) {
membersReq = listMembers(studyId, { limit: 500 });
}
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const); const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
if (membersResp?.data) { if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || []; members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
@@ -437,7 +319,7 @@ onMounted(async () => {
} }
} }
await load(); await load();
loadTraining(); await loadTraining();
}); });
</script> </script>
@@ -461,6 +343,7 @@ onMounted(async () => {
.training-header { .training-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
margin-bottom: 12px; margin-bottom: 12px;
} }
@@ -470,34 +353,15 @@ onMounted(async () => {
color: var(--ctms-text-main); color: var(--ctms-text-main);
} }
.training-add-row { .status-with-date {
border: 1px dashed var(--ctms-border-color); display: inline-flex;
border-top: none;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctms-text-secondary);
cursor: pointer;
}
.training-add-row:hover {
color: var(--ctms-text-main);
}
.training-add-row.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.training-add-icon {
font-size: 20px;
line-height: 1;
}
.attachment-grid {
display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 2px;
line-height: 1.2;
}
.status-date-small {
font-size: 12px;
color: var(--ctms-text-secondary);
} }
</style> </style>
@@ -0,0 +1,304 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="640px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="startup-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ TEXT.modules.startupMeetingAuth.kickoffEditTitle }}</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" label-position="top" class="startup-editor-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site">
<el-input :model-value="siteName || TEXT.common.fallback" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
<el-input :model-value="ownerLabel || TEXT.common.fallback" disabled />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker
v-model="form.kickoff_date"
:disabled="isFormReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
/>
</el-form-item>
<div v-if="readonly" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
<div v-if="meetingId" class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="startup_kickoff"
:entity-id="meetingId"
:entity-groups="kickoffAttachmentGroups"
: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 } from "element-plus";
import { getKickoff, updateKickoff } from "../../api/startup";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
const props = defineProps<{
modelValue: boolean;
meetingId: string;
studyId: string;
siteName?: string;
ownerLabel?: string;
readonly?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
saved: [meetingId: string];
}>();
const { can } = usePermission();
const canUpdateAuth = computed(() => can("startup.auth.update"));
const drawerLoading = ref(false);
const drawerErrorMessage = ref("");
const saving = ref(false);
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const form = reactive({
kickoff_date: "",
});
const isFormReadOnly = computed(() => !canUpdateAuth.value || !!props.readonly);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const kickoffAttachmentGroups = [
{ label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel, entityType: "startup_kickoff_minutes" },
{ label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel, entityType: "startup_kickoff_signin" },
{ label: TEXT.modules.startupMeetingAuth.kickoffPptLabel, entityType: "startup_kickoff_ppt" },
{ label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel, entityType: "startup_kickoff" },
];
const resetForm = () => {
form.kickoff_date = "";
drawerErrorMessage.value = "";
formRef.value?.clearValidate();
};
const loadEditingDetail = async () => {
if (!props.studyId || !props.meetingId) return;
drawerLoading.value = true;
drawerErrorMessage.value = "";
try {
const { data } = await getKickoff(props.studyId, props.meetingId);
form.kickoff_date = data.kickoff_date || "";
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
drawerErrorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
drawerLoading.value = false;
}
};
const reloadEditingDetail = () => {
loadEditingDetail();
};
const saveForm = async () => {
if (!props.studyId || !props.meetingId) return;
if (isFormReadOnly.value) {
ElMessage.warning(props.readonly ? "中心已停用" : "权限不足");
return;
}
saving.value = true;
try {
await updateKickoff(props.studyId, props.meetingId, {
kickoff_date: form.kickoff_date || null,
});
await attachmentPanelRef.value?.uploadPending(props.meetingId);
drawerDirtyGuard.syncBaseline();
emit("update:modelValue", false);
emit("saved", props.meetingId);
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => props.modelValue,
async (visible) => {
if (!visible) return;
resetForm();
await loadEditingDetail();
},
);
</script>
<style scoped>
:deep(.startup-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.startup-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;
}
.startup-editor-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;
}
.group-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.group-dot-basic {
background: #3b82f6;
}
.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%;
}
.attachment-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 20px 16px;
}
</style>
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readForm = () => readFileSync(resolve(__dirname, "./KickoffForm.vue"), "utf8");
const readDetail = () => readFileSync(resolve(__dirname, "./KickoffDetail.vue"), "utf8");
describe("Kickoff member permissions", () => {
it("does not load project members in the form unless the role can read project members", () => {
const source = readForm();
expect(source).toContain('can("project.members.list")');
expect(source).toContain("if (canReadMembers.value)");
});
it("does not load project members in the detail page unless the role can read project members", () => {
const source = readDetail();
expect(source).toContain('can("project.members.list")');
expect(source).toContain("if (canReadMembers.value)");
});
});
@@ -0,0 +1,76 @@
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("startup auth editor drawer attachments", () => {
it("uses upload cards for kickoff attachments instead of full attachment tables", () => {
const source = read("./KickoffEditorDrawer.vue");
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.meetingId)");
expect(source).toContain('entityType: "startup_kickoff_minutes"');
expect(source).toContain('entityType: "startup_kickoff_signin"');
expect(source).toContain('entityType: "startup_kickoff_ppt"');
expect(source).toContain('entityType: "startup_kickoff"');
expect(source).not.toContain("PendingAttachmentUpload");
expect(source).not.toContain("attendees");
expect(source).not.toContain("parseAttendees");
expect(source).not.toContain("attendeesPlaceholder");
});
it("does not expose attachments in the training authorization editor", () => {
const source = read("./TrainingEditorDrawer.vue");
expect(source).not.toContain("PendingAttachmentUpload");
expect(source).not.toContain("AttachmentList");
expect(source).not.toContain("training_authorization");
expect(source).not.toContain("attachments:");
expect(source).not.toContain("uploadPending");
});
it("supports batch creating and single-row updating training authorizations in the same drawer", () => {
const source = read("./TrainingEditorDrawer.vue");
expect(source).toContain("recordId?: string");
expect(source).toContain('editorTitle');
expect(source).toContain("createTrainingAuthorization");
expect(source).toContain("updateTrainingAuthorization");
expect(source).toContain("const isCreateMode = computed(() => !props.recordId)");
expect(source).toContain("const canCreateAuth = computed(() => can(\"startup.auth.create\"))");
expect(source).toContain("const canSaveAuth = computed(() => (isCreateMode.value ? canCreateAuth.value : canUpdateAuth.value))");
expect(source).toContain("batchRows");
expect(source).toContain("addBatchRow");
expect(source).toContain("removeBatchRow");
expect(source).toContain("for (const row of batchRows.value)");
});
it("keeps training authorization editor compact and hides site input while preserving site context", () => {
const source = read("./TrainingEditorDrawer.vue");
expect(source).not.toContain(':label="TEXT.common.fields.site"');
expect(source).not.toContain('prop="site_name"');
expect(source).toContain("site_name: props.initialSiteName || form.site_name || null");
expect(source).toContain('class="compact-row-grid"');
expect(source).toContain('class="compact-field compact-field--name"');
expect(source).toContain('class="compact-field compact-field--status"');
expect(source).toContain('class="compact-field compact-field--remark"');
});
it("uses a single-layer batch editor layout for training authorization creation", () => {
const source = read("./TrainingEditorDrawer.vue");
expect(source).toContain('class="form-section training-batch-editor"');
expect(source).toContain('class="batch-editor"');
expect(source).toContain('class="batch-entry"');
expect(source).toContain('class="status-inline"');
expect(source).toContain('class="status-date"');
expect(source).not.toContain('class="batch-row"');
expect(source).not.toContain(".batch-row {");
});
});
@@ -0,0 +1,125 @@
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("startup auth form/detail permissions", () => {
it("maps startup auth actions to backend operation permissions", () => {
const source = read("../../utils/permission.ts");
expect(source).toContain('"startup.auth.create": "startup_auth:create"');
expect(source).toContain('"startup.auth.update": "startup_auth:update"');
expect(source).toContain('"startup.auth.delete": "startup_auth:delete"');
});
it("uses create/update permissions in kickoff and training forms", () => {
const kickoffForm = read("./KickoffForm.vue");
const trainingForm = read("./TrainingForm.vue");
expect(kickoffForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
expect(kickoffForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
expect(kickoffForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
expect(kickoffForm).toContain("if (!canSaveAuth.value)");
expect(trainingForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
expect(trainingForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
expect(trainingForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
expect(trainingForm).toContain("if (!canSaveAuth.value)");
});
it("opens kickoff detail edits in a drawer and keeps training actions split by backend operations", () => {
const source = read("./KickoffDetail.vue");
expect(source).toContain("<KickoffEditorDrawer");
expect(source).toContain("<TrainingEditorDrawer");
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("trainingEditorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
expect(source).toContain("attachmentRefreshKey.value += 1");
expect(source).toContain("const handleTrainingEditorSaved = () =>");
expect(source).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
expect(source).toContain('const canDeleteAuth = computed(() => can("startup.auth.delete"))');
expect(source).toContain('v-if="canUpdateAuth"');
expect(source).toContain('v-if="canCreateAuth || canUpdateAuth || canDeleteAuth"');
expect(source).toContain("if (!canCreateAuth.value)");
expect(source).toContain("if (!canUpdateAuth.value)");
expect(source).toContain("if (!canDeleteAuth.value)");
expect(source).not.toContain("trainingRowDraft");
expect(source).not.toContain("isTrainingEditing");
expect(source).not.toContain("saveTraining");
expect(source).not.toContain("cancelTrainingEdit");
expect(source).not.toContain("editMode");
expect(source).not.toContain("saveEdit");
});
it("opens training detail edits in a drawer without navigating to the old edit page", () => {
const source = read("./TrainingDetail.vue");
expect(source).toContain("<TrainingEditorDrawer");
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
expect(source).toContain('v-if="canUpdateAuth"');
expect(source).toContain("if (!canUpdateAuth.value)");
expect(source).not.toContain("AttachmentList");
expect(source).not.toContain("training_authorization");
expect(source).not.toContain("attachmentRefreshKey");
expect(source).not.toContain("router.push(`/startup/training/${recordId}/edit`)");
});
it("renders kickoff attachments as one aggregated table with attachment type labels", () => {
const source = read("./KickoffDetail.vue");
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
expect(source).not.toContain("v-for=\"group in kickoffAttachmentGroups\"");
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel');
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel');
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffPptLabel');
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel');
});
it("allows legacy kickoff and training forms to queue attachments before saving", () => {
const kickoffForm = read("./KickoffForm.vue");
const trainingForm = read("./TrainingForm.vue");
expect(kickoffForm).toContain("<AttachmentList");
expect(kickoffForm).toContain('ref="attachmentPanelRef"');
expect(kickoffForm).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
expect(kickoffForm).toContain(':entity-id="meetingId || \'\'"');
expect(kickoffForm).toContain(':mode="\'upload\'"');
expect(kickoffForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(kickoffForm).not.toContain('v-for="group in kickoffAttachmentGroups"');
expect(kickoffForm).not.toContain("StateEmpty");
expect(trainingForm).toContain("<AttachmentList");
expect(trainingForm).toContain('ref="attachmentPanelRef"');
expect(trainingForm).toContain('entity-type="training_authorization"');
expect(trainingForm).toContain(':entity-id="recordId || \'\'"');
expect(trainingForm).toContain(':mode="\'upload\'"');
expect(trainingForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(trainingForm).not.toContain('v-if="isEdit && recordId"');
expect(trainingForm).not.toContain("StateEmpty");
});
it("shows training and authorization dates under positive status in the training table", () => {
const source = read("./KickoffDetail.vue");
expect(source).toContain("trainingStatusLabel(scope.row)");
expect(source).toContain("authorizationStatusLabel(scope.row)");
expect(source).toContain("scope.row.trained && scope.row.trained_date");
expect(source).toContain("scope.row.authorized && scope.row.authorized_date");
expect(source).toContain('class="status-date-small"');
expect(source).not.toContain("scope.row.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
expect(source).not.toContain("scope.row.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
});
it("loads training authorizations for the current kickoff site only", () => {
const source = read("./KickoffDetail.vue");
expect(source).toContain("if (!studyId || !siteInfo.name) return;");
expect(source).toContain("listTrainingAuthorizations(studyId, { site_name: siteInfo.name })");
expect(source).not.toContain("listTrainingAuthorizations(studyId);");
});
});
+30 -18
View File
@@ -5,8 +5,7 @@
<div class="unified-section-header"> <div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div> <div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
<div class="ctms-section-actions"> <div class="ctms-section-actions">
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <el-button v-if="canUpdateAuth" type="primary" :disabled="isInactiveSite" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div> </div>
</div> </div>
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
@@ -20,34 +19,34 @@
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</section> </section>
<section class="unified-section">
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="training_authorization"
:entity-id="recordId"
:readonly="isReadOnly"
/>
</section>
</div> </div>
<TrainingEditorDrawer
v-model="editorVisible"
:record-id="recordId"
:study-id="studyId"
: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 { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getTrainingAuthorization } from "../../api/startup"; import { getTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false); const loading = ref(false);
const editorVisible = ref(false);
const recordId = route.params.recordId as string; const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
@@ -70,7 +69,9 @@ const siteActiveMap = computed(() => {
}); });
return map; return map;
}); });
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false); const canUpdateAuth = computed(() => can("startup.auth.update"));
const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const isReadOnly = computed(() => !canUpdateAuth.value || isInactiveSite.value);
const loadSites = async () => { const loadSites = async () => {
if (!studyId) return; if (!studyId) return;
@@ -88,6 +89,10 @@ const load = async () => {
try { try {
const { data } = await getTrainingAuthorization(studyId, recordId); const { data } = await getTrainingAuthorization(studyId, recordId);
Object.assign(detail, data); Object.assign(detail, data);
study.setViewContext({
siteName: detail.site_name || TEXT.common.fallback,
pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle,
});
} 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 {
@@ -96,13 +101,20 @@ const load = async () => {
}; };
const goEdit = () => { const goEdit = () => {
if (isReadOnly.value) { if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
} }
router.push(`/startup/training/${recordId}/edit`); editorVisible.value = true;
};
const handleEditorSaved = () => {
load();
}; };
const goBack = () => router.push("/startup/meeting-auth");
onMounted(async () => { onMounted(async () => {
await loadSites(); await loadSites();
@@ -0,0 +1,566 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="640px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="startup-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">
{{ editorTitle }}
</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="startup-editor-form">
<div v-if="isCreateMode" class="form-section training-batch-editor">
<div class="section-title action-title">
<span>
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}
</span>
<el-button type="primary" size="small" :disabled="isFormReadOnly" @click="addBatchRow">
{{ TEXT.common.actions.add }}
</el-button>
</div>
<div class="batch-editor">
<div v-for="(row, index) in batchRows" :key="row.tempId" class="batch-entry">
<div class="batch-entry-header">
<span>{{ index + 1 }}</span>
<el-button
v-if="batchRows.length > 1"
link
type="danger"
:disabled="isFormReadOnly"
@click="removeBatchRow(index)"
>
{{ TEXT.common.actions.remove }}
</el-button>
</div>
<div class="compact-row-grid">
<el-form-item class="compact-field compact-field--name" :label="TEXT.common.fields.name" :error="batchErrors[index]?.name">
<el-input v-model="row.name" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item class="compact-field" :label="TEXT.common.labels.role">
<el-input v-model="row.role" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.trained">
<div class="status-inline">
<el-switch v-model="row.trained" :disabled="isFormReadOnly" />
<el-date-picker
v-model="row.trained_date"
:disabled="isFormReadOnly || !row.trained"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-date"
/>
</div>
</el-form-item>
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.authorized">
<div class="status-inline">
<el-switch v-model="row.authorized" :disabled="isFormReadOnly" />
<el-date-picker
v-model="row.authorized_date"
:disabled="isFormReadOnly || !row.authorized"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-date"
/>
</div>
</el-form-item>
<el-form-item class="compact-field compact-field--remark" :label="TEXT.common.fields.remark">
<el-input v-model="row.remark" :disabled="isFormReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</div>
</div>
</div>
</div>
<div v-else class="form-section">
<div class="section-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}
</div>
<div class="compact-row-grid compact-row-grid--single">
<el-form-item class="compact-field compact-field--name" :label="TEXT.common.fields.name" prop="name">
<el-input v-model="form.name" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item class="compact-field" :label="TEXT.common.labels.role" prop="role">
<el-input v-model="form.role" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.trained">
<div class="status-inline">
<el-switch v-model="form.trained" :disabled="isFormReadOnly" />
<el-date-picker
v-model="form.trained_date"
:disabled="isFormReadOnly || !form.trained"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-date"
/>
</div>
</el-form-item>
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.authorized">
<div class="status-inline">
<el-switch v-model="form.authorized" :disabled="isFormReadOnly" />
<el-date-picker
v-model="form.authorized_date"
:disabled="isFormReadOnly || !form.authorized"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-date"
/>
</div>
</el-form-item>
<el-form-item class="compact-field compact-field--remark" :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>
<div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</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 { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
type TrainingFormModel = {
name: string;
role: string;
site_name: string;
trained: boolean;
authorized: boolean;
trained_date: string;
authorized_date: string;
remark: string;
};
type BatchTrainingFormModel = TrainingFormModel & {
tempId: string;
};
const props = defineProps<{
modelValue: boolean;
recordId?: string;
studyId: string;
sites: any[];
initialSiteName?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
saved: [recordId: string];
}>();
const { can } = usePermission();
const canCreateAuth = computed(() => can("startup.auth.create"));
const canUpdateAuth = computed(() => can("startup.auth.update"));
const isCreateMode = computed(() => !props.recordId);
const canSaveAuth = computed(() => (isCreateMode.value ? canCreateAuth.value : canUpdateAuth.value));
const drawerLoading = ref(false);
const drawerErrorMessage = ref("");
const saving = ref(false);
const formRef = ref<FormInstance>();
const form = reactive<TrainingFormModel>({
name: "",
role: "",
site_name: "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
});
const batchRows = ref<BatchTrainingFormModel[]>([]);
const batchErrors = ref<Record<string, string>[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
props.sites.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const currentSiteName = computed(() => props.initialSiteName || form.site_name || "");
const selectedSiteInactive = computed(() => !!currentSiteName.value && siteActiveMap.value[currentSiteName.value] === false);
const isFormReadOnly = computed(() => !canSaveAuth.value || selectedSiteInactive.value);
const editorTitle = computed(() => (isCreateMode.value ? TEXT.modules.startupMeetingAuth.trainingNewTitle : TEXT.modules.startupMeetingAuth.trainingEditTitle));
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
batchRows: batchRows.value,
}));
const rules: FormRules<TrainingFormModel> = {
name: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
};
const createEmptyBatchRow = (): BatchTrainingFormModel => ({
tempId: `${Date.now()}-${Math.random()}`,
name: "",
role: "",
site_name: props.initialSiteName || "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
});
const resetForm = () => {
Object.assign(form, {
name: "",
role: "",
site_name: props.initialSiteName || "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
});
batchRows.value = [createEmptyBatchRow()];
batchErrors.value = [];
drawerErrorMessage.value = "";
formRef.value?.clearValidate();
};
const loadEditingDetail = async () => {
if (!props.studyId || !props.recordId) {
drawerDirtyGuard.syncBaseline();
return;
}
drawerLoading.value = true;
drawerErrorMessage.value = "";
try {
const { data } = await getTrainingAuthorization(props.studyId, props.recordId);
Object.assign(form, {
name: data.name || "",
role: data.role || "",
site_name: data.site_name || "",
trained: !!data.trained,
authorized: !!data.authorized,
trained_date: data.trained_date || "",
authorized_date: data.authorized_date || "",
remark: data.remark || "",
});
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
drawerErrorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
drawerLoading.value = false;
}
};
const reloadEditingDetail = () => {
loadEditingDetail();
};
const addBatchRow = () => {
batchRows.value.push(createEmptyBatchRow());
};
const removeBatchRow = (index: number) => {
if (batchRows.value.length <= 1) return;
batchRows.value.splice(index, 1);
batchErrors.value.splice(index, 1);
};
const normalizePayload = (row: TrainingFormModel) => ({
name: row.name.trim(),
role: row.role || null,
site_name: props.initialSiteName || form.site_name || null,
trained: row.trained,
authorized: row.authorized,
trained_date: row.trained && row.trained_date ? row.trained_date : null,
authorized_date: row.authorized && row.authorized_date ? row.authorized_date : null,
remark: row.remark || null,
});
const validateBatchRows = () => {
const errors: Record<string, string>[] = [];
let ok = true;
batchRows.value.forEach((row, index) => {
const entry: Record<string, string> = {};
if (!row.name.trim()) {
entry.name = TEXT.common.messages.required;
ok = false;
}
errors[index] = entry;
});
batchErrors.value = errors;
return ok;
};
const saveForm = async () => {
if (!props.studyId) return;
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return;
if (isCreateMode.value && !validateBatchRows()) return;
saving.value = true;
try {
let savedId = props.recordId || "";
if (props.recordId) {
await updateTrainingAuthorization(props.studyId, props.recordId, normalizePayload(form));
} else {
for (const row of batchRows.value) {
const { data } = await createTrainingAuthorization(props.studyId, normalizePayload(row));
savedId = data?.id || data?.data?.id || savedId;
}
}
drawerDirtyGuard.syncBaseline();
emit("update:modelValue", false);
emit("saved", savedId);
ElMessage.success(isCreateMode.value ? TEXT.common.messages.createSuccess : TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => props.modelValue,
async (visible) => {
if (!visible) return;
resetForm();
await loadEditingDetail();
},
);
</script>
<style scoped>
:deep(.startup-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 24px 12px;
}
:deep(.startup-editor-drawer > .el-drawer__body) {
padding: 0 24px 4px;
}
.editor-header {
display: flex;
flex-direction: column;
}
.editor-title {
font-size: 18px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.3;
}
.startup-editor-form {
padding: 0;
}
.form-section {
padding: 0;
background: transparent;
}
.form-section + .form-section {
margin-top: 20px;
}
.section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
color: #1a3560;
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid #edf2f7;
}
.action-title {
justify-content: space-between;
}
.action-title > span {
display: flex;
align-items: center;
gap: 8px;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.group-dot-basic {
background: #3b82f6;
}
.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;
}
.status-inline {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 0;
}
.status-date {
width: min(156px, calc(100% - 52px));
flex: 1 1 128px;
min-width: 0;
}
.compact-row-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 16px;
align-items: start;
}
.compact-row-grid--single {
margin-bottom: 4px;
}
.compact-field {
margin-bottom: 6px;
}
.compact-field--name {
min-width: 0;
}
.compact-field--status {
min-width: 0;
}
.compact-field--remark {
grid-column: 1 / -1;
}
.batch-editor {
display: flex;
flex-direction: column;
gap: 0;
border-top: 1px solid #edf2f7;
}
.batch-entry {
border: none;
border-radius: 0;
padding: 18px 0 12px;
background: transparent;
border-bottom: 1px solid #edf2f7;
}
.batch-entry:last-child {
border-bottom: none;
}
.batch-entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
color: var(--ctms-text-secondary);
font-size: 13px;
font-weight: 600;
}
:deep(.compact-field .el-form-item__label) {
margin-bottom: 6px;
color: #30415c;
font-size: 13px;
font-weight: 600;
}
:deep(.compact-field .el-input__wrapper),
:deep(.compact-field .el-textarea__inner) {
box-shadow: 0 0 0 1px #dce3ec inset;
}
:deep(.compact-field .el-input__wrapper:hover),
:deep(.compact-field .el-textarea__inner:hover) {
box-shadow: 0 0 0 1px #c8d3e1 inset;
}
:deep(.status-date.is-disabled .el-input__wrapper) {
background: #f6f8fb;
box-shadow: 0 0 0 1px #e2e8f0 inset;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 16px;
}
</style>