项目里程碑初步优化
This commit is contained in:
@@ -1,13 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<div class="audit-toolbar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.entityType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEntityType" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in entityTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
@@ -19,7 +14,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 120px">
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" class="filter-select-result">
|
||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
|
||||
</el-select>
|
||||
@@ -38,14 +33,17 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<div class="ctms-action-group">
|
||||
<el-button v-if="isAdmin" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('system')">
|
||||
{{ TEXT.modules.adminAuditLogs.exportSystem }}
|
||||
<el-dropdown trigger="click" @command="handleExportCommand" class="audit-export-dropdown">
|
||||
<el-button plain class="audit-export-btn" :loading="exportLoading">
|
||||
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<el-button v-if="canProjectExport" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('project')">
|
||||
{{ TEXT.modules.adminAuditLogs.exportProject }}
|
||||
</el-button>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="isAdmin" command="system">{{ TEXT.modules.adminAuditLogs.exportSystem }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="unified-section table-section">
|
||||
@@ -56,7 +54,6 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
|
||||
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
|
||||
@@ -67,7 +64,7 @@
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.targetTypeLabel">
|
||||
<strong>{{ scope.row.targetTypeLabel }}</strong>
|
||||
<span class="text-gray-500" v-if="scope.row.targetName">({{ scope.row.targetName }})</span>
|
||||
<span class="text-gray-500" v-if="scope.row.targetName">:{{ scope.row.targetName }}</span>
|
||||
</span>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
@@ -75,7 +72,11 @@
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||||
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
|
||||
<div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ line }}</div>
|
||||
<div class="diff-more-row" v-if="scope.row.diffText.length > DIFF_PREVIEW_LIMIT">
|
||||
<span class="diff-more-text">其余 {{ scope.row.diffText.length - DIFF_PREVIEW_LIMIT }} 项</span>
|
||||
<el-button link type="primary" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
|
||||
</template>
|
||||
@@ -98,6 +99,47 @@
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
direction="rtl"
|
||||
size="560px"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
class="audit-detail-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-title">审计日志详情</div>
|
||||
<el-button @click="detailVisible = false">{{ TEXT.common.actions.close }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="selectedLog">
|
||||
<div class="detail-meta-grid">
|
||||
<div class="meta-item"><span class="meta-label">时间</span><span>{{ displayDateTime(selectedLog.timestamp) }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">操作人</span><span>{{ selectedLog.actorName || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">事件</span><span>{{ selectedLog.eventLabel || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">结果</span><span>{{ selectedLog.resultLabel || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item meta-item-full">
|
||||
<span class="meta-label">对象</span>
|
||||
<span>{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">:{{ selectedLog.targetName }}</span></span>
|
||||
</div>
|
||||
<div class="meta-item meta-item-full"><span class="meta-label">动作</span><span>{{ selectedLog.actionText || TEXT.common.fallback }}</span></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">变更明细</div>
|
||||
<el-scrollbar max-height="58vh">
|
||||
<div v-if="selectedLog.diffText?.length" class="detail-lines">
|
||||
<div v-for="(line, idx) in selectedLog.diffText" :key="idx" class="detail-line-row">
|
||||
<span class="detail-line-index">{{ Number(idx) + 1 }}.</span>
|
||||
<span class="detail-line-text">{{ line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -105,6 +147,7 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ArrowDown } from "@element-plus/icons-vue";
|
||||
import { fetchAuditLogs } from "../../api/auditLogs";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { listMembers } from "../../api/members";
|
||||
@@ -146,9 +189,11 @@ const users = ref<any[]>([]);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const total = ref(0);
|
||||
const detailVisible = ref(false);
|
||||
const selectedLog = ref<any | null>(null);
|
||||
const DIFF_PREVIEW_LIMIT = 2;
|
||||
|
||||
const filters = ref({
|
||||
entityType: "",
|
||||
eventType: "",
|
||||
operatorId: "",
|
||||
result: "",
|
||||
@@ -159,16 +204,11 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||||
value,
|
||||
label: cfg.label,
|
||||
}));
|
||||
const entityTypeOptions = [
|
||||
{ value: "study_setup_config", label: "立项配置" },
|
||||
{ value: "study", label: "项目" },
|
||||
{ value: "subject", label: "参与者" },
|
||||
{ value: "site", label: "中心" },
|
||||
{ value: "finance", label: "费用" },
|
||||
{ value: "drug_shipment", label: "药品流向" },
|
||||
{ value: "audit_log", label: "审计日志" },
|
||||
];
|
||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: u.username, value: u.id })));
|
||||
const resolveUserDisplayName = (u: any): string => {
|
||||
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const canProjectExport = computed(() => !!study.currentStudy && auth.user?.role === "ADMIN");
|
||||
|
||||
@@ -183,14 +223,14 @@ const loadUsers = async () => {
|
||||
try {
|
||||
if (auth.user?.role === "ADMIN") {
|
||||
const { data } = await fetchUsers({ limit: 500 });
|
||||
users.value = (data as any).items || data || [];
|
||||
users.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||
} else if (study.currentStudy) {
|
||||
// 非管理员,尝试加载项目成员作为备选
|
||||
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
users.value = items.map((m: any) => ({
|
||||
id: m.user_id,
|
||||
username: m.user?.display_name || m.user?.full_name || m.user?.username || m.username,
|
||||
full_name: m.user?.display_name || m.user?.full_name || m.user?.username || m.username,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
@@ -205,7 +245,6 @@ const loadLogs = async () => {
|
||||
const params: Record<string, any> = {
|
||||
skip: (page.value - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
entity_type: filters.value.entityType || undefined,
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
@@ -237,10 +276,12 @@ const loadLocalLogs = () => {
|
||||
|
||||
const enrichLogs = () => {
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = cur.username;
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
return acc;
|
||||
}, {});
|
||||
const filtered = rawLogs.value.filter((log) => {
|
||||
if (filters.value.eventType && String(log.action || "") !== filters.value.eventType) return false;
|
||||
if (filters.value.operatorId && String(log.operator_id || "") !== filters.value.operatorId) return false;
|
||||
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
|
||||
if (filters.value.range?.length === 2) {
|
||||
const ts = new Date(log.created_at);
|
||||
@@ -252,7 +293,8 @@ const enrichLogs = () => {
|
||||
});
|
||||
logs.value = filtered
|
||||
.map((log) => normalizeAuditEvent(log, userMap))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, pageSize);
|
||||
if (users.value.length === 0) {
|
||||
const byActor = new Map<string, string>();
|
||||
logs.value.forEach((log) => {
|
||||
@@ -260,7 +302,7 @@ const enrichLogs = () => {
|
||||
byActor.set(log.actorId, log.actorName || log.actorId);
|
||||
}
|
||||
});
|
||||
users.value = Array.from(byActor.entries()).map(([id, username]) => ({ id, username }));
|
||||
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,6 +311,16 @@ const onPageChange = (p: number) => {
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
const getDiffPreview = (log: any) => {
|
||||
const lines = Array.isArray(log?.diffText) ? log.diffText : [];
|
||||
return lines.slice(0, DIFF_PREVIEW_LIMIT);
|
||||
};
|
||||
|
||||
const openDetail = (log: any) => {
|
||||
selectedLog.value = log;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const fetchAllForExport = async () => {
|
||||
if (!study.currentStudy) return [];
|
||||
exportLoading.value = true;
|
||||
@@ -276,7 +328,6 @@ const fetchAllForExport = async () => {
|
||||
const params: Record<string, any> = {
|
||||
skip: 0,
|
||||
limit: 2000,
|
||||
entity_type: filters.value.entityType || undefined,
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
@@ -285,7 +336,7 @@ const fetchAllForExport = async () => {
|
||||
const local = loadLocalLogs();
|
||||
const merged = dedupeLogs([...items, ...local]);
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = cur.username;
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
return acc;
|
||||
}, {});
|
||||
const filtered = merged.filter((log) => {
|
||||
@@ -306,6 +357,10 @@ const fetchAllForExport = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCommand = (command: string) => {
|
||||
confirmExport(command as "system" | "project");
|
||||
};
|
||||
|
||||
const confirmExport = async (scope: "system" | "project") => {
|
||||
const ok = await ElMessageBox.confirm(
|
||||
TEXT.modules.adminAuditLogs.exportConfirm,
|
||||
@@ -351,30 +406,60 @@ onMounted(async () => {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.audit-toolbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 150px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.filter-select-result {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.date-range-picker-comp {
|
||||
width: 280px !important;
|
||||
width: 260px !important;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.audit-export-dropdown {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-export-btn {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
color: #3b6cb5 !important;
|
||||
border-color: #b3cce6 !important;
|
||||
background: #f0f6ff !important;
|
||||
}
|
||||
|
||||
.audit-export-btn:hover,
|
||||
.audit-export-btn:focus {
|
||||
color: #2a5699 !important;
|
||||
border-color: #8cb4de !important;
|
||||
background: #e2edfa !important;
|
||||
}
|
||||
|
||||
.diff-container {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
@@ -386,9 +471,95 @@ onMounted(async () => {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diff-more-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-more-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #11264d;
|
||||
}
|
||||
|
||||
.detail-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e6edf7;
|
||||
border-radius: 8px;
|
||||
background: #fbfdff;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.meta-item-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e3a5f;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.detail-line-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-line-index {
|
||||
color: #94a3b8;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.detail-line-text {
|
||||
color: #475569;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
>
|
||||
回滚版本
|
||||
</el-button>
|
||||
<el-button plain @click="triggerImportConfig">导入Excel</el-button>
|
||||
<el-dropdown trigger="click" class="setup-secondary-menu" @command="handleSecondaryAction">
|
||||
<el-button plain class="secondary-menu-btn">
|
||||
更多
|
||||
@@ -73,17 +72,9 @@
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
中心管理
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="export" :disabled="!canExportSetup">导出Excel</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<input
|
||||
ref="importInputRef"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
||||
class="hidden-file-input"
|
||||
@change="handleImportFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -339,14 +330,14 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划中心数">
|
||||
<el-input-number v-model="form.planned_site_count" :min="0" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.planned_site_count" :min="0" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划入组数">
|
||||
<el-input-number v-model="form.planned_enrollment_count" :min="0" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.planned_enrollment_count" :min="0" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -422,22 +413,22 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitTotal">
|
||||
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
|
||||
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
|
||||
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
|
||||
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" class="w-full" />
|
||||
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -475,14 +466,30 @@
|
||||
<span class="milestone-cell-text">{{ scope.row.name || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划日期" width="180">
|
||||
<el-table-column label="计划时间" width="240">
|
||||
<template #default="scope">
|
||||
<span class="milestone-cell-text">{{ scope.row.planDate || "-" }}</span>
|
||||
<div class="milestone-plan-cell">
|
||||
<div class="milestone-plan-line">
|
||||
<span class="milestone-plan-dot" />
|
||||
<span class="milestone-plan-label">开始:</span>
|
||||
<span>{{ resolveProjectMilestoneStart(scope.row) || "-" }}</span>
|
||||
</div>
|
||||
<div class="milestone-plan-line">
|
||||
<span class="milestone-plan-dot" />
|
||||
<span class="milestone-plan-label">结束:</span>
|
||||
<span>{{ resolveProjectMilestoneEnd(scope.row) || "-" }}</span>
|
||||
</div>
|
||||
<div class="milestone-plan-line">
|
||||
<span class="milestone-plan-dot" />
|
||||
<span class="milestone-plan-label">耗时:</span>
|
||||
<span>{{ formatProjectMilestoneDuration(scope.row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="milestone-cell-text">{{ scope.row.owner || "-" }}</span>
|
||||
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="150">
|
||||
@@ -557,6 +564,7 @@
|
||||
v-model="currentSetupDraft.enrollmentPlan.totalTarget"
|
||||
:min="0"
|
||||
:disabled="!canEditSetup"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="enrollment-inline-value">{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}</span>
|
||||
@@ -575,9 +583,12 @@
|
||||
</div>
|
||||
<div class="enrollment-summary-line">
|
||||
<span class="summary-title">中心计划情况:</span>
|
||||
<span>计划入组数 <b>{{ toDisplayNumber(selectedSitePlanTarget) }}</b> 人</span>
|
||||
<span>已填写计划数 <b>{{ toDisplayNumber(selectedSiteCyclePlannedCount) }}</b> 人</span>
|
||||
<span>待分配 {{ selectedSiteCyclePendingCount }} 人</span>
|
||||
<span>已配置中心 <b>{{ centerConfiguredSiteCount }}</b>/<b>{{ centerActiveSiteCount }}</b> 个</span>
|
||||
<span>中心计划总数 <b>{{ toDisplayNumber(centerPlannedCount) }}</b> 人</span>
|
||||
<span>已填写计划数 <b>{{ toDisplayNumber(centerCyclePlannedCount) }}</b> 人</span>
|
||||
<span v-if="centerCycleOverflowCount > 0" class="summary-danger">已超出 {{ centerCycleOverflowCount }} 人</span>
|
||||
<span v-else-if="centerCyclePendingCount > 0">待分配 {{ centerCyclePendingCount }} 人</span>
|
||||
<span v-else class="summary-success">已完成分配</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -668,7 +679,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="milestone-cell-text">{{ scope.row.owner || "-" }}</span>
|
||||
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="150">
|
||||
@@ -761,6 +772,7 @@
|
||||
v-model="selectedSiteEnrollmentPlan.target"
|
||||
:min="0"
|
||||
:disabled="!canEditSetup"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="enrollment-inline-value">{{ toDisplayNumber(selectedSiteEnrollmentPlan?.target) }}</span>
|
||||
@@ -778,6 +790,7 @@
|
||||
</div>
|
||||
<div class="enrollment-summary-line">
|
||||
<span class="summary-title">中心计划情况:</span>
|
||||
<span class="summary-site-name">{{ selectedSitePlanName }}</span>
|
||||
<span>计划入组数 <b>{{ toDisplayNumber(selectedSitePlanTarget) }}</b> 人</span>
|
||||
<span>已填写计划数 <b>{{ toDisplayNumber(selectedSiteCyclePlannedCount) }}</b> 人</span>
|
||||
<span>待分配 {{ selectedSiteCyclePendingCount }} 人</span>
|
||||
@@ -961,7 +974,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="publishConfirmVisible" title="发布确认" width="920px" top="8vh" class="setup-publish-dialog">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="publishConfirmVisible" title="发布确认" width="920px" top="8vh" class="setup-publish-dialog">
|
||||
<div class="conflict-summary">
|
||||
<div>发布目标:立项配置草稿</div>
|
||||
<div>当前发布版本:{{ setupPublishedVersionText }}</div>
|
||||
@@ -990,9 +1003,14 @@
|
||||
<el-tag v-for="line in publishDiffLines" :key="line" effect="plain" type="warning">{{ line }}</el-tag>
|
||||
</div>
|
||||
<div v-if="publishFieldDiffRows.length" class="conflict-readable-diff">
|
||||
<div class="publish-diff-overview">
|
||||
<el-tag v-for="item in publishDiffModuleSummary" :key="item.module" type="info" effect="plain">
|
||||
{{ item.module }}:{{ item.count }} 项变更
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="publishFieldDiffRows" size="small" border max-height="320">
|
||||
<el-table-column prop="moduleLabel" label="模块" width="140" />
|
||||
<el-table-column prop="path" label="字段路径" min-width="260" />
|
||||
<el-table-column prop="path" label="变更项" min-width="300" />
|
||||
<el-table-column prop="changeType" label="变更类型" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.changeType === '新增' ? 'success' : scope.row.changeType === '删除' ? 'danger' : 'warning'" effect="plain">
|
||||
@@ -1000,8 +1018,8 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="localValue" label="草稿值" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="serverValue" label="已发布值" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="serverValue" label="上一版本值" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="localValue" label="预发布版本值" min-width="220" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -1009,7 +1027,7 @@
|
||||
<el-button type="primary" :loading="publishConfirmLoading" @click="confirmPublishConfig">确认发布</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="rollbackDialogVisible" title="选择回滚版本" width="760px" top="10vh">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="rollbackDialogVisible" title="选择回滚版本" width="760px" top="10vh">
|
||||
<el-table :data="setupVersionHistory" height="360" v-loading="rollbackDialogLoading">
|
||||
<el-table-column prop="version" label="发布版本" width="120">
|
||||
<template #default="scope">v{{ scope.row.display_version || scope.row.version }}</template>
|
||||
@@ -1053,17 +1071,42 @@
|
||||
<el-form-item label="里程碑">
|
||||
<el-input v-model="projectMilestoneEditorForm.name" placeholder="例如:首例入组" />
|
||||
</el-form-item>
|
||||
<el-form-item label="计划日期">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker
|
||||
v-model="projectMilestoneEditorForm.planDate"
|
||||
v-model="projectMilestoneEditorForm.startDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
placeholder="选择日期"
|
||||
placeholder="选择开始日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker
|
||||
v-model="projectMilestoneEditorForm.endDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
placeholder="选择结束日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="耗时">
|
||||
<span>{{ formatProjectMilestoneDuration(projectMilestoneEditorForm) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="projectMilestoneEditorForm.owner" placeholder="负责人" />
|
||||
<el-select
|
||||
v-model="projectMilestoneEditorForm.owner"
|
||||
filterable
|
||||
clearable
|
||||
class="w-full"
|
||||
placeholder="从项目成员中选择负责人"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in projectMilestoneOwnerOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="projectMilestoneEditorForm.status" class="w-full" placeholder="状态">
|
||||
@@ -1112,7 +1155,20 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="siteMilestoneEditorForm.owner" placeholder="负责人" />
|
||||
<el-select
|
||||
v-model="siteMilestoneEditorForm.owner"
|
||||
filterable
|
||||
clearable
|
||||
class="w-full"
|
||||
placeholder="从项目成员中选择负责人"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in siteMilestoneOwnerOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="siteMilestoneEditorForm.status" class="w-full" placeholder="状态">
|
||||
@@ -1160,7 +1216,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划例数">
|
||||
<el-input-number v-model="siteEnrollmentEditorForm.target" :min="0" class="w-full" />
|
||||
<el-input-number v-model="siteEnrollmentEditorForm.target" :min="0" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启动日期">
|
||||
<el-date-picker v-model="siteEnrollmentEditorForm.startDate" type="date" value-format="YYYY-MM-DD" class="w-full" />
|
||||
@@ -1396,13 +1452,12 @@ const {
|
||||
listVersions: fetchSetupVersions,
|
||||
rollbackConfig,
|
||||
deleteVersion: deleteSetupVersion,
|
||||
exportExcel: exportSetupExcel,
|
||||
importExcel: importSetupExcel,
|
||||
} = useSetupConfig();
|
||||
|
||||
const project = ref<Study | null>(null);
|
||||
const siteOptions = ref<Site[]>([]);
|
||||
const memberDisplayMap = ref<Record<string, string>>({});
|
||||
const projectMemberOptions = ref<Array<{ value: string; label: string }>>([]);
|
||||
const activeStep = ref(0);
|
||||
const infoTab = ref<"basic" | "summary">("basic");
|
||||
const step1EditSection = ref<Step1EditSection>("all");
|
||||
@@ -1418,7 +1473,6 @@ const formRef = ref<FormInstance>();
|
||||
const draftReady = ref(false);
|
||||
const suppressDraftWatch = ref(false);
|
||||
const autoSaveTimer = ref<number | undefined>(undefined);
|
||||
const importInputRef = ref<HTMLInputElement | null>(null);
|
||||
const setupDirtySinceLastPersist = ref(false);
|
||||
const projectDirtySinceLastPersist = ref(false);
|
||||
const suppressEnrollmentFieldSync = ref(false);
|
||||
@@ -1480,6 +1534,9 @@ const projectMilestoneEditorForm = reactive<ProjectMilestoneDraft>({
|
||||
id: "",
|
||||
name: "",
|
||||
planDate: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
durationDays: 1,
|
||||
owner: "",
|
||||
remark: "",
|
||||
status: "未开始",
|
||||
@@ -1628,7 +1685,6 @@ const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String
|
||||
const publishedProjectSignatureKey = computed(() => `ctms_setup_published_project_sig_${String(route.params.projectId || "")}`);
|
||||
const setupRole = computed(() => String(project.value?.role_in_study || authStore.user?.role || "").toUpperCase());
|
||||
const canManageSetup = computed(() => ["ADMIN", "PM"].includes(setupRole.value));
|
||||
const canExportSetup = computed(() => ["ADMIN", "PM", "CRA"].includes(setupRole.value));
|
||||
const isPublishedView = computed(() => setupViewMode.value === "published");
|
||||
const currentSetupDraft = computed<SetupConfigDraft>(() => {
|
||||
return setupDraft;
|
||||
@@ -1863,6 +1919,50 @@ const normalizePlanDate = (value: string | null | undefined): string => {
|
||||
if (!value) return "";
|
||||
return String(value);
|
||||
};
|
||||
const parseDateOnlyValue = (value: string | null | undefined): Date | null => {
|
||||
const normalized = normalizePlanDate(value);
|
||||
if (!normalized) return null;
|
||||
const d = new Date(`${normalized}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
};
|
||||
const calculateDurationDays = (startDate: string | null | undefined, endDate: string | null | undefined): number => {
|
||||
const start = parseDateOnlyValue(startDate);
|
||||
const end = parseDateOnlyValue(endDate);
|
||||
if (!start || !end) return 0;
|
||||
if (end < start) return 0;
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
return Math.floor(diffMs / 86400000) + 1;
|
||||
};
|
||||
const resolveProjectMilestoneStart = (row: Partial<ProjectMilestoneDraft> | null | undefined): string =>
|
||||
normalizePlanDate(row?.startDate || row?.planDate || "");
|
||||
const resolveProjectMilestoneEnd = (row: Partial<ProjectMilestoneDraft> | null | undefined): string =>
|
||||
normalizePlanDate(row?.endDate || row?.planDate || "");
|
||||
const resolveProjectMilestoneDurationDays = (row: Partial<ProjectMilestoneDraft> | null | undefined): number => {
|
||||
const rawDays = Number((row as any)?.durationDays);
|
||||
if (Number.isFinite(rawDays) && rawDays > 0) return Math.floor(rawDays);
|
||||
const startDate = resolveProjectMilestoneStart(row);
|
||||
const endDate = resolveProjectMilestoneEnd(row);
|
||||
if (!startDate && !endDate) return 0;
|
||||
return calculateDurationDays(startDate, endDate) || 1;
|
||||
};
|
||||
const formatProjectMilestoneDuration = (row: Partial<ProjectMilestoneDraft> | null | undefined): string => {
|
||||
const days = resolveProjectMilestoneDurationDays(row);
|
||||
return days > 0 ? `${days}天` : "-";
|
||||
};
|
||||
const normalizeProjectMilestoneRows = (rows: ProjectMilestoneDraft[]): ProjectMilestoneDraft[] =>
|
||||
rows.map((row) => {
|
||||
const startDate = resolveProjectMilestoneStart(row);
|
||||
const endDate = resolveProjectMilestoneEnd(row);
|
||||
const durationDays = resolveProjectMilestoneDurationDays({ ...row, startDate, endDate });
|
||||
return {
|
||||
...row,
|
||||
planDate: startDate,
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays: durationDays > 0 ? durationDays : 1,
|
||||
};
|
||||
});
|
||||
const getProjectPlanWindow = (): { start: string; end: string } => ({
|
||||
start: normalizePlanDate(form.value.plan_start_date || project.value?.plan_start_date || ""),
|
||||
end: normalizePlanDate(form.value.plan_end_date || project.value?.plan_end_date || ""),
|
||||
@@ -2200,6 +2300,11 @@ const centerEnrollmentTargetMap = computed(() => {
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const centerActiveSiteCount = computed(() => siteOptions.value.filter((site) => site.is_active !== false).length);
|
||||
const centerConfiguredSiteCount = computed(() => {
|
||||
const plannedSiteIds = centerEnrollmentTargetMap.value;
|
||||
return siteOptions.value.filter((site) => site.is_active !== false && plannedSiteIds.has(String(site.id || "").trim())).length;
|
||||
});
|
||||
const centerPlannedCount = computed(() => {
|
||||
return Array.from(centerEnrollmentTargetMap.value.values()).reduce((sum, target) => sum + target, 0);
|
||||
});
|
||||
@@ -2234,6 +2339,13 @@ const sumSiteEnrollmentTargetForCurrentCycle = (targets: Record<EnrollmentCycle,
|
||||
});
|
||||
return sum;
|
||||
};
|
||||
const centerCyclePlannedCount = computed(() => {
|
||||
return setupDraft.siteEnrollmentPlans.reduce((sum, row) => {
|
||||
return sum + sumSiteEnrollmentTargetForCurrentCycle(getSiteEnrollmentTargetsByCycle(row));
|
||||
}, 0);
|
||||
});
|
||||
const centerCyclePendingCount = computed(() => Math.max(centerPlannedCount.value - centerCyclePlannedCount.value, 0));
|
||||
const centerCycleOverflowCount = computed(() => Math.max(centerCyclePlannedCount.value - centerPlannedCount.value, 0));
|
||||
const getSiteEnrollmentPeriodTarget = (row: SiteEnrollmentPlanDraft, key: string | null): number => {
|
||||
if (!key) return 0;
|
||||
const targets = getSiteEnrollmentTargetsByCycle(row);
|
||||
@@ -2387,7 +2499,11 @@ const isStepCompleted = (stepIndex: number): boolean => {
|
||||
if (stepIndex === 1) {
|
||||
return (
|
||||
setupDraft.projectMilestones.length > 0 &&
|
||||
setupDraft.projectMilestones.every((item) => (item.name || "").trim().length > 0 && (item.planDate || "").length > 0)
|
||||
setupDraft.projectMilestones.every((item) => {
|
||||
const startDate = resolveProjectMilestoneStart(item);
|
||||
const endDate = resolveProjectMilestoneEnd(item);
|
||||
return (item.name || "").trim().length > 0 && !!startDate && !!endDate && startDate <= endDate;
|
||||
})
|
||||
);
|
||||
}
|
||||
if (stepIndex === 2) {
|
||||
@@ -2475,6 +2591,28 @@ const normalizeOwnerDisplay = (value: string | null | undefined): string => {
|
||||
.map((item) => memberDisplayMap.value[item] || item)
|
||||
.join("、") || "-";
|
||||
};
|
||||
const projectMilestoneOwnerOptions = computed(() => {
|
||||
const options = [...projectMemberOptions.value];
|
||||
const currentOwner = String(projectMilestoneEditorForm.owner || "").trim();
|
||||
if (currentOwner && !options.some((option) => option.value === currentOwner)) {
|
||||
options.unshift({
|
||||
value: currentOwner,
|
||||
label: normalizeOwnerDisplay(currentOwner),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const siteMilestoneOwnerOptions = computed(() => {
|
||||
const options = [...projectMemberOptions.value];
|
||||
const currentOwner = String(siteMilestoneEditorForm.owner || "").trim();
|
||||
if (currentOwner && !options.some((option) => option.value === currentOwner)) {
|
||||
options.unshift({
|
||||
value: currentOwner,
|
||||
label: normalizeOwnerDisplay(currentOwner),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const siteEnrollmentPlanMap = computed(() => {
|
||||
const map = new Map<string, SiteEnrollmentPlanDraft>();
|
||||
setupDraft.siteEnrollmentPlans.forEach((row) => {
|
||||
@@ -2533,6 +2671,15 @@ const selectedSiteEnrollmentPlan = computed(() => {
|
||||
if (index < 0) return null;
|
||||
return setupDraft.siteEnrollmentPlans[index] || null;
|
||||
});
|
||||
const selectedSitePlanName = computed(() => {
|
||||
const nameFromPlan = (selectedSiteEnrollmentPlan.value?.siteName || "").trim();
|
||||
if (nameFromPlan) return nameFromPlan;
|
||||
const selectedSiteId = normalizeSiteId(selectedSiteEnrollmentPlanSiteId.value);
|
||||
if (!selectedSiteId) return "当前中心";
|
||||
const matched = siteEnrollmentSelectableSites.value.find((site) => normalizeSiteId(site.id) === selectedSiteId);
|
||||
const name = (matched?.name || "").trim();
|
||||
return name || "当前中心";
|
||||
});
|
||||
const selectedSitePlanTarget = computed(() => normalizeEnrollmentTarget(selectedSiteEnrollmentPlan.value?.target));
|
||||
const selectedSiteCyclePlannedCount = computed(() => {
|
||||
const row = selectedSiteEnrollmentPlan.value;
|
||||
@@ -2630,7 +2777,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
|
||||
|
||||
const applySetupDraft = (draft: SetupConfigDraft) => {
|
||||
suppressDraftWatch.value = true;
|
||||
setupDraft.projectMilestones = clone(draft.projectMilestones);
|
||||
setupDraft.projectMilestones = normalizeProjectMilestoneRows(clone(draft.projectMilestones));
|
||||
setupDraft.enrollmentPlan = clone(draft.enrollmentPlan);
|
||||
setupDraft.siteMilestones = clone(draft.siteMilestones);
|
||||
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
|
||||
@@ -2830,6 +2977,13 @@ const publishDiffLines = computed(() => {
|
||||
return lines;
|
||||
});
|
||||
const publishFieldDiffRows = computed(() => [...buildProjectDiffRows(), ...buildReadableDiffRows(setupDraft, publishCompareBase.value)]);
|
||||
const publishDiffModuleSummary = computed(() => {
|
||||
const grouped = new Map<string, number>();
|
||||
publishFieldDiffRows.value.forEach((row) => {
|
||||
grouped.set(row.moduleLabel, (grouped.get(row.moduleLabel) || 0) + 1);
|
||||
});
|
||||
return Array.from(grouped.entries()).map(([module, count]) => ({ module, count }));
|
||||
});
|
||||
|
||||
const loadPublishedProjectSignatureFromLocal = () => {
|
||||
try {
|
||||
@@ -2906,8 +3060,19 @@ const buildProjectDiffRows = (): DiffRow[] => {
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
const normalizeDiffRowForBusiness = (row: DiffRow): DiffRow => {
|
||||
if (row.path.includes("负责人")) {
|
||||
return {
|
||||
...row,
|
||||
localValue: row.localValue === "未填写" ? row.localValue : normalizeOwnerDisplay(row.localValue),
|
||||
serverValue: row.serverValue === "未填写" ? row.serverValue : normalizeOwnerDisplay(row.serverValue),
|
||||
};
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
const buildReadableDiffRows = (localDraft: SetupConfigDraft | null, serverDraft: SetupConfigDraft | null): DiffRow[] =>
|
||||
buildSetupReadableDiffRows(localDraft, serverDraft, setupModuleLabels);
|
||||
buildSetupReadableDiffRows(localDraft, serverDraft, setupModuleLabels).map(normalizeDiffRowForBusiness);
|
||||
|
||||
const applySetupResponseMeta = (data: StudySetupConfigResponse) => {
|
||||
setupRevision.value = typeof data.version === "number" ? data.version : setupRevision.value;
|
||||
@@ -3151,10 +3316,12 @@ const initializeSetupDraft = async () => {
|
||||
|
||||
const loadMemberDisplayMap = async (studyId: string) => {
|
||||
const nextMap: Record<string, string> = {};
|
||||
const memberOptionMap = new Map<string, string>();
|
||||
try {
|
||||
const { data } = await listMembers(studyId, { limit: 500 });
|
||||
const rows = Array.isArray(data) ? data : (data as any).items || [];
|
||||
rows.forEach((row: any) => {
|
||||
if (row?.is_active === false) return;
|
||||
const userId = String(row?.user_id || row?.userId || "").trim();
|
||||
const memberId = String(row?.id || "").trim();
|
||||
if (!userId) return;
|
||||
@@ -3167,8 +3334,13 @@ const loadMemberDisplayMap = async (studyId: string) => {
|
||||
row?.username ||
|
||||
row?.email ||
|
||||
userId;
|
||||
const roleInStudy = String(row?.role_in_study || row?.roleInStudy || "").trim();
|
||||
const optionLabel = roleInStudy ? `${displayName}(${roleInStudy})` : displayName;
|
||||
nextMap[userId] = displayName;
|
||||
if (memberId) nextMap[memberId] = displayName;
|
||||
if (!memberOptionMap.has(userId)) {
|
||||
memberOptionMap.set(userId, optionLabel);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Ignore member API errors and fallback to users API below.
|
||||
@@ -3185,6 +3357,9 @@ const loadMemberDisplayMap = async (studyId: string) => {
|
||||
// ignore
|
||||
}
|
||||
memberDisplayMap.value = nextMap;
|
||||
projectMemberOptions.value = Array.from(memberOptionMap.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
|
||||
};
|
||||
|
||||
const loadProject = async () => {
|
||||
@@ -3664,68 +3839,6 @@ const confirmPublishConfig = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportConfigNow = async () => {
|
||||
if (!project.value || !canExportSetup.value) return;
|
||||
try {
|
||||
const { data } = await exportSetupExcel(project.value.id);
|
||||
const filename = `setup-config-${project.value.code || project.value.id}.xlsx`;
|
||||
const blob = data instanceof Blob ? data : new Blob([data], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
ElMessage.success("已导出配置Excel");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getApiErrorMessage(e, "导出失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const triggerImportConfig = () => {
|
||||
if (!canManageSetup.value) return;
|
||||
importInputRef.value?.click();
|
||||
};
|
||||
|
||||
const handleImportFile = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file || !project.value || !canEditSetup.value || !canManageSetup.value) return;
|
||||
if (!file.name.toLowerCase().endsWith(".xlsx")) {
|
||||
ElMessage.warning("请上传Excel文件(.xlsx)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = new FormData();
|
||||
payload.append("file", file);
|
||||
if (setupRevision.value) {
|
||||
payload.append("expected_version", String(setupRevision.value));
|
||||
}
|
||||
const { data: imported } = await importSetupExcel(project.value.id, payload);
|
||||
applySetupResponseMeta(imported);
|
||||
clearSetupValidationErrors();
|
||||
applySetupDraft(imported.data);
|
||||
markSetupSynced();
|
||||
setupSaveMeta.value = {
|
||||
updatedAt: formatDisplayTime(imported.updated_at),
|
||||
savedBy: imported.saved_by_name || resolveSavedByDisplay(imported.saved_by),
|
||||
serverSynced: true,
|
||||
};
|
||||
ElMessage.success("Excel已导入并自动填充配置");
|
||||
} catch (e: any) {
|
||||
if (handleValidationFailure(e, "导入失败,请检查Excel内容")) return;
|
||||
if (e?.response?.status === 409) {
|
||||
ElMessage.warning("导入失败:配置已更新,正在刷新最新版本");
|
||||
await refreshSetupDraftFromServer();
|
||||
return;
|
||||
}
|
||||
ElMessage.error(getApiErrorMessage(e, "导入失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const enterProject = () => {
|
||||
if (!project.value) return;
|
||||
studyStore.setCurrentStudy({ ...project.value, role_in_study: project.value.role_in_study || "PM" } as Study);
|
||||
@@ -3752,9 +3865,6 @@ const handleSecondaryAction = (command: string) => {
|
||||
goSites();
|
||||
return;
|
||||
}
|
||||
if (command === "export") {
|
||||
void exportConfigNow();
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
@@ -3774,7 +3884,17 @@ const goStep = (index: number) => {
|
||||
|
||||
const addProjectMilestone = () => {
|
||||
if (!canMutateDraft()) return;
|
||||
setupDraft.projectMilestones.push({ id: makeId(), name: "", planDate: "", owner: "", remark: "", status: "未开始" });
|
||||
setupDraft.projectMilestones.push({
|
||||
id: makeId(),
|
||||
name: "",
|
||||
planDate: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
durationDays: 1,
|
||||
owner: "",
|
||||
remark: "",
|
||||
status: "未开始",
|
||||
});
|
||||
};
|
||||
const ensureStep2Editable = (): boolean => {
|
||||
if (!project.value) return false;
|
||||
@@ -3817,6 +3937,9 @@ const onProjectMilestoneTemplateSelect = async (templateKey: string) => {
|
||||
id: makeId(),
|
||||
name,
|
||||
planDate: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
durationDays: 1,
|
||||
owner: "",
|
||||
remark: "",
|
||||
status: "未开始",
|
||||
@@ -3831,7 +3954,10 @@ const openProjectMilestoneEditor = (index: number) => {
|
||||
openIndexedEditor(setupDraft.projectMilestones, index, projectMilestoneEditor, (row) => {
|
||||
projectMilestoneEditorForm.id = row.id || "";
|
||||
projectMilestoneEditorForm.name = row.name || "";
|
||||
projectMilestoneEditorForm.planDate = row.planDate || "";
|
||||
projectMilestoneEditorForm.startDate = resolveProjectMilestoneStart(row);
|
||||
projectMilestoneEditorForm.endDate = resolveProjectMilestoneEnd(row);
|
||||
projectMilestoneEditorForm.durationDays = resolveProjectMilestoneDurationDays(row) || 1;
|
||||
projectMilestoneEditorForm.planDate = resolveProjectMilestoneStart(row);
|
||||
projectMilestoneEditorForm.owner = row.owner || "";
|
||||
projectMilestoneEditorForm.remark = row.remark || "";
|
||||
projectMilestoneEditorForm.status = row.status || "未开始";
|
||||
@@ -3846,14 +3972,28 @@ const saveProjectMilestoneEditor = () => {
|
||||
ElMessage.warning("请填写里程碑名称");
|
||||
return;
|
||||
}
|
||||
if (!projectMilestoneEditorForm.planDate) {
|
||||
ElMessage.warning("请选择计划日期");
|
||||
const startDate = normalizePlanDate(projectMilestoneEditorForm.startDate || projectMilestoneEditorForm.planDate);
|
||||
const endDate = normalizePlanDate(projectMilestoneEditorForm.endDate || projectMilestoneEditorForm.planDate);
|
||||
if (!startDate) {
|
||||
ElMessage.warning("请选择开始日期");
|
||||
return;
|
||||
}
|
||||
if (!endDate) {
|
||||
ElMessage.warning("请选择结束日期");
|
||||
return;
|
||||
}
|
||||
if (startDate > endDate) {
|
||||
ElMessage.warning("结束日期不能早于开始日期");
|
||||
return;
|
||||
}
|
||||
const durationDays = calculateDurationDays(startDate, endDate);
|
||||
setupDraft.projectMilestones[index] = {
|
||||
id: projectMilestoneEditorForm.id || makeId(),
|
||||
name,
|
||||
planDate: projectMilestoneEditorForm.planDate,
|
||||
planDate: startDate,
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays: durationDays > 0 ? durationDays : 1,
|
||||
owner: (projectMilestoneEditorForm.owner || "").trim(),
|
||||
remark: (projectMilestoneEditorForm.remark || "").trim(),
|
||||
status: projectMilestoneEditorForm.status || "未开始",
|
||||
@@ -4468,7 +4608,14 @@ onBeforeUnmount(() => {
|
||||
.enrollment-inline-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5f7499;
|
||||
color: #4a6283;
|
||||
background: #f3f6fb;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e6edf7;
|
||||
}
|
||||
|
||||
.enrollment-inline-label.required::before {
|
||||
@@ -4478,6 +4625,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.enrollment-date-cell {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.enrollment-date-cell :deep(.el-date-editor) {
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
@@ -4488,12 +4639,18 @@ onBeforeUnmount(() => {
|
||||
.enrollment-inline-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #102a4d;
|
||||
color: #11264d;
|
||||
}
|
||||
|
||||
.enrollment-sep {
|
||||
color: #8da3c7;
|
||||
font-weight: 600;
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.enrollment-plan-summary {
|
||||
@@ -4515,6 +4672,11 @@ onBeforeUnmount(() => {
|
||||
color: #38537b;
|
||||
}
|
||||
|
||||
.summary-site-name {
|
||||
font-weight: 700;
|
||||
color: #11264d;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-weight: 700;
|
||||
color: #284a7b;
|
||||
@@ -4525,6 +4687,11 @@ onBeforeUnmount(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-success {
|
||||
color: #2f7a4d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.enrollment-plan-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4858,13 +5025,15 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.info-group {
|
||||
padding-top: 4px;
|
||||
padding: 16px 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef3fb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(22, 47, 86, 0.03);
|
||||
}
|
||||
|
||||
.info-group + .info-group {
|
||||
margin-top: 2px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eaf1fa;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.info-group-title {
|
||||
@@ -4919,30 +5088,45 @@ onBeforeUnmount(() => {
|
||||
color: #284a7a;
|
||||
}
|
||||
|
||||
.basic-edit-group {
|
||||
padding: 18px 20px 4px;
|
||||
background: #f8fbff;
|
||||
border: 1px solid #e6edf7;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.basic-edit-group + .basic-edit-group {
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eaf1fa;
|
||||
margin-top: 0;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #e6edf7;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
min-height: 28px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #7085ab;
|
||||
color: #5f7499;
|
||||
min-width: 112px;
|
||||
font-size: 14px;
|
||||
background: #f4f8ff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #0f2345;
|
||||
color: #11264d;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.setup-info-tabs {
|
||||
@@ -4959,8 +5143,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.setup-section :deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eef3fb;
|
||||
}
|
||||
|
||||
.setup-section :deep(.el-table th.el-table__cell) {
|
||||
@@ -4998,6 +5183,33 @@ onBeforeUnmount(() => {
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.milestone-plan-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.milestone-plan-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.milestone-plan-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #2f84c6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.milestone-plan-label {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.milestone-actions-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -5115,6 +5327,13 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.publish-diff-overview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.conflict-readable-diff :deep(.el-table th.el-table__cell) {
|
||||
background: #f7faff;
|
||||
color: #2f4672;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
|
||||
<el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules">
|
||||
<el-form-item :label="TEXT.modules.adminProjectMembers.user" prop="user_id">
|
||||
<el-select v-model="newMember.user_id" filterable :placeholder="TEXT.modules.adminProjectMembers.userPlaceholder">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<div class="tip">{{ TEXT.modules.adminSites.sitePrefix }}{{ site?.name }}</div>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item :label="TEXT.modules.adminSites.craSelect">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="site ? TEXT.modules.adminSites.editTitle : TEXT.modules.adminSites.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="site ? TEXT.modules.adminSites.editTitle : TEXT.modules.adminSites.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.siteName" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.siteName" />
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="unified-section site-table-section">
|
||||
<el-table :data="displaySites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="220">
|
||||
<el-table :data="displaySites" v-loading="loading" stripe :row-class-name="siteRowClass" style="width: 100%">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="220">
|
||||
<template #default="scope">
|
||||
<div class="site-name-cell">
|
||||
<span>{{ scope.row.name }}</span>
|
||||
@@ -26,7 +26,7 @@
|
||||
{{ phoneText(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contact" :label="TEXT.common.fields.contact" width="160">
|
||||
<el-table-column prop="contact" :label="TEXT.common.fields.contact" min-width="160">
|
||||
<template #default="scope">
|
||||
{{ contactLabel(scope.row) }}
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.email" prop="email">
|
||||
<el-input v-model="form.email" :disabled="!!user" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email" />
|
||||
@@ -104,7 +104,7 @@ watch(
|
||||
form.email = props.user.email;
|
||||
form.full_name = props.user.full_name;
|
||||
form.department = props.user.department;
|
||||
form.is_active = props.user.is_active;
|
||||
form.is_active = props.user.is_active ?? true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="TEXT.modules.adminUsers.resetTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminUsers.resetTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
|
||||
Reference in New Issue
Block a user