抽屉化物资与启动授权流程
1、将药物发货新建和编辑从独立页面迁移到抽屉,详情页支持权限控制和停用中心只读状态。 2、补充药物发货状态字段校验,统一 PENDING、IN_TRANSIT、SIGNED、EXCEPTION 状态及演示数据。 3、为物资设备增加详情页和编辑抽屉,复用通用附件上传并按项目权限控制操作入口。 4、将启动会和培训授权编辑迁移到抽屉,详情页按中心过滤培训记录并移除旧独立编辑路由。
This commit is contained in:
@@ -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,196 +1,571 @@
|
||||
<template>
|
||||
<div class="page page--flush">
|
||||
<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"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
/>
|
||||
</el-select>
|
||||
</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>
|
||||
</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.EXCEPTION" value="EXCEPTION" />
|
||||
</el-select>
|
||||
</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>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
: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-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-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-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>
|
||||
</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-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
<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"
|
||||
: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>
|
||||
</div>
|
||||
</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) => {
|
||||
if (val) {
|
||||
const matched = sites.value.find(s => s.id === val);
|
||||
if (matched) study.setCurrentSite(matched);
|
||||
} else {
|
||||
study.setCurrentSite(null);
|
||||
watch(
|
||||
() => filters.center_id,
|
||||
(val: string) => {
|
||||
if (val) {
|
||||
const matched = sites.value.find((site) => site.id === val);
|
||||
if (matched) study.setCurrentSite(matched);
|
||||
} else {
|
||||
study.setCurrentSite(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
watch(() => study.currentSite, (newSite: any) => {
|
||||
filters.center_id = newSite?.id || "";
|
||||
});
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user