feat(ia): polish project operation workspaces

This commit is contained in:
Cheng Zhou
2026-06-08 11:06:07 +08:00
parent 834b4f1d48
commit 8cd8c38900
25 changed files with 2744 additions and 1516 deletions
@@ -43,6 +43,7 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('entity-type="drug_shipment"');
expect(source).toContain(':entity-id="editingId"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain(':center-upload-card-content="true"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(shipmentId)");
expect(source).toContain("uploadPending");
+457 -260
View File
@@ -1,10 +1,12 @@
<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">
<div class="page">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.site }}</span>
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
<el-option
v-for="site in sites"
@@ -13,78 +15,96 @@
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.direction">
</div>
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.direction }}</span>
<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">
</div>
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.status }}</span>
<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="filter-actions">
<el-button type="primary" size="small" @click="handleSearch" class="filter-btn">{{ TEXT.common.actions.search }}</el-button>
<el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</div>
</div>
<div class="toolbar-right">
<div v-if="items.length" class="filter-summary">
{{ items.length }} 条记录
</div>
<el-button v-if="canCreate" type="primary" @click="openCreate" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.drugShipments.newTitle }}
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="sortedItems"
class="ctms-table shipment-table"
class="shipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="shipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }">{{ row.site_name || TEXT.common.fallback }}</template>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<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">
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="{ row }">{{ displayDate(row.ship_date) }}</template>
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="{ row }">{{ displayDate(row.receive_date) }}</template>
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="{ row }">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</template>
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="{ row }">{{ row.batch_no || TEXT.common.fallback }}</template>
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<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">
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</el-tag>
</span>
</template>
</el-table-column>
<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 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>
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
v-if="canDelete"
link
type="danger"
size="small"
:disabled="isInactiveSite(row.center_id)"
@click.stop="remove(row)"
>
@@ -94,13 +114,19 @@
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">{{ TEXT.modules.drugShipments.empty }}</div>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span>{{ TEXT.modules.drugShipments.empty }}</span>
</div>
</template>
</el-table>
</section>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<!-- ==================== 编辑抽屉 ==================== -->
<el-drawer
v-if="drawerVisible"
v-model="drawerVisible"
@@ -118,115 +144,92 @@
</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>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--blue" />
{{ 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 class="field-grid field-grid--2">
<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-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>
</div>
<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>
<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>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--amber" />
{{ 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 class="field-grid field-grid--2">
<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-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>
</div>
<div class="field-grid field-grid--2">
<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-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>
</div>
<div class="field-grid field-grid--2">
<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-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>
</div>
</div>
<!-- 备注分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-remark"></span>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--green" />
{{ TEXT.common.fields.remark }}
</div>
<el-form-item prop="remark" :required="requiresRemark(form.status)">
@@ -240,10 +243,9 @@
</el-form-item>
</div>
<!-- 附件分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--purple" />
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
@@ -259,8 +261,8 @@
<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>
<el-button @click="drawerVisible = false" class="footer-btn">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm" class="footer-btn footer-btn--primary">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
@@ -271,6 +273,7 @@
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import {
@@ -569,16 +572,11 @@ const onRowClick = (row: ShipmentRow) => {
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";
case "PENDING": return "info";
case "IN_TRANSIT": return "primary";
case "SIGNED": return "success";
case "EXCEPTION": return "danger";
default: return "info";
}
};
@@ -642,31 +640,260 @@ onMounted(async () => {
</script>
<style scoped>
/* ==================== Base ==================== */
.page {
display: flex;
flex-direction: column;
padding: 4px 0;
background: transparent;
}
.filter-row {
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ==================== Page Header ==================== */
.create-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
}
/* ==================== Table Toolbar ==================== */
.table-card-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
}
.filter-select {
width: 150px;
.toolbar-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
/* ========== 抽屉头部 ========== */
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.filter-select {
width: 140px;
}
.filter-actions {
display: flex;
gap: 8px;
align-items: flex-end;
padding-bottom: 1px;
}
.filter-btn {
border-radius: 6px;
}
.filter-summary {
font-size: 12px;
font-weight: 500;
color: #a3a3a3;
white-space: nowrap;
}
/* ==================== Table Card ==================== */
.table-card {
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 8px rgba(0, 0, 0, 0.02);
}
.shipment-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
.shipment-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.shipment-table :deep(th.el-table__cell) {
background: #f4f5f7;
color: #2a2a2a;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 14px 14px;
border-bottom: 2px solid #e0e0e0 !important;
}
.shipment-table :deep(th.el-table__cell .cell) {
display: flex;
align-items: center;
gap: 4px;
}
.shipment-table :deep(td.el-table__cell) {
padding: 12px 14px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
}
.shipment-table :deep(.el-table__body tr) {
cursor: pointer;
transition: background 0.1s ease;
}
.cell-nowrap {
white-space: nowrap;
}
.cell-primary {
font-weight: 600;
color: #0a0a0a;
}
.cell-mono {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
color: #0a0a0a;
}
.cell-muted {
color: #737373;
font-size: 14px;
}
.cell-actions {
display: flex;
gap: 8px;
white-space: nowrap;
}
.cell-actions :deep(.el-button) {
height: auto;
padding: 0;
font-size: 13px;
}
.cell-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
/* ==================== Direction Tags ==================== */
.dir-tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 12px;
font-weight: 700;
border-radius: 4px;
line-height: 1.6;
}
.dir-tag--send {
background: #fff7ed;
color: #ea580c;
}
.dir-tag--return {
background: #f0fdf4;
color: #16a34a;
}
/* ==================== Status Pills ==================== */
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 12px;
font-weight: 700;
border-radius: 20px;
line-height: 1.6;
}
.status-pill--info {
background: #f5f5f5;
color: #737373;
}
.status-pill--primary {
background: #eff4ff;
color: #2563eb;
}
.status-pill--success {
background: #dcfce7;
color: #16a34a;
}
.status-pill--danger {
background: #fef2f2;
color: #dc2626;
}
/* ==================== Empty State ==================== */
.table-empty {
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
}
.table-empty span {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
}
/* ==================== Drawer Editor ==================== */
:deep(.shipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
padding: 20px 24px 8px;
}
:deep(.shipment-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px;
padding: 0 24px 4px;
}
.editor-header {
@@ -675,69 +902,61 @@ onMounted(async () => {
}
.editor-title {
font-size: 20px;
font-size: 18px;
font-weight: 700;
color: var(--ctms-text-main);
color: #0a0a0a;
line-height: 1.2;
letter-spacing: -0.01em;
}
/* ========== 表单整体 ========== */
.shipment-form {
padding: 0 4px 0 0;
padding: 0;
}
/* ========== 分组卡片 ========== */
.form-group {
border: 1px solid #e8eef6;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
/* ==================== Form Sections ==================== */
.form-section {
padding-bottom: 6px;
}
.form-group:hover {
border-color: #d0dced;
.form-section + .form-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.form-group + .form-group {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
.form-section-title {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
margin-bottom: 16px;
}
.group-dot {
width: 10px;
height: 10px;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* 基本信息 - 蓝色 */
.group-dot-basic {
background: #3b82f6;
.dot--blue { background: #3b82f6; }
.dot--amber { background: #f59e0b; }
.dot--green { background: #22c55e; }
.dot--purple { background: #8b5cf6; }
.field-grid {
display: grid;
gap: 0 16px;
}
/* 运输记录 - 琥珀色 */
.group-dot-record {
background: #f0ad2c;
.field-grid--2 {
grid-template-columns: 1fr 1fr;
}
/* 备注 - 绿色 */
.group-dot-remark {
background: #22c55e;
}
/* 附件 - 紫色 */
.group-dot-attachment {
background: #8b5cf6;
.full-width {
width: 100%;
}
.inactive-hint {
@@ -745,12 +964,12 @@ onMounted(async () => {
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #fff7d6;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-bottom: 8px;
margin-top: 4px;
}
.hint-icon {
@@ -766,85 +985,62 @@ onMounted(async () => {
line-height: 1;
}
/* ========== 表单元素细节 ========== */
.full-width {
width: 100%;
}
/* ========== 底部按钮 ========== */
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ========== 表单元素微调 ========== */
.footer-btn {
height: 36px;
border-radius: 8px;
padding: 0 20px;
font-size: 13px;
font-weight: 500;
}
.footer-btn--primary {
padding: 0 24px;
}
.shipment-form :deep(.el-form-item) {
margin-bottom: 12px;
margin-bottom: 14px;
}
.shipment-form :deep(.el-form-item__label) {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #4a6283;
color: #525252;
padding-bottom: 4px;
}
.shipment-table :deep(.el-table__inner-wrapper::before) {
display: none;
.shipment-form :deep(.el-form-item__error) {
font-size: 11px;
}
/* ========== 表格居中 ========== */
.shipment-table :deep(th.el-table__cell .cell),
.shipment-table :deep(td.el-table__cell .cell) {
text-align: center;
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.field-grid--2 {
grid-template-columns: 1fr;
}
.toolbar-filters {
flex-direction: column;
align-items: stretch;
}
.filter-select {
width: 100%;
}
.table-card-toolbar {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
}
.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;
}
.type-send {
background-color: #fff7ed !important;
color: #ea580c !important;
}
.type-return {
background-color: #fefce8 !important;
color: #ca8a04 !important;
}
.status-tag {
font-weight: 500;
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
@media (max-width: 640px) {
.create-btn {
width: 100%;
}
}
</style>
@@ -854,7 +1050,8 @@ onMounted(async () => {
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
.row-inactive .status-pill,
.row-inactive .dir-tag {
opacity: 0.6;
}
</style>
@@ -54,6 +54,7 @@ describe("MaterialEquipment project permissions", () => {
expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':entity-id="editingId"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain(':center-upload-card-content="true"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)");
expect(source).toContain("uploadPending");
+371 -155
View File
@@ -1,52 +1,87 @@
<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="设备名称">
<el-input v-model="filters.name" clearable placeholder="请输入" class="name-filter" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<el-button v-if="canCreate" type="primary" @click="openCreate">新建</el-button>
<div class="page">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
<div class="filter-item">
<span class="filter-label">设备名称</span>
<el-input v-model="filters.name" clearable placeholder="请输入" class="filter-input" />
</div>
<div class="filter-actions">
<el-button type="primary" size="small" @click="handleSearch" class="filter-btn">查询</el-button>
<el-button size="small" @click="resetFilters" class="filter-btn">重置</el-button>
</div>
</div>
<div class="toolbar-right">
<div v-if="rows.length" class="filter-summary">
{{ rows.length }} 条记录
</div>
<el-button v-if="canCreate" type="primary" @click="openCreate" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建设备
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="rows"
class="ctms-table equipment-table"
class="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 />
<el-table-column prop="brand" label="品牌" show-overflow-tooltip />
<el-table-column label="是否需要校准">
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
{{ row.needCalibration ? "是" : "否" }}
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="操作">
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<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>
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">暂无设备数据</div>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>暂无设备数据</span>
</div>
</template>
</el-table>
</section>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<!-- ==================== 编辑抽屉 ==================== -->
<el-drawer
v-if="drawerVisible"
v-model="drawerVisible"
@@ -64,72 +99,57 @@
</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 class="form-section">
<div class="form-section-title">
<span class="dot dot--blue" />
基本信息
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" placeholder="请输入设备名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="specModel">
<el-input v-model="form.specModel" 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" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="品牌" prop="brand">
<el-input v-model="form.brand" placeholder="请输入品牌" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="产地" prop="origin">
<el-input v-model="form.origin" placeholder="请输入产地" />
</el-form-item>
</el-col>
</el-row>
<div class="field-grid field-grid--2">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" placeholder="请输入设备名称" />
</el-form-item>
<el-form-item label="规格型号" prop="specModel">
<el-input v-model="form.specModel" placeholder="请输入规格型号" />
</el-form-item>
</div>
<div class="field-grid field-grid--3">
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" placeholder="请输入" />
</el-form-item>
<el-form-item label="品牌" prop="brand">
<el-input v-model="form.brand" placeholder="请输入品牌" />
</el-form-item>
<el-form-item label="产地" prop="origin">
<el-input v-model="form.origin" placeholder="请输入产地" />
</el-form-item>
</div>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-calibration"></span>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--green" />
校准设置
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="是否需要校准" prop="needCalibration">
<el-select v-model="form.needCalibration" 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" :min="1" :precision="0" class="full-width" />
</el-form-item>
</el-col>
</el-row>
<div v-if="!form.needCalibration" class="calibration-hint">
<div class="field-grid field-grid--2">
<el-form-item label="是否需要校准" prop="needCalibration">
<el-select v-model="form.needCalibration" placeholder="请选择" class="full-width">
<el-option label="是" :value="true" />
<el-option label="" :value="false" />
</el-select>
</el-form-item>
<el-form-item v-if="form.needCalibration" label="校准周期(天)" prop="calibrationCycleDays">
<el-input-number v-model="form.calibrationCycleDays" :min="1" :precision="0" class="full-width" />
</el-form-item>
</div>
<div v-if="!form.needCalibration" class="hint-box">
<span class="hint-icon"></span>
<span>该设备无需定期校准</span>
</div>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-file"></span>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--purple" />
附件
</div>
<AttachmentList
@@ -145,8 +165,8 @@
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">保存</el-button>
<el-button @click="drawerVisible = false" class="footer-btn">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm" class="footer-btn footer-btn--primary">保存</el-button>
</div>
</template>
</el-drawer>
@@ -157,6 +177,7 @@
import { computed, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import {
createMaterialEquipment,
deleteMaterialEquipment,
@@ -398,31 +419,212 @@ watch(
</script>
<style scoped>
/* ==================== Base ==================== */
.page {
display: flex;
flex-direction: column;
padding: 4px 0;
background: transparent;
}
.filter-row {
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ==================== Table Toolbar ==================== */
.table-card-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
}
.name-filter {
width: 260px;
.toolbar-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
/* ========== 抽屉头部 ========== */
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.filter-input {
width: 200px;
}
.filter-actions {
display: flex;
gap: 8px;
align-items: flex-end;
padding-bottom: 1px;
}
.filter-btn {
border-radius: 6px;
}
.filter-summary {
font-size: 12px;
font-weight: 500;
color: #a3a3a3;
white-space: nowrap;
}
.create-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
}
/* ==================== Table Card ==================== */
.table-card {
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 8px rgba(0, 0, 0, 0.02);
}
.equipment-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
.equipment-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.equipment-table :deep(th.el-table__cell) {
background: #f4f5f7;
color: #2a2a2a;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 14px 14px;
border-bottom: 2px solid #e0e0e0 !important;
}
.equipment-table :deep(td.el-table__cell) {
padding: 12px 14px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
}
.equipment-table :deep(.el-table__body tr) {
cursor: pointer;
transition: background 0.1s ease;
}
/* ==================== Cell Helpers ==================== */
.cell-nowrap {
white-space: nowrap;
}
.cell-primary {
font-weight: 600;
color: #0a0a0a;
}
.cell-actions {
display: flex;
gap: 8px;
white-space: nowrap;
}
.cell-actions :deep(.el-button) {
height: auto;
padding: 0;
font-size: 13px;
}
.cell-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
/* ==================== Status Pills ==================== */
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 12px;
font-weight: 700;
border-radius: 20px;
line-height: 1.6;
}
.status-pill--yes {
background: #dcfce7;
color: #16a34a;
}
.status-pill--no {
background: #f5f5f5;
color: #737373;
}
/* ==================== Empty State ==================== */
.table-empty {
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
}
.table-empty span {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
}
/* ==================== Drawer Editor ==================== */
:deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
padding: 20px 24px 8px;
}
:deep(.equipment-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px;
padding: 0 24px 4px;
}
.editor-header {
@@ -431,68 +633,67 @@ watch(
}
.editor-title {
font-size: 20px;
font-size: 18px;
font-weight: 700;
color: var(--ctms-text-main);
color: #0a0a0a;
line-height: 1.2;
letter-spacing: -0.01em;
}
/* ========== 表单整体 ========== */
.equipment-form {
padding: 0 4px 0 0;
padding: 0;
}
/* ========== 分组卡片 ========== */
.form-group {
border: 1px solid #e8eef6;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
/* ==================== Form Sections ==================== */
.form-section {
padding-bottom: 6px;
}
.form-group:hover {
border-color: #d0dced;
.form-section + .form-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.form-group + .form-group {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
.form-section-title {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
margin-bottom: 16px;
}
.group-dot {
width: 10px;
height: 10px;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* 基本信息 - 蓝色 */
.group-dot-basic {
background: #3b82f6;
.dot--blue { background: #3b82f6; }
.dot--green { background: #22c55e; }
.dot--purple { background: #8b5cf6; }
.field-grid {
display: grid;
gap: 0 16px;
}
/* 附件 - 琥珀色 */
.group-dot-file {
background: #f0ad2c;
.field-grid--2 {
grid-template-columns: 1fr 1fr;
}
/* 校准设置 - 绿色 */
.group-dot-calibration {
background: #22c55e;
.field-grid--3 {
grid-template-columns: 1fr 1fr 1fr;
}
/* ========== 校准提示 ========== */
.calibration-hint {
.full-width {
width: 100%;
}
.hint-box {
display: flex;
align-items: center;
gap: 6px;
@@ -502,7 +703,7 @@ watch(
border-radius: 8px;
font-size: 13px;
color: #166534;
margin-bottom: 8px;
margin-top: 4px;
}
.hint-icon {
@@ -510,47 +711,62 @@ watch(
line-height: 1;
}
/* ========== 表单元素细节 ========== */
.full-width {
width: 100%;
}
/* ========== 底部按钮 ========== */
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ========== 表单元素微调 ========== */
.footer-btn {
height: 36px;
border-radius: 8px;
padding: 0 20px;
font-size: 13px;
font-weight: 500;
}
.footer-btn--primary {
padding: 0 24px;
}
.equipment-form :deep(.el-form-item) {
margin-bottom: 12px;
margin-bottom: 14px;
}
.equipment-form :deep(.el-form-item__label) {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #4a6283;
color: #525252;
padding-bottom: 4px;
}
.equipment-table :deep(.el-table__inner-wrapper::before) {
display: none;
.equipment-form :deep(.el-form-item__error) {
font-size: 11px;
}
/* ========== 表格居中 ========== */
.equipment-table :deep(th.el-table__cell .cell),
.equipment-table :deep(td.el-table__cell .cell) {
text-align: center;
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.field-grid--2,
.field-grid--3 {
grid-template-columns: 1fr;
}
.toolbar-filters {
flex-direction: column;
align-items: stretch;
}
.filter-input {
width: 100%;
}
.table-card-toolbar {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
@media (max-width: 640px) {
.create-btn {
width: 100%;
}
}
</style>
+22 -10
View File
@@ -10,7 +10,7 @@
<span class="overview-top-title">项目总览</span>
</div>
<div class="overview-top-right">
<span class="overview-updated-label">更新于 {{ updatedAtLabel }}</span>
<span class="overview-live-clock">{{ liveClock }}</span>
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
@@ -84,7 +84,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
@@ -101,7 +101,8 @@ const study = useStudyStore();
const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const updatedAtLabel = ref(displayDateTime(new Date()));
const liveClock = ref(displayDateTime(new Date()));
let clockTimer: ReturnType<typeof setInterval> | null = null;
const centers = computed(() => overview.value?.centers || []);
@@ -174,9 +175,6 @@ const loadOverview = async () => {
const siteActiveMap = new Map<string, boolean>(siteList.map((s: any) => [s.id, !!s.is_active]));
const { data } = await fetchProjectOverview(studyId);
overview.value = adaptProjectOverview(data);
updatedAtLabel.value = overview.value.updated_at
? displayDateTime(overview.value.updated_at)
: displayDateTime(new Date());
if (overview.value && overview.value.centers.length === 0 && siteList.length > 0) {
overview.value.centers = buildFallbackCentersFromSites(siteList);
@@ -193,7 +191,6 @@ const loadOverview = async () => {
}
} catch {
overview.value = buildOverviewFromSites(studyId, siteList);
updatedAtLabel.value = displayDateTime(overview.value.updated_at);
} finally {
loading.value = false;
}
@@ -201,11 +198,20 @@ const loadOverview = async () => {
const reset = () => {
overview.value = null;
updatedAtLabel.value = displayDateTime(new Date());
};
onMounted(() => {
loadOverview();
clockTimer = setInterval(() => {
liveClock.value = displayDateTime(new Date());
}, 1000);
});
onUnmounted(() => {
if (clockTimer) {
clearInterval(clockTimer);
clockTimer = null;
}
});
watch(
@@ -272,9 +278,15 @@ watch(
gap: 12px;
}
.overview-updated-label {
.overview-live-clock {
font-size: 13px;
color: var(--ctms-text-secondary);
font-weight: 600;
color: var(--ctms-primary);
font-variant-numeric: tabular-nums;
background: rgba(63, 93, 117, 0.06);
padding: 2px 10px;
border-radius: 6px;
letter-spacing: 0.02em;
}
.refresh-btn {
+88 -113
View File
@@ -1,61 +1,71 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush">
<el-form :inline="true" class="filter-form">
<div class="filter-item-form">
<el-input v-model="filters.keyword" :placeholder="TEXT.common.fields.keyword" clearable class="filter-input-comp">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="page">
<div class="page-header">
<h2 class="page-title">{{ TEXT.menu.riskIssuePd }}</h2>
</div>
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.keyword }}</span>
<el-input v-model="filters.keyword" clearable placeholder="请输入" class="filter-input" />
</div>
<div class="filter-item-form">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.site }}</span>
<el-select v-model="filters.siteId" clearable filterable placeholder="请选择" class="filter-select">
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.status }}</span>
<el-select v-model="filters.status" clearable placeholder="请选择" class="filter-select">
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
<div class="filter-actions-comp">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<div class="filter-actions">
<el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</div>
</el-form>
</div>
</div>
<div class="unified-section table-section section--flush-x section--flush-top section--flush-bottom">
<el-table :data="filteredItems" v-loading="loading" style="width: 100%" table-layout="fixed" class="risk-table">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" show-overflow-tooltip />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip />
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.pdDescription" show-overflow-tooltip />
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
<template #default="scope">
<el-table :data="filteredItems" v-loading="loading" style="width: 100%" class="risk-table" table-layout="fixed">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" show-overflow-tooltip>
<template #default="scope"><span class="cell-mono cell-nowrap">{{ scope.row.pd_no || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope"><span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope">
<span :class="['status-pill', scope.row.status === 'CLOSED' ? 'status-pill--success' : 'status-pill--pending']">
{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.pdDescription" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ scope.row.description || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button link type="primary" size="small" @click="goSubject(scope.row.subject_id)">
{{ TEXT.common.actions.view }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="risk-table-empty">{{ TEXT.modules.riskIssuePd.emptyDescription }}</div>
</div>
</template>
</el-table>
</div>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<span>{{ TEXT.modules.riskIssuePd.emptyDescription }}</span>
</div>
</template>
</el-table>
</div>
</div>
</template>
@@ -64,7 +74,6 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { CircleCheck, Location, Search } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { listStudySubjectPds } from "../../api/subjectPds";
import { fetchSites } from "../../api/sites";
@@ -77,33 +86,18 @@ const loading = ref(false);
const items = ref<any[]>([]);
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
const filters = ref({
keyword: "",
siteId: study.currentSite?.id || "",
status: "",
});
const filters = ref({ keyword: "", siteId: study.currentSite?.id || "", status: "" });
const statusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label }));
const resetFilters = () => {
filters.value.keyword = "";
filters.value.siteId = "";
filters.value.status = "";
};
const resetFilters = () => { filters.value.keyword = ""; filters.value.siteId = ""; filters.value.status = ""; };
const loadSites = async (studyId: string) => {
try {
const { data } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(data) ? data : data.items || [];
siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name }));
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => {
acc[site.id] = site.name;
return acc;
}, {});
} catch {
siteOptions.value = [];
siteMap.value = {};
}
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => { acc[site.id] = site.name; return acc; }, {});
} catch { siteOptions.value = []; siteMap.value = {}; }
};
const load = async () => {
@@ -122,11 +116,8 @@ const load = async () => {
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
items.value = list.sort((a: any, b: any) => getPdSeq(a?.pd_no) - getPdSeq(b?.pd_no));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); }
finally { loading.value = false; }
};
const filteredItems = computed(() => {
@@ -147,59 +138,43 @@ const goSubject = (subjectId: string) => {
router.push({ path: `/subjects/${subjectId}`, query: { tab: "pd" } });
};
watch(
() => study.currentSite,
(newSite) => {
filters.value.siteId = newSite?.id || "";
}
);
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
onMounted(load);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.page { display: flex; flex-direction: column; gap: 0; padding: 0; background: #ffffff; }
.filter-form {
display: flex;
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.page-header { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-size: 18px; font-weight: 700; color: #0a0a0a; letter-spacing: -0.01em; }
.filter-item-form {
margin-bottom: 0;
margin-right: 0;
}
.table-card { background: #ffffff; overflow: hidden; }
.filter-input-comp {
width: 200px;
}
.table-card-toolbar { display: flex; align-items: center; padding: 14px 20px; border-bottom: 1px solid #f0f0f0; }
.toolbar-filters { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
.filter-item { display: flex; flex-direction: column; gap: 4px; }
.filter-label { font-size: 11px; font-weight: 600; color: #8a8a8a; text-transform: uppercase; letter-spacing: 0.03em; }
.filter-input { width: 160px; }
.filter-select { width: 140px; }
.filter-actions { display: flex; gap: 8px; align-items: flex-end; padding-bottom: 1px; }
.filter-btn { border-radius: 6px; }
.filter-select-comp {
width: 170px;
}
.risk-table { --el-table-border-color: transparent; --el-table-row-hover-bg-color: #f8f9fb; }
.risk-table :deep(.el-table__inner-wrapper::before) { display: none; }
.risk-table :deep(th.el-table__cell) { background: #f4f5f7; color: #2a2a2a; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; padding: 14px 14px; border-bottom: 2px solid #e0e0e0 !important; }
.risk-table :deep(td.el-table__cell) { padding: 12px 14px; color: #0a0a0a; font-size: 14px; font-weight: 500; }
.risk-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
.filter-actions-comp {
margin-bottom: 0;
margin-right: 0;
}
.cell-nowrap { white-space: nowrap; }
.cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; font-weight: 600; color: #0a0a0a; }
.cell-actions { display: flex; gap: 8px; white-space: nowrap; }
.cell-actions :deep(.el-button) { font-size: 13px; padding: 0; height: auto; }
.risk-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.status-pill { display: inline-flex; align-items: center; padding: 2px 10px; font-size: 11px; font-weight: 600; border-radius: 20px; line-height: 1.6; }
.status-pill--success { background: #dcfce7; color: #16a34a; }
.status-pill--pending { background: #f5f5f5; color: #737373; }
.risk-table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
.table-empty { min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; }
.empty-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; background: #f5f5f5; color: #c4c4c4; }
.table-empty span { font-size: 13px; font-weight: 500; color: #a3a3a3; }
</style>
@@ -1,18 +1,9 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="main-content-flat unified-shell">
<el-tabs v-if="canReadFeasibility || canReadEthics" v-model="activeTab" class="ctms-tabs">
<el-tab-pane v-if="canReadFeasibility" :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
<div class="ctms-tab-content unified-section section--flush">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityTab }}</div>
<div class="ctms-section-actions">
<el-button v-if="canCreateFeasibility" type="primary" @click="goNewFeasibility">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}
</el-button>
</div>
</div>
<div class="tabs-header">
<el-tabs v-if="canReadFeasibility || canReadEthics" v-model="activeTab" class="ctms-tabs">
<el-tab-pane v-if="canReadFeasibility" :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
<el-table
:data="sortedFeasibilityItems"
v-loading="loadingFeasibility"
@@ -47,45 +38,38 @@
<span class="font-mono text-main">{{ row.project_no }}</span>
</template>
</el-table-column>
<el-table-column v-if="canUpdateFeasibility || canDeleteFeasibility" :label="TEXT.common.labels.actions" width="160" align="center">
<el-table-column v-if="canUpdateFeasibility || canDeleteFeasibility" :label="TEXT.common.labels.actions" width="130" fixed="right">
<template #default="scope">
<el-button
v-if="canUpdateFeasibility"
link
type="primary"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="openFeasibilityEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteFeasibility"
link
type="danger"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="removeFeasibility(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
<div class="cell-actions">
<el-button
v-if="canUpdateFeasibility"
link
type="primary"
size="small"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="openFeasibilityEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteFeasibility"
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="removeFeasibility(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loadingFeasibility" class="table-empty">{{ TEXT.modules.startupFeasibilityEthics.emptyFeasibility }}</div>
</template>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane v-if="canReadEthics" :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
<div class="ctms-tab-content unified-section section--flush">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsTab }}</div>
<div class="ctms-section-actions">
<el-button v-if="canCreateEthics" type="primary" @click="goNewEthics">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}
</el-button>
</div>
</div>
<el-table
:data="sortedEthicsItems"
v-loading="loadingEthics"
@@ -123,36 +107,50 @@
<span class="font-mono text-main">{{ row.approval_no }}</span>
</template>
</el-table-column>
<el-table-column v-if="canUpdateEthics || canDeleteEthics" :label="TEXT.common.labels.actions" width="160" align="center">
<el-table-column v-if="canUpdateEthics || canDeleteEthics" :label="TEXT.common.labels.actions" width="130" fixed="right">
<template #default="scope">
<el-button
v-if="canUpdateEthics"
link
type="primary"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="openEthicsEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteEthics"
link
type="danger"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="removeEthics(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
<div class="cell-actions">
<el-button
v-if="canUpdateEthics"
link
type="primary"
size="small"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="openEthicsEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteEthics"
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="removeEthics(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loadingEthics" class="table-empty">{{ TEXT.modules.startupFeasibilityEthics.emptyEthics }}</div>
</template>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</div>
<div class="tabs-actions">
<el-button v-if="activeTab === 'feasibility' && canCreateFeasibility" type="primary" size="small" @click="goNewFeasibility" class="tab-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}
</el-button>
<el-button v-if="activeTab === 'ethics' && canCreateEthics" type="primary" size="small" @click="goNewEthics" class="tab-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}
</el-button>
</div>
</div>
</div>
<FeasibilityEditorDrawer
v-model="feasibilityDrawerVisible"
@@ -422,30 +420,128 @@ watchEffect(() => {
.page {
display: flex;
flex-direction: column;
gap: 0;
padding: 4px 0;
background: #eeeff1;
}
.ctms-tab-content {
gap: 0;
.main-content-flat {
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.03), 0 4px 12px rgba(0,0,0,0.03);
}
.ctms-tab-content .ctms-section-header {
margin-bottom: 0;
/* ==================== Tabs Header ==================== */
.tabs-header {
position: relative;
}
.tabs-actions {
position: absolute;
top: 10px;
right: 20px;
display: flex;
gap: 8px;
z-index: 1;
}
.tab-action-btn {
height: 28px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
padding: 0 10px;
}
/* ==================== Tabs ==================== */
.ctms-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 24px;
background: transparent;
border-bottom: 1px solid #e8e8e8;
}
.ctms-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
.ctms-tabs :deep(.el-tabs__active-bar) {
background: #0a0a0a;
height: 2px;
bottom: -1px;
}
.ctms-tabs :deep(.el-tabs__item) {
font-size: 13px;
font-weight: 600;
color: #a3a3a3;
padding: 14px 20px;
transition: color 0.15s ease;
}
.ctms-tabs :deep(.el-tabs__item:hover) { color: #525252; }
.ctms-tabs :deep(.el-tabs__item.is-active) { color: #0a0a0a; }
.ctms-tabs :deep(.el-tabs__content) { padding: 0; }
.ctms-tabs :deep(.el-tab-pane) { padding: 0; }
/* ==================== Table ==================== */
.ctms-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
.ctms-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
.ctms-table :deep(th.el-table__cell) {
background: #f4f5f7;
color: #2a2a2a;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 14px 14px;
border-bottom: 2px solid #e0e0e0 !important;
}
.ctms-table :deep(td.el-table__cell) {
padding: 12px 14px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
.ctms-table :deep(.el-table__body tr) {
cursor: pointer;
transition: background 0.1s ease;
}
.ctms-table :deep(.el-tag) {
font-weight: 600;
border: none;
}
.ctms-table :deep(.cell-actions) {
display: flex;
gap: 8px;
white-space: nowrap;
}
.ctms-table :deep(.cell-actions .el-button) {
font-size: 13px;
padding: 0;
height: auto;
}
.ctms-table :deep(.cell-actions .el-button + .el-button) {
margin-left: 0;
}
.table-empty {
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #a3a3a3;
font-size: 13px;
font-weight: 500;
}
</style>
@@ -12,12 +12,32 @@ describe("StartupMeetingAuth permissions", () => {
expect(source).toContain("if (canReadMembers.value)");
});
it("does not create kickoff records from row click unless startup auth create permission is granted", () => {
it("does not lazily create kickoff records from row click or edit action", () => {
const source = readSource();
expect(source).toContain('can("startup.auth.create")');
expect(source).toContain("canCreateAuth");
expect(source).toContain("if (!canCreateAuth.value)");
expect(source).not.toContain("createKickoff");
expect(source).toContain("if (!row.meeting_id) return");
});
it("renders rows from kickoff records instead of center placeholders", () => {
const source = readSource();
expect(source).toContain("const siteMap = computed");
expect(source).toContain("return kickoffItems.value");
expect(source).not.toContain("return sites.value");
});
it("renders kickoff operation column with edit action only", () => {
const source = readSource();
expect(source).toContain(":label=\"TEXT.common.labels.actions\"");
expect(source).toContain('can("startup.auth.update")');
expect(source).toContain("canUpdateAuth");
expect(source).not.toContain("canDeleteAuth");
expect(source).toContain("v-if=\"canUpdateAuth && scope.row.meeting_id\"");
expect(source).toContain("@click.stop=\"openKickoffEditor(scope.row)\"");
expect(source).not.toContain("removeKickoff");
expect(source).toContain("<KickoffEditorDrawer");
});
it("uses backend contact display names before falling back to member lookup", () => {
+125 -29
View File
@@ -25,12 +25,37 @@
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="canUpdateAuth" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button
v-if="canUpdateAuth && scope.row.meeting_id"
link
type="primary"
size="small"
:disabled="scope.row.is_active === false"
@click.stop="openKickoffEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">{{ TEXT.modules.startupMeetingAuth.emptyKickoff }}</div>
</template>
</el-table>
</section>
</div>
<KickoffEditorDrawer
v-model="editorVisible"
:meeting-id="editingMeetingId"
:study-id="study.currentStudy?.id || ''"
:site-name="editingSiteName"
:owner-label="editingOwnerLabel"
:readonly="editingReadOnly"
@saved="handleEditorSaved"
/>
</div>
</template>
@@ -40,7 +65,7 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { createKickoff, listKickoffs } from "../../api/startup";
import { listKickoffs } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
@@ -48,6 +73,7 @@ import { displayDate } from "../../utils/display";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import KickoffEditorDrawer from "../startup/KickoffEditorDrawer.vue";
const router = useRouter();
const auth = useAuthStore();
@@ -58,9 +84,14 @@ const sites = ref<any[]>([]);
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const loading = ref(false);
const editorVisible = ref(false);
const editingMeetingId = ref("");
const editingSiteName = ref("");
const editingOwnerLabel = ref("");
const editingReadOnly = ref(false);
const isAdmin = computed(() => isSystemAdmin(auth.user));
const canReadMembers = computed(() => can("project.members.list"));
const canCreateAuth = computed(() => can("startup.auth.create"));
const canUpdateAuth = computed(() => can("startup.auth.update"));
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
@@ -76,10 +107,10 @@ const memberNameMap = computed(() => {
return map;
});
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
const siteMap = computed(() => {
const map: Record<string, any> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
if (site?.id) map[site.id] = site;
});
return map;
});
@@ -96,22 +127,18 @@ const contactLabel = (row: any) => {
};
const kickoffRows = computed(() => {
const kickoffMap = kickoffItems.value.reduce<Record<string, any>>((acc, item) => {
if (item.site_id) acc[item.site_id] = item;
return acc;
}, {});
return sites.value
.map((site) => {
const meeting = kickoffMap[site.id];
return kickoffItems.value
.map((meeting) => {
const site = siteMap.value[meeting.site_id] || {};
return {
site_id: site.id,
site_id: meeting.site_id,
site_name: site.name || TEXT.common.fallback,
contact: site.contact,
contact_display: site.contact_display,
kickoff_date: meeting?.kickoff_date || null,
meeting_id: meeting?.id,
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
is_active: !!site.is_active,
kickoff_date: meeting.kickoff_date || null,
meeting_id: meeting.id,
status: meeting.kickoff_date ? "COMPLETED" : "PENDING",
is_active: site.is_active !== false,
};
})
.sort((a, b) => Number(a.is_active === false) - Number(b.is_active === false));
@@ -147,27 +174,40 @@ const loadKickoffs = async () => {
};
const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`);
const prepareKickoffEditor = (row: any, meetingId: string) => {
editingMeetingId.value = meetingId;
editingSiteName.value = row.site_name || TEXT.common.fallback;
editingOwnerLabel.value = contactLabel(row);
editingReadOnly.value = row.is_active === false;
editorVisible.value = true;
};
const onKickoffRowClick = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.site_id) return;
if (row.is_active === false && !row.meeting_id) {
ElMessage.warning("中心已停用");
return;
}
if (!row?.site_id) return;
if (!row.meeting_id) return;
if (row.meeting_id) {
goKickoffDetail(row.meeting_id);
return;
}
if (!canCreateAuth.value) {
};
const openKickoffEditor = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.site_id || !row.meeting_id) return;
if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
try {
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any;
goKickoffDetail(data.id);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
if (row.is_active === false) {
ElMessage.warning("中心已停用");
return;
}
prepareKickoffEditor(row, row.meeting_id);
};
const handleEditorSaved = async () => {
editorVisible.value = false;
await loadKickoffs();
};
const kickoffRowClass = ({ row }: { row: any }) => (row?.is_active === false ? "row-inactive" : "");
@@ -183,10 +223,32 @@ onMounted(() => {
gap: 0;
}
.kickoff-table-section {
background: #ffffff;
overflow: hidden;
}
.kickoff-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.cell-actions {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.cell-actions .el-button {
font-size: 13px;
padding: 0;
height: auto;
}
.cell-actions .el-button + .el-button {
margin-left: 0;
}
.table-empty {
min-height: 220px;
display: flex;
@@ -197,6 +259,40 @@ onMounted(() => {
font-weight: 500;
letter-spacing: 0.02em;
}
/* ==================== Table header (文件版本管理 style) ==================== */
.kickoff-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
.kickoff-table :deep(th.el-table__cell) {
background: #f4f5f7;
color: #2a2a2a;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 14px 14px;
border-bottom: 2px solid #e0e0e0 !important;
}
.kickoff-table :deep(td.el-table__cell) {
padding: 12px 14px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
}
.kickoff-table :deep(.el-table__body tr) {
cursor: pointer;
transition: background 0.1s ease;
}
.kickoff-table :deep(.el-tag) {
font-weight: 600;
border: none;
}
</style>
<style>
+220 -201
View File
@@ -1,132 +1,102 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item-form">
<el-input
v-model="filters.keyword"
:placeholder="TEXT.modules.subjectManagement.screeningNo"
clearable
class="filter-input-comp"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="page">
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
<div class="filter-item">
<span class="filter-label">{{ TEXT.modules.subjectManagement.screeningNo }}</span>
<el-input v-model="filters.keyword" clearable placeholder="请输入" class="filter-input" />
</div>
<div class="filter-item-form">
<el-select
v-model="filters.siteId"
:placeholder="TEXT.common.fields.site"
clearable
filterable
class="filter-select-comp"
>
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.site }}</span>
<el-select v-model="filters.siteId" clearable filterable placeholder="请选择" class="filter-select">
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<div class="filter-item-form">
<el-select
v-model="filters.status"
:placeholder="TEXT.common.fields.status"
clearable
class="filter-select-comp"
>
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.status }}</span>
<el-select v-model="filters.status" clearable placeholder="请选择" class="filter-select">
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
<div class="filter-actions-comp">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<div class="filter-actions">
<el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="header-action-btn">
</div>
<div class="toolbar-right">
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
</el-button>
</el-form>
</div>
<div class="unified-section table-section section--flush-x section--flush-top section--flush-bottom">
<el-table
:data="pagedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
:row-class-name="subjectRowClass"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
<span class="subject-no">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
<span
v-for="badge in getAeBadges(scope.row)"
:key="badge.type"
:class="['ae-badge', `ae-badge-${badge.type}`]"
>
{{ badge.label }}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
<template #default="scope">{{ displayDate(scope.row.consent_date) }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope">{{ displaySubjectStatus(scope.row) }}</template>
</el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
</el-table-column>
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope">{{ displayDate(scope.row.completion_date) }}</template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
v-if="canDeleteSubject"
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">{{ TEXT.modules.subjectManagement.empty }}</div>
</template>
</el-table>
<div class="pagination-wrap" v-if="filteredItems.length > 0">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="prev, pager, next, sizes, total"
small
/>
</div>
</div>
<el-table
:data="pagedItems"
v-loading="loading"
style="width: 100%"
class="subject-table"
:row-class-name="subjectRowClass"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
{{ badge.label }}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
</el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
</el-table-column>
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
</div>
</template>
</el-table>
<div v-if="filteredItems.length > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="prev, pager, next, sizes, total"
small
/>
</div>
</div>
<SubjectEditorDrawer
v-model="subjectDrawerVisible"
@success="handleSubjectEditorSuccess"
/>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
</div>
</template>
@@ -134,7 +104,7 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Search, Location, CircleCheck, Plus } from "@element-plus/icons-vue";
import { Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { deleteSubject, fetchSubjects } from "../../api/subjects";
@@ -212,34 +182,22 @@ const load = async () => {
}
};
const goNew = () => {
subjectDrawerVisible.value = true;
};
const goNew = () => { subjectDrawerVisible.value = true; };
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => {
load();
};
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const handleSubjectEditorSuccess = () => { load(); };
const onRowClick = (row: any) => { if (!row?.id) return; goDetail(row.id); };
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteSubject.value) return;
if (isInactiveSite(row?.site_id)) {
ElMessage.warning("中心已停用");
return;
}
if (isInactiveSite(row?.site_id)) { ElMessage.warning("中心已停用"); return; }
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteSubject(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); }
};
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
@@ -247,9 +205,7 @@ const getAeBadges = (row: any) => {
const badges: Array<{ type: "ae" | "sae" | "susar"; label: string }> = [];
if (row?.has_sae) badges.push({ type: "sae", label: TEXT.modules.subjectDetail.aeType.sae });
if (row?.has_susar) badges.push({ type: "susar", label: TEXT.modules.subjectDetail.aeType.susar });
if (!row?.has_sae && !row?.has_susar && row?.has_ae) {
badges.push({ type: "ae", label: TEXT.modules.subjectDetail.aeType.ae });
}
if (!row?.has_sae && !row?.has_susar && row?.has_ae) badges.push({ type: "ae", label: TEXT.modules.subjectDetail.aeType.ae });
return badges;
};
const displaySubjectStatus = (subject: any) => {
@@ -276,96 +232,144 @@ const pagedItems = computed(() => {
const subjectRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
watch(
() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize],
() => {
pagination.value.currentPage = 1;
}
);
watch(
() => filteredItems.value.length,
(total) => {
const maxPage = Math.max(1, Math.ceil(total / pagination.value.pageSize));
if (pagination.value.currentPage > maxPage) {
pagination.value.currentPage = maxPage;
}
}
);
watch(() => study.currentSite, (newSite) => {
filters.value.siteId = newSite?.id || "";
watch(() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize], () => { pagination.value.currentPage = 1; });
watch(() => filteredItems.value.length, (total) => {
const maxPage = Math.max(1, Math.ceil(total / pagination.value.pageSize));
if (pagination.value.currentPage > maxPage) pagination.value.currentPage = maxPage;
});
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
onMounted(async () => {
await loadSites();
load();
});
onMounted(async () => { await loadSites(); load(); });
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
background: #ffffff;
}
.filter-form {
.table-card {
background: #ffffff;
overflow: hidden;
}
/* ==================== Toolbar ==================== */
.table-card-toolbar {
display: flex;
width: 100%;
gap: 8px;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
.toolbar-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.filter-select-comp,
.filter-input-comp {
width: 140px;
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
.filter-spacer {
flex: 1;
.filter-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-action-btn {
height: 32px;
padding: 0 12px;
border-radius: 10px;
.filter-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.03em;
}
:deep(.ctms-table .el-table__inner-wrapper::before) {
display: none;
.filter-input { width: 160px; }
.filter-select { width: 140px; }
.filter-actions {
display: flex;
gap: 8px;
align-items: flex-end;
padding-bottom: 1px;
}
.filter-btn { border-radius: 6px; }
.create-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
}
/* ==================== Table ==================== */
.subject-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
.subject-table :deep(.el-table__inner-wrapper::before) { display: none; }
.subject-table :deep(th.el-table__cell) {
background: #f4f5f7;
color: #2a2a2a;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 14px 14px;
border-bottom: 2px solid #e0e0e0 !important;
}
.subject-table :deep(td.el-table__cell) {
padding: 12px 14px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
}
.subject-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
/* ==================== Cell Helpers ==================== */
.cell-nowrap { white-space: nowrap; }
.cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; color: #0a0a0a; font-weight: 600; }
.cell-actions { display: flex; gap: 8px; white-space: nowrap; }
.cell-actions :deep(.el-button) { font-size: 13px; padding: 0; height: auto; }
.cell-actions :deep(.el-button + .el-button) { margin-left: 0; }
/* ==================== Subject Info ==================== */
.subject-info-cell {
display: flex;
align-items: center;
gap: 8px;
}
.subject-no {
font-weight: 600;
color: var(--ctms-text-main);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.ae-badge {
display: inline-flex;
align-items: center;
color: #ffffff;
border: 1px solid transparent;
padding: 2px 6px;
height: 18px;
line-height: normal;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
white-space: nowrap;
}
.ae-badge::before {
@@ -378,32 +382,46 @@ onMounted(async () => {
margin-right: 4px;
}
.ae-badge-ae {
background: #3b82f6;
}
.ae-badge-sae {
background: #ef4444;
}
.ae-badge-susar {
background: #8b5cf6;
}
.ae-badge-ae { background: #3b82f6; }
.ae-badge-sae { background: #ef4444; }
.ae-badge-susar { background: #8b5cf6; }
/* ==================== Pagination ==================== */
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 10px;
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
/* ==================== Empty State ==================== */
.table-empty {
min-height: 220px;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
}
.table-empty span { font-size: 13px; font-weight: 500; color: #a3a3a3; }
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; }
.table-card-toolbar { flex-direction: column; gap: 12px; align-items: stretch; }
}
</style>
@@ -413,7 +431,8 @@ onMounted(async () => {
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
.row-inactive .el-tag,
.row-inactive .ae-badge {
opacity: 0.6;
}
</style>