项目里程碑初步优化

This commit is contained in:
Cheng Zhou
2026-02-27 09:06:06 +08:00
parent 8f3f717e48
commit fd7e3fc948
47 changed files with 2029 additions and 783 deletions
+210 -39
View File
@@ -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>