a17f4cc522
- 新增项目成员候选用户接口,避免 PM 读取全局用户列表 - 按项目角色和权限矩阵控制项目管理、成员、中心与审计入口 - 合并侧边栏管理后台入口,统一从项目管理进入成员等模块 - 加强成员管理安全约束,禁止 PM 修改自己、系统管理员或更高权限角色 - 修复多项目角色缓存串用问题,并补充前后端回归测试
632 lines
20 KiB
Vue
632 lines
20 KiB
Vue
<template>
|
||
<div class="page">
|
||
<div class="main-content-flat unified-shell">
|
||
<div class="audit-toolbar">
|
||
<el-form :inline="true" :model="filters" class="filter-form">
|
||
<div class="filter-item-form">
|
||
<el-select
|
||
v-model="filters.eventType"
|
||
clearable
|
||
:placeholder="TEXT.modules.adminAuditLogs.filterEvent"
|
||
@change="onServerFilterChange"
|
||
class="filter-select-comp"
|
||
>
|
||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
</div>
|
||
<div class="filter-item-form">
|
||
<el-select
|
||
v-model="filters.operatorId"
|
||
clearable
|
||
:placeholder="TEXT.modules.adminAuditLogs.filterOperator"
|
||
filterable
|
||
@change="onServerFilterChange"
|
||
class="filter-select-comp"
|
||
>
|
||
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
||
</el-select>
|
||
</div>
|
||
<div class="filter-item-form">
|
||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
|
||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
|
||
</el-select>
|
||
</div>
|
||
<div class="filter-item-form">
|
||
<el-date-picker
|
||
v-model="filters.range"
|
||
type="daterange"
|
||
unlink-panels
|
||
:start-placeholder="TEXT.modules.adminAuditLogs.rangeStart"
|
||
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
|
||
format="YYYY-MM-DD"
|
||
value-format="YYYY-MM-DD"
|
||
class="date-range-picker-comp"
|
||
@change="onLocalFilterChange"
|
||
/>
|
||
</div>
|
||
<div class="filter-spacer"></div>
|
||
<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>
|
||
<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">
|
||
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" stripe table-layout="fixed">
|
||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" align="center" show-overflow-tooltip>
|
||
<template #default="scope">
|
||
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" show-overflow-tooltip />
|
||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" show-overflow-tooltip>
|
||
<template #default="scope">
|
||
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" show-overflow-tooltip />
|
||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" show-overflow-tooltip>
|
||
<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>
|
||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" show-overflow-tooltip>
|
||
<template #default="scope">
|
||
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||
<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>
|
||
</el-table-column>
|
||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="110" align="center">
|
||
<template #default="scope">
|
||
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
|
||
{{ scope.row.resultLabel }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="pagination-wrap" v-if="total > 0">
|
||
<el-pagination
|
||
v-model:current-page="page"
|
||
v-model:page-size="pageSize"
|
||
:page-sizes="[5, 10, 20]"
|
||
:total="total"
|
||
layout="prev, pager, next, sizes, total"
|
||
small
|
||
@current-change="onPageChange"
|
||
@size-change="onPageSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-drawer
|
||
v-if="detailVisible"
|
||
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>
|
||
|
||
<script setup lang="ts">
|
||
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 { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
|
||
import { fetchProjectRolePermissions } from "../../api/projectPermissions";
|
||
import { fetchUsers } from "../../api/users";
|
||
import { listMembers } from "../../api/members";
|
||
import { auditDict, normalizeAuditEvent } from "../../audit";
|
||
import { useStudyStore } from "../../store/study";
|
||
import { useAuthStore } from "../../store/auth";
|
||
import { roleDict, getDictLabel } from "../../dictionaries";
|
||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||
import { displayDateTime } from "../../utils/display";
|
||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||
import { TEXT } from "../../locales";
|
||
|
||
const study = useStudyStore();
|
||
const auth = useAuthStore();
|
||
const router = useRouter();
|
||
|
||
const loading = ref(false);
|
||
const exportLoading = ref(false);
|
||
const logs = ref<any[]>([]);
|
||
const users = ref<any[]>([]);
|
||
const allLogs = ref<any[]>([]);
|
||
const permissionMatrix = ref<any | null>(null);
|
||
const page = ref(1);
|
||
const pageSize = ref(20);
|
||
const total = ref(0);
|
||
const detailVisible = ref(false);
|
||
const selectedLog = ref<any | null>(null);
|
||
const DIFF_PREVIEW_LIMIT = 2;
|
||
|
||
const filters = ref({
|
||
eventType: "",
|
||
operatorId: "",
|
||
result: "",
|
||
range: [],
|
||
});
|
||
|
||
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||
value,
|
||
label: cfg.label,
|
||
}));
|
||
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(() => isSystemAdmin(auth.user));
|
||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||
const canProjectExport = computed(() => {
|
||
if (isAdmin.value) return true;
|
||
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read;
|
||
});
|
||
const canAccessAuditLogs = computed(() => {
|
||
if (isAdmin.value) return true;
|
||
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read;
|
||
});
|
||
|
||
const ensureAccess = () => {
|
||
if (!canAccessAuditLogs.value) {
|
||
router.replace(study.currentStudy ? "/project/overview" : "/workbench");
|
||
}
|
||
};
|
||
|
||
const loadPermissionMatrix = async () => {
|
||
if (!study.currentStudy) return;
|
||
const { data } = await fetchProjectRolePermissions(study.currentStudy.id);
|
||
permissionMatrix.value = data;
|
||
};
|
||
|
||
const loadUsers = async () => {
|
||
try {
|
||
if (isAdmin.value) {
|
||
const { data } = await fetchUsers({ limit: 500 });
|
||
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,
|
||
full_name: m.user?.display_name || m.user?.full_name || m.user?.username || m.username,
|
||
}));
|
||
}
|
||
} catch {
|
||
users.value = [];
|
||
}
|
||
};
|
||
|
||
const loadLogs = async () => {
|
||
if (!study.currentStudy) return;
|
||
loading.value = true;
|
||
try {
|
||
if (!permissionMatrix.value) {
|
||
await loadPermissionMatrix();
|
||
}
|
||
const batchLimit = 500;
|
||
let skip = 0;
|
||
const allItems: any[] = [];
|
||
const params: Record<string, any> = {
|
||
skip,
|
||
limit: batchLimit,
|
||
action: filters.value.eventType || undefined,
|
||
operator_id: filters.value.operatorId || undefined,
|
||
};
|
||
while (true) {
|
||
params.skip = skip;
|
||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||
allItems.push(...items);
|
||
if (items.length < batchLimit) break;
|
||
skip += items.length;
|
||
}
|
||
enrichLogs(allItems);
|
||
refreshPagedLogs();
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const enrichLogs = (items: any[]) => {
|
||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||
acc[cur.id] = resolveUserDisplayName(cur);
|
||
return acc;
|
||
}, {});
|
||
allLogs.value = items
|
||
.map((log) => normalizeAuditEvent(log, userMap))
|
||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||
if (users.value.length === 0) {
|
||
const byActor = new Map<string, string>();
|
||
allLogs.value.forEach((log) => {
|
||
if (!byActor.has(log.actorId)) {
|
||
byActor.set(log.actorId, log.actorName || log.actorId);
|
||
}
|
||
});
|
||
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
|
||
}
|
||
};
|
||
|
||
const filterLogs = (items: any[]) =>
|
||
items.filter((log) => {
|
||
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
|
||
if (filters.value.range?.length === 2) {
|
||
const ts = new Date(log.timestamp);
|
||
const start = new Date(filters.value.range[0]);
|
||
const end = new Date(filters.value.range[1]);
|
||
if (ts < start || ts > end) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
const refreshPagedLogs = () => {
|
||
const filtered = filterLogs(allLogs.value);
|
||
total.value = filtered.length;
|
||
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value));
|
||
if (page.value > maxPage) page.value = maxPage;
|
||
const start = (page.value - 1) * pageSize.value;
|
||
logs.value = filtered.slice(start, start + pageSize.value);
|
||
};
|
||
|
||
const onServerFilterChange = () => {
|
||
page.value = 1;
|
||
loadLogs();
|
||
};
|
||
|
||
const onLocalFilterChange = () => {
|
||
page.value = 1;
|
||
refreshPagedLogs();
|
||
};
|
||
|
||
const onPageChange = (p: number) => {
|
||
page.value = p;
|
||
refreshPagedLogs();
|
||
};
|
||
|
||
const onPageSizeChange = (size: number) => {
|
||
pageSize.value = size;
|
||
page.value = 1;
|
||
refreshPagedLogs();
|
||
};
|
||
|
||
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;
|
||
try {
|
||
const params: Record<string, any> = {
|
||
skip: 0,
|
||
limit: 2000,
|
||
action: filters.value.eventType || undefined,
|
||
operator_id: filters.value.operatorId || undefined,
|
||
};
|
||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||
acc[cur.id] = resolveUserDisplayName(cur);
|
||
return acc;
|
||
}, {});
|
||
const filtered = items.filter((log: any) => {
|
||
if (filters.value.range?.length === 2) {
|
||
const ts = new Date(log.created_at);
|
||
const start = new Date(filters.value.range[0]);
|
||
const end = new Date(filters.value.range[1]);
|
||
if (ts < start || ts > end) return false;
|
||
}
|
||
return true;
|
||
});
|
||
return filtered.map((log: any) => normalizeAuditEvent(log, userMap));
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
|
||
return [];
|
||
} finally {
|
||
exportLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleExportCommand = (command: string) => {
|
||
confirmExport(command as "system" | "project");
|
||
};
|
||
|
||
const confirmExport = async (scope: "system" | "project") => {
|
||
const currentStudy = study.currentStudy;
|
||
if (!currentStudy) return;
|
||
const ok = await ElMessageBox.confirm(
|
||
TEXT.modules.adminAuditLogs.exportConfirm,
|
||
TEXT.modules.adminAuditLogs.exportConfirmTitle,
|
||
{ type: "warning", confirmButtonText: TEXT.common.actions.confirm, cancelButtonText: TEXT.common.actions.cancel }
|
||
).catch(() => null);
|
||
if (!ok) return;
|
||
const events = await fetchAllForExport();
|
||
if (!events.length) {
|
||
ElMessage.warning(TEXT.modules.adminAuditLogs.exportEmpty);
|
||
return;
|
||
}
|
||
const fileName =
|
||
scope === "system"
|
||
? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
|
||
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
||
exportAuditCsv(events, { fileName });
|
||
await createAuditEvent(currentStudy.id, {
|
||
action: scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT",
|
||
entity_type: "audit_log",
|
||
entity_id: null,
|
||
detail: JSON.stringify(
|
||
{
|
||
targetName: currentStudy.name,
|
||
scope,
|
||
exportedCount: events.length,
|
||
},
|
||
null,
|
||
0
|
||
),
|
||
}).catch(() => null);
|
||
};
|
||
|
||
onMounted(async () => {
|
||
if (!auth.user && auth.token) {
|
||
await auth.fetchMe().catch(() => {});
|
||
}
|
||
await loadPermissionMatrix().catch(() => {});
|
||
ensureAccess();
|
||
await loadUsers();
|
||
await loadLogs();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
.main-content-flat {
|
||
width: 100%;
|
||
background: transparent;
|
||
border: 0;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.main-content-flat :deep(.el-card__body) {
|
||
padding: 0;
|
||
}
|
||
|
||
.table-section {
|
||
padding: 0;
|
||
}
|
||
|
||
.audit-toolbar {
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--unified-shell-divider);
|
||
}
|
||
|
||
.filter-form {
|
||
display: flex;
|
||
width: 100%;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
.filter-item-form {
|
||
margin-bottom: 0 !important;
|
||
margin-right: 0 !important;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.filter-select-comp {
|
||
width: 132px;
|
||
}
|
||
|
||
.filter-select-result {
|
||
width: 96px;
|
||
}
|
||
|
||
.date-range-picker-comp {
|
||
width: 248px !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;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.diff-line {
|
||
margin-bottom: 2px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.diff-more-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.diff-more-text {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.pagination-wrap {
|
||
margin-top: 10px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding: 0 0 6px;
|
||
}
|
||
|
||
.audit-table :deep(.el-table__inner-wrapper::before) {
|
||
display: none;
|
||
}
|
||
|
||
.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>
|