feat(ia): polish project operation workspaces
This commit is contained in:
@@ -631,7 +631,7 @@ export const TEXT = {
|
||||
listTitle: "AE/SAE 列表",
|
||||
emptyDescription: "暂无 AE/SAE 记录",
|
||||
subjectNo: "参与者编号",
|
||||
toSubject: "查看参与者",
|
||||
toSubject: "查看",
|
||||
},
|
||||
riskIssuePd: {
|
||||
title: "PD",
|
||||
@@ -640,7 +640,7 @@ export const TEXT = {
|
||||
emptyDescription: "暂无 PD 记录",
|
||||
syncStatus: "同步状态",
|
||||
synced: "已同步",
|
||||
toSubject: "查看参与者",
|
||||
toSubject: "查看",
|
||||
},
|
||||
riskIssueMonitoringVisits: {
|
||||
title: "监查访视问题",
|
||||
@@ -1028,7 +1028,6 @@ export const TEXT = {
|
||||
失访: "失访",
|
||||
},
|
||||
aeStatus: {
|
||||
NEW: "新建",
|
||||
FOLLOW_UP: "随访中",
|
||||
CLOSED: "已关闭",
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("DrugShipmentEditorDrawer attachments", () => {
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain('entity-type="drug_shipment"');
|
||||
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(props.shipmentId)");
|
||||
expect(source).toContain("uploadPending");
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
:entity-id="shipmentId"
|
||||
:mode="'upload'"
|
||||
:readonly="isFormReadOnly"
|
||||
:center-upload-card-content="true"
|
||||
/>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
@@ -33,12 +33,26 @@ describe("ContractFeeEditorDrawer attachments", () => {
|
||||
it("shows attachment upload cards while creating a new contract fee", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('class="form-group attachment-form-group"');
|
||||
expect(source).toContain('class="form-section"');
|
||||
expect(source).toContain(':entity-id="form.id"');
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain(':upload-card-columns="3"');
|
||||
expect(source).not.toContain(':hide-upload-card-labels="true"');
|
||||
expect(source).not.toContain('<div v-if="form.id" class="form-group">');
|
||||
});
|
||||
|
||||
it("uses centered three-column upload cards with contract-specific copy", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('uploadText: "点击上传合同"');
|
||||
expect(source).toContain('uploadText: "点击上传凭证"');
|
||||
expect(source).toContain('uploadText: "点击上传发票"');
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain(':upload-card-columns="3"');
|
||||
expect(source).not.toContain(':hide-upload-card-labels="true"');
|
||||
});
|
||||
|
||||
it("labels the section as attachments instead of contract attachments", () => {
|
||||
const source = readLocale();
|
||||
|
||||
@@ -49,13 +63,12 @@ describe("ContractFeeEditorDrawer attachments", () => {
|
||||
it("uses a payment grid that keeps date pickers inside their columns", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('class="payment-grid"');
|
||||
expect(source).toContain('class="payment-field payment-field--amount"');
|
||||
expect(source).toContain('class="payment-field payment-field--status"');
|
||||
expect(source).toContain('class="payment-field payment-field--remark"');
|
||||
expect(source).toContain("grid-template-columns: minmax(180px, 0.9fr) repeat(2, minmax(220px, 1fr));");
|
||||
expect(source).toContain('class="field-grid field-grid--3"');
|
||||
expect(source).toContain('class="status-row"');
|
||||
expect(source).toContain('class="status-datepicker"');
|
||||
expect(source).toContain("grid-template-columns: 1fr 1fr 1fr;");
|
||||
expect(source).toContain("min-width: 0;");
|
||||
expect(source).toContain(".status-picker :deep(.el-date-editor.el-input)");
|
||||
expect(source).toContain(".status-datepicker :deep(.el-date-editor.el-input)");
|
||||
});
|
||||
|
||||
it("uses ten-thousand yuan as the only amount unit and removes currency selection", () => {
|
||||
|
||||
@@ -25,91 +25,83 @@
|
||||
<StateLoading v-else-if="drawerLoading" :rows="6" />
|
||||
|
||||
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" class="contract-fee-form">
|
||||
<div class="form-group">
|
||||
<div class="form-group-title">
|
||||
<span class="group-dot group-dot-basic"></span>
|
||||
<!-- 合同信息 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">
|
||||
<span class="dot dot--blue" />
|
||||
{{ TEXT.modules.feeContracts.contractInfoTitle }}
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||
<el-select
|
||||
v-model="form.center_id"
|
||||
:disabled="!!contractId || isFormReadOnly"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="full-width"
|
||||
>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
:disabled="!site.is_active"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no">
|
||||
<el-input v-model="form.contract_no" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.signedDate" prop="signed_date">
|
||||
<el-date-picker
|
||||
v-model="form.signed_date"
|
||||
:disabled="isFormReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="full-width"
|
||||
|
||||
<div class="field-grid field-grid--3">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||
<el-select
|
||||
v-model="form.center_id"
|
||||
:disabled="!!contractId || isFormReadOnly"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="full-width"
|
||||
>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
:disabled="!site.is_active"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="`${TEXT.modules.feeContracts.contractAmount}(万元)`" prop="contract_amount">
|
||||
<el-input-number
|
||||
v-model="form.contract_amount"
|
||||
:disabled="isFormReadOnly"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
class="full-width"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases">
|
||||
<el-input-number v-model="form.contract_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
|
||||
<el-input-number v-model="form.actual_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no">
|
||||
<el-input v-model="form.contract_no" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.signedDate" prop="signed_date">
|
||||
<el-date-picker
|
||||
v-model="form.signed_date"
|
||||
:disabled="isFormReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--3">
|
||||
<el-form-item :label="`${TEXT.modules.feeContracts.contractAmount}(万元)`" prop="contract_amount">
|
||||
<el-input-number
|
||||
v-model="form.contract_amount"
|
||||
:disabled="isFormReadOnly"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
class="full-width"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases">
|
||||
<el-input-number v-model="form.contract_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
|
||||
<el-input-number v-model="form.actual_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
|
||||
<el-input v-model="form.remark" :disabled="isFormReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
|
||||
<div v-if="selectedSiteInactive" class="inactive-hint">
|
||||
<span class="hint-icon">!</span>
|
||||
<span>中心已停用,当前记录不可编辑</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group-title action-title">
|
||||
<span>
|
||||
<span class="group-dot group-dot-payment"></span>
|
||||
<!-- 分期付款 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title form-section-title--action">
|
||||
<span class="section-title-left">
|
||||
<span class="dot dot--amber" />
|
||||
{{ TEXT.modules.feeContracts.paymentTitle }}
|
||||
</span>
|
||||
<el-button type="primary" size="small" :disabled="isFormReadOnly" @click="addPayment">
|
||||
<el-button type="primary" size="small" :disabled="isFormReadOnly" @click="addPayment" class="add-payment-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.common.actions.add }}
|
||||
</el-button>
|
||||
@@ -120,25 +112,24 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="payment-list">
|
||||
<div v-for="(payment, index) in payments" :key="payment.tempId" class="payment-item">
|
||||
<div class="payment-item-header">
|
||||
<div class="payment-seq">
|
||||
<span class="seq-circle">{{ index + 1 }}</span>
|
||||
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
|
||||
<div v-for="(payment, index) in payments" :key="payment.tempId" class="payment-card">
|
||||
<div class="payment-card-header">
|
||||
<div class="payment-card-seq">
|
||||
<span class="seq-badge">{{ index + 1 }}</span>
|
||||
<span class="seq-label">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
|
||||
</div>
|
||||
<el-button link type="danger" :disabled="isFormReadOnly" @click="removePayment(index)">
|
||||
<el-button link type="danger" :disabled="isFormReadOnly" @click="removePayment(index)" class="remove-btn">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="payment-grid">
|
||||
<div class="payment-field payment-field--amount">
|
||||
|
||||
<div class="payment-card-body">
|
||||
<div class="field-grid field-grid--3">
|
||||
<el-form-item :label="`${TEXT.common.fields.amount}(万元)`" :error="paymentErrors[index]?.amount">
|
||||
<el-input-number v-model="payment.amount" :disabled="isFormReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="payment-field payment-field--status">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
|
||||
<div class="status-control">
|
||||
<div class="status-row">
|
||||
<el-checkbox v-model="payment.is_paid" :disabled="isFormReadOnly" @change="() => onPaidToggle(payment)">
|
||||
{{ TEXT.modules.feeContracts.isPaid }}
|
||||
</el-checkbox>
|
||||
@@ -149,15 +140,13 @@
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="isFormReadOnly"
|
||||
class="status-picker"
|
||||
class="status-datepicker"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="payment-field payment-field--status">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
|
||||
<div class="status-control">
|
||||
<div class="status-row">
|
||||
<el-checkbox v-model="payment.is_verified" :disabled="isFormReadOnly" @change="() => onVerifiedToggle(payment)">
|
||||
{{ TEXT.modules.feeContracts.isVerified }}
|
||||
</el-checkbox>
|
||||
@@ -168,18 +157,17 @@
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="isFormReadOnly"
|
||||
class="status-picker"
|
||||
class="status-datepicker"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="payment-field payment-field--remark">
|
||||
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
|
||||
<el-input v-model="payment.remark" :disabled="isFormReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
|
||||
<el-input v-model="payment.remark" :disabled="isFormReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentErrors[index]?.verification" class="payment-error">
|
||||
{{ paymentErrors[index].verification }}
|
||||
</div>
|
||||
@@ -187,9 +175,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group attachment-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--green" />
|
||||
{{ TEXT.modules.feeContracts.attachmentTitle }}
|
||||
</div>
|
||||
<AttachmentList
|
||||
@@ -200,14 +189,16 @@
|
||||
:entity-groups="attachmentGroups"
|
||||
:mode="'upload'"
|
||||
:readonly="isFormReadOnly"
|
||||
:center-upload-card-content="true"
|
||||
:upload-card-columns="3"
|
||||
/>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly || drawerLoading" @click="saveForm">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="emit('update:modelValue', false)" class="footer-btn">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly || drawerLoading" @click="saveForm" class="footer-btn footer-btn--primary">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
@@ -312,18 +303,23 @@ const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||
const selectedSiteInactive = computed(() => !!props.contractId && !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||
const isFormReadOnly = computed(() => (props.contractId ? !canUpdate.value : !canCreate.value) || selectedSiteInactive.value);
|
||||
|
||||
|
||||
|
||||
const attachmentGroups = [
|
||||
{
|
||||
entityType: "contract_fee_contract",
|
||||
label: TEXT.modules.feeContracts.attachmentContract,
|
||||
uploadText: "点击上传合同",
|
||||
},
|
||||
{
|
||||
entityType: "contract_fee_voucher",
|
||||
label: TEXT.modules.feeContracts.attachmentVoucher,
|
||||
uploadText: "点击上传凭证",
|
||||
},
|
||||
{
|
||||
entityType: "contract_fee_invoice",
|
||||
label: TEXT.modules.feeContracts.attachmentInvoice,
|
||||
uploadText: "点击上传发票",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -558,13 +554,14 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==================== Drawer ==================== */
|
||||
:deep(.contract-fee-editor-drawer > .el-drawer__header) {
|
||||
margin-bottom: 0;
|
||||
padding: 22px 20px 8px;
|
||||
padding: 20px 24px 8px;
|
||||
}
|
||||
|
||||
:deep(.contract-fee-editor-drawer > .el-drawer__body) {
|
||||
padding: 0 20px 4px;
|
||||
padding: 0 24px 4px;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
@@ -573,82 +570,90 @@ 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;
|
||||
}
|
||||
|
||||
/* ==================== Form ==================== */
|
||||
.contract-fee-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-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;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
.form-section-title--action {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-title > span {
|
||||
.section-title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
/* Dots */
|
||||
.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; }
|
||||
|
||||
/* ==================== Field Grid ==================== */
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.group-dot-payment {
|
||||
background: #f0ad2c;
|
||||
.field-grid--2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.group-dot-attachment {
|
||||
background: #22c55e;
|
||||
.field-grid--3 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ==================== Inactive Hint ==================== */
|
||||
.inactive-hint {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -662,134 +667,164 @@ watch(
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ==================== Empty State ==================== */
|
||||
.section-empty {
|
||||
padding: 28px 0;
|
||||
padding: 32px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: #8a97ab;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #a3a3a3;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ==================== Payment Cards ==================== */
|
||||
.payment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.payment-item {
|
||||
border: 1px solid #e8eef6;
|
||||
border-radius: 8px;
|
||||
padding: 18px 20px 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||
box-shadow: 0 8px 20px rgba(38, 73, 119, 0.05);
|
||||
.payment-card {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px 14px;
|
||||
background: #fafbfc;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.payment-item-header {
|
||||
.payment-card:hover {
|
||||
border-color: #d4d4d4;
|
||||
}
|
||||
|
||||
.payment-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.payment-seq {
|
||||
.payment-card-seq {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.seq-circle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: #3f5f7a;
|
||||
color: white;
|
||||
display: flex;
|
||||
.seq-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seq-text {
|
||||
font-size: 18px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
background: #f0f0f0;
|
||||
color: #525252;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.payment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.9fr) repeat(2, minmax(220px, 1fr));
|
||||
gap: 14px 18px;
|
||||
align-items: start;
|
||||
.seq-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
.payment-field {
|
||||
min-width: 0;
|
||||
.remove-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.payment-field--remark {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.status-control {
|
||||
.payment-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Status row (checkbox + date picker) */
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-picker {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-picker :deep(.el-date-editor.el-input),
|
||||
.status-picker :deep(.el-input__wrapper) {
|
||||
.status-datepicker {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-datepicker :deep(.el-date-editor.el-input),
|
||||
.status-datepicker :deep(.el-input__wrapper) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ==================== Payment Error ==================== */
|
||||
.payment-error {
|
||||
color: var(--ctms-danger);
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 6px 10px;
|
||||
background: #fef2f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* ==================== Footer ==================== */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ==================== Element Overrides ==================== */
|
||||
.contract-fee-form :deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.contract-fee-form :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4a6283;
|
||||
color: #525252;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.payment-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.contract-fee-form :deep(.el-form-item__error) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.payment-field--remark {
|
||||
grid-column: auto;
|
||||
.add-payment-btn {
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
/* ==================== Responsive ==================== */
|
||||
@media (max-width: 700px) {
|
||||
.field-grid--2,
|
||||
.field-grid--3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("MaterialEquipmentEditorDrawer attachments", () => {
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain('entity-type="material_equipment"');
|
||||
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(props.equipmentId)");
|
||||
expect(source).toContain("uploadPending");
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
:entity-id="equipmentId"
|
||||
:mode="'upload'"
|
||||
:readonly="readonly"
|
||||
:center-upload-card-content="true"
|
||||
/>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
@@ -111,6 +111,19 @@ describe("route view overlays are mounted only when visible", () => {
|
||||
file: "./admin/PermissionManagement.vue",
|
||||
snippets: [':close-on-click-modal="true"', ':before-close="handleTemplateDrawerBeforeClose"'],
|
||||
},
|
||||
{
|
||||
file: "./documents/DocumentList.vue",
|
||||
snippets: [':close-on-click-modal="true"', ':before-close="editorDirtyGuard.beforeClose"'],
|
||||
},
|
||||
{
|
||||
file: "./documents/DocumentDetail.vue",
|
||||
snippets: [
|
||||
':close-on-click-modal="true"',
|
||||
':before-close="editorDirtyGuard.beforeClose"',
|
||||
':before-close="uploadDirtyGuard.beforeClose"',
|
||||
':before-close="distributeDirtyGuard.beforeClose"',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "./admin/AuditLogs.vue",
|
||||
snippets: [':close-on-click-modal="true"'],
|
||||
|
||||
@@ -1,38 +1,110 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="unified-shell" v-loading="loading">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button v-if="canUpdateEthics" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor">
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<!-- ==================== Hero ==================== -->
|
||||
<div class="hero-banner">
|
||||
<div class="hero-content">
|
||||
<div class="hero-top">
|
||||
<div class="hero-title-group">
|
||||
<h2 class="hero-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</h2>
|
||||
<span :class="['hero-status-badge', `hero-status-badge--${statusTagType(status)}`]">
|
||||
{{ statusLabel(status) }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button v-if="canUpdateEthics" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor" class="hero-edit-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--site">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.site }}</span>
|
||||
<span class="stat-value">{{ displaySite(detail.site_id) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--no">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.approvalNo }}</span>
|
||||
<span class="stat-value stat-value--mono">{{ detail.approval_no || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--submit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.submitDate }}</span>
|
||||
<span class="stat-value">{{ displayDate(detail.submit_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--accept">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.acceptDate }}</span>
|
||||
<span class="stat-value">{{ displayDate(detail.accept_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--meeting">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.meetingDate }}</span>
|
||||
<span class="stat-value">{{ displayDate(detail.meeting_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--approved">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.approvedDate }}</span>
|
||||
<span class="stat-value">{{ displayDate(detail.approved_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvalNo">{{ detail.approval_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.meetingDate">{{ displayDate(detail.meeting_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="unified-section attachment-section">
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_ethics"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
:hide-uploader="true"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 附件 ==================== -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-header-left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-divider" />
|
||||
<div class="section-card-body">
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_ethics"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
:hide-uploader="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<EthicsEditorDrawer
|
||||
v-model="editorVisible"
|
||||
:record-id="recordId"
|
||||
@@ -45,6 +117,7 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Edit } from "@element-plus/icons-vue";
|
||||
import dayjs from "dayjs";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getEthics } from "../../api/startup";
|
||||
@@ -52,6 +125,8 @@ import { fetchSites } from "../../api/sites";
|
||||
import type { Site } from "../../types/api";
|
||||
import { displayDate } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateError from "../../components/StateError.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import type { StageStatus } from "../ia/project-overview/overview.adapter";
|
||||
import { TEXT } from "../../locales";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
@@ -61,6 +136,7 @@ const route = useRoute();
|
||||
const study = useStudyStore();
|
||||
const { can } = usePermission();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const editorVisible = ref(false);
|
||||
const recordId = route.params.recordId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
@@ -73,30 +149,15 @@ const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.val
|
||||
const canUpdateEthics = computed(() => can("startup.ethics.update"));
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_id: "",
|
||||
submit_date: "",
|
||||
accept_date: "",
|
||||
meeting_date: "",
|
||||
approved_date: "",
|
||||
approval_no: "",
|
||||
site_id: "", submit_date: "", accept_date: "", meeting_date: "", approved_date: "", approval_no: "",
|
||||
});
|
||||
|
||||
const SUBMIT_TO_ACCEPT_MONTHS =
|
||||
Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) ||
|
||||
Number(import.meta.env.VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS) ||
|
||||
1;
|
||||
const ACCEPT_TO_APPROVAL_MONTHS =
|
||||
Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) ||
|
||||
Number(import.meta.env.VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS) ||
|
||||
1;
|
||||
const SUBMIT_TO_ACCEPT_MONTHS = Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) || 1;
|
||||
const ACCEPT_TO_APPROVAL_MONTHS = Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) || 1;
|
||||
|
||||
const statusLabelMap: Record<StageStatus, string> = {
|
||||
COMPLETED: "已完成",
|
||||
IN_PROGRESS: "进行中",
|
||||
NOT_STARTED: "未开始",
|
||||
BLOCKED: "阻塞/延期",
|
||||
COMPLETED: "已完成", IN_PROGRESS: "进行中", NOT_STARTED: "未开始", BLOCKED: "阻塞/延期",
|
||||
};
|
||||
|
||||
const statusLabel = (status: StageStatus) => statusLabelMap[status] || "未开始";
|
||||
const statusTagType = (status: StageStatus) => {
|
||||
if (status === "COMPLETED") return "success";
|
||||
@@ -109,7 +170,6 @@ const isBlockedByAcceptDate = (acceptDate?: string, approvedDate?: string) => {
|
||||
if (!acceptDate || approvedDate) return false;
|
||||
return dayjs(acceptDate).add(ACCEPT_TO_APPROVAL_MONTHS, "month").isBefore(dayjs());
|
||||
};
|
||||
|
||||
const isBlockedBySubmitDate = (submitDate?: string, acceptDate?: string) => {
|
||||
if (!submitDate || acceptDate) return false;
|
||||
return dayjs(submitDate).add(SUBMIT_TO_ACCEPT_MONTHS, "month").isBefore(dayjs());
|
||||
@@ -128,67 +188,124 @@ const loadSites = async () => {
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
}
|
||||
} catch { sites.value = []; }
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !recordId) return;
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const { data } = await getEthics(studyId, recordId);
|
||||
Object.assign(detail, data);
|
||||
const siteName = displaySite(detail.site_id);
|
||||
const hasSiteName = siteName !== TEXT.common.fallback;
|
||||
study.setViewContext({
|
||||
siteName,
|
||||
pageTitle: detail.approval_no || (hasSiteName ? siteName : TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle),
|
||||
});
|
||||
study.setViewContext({ siteName, pageTitle: detail.approval_no || siteName || TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle });
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
|
||||
} finally { loading.value = false; }
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
if (!canUpdateEthics.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (isInactiveSite(detail.site_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!canUpdateEthics.value) { ElMessage.warning("权限不足"); return; }
|
||||
if (isInactiveSite(detail.site_id)) { ElMessage.warning("中心已停用"); return; }
|
||||
editorVisible.value = true;
|
||||
};
|
||||
const handleEditorSuccess = async () => { await load(); };
|
||||
|
||||
const handleEditorSuccess = async () => {
|
||||
await load();
|
||||
};
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
await load();
|
||||
});
|
||||
onMounted(async () => { await loadSites(); await load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
display: flex; flex-direction: column; gap: 16px; padding: 0; background: transparent;
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
padding: 0;
|
||||
/* ==================== Hero Banner ==================== */
|
||||
.hero-banner {
|
||||
position: relative;
|
||||
background: #fff7ee;
|
||||
border: 1px solid rgba(180, 110, 50, 0.10);
|
||||
border-radius: 8px;
|
||||
padding: 28px 32px 24px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.7) inset,
|
||||
0 1px 2px rgba(180, 110, 50, 0.04);
|
||||
}
|
||||
</style>
|
||||
.hero-content { position: relative; z-index: 1; }
|
||||
.hero-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 22px; }
|
||||
.hero-title-group { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
.hero-title { margin: 0; font-size: 19px; font-weight: 600; color: #3d2410; letter-spacing: 0.005em; }
|
||||
|
||||
.hero-status-badge {
|
||||
display: inline-flex; align-items: center; padding: 2px 12px; font-size: 11px; font-weight: 500;
|
||||
border-radius: 20px; letter-spacing: 0.02em; backdrop-filter: blur(8px);
|
||||
}
|
||||
.hero-status-badge--info { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.55); border: 1px solid rgba(180,110,50,0.12); }
|
||||
.hero-status-badge--primary { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.78); border: 1px solid rgba(180,110,50,0.18); }
|
||||
.hero-status-badge--success { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.78); border: 1px solid rgba(180,110,50,0.18); }
|
||||
.hero-status-badge--danger { background: rgba(255,240,240,0.70); color: rgba(160,40,40,0.75); border: 1px solid rgba(200,80,80,0.18); }
|
||||
|
||||
.hero-edit-btn {
|
||||
height: 34px; border-radius: 6px; padding: 0 18px; font-weight: 500;
|
||||
background: rgba(255,255,255,0.85); border: 1px solid rgba(180,110,50,0.18); color: #3d2410;
|
||||
backdrop-filter: blur(10px) saturate(140%); -webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 1px 2px rgba(180,110,50,0.06);
|
||||
}
|
||||
.hero-edit-btn:hover {
|
||||
background: #ffffff; border-color: rgba(180,110,50,0.30); color: #3d2410;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 4px 12px rgba(212,130,60,0.14);
|
||||
}
|
||||
.hero-edit-btn:active { transform: scale(0.97); }
|
||||
|
||||
.hero-stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; }
|
||||
|
||||
.hero-stat {
|
||||
position: relative; display: flex; align-items: center; gap: 14px; padding: 14px 16px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 100%);
|
||||
border: 1px solid rgba(180,110,50,0.10); border-radius: 6px;
|
||||
backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||
transition: all 0.25s ease;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 1px 2px rgba(180,110,50,0.04);
|
||||
}
|
||||
.hero-stat:hover {
|
||||
background: linear-gradient(180deg, #ffffff 0%, rgba(255,255,255,0.92) 100%);
|
||||
border-color: rgba(180,110,50,0.18); transform: translateY(-1px);
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 6px 16px rgba(212,130,60,0.10);
|
||||
}
|
||||
|
||||
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; border: 1px solid rgba(180,110,50,0.06); }
|
||||
.stat-icon-wrap--site { background: rgba(212,145,80,0.14); color: #b06b30; }
|
||||
.stat-icon-wrap--no { background: rgba(168,158,80,0.16); color: #8a7028; }
|
||||
.stat-icon-wrap--submit { background: rgba(220,160,90,0.18); color: #b87532; }
|
||||
.stat-icon-wrap--accept { background: rgba(196,116,92,0.14); color: #a85838; }
|
||||
.stat-icon-wrap--meeting { background: rgba(212,145,80,0.14); color: #b06b30; }
|
||||
.stat-icon-wrap--approved { background: rgba(196,100,100,0.14); color: #aa4444; }
|
||||
|
||||
.stat-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.stat-label { font-size: 11px; font-weight: 500; color: rgba(80,45,20,0.50); letter-spacing: 0.03em; text-transform: uppercase; }
|
||||
.stat-value { font-size: 14px; font-weight: 600; color: #3d2410; word-break: break-all; }
|
||||
.stat-value--mono { font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
|
||||
/* ==================== Section Card ==================== */
|
||||
.section-card { background: #ffffff; border-radius: 6px; overflow: hidden; box-shadow: none; }
|
||||
.section-card-header { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px 0; }
|
||||
.section-card-header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.section-card-title { font-size: 14px; font-weight: 600; color: #0a0a0a; letter-spacing: -0.01em; }
|
||||
.section-divider { display: none; }
|
||||
.section-card-body { padding: 20px 24px 24px; }
|
||||
|
||||
.accent-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.accent-dot--blue { background: #3b82f6; }
|
||||
|
||||
@media (max-width: 1200px) { .hero-stats { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 960px) { .hero-stats { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 640px) {
|
||||
.hero-banner { padding: 20px; }
|
||||
.hero-stats { grid-template-columns: 1fr; }
|
||||
.hero-top { flex-direction: column; gap: 12px; }
|
||||
.hero-edit-btn { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,101 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="unified-shell" v-loading="loading">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button v-if="canUpdateInitiation" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor">
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<!-- ==================== Hero ==================== -->
|
||||
<div class="hero-banner">
|
||||
<div class="hero-content">
|
||||
<div class="hero-top">
|
||||
<div class="hero-title-group">
|
||||
<h2 class="hero-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</h2>
|
||||
<span :class="['hero-status-badge', `hero-status-badge--${statusTagType(status)}`]">
|
||||
{{ statusLabel(status) }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button v-if="canUpdateInitiation" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor" class="hero-edit-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--site">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.site }}</span>
|
||||
<span class="stat-value">{{ displaySite(detail.site_id) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--no">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.projectNo }}</span>
|
||||
<span class="stat-value stat-value--mono">{{ detail.project_no || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--submit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.submitDate }}</span>
|
||||
<span class="stat-value">{{ displayDate(detail.submit_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--accept">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.acceptDate }}</span>
|
||||
<span class="stat-value">{{ displayDate(detail.accept_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="stat-icon-wrap stat-icon-wrap--approved">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-label">{{ TEXT.common.fields.approvedDate }}</span>
|
||||
<span class="stat-value">{{ displayDate(detail.approved_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.projectNo">{{ detail.project_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="unified-section attachment-section">
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
:hide-uploader="true"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 附件 ==================== -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-header-left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-divider" />
|
||||
<div class="section-card-body">
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
:hide-uploader="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<FeasibilityEditorDrawer
|
||||
v-model="editorVisible"
|
||||
:record-id="recordId"
|
||||
@@ -44,6 +108,7 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Edit } from "@element-plus/icons-vue";
|
||||
import dayjs from "dayjs";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getFeasibility } from "../../api/startup";
|
||||
@@ -51,6 +116,8 @@ import { fetchSites } from "../../api/sites";
|
||||
import type { Site } from "../../types/api";
|
||||
import { displayDate } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateError from "../../components/StateError.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import type { StageStatus } from "../ia/project-overview/overview.adapter";
|
||||
import { TEXT } from "../../locales";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
@@ -60,6 +127,7 @@ const route = useRoute();
|
||||
const study = useStudyStore();
|
||||
const { can } = usePermission();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const editorVisible = ref(false);
|
||||
const recordId = route.params.recordId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
@@ -72,29 +140,15 @@ const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.val
|
||||
const canUpdateInitiation = computed(() => can("startup.initiation.update"));
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_id: "",
|
||||
submit_date: "",
|
||||
accept_date: "",
|
||||
approved_date: "",
|
||||
project_no: "",
|
||||
site_id: "", submit_date: "", accept_date: "", approved_date: "", project_no: "",
|
||||
});
|
||||
|
||||
const SUBMIT_TO_ACCEPT_MONTHS =
|
||||
Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) ||
|
||||
Number(import.meta.env.VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS) ||
|
||||
1;
|
||||
const ACCEPT_TO_APPROVAL_MONTHS =
|
||||
Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) ||
|
||||
Number(import.meta.env.VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS) ||
|
||||
1;
|
||||
const SUBMIT_TO_ACCEPT_MONTHS = Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) || 1;
|
||||
const ACCEPT_TO_APPROVAL_MONTHS = Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) || 1;
|
||||
|
||||
const statusLabelMap: Record<StageStatus, string> = {
|
||||
COMPLETED: "已完成",
|
||||
IN_PROGRESS: "进行中",
|
||||
NOT_STARTED: "未开始",
|
||||
BLOCKED: "阻塞/延期",
|
||||
COMPLETED: "已完成", IN_PROGRESS: "进行中", NOT_STARTED: "未开始", BLOCKED: "阻塞/延期",
|
||||
};
|
||||
|
||||
const statusLabel = (status: StageStatus) => statusLabelMap[status] || "未开始";
|
||||
const statusTagType = (status: StageStatus) => {
|
||||
if (status === "COMPLETED") return "success";
|
||||
@@ -107,7 +161,6 @@ const isBlockedByAcceptDate = (acceptDate?: string, approvedDate?: string) => {
|
||||
if (!acceptDate || approvedDate) return false;
|
||||
return dayjs(acceptDate).add(ACCEPT_TO_APPROVAL_MONTHS, "month").isBefore(dayjs());
|
||||
};
|
||||
|
||||
const isBlockedBySubmitDate = (submitDate?: string, acceptDate?: string) => {
|
||||
if (!submitDate || acceptDate) return false;
|
||||
return dayjs(submitDate).add(SUBMIT_TO_ACCEPT_MONTHS, "month").isBefore(dayjs());
|
||||
@@ -126,67 +179,122 @@ const loadSites = async () => {
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
}
|
||||
} catch { sites.value = []; }
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !recordId) return;
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const { data } = await getFeasibility(studyId, recordId);
|
||||
Object.assign(detail, data);
|
||||
const siteName = displaySite(detail.site_id);
|
||||
const hasSiteName = siteName !== TEXT.common.fallback;
|
||||
study.setViewContext({
|
||||
siteName,
|
||||
pageTitle: detail.project_no || (hasSiteName ? siteName : TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle),
|
||||
});
|
||||
study.setViewContext({ siteName, pageTitle: detail.project_no || siteName || TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle });
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
|
||||
} finally { loading.value = false; }
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
if (!canUpdateInitiation.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (isInactiveSite(detail.site_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!canUpdateInitiation.value) { ElMessage.warning("权限不足"); return; }
|
||||
if (isInactiveSite(detail.site_id)) { ElMessage.warning("中心已停用"); return; }
|
||||
editorVisible.value = true;
|
||||
};
|
||||
const handleEditorSuccess = async () => { await load(); };
|
||||
|
||||
const handleEditorSuccess = async () => {
|
||||
await load();
|
||||
};
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
await load();
|
||||
});
|
||||
onMounted(async () => { await loadSites(); await load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
display: flex; flex-direction: column; gap: 16px; padding: 0; background: transparent;
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
padding: 0;
|
||||
/* ==================== Hero Banner ==================== */
|
||||
.hero-banner {
|
||||
position: relative;
|
||||
background: #fff7ee;
|
||||
border: 1px solid rgba(180, 110, 50, 0.10);
|
||||
border-radius: 8px;
|
||||
padding: 28px 32px 24px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.7) inset,
|
||||
0 1px 2px rgba(180, 110, 50, 0.04);
|
||||
}
|
||||
</style>
|
||||
.hero-content { position: relative; z-index: 1; }
|
||||
.hero-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 22px; }
|
||||
.hero-title-group { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
.hero-title { margin: 0; font-size: 19px; font-weight: 600; color: #3d2410; letter-spacing: 0.005em; }
|
||||
|
||||
.hero-status-badge {
|
||||
display: inline-flex; align-items: center; padding: 2px 12px; font-size: 11px; font-weight: 500;
|
||||
border-radius: 20px; letter-spacing: 0.02em; backdrop-filter: blur(8px);
|
||||
}
|
||||
.hero-status-badge--info { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.55); border: 1px solid rgba(180,110,50,0.12); }
|
||||
.hero-status-badge--primary { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.78); border: 1px solid rgba(180,110,50,0.18); }
|
||||
.hero-status-badge--success { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.78); border: 1px solid rgba(180,110,50,0.18); }
|
||||
.hero-status-badge--danger { background: rgba(255,240,240,0.70); color: rgba(160,40,40,0.75); border: 1px solid rgba(200,80,80,0.18); }
|
||||
|
||||
.hero-edit-btn {
|
||||
height: 34px; border-radius: 6px; padding: 0 18px; font-weight: 500;
|
||||
background: rgba(255,255,255,0.85); border: 1px solid rgba(180,110,50,0.18); color: #3d2410;
|
||||
backdrop-filter: blur(10px) saturate(140%); -webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 1px 2px rgba(180,110,50,0.06);
|
||||
}
|
||||
.hero-edit-btn:hover {
|
||||
background: #ffffff; border-color: rgba(180,110,50,0.30); color: #3d2410;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 4px 12px rgba(212,130,60,0.14);
|
||||
}
|
||||
.hero-edit-btn:active { transform: scale(0.97); }
|
||||
|
||||
.hero-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
|
||||
|
||||
.hero-stat {
|
||||
position: relative; display: flex; align-items: center; gap: 14px; padding: 14px 16px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 100%);
|
||||
border: 1px solid rgba(180,110,50,0.10); border-radius: 6px;
|
||||
backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||
transition: all 0.25s ease;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 1px 2px rgba(180,110,50,0.04);
|
||||
}
|
||||
.hero-stat:hover {
|
||||
background: linear-gradient(180deg, #ffffff 0%, rgba(255,255,255,0.92) 100%);
|
||||
border-color: rgba(180,110,50,0.18); transform: translateY(-1px);
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 6px 16px rgba(212,130,60,0.10);
|
||||
}
|
||||
|
||||
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; border: 1px solid rgba(180,110,50,0.06); }
|
||||
.stat-icon-wrap--site { background: rgba(212,145,80,0.14); color: #b06b30; }
|
||||
.stat-icon-wrap--no { background: rgba(168,158,80,0.16); color: #8a7028; }
|
||||
.stat-icon-wrap--submit { background: rgba(220,160,90,0.18); color: #b87532; }
|
||||
.stat-icon-wrap--accept { background: rgba(196,116,92,0.14); color: #a85838; }
|
||||
.stat-icon-wrap--approved { background: rgba(196,100,100,0.14); color: #aa4444; }
|
||||
|
||||
.stat-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.stat-label { font-size: 11px; font-weight: 500; color: rgba(80,45,20,0.50); letter-spacing: 0.03em; text-transform: uppercase; }
|
||||
.stat-value { font-size: 14px; font-weight: 600; color: #3d2410; word-break: break-all; }
|
||||
.stat-value--mono { font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
|
||||
/* ==================== Section Card ==================== */
|
||||
.section-card { background: #ffffff; border-radius: 6px; overflow: hidden; box-shadow: none; }
|
||||
.section-card-header { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px 0; }
|
||||
.section-card-header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.section-card-title { font-size: 14px; font-weight: 600; color: #0a0a0a; letter-spacing: -0.01em; }
|
||||
.section-divider { display: none; }
|
||||
.section-card-body { padding: 20px 24px 24px; }
|
||||
|
||||
.accent-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.accent-dot--blue { background: #3b82f6; }
|
||||
|
||||
@media (max-width: 960px) { .hero-stats { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 640px) {
|
||||
.hero-banner { padding: 20px; }
|
||||
.hero-stats { grid-template-columns: 1fr; }
|
||||
.hero-top { flex-direction: column; gap: 12px; }
|
||||
.hero-edit-btn { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,117 +1,126 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button
|
||||
v-if="canUpdateAuth"
|
||||
type="primary"
|
||||
:disabled="loading || isReadOnly"
|
||||
@click="startEdit"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="loadAll">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<!-- ==================== 顶部信息区 ==================== -->
|
||||
<div class="top-section">
|
||||
<div class="top-header">
|
||||
<div class="top-header-left">
|
||||
<span class="top-breadcrumb">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</span>
|
||||
<h1 class="top-title">{{ siteInfo.name || TEXT.common.fallback }}</h1>
|
||||
</div>
|
||||
<el-button v-if="canUpdateAuth" :disabled="loading || isReadOnly" @click="startEdit" class="top-edit-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="info-bar">
|
||||
<div class="info-block">
|
||||
<span class="info-block-label">{{ TEXT.modules.startupMeetingAuth.ownerLabel }}</span>
|
||||
<span class="info-block-value">{{ ownerLabel }}</span>
|
||||
</div>
|
||||
<div class="info-divider" />
|
||||
<div class="info-block">
|
||||
<span class="info-block-label">{{ TEXT.common.fields.status }}</span>
|
||||
<span :class="['info-block-value', statusTagType === 'success' ? 'info-block-value--success' : 'info-block-value--info']">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="info-divider" />
|
||||
<div class="info-block">
|
||||
<span class="info-block-label">{{ TEXT.common.fields.kickoffDate }}</span>
|
||||
<span class="info-block-value">{{ displayDate(detail.kickoff_date) || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">
|
||||
{{ siteInfo.name || TEXT.common.fallback }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
|
||||
{{ ownerLabel }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
|
||||
{{ displayDate(detail.kickoff_date) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="unified-section training-section">
|
||||
<div class="training-header">
|
||||
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
|
||||
<el-button v-if="canCreateAuth" type="primary" size="small" :disabled="isReadOnly" @click="startTrainingAdd">
|
||||
<!-- ==================== 培训授权列表 ==================== -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title-row">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
||||
<span class="section-card-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</span>
|
||||
<span class="section-card-badge">{{ trainingItems.length }}</span>
|
||||
</div>
|
||||
<el-button v-if="canCreateAuth" size="small" :disabled="isReadOnly" @click="startTrainingAdd" class="add-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.common.actions.add }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%" class="ctms-table" table-layout="fixed">
|
||||
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%" class="training-table" table-layout="fixed">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.name" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ scope.row.name || TEXT.common.fallback }}
|
||||
</template>
|
||||
<template #default="scope"><span class="cell-primary cell-nowrap">{{ scope.row.name || TEXT.common.fallback }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ scope.row.role || TEXT.common.fallback }}
|
||||
</template>
|
||||
<template #default="scope"><span class="cell-nowrap">{{ scope.row.role || TEXT.common.fallback }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.trained">
|
||||
<template #default="scope">
|
||||
<div class="status-with-date">
|
||||
<span>{{ trainingStatusLabel(scope.row) }}</span>
|
||||
<span v-if="scope.row.trained && scope.row.trained_date" class="status-date-small">
|
||||
{{ displayDate(scope.row.trained_date) }}
|
||||
</span>
|
||||
<div class="status-cell">
|
||||
<span :class="['status-dot', scope.row.trained ? 'status-dot--yes' : 'status-dot--no']" />
|
||||
<span>{{ scope.row.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
|
||||
<span v-if="scope.row.trained && scope.row.trained_date" class="status-date">{{ displayDate(scope.row.trained_date) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.authorized">
|
||||
<template #default="scope">
|
||||
<div class="status-with-date">
|
||||
<span>{{ authorizationStatusLabel(scope.row) }}</span>
|
||||
<span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date-small">
|
||||
{{ displayDate(scope.row.authorized_date) }}
|
||||
</span>
|
||||
<div class="status-cell">
|
||||
<span :class="['status-dot', scope.row.authorized ? 'status-dot--yes' : 'status-dot--no']" />
|
||||
<span>{{ scope.row.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
|
||||
<span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date">{{ displayDate(scope.row.authorized_date) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canCreateAuth || canUpdateAuth || canDeleteAuth" :label="TEXT.common.labels.actions" width="140">
|
||||
<el-table-column v-if="canCreateAuth || canUpdateAuth || canDeleteAuth" :label="TEXT.common.labels.actions" width="110" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<div v-if="!loadingTraining" 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="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.startupMeetingAuth.emptyTraining }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section v-if="meetingId" class="unified-section attachment-section">
|
||||
<AttachmentList
|
||||
:study-id="studyId"
|
||||
entity-type="startup_kickoff"
|
||||
:entity-id="meetingId"
|
||||
:entity-groups="kickoffAttachmentGroups"
|
||||
:title="TEXT.common.labels.attachments"
|
||||
:readonly="true"
|
||||
:hide-uploader="true"
|
||||
:refresh-key="attachmentRefreshKey"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<KickoffEditorDrawer
|
||||
v-model="editorVisible"
|
||||
:meeting-id="meetingId"
|
||||
:study-id="studyId"
|
||||
:site-name="siteInfo.name"
|
||||
:owner-label="ownerLabel"
|
||||
:readonly="isReadOnly"
|
||||
@saved="handleEditorSaved"
|
||||
/>
|
||||
<TrainingEditorDrawer
|
||||
v-model="trainingEditorVisible"
|
||||
:record-id="editingTrainingId"
|
||||
:study-id="studyId"
|
||||
:sites="sites"
|
||||
:initial-site-name="siteInfo.name"
|
||||
@saved="handleTrainingEditorSaved"
|
||||
/>
|
||||
<!-- ==================== 附件 ==================== -->
|
||||
<div v-if="meetingId" class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title-row">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#6c5ce7"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<AttachmentList
|
||||
:study-id="studyId"
|
||||
entity-type="startup_kickoff"
|
||||
:entity-id="meetingId"
|
||||
:entity-groups="kickoffAttachmentGroups"
|
||||
:readonly="true"
|
||||
:hide-uploader="true"
|
||||
:refresh-key="attachmentRefreshKey"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<KickoffEditorDrawer v-model="editorVisible" :meeting-id="meetingId" :study-id="studyId" :site-name="siteInfo.name" :owner-label="ownerLabel" :readonly="isReadOnly" @saved="handleEditorSaved" />
|
||||
<TrainingEditorDrawer v-model="trainingEditorVisible" :record-id="editingTrainingId" :study-id="studyId" :sites="sites" :initial-site-name="siteInfo.name" @saved="handleTrainingEditorSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -119,18 +128,17 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Plus } from "@element-plus/icons-vue";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import {
|
||||
getKickoff,
|
||||
listTrainingAuthorizations,
|
||||
deleteTrainingAuthorization,
|
||||
} from "../../api/startup";
|
||||
import { getKickoff, listTrainingAuthorizations, deleteTrainingAuthorization } from "../../api/startup";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { listMembers } from "../../api/members";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { displayDate } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateError from "../../components/StateError.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import KickoffEditorDrawer from "./KickoffEditorDrawer.vue";
|
||||
@@ -141,6 +149,7 @@ const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const { can } = usePermission();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const loadingTraining = ref(false);
|
||||
const editorVisible = ref(false);
|
||||
const trainingEditorVisible = ref(false);
|
||||
@@ -164,46 +173,33 @@ const kickoffAttachmentGroups = [
|
||||
{ entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
|
||||
];
|
||||
|
||||
const detail = reactive<any>({
|
||||
kickoff_date: "",
|
||||
});
|
||||
const detail = reactive<any>({ kickoff_date: "" });
|
||||
const trainingItems = ref<any[]>([]);
|
||||
|
||||
const memberNameMap = computed(() => {
|
||||
const map: Record<string, string> = {};
|
||||
users.value.forEach((u: any) => {
|
||||
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
|
||||
});
|
||||
users.value.forEach((u: any) => { if (u?.id) map[u.id] = u.full_name || u.username || u.id; });
|
||||
members.value.forEach((m: any) => {
|
||||
const user = m?.user;
|
||||
if (m?.user_id && !map[m.user_id]) {
|
||||
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
|
||||
}
|
||||
if (m?.user_id && !map[m.user_id]) map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const ownerLabel = computed(() => {
|
||||
if (!siteInfo.contact) return TEXT.common.fallback;
|
||||
return String(siteInfo.contact)
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean)
|
||||
.map((c) => memberNameMap.value[c] || c)
|
||||
.join("、") || TEXT.common.fallback;
|
||||
return String(siteInfo.contact).split(",").map((c) => c.trim()).filter(Boolean).map((c) => memberNameMap.value[c] || c).join("、") || TEXT.common.fallback;
|
||||
});
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
|
||||
);
|
||||
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
|
||||
const statusLabel = computed(() => detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending);
|
||||
const statusTagType = computed(() => detail.kickoff_date ? "success" : "info");
|
||||
const isReadOnly = computed(() => siteInfo.is_active === false);
|
||||
const trainingStatusLabel = (row: any) => (row?.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
|
||||
const authorizationStatusLabel = (row: any) => (row?.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
|
||||
|
||||
const loadAll = async () => { await load(); await loadTraining(); };
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !meetingId) return;
|
||||
loading.value = true;
|
||||
loading.value = true; errorMessage.value = "";
|
||||
try {
|
||||
const { data } = await getKickoff(studyId, meetingId);
|
||||
Object.assign(detail, data);
|
||||
@@ -213,19 +209,11 @@ const load = async () => {
|
||||
sites.value = list;
|
||||
const matched = list.find((site: any) => site.id === data.site_id);
|
||||
Object.assign(siteInfo, matched || { name: "" });
|
||||
} else {
|
||||
Object.assign(siteInfo, { name: "" });
|
||||
}
|
||||
} else { Object.assign(siteInfo, { name: "" }); }
|
||||
const siteName = siteInfo.name || TEXT.common.fallback;
|
||||
study.setViewContext({
|
||||
siteName,
|
||||
pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle,
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
study.setViewContext({ siteName, pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle });
|
||||
} catch (e: any) { errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed; }
|
||||
finally { loading.value = false; }
|
||||
};
|
||||
|
||||
const loadTraining = async () => {
|
||||
@@ -234,134 +222,250 @@ const loadTraining = async () => {
|
||||
try {
|
||||
const { data } = await listTrainingAuthorizations(studyId, { site_name: siteInfo.name });
|
||||
trainingItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loadingTraining.value = false;
|
||||
}
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); }
|
||||
finally { loadingTraining.value = false; }
|
||||
};
|
||||
|
||||
const startTrainingEdit = (row: any) => {
|
||||
if (isReadOnly.value) return;
|
||||
if (!canUpdateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
editingTrainingId.value = row?.id || undefined;
|
||||
trainingEditorVisible.value = true;
|
||||
if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
|
||||
editingTrainingId.value = row?.id || undefined; trainingEditorVisible.value = true;
|
||||
};
|
||||
|
||||
const startTrainingAdd = () => {
|
||||
if (isReadOnly.value) return;
|
||||
if (!canCreateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
editingTrainingId.value = undefined;
|
||||
trainingEditorVisible.value = true;
|
||||
if (!canCreateAuth.value) { ElMessage.warning("权限不足"); return; }
|
||||
editingTrainingId.value = undefined; trainingEditorVisible.value = true;
|
||||
};
|
||||
|
||||
const removeTraining = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!canDeleteAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; }
|
||||
if (!canDeleteAuth.value) { ElMessage.warning("权限不足"); return; }
|
||||
if (!studyId) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteTrainingAuthorization(studyId, row.id);
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
loadTraining();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
}
|
||||
try { await deleteTrainingAuthorization(studyId, row.id); ElMessage.success(TEXT.common.messages.deleteSuccess); loadTraining(); }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); }
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!canUpdateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; }
|
||||
if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEditorSaved = () => {
|
||||
load();
|
||||
attachmentRefreshKey.value += 1;
|
||||
};
|
||||
|
||||
const handleTrainingEditorSaved = () => {
|
||||
loadTraining();
|
||||
};
|
||||
const handleEditorSaved = () => { load(); attachmentRefreshKey.value += 1; };
|
||||
const handleTrainingEditorSaved = () => { loadTraining(); };
|
||||
|
||||
onMounted(async () => {
|
||||
if (studyId) {
|
||||
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
|
||||
let membersReq: Promise<any> = Promise.resolve(null);
|
||||
if (canReadMembers.value) {
|
||||
membersReq = listMembers(studyId, { limit: 500 });
|
||||
}
|
||||
if (canReadMembers.value) membersReq = listMembers(studyId, { limit: 500 });
|
||||
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
|
||||
if (membersResp?.data) {
|
||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||
}
|
||||
if (usersResp?.data && !Array.isArray(usersResp.data)) {
|
||||
users.value = usersResp.data.items || [];
|
||||
}
|
||||
if (membersResp?.data) members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||
if (usersResp?.data && !Array.isArray(usersResp.data)) users.value = usersResp.data.items || [];
|
||||
}
|
||||
await load();
|
||||
await loadTraining();
|
||||
await loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex; flex-direction: column; gap: 16px; padding: 4px 0; background: #f4f5f7;
|
||||
}
|
||||
|
||||
/* ==================== Top Section ==================== */
|
||||
.top-section {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.top-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.training-section,
|
||||
.attachment-section {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.training-section {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.training-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.training-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.status-with-date {
|
||||
display: inline-flex;
|
||||
.top-header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.top-breadcrumb {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.top-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.status-date-small {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
.top-edit-btn {
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
font-weight: 500;
|
||||
background: #f4f3ff;
|
||||
border: 1px solid #e0ddff;
|
||||
color: #6c5ce7;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.top-edit-btn:hover {
|
||||
background: #6c5ce7;
|
||||
border-color: #6c5ce7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ==================== Info Bar ==================== */
|
||||
.info-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: #f8f9fc;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-block + .info-block {
|
||||
border-left: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.info-block-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.info-block-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.info-block-value--success { color: #16a34a; }
|
||||
.info-block-value--info { color: #6b7280; }
|
||||
|
||||
.info-divider { display: none; }
|
||||
|
||||
/* ==================== Section Cards ==================== */
|
||||
.section-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.section-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #6c5ce7;
|
||||
}
|
||||
|
||||
.section-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.section-card-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: #f0f0f5;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.section-card-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
/* ==================== Training Table ==================== */
|
||||
.training-table {
|
||||
--el-table-border-color: transparent;
|
||||
--el-table-row-hover-bg-color: #f8f9fb;
|
||||
}
|
||||
.training-table :deep(.el-table__inner-wrapper::before) { display: none; }
|
||||
.training-table :deep(th.el-table__cell) {
|
||||
background: #f8f9fc;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #edf0f5 !important;
|
||||
}
|
||||
.training-table :deep(td.el-table__cell) {
|
||||
padding: 10px 14px;
|
||||
color: #1a1a2e;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.training-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: #1a1a2e; }
|
||||
.cell-actions { display: flex; gap: 8px; white-space: nowrap; }
|
||||
.cell-actions :deep(.el-button) { font-size: 12px; padding: 0; height: auto; }
|
||||
.cell-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
|
||||
/* ==================== Status ==================== */
|
||||
.status-cell { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot--yes { background: #22c55e; }
|
||||
.status-dot--no { background: #d1d5db; }
|
||||
.status-date { font-size: 11px; color: #9ca3af; margin-left: 2px; }
|
||||
|
||||
/* ==================== Empty State ==================== */
|
||||
.table-empty { min-height: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; }
|
||||
.empty-icon { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #f3f4f6; color: #c4c4c4; }
|
||||
.table-empty span { font-size: 13px; font-weight: 500; color: #a3a3a3; }
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<el-form v-else ref="formRef" :model="form" label-position="top" class="startup-editor-form">
|
||||
<div class="form-group">
|
||||
<div class="form-group-title">
|
||||
<span class="group-dot group-dot-basic"></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
<div v-if="meetingId" class="form-group">
|
||||
<div class="form-group-title">
|
||||
<span class="group-dot group-dot-attachment"></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#8b5cf6;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
{{ TEXT.common.labels.attachments }}
|
||||
</div>
|
||||
<AttachmentList
|
||||
|
||||
@@ -1,32 +1,78 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button v-if="canUpdateAuth" type="primary" :disabled="isInactiveSite" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<!-- ==================== 顶部信息卡 ==================== -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-accent" />
|
||||
<div class="hero-body">
|
||||
<div class="hero-top">
|
||||
<div class="hero-main">
|
||||
<div class="hero-label">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
|
||||
<h1 class="hero-title">{{ detail.name || TEXT.common.fallback }}</h1>
|
||||
<div class="hero-role-tag">{{ displayEnum(TEXT.enums.userRole, detail.role) }}</div>
|
||||
</div>
|
||||
<el-button v-if="canUpdateAuth" :disabled="isInactiveSite" @click="goEdit" class="hero-action-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<div class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<span class="meta-label">{{ TEXT.common.fields.site }}</span>
|
||||
<span class="meta-value">{{ detail.site_name || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="meta-divider" />
|
||||
<div class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span class="meta-label">{{ TEXT.common.fields.trainedDate }}</span>
|
||||
<span class="meta-value">{{ displayDate(detail.trained_date) }}</span>
|
||||
</div>
|
||||
<div class="meta-divider" />
|
||||
<div class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<span class="meta-label">{{ TEXT.common.fields.authorizedDate }}</span>
|
||||
<span class="meta-value">{{ displayDate(detail.authorized_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-status-row">
|
||||
<div class="hero-status-item">
|
||||
<span class="hero-status-circle" :class="detail.trained ? 'circle--yes' : 'circle--no'" />
|
||||
<div class="hero-status-text">
|
||||
<span class="hero-status-label">{{ TEXT.common.fields.trained }}</span>
|
||||
<span class="hero-status-value">{{ detail.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-status-item">
|
||||
<span class="hero-status-circle" :class="detail.authorized ? 'circle--yes' : 'circle--no'" />
|
||||
<div class="hero-status-text">
|
||||
<span class="hero-status-label">{{ TEXT.common.fields.authorized }}</span>
|
||||
<span class="hero-status-value">{{ detail.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.name">{{ detail.name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.labels.role">{{ displayEnum(TEXT.enums.userRole, detail.role) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.trained">{{ detail.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.trainedDate">{{ displayDate(detail.trained_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.authorized">{{ detail.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.authorizedDate">{{ displayDate(detail.authorized_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
</div>
|
||||
<TrainingEditorDrawer
|
||||
v-model="editorVisible"
|
||||
:record-id="recordId"
|
||||
:study-id="studyId"
|
||||
:sites="sites"
|
||||
@saved="handleEditorSaved"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 备注 ==================== -->
|
||||
<div v-if="detail.remark" class="remark-card">
|
||||
<div class="remark-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>{{ TEXT.common.fields.remark }}</span>
|
||||
</div>
|
||||
<p class="remark-text">{{ detail.remark }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<TrainingEditorDrawer v-model="editorVisible" :record-id="recordId" :study-id="studyId" :sites="sites" @saved="handleEditorSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,10 +80,13 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Edit } from "@element-plus/icons-vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getTrainingAuthorization } from "../../api/startup";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import StateError from "../../components/StateError.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
|
||||
@@ -46,86 +95,240 @@ const route = useRoute();
|
||||
const study = useStudyStore();
|
||||
const { can } = usePermission();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const editorVisible = ref(false);
|
||||
const recordId = route.params.recordId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const detail = reactive<any>({
|
||||
name: "",
|
||||
role: "",
|
||||
site_name: "",
|
||||
trained: false,
|
||||
authorized: false,
|
||||
trained_date: "",
|
||||
authorized_date: "",
|
||||
remark: "",
|
||||
name: "", role: "", site_name: "", trained: false, authorized: false, trained_date: "", authorized_date: "", remark: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
sites.value.forEach((site) => { if (site?.name) map[site.name] = !!site.is_active; });
|
||||
return map;
|
||||
});
|
||||
const canUpdateAuth = computed(() => can("startup.auth.update"));
|
||||
const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||
const isReadOnly = computed(() => !canUpdateAuth.value || isInactiveSite.value);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
} catch { sites.value = []; }
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !recordId) return;
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const { data } = await getTrainingAuthorization(studyId, recordId);
|
||||
Object.assign(detail, data);
|
||||
study.setViewContext({
|
||||
siteName: detail.site_name || TEXT.common.fallback,
|
||||
pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle,
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
study.setViewContext({ siteName: detail.site_name || TEXT.common.fallback, pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle });
|
||||
} catch (e: any) { errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed; }
|
||||
finally { loading.value = false; }
|
||||
};
|
||||
|
||||
const goEdit = () => {
|
||||
if (!canUpdateAuth.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
if (isInactiveSite.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
|
||||
if (isInactiveSite.value) { ElMessage.warning("中心已停用"); return; }
|
||||
editorVisible.value = true;
|
||||
};
|
||||
const handleEditorSaved = () => { load(); };
|
||||
|
||||
const handleEditorSaved = () => {
|
||||
load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
onMounted(async () => { await loadSites(); load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex; flex-direction: column; gap: 16px; padding: 4px 0; background: #f4f5f7;
|
||||
}
|
||||
|
||||
/* ==================== Hero Card ==================== */
|
||||
.hero-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.hero-accent {
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #6c5ce7 0%, #a29bfe 50%, #6c5ce7 100%);
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
padding: 24px 28px 20px;
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8a8a8a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-role-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: #f4f3ff;
|
||||
color: #6c5ce7;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.hero-action-btn {
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
font-weight: 500;
|
||||
background: #f4f3ff;
|
||||
border: 1px solid #e0ddff;
|
||||
color: #6c5ce7;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.hero-action-btn:hover {
|
||||
background: #6c5ce7;
|
||||
border-color: #6c5ce7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ==================== Hero Meta Row ==================== */
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fc;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.meta-item svg { flex-shrink: 0; }
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ==================== Status Row ==================== */
|
||||
.hero-status-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero-status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-status-circle {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.circle--yes { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); }
|
||||
.circle--no { background: #d1d5db; }
|
||||
|
||||
.hero-status-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.hero-status-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.hero-status-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* ==================== Remark Card ==================== */
|
||||
.remark-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.remark-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.remark-header svg { color: #6c5ce7; }
|
||||
|
||||
.remark-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -510,7 +510,6 @@ watch(
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.batch-entry {
|
||||
|
||||
Reference in New Issue
Block a user