抽屉化物资与启动授权流程
1、将药物发货新建和编辑从独立页面迁移到抽屉,详情页支持权限控制和停用中心只读状态。 2、补充药物发货状态字段校验,统一 PENDING、IN_TRANSIT、SIGNED、EXCEPTION 状态及演示数据。 3、为物资设备增加详情页和编辑抽屉,复用通用附件上传并按项目权限控制操作入口。 4、将启动会和培训授权编辑迁移到抽屉,详情页按中心过滤培训记录并移除旧独立编辑路由。
This commit is contained in:
@@ -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 site as site_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()
|
||||
|
||||
@@ -143,6 +148,20 @@ async def update_shipment(
|
||||
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
|
||||
else:
|
||||
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)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
|
||||
@@ -468,13 +468,14 @@ async def list_training_authorizations(
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
site_name: str | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[TrainingAuthorizationRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
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]
|
||||
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ async def list_training_authorizations(
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
site_names: set[str] | None = None,
|
||||
site_name: str | None = None,
|
||||
) -> Sequence[TrainingAuthorization]:
|
||||
if site_names is not None and not site_names:
|
||||
return []
|
||||
@@ -255,6 +256,8 @@ async def list_training_authorizations(
|
||||
)
|
||||
if site_names is not None:
|
||||
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)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -1,22 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
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):
|
||||
direction: str
|
||||
center_id: uuid.UUID
|
||||
site_name: Optional[str] = None
|
||||
ship_date: date
|
||||
receive_date: date
|
||||
quantity: int
|
||||
batch_no: str
|
||||
carrier: str
|
||||
tracking_no: str
|
||||
ship_date: Optional[date] = None
|
||||
receive_date: Optional[date] = None
|
||||
quantity: Optional[int] = None
|
||||
batch_no: Optional[str] = None
|
||||
carrier: Optional[str] = None
|
||||
tracking_no: Optional[str] = None
|
||||
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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
|
||||
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 (
|
||||
id uuid PRIMARY KEY,
|
||||
project_id uuid NOT NULL,
|
||||
center_id uuid NOT NULL,
|
||||
contract_no character varying(100),
|
||||
signed_date date,
|
||||
contract_amount numeric(12, 2) NOT NULL,
|
||||
currency character varying(10) NOT NULL DEFAULT 'CNY',
|
||||
remark text,
|
||||
contract_cases integer NOT NULL,
|
||||
actual_cases integer,
|
||||
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
|
||||
);
|
||||
|
||||
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 (
|
||||
id uuid PRIMARY KEY,
|
||||
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_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_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_site_id ON public.finance_items (site_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')
|
||||
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 (
|
||||
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
|
||||
@@ -607,10 +571,10 @@ ON CONFLICT (id) DO NOTHING;
|
||||
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
|
||||
) 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'),
|
||||
('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'),
|
||||
('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'),
|
||||
('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')
|
||||
('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', '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', 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, 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;
|
||||
|
||||
INSERT INTO public.startup_feasibility (
|
||||
|
||||
@@ -65,6 +65,27 @@ describe("admin project route permissions", () => {
|
||||
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", () => {
|
||||
const source = readRouter();
|
||||
|
||||
|
||||
@@ -23,10 +23,11 @@ import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
||||
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
||||
import FeeContracts from "../views/fees/ContractFees.vue";
|
||||
import FeeContractForm from "../views/fees/ContractFeeForm.vue";
|
||||
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
|
||||
import DrugShipments from "../views/ia/DrugShipments.vue";
|
||||
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
|
||||
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
|
||||
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
|
||||
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
|
||||
import DocumentList from "../views/documents/DocumentList.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 KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.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 FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
|
||||
import EthicsForm from "../views/startup/EthicsForm.vue";
|
||||
import EthicsDetail from "../views/startup/EthicsDetail.vue";
|
||||
import KickoffForm from "../views/startup/KickoffForm.vue";
|
||||
import KickoffDetail from "../views/startup/KickoffDetail.vue";
|
||||
import TrainingForm from "../views/startup/TrainingForm.vue";
|
||||
import TrainingDetail from "../views/startup/TrainingDetail.vue";
|
||||
import PrecautionForm from "../views/knowledge/PrecautionForm.vue";
|
||||
import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
|
||||
@@ -109,42 +107,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: FeeContracts,
|
||||
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",
|
||||
name: "FeeContractDetail",
|
||||
component: FeeContractDetail,
|
||||
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",
|
||||
name: "DrugShipments",
|
||||
component: DrugShipments,
|
||||
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",
|
||||
name: "DrugShipmentDetail",
|
||||
@@ -152,10 +126,16 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "drug/shipments/:shipmentId/edit",
|
||||
name: "DrugShipmentEdit",
|
||||
component: ShipmentForm,
|
||||
meta: { title: TEXT.common.actions.edit + TEXT.menu.drugShipments, requiresStudy: true },
|
||||
path: "materials/equipment",
|
||||
name: "MaterialEquipment",
|
||||
component: MaterialEquipment,
|
||||
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",
|
||||
@@ -235,30 +215,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: KickoffDetail,
|
||||
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",
|
||||
name: "StartupTrainingDetail",
|
||||
component: TrainingDetail,
|
||||
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",
|
||||
name: "SubjectManagement",
|
||||
@@ -447,7 +409,7 @@ const router = createRouter({
|
||||
|
||||
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
|
||||
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" },
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useStudyStore = defineStore("study", () => {
|
||||
const currentStudy = ref<Study | null>(null);
|
||||
const currentStudyRole = ref<string | null>(null);
|
||||
const currentSite = ref<any | null>(null);
|
||||
const viewContext = ref<{ siteName?: string } | null>(null);
|
||||
const viewContext = ref<{ siteName?: string; pageTitle?: string } | null>(null);
|
||||
const currentPermissions = ref<any | null>(null);
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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\"");
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,36 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<section v-loading="loading" class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div>
|
||||
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
<div class="detail-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</div>
|
||||
</div>
|
||||
<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 class="section-title-row">
|
||||
<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) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<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.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) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
|
||||
<section class="unified-section">
|
||||
<div class="ctms-section-title section-title-inline">{{ TEXT.modules.drugShipments.recordLabel }}</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="unified-section attachment-section">
|
||||
<AttachmentList
|
||||
v-if="shipmentId"
|
||||
v-if="shipmentId && studyId"
|
||||
:study-id="studyId"
|
||||
entity-type="drug_shipment"
|
||||
:entity-id="shipmentId"
|
||||
:readonly="isReadOnly"
|
||||
:hide-uploader="true"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<DrugShipmentEditorDrawer
|
||||
v-model="editorVisible"
|
||||
:shipment-id="shipmentId || ''"
|
||||
:readonly="isReadOnly"
|
||||
@saved="handleEditorSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
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 { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getDrugShipment } from "../../api/drugShipments";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
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";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const shipmentId = route.params.shipmentId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const editorVisible = ref(false);
|
||||
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: "",
|
||||
status: "",
|
||||
site_name: "",
|
||||
ship_date: "",
|
||||
receive_date: "",
|
||||
quantity: null,
|
||||
quantity: null as number | null,
|
||||
batch_no: "",
|
||||
carrier: "",
|
||||
tracking_no: "",
|
||||
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 () => {
|
||||
if (!studyId || !shipmentId) return;
|
||||
if (!studyId.value || !shipmentId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await getDrugShipment(studyId, shipmentId);
|
||||
Object.assign(detail, data);
|
||||
const { data } = await getDrugShipment(studyId.value, shipmentId.value);
|
||||
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 || "",
|
||||
});
|
||||
|
||||
// 同步中心名称到面包屑
|
||||
if (detail.site_name) {
|
||||
study.setViewContext({ siteName: detail.site_name });
|
||||
}
|
||||
study.setViewContext({
|
||||
siteName: detail.site_name || TEXT.common.fallback,
|
||||
pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle,
|
||||
});
|
||||
} 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 {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/drug/shipments/${shipmentId}/edit`);
|
||||
const goBack = () => router.push("/drug/shipments");
|
||||
const goEdit = () => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -109,10 +208,27 @@ onMounted(load);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.detail-subtitle {
|
||||
margin-top: 4px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -123,4 +239,19 @@ onMounted(load);
|
||||
.section-title-inline {
|
||||
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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
<template>
|
||||
<div class="page page--flush">
|
||||
|
||||
<div class="main-content-flat unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<div class="filter-item">
|
||||
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<div v-if="study.currentStudy" class="unified-shell">
|
||||
<section class="unified-section section--flush">
|
||||
<div class="filter-row">
|
||||
<el-form :inline="true" :model="filters">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
@@ -16,181 +13,559 @@
|
||||
:value="site.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.fields.direction" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Menu /></el-icon>
|
||||
</template>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.direction">
|
||||
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
|
||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.fields.status" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
</template>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.status">
|
||||
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
|
||||
<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>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
</el-form-item>
|
||||
<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>
|
||||
</div>
|
||||
<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-item>
|
||||
</el-form>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
|
||||
</div>
|
||||
<div class="unified-section table-section section--flush-x section--flush-top section--flush-bottom">
|
||||
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
:data="sortedItems"
|
||||
class="ctms-table shipment-table"
|
||||
style="width: 100%"
|
||||
class="shipment-table"
|
||||
table-layout="fixed"
|
||||
:row-class-name="shipmentRowClass"
|
||||
@row-click="onRowClick"
|
||||
table-layout="fixed"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" width="150" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.site_name || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
|
||||
<template #default="{ row }">
|
||||
<el-tag :class="['type-tag', row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="165">
|
||||
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
|
||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
|
||||
<template #default="{ row }">{{ displayDate(row.ship_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="145">
|
||||
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
|
||||
<template #default="{ row }">{{ displayDate(row.receive_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="100" align="right">
|
||||
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template>
|
||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
|
||||
<template #default="{ row }">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" width="120" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.batch_no || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)" effect="plain" round size="small" class="status-tag">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" width="160" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="110" align="center">
|
||||
<template #default="scope">
|
||||
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" width="130">
|
||||
<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
|
||||
v-if="canDelete"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
:disabled="isInactiveSite(row.center_id)"
|
||||
@click.stop="remove(row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<div v-if="!loading" class="table-empty">{{ TEXT.modules.drugShipments.empty }}</div>
|
||||
</template>
|
||||
</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 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Location, Menu, CircleCheck, Plus } from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
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 { displayDate, displayEnum } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
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 auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const saving = ref(false);
|
||||
const items = ref<ShipmentRow[]>([]);
|
||||
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({
|
||||
center_id: study.currentSite?.id || "",
|
||||
direction: "",
|
||||
status: "",
|
||||
direction: "" as "" | ShipmentDirection,
|
||||
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 studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (!studyId) {
|
||||
sites.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
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 {
|
||||
sites.value = [];
|
||||
siteActiveMap.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (!studyId) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listDrugShipments(studyId, {});
|
||||
items.value = Array.isArray(data) ? data : data?.items || [];
|
||||
const { data } = await listDrugShipments(studyId, {
|
||||
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) {
|
||||
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 {
|
||||
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 = () => {
|
||||
filters.center_id = "";
|
||||
filters.direction = "";
|
||||
filters.status = "";
|
||||
study.setCurrentSite(null);
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/drug/shipments/new");
|
||||
const goDetail = (id: string) => router.push(`/drug/shipments/${id}`);
|
||||
const onRowClick = (row: any) => {
|
||||
if (!row?.id) return;
|
||||
goDetail(row.id);
|
||||
const handleSearch = () => {
|
||||
load();
|
||||
};
|
||||
|
||||
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();
|
||||
const filteredItems = computed(() =>
|
||||
items.value.filter((item) => {
|
||||
if (filters.center_id && item?.center_id !== filters.center_id) return false;
|
||||
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 onRowClick = (row: ShipmentRow) => {
|
||||
if (!row?.id) return;
|
||||
router.push(`/drug/shipments/${row.id}`);
|
||||
};
|
||||
|
||||
const statusType = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -200,8 +575,6 @@ const statusType = (status: string) => {
|
||||
return "primary";
|
||||
case "SIGNED":
|
||||
return "success";
|
||||
case "RETURNED":
|
||||
return "warning";
|
||||
case "EXCEPTION":
|
||||
return "danger";
|
||||
default:
|
||||
@@ -209,40 +582,62 @@ const statusType = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (row: any) => {
|
||||
const remove = async (row: ShipmentRow) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (!canDelete.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (isInactiveSite(row?.center_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
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;
|
||||
try {
|
||||
await deleteDrugShipment(studyId, row.id);
|
||||
await load();
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
load();
|
||||
} 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) {
|
||||
const matched = sites.value.find(s => s.id === val);
|
||||
const matched = sites.value.find((site) => site.id === val);
|
||||
if (matched) study.setCurrentSite(matched);
|
||||
} else {
|
||||
study.setCurrentSite(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(() => study.currentSite, (newSite: any) => {
|
||||
watch(
|
||||
() => study.currentSite,
|
||||
(newSite: any) => {
|
||||
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 () => {
|
||||
await loadSites();
|
||||
load();
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -252,46 +647,176 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
.filter-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
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;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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) {
|
||||
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 {
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
@@ -311,9 +836,6 @@ onMounted(async () => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--ctms-text-placeholder);
|
||||
}
|
||||
.table-empty {
|
||||
min-height: 220px;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,18 @@
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="openCreate">新建</el-button>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate">新建</el-button>
|
||||
</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="specModel" label="规格型号" show-overflow-tooltip />
|
||||
<el-table-column prop="unit" label="单位" show-overflow-tooltip />
|
||||
@@ -25,10 +33,10 @@
|
||||
{{ row.needCalibration ? "是" : "否" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column label="操作">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
|
||||
<el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="canDelete" link type="danger" @click.stop="removeRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -44,14 +52,14 @@
|
||||
v-model="drawerVisible"
|
||||
direction="rtl"
|
||||
size="620px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-click-modal="true"
|
||||
:before-close="drawerDirtyGuard.beforeClose"
|
||||
:show-close="false"
|
||||
class="equipment-editor-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div>
|
||||
<div class="editor-subtitle">{{ editingId ? "修改设备基本信息与校准配置" : "添加新的设备记录到设备台账" }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -93,49 +101,6 @@
|
||||
</el-row>
|
||||
</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-title">
|
||||
<span class="group-dot group-dot-calibration"></span>
|
||||
@@ -161,12 +126,27 @@
|
||||
<span>该设备无需定期校准</span>
|
||||
</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>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<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>
|
||||
</template>
|
||||
</el-drawer>
|
||||
@@ -174,16 +154,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from "element-plus";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import {
|
||||
createMaterialEquipment,
|
||||
deleteMaterialEquipment,
|
||||
listMaterialEquipments,
|
||||
updateMaterialEquipment,
|
||||
} from "../../api/materialEquipments";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
interface EquipmentRow {
|
||||
@@ -193,8 +179,6 @@ interface EquipmentRow {
|
||||
unit: string;
|
||||
brand: string;
|
||||
origin: string;
|
||||
productionPermitFileName: string;
|
||||
techIndexFileName: string;
|
||||
needCalibration: boolean;
|
||||
calibrationCycleDays: number | null;
|
||||
}
|
||||
@@ -202,12 +186,16 @@ interface EquipmentRow {
|
||||
type FormModel = Omit<EquipmentRow, "id">;
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const filters = reactive({ name: "" });
|
||||
const rows = ref<EquipmentRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const drawerVisible = ref(false);
|
||||
const editingId = ref("");
|
||||
const formRef = ref<FormInstance>();
|
||||
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||
|
||||
const defaultForm: FormModel = {
|
||||
name: "",
|
||||
@@ -215,13 +203,21 @@ const defaultForm: FormModel = {
|
||||
unit: "",
|
||||
brand: "",
|
||||
origin: "",
|
||||
productionPermitFileName: "",
|
||||
techIndexFileName: "",
|
||||
needCalibration: true,
|
||||
calibrationCycleDays: 30,
|
||||
};
|
||||
|
||||
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> = {
|
||||
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
|
||||
@@ -262,8 +258,6 @@ const loadRows = async () => {
|
||||
unit: item.unit || "",
|
||||
brand: item.brand || "",
|
||||
origin: item.origin || "",
|
||||
productionPermitFileName: item.production_permit_file_name || "",
|
||||
techIndexFileName: item.tech_index_file_name || "",
|
||||
needCalibration: !!item.need_calibration,
|
||||
calibrationCycleDays: item.calibration_cycle_days ?? null,
|
||||
}));
|
||||
@@ -281,12 +275,21 @@ const resetForm = () => {
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
if (!canCreate.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
editingId.value = "";
|
||||
resetForm();
|
||||
drawerDirtyGuard.syncBaseline();
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: EquipmentRow) => {
|
||||
if (!canUpdate.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
editingId.value = row.id;
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
@@ -294,18 +297,28 @@ const openEdit = (row: EquipmentRow) => {
|
||||
unit: row.unit,
|
||||
brand: row.brand,
|
||||
origin: row.origin,
|
||||
productionPermitFileName: row.productionPermitFileName,
|
||||
techIndexFileName: row.techIndexFileName,
|
||||
needCalibration: row.needCalibration,
|
||||
calibrationCycleDays: row.calibrationCycleDays,
|
||||
});
|
||||
formRef.value?.clearValidate();
|
||||
drawerDirtyGuard.syncBaseline();
|
||||
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 studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (isFormReadOnly.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
const ok = await formRef.value?.validate().catch(() => false);
|
||||
if (!ok) return;
|
||||
const payload = {
|
||||
@@ -314,28 +327,37 @@ const saveForm = async () => {
|
||||
unit: form.unit.trim() || null,
|
||||
brand: form.brand.trim(),
|
||||
origin: form.origin.trim() || null,
|
||||
production_permit_file_name: form.productionPermitFileName || null,
|
||||
tech_index_file_name: form.techIndexFileName || null,
|
||||
need_calibration: !!form.needCalibration,
|
||||
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
|
||||
};
|
||||
saving.value = true;
|
||||
try {
|
||||
let equipmentId = editingId.value;
|
||||
if (editingId.value) {
|
||||
await updateMaterialEquipment(studyId, editingId.value, payload);
|
||||
} else {
|
||||
await createMaterialEquipment(studyId, payload);
|
||||
const { data } = (await createMaterialEquipment(studyId, payload)) as any;
|
||||
equipmentId = data?.id || "";
|
||||
}
|
||||
await attachmentPanelRef.value?.uploadPending(equipmentId);
|
||||
await loadRows();
|
||||
drawerDirtyGuard.syncBaseline();
|
||||
drawerVisible.value = false;
|
||||
ElMessage.success("保存成功");
|
||||
} 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 studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (!canDelete.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null);
|
||||
if (!ok) return;
|
||||
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 = () => {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
@@ -419,15 +437,9 @@ watch(
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.editor-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ========== 表单整体 ========== */
|
||||
.equipment-form {
|
||||
padding: 4px 4px 0 0;
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
|
||||
/* ========== 分组卡片 ========== */
|
||||
@@ -469,7 +481,7 @@ watch(
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* 资质文件 - 琥珀色 */
|
||||
/* 附件 - 琥珀色 */
|
||||
.group-dot-file {
|
||||
background: #f0ad2c;
|
||||
}
|
||||
@@ -479,74 +491,6 @@ watch(
|
||||
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 {
|
||||
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)");
|
||||
});
|
||||
});
|
||||
@@ -45,17 +45,22 @@ import { fetchSites } from "../../api/sites";
|
||||
import { listMembers } from "../../api/members";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { displayDate } from "../../utils/display";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { TEXT } from "../../locales";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const { can } = usePermission();
|
||||
const kickoffItems = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
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 map: Record<string, string> = {};
|
||||
@@ -85,7 +90,7 @@ const contactLabel = (row: any) => {
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean)
|
||||
.map((c) => memberNameMap.value[c] || TEXT.common.fallback)
|
||||
.map((c) => memberNameMap.value[c] || c)
|
||||
.join("、") || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
@@ -118,15 +123,19 @@ const loadKickoffs = async () => {
|
||||
const requests = [
|
||||
fetchSites(studyId, { limit: 500 }),
|
||||
listKickoffs(studyId),
|
||||
listMembers(studyId, { limit: 500 }),
|
||||
];
|
||||
if (canReadMembers.value) {
|
||||
requests.push(listMembers(studyId, { limit: 500 }));
|
||||
}
|
||||
if (isAdmin.value) {
|
||||
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 || [];
|
||||
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 || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -147,6 +156,10 @@ const onKickoffRowClick = async (row: any) => {
|
||||
goKickoffDetail(row.meeting_id);
|
||||
return;
|
||||
}
|
||||
if (!canCreateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any;
|
||||
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>
|
||||
@@ -6,16 +6,13 @@
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button
|
||||
v-if="!editMode"
|
||||
v-if="canUpdateAuth"
|
||||
type="primary"
|
||||
:disabled="loading || isReadOnly"
|
||||
@click="startEdit"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
@@ -29,14 +26,7 @@
|
||||
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
|
||||
<el-date-picker
|
||||
v-if="editMode"
|
||||
v-model="draft.kickoff_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.kickoff_date) }}</span>
|
||||
{{ displayDate(detail.kickoff_date) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
@@ -44,116 +34,96 @@
|
||||
<section class="unified-section training-section">
|
||||
<div class="training-header">
|
||||
<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>
|
||||
<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>
|
||||
<template #default="scope">
|
||||
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.name" size="small" />
|
||||
<span v-else>{{ scope.row.name || TEXT.common.fallback }}</span>
|
||||
{{ scope.row.name || TEXT.common.fallback }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.role" size="small" />
|
||||
<span v-else>{{ scope.row.role || TEXT.common.fallback }}</span>
|
||||
{{ scope.row.role || TEXT.common.fallback }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.trained">
|
||||
<template #default="scope">
|
||||
<el-checkbox
|
||||
v-if="isTrainingEditing(scope.row)"
|
||||
v-model="trainingRowDraft.trained"
|
||||
:disabled="isReadOnly"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox
|
||||
v-else
|
||||
v-model="scope.row.trained"
|
||||
:disabled="isReadOnly"
|
||||
@change="toggleTraining(scope.row)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="status-with-date">
|
||||
<span>{{ trainingStatusLabel(scope.row) }}</span>
|
||||
<span v-if="scope.row.trained && scope.row.trained_date" class="status-date-small">
|
||||
{{ displayDate(scope.row.trained_date) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.authorized">
|
||||
<template #default="scope">
|
||||
<el-checkbox
|
||||
v-if="isTrainingEditing(scope.row)"
|
||||
v-model="trainingRowDraft.authorized"
|
||||
:disabled="isReadOnly"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox
|
||||
v-else
|
||||
v-model="scope.row.authorized"
|
||||
:disabled="isReadOnly"
|
||||
@change="toggleTraining(scope.row)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="status-with-date">
|
||||
<span>{{ authorizationStatusLabel(scope.row) }}</span>
|
||||
<span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date-small">
|
||||
{{ displayDate(scope.row.authorized_date) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</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 v-if="isTrainingEditing(scope.row)">
|
||||
<el-button link type="primary" size="small" :loading="savingTraining" @click.stop="saveTraining(scope.row)">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button link size="small" :disabled="savingTraining" @click.stop="cancelTrainingEdit">
|
||||
{{ TEXT.common.actions.cancel }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
||||
<el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</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 }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</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 v-if="meetingId" class="unified-section attachment-section">
|
||||
<div class="attachment-grid">
|
||||
<AttachmentList
|
||||
v-for="group in kickoffAttachmentGroups"
|
||||
:key="group.entityType"
|
||||
:study-id="studyId"
|
||||
:entity-type="group.entityType"
|
||||
entity-type="startup_kickoff"
|
||||
:entity-id="meetingId"
|
||||
:title="group.title"
|
||||
:entity-groups="kickoffAttachmentGroups"
|
||||
:title="TEXT.common.labels.attachments"
|
||||
:readonly="true"
|
||||
:hide-uploader="true"
|
||||
:refresh-key="attachmentRefreshKey"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import {
|
||||
getKickoff,
|
||||
updateKickoff,
|
||||
listTrainingAuthorizations,
|
||||
createTrainingAuthorization,
|
||||
updateTrainingAuthorization,
|
||||
deleteTrainingAuthorization,
|
||||
} from "../../api/startup";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
@@ -161,62 +131,43 @@ import { listMembers } from "../../api/members";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { displayDate } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT, requiredMessage } from "../../locales";
|
||||
import { TEXT } from "../../locales";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import KickoffEditorDrawer from "./KickoffEditorDrawer.vue";
|
||||
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const { can } = usePermission();
|
||||
const loading = ref(false);
|
||||
const loadingTraining = ref(false);
|
||||
const saving = ref(false);
|
||||
const editMode = ref(false);
|
||||
const editorVisible = ref(false);
|
||||
const trainingEditorVisible = ref(false);
|
||||
const editingTrainingId = ref<string | undefined>();
|
||||
const attachmentRefreshKey = ref(0);
|
||||
const meetingId = route.params.meetingId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
|
||||
const sites = ref<any[]>([]);
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
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 = [
|
||||
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
||||
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
||||
{ entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
|
||||
{ entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
|
||||
{ entityType: "startup_kickoff_minutes", label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
||||
{ entityType: "startup_kickoff_signin", label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
||||
{ entityType: "startup_kickoff_ppt", label: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
|
||||
{ entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
|
||||
];
|
||||
|
||||
const detail = reactive<any>({
|
||||
kickoff_date: "",
|
||||
attendees: [],
|
||||
});
|
||||
const draft = reactive({
|
||||
kickoff_date: "",
|
||||
});
|
||||
const trainingItems = ref<any[]>([]);
|
||||
const trainingEditingRowId = ref<string | null>(null);
|
||||
const newTrainingActive = ref(false);
|
||||
const savingTraining = ref(false);
|
||||
const trainingRowDraft = reactive({
|
||||
name: "",
|
||||
role: "",
|
||||
trained: false,
|
||||
authorized: false,
|
||||
});
|
||||
|
||||
const trainingRows = computed(() => {
|
||||
if (!newTrainingActive.value) return trainingItems.value;
|
||||
return [
|
||||
...trainingItems.value,
|
||||
{
|
||||
id: "__new__",
|
||||
__isNew: true,
|
||||
name: "",
|
||||
role: "",
|
||||
trained: false,
|
||||
authorized: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const memberNameMap = computed(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -247,6 +198,8 @@ const statusLabel = computed(() =>
|
||||
);
|
||||
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
|
||||
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 () => {
|
||||
if (!studyId || !meetingId) return;
|
||||
@@ -254,17 +207,20 @@ const load = async () => {
|
||||
try {
|
||||
const { data } = await getKickoff(studyId, meetingId);
|
||||
Object.assign(detail, data);
|
||||
if (!editMode.value) {
|
||||
draft.kickoff_date = data.kickoff_date || "";
|
||||
}
|
||||
if (data?.site_id) {
|
||||
const { data: sitesData } = await fetchSites(studyId, { limit: 500 });
|
||||
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
|
||||
sites.value = list;
|
||||
const matched = list.find((site: any) => site.id === data.site_id);
|
||||
Object.assign(siteInfo, matched || { name: "" });
|
||||
} else {
|
||||
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) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
@@ -273,10 +229,10 @@ const load = async () => {
|
||||
};
|
||||
|
||||
const loadTraining = async () => {
|
||||
if (!studyId) return;
|
||||
if (!studyId || !siteInfo.name) return;
|
||||
loadingTraining.value = true;
|
||||
try {
|
||||
const { data } = await listTrainingAuthorizations(studyId);
|
||||
const { data } = await listTrainingAuthorizations(studyId, { site_name: siteInfo.name });
|
||||
trainingItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -285,88 +241,24 @@ const loadTraining = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTraining = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
const startTrainingEdit = (row: any) => {
|
||||
if (isReadOnly.value) return;
|
||||
if (!canUpdateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (!studyId) return;
|
||||
try {
|
||||
await updateTrainingAuthorization(studyId, row.id, {
|
||||
trained: row.trained,
|
||||
authorized: row.authorized,
|
||||
});
|
||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
loadTraining();
|
||||
}
|
||||
};
|
||||
|
||||
const resetTrainingDraft = (row?: any) => {
|
||||
trainingRowDraft.name = row?.name || "";
|
||||
trainingRowDraft.role = row?.role || "";
|
||||
trainingRowDraft.trained = !!row?.trained;
|
||||
trainingRowDraft.authorized = !!row?.authorized;
|
||||
};
|
||||
|
||||
const isTrainingEditing = (row: any) => {
|
||||
if (row?.__isNew) return newTrainingActive.value;
|
||||
return trainingEditingRowId.value === row?.id;
|
||||
};
|
||||
|
||||
const startTrainingEdit = (row: any) => {
|
||||
if (savingTraining.value || isReadOnly.value) return;
|
||||
newTrainingActive.value = false;
|
||||
trainingEditingRowId.value = row?.id || null;
|
||||
resetTrainingDraft(row);
|
||||
editingTrainingId.value = row?.id || undefined;
|
||||
trainingEditorVisible.value = true;
|
||||
};
|
||||
|
||||
const startTrainingAdd = () => {
|
||||
if (newTrainingActive.value || savingTraining.value || isReadOnly.value) return;
|
||||
trainingEditingRowId.value = null;
|
||||
newTrainingActive.value = true;
|
||||
resetTrainingDraft();
|
||||
};
|
||||
|
||||
const cancelTrainingEdit = () => {
|
||||
trainingEditingRowId.value = null;
|
||||
newTrainingActive.value = false;
|
||||
resetTrainingDraft();
|
||||
};
|
||||
|
||||
const saveTraining = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
if (isReadOnly.value) return;
|
||||
if (!canCreateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (!studyId) return;
|
||||
if (!trainingRowDraft.name.trim()) {
|
||||
ElMessage.error(requiredMessage(TEXT.common.fields.name));
|
||||
return;
|
||||
}
|
||||
savingTraining.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: trainingRowDraft.name.trim(),
|
||||
role: (trainingRowDraft.role || "").trim() || null,
|
||||
trained: trainingRowDraft.trained,
|
||||
authorized: trainingRowDraft.authorized,
|
||||
};
|
||||
if (row?.__isNew) {
|
||||
await createTrainingAuthorization(studyId, payload);
|
||||
ElMessage.success(TEXT.common.messages.createSuccess);
|
||||
} else {
|
||||
await updateTrainingAuthorization(studyId, row.id, payload);
|
||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||
}
|
||||
cancelTrainingEdit();
|
||||
loadTraining();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
savingTraining.value = false;
|
||||
}
|
||||
editingTrainingId.value = undefined;
|
||||
trainingEditorVisible.value = true;
|
||||
};
|
||||
|
||||
const removeTraining = async (row: any) => {
|
||||
@@ -374,6 +266,10 @@ const removeTraining = async (row: any) => {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!canDeleteAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (!studyId) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
@@ -391,43 +287,29 @@ const startEdit = () => {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
editMode.value = true;
|
||||
draft.kickoff_date = detail.kickoff_date || "";
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
editMode.value = false;
|
||||
draft.kickoff_date = detail.kickoff_date || "";
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
if (!canUpdateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !meetingId) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
kickoff_date: draft.kickoff_date || null,
|
||||
};
|
||||
const { data } = await updateKickoff(studyId, meetingId, payload);
|
||||
Object.assign(detail, data);
|
||||
editMode.value = false;
|
||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const goBack = () => router.push("/startup/meeting-auth");
|
||||
const handleEditorSaved = () => {
|
||||
load();
|
||||
attachmentRefreshKey.value += 1;
|
||||
};
|
||||
|
||||
const handleTrainingEditorSaved = () => {
|
||||
loadTraining();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (studyId) {
|
||||
const membersReq = listMembers(studyId, { limit: 500 });
|
||||
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);
|
||||
if (membersResp?.data) {
|
||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||
@@ -437,7 +319,7 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
await load();
|
||||
loadTraining();
|
||||
await loadTraining();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -461,6 +343,7 @@ onMounted(async () => {
|
||||
.training-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -470,34 +353,15 @@ onMounted(async () => {
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.training-add-row {
|
||||
border: 1px dashed var(--ctms-border-color);
|
||||
border-top: none;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ctms-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.training-add-row:hover {
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.training-add-row.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.training-add-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.attachment-grid {
|
||||
display: flex;
|
||||
.status-with-date {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.status-date-small {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
</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);");
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,7 @@
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
<el-button v-if="canUpdateAuth" type="primary" :disabled="isInactiveSite" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<TrainingEditorDrawer
|
||||
v-model="editorVisible"
|
||||
:record-id="recordId"
|
||||
:study-id="studyId"
|
||||
:sites="sites"
|
||||
@saved="handleEditorSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getTrainingAuthorization } from "../../api/startup";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const { can } = usePermission();
|
||||
const loading = ref(false);
|
||||
const editorVisible = ref(false);
|
||||
const recordId = route.params.recordId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const sites = ref<any[]>([]);
|
||||
@@ -70,7 +69,9 @@ const siteActiveMap = computed(() => {
|
||||
});
|
||||
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 () => {
|
||||
if (!studyId) return;
|
||||
@@ -88,6 +89,10 @@ const load = async () => {
|
||||
try {
|
||||
const { data } = await getTrainingAuthorization(studyId, recordId);
|
||||
Object.assign(detail, data);
|
||||
study.setViewContext({
|
||||
siteName: detail.site_name || TEXT.common.fallback,
|
||||
pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle,
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
@@ -96,13 +101,20 @@ const load = async () => {
|
||||
};
|
||||
|
||||
const goEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
if (!canUpdateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (isInactiveSite.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/startup/training/${recordId}/edit`);
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEditorSaved = () => {
|
||||
load();
|
||||
};
|
||||
const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
onMounted(async () => {
|
||||
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>
|
||||
Reference in New Issue
Block a user