feat(ia): polish project operation workspaces

This commit is contained in:
Cheng Zhou
2026-06-08 11:06:07 +08:00
parent 834b4f1d48
commit 8cd8c38900
25 changed files with 2744 additions and 1516 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
"""概览数据聚合CRUD操作""" """概览数据聚合CRUD操作"""
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
from typing import Dict, List, Tuple from typing import Dict, List, Tuple
from sqlalchemy import Date, cast, func, select, and_, or_ from sqlalchemy import Date, cast, func, select, and_, or_
@@ -34,7 +34,7 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict:
if not sites: if not sites:
return { return {
"study_id": str(study_id), "study_id": str(study_id),
"updated_at": datetime.now().isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"centers": [], "centers": [],
"enrollment_by_month": [] "enrollment_by_month": []
} }
@@ -138,7 +138,7 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict:
return { return {
"study_id": str(study_id), "study_id": str(study_id),
"updated_at": datetime.now().isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"centers": centers, "centers": centers,
"enrollment_by_month": monthly_enrollment "enrollment_by_month": monthly_enrollment
} }
+2 -3
View File
@@ -631,7 +631,7 @@ export const TEXT = {
listTitle: "AE/SAE 列表", listTitle: "AE/SAE 列表",
emptyDescription: "暂无 AE/SAE 记录", emptyDescription: "暂无 AE/SAE 记录",
subjectNo: "参与者编号", subjectNo: "参与者编号",
toSubject: "查看参与者", toSubject: "查看",
}, },
riskIssuePd: { riskIssuePd: {
title: "PD", title: "PD",
@@ -640,7 +640,7 @@ export const TEXT = {
emptyDescription: "暂无 PD 记录", emptyDescription: "暂无 PD 记录",
syncStatus: "同步状态", syncStatus: "同步状态",
synced: "已同步", synced: "已同步",
toSubject: "查看参与者", toSubject: "查看",
}, },
riskIssueMonitoringVisits: { riskIssueMonitoringVisits: {
title: "监查访视问题", title: "监查访视问题",
@@ -1028,7 +1028,6 @@ export const TEXT = {
访: "失访", 访: "失访",
}, },
aeStatus: { aeStatus: {
NEW: "新建",
FOLLOW_UP: "随访中", FOLLOW_UP: "随访中",
CLOSED: "已关闭", CLOSED: "已关闭",
}, },
@@ -12,6 +12,7 @@ describe("DrugShipmentEditorDrawer attachments", () => {
expect(source).toContain('ref="attachmentPanelRef"'); expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="drug_shipment"'); expect(source).toContain('entity-type="drug_shipment"');
expect(source).toContain(':mode="\'upload\'"'); expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain(':center-upload-card-content="true"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)"); expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)");
expect(source).toContain("uploadPending"); expect(source).toContain("uploadPending");
@@ -128,6 +128,7 @@
:entity-id="shipmentId" :entity-id="shipmentId"
:mode="'upload'" :mode="'upload'"
:readonly="isFormReadOnly" :readonly="isFormReadOnly"
:center-upload-card-content="true"
/> />
</div> </div>
</el-form> </el-form>
@@ -33,12 +33,26 @@ describe("ContractFeeEditorDrawer attachments", () => {
it("shows attachment upload cards while creating a new contract fee", () => { it("shows attachment upload cards while creating a new contract fee", () => {
const source = readDrawer(); 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(':entity-id="form.id"');
expect(source).toContain("<AttachmentList"); 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">'); 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", () => { it("labels the section as attachments instead of contract attachments", () => {
const source = readLocale(); const source = readLocale();
@@ -49,13 +63,12 @@ describe("ContractFeeEditorDrawer attachments", () => {
it("uses a payment grid that keeps date pickers inside their columns", () => { it("uses a payment grid that keeps date pickers inside their columns", () => {
const source = readDrawer(); const source = readDrawer();
expect(source).toContain('class="payment-grid"'); expect(source).toContain('class="field-grid field-grid--3"');
expect(source).toContain('class="payment-field payment-field--amount"'); expect(source).toContain('class="status-row"');
expect(source).toContain('class="payment-field payment-field--status"'); expect(source).toContain('class="status-datepicker"');
expect(source).toContain('class="payment-field payment-field--remark"'); expect(source).toContain("grid-template-columns: 1fr 1fr 1fr;");
expect(source).toContain("grid-template-columns: minmax(180px, 0.9fr) repeat(2, minmax(220px, 1fr));");
expect(source).toContain("min-width: 0;"); 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", () => { 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" /> <StateLoading v-else-if="drawerLoading" :rows="6" />
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" class="contract-fee-form"> <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"> <div class="form-section">
<span class="group-dot group-dot-basic"></span> <div class="form-section-title">
<span class="dot dot--blue" />
{{ TEXT.modules.feeContracts.contractInfoTitle }} {{ TEXT.modules.feeContracts.contractInfoTitle }}
</div> </div>
<el-row :gutter="16">
<el-col :span="12"> <div class="field-grid field-grid--3">
<el-form-item :label="TEXT.common.fields.site" prop="center_id"> <el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select <el-select
v-model="form.center_id" v-model="form.center_id"
:disabled="!!contractId || isFormReadOnly" :disabled="!!contractId || isFormReadOnly"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
class="full-width" class="full-width"
> >
<el-option <el-option
v-for="site in sites" v-for="site in sites"
:key="site.id" :key="site.id"
:label="site.name || TEXT.common.fallback" :label="site.name || TEXT.common.fallback"
:value="site.id" :value="site.id"
:disabled="!site.is_active" :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"
/> />
</el-form-item> </el-select>
</el-col> </el-form-item>
</el-row> <el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no">
<el-row :gutter="16"> <el-input v-model="form.contract_no" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
<el-col :span="8"> </el-form-item>
<el-form-item :label="`${TEXT.modules.feeContracts.contractAmount}(万元)`" prop="contract_amount"> <el-form-item :label="TEXT.common.fields.signedDate" prop="signed_date">
<el-input-number <el-date-picker
v-model="form.contract_amount" v-model="form.signed_date"
:disabled="isFormReadOnly" :disabled="isFormReadOnly"
:min="0" type="date"
:precision="2" value-format="YYYY-MM-DD"
:step="1" :placeholder="TEXT.common.placeholders.select"
class="full-width" class="full-width"
controls-position="right" />
/> </el-form-item>
</el-form-item> </div>
</el-col>
<el-col :span="8"> <div class="field-grid field-grid--3">
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases"> <el-form-item :label="`${TEXT.modules.feeContracts.contractAmount}(万元)`" prop="contract_amount">
<el-input-number v-model="form.contract_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" /> <el-input-number
</el-form-item> v-model="form.contract_amount"
</el-col> :disabled="isFormReadOnly"
<el-col :span="8"> :min="0"
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases"> :precision="2"
<el-input-number v-model="form.actual_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" /> :step="1"
</el-form-item> class="full-width"
</el-col> controls-position="right"
</el-row> />
</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-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-input v-model="form.remark" :disabled="isFormReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<div v-if="selectedSiteInactive" class="inactive-hint"> <div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span> <span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span> <span>中心已停用当前记录不可编辑</span>
</div> </div>
</div> </div>
<div class="form-group"> <!-- 分期付款 -->
<div class="form-group-title action-title"> <div class="form-section">
<span> <div class="form-section-title form-section-title--action">
<span class="group-dot group-dot-payment"></span> <span class="section-title-left">
<span class="dot dot--amber" />
{{ TEXT.modules.feeContracts.paymentTitle }} {{ TEXT.modules.feeContracts.paymentTitle }}
</span> </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> <el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }} {{ TEXT.common.actions.add }}
</el-button> </el-button>
@@ -120,25 +112,24 @@
</div> </div>
<div v-else class="payment-list"> <div v-else class="payment-list">
<div v-for="(payment, index) in payments" :key="payment.tempId" class="payment-item"> <div v-for="(payment, index) in payments" :key="payment.tempId" class="payment-card">
<div class="payment-item-header"> <div class="payment-card-header">
<div class="payment-seq"> <div class="payment-card-seq">
<span class="seq-circle">{{ index + 1 }}</span> <span class="seq-badge">{{ index + 1 }}</span>
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span> <span class="seq-label">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</div> </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-icon><Delete /></el-icon>
</el-button> </el-button>
</div> </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-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-input-number v-model="payment.amount" :disabled="isFormReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item> </el-form-item>
</div>
<div class="payment-field payment-field--status">
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date"> <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)"> <el-checkbox v-model="payment.is_paid" :disabled="isFormReadOnly" @change="() => onPaidToggle(payment)">
{{ TEXT.modules.feeContracts.isPaid }} {{ TEXT.modules.feeContracts.isPaid }}
</el-checkbox> </el-checkbox>
@@ -149,15 +140,13 @@
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="isFormReadOnly" :disabled="isFormReadOnly"
class="status-picker" class="status-datepicker"
size="small" size="small"
/> />
</div> </div>
</el-form-item> </el-form-item>
</div>
<div class="payment-field payment-field--status">
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date"> <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)"> <el-checkbox v-model="payment.is_verified" :disabled="isFormReadOnly" @change="() => onVerifiedToggle(payment)">
{{ TEXT.modules.feeContracts.isVerified }} {{ TEXT.modules.feeContracts.isVerified }}
</el-checkbox> </el-checkbox>
@@ -168,18 +157,17 @@
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="isFormReadOnly" :disabled="isFormReadOnly"
class="status-picker" class="status-datepicker"
size="small" size="small"
/> />
</div> </div>
</el-form-item> </el-form-item>
</div> </div>
<div class="payment-field payment-field--remark"> <el-form-item :label="TEXT.common.fields.remark" class="mb-0">
<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-input v-model="payment.remark" :disabled="isFormReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" /> </el-form-item>
</el-form-item>
</div>
</div> </div>
<div v-if="paymentErrors[index]?.verification" class="payment-error"> <div v-if="paymentErrors[index]?.verification" class="payment-error">
{{ paymentErrors[index].verification }} {{ paymentErrors[index].verification }}
</div> </div>
@@ -187,9 +175,10 @@
</div> </div>
</div> </div>
<div class="form-group attachment-form-group"> <!-- 附件 -->
<div class="form-group-title"> <div class="form-section">
<span class="group-dot group-dot-attachment"></span> <div class="form-section-title">
<span class="dot dot--green" />
{{ TEXT.modules.feeContracts.attachmentTitle }} {{ TEXT.modules.feeContracts.attachmentTitle }}
</div> </div>
<AttachmentList <AttachmentList
@@ -200,14 +189,16 @@
:entity-groups="attachmentGroups" :entity-groups="attachmentGroups"
:mode="'upload'" :mode="'upload'"
:readonly="isFormReadOnly" :readonly="isFormReadOnly"
:center-upload-card-content="true"
:upload-card-columns="3"
/> />
</div> </div>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="drawer-footer"> <div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</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">{{ TEXT.common.actions.save }}</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> </div>
</template> </template>
</el-drawer> </el-drawer>
@@ -312,18 +303,23 @@ const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
const selectedSiteInactive = computed(() => !!props.contractId && !!form.center_id && siteActiveMap.value[form.center_id] === false); 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 isFormReadOnly = computed(() => (props.contractId ? !canUpdate.value : !canCreate.value) || selectedSiteInactive.value);
const attachmentGroups = [ const attachmentGroups = [
{ {
entityType: "contract_fee_contract", entityType: "contract_fee_contract",
label: TEXT.modules.feeContracts.attachmentContract, label: TEXT.modules.feeContracts.attachmentContract,
uploadText: "点击上传合同",
}, },
{ {
entityType: "contract_fee_voucher", entityType: "contract_fee_voucher",
label: TEXT.modules.feeContracts.attachmentVoucher, label: TEXT.modules.feeContracts.attachmentVoucher,
uploadText: "点击上传凭证",
}, },
{ {
entityType: "contract_fee_invoice", entityType: "contract_fee_invoice",
label: TEXT.modules.feeContracts.attachmentInvoice, label: TEXT.modules.feeContracts.attachmentInvoice,
uploadText: "点击上传发票",
}, },
]; ];
@@ -558,13 +554,14 @@ watch(
</script> </script>
<style scoped> <style scoped>
/* ==================== Drawer ==================== */
:deep(.contract-fee-editor-drawer > .el-drawer__header) { :deep(.contract-fee-editor-drawer > .el-drawer__header) {
margin-bottom: 0; margin-bottom: 0;
padding: 22px 20px 8px; padding: 20px 24px 8px;
} }
:deep(.contract-fee-editor-drawer > .el-drawer__body) { :deep(.contract-fee-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px; padding: 0 24px 4px;
} }
.editor-header { .editor-header {
@@ -573,82 +570,90 @@ watch(
} }
.editor-title { .editor-title {
font-size: 20px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: var(--ctms-text-main); color: #0a0a0a;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.01em;
} }
/* ==================== Form ==================== */
.contract-fee-form { .contract-fee-form {
padding: 0 4px 0 0; padding: 0;
} }
.form-group { .form-section {
border: 1px solid #e8eef6; padding-bottom: 6px;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
} }
.form-group:hover { .form-section + .form-section {
border-color: #d0dced; margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
} }
.form-group + .form-group { .form-section-title {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex; display: flex;
align-items: center; 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; justify-content: space-between;
} }
.action-title > span { .section-title-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
} }
.group-dot { /* Dots */
width: 10px; .dot {
height: 10px; width: 8px;
height: 8px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
.group-dot-basic { .dot--blue { background: #3b82f6; }
background: #3b82f6; .dot--amber { background: #f59e0b; }
.dot--green { background: #22c55e; }
/* ==================== Field Grid ==================== */
.field-grid {
display: grid;
gap: 0 16px;
} }
.group-dot-payment { .field-grid--2 {
background: #f0ad2c; grid-template-columns: 1fr 1fr;
} }
.group-dot-attachment { .field-grid--3 {
background: #22c55e; grid-template-columns: 1fr 1fr 1fr;
} }
.full-width {
width: 100%;
}
/* ==================== Inactive Hint ==================== */
.inactive-hint { .inactive-hint {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 8px 12px; padding: 8px 12px;
background: #fff7d6; background: #fffbeb;
border: 1px solid #fde68a; border: 1px solid #fde68a;
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
color: #92400e; color: #92400e;
margin-bottom: 8px; margin-top: 4px;
} }
.hint-icon { .hint-icon {
@@ -662,134 +667,164 @@ watch(
color: #ffffff; color: #ffffff;
font-size: 12px; font-size: 12px;
line-height: 1; line-height: 1;
flex-shrink: 0;
} }
.full-width { /* ==================== Empty State ==================== */
width: 100%;
}
.section-empty { .section-empty {
padding: 28px 0; padding: 32px 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
color: #8a97ab; color: #a3a3a3;
font-size: 14px; font-size: 13px;
line-height: 1.6; font-weight: 500;
} }
/* ==================== Payment Cards ==================== */
.payment-list { .payment-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.payment-item { .payment-card {
border: 1px solid #e8eef6; border: 1px solid #f0f0f0;
border-radius: 8px; border-radius: 10px;
padding: 18px 20px 16px; padding: 16px 18px 14px;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); background: #fafbfc;
box-shadow: 0 8px 20px rgba(38, 73, 119, 0.05); transition: border-color 0.15s ease;
} }
.payment-item-header { .payment-card:hover {
border-color: #d4d4d4;
}
.payment-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 16px; margin-bottom: 14px;
} }
.payment-seq { .payment-card-seq {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.seq-circle { .seq-badge {
width: 28px; display: inline-flex;
height: 28px;
border-radius: 50%;
background-color: #3f5f7a;
color: white;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 13px; min-width: 22px;
font-weight: 600; height: 22px;
} padding: 0 6px;
border-radius: 4px;
.seq-text { background: #f0f0f0;
font-size: 18px; color: #525252;
font-size: 11px;
font-weight: 700; font-weight: 700;
color: var(--ctms-text-main); line-height: 1;
} }
.payment-grid { .seq-label {
display: grid; font-size: 14px;
grid-template-columns: minmax(180px, 0.9fr) repeat(2, minmax(220px, 1fr)); font-weight: 600;
gap: 14px 18px; color: #0a0a0a;
align-items: start;
} }
.payment-field { .remove-btn {
min-width: 0; font-size: 13px;
} }
.payment-field--remark { .payment-card-body {
grid-column: 1 / -1;
}
.status-control {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0;
}
/* Status row (checkbox + date picker) */
.status-row {
display: flex;
align-items: center;
gap: 10px; gap: 10px;
min-width: 0; flex-wrap: wrap;
} }
.status-picker { .status-datepicker {
width: 100%; width: auto;
min-width: 0; min-width: 0;
} flex: 1;
}
.status-picker :deep(.el-date-editor.el-input),
.status-picker :deep(.el-input__wrapper) { .status-datepicker :deep(.el-date-editor.el-input),
.status-datepicker :deep(.el-input__wrapper) {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
/* ==================== Payment Error ==================== */
.payment-error { .payment-error {
color: var(--ctms-danger); color: #dc2626;
font-size: 12px; font-size: 12px;
margin-top: 8px; margin-top: 10px;
padding: 6px 10px;
background: #fef2f2;
border-radius: 6px;
} }
.mb-0 { .mb-0 {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
/* ==================== Footer ==================== */
.drawer-footer { .drawer-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; 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) { .contract-fee-form :deep(.el-form-item) {
margin-bottom: 12px; margin-bottom: 14px;
} }
.contract-fee-form :deep(.el-form-item__label) { .contract-fee-form :deep(.el-form-item__label) {
font-size: 13px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #4a6283; color: #525252;
padding-bottom: 4px; padding-bottom: 4px;
} }
@media (max-width: 760px) { .contract-fee-form :deep(.el-form-item__error) {
.payment-grid { font-size: 11px;
grid-template-columns: 1fr; }
}
.payment-field--remark { .add-payment-btn {
grid-column: auto; 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> </style>
@@ -43,6 +43,7 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('entity-type="drug_shipment"'); expect(source).toContain('entity-type="drug_shipment"');
expect(source).toContain(':entity-id="editingId"'); expect(source).toContain(':entity-id="editingId"');
expect(source).toContain(':mode="\'upload\'"'); expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain(':center-upload-card-content="true"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(shipmentId)"); expect(source).toContain("await attachmentPanelRef.value?.uploadPending(shipmentId)");
expect(source).toContain("uploadPending"); expect(source).toContain("uploadPending");
+457 -260
View File
@@ -1,10 +1,12 @@
<template> <template>
<div class="page page--flush"> <div class="page">
<div v-if="study.currentStudy" class="unified-shell"> <div v-if="study.currentStudy" class="page-inner">
<section class="unified-section section--flush"> <!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="filter-row"> <div class="table-card">
<el-form :inline="true" :model="filters"> <div class="table-card-toolbar">
<el-form-item :label="TEXT.common.fields.site"> <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-select v-model="filters.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
<el-option <el-option
v-for="site in sites" v-for="site in sites"
@@ -13,78 +15,96 @@
:value="site.id" :value="site.id"
/> />
</el-select> </el-select>
</el-form-item> </div>
<el-form-item :label="TEXT.common.fields.direction"> <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-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.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" /> <el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select> </el-select>
</el-form-item> </div>
<el-form-item :label="TEXT.common.fields.status"> <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-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.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" /> <el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" /> <el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" /> <el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select> </el-select>
</el-form-item> </div>
<el-form-item> <div class="filter-actions">
<el-button type="primary" @click="handleSearch">{{ TEXT.common.actions.search }}</el-button> <el-button type="primary" size="small" @click="handleSearch" class="filter-btn">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item> </div>
</el-form> </div>
<el-button v-if="canCreate" type="primary" @click="openCreate">{{ TEXT.modules.drugShipments.newTitle }}</el-button> <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> </div>
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="sortedItems" :data="sortedItems"
class="ctms-table shipment-table" class="shipment-table"
style="width: 100%" style="width: 100%"
table-layout="fixed" table-layout="fixed"
:row-class-name="shipmentRowClass" :row-class-name="shipmentRowClass"
@row-click="onRowClick" @row-click="onRowClick"
> >
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip> <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>
<el-table-column prop="direction" :label="TEXT.common.fields.direction"> <el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="{ row }"> <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) }} {{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</el-tag> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate"> <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>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate"> <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>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity"> <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>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip> <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>
<el-table-column prop="status" :label="TEXT.common.fields.status"> <el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="{ row }"> <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) }} {{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</el-tag> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip> <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 }"> <template #default="{ row }">
<div class="shipment-actions"> <span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
<el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button> </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 <el-button
v-if="canDelete" v-if="canDelete"
link link
type="danger" type="danger"
size="small"
:disabled="isInactiveSite(row.center_id)" :disabled="isInactiveSite(row.center_id)"
@click.stop="remove(row)" @click.stop="remove(row)"
> >
@@ -94,13 +114,19 @@
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
<div v-if="!loading" class="table-empty">{{ TEXT.modules.drugShipments.empty }}</div> <div v-if="!loading" class="table-empty">
<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> </template>
</el-table> </el-table>
</section> </div>
</div> </div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<!-- ==================== 编辑抽屉 ==================== -->
<el-drawer <el-drawer
v-if="drawerVisible" v-if="drawerVisible"
v-model="drawerVisible" v-model="drawerVisible"
@@ -118,115 +144,92 @@
</template> </template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="shipment-form"> <el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="shipment-form">
<!-- 基本信息分组 --> <div class="form-section">
<div class="form-group"> <div class="form-section-title">
<div class="form-group-title"> <span class="dot dot--blue" />
<span class="group-dot group-dot-basic"></span>
{{ TEXT.common.labels.basicInfo }} {{ TEXT.common.labels.basicInfo }}
</div> </div>
<el-row :gutter="16"> <div class="field-grid field-grid--2">
<el-col :span="12"> <el-form-item :label="TEXT.common.fields.site" prop="center_id">
<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-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly"> <el-option
<el-option v-for="site in sites"
v-for="site in sites" :key="site.id"
:key="site.id" :label="site.name || TEXT.common.fallback"
:label="site.name || TEXT.common.fallback" :value="site.id"
:value="site.id" :disabled="!site.is_active"
:disabled="!site.is_active" />
/> </el-select>
</el-select> </el-form-item>
</el-form-item> <el-form-item :label="TEXT.common.fields.direction" prop="direction">
</el-col> <el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<el-col :span="12"> <el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-form-item :label="TEXT.common.fields.direction" prop="direction"> <el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly"> </el-select>
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" /> </el-form-item>
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" /> </div>
</el-select> <el-form-item :label="TEXT.common.fields.status" prop="status">
</el-form-item> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
</el-col> <el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
</el-row> <el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-row :gutter="16"> <el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-col :span="12"> <el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
<el-form-item :label="TEXT.common.fields.status" prop="status"> </el-select>
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly"> </el-form-item>
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<div v-if="selectedSiteInactive" class="inactive-hint"> <div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span> <span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span> <span>中心已停用当前记录不可编辑</span>
</div> </div>
</div> </div>
<!-- 运输记录分组 --> <div class="form-section">
<div class="form-group"> <div class="form-section-title">
<div class="form-group-title"> <span class="dot dot--amber" />
<span class="group-dot group-dot-record"></span>
{{ TEXT.modules.drugShipments.recordLabel }} {{ TEXT.modules.drugShipments.recordLabel }}
</div> </div>
<el-row :gutter="16"> <div class="field-grid field-grid--2">
<el-col :span="12"> <el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date" :required="requiresShipmentDetails(form.status)">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date" :required="requiresShipmentDetails(form.status)"> <el-date-picker
<el-date-picker v-model="form.ship_date"
v-model="form.ship_date" type="date"
type="date" value-format="YYYY-MM-DD"
value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select"
:placeholder="TEXT.common.placeholders.select" class="full-width"
class="full-width" :disabled="isFormReadOnly"
:disabled="isFormReadOnly" />
/> </el-form-item>
</el-form-item> <el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date" :required="requiresReceiveDate(form.status)">
</el-col> <el-date-picker
<el-col :span="12"> v-model="form.receive_date"
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date" :required="requiresReceiveDate(form.status)"> type="date"
<el-date-picker value-format="YYYY-MM-DD"
v-model="form.receive_date" :placeholder="TEXT.common.placeholders.select"
type="date" class="full-width"
value-format="YYYY-MM-DD" :disabled="isFormReadOnly"
:placeholder="TEXT.common.placeholders.select" />
class="full-width" </el-form-item>
:disabled="isFormReadOnly" </div>
/> <div class="field-grid field-grid--2">
</el-form-item> <el-form-item :label="TEXT.common.fields.quantity" prop="quantity" :required="requiresShipmentDetails(form.status)">
</el-col> <el-input-number v-model="form.quantity" :min="0" :controls="false" class="full-width" :disabled="isFormReadOnly" />
</el-row> </el-form-item>
<el-row :gutter="16"> <el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no" :required="requiresShipmentDetails(form.status)">
<el-col :span="12"> <el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isFormReadOnly" />
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity" :required="requiresShipmentDetails(form.status)"> </el-form-item>
<el-input-number v-model="form.quantity" :min="0" :controls="false" class="full-width" :disabled="isFormReadOnly" /> </div>
</el-form-item> <div class="field-grid field-grid--2">
</el-col> <el-form-item :label="TEXT.common.fields.carrier" prop="carrier" :required="requiresShipmentDetails(form.status)">
<el-col :span="12"> <el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isFormReadOnly" />
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no" :required="requiresShipmentDetails(form.status)"> </el-form-item>
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isFormReadOnly" /> <el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no" :required="requiresShipmentDetails(form.status)">
</el-form-item> <el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isFormReadOnly" />
</el-col> </el-form-item>
</el-row> </div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isFormReadOnly" />
</el-form-item>
</el-col>
</el-row>
</div> </div>
<!-- 备注分组 --> <div class="form-section">
<div class="form-group"> <div class="form-section-title">
<div class="form-group-title"> <span class="dot dot--green" />
<span class="group-dot group-dot-remark"></span>
{{ TEXT.common.fields.remark }} {{ TEXT.common.fields.remark }}
</div> </div>
<el-form-item prop="remark" :required="requiresRemark(form.status)"> <el-form-item prop="remark" :required="requiresRemark(form.status)">
@@ -240,10 +243,9 @@
</el-form-item> </el-form-item>
</div> </div>
<!-- 附件分组 --> <div class="form-section">
<div class="form-group"> <div class="form-section-title">
<div class="form-group-title"> <span class="dot dot--purple" />
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.common.labels.attachments }} {{ TEXT.common.labels.attachments }}
</div> </div>
<AttachmentList <AttachmentList
@@ -259,8 +261,8 @@
<template #footer> <template #footer>
<div class="drawer-footer"> <div class="drawer-footer">
<el-button @click="drawerVisible = false">{{ TEXT.common.actions.cancel }}</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">{{ TEXT.common.actions.save }}</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> </div>
</template> </template>
</el-drawer> </el-drawer>
@@ -271,6 +273,7 @@
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { import {
@@ -569,16 +572,11 @@ const onRowClick = (row: ShipmentRow) => {
const statusType = (status: string) => { const statusType = (status: string) => {
switch (status) { switch (status) {
case "PENDING": case "PENDING": return "info";
return "info"; case "IN_TRANSIT": return "primary";
case "IN_TRANSIT": case "SIGNED": return "success";
return "primary"; case "EXCEPTION": return "danger";
case "SIGNED": default: return "info";
return "success";
case "EXCEPTION":
return "danger";
default:
return "info";
} }
}; };
@@ -642,31 +640,260 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
/* ==================== Base ==================== */
.page { .page {
display: flex; display: flex;
flex-direction: column; 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; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 12px; align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px; gap: 12px;
} }
.filter-select { .toolbar-filters {
width: 150px; 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) { :deep(.shipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0; margin-bottom: 0;
padding: 22px 20px 8px; padding: 20px 24px 8px;
} }
:deep(.shipment-editor-drawer > .el-drawer__body) { :deep(.shipment-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px; padding: 0 24px 4px;
} }
.editor-header { .editor-header {
@@ -675,69 +902,61 @@ onMounted(async () => {
} }
.editor-title { .editor-title {
font-size: 20px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: var(--ctms-text-main); color: #0a0a0a;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.01em;
} }
/* ========== 表单整体 ========== */
.shipment-form { .shipment-form {
padding: 0 4px 0 0; padding: 0;
} }
/* ========== 分组卡片 ========== */ /* ==================== Form Sections ==================== */
.form-group { .form-section {
border: 1px solid #e8eef6; padding-bottom: 6px;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
} }
.form-group:hover { .form-section + .form-section {
border-color: #d0dced; margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
} }
.form-group + .form-group { .form-section-title {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
margin-bottom: 16px;
} }
.group-dot { .dot {
width: 10px; width: 8px;
height: 10px; height: 8px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
/* 基本信息 - 蓝色 */ .dot--blue { background: #3b82f6; }
.group-dot-basic { .dot--amber { background: #f59e0b; }
background: #3b82f6; .dot--green { background: #22c55e; }
.dot--purple { background: #8b5cf6; }
.field-grid {
display: grid;
gap: 0 16px;
} }
/* 运输记录 - 琥珀色 */ .field-grid--2 {
.group-dot-record { grid-template-columns: 1fr 1fr;
background: #f0ad2c;
} }
/* 备注 - 绿色 */ .full-width {
.group-dot-remark { width: 100%;
background: #22c55e;
}
/* 附件 - 紫色 */
.group-dot-attachment {
background: #8b5cf6;
} }
.inactive-hint { .inactive-hint {
@@ -745,12 +964,12 @@ onMounted(async () => {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 8px 12px; padding: 8px 12px;
background: #fff7d6; background: #fffbeb;
border: 1px solid #fde68a; border: 1px solid #fde68a;
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
color: #92400e; color: #92400e;
margin-bottom: 8px; margin-top: 4px;
} }
.hint-icon { .hint-icon {
@@ -766,85 +985,62 @@ onMounted(async () => {
line-height: 1; line-height: 1;
} }
/* ========== 表单元素细节 ========== */
.full-width {
width: 100%;
}
/* ========== 底部按钮 ========== */
.drawer-footer { .drawer-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; 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) { .shipment-form :deep(.el-form-item) {
margin-bottom: 12px; margin-bottom: 14px;
} }
.shipment-form :deep(.el-form-item__label) { .shipment-form :deep(.el-form-item__label) {
font-size: 13px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #4a6283; color: #525252;
padding-bottom: 4px; padding-bottom: 4px;
} }
.shipment-table :deep(.el-table__inner-wrapper::before) { .shipment-form :deep(.el-form-item__error) {
display: none; font-size: 11px;
} }
/* ========== 表格居中 ========== */ /* ==================== Responsive ==================== */
.shipment-table :deep(th.el-table__cell .cell), @media (max-width: 960px) {
.shipment-table :deep(td.el-table__cell .cell) { .field-grid--2 {
text-align: center; 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 { @media (max-width: 640px) {
display: inline-flex; .create-btn {
align-items: center; width: 100%;
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;
} }
</style> </style>
@@ -854,7 +1050,8 @@ onMounted(async () => {
background-color: #fff7d6 !important; background-color: #fff7d6 !important;
} }
.row-inactive .el-tag { .row-inactive .status-pill,
.row-inactive .dir-tag {
opacity: 0.6; opacity: 0.6;
} }
</style> </style>
@@ -54,6 +54,7 @@ describe("MaterialEquipment project permissions", () => {
expect(source).toContain('entity-type="material_equipment"'); expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':entity-id="editingId"'); expect(source).toContain(':entity-id="editingId"');
expect(source).toContain(':mode="\'upload\'"'); expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain(':center-upload-card-content="true"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)"); expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)");
expect(source).toContain("uploadPending"); expect(source).toContain("uploadPending");
+371 -155
View File
@@ -1,52 +1,87 @@
<template> <template>
<div class="page page--flush"> <div class="page">
<div v-if="study.currentStudy" class="unified-shell"> <div v-if="study.currentStudy" class="page-inner">
<section class="unified-section section--flush"> <!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="filter-row"> <div class="table-card">
<el-form :inline="true" :model="filters"> <div class="table-card-toolbar">
<el-form-item label="设备名称"> <div class="toolbar-filters">
<el-input v-model="filters.name" clearable placeholder="请输入" class="name-filter" /> <div class="filter-item">
</el-form-item> <span class="filter-label">设备名称</span>
<el-form-item> <el-input v-model="filters.name" clearable placeholder="请输入" class="filter-input" />
<el-button type="primary" @click="handleSearch">查询</el-button> </div>
<el-button @click="resetFilters">重置</el-button> <div class="filter-actions">
</el-form-item> <el-button type="primary" size="small" @click="handleSearch" class="filter-btn">查询</el-button>
</el-form> <el-button size="small" @click="resetFilters" class="filter-btn">重置</el-button>
<el-button v-if="canCreate" type="primary" @click="openCreate">新建</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> </div>
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="rows" :data="rows"
class="ctms-table equipment-table" class="equipment-table"
style="width: 100%" style="width: 100%"
table-layout="fixed" table-layout="fixed"
:row-class-name="equipmentRowClass" :row-class-name="equipmentRowClass"
@row-click="onRowClick" @row-click="onRowClick"
> >
<el-table-column prop="name" label="设备名称" show-overflow-tooltip /> <el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" show-overflow-tooltip />
<el-table-column prop="brand" label="品牌" show-overflow-tooltip />
<el-table-column label="是否需要校准">
<template #default="{ row }"> <template #default="{ row }">
{{ row.needCalibration ? "是" : "否" }} <span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作"> <el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">编辑</el-button> <span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
<el-button v-if="canDelete" link type="danger" @click.stop="removeRow(row)">删除</el-button> </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> </template>
</el-table-column> </el-table-column>
<template #empty> <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> </template>
</el-table> </el-table>
</section> </div>
</div> </div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<!-- ==================== 编辑抽屉 ==================== -->
<el-drawer <el-drawer
v-if="drawerVisible" v-if="drawerVisible"
v-model="drawerVisible" v-model="drawerVisible"
@@ -64,72 +99,57 @@
</template> </template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="equipment-form"> <el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="equipment-form">
<!-- 基本信息分组 --> <div class="form-section">
<div class="form-group"> <div class="form-section-title">
<div class="form-group-title"> <span class="dot dot--blue" />
<span class="group-dot group-dot-basic"></span>
基本信息 基本信息
</div> </div>
<el-row :gutter="16"> <div class="field-grid field-grid--2">
<el-col :span="12"> <el-form-item label="设备名称" prop="name">
<el-form-item label="设备名称" prop="name"> <el-input v-model="form.name" placeholder="请输入设备名称" />
<el-input v-model="form.name" placeholder="请输入设备名称" /> </el-form-item>
</el-form-item> <el-form-item label="规格型号" prop="specModel">
</el-col> <el-input v-model="form.specModel" placeholder="请输入规格型号" />
<el-col :span="12"> </el-form-item>
<el-form-item label="规格型号" prop="specModel"> </div>
<el-input v-model="form.specModel" placeholder="请输入规格型号" /> <div class="field-grid field-grid--3">
</el-form-item> <el-form-item label="单位" prop="unit">
</el-col> <el-input v-model="form.unit" placeholder="请输入" />
</el-row> </el-form-item>
<el-row :gutter="16"> <el-form-item label="品牌" prop="brand">
<el-col :span="8"> <el-input v-model="form.brand" placeholder="请输入品牌" />
<el-form-item label="单位" prop="unit"> </el-form-item>
<el-input v-model="form.unit" placeholder="请输入" /> <el-form-item label="产地" prop="origin">
</el-form-item> <el-input v-model="form.origin" placeholder="请输入产地" />
</el-col> </el-form-item>
<el-col :span="8"> </div>
<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> </div>
<div class="form-group"> <div class="form-section">
<div class="form-group-title"> <div class="form-section-title">
<span class="group-dot group-dot-calibration"></span> <span class="dot dot--green" />
校准设置 校准设置
</div> </div>
<el-row :gutter="16"> <div class="field-grid field-grid--2">
<el-col :span="12"> <el-form-item label="是否需要校准" prop="needCalibration">
<el-form-item label="是否需要校准" prop="needCalibration"> <el-select v-model="form.needCalibration" placeholder="请选择" class="full-width">
<el-select v-model="form.needCalibration" placeholder="请选择" class="full-width"> <el-option label="是" :value="true" />
<el-option label="" :value="true" /> <el-option label="" :value="false" />
<el-option label="否" :value="false" /> </el-select>
</el-select> </el-form-item>
</el-form-item> <el-form-item v-if="form.needCalibration" label="校准周期(天)" prop="calibrationCycleDays">
</el-col> <el-input-number v-model="form.calibrationCycleDays" :min="1" :precision="0" class="full-width" />
<el-col v-if="form.needCalibration" :span="12"> </el-form-item>
<el-form-item label="校准周期(天)" prop="calibrationCycleDays"> </div>
<el-input-number v-model="form.calibrationCycleDays" :min="1" :precision="0" class="full-width" /> <div v-if="!form.needCalibration" class="hint-box">
</el-form-item>
</el-col>
</el-row>
<div v-if="!form.needCalibration" class="calibration-hint">
<span class="hint-icon"></span> <span class="hint-icon"></span>
<span>该设备无需定期校准</span> <span>该设备无需定期校准</span>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-section">
<div class="form-group-title"> <div class="form-section-title">
<span class="group-dot group-dot-file"></span> <span class="dot dot--purple" />
附件 附件
</div> </div>
<AttachmentList <AttachmentList
@@ -145,8 +165,8 @@
<template #footer> <template #footer>
<div class="drawer-footer"> <div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button> <el-button @click="drawerVisible = false" class="footer-btn">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">保存</el-button> <el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm" class="footer-btn footer-btn--primary">保存</el-button>
</div> </div>
</template> </template>
</el-drawer> </el-drawer>
@@ -157,6 +177,7 @@
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { import {
createMaterialEquipment, createMaterialEquipment,
deleteMaterialEquipment, deleteMaterialEquipment,
@@ -398,31 +419,212 @@ watch(
</script> </script>
<style scoped> <style scoped>
/* ==================== Base ==================== */
.page { .page {
display: flex; display: flex;
flex-direction: column; 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; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 12px; align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px; gap: 12px;
} }
.name-filter { .toolbar-filters {
width: 260px; 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) { :deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0; margin-bottom: 0;
padding: 22px 20px 8px; padding: 20px 24px 8px;
} }
:deep(.equipment-editor-drawer > .el-drawer__body) { :deep(.equipment-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px; padding: 0 24px 4px;
} }
.editor-header { .editor-header {
@@ -431,68 +633,67 @@ watch(
} }
.editor-title { .editor-title {
font-size: 20px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: var(--ctms-text-main); color: #0a0a0a;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.01em;
} }
/* ========== 表单整体 ========== */
.equipment-form { .equipment-form {
padding: 0 4px 0 0; padding: 0;
} }
/* ========== 分组卡片 ========== */ /* ==================== Form Sections ==================== */
.form-group { .form-section {
border: 1px solid #e8eef6; padding-bottom: 6px;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
} }
.form-group:hover { .form-section + .form-section {
border-color: #d0dced; margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
} }
.form-group + .form-group { .form-section-title {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
margin-bottom: 16px;
} }
.group-dot { .dot {
width: 10px; width: 8px;
height: 10px; height: 8px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
/* 基本信息 - 蓝色 */ .dot--blue { background: #3b82f6; }
.group-dot-basic { .dot--green { background: #22c55e; }
background: #3b82f6; .dot--purple { background: #8b5cf6; }
.field-grid {
display: grid;
gap: 0 16px;
} }
/* 附件 - 琥珀色 */ .field-grid--2 {
.group-dot-file { grid-template-columns: 1fr 1fr;
background: #f0ad2c;
} }
/* 校准设置 - 绿色 */ .field-grid--3 {
.group-dot-calibration { grid-template-columns: 1fr 1fr 1fr;
background: #22c55e;
} }
/* ========== 校准提示 ========== */ .full-width {
.calibration-hint { width: 100%;
}
.hint-box {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -502,7 +703,7 @@ watch(
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
color: #166534; color: #166534;
margin-bottom: 8px; margin-top: 4px;
} }
.hint-icon { .hint-icon {
@@ -510,47 +711,62 @@ watch(
line-height: 1; line-height: 1;
} }
/* ========== 表单元素细节 ========== */
.full-width {
width: 100%;
}
/* ========== 底部按钮 ========== */
.drawer-footer { .drawer-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; 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) { .equipment-form :deep(.el-form-item) {
margin-bottom: 12px; margin-bottom: 14px;
} }
.equipment-form :deep(.el-form-item__label) { .equipment-form :deep(.el-form-item__label) {
font-size: 13px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #4a6283; color: #525252;
padding-bottom: 4px; padding-bottom: 4px;
} }
.equipment-table :deep(.el-table__inner-wrapper::before) { .equipment-form :deep(.el-form-item__error) {
display: none; font-size: 11px;
} }
/* ========== 表格居中 ========== */ /* ==================== Responsive ==================== */
.equipment-table :deep(th.el-table__cell .cell), @media (max-width: 960px) {
.equipment-table :deep(td.el-table__cell .cell) { .field-grid--2,
text-align: center; .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; @media (max-width: 640px) {
display: flex; .create-btn {
align-items: center; width: 100%;
justify-content: center; }
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
} }
</style> </style>
+22 -10
View File
@@ -10,7 +10,7 @@
<span class="overview-top-title">项目总览</span> <span class="overview-top-title">项目总览</span>
</div> </div>
<div class="overview-top-right"> <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"> <el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon> <template #icon>
<el-icon><Refresh /></el-icon> <el-icon><Refresh /></el-icon>
@@ -84,7 +84,7 @@
</template> </template>
<script setup lang="ts"> <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 { Refresh } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -101,7 +101,8 @@ const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null); const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center"); 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 || []); 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 siteActiveMap = new Map<string, boolean>(siteList.map((s: any) => [s.id, !!s.is_active]));
const { data } = await fetchProjectOverview(studyId); const { data } = await fetchProjectOverview(studyId);
overview.value = adaptProjectOverview(data); 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) { if (overview.value && overview.value.centers.length === 0 && siteList.length > 0) {
overview.value.centers = buildFallbackCentersFromSites(siteList); overview.value.centers = buildFallbackCentersFromSites(siteList);
@@ -193,7 +191,6 @@ const loadOverview = async () => {
} }
} catch { } catch {
overview.value = buildOverviewFromSites(studyId, siteList); overview.value = buildOverviewFromSites(studyId, siteList);
updatedAtLabel.value = displayDateTime(overview.value.updated_at);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -201,11 +198,20 @@ const loadOverview = async () => {
const reset = () => { const reset = () => {
overview.value = null; overview.value = null;
updatedAtLabel.value = displayDateTime(new Date());
}; };
onMounted(() => { onMounted(() => {
loadOverview(); loadOverview();
clockTimer = setInterval(() => {
liveClock.value = displayDateTime(new Date());
}, 1000);
});
onUnmounted(() => {
if (clockTimer) {
clearInterval(clockTimer);
clockTimer = null;
}
}); });
watch( watch(
@@ -272,9 +278,15 @@ watch(
gap: 12px; gap: 12px;
} }
.overview-updated-label { .overview-live-clock {
font-size: 13px; 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 { .refresh-btn {
+88 -113
View File
@@ -1,61 +1,71 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page">
<div class="main-content-flat unified-shell"> <div class="page-header">
<div class="filter-container unified-action-bar bar--flush"> <h2 class="page-title">{{ TEXT.menu.riskIssuePd }}</h2>
<el-form :inline="true" class="filter-form"> </div>
<div class="filter-item-form"> <div class="table-card">
<el-input v-model="filters.keyword" :placeholder="TEXT.common.fields.keyword" clearable class="filter-input-comp"> <div class="table-card-toolbar">
<template #prefix> <div class="toolbar-filters">
<el-icon><Search /></el-icon> <div class="filter-item">
</template> <span class="filter-label">{{ TEXT.common.fields.keyword }}</span>
</el-input> <el-input v-model="filters.keyword" clearable placeholder="请输入" class="filter-input" />
</div> </div>
<div class="filter-item-form"> <div class="filter-item">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp"> <span class="filter-label">{{ TEXT.common.fields.site }}</span>
<template #prefix> <el-select v-model="filters.siteId" clearable filterable placeholder="请选择" class="filter-select">
<el-icon><Location /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" /> <el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select> </el-select>
</div> </div>
<div class="filter-item-form"> <div class="filter-item">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp"> <span class="filter-label">{{ TEXT.common.fields.status }}</span>
<template #prefix> <el-select v-model="filters.status" clearable placeholder="请选择" class="filter-select">
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /> <el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select> </el-select>
</div> </div>
<div class="filter-actions-comp"> <div class="filter-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</div> </div>
</el-form> </div>
</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 :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 /> <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 /> <template #default="scope"><span class="cell-mono cell-nowrap">{{ scope.row.pd_no || TEXT.common.fallback }}</span></template>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip> </el-table-column>
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template> <el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
</el-table-column> <template #default="scope"><span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span></template>
<el-table-column prop="status" :label="TEXT.common.fields.status"> </el-table-column>
<template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template> <el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
</el-table-column> <template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
<el-table-column prop="description" :label="TEXT.common.fields.pdDescription" show-overflow-tooltip /> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center"> <el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope"> <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)"> <el-button link type="primary" size="small" @click="goSubject(scope.row.subject_id)">
{{ TEXT.common.actions.view }} {{ TEXT.common.actions.view }}
</el-button> </el-button>
</template> </div>
</el-table-column>
<template #empty>
<div v-if="!loading" class="risk-table-empty">{{ TEXT.modules.riskIssuePd.emptyDescription }}</div>
</template> </template>
</el-table> </el-table-column>
</div> <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>
</div> </div>
</template> </template>
@@ -64,7 +74,6 @@
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { CircleCheck, Location, Search } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listStudySubjectPds } from "../../api/subjectPds"; import { listStudySubjectPds } from "../../api/subjectPds";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
@@ -77,33 +86,18 @@ const loading = ref(false);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const siteOptions = ref<Array<{ id: string; name: string }>>([]); const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({}); const siteMap = ref<Record<string, string>>({});
const filters = ref({ const filters = ref({ keyword: "", siteId: study.currentSite?.id || "", status: "" });
keyword: "",
siteId: study.currentSite?.id || "",
status: "",
});
const statusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label })); 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) => { const loadSites = async (studyId: string) => {
try { try {
const { data } = await fetchSites(studyId, { limit: 500 }); const { data } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(data) ? data : data.items || []; const list = Array.isArray(data) ? data : data.items || [];
siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name })); siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name }));
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => { siteMap.value = list.reduce((acc: Record<string, string>, site: any) => { acc[site.id] = site.name; return acc; }, {});
acc[site.id] = site.name; } catch { siteOptions.value = []; siteMap.value = {}; }
return acc;
}, {});
} catch {
siteOptions.value = [];
siteMap.value = {};
}
}; };
const load = async () => { const load = async () => {
@@ -122,11 +116,8 @@ const load = async () => {
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY; return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
}; };
items.value = list.sort((a: any, b: any) => getPdSeq(a?.pd_no) - getPdSeq(b?.pd_no)); items.value = list.sort((a: any, b: any) => getPdSeq(a?.pd_no) - getPdSeq(b?.pd_no));
} catch (e: any) { } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); }
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); finally { loading.value = false; }
} finally {
loading.value = false;
}
}; };
const filteredItems = computed(() => { const filteredItems = computed(() => {
@@ -147,59 +138,43 @@ const goSubject = (subjectId: string) => {
router.push({ path: `/subjects/${subjectId}`, query: { tab: "pd" } }); router.push({ path: `/subjects/${subjectId}`, query: { tab: "pd" } });
}; };
watch( watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
() => study.currentSite,
(newSite) => {
filters.value.siteId = newSite?.id || "";
}
);
onMounted(load); onMounted(load);
</script> </script>
<style scoped> <style scoped>
.page { .page { display: flex; flex-direction: column; gap: 0; padding: 0; background: #ffffff; }
display: flex;
flex-direction: column;
}
.filter-form { .page-header { display: flex; justify-content: space-between; align-items: flex-start; }
display: flex; .page-title { margin: 0; font-size: 18px; font-weight: 700; color: #0a0a0a; letter-spacing: -0.01em; }
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.filter-item-form { .table-card { background: #ffffff; overflow: hidden; }
margin-bottom: 0;
margin-right: 0;
}
.filter-input-comp { .table-card-toolbar { display: flex; align-items: center; padding: 14px 20px; border-bottom: 1px solid #f0f0f0; }
width: 200px; .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 { .risk-table { --el-table-border-color: transparent; --el-table-row-hover-bg-color: #f8f9fb; }
width: 170px; .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 { .cell-nowrap { white-space: nowrap; }
margin-bottom: 0; .cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; font-weight: 600; color: #0a0a0a; }
margin-right: 0; .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) { .status-pill { display: inline-flex; align-items: center; padding: 2px 10px; font-size: 11px; font-weight: 600; border-radius: 20px; line-height: 1.6; }
display: none; .status-pill--success { background: #dcfce7; color: #16a34a; }
} .status-pill--pending { background: #f5f5f5; color: #737373; }
.risk-table-empty { .table-empty { min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; }
min-height: 220px; .empty-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; background: #f5f5f5; color: #c4c4c4; }
display: flex; .table-empty span { font-size: 13px; font-weight: 500; color: #a3a3a3; }
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
</style> </style>
@@ -1,18 +1,9 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page ctms-page-shell page--flush">
<div class="main-content-flat unified-shell"> <div class="main-content-flat unified-shell">
<el-tabs v-if="canReadFeasibility || canReadEthics" v-model="activeTab" class="ctms-tabs"> <div class="tabs-header">
<el-tab-pane v-if="canReadFeasibility" :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility"> <el-tabs v-if="canReadFeasibility || canReadEthics" v-model="activeTab" class="ctms-tabs">
<div class="ctms-tab-content unified-section section--flush"> <el-tab-pane v-if="canReadFeasibility" :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
<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>
<el-table <el-table
:data="sortedFeasibilityItems" :data="sortedFeasibilityItems"
v-loading="loadingFeasibility" v-loading="loadingFeasibility"
@@ -47,45 +38,38 @@
<span class="font-mono text-main">{{ row.project_no }}</span> <span class="font-mono text-main">{{ row.project_no }}</span>
</template> </template>
</el-table-column> </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"> <template #default="scope">
<el-button <div class="cell-actions">
v-if="canUpdateFeasibility" <el-button
link v-if="canUpdateFeasibility"
type="primary" link
:disabled="isInactiveSite(scope.row.site_id)" type="primary"
@click.stop="openFeasibilityEditor(scope.row)" size="small"
> :disabled="isInactiveSite(scope.row.site_id)"
{{ TEXT.common.actions.edit }} @click.stop="openFeasibilityEditor(scope.row)"
</el-button> >
<el-button {{ TEXT.common.actions.edit }}
v-if="canDeleteFeasibility" </el-button>
link <el-button
type="danger" v-if="canDeleteFeasibility"
:disabled="isInactiveSite(scope.row.site_id)" link
@click.stop="removeFeasibility(scope.row)" type="danger"
> size="small"
{{ TEXT.common.actions.delete }} :disabled="isInactiveSite(scope.row.site_id)"
</el-button> @click.stop="removeFeasibility(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
<div v-if="!loadingFeasibility" class="table-empty">{{ TEXT.modules.startupFeasibilityEthics.emptyFeasibility }}</div> <div v-if="!loadingFeasibility" class="table-empty">{{ TEXT.modules.startupFeasibilityEthics.emptyFeasibility }}</div>
</template> </template>
</el-table> </el-table>
</div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane v-if="canReadEthics" :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics"> <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 <el-table
:data="sortedEthicsItems" :data="sortedEthicsItems"
v-loading="loadingEthics" v-loading="loadingEthics"
@@ -123,36 +107,50 @@
<span class="font-mono text-main">{{ row.approval_no }}</span> <span class="font-mono text-main">{{ row.approval_no }}</span>
</template> </template>
</el-table-column> </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"> <template #default="scope">
<el-button <div class="cell-actions">
v-if="canUpdateEthics" <el-button
link v-if="canUpdateEthics"
type="primary" link
:disabled="isInactiveSite(scope.row.site_id)" type="primary"
@click.stop="openEthicsEditor(scope.row)" size="small"
> :disabled="isInactiveSite(scope.row.site_id)"
{{ TEXT.common.actions.edit }} @click.stop="openEthicsEditor(scope.row)"
</el-button> >
<el-button {{ TEXT.common.actions.edit }}
v-if="canDeleteEthics" </el-button>
link <el-button
type="danger" v-if="canDeleteEthics"
:disabled="isInactiveSite(scope.row.site_id)" link
@click.stop="removeEthics(scope.row)" type="danger"
> size="small"
{{ TEXT.common.actions.delete }} :disabled="isInactiveSite(scope.row.site_id)"
</el-button> @click.stop="removeEthics(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
<div v-if="!loadingEthics" class="table-empty">{{ TEXT.modules.startupFeasibilityEthics.emptyEthics }}</div> <div v-if="!loadingEthics" class="table-empty">{{ TEXT.modules.startupFeasibilityEthics.emptyEthics }}</div>
</template> </template>
</el-table> </el-table>
</div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </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 <FeasibilityEditorDrawer
v-model="feasibilityDrawerVisible" v-model="feasibilityDrawerVisible"
@@ -422,30 +420,128 @@ watchEffect(() => {
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; padding: 4px 0;
background: #eeeff1;
} }
.ctms-tab-content { .main-content-flat {
gap: 0; 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 { /* ==================== Tabs Header ==================== */
margin-bottom: 0; .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) { .ctms-table :deep(.el-table__inner-wrapper::before) {
display: none; display: none;
} }
.table-empty { .ctms-table :deep(th.el-table__cell) {
min-height: 220px; background: #f4f5f7;
display: flex; color: #2a2a2a;
align-items: center; font-size: 12px;
justify-content: center; font-weight: 700;
color: #8a97ab; 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-size: 14px;
font-weight: 500; 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> </style>
@@ -12,12 +12,32 @@ describe("StartupMeetingAuth permissions", () => {
expect(source).toContain("if (canReadMembers.value)"); 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(); const source = readSource();
expect(source).toContain('can("startup.auth.create")'); expect(source).not.toContain("createKickoff");
expect(source).toContain("canCreateAuth"); expect(source).toContain("if (!row.meeting_id) return");
expect(source).toContain("if (!canCreateAuth.value)"); });
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", () => { it("uses backend contact display names before falling back to member lookup", () => {
+125 -29
View File
@@ -25,12 +25,37 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </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> <template #empty>
<div v-if="!loading" class="table-empty">{{ TEXT.modules.startupMeetingAuth.emptyKickoff }}</div> <div v-if="!loading" class="table-empty">{{ TEXT.modules.startupMeetingAuth.emptyKickoff }}</div>
</template> </template>
</el-table> </el-table>
</section> </section>
</div> </div>
<KickoffEditorDrawer
v-model="editorVisible"
:meeting-id="editingMeetingId"
:study-id="study.currentStudy?.id || ''"
:site-name="editingSiteName"
:owner-label="editingOwnerLabel"
:readonly="editingReadOnly"
@saved="handleEditorSaved"
/>
</div> </div>
</template> </template>
@@ -40,7 +65,7 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { createKickoff, listKickoffs } from "../../api/startup"; import { listKickoffs } from "../../api/startup";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users"; import { fetchUsers } from "../../api/users";
@@ -48,6 +73,7 @@ import { displayDate } from "../../utils/display";
import { isSystemAdmin } from "../../utils/roles"; import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
import KickoffEditorDrawer from "../startup/KickoffEditorDrawer.vue";
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -58,9 +84,14 @@ const sites = ref<any[]>([]);
const users = ref<any[]>([]); const users = ref<any[]>([]);
const members = ref<any[]>([]); const members = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const editorVisible = ref(false);
const editingMeetingId = ref("");
const editingSiteName = ref("");
const editingOwnerLabel = ref("");
const editingReadOnly = ref(false);
const isAdmin = computed(() => isSystemAdmin(auth.user)); const isAdmin = computed(() => isSystemAdmin(auth.user));
const canReadMembers = computed(() => can("project.members.list")); 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 memberNameMap = computed(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
@@ -76,10 +107,10 @@ const memberNameMap = computed(() => {
return map; return map;
}); });
const siteActiveMap = computed(() => { const siteMap = computed(() => {
const map: Record<string, boolean> = {}; const map: Record<string, any> = {};
sites.value.forEach((site) => { sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active; if (site?.id) map[site.id] = site;
}); });
return map; return map;
}); });
@@ -96,22 +127,18 @@ const contactLabel = (row: any) => {
}; };
const kickoffRows = computed(() => { const kickoffRows = computed(() => {
const kickoffMap = kickoffItems.value.reduce<Record<string, any>>((acc, item) => { return kickoffItems.value
if (item.site_id) acc[item.site_id] = item; .map((meeting) => {
return acc; const site = siteMap.value[meeting.site_id] || {};
}, {});
return sites.value
.map((site) => {
const meeting = kickoffMap[site.id];
return { return {
site_id: site.id, site_id: meeting.site_id,
site_name: site.name || TEXT.common.fallback, site_name: site.name || TEXT.common.fallback,
contact: site.contact, contact: site.contact,
contact_display: site.contact_display, contact_display: site.contact_display,
kickoff_date: meeting?.kickoff_date || null, kickoff_date: meeting.kickoff_date || null,
meeting_id: meeting?.id, meeting_id: meeting.id,
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING", status: meeting.kickoff_date ? "COMPLETED" : "PENDING",
is_active: !!site.is_active, is_active: site.is_active !== false,
}; };
}) })
.sort((a, b) => Number(a.is_active === false) - Number(b.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 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 onKickoffRowClick = async (row: any) => {
const studyId = study.currentStudy?.id; if (!row?.site_id) return;
if (!studyId || !row?.site_id) return; if (!row.meeting_id) return;
if (row.is_active === false && !row.meeting_id) {
ElMessage.warning("中心已停用");
return;
}
if (row.meeting_id) { if (row.meeting_id) {
goKickoffDetail(row.meeting_id); goKickoffDetail(row.meeting_id);
return; 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("权限不足"); ElMessage.warning("权限不足");
return; return;
} }
try { if (row.is_active === false) {
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any; ElMessage.warning("中心已停用");
goKickoffDetail(data.id); return;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
} }
prepareKickoffEditor(row, row.meeting_id);
};
const handleEditorSaved = async () => {
editorVisible.value = false;
await loadKickoffs();
}; };
const kickoffRowClass = ({ row }: { row: any }) => (row?.is_active === false ? "row-inactive" : ""); const kickoffRowClass = ({ row }: { row: any }) => (row?.is_active === false ? "row-inactive" : "");
@@ -183,10 +223,32 @@ onMounted(() => {
gap: 0; gap: 0;
} }
.kickoff-table-section {
background: #ffffff;
overflow: hidden;
}
.kickoff-table :deep(.el-table__inner-wrapper::before) { .kickoff-table :deep(.el-table__inner-wrapper::before) {
display: none; 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 { .table-empty {
min-height: 220px; min-height: 220px;
display: flex; display: flex;
@@ -197,6 +259,40 @@ onMounted(() => {
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; 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>
<style> <style>
+220 -201
View File
@@ -1,132 +1,102 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page">
<div class="main-content-flat unified-shell"> <div class="table-card">
<div class="filter-container unified-action-bar bar--flush"> <div class="table-card-toolbar">
<el-form :inline="true" :model="filters" class="filter-form"> <div class="toolbar-filters">
<div class="filter-item-form"> <div class="filter-item">
<el-input <span class="filter-label">{{ TEXT.modules.subjectManagement.screeningNo }}</span>
v-model="filters.keyword" <el-input v-model="filters.keyword" clearable placeholder="请输入" class="filter-input" />
:placeholder="TEXT.modules.subjectManagement.screeningNo"
clearable
class="filter-input-comp"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div> </div>
<div class="filter-item-form"> <div class="filter-item">
<el-select <span class="filter-label">{{ TEXT.common.fields.site }}</span>
v-model="filters.siteId" <el-select v-model="filters.siteId" clearable filterable placeholder="请选择" class="filter-select">
: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="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" /> <el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select> </el-select>
</div> </div>
<div class="filter-item-form"> <div class="filter-item">
<el-select <span class="filter-label">{{ TEXT.common.fields.status }}</span>
v-model="filters.status" <el-select v-model="filters.status" clearable placeholder="请选择" class="filter-select">
: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="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /> <el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select> </el-select>
</div> </div>
<div class="filter-actions-comp"> <div class="filter-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</div> </div>
<div class="filter-spacer"></div> </div>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="header-action-btn"> <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> <el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }} {{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
</el-button> </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>
</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> </div>
<SubjectEditorDrawer
v-model="subjectDrawerVisible" <SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
@success="handleSubjectEditorSuccess"
/>
</div> </div>
</template> </template>
@@ -134,7 +104,7 @@
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; 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 { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { deleteSubject, fetchSubjects } from "../../api/subjects"; import { deleteSubject, fetchSubjects } from "../../api/subjects";
@@ -212,34 +182,22 @@ const load = async () => {
} }
}; };
const goNew = () => { const goNew = () => { subjectDrawerVisible.value = true; };
subjectDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/subjects/${id}`); const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => { const handleSubjectEditorSuccess = () => { load(); };
load(); const onRowClick = (row: any) => { if (!row?.id) return; goDetail(row.id); };
};
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
if (!canDeleteSubject.value) return; if (!canDeleteSubject.value) return;
if (isInactiveSite(row?.site_id)) { if (isInactiveSite(row?.site_id)) { ElMessage.warning("中心已停用"); return; }
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await deleteSubject(studyId, row.id); await deleteSubject(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess); ElMessage.success(TEXT.common.messages.deleteSuccess);
load(); load();
} catch (e: any) { } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); }
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
}; };
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false; 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 }> = []; 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_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_susar) badges.push({ type: "susar", label: TEXT.modules.subjectDetail.aeType.susar });
if (!row?.has_sae && !row?.has_susar && row?.has_ae) { if (!row?.has_sae && !row?.has_susar && row?.has_ae) badges.push({ type: "ae", label: TEXT.modules.subjectDetail.aeType.ae });
badges.push({ type: "ae", label: TEXT.modules.subjectDetail.aeType.ae });
}
return badges; return badges;
}; };
const displaySubjectStatus = (subject: any) => { const displaySubjectStatus = (subject: any) => {
@@ -276,96 +232,144 @@ const pagedItems = computed(() => {
const subjectRowClass = ({ row }: { row: any }) => const subjectRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim(); `${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
watch( watch(() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize], () => { pagination.value.currentPage = 1; });
() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize], watch(() => filteredItems.value.length, (total) => {
() => { const maxPage = Math.max(1, Math.ceil(total / pagination.value.pageSize));
pagination.value.currentPage = 1; if (pagination.value.currentPage > maxPage) pagination.value.currentPage = maxPage;
}
);
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(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
onMounted(async () => { onMounted(async () => { await loadSites(); load(); });
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0;
padding: 0;
background: #ffffff;
} }
.filter-form { .table-card {
background: #ffffff;
overflow: hidden;
}
/* ==================== Toolbar ==================== */
.table-card-toolbar {
display: flex; display: flex;
width: 100%; justify-content: space-between;
gap: 8px;
align-items: center; align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
} }
.filter-item-form { .toolbar-filters {
margin-bottom: 0 !important; display: flex;
margin-right: 0 !important; align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
} }
.filter-select-comp, .toolbar-right {
.filter-input-comp { display: flex;
width: 140px; align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
} }
.filter-spacer { .filter-item {
flex: 1; display: flex;
flex-direction: column;
gap: 4px;
} }
.header-action-btn { .filter-label {
height: 32px; font-size: 11px;
padding: 0 12px; font-weight: 600;
border-radius: 10px; color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.03em;
} }
:deep(.ctms-table .el-table__inner-wrapper::before) { .filter-input { width: 160px; }
display: none; .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 { .subject-info-cell {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; 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 { .ae-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
color: #ffffff; color: #ffffff;
border: 1px solid transparent;
padding: 2px 6px; padding: 2px 6px;
height: 18px; height: 18px;
line-height: normal;
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.02em; letter-spacing: 0.02em;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
white-space: nowrap;
} }
.ae-badge::before { .ae-badge::before {
@@ -378,32 +382,46 @@ onMounted(async () => {
margin-right: 4px; margin-right: 4px;
} }
.ae-badge-ae { .ae-badge-ae { background: #3b82f6; }
background: #3b82f6; .ae-badge-sae { background: #ef4444; }
} .ae-badge-susar { background: #8b5cf6; }
.ae-badge-sae {
background: #ef4444;
}
.ae-badge-susar {
background: #8b5cf6;
}
/* ==================== Pagination ==================== */
.pagination-wrap { .pagination-wrap {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 10px; padding: 12px 20px;
border-top: 1px solid #f0f0f0;
} }
/* ==================== Empty State ==================== */
.table-empty { .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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #8a97ab; width: 48px;
font-size: 14px; height: 48px;
font-weight: 500; border-radius: 50%;
letter-spacing: 0.02em; 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> </style>
@@ -413,7 +431,8 @@ onMounted(async () => {
background-color: #fff7d6 !important; background-color: #fff7d6 !important;
} }
.row-inactive .el-tag { .row-inactive .el-tag,
.row-inactive .ae-badge {
opacity: 0.6; opacity: 0.6;
} }
</style> </style>
@@ -16,6 +16,7 @@ describe("MaterialEquipmentEditorDrawer attachments", () => {
expect(source).toContain('ref="attachmentPanelRef"'); expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="material_equipment"'); expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':mode="\'upload\'"'); expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain(':center-upload-card-content="true"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.equipmentId)"); expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.equipmentId)");
expect(source).toContain("uploadPending"); expect(source).toContain("uploadPending");
@@ -88,6 +88,7 @@
:entity-id="equipmentId" :entity-id="equipmentId"
:mode="'upload'" :mode="'upload'"
:readonly="readonly" :readonly="readonly"
:center-upload-card-content="true"
/> />
</div> </div>
</el-form> </el-form>
@@ -111,6 +111,19 @@ describe("route view overlays are mounted only when visible", () => {
file: "./admin/PermissionManagement.vue", file: "./admin/PermissionManagement.vue",
snippets: [':close-on-click-modal="true"', ':before-close="handleTemplateDrawerBeforeClose"'], 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", file: "./admin/AuditLogs.vue",
snippets: [':close-on-click-modal="true"'], snippets: [':close-on-click-modal="true"'],
+204 -87
View File
@@ -1,38 +1,110 @@
<template> <template>
<div class="page"> <div class="page">
<div class="unified-shell" v-loading="loading"> <StateError v-if="errorMessage" :description="errorMessage">
<section class="unified-section"> <template #action>
<div class="unified-section-header"> <el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</div> </template>
<div class="ctms-section-actions"> </StateError>
<el-button v-if="canUpdateEthics" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor">
<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 }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
</div> </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> </div>
<el-descriptions :column="2" border class="ctms-descriptions"> </div>
<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> <div class="section-card">
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item> <div class="section-card-header">
<el-descriptions-item :label="TEXT.common.fields.meetingDate">{{ displayDate(detail.meeting_date) }}</el-descriptions-item> <div class="section-card-header-left">
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item> <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>
<el-descriptions-item :label="TEXT.common.fields.status"> <span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag> </div>
</el-descriptions-item> </div>
</el-descriptions> <div class="section-divider" />
</section> <div class="section-card-body">
<section class="unified-section attachment-section"> <AttachmentList
<AttachmentList v-if="recordId"
v-if="recordId" :study-id="studyId"
:study-id="studyId" entity-type="startup_ethics"
entity-type="startup_ethics" :entity-id="recordId"
:entity-id="recordId" :readonly="isInactiveSite(detail.site_id)"
:readonly="isInactiveSite(detail.site_id)" :hide-uploader="true"
:hide-uploader="true" />
/> </div>
</section> </div>
</div> </template>
<EthicsEditorDrawer <EthicsEditorDrawer
v-model="editorVisible" v-model="editorVisible"
:record-id="recordId" :record-id="recordId"
@@ -45,6 +117,7 @@
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getEthics } from "../../api/startup"; import { getEthics } from "../../api/startup";
@@ -52,6 +125,8 @@ import { fetchSites } from "../../api/sites";
import type { Site } from "../../types/api"; import type { Site } from "../../types/api";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import type { StageStatus } from "../ia/project-overview/overview.adapter"; import type { StageStatus } from "../ia/project-overview/overview.adapter";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
@@ -61,6 +136,7 @@ const route = useRoute();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission(); const { can } = usePermission();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false); const editorVisible = ref(false);
const recordId = route.params.recordId as string; const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
@@ -73,30 +149,15 @@ const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.val
const canUpdateEthics = computed(() => can("startup.ethics.update")); const canUpdateEthics = computed(() => can("startup.ethics.update"));
const detail = reactive<any>({ const detail = reactive<any>({
site_id: "", site_id: "", submit_date: "", accept_date: "", meeting_date: "", approved_date: "", approval_no: "",
submit_date: "",
accept_date: "",
meeting_date: "",
approved_date: "",
approval_no: "",
}); });
const SUBMIT_TO_ACCEPT_MONTHS = const SUBMIT_TO_ACCEPT_MONTHS = Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) || 1;
Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) || const ACCEPT_TO_APPROVAL_MONTHS = Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) || 1;
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 statusLabelMap: Record<StageStatus, string> = { const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成", COMPLETED: "已完成", IN_PROGRESS: "进行中", NOT_STARTED: "未开始", BLOCKED: "阻塞/延期",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
}; };
const statusLabel = (status: StageStatus) => statusLabelMap[status] || "未开始"; const statusLabel = (status: StageStatus) => statusLabelMap[status] || "未开始";
const statusTagType = (status: StageStatus) => { const statusTagType = (status: StageStatus) => {
if (status === "COMPLETED") return "success"; if (status === "COMPLETED") return "success";
@@ -109,7 +170,6 @@ const isBlockedByAcceptDate = (acceptDate?: string, approvedDate?: string) => {
if (!acceptDate || approvedDate) return false; if (!acceptDate || approvedDate) return false;
return dayjs(acceptDate).add(ACCEPT_TO_APPROVAL_MONTHS, "month").isBefore(dayjs()); return dayjs(acceptDate).add(ACCEPT_TO_APPROVAL_MONTHS, "month").isBefore(dayjs());
}; };
const isBlockedBySubmitDate = (submitDate?: string, acceptDate?: string) => { const isBlockedBySubmitDate = (submitDate?: string, acceptDate?: string) => {
if (!submitDate || acceptDate) return false; if (!submitDate || acceptDate) return false;
return dayjs(submitDate).add(SUBMIT_TO_ACCEPT_MONTHS, "month").isBefore(dayjs()); return dayjs(submitDate).add(SUBMIT_TO_ACCEPT_MONTHS, "month").isBefore(dayjs());
@@ -128,67 +188,124 @@ const loadSites = async () => {
try { try {
const { data } = await fetchSites(studyId, { limit: 500 }); const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || []; sites.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch { sites.value = []; }
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
}; };
const load = async () => { const load = async () => {
if (!studyId || !recordId) return; if (!studyId || !recordId) return;
loading.value = true; loading.value = true;
errorMessage.value = "";
try { try {
const { data } = await getEthics(studyId, recordId); const { data } = await getEthics(studyId, recordId);
Object.assign(detail, data); Object.assign(detail, data);
const siteName = displaySite(detail.site_id); const siteName = displaySite(detail.site_id);
const hasSiteName = siteName !== TEXT.common.fallback; study.setViewContext({ siteName, pageTitle: detail.approval_no || siteName || TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle });
study.setViewContext({
siteName,
pageTitle: detail.approval_no || (hasSiteName ? siteName : TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle),
});
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally { } finally { loading.value = false; }
loading.value = false;
}
}; };
const openEditor = () => { const openEditor = () => {
if (!canUpdateEthics.value) { if (!canUpdateEthics.value) { ElMessage.warning("权限不足"); return; }
ElMessage.warning("权限不足"); if (isInactiveSite(detail.site_id)) { ElMessage.warning("中心已停用"); return; }
return;
}
if (isInactiveSite(detail.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editorVisible.value = true; editorVisible.value = true;
}; };
const handleEditorSuccess = async () => { await load(); };
const handleEditorSuccess = async () => { onMounted(async () => { await loadSites(); await load(); });
await load();
};
onMounted(async () => {
await loadSites();
await load();
});
</script> </script>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex; flex-direction: column; gap: 16px; padding: 0; background: transparent;
flex-direction: column;
gap: 0;
} }
.attachment-section { /* ==================== Hero Banner ==================== */
padding: 0; .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> .hero-title { margin: 0; font-size: 19px; font-weight: 600; color: #3d2410; letter-spacing: 0.005em; }
.page {
display: flex; .hero-status-badge {
flex-direction: column; display: inline-flex; align-items: center; padding: 2px 12px; font-size: 11px; font-weight: 500;
gap: 0; 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> </style>
+193 -85
View File
@@ -1,37 +1,101 @@
<template> <template>
<div class="page"> <div class="page">
<div class="unified-shell" v-loading="loading"> <StateError v-if="errorMessage" :description="errorMessage">
<section class="unified-section"> <template #action>
<div class="unified-section-header"> <el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</div> </template>
<div class="ctms-section-actions"> </StateError>
<el-button v-if="canUpdateInitiation" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor">
<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 }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
</div> </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> </div>
<el-descriptions :column="2" border class="ctms-descriptions"> </div>
<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> <div class="section-card">
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item> <div class="section-card-header">
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item> <div class="section-card-header-left">
<el-descriptions-item :label="TEXT.common.fields.status"> <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>
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag> <span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
</el-descriptions-item> </div>
</el-descriptions> </div>
</section> <div class="section-divider" />
<section class="unified-section attachment-section"> <div class="section-card-body">
<AttachmentList <AttachmentList
v-if="recordId" v-if="recordId"
:study-id="studyId" :study-id="studyId"
entity-type="startup_feasibility" entity-type="startup_feasibility"
:entity-id="recordId" :entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)" :readonly="isInactiveSite(detail.site_id)"
:hide-uploader="true" :hide-uploader="true"
/> />
</section> </div>
</div> </div>
</template>
<FeasibilityEditorDrawer <FeasibilityEditorDrawer
v-model="editorVisible" v-model="editorVisible"
:record-id="recordId" :record-id="recordId"
@@ -44,6 +108,7 @@
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getFeasibility } from "../../api/startup"; import { getFeasibility } from "../../api/startup";
@@ -51,6 +116,8 @@ import { fetchSites } from "../../api/sites";
import type { Site } from "../../types/api"; import type { Site } from "../../types/api";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import type { StageStatus } from "../ia/project-overview/overview.adapter"; import type { StageStatus } from "../ia/project-overview/overview.adapter";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
@@ -60,6 +127,7 @@ const route = useRoute();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission(); const { can } = usePermission();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false); const editorVisible = ref(false);
const recordId = route.params.recordId as string; const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
@@ -72,29 +140,15 @@ const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.val
const canUpdateInitiation = computed(() => can("startup.initiation.update")); const canUpdateInitiation = computed(() => can("startup.initiation.update"));
const detail = reactive<any>({ const detail = reactive<any>({
site_id: "", site_id: "", submit_date: "", accept_date: "", approved_date: "", project_no: "",
submit_date: "",
accept_date: "",
approved_date: "",
project_no: "",
}); });
const SUBMIT_TO_ACCEPT_MONTHS = const SUBMIT_TO_ACCEPT_MONTHS = Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) || 1;
Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) || const ACCEPT_TO_APPROVAL_MONTHS = Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) || 1;
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 statusLabelMap: Record<StageStatus, string> = { const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成", COMPLETED: "已完成", IN_PROGRESS: "进行中", NOT_STARTED: "未开始", BLOCKED: "阻塞/延期",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
}; };
const statusLabel = (status: StageStatus) => statusLabelMap[status] || "未开始"; const statusLabel = (status: StageStatus) => statusLabelMap[status] || "未开始";
const statusTagType = (status: StageStatus) => { const statusTagType = (status: StageStatus) => {
if (status === "COMPLETED") return "success"; if (status === "COMPLETED") return "success";
@@ -107,7 +161,6 @@ const isBlockedByAcceptDate = (acceptDate?: string, approvedDate?: string) => {
if (!acceptDate || approvedDate) return false; if (!acceptDate || approvedDate) return false;
return dayjs(acceptDate).add(ACCEPT_TO_APPROVAL_MONTHS, "month").isBefore(dayjs()); return dayjs(acceptDate).add(ACCEPT_TO_APPROVAL_MONTHS, "month").isBefore(dayjs());
}; };
const isBlockedBySubmitDate = (submitDate?: string, acceptDate?: string) => { const isBlockedBySubmitDate = (submitDate?: string, acceptDate?: string) => {
if (!submitDate || acceptDate) return false; if (!submitDate || acceptDate) return false;
return dayjs(submitDate).add(SUBMIT_TO_ACCEPT_MONTHS, "month").isBefore(dayjs()); return dayjs(submitDate).add(SUBMIT_TO_ACCEPT_MONTHS, "month").isBefore(dayjs());
@@ -126,67 +179,122 @@ const loadSites = async () => {
try { try {
const { data } = await fetchSites(studyId, { limit: 500 }); const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || []; sites.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch { sites.value = []; }
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
}; };
const load = async () => { const load = async () => {
if (!studyId || !recordId) return; if (!studyId || !recordId) return;
loading.value = true; loading.value = true;
errorMessage.value = "";
try { try {
const { data } = await getFeasibility(studyId, recordId); const { data } = await getFeasibility(studyId, recordId);
Object.assign(detail, data); Object.assign(detail, data);
const siteName = displaySite(detail.site_id); const siteName = displaySite(detail.site_id);
const hasSiteName = siteName !== TEXT.common.fallback; study.setViewContext({ siteName, pageTitle: detail.project_no || siteName || TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle });
study.setViewContext({
siteName,
pageTitle: detail.project_no || (hasSiteName ? siteName : TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle),
});
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally { } finally { loading.value = false; }
loading.value = false;
}
}; };
const openEditor = () => { const openEditor = () => {
if (!canUpdateInitiation.value) { if (!canUpdateInitiation.value) { ElMessage.warning("权限不足"); return; }
ElMessage.warning("权限不足"); if (isInactiveSite(detail.site_id)) { ElMessage.warning("中心已停用"); return; }
return;
}
if (isInactiveSite(detail.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editorVisible.value = true; editorVisible.value = true;
}; };
const handleEditorSuccess = async () => { await load(); };
const handleEditorSuccess = async () => { onMounted(async () => { await loadSites(); await load(); });
await load();
};
onMounted(async () => {
await loadSites();
await load();
});
</script> </script>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex; flex-direction: column; gap: 16px; padding: 0; background: transparent;
flex-direction: column;
gap: 0;
} }
.attachment-section { /* ==================== Hero Banner ==================== */
padding: 0; .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> .hero-title { margin: 0; font-size: 19px; font-weight: 600; color: #3d2410; letter-spacing: 0.005em; }
.page {
display: flex; .hero-status-badge {
flex-direction: column; display: inline-flex; align-items: center; padding: 2px 12px; font-size: 11px; font-weight: 500;
gap: 0; 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> </style>
+323 -219
View File
@@ -1,117 +1,126 @@
<template> <template>
<div class="page"> <div class="page">
<div class="unified-shell"> <StateError v-if="errorMessage" :description="errorMessage">
<section class="unified-section" v-loading="loading"> <template #action>
<div class="unified-section-header"> <el-button type="primary" @click="loadAll">{{ TEXT.common.actions.retry }}</el-button>
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div> </template>
<div class="ctms-section-actions"> </StateError>
<el-button
v-if="canUpdateAuth" <StateLoading v-else-if="loading" :rows="6" />
type="primary"
:disabled="loading || isReadOnly" <template v-else>
@click="startEdit" <!-- ==================== 顶部信息区 ==================== -->
> <div class="top-section">
{{ TEXT.common.actions.edit }} <div class="top-header">
</el-button> <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>
</div> </div>
<el-descriptions :column="2" border> </div>
<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>
<section class="unified-section training-section"> <!-- ==================== 培训授权列表 ==================== -->
<div class="training-header"> <div class="section-card">
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div> <div class="section-card-header">
<el-button v-if="canCreateAuth" type="primary" size="small" :disabled="isReadOnly" @click="startTrainingAdd"> <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 }} {{ TEXT.common.actions.add }}
</el-button> </el-button>
</div> </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> <el-table-column prop="name" :label="TEXT.common.fields.name" show-overflow-tooltip>
<template #default="scope"> <template #default="scope"><span class="cell-primary cell-nowrap">{{ scope.row.name || TEXT.common.fallback }}</span></template>
{{ scope.row.name || TEXT.common.fallback }}
</template>
</el-table-column> </el-table-column>
<el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip> <el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip>
<template #default="scope"> <template #default="scope"><span class="cell-nowrap">{{ scope.row.role || TEXT.common.fallback }}</span></template>
{{ scope.row.role || TEXT.common.fallback }}
</template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.trained"> <el-table-column :label="TEXT.common.fields.trained">
<template #default="scope"> <template #default="scope">
<div class="status-with-date"> <div class="status-cell">
<span>{{ trainingStatusLabel(scope.row) }}</span> <span :class="['status-dot', scope.row.trained ? 'status-dot--yes' : 'status-dot--no']" />
<span v-if="scope.row.trained && scope.row.trained_date" class="status-date-small"> <span>{{ scope.row.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
{{ displayDate(scope.row.trained_date) }} <span v-if="scope.row.trained && scope.row.trained_date" class="status-date">{{ displayDate(scope.row.trained_date) }}</span>
</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.authorized"> <el-table-column :label="TEXT.common.fields.authorized">
<template #default="scope"> <template #default="scope">
<div class="status-with-date"> <div class="status-cell">
<span>{{ authorizationStatusLabel(scope.row) }}</span> <span :class="['status-dot', scope.row.authorized ? 'status-dot--yes' : 'status-dot--no']" />
<span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date-small"> <span>{{ scope.row.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
{{ displayDate(scope.row.authorized_date) }} <span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date">{{ displayDate(scope.row.authorized_date) }}</span>
</span>
</div> </div>
</template> </template>
</el-table-column> </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"> <template #default="scope">
<el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)"> <div class="cell-actions">
{{ TEXT.common.actions.edit }} <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> <el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)"> </div>
{{ TEXT.common.actions.delete }}
</el-button>
</template> </template>
</el-table-column> </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> </el-table>
</section> </div>
<section v-if="meetingId" class="unified-section attachment-section"> <!-- ==================== 附件 ==================== -->
<AttachmentList <div v-if="meetingId" class="section-card">
:study-id="studyId" <div class="section-card-header">
entity-type="startup_kickoff" <div class="section-card-title-row">
:entity-id="meetingId" <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>
:entity-groups="kickoffAttachmentGroups" <span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
:title="TEXT.common.labels.attachments" </div>
:readonly="true" </div>
:hide-uploader="true" <div class="section-card-body">
:refresh-key="attachmentRefreshKey" <AttachmentList
/> :study-id="studyId"
</section> entity-type="startup_kickoff"
</div> :entity-id="meetingId"
<KickoffEditorDrawer :entity-groups="kickoffAttachmentGroups"
v-model="editorVisible" :readonly="true"
:meeting-id="meetingId" :hide-uploader="true"
:study-id="studyId" :refresh-key="attachmentRefreshKey"
:site-name="siteInfo.name" />
:owner-label="ownerLabel" </div>
:readonly="isReadOnly" </div>
@saved="handleEditorSaved" </template>
/>
<TrainingEditorDrawer <KickoffEditorDrawer v-model="editorVisible" :meeting-id="meetingId" :study-id="studyId" :site-name="siteInfo.name" :owner-label="ownerLabel" :readonly="isReadOnly" @saved="handleEditorSaved" />
v-model="trainingEditorVisible" <TrainingEditorDrawer v-model="trainingEditorVisible" :record-id="editingTrainingId" :study-id="studyId" :sites="sites" :initial-site-name="siteInfo.name" @saved="handleTrainingEditorSaved" />
:record-id="editingTrainingId"
:study-id="studyId"
:sites="sites"
:initial-site-name="siteInfo.name"
@saved="handleTrainingEditorSaved"
/>
</div> </div>
</template> </template>
@@ -119,18 +128,17 @@
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { import { getKickoff, listTrainingAuthorizations, deleteTrainingAuthorization } from "../../api/startup";
getKickoff,
listTrainingAuthorizations,
deleteTrainingAuthorization,
} from "../../api/startup";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users"; import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display"; import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
import KickoffEditorDrawer from "./KickoffEditorDrawer.vue"; import KickoffEditorDrawer from "./KickoffEditorDrawer.vue";
@@ -141,6 +149,7 @@ const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission(); const { can } = usePermission();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref("");
const loadingTraining = ref(false); const loadingTraining = ref(false);
const editorVisible = ref(false); const editorVisible = ref(false);
const trainingEditorVisible = ref(false); const trainingEditorVisible = ref(false);
@@ -164,46 +173,33 @@ const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel }, { entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
]; ];
const detail = reactive<any>({ const detail = reactive<any>({ kickoff_date: "" });
kickoff_date: "",
});
const trainingItems = ref<any[]>([]); const trainingItems = ref<any[]>([]);
const memberNameMap = computed(() => { const memberNameMap = computed(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
users.value.forEach((u: any) => { users.value.forEach((u: any) => { if (u?.id) map[u.id] = u.full_name || u.username || u.id; });
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => { members.value.forEach((m: any) => {
const user = m?.user; const user = m?.user;
if (m?.user_id && !map[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;
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
}
}); });
return map; return map;
}); });
const ownerLabel = computed(() => { const ownerLabel = computed(() => {
if (!siteInfo.contact) return TEXT.common.fallback; if (!siteInfo.contact) return TEXT.common.fallback;
return String(siteInfo.contact) return String(siteInfo.contact).split(",").map((c) => c.trim()).filter(Boolean).map((c) => memberNameMap.value[c] || c).join("、") || TEXT.common.fallback;
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
}); });
const statusLabel = computed(() => const statusLabel = computed(() => detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending);
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending const statusTagType = computed(() => detail.kickoff_date ? "success" : "info");
);
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
const isReadOnly = computed(() => siteInfo.is_active === false); const isReadOnly = computed(() => siteInfo.is_active === false);
const trainingStatusLabel = (row: any) => (row?.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
const authorizationStatusLabel = (row: any) => (row?.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no); const loadAll = async () => { await load(); await loadTraining(); };
const load = async () => { const load = async () => {
if (!studyId || !meetingId) return; if (!studyId || !meetingId) return;
loading.value = true; loading.value = true; errorMessage.value = "";
try { try {
const { data } = await getKickoff(studyId, meetingId); const { data } = await getKickoff(studyId, meetingId);
Object.assign(detail, data); Object.assign(detail, data);
@@ -213,19 +209,11 @@ const load = async () => {
sites.value = list; sites.value = list;
const matched = list.find((site: any) => site.id === data.site_id); const matched = list.find((site: any) => site.id === data.site_id);
Object.assign(siteInfo, matched || { name: "" }); Object.assign(siteInfo, matched || { name: "" });
} else { } else { Object.assign(siteInfo, { name: "" }); }
Object.assign(siteInfo, { name: "" });
}
const siteName = siteInfo.name || TEXT.common.fallback; const siteName = siteInfo.name || TEXT.common.fallback;
study.setViewContext({ study.setViewContext({ siteName, pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle });
siteName, } catch (e: any) { errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed; }
pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle, finally { loading.value = false; }
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
}; };
const loadTraining = async () => { const loadTraining = async () => {
@@ -234,134 +222,250 @@ const loadTraining = async () => {
try { try {
const { data } = await listTrainingAuthorizations(studyId, { site_name: siteInfo.name }); const { data } = await listTrainingAuthorizations(studyId, { site_name: siteInfo.name });
trainingItems.value = Array.isArray(data) ? data : data.items || []; trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) { } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); }
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); finally { loadingTraining.value = false; }
} finally {
loadingTraining.value = false;
}
}; };
const startTrainingEdit = (row: any) => { const startTrainingEdit = (row: any) => {
if (isReadOnly.value) return; if (isReadOnly.value) return;
if (!canUpdateAuth.value) { if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
ElMessage.warning("权限不足"); editingTrainingId.value = row?.id || undefined; trainingEditorVisible.value = true;
return;
}
editingTrainingId.value = row?.id || undefined;
trainingEditorVisible.value = true;
}; };
const startTrainingAdd = () => { const startTrainingAdd = () => {
if (isReadOnly.value) return; if (isReadOnly.value) return;
if (!canCreateAuth.value) { if (!canCreateAuth.value) { ElMessage.warning("权限不足"); return; }
ElMessage.warning("权限不足"); editingTrainingId.value = undefined; trainingEditorVisible.value = true;
return;
}
editingTrainingId.value = undefined;
trainingEditorVisible.value = true;
}; };
const removeTraining = async (row: any) => { const removeTraining = async (row: any) => {
if (isReadOnly.value) { if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; }
ElMessage.warning("中心已停用"); if (!canDeleteAuth.value) { ElMessage.warning("权限不足"); return; }
return;
}
if (!canDeleteAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (!studyId) return; if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
try { try { await deleteTrainingAuthorization(studyId, row.id); ElMessage.success(TEXT.common.messages.deleteSuccess); loadTraining(); }
await deleteTrainingAuthorization(studyId, row.id); catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); }
ElMessage.success(TEXT.common.messages.deleteSuccess);
loadTraining();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
}; };
const startEdit = () => { const startEdit = () => {
if (isReadOnly.value) { if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; }
ElMessage.warning("中心已停用"); if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
return;
}
if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
editorVisible.value = true; editorVisible.value = true;
}; };
const handleEditorSaved = () => { load(); attachmentRefreshKey.value += 1; };
const handleEditorSaved = () => { const handleTrainingEditorSaved = () => { loadTraining(); };
load();
attachmentRefreshKey.value += 1;
};
const handleTrainingEditorSaved = () => {
loadTraining();
};
onMounted(async () => { onMounted(async () => {
if (studyId) { if (studyId) {
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null); const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
let membersReq: Promise<any> = Promise.resolve(null); let membersReq: Promise<any> = Promise.resolve(null);
if (canReadMembers.value) { if (canReadMembers.value) membersReq = listMembers(studyId, { limit: 500 });
membersReq = listMembers(studyId, { limit: 500 });
}
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const); const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
if (membersResp?.data) { if (membersResp?.data) members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || []; if (usersResp?.data && !Array.isArray(usersResp.data)) users.value = usersResp.data.items || [];
}
if (usersResp?.data && !Array.isArray(usersResp.data)) {
users.value = usersResp.data.items || [];
}
} }
await load(); await loadAll();
await loadTraining();
}); });
</script> </script>
<style scoped> <style scoped>
.page { .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; 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; justify-content: space-between;
margin-bottom: 12px; align-items: flex-start;
margin-bottom: 20px;
} }
.training-title { .top-header-left {
font-size: 15px; display: flex;
font-weight: 600;
color: var(--ctms-text-main);
}
.status-with-date {
display: inline-flex;
flex-direction: column; 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; line-height: 1.2;
} }
.status-date-small { .top-edit-btn {
font-size: 12px; height: 34px;
color: var(--ctms-text-secondary); 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> </style>
@@ -27,7 +27,7 @@
<el-form v-else ref="formRef" :model="form" label-position="top" class="startup-editor-form"> <el-form v-else ref="formRef" :model="form" label-position="top" class="startup-editor-form">
<div class="form-group"> <div class="form-group">
<div class="form-group-title"> <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 }} {{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}
</div> </div>
<el-row :gutter="16"> <el-row :gutter="16">
@@ -60,7 +60,7 @@
<div v-if="meetingId" class="form-group"> <div v-if="meetingId" class="form-group">
<div class="form-group-title"> <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 }} {{ TEXT.common.labels.attachments }}
</div> </div>
<AttachmentList <AttachmentList
+269 -66
View File
@@ -1,32 +1,78 @@
<template> <template>
<div class="page"> <div class="page">
<div class="unified-shell"> <StateError v-if="errorMessage" :description="errorMessage">
<section class="unified-section" v-loading="loading"> <template #action>
<div class="unified-section-header"> <el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div> </template>
<div class="ctms-section-actions"> </StateError>
<el-button v-if="canUpdateAuth" type="primary" :disabled="isInactiveSite" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<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>
</div> </div>
<el-descriptions :column="2" border> </div>
<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> <div v-if="detail.remark" class="remark-card">
<el-descriptions-item :label="TEXT.common.fields.trained">{{ detail.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item> <div class="remark-header">
<el-descriptions-item :label="TEXT.common.fields.trainedDate">{{ displayDate(detail.trained_date) }}</el-descriptions-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 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<el-descriptions-item :label="TEXT.common.fields.authorized">{{ detail.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item> <span>{{ TEXT.common.fields.remark }}</span>
<el-descriptions-item :label="TEXT.common.fields.authorizedDate">{{ displayDate(detail.authorized_date) }}</el-descriptions-item> </div>
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item> <p class="remark-text">{{ detail.remark }}</p>
</el-descriptions> </div>
</section> </template>
</div>
<TrainingEditorDrawer <TrainingEditorDrawer v-model="editorVisible" :record-id="recordId" :study-id="studyId" :sites="sites" @saved="handleEditorSaved" />
v-model="editorVisible"
:record-id="recordId"
:study-id="studyId"
:sites="sites"
@saved="handleEditorSaved"
/>
</div> </div>
</template> </template>
@@ -34,10 +80,13 @@
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getTrainingAuthorization } from "../../api/startup"; import { getTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue"; import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
@@ -46,86 +95,240 @@ const route = useRoute();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission(); const { can } = usePermission();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false); const editorVisible = ref(false);
const recordId = route.params.recordId as string; const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]); const sites = ref<any[]>([]);
const detail = reactive<any>({ const detail = reactive<any>({
name: "", name: "", role: "", site_name: "", trained: false, authorized: false, trained_date: "", authorized_date: "", remark: "",
role: "",
site_name: "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
}); });
const siteActiveMap = computed(() => { const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
sites.value.forEach((site) => { sites.value.forEach((site) => { if (site?.name) map[site.name] = !!site.is_active; });
if (site?.name) map[site.name] = !!site.is_active;
});
return map; return map;
}); });
const canUpdateAuth = computed(() => can("startup.auth.update")); const canUpdateAuth = computed(() => can("startup.auth.update"));
const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false); const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const isReadOnly = computed(() => !canUpdateAuth.value || isInactiveSite.value);
const loadSites = async () => { const loadSites = async () => {
if (!studyId) return; if (!studyId) return;
try { try {
const { data } = await fetchSites(studyId, { limit: 500 }); const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || []; sites.value = Array.isArray(data) ? data : data.items || [];
} catch { } catch { sites.value = []; }
sites.value = [];
}
}; };
const load = async () => { const load = async () => {
if (!studyId || !recordId) return; if (!studyId || !recordId) return;
loading.value = true; loading.value = true;
errorMessage.value = "";
try { try {
const { data } = await getTrainingAuthorization(studyId, recordId); const { data } = await getTrainingAuthorization(studyId, recordId);
Object.assign(detail, data); Object.assign(detail, data);
study.setViewContext({ study.setViewContext({ siteName: detail.site_name || TEXT.common.fallback, pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle });
siteName: detail.site_name || TEXT.common.fallback, } catch (e: any) { errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed; }
pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle, finally { loading.value = false; }
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
}; };
const goEdit = () => { const goEdit = () => {
if (!canUpdateAuth.value) { if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
ElMessage.warning("权限不足"); if (isInactiveSite.value) { ElMessage.warning("中心已停用"); return; }
return;
}
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
editorVisible.value = true; editorVisible.value = true;
}; };
const handleEditorSaved = () => { load(); };
const handleEditorSaved = () => { onMounted(async () => { await loadSites(); load(); });
load();
};
onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
.page { .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; display: flex;
flex-direction: column; 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> </style>
@@ -510,7 +510,6 @@ watch(
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
border-top: 1px solid #edf2f7;
} }
.batch-entry { .batch-entry {