You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

833 lines
27 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<ElDrawer
:model-value="visible"
title="任务日志"
direction="rtl"
size="620px"
append-to-body
@update:model-value="(v: boolean) => emit('update:visible', v)"
>
<template v-if="taskId">
<!-- 任务简要信息 -->
<div v-if="task" class="task-summary">
<ElDescriptions :column="1" size="small" border>
<ElDescriptionsItem label="任务名称">{{ task.task_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="任务编号">
#{{ task.id }}
</ElDescriptionsItem>
</ElDescriptions>
</div>
<!-- 日志 Tab 切换 -->
<ElTabs v-model="activeTab" class="log-tabs">
<!-- ============ 审查日志 ============ -->
<ElTabPane name="review">
<template #label>
<span class="tab-label">
审查日志
<span v-if="reviewWaiting" class="tab-status tab-status--running"></span>
<span v-else-if="reviewFinished" class="tab-status tab-status--done"></span>
</span>
</template>
<div ref="reviewScrollRef" class="tab-scroll" @scroll.passive="onReviewScroll">
<ElTimeline class="review-timeline">
<ElTimelineItem
v-for="(log, idx) in reviewLogs"
:key="idx"
:timestamp="log.time"
placement="top"
:type="reviewLevelType(log.level)"
:hollow="log.level === 'info'"
>
<div class="review-card" :class="`review-card--${log.level}`">
<div class="review-card-header">
<ElTag size="small" :type="phaseTagType(log.phase)" effect="light">
{{ log.phase }}
</ElTag>
<span class="review-card-title">{{ log.title }}</span>
</div>
<div v-if="log.detail" class="review-card-detail">
{{ log.detail }}
</div>
<!-- 子 timeline分段审查的调用链路 -->
<ElTimeline v-if="log.children && log.children.length" class="sub-timeline">
<ElTimelineItem
v-for="(child, cidx) in log.children"
:key="cidx"
:timestamp="child.time"
placement="top"
:type="reviewLevelType(child.level)"
:hollow="child.level === 'info'"
>
<div class="review-sub-card" :class="`review-sub-card--${child.level}`">
<div class="review-sub-header">
<span class="review-sub-step">{{ child.title }}</span>
</div>
<div v-if="child.detail" class="review-sub-detail">
{{ child.detail }}
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</div>
</ElTimelineItem>
</ElTimeline>
<!-- 无审查日志时的空状态 -->
<div v-if="reviewLogs.length === 0 && !reviewWaiting && !reviewFinished" class="empty-logs">
暂无审查日志
</div>
<!-- 等待新日志加载动画 -->
<div v-if="reviewWaiting" class="review-loading">
<ElIcon class="is-loading" :size="16"><Loading /></ElIcon>
<span class="loading-text">正在审查,等待日志…</span>
</div>
<!-- 审查完成 -->
<div v-if="reviewFinished" class="review-finished">
<ElIcon color="#67c23a" :size="16"><CircleCheck /></ElIcon>
<span>审查完成</span>
<ElButton link type="primary" :icon="RefreshRight" @click="restartReview">
重新审查
</ElButton>
</div>
<!-- 滚动锚点 -->
<div ref="scrollSentinelRef" />
</div>
</ElTabPane>
<!-- ============ 操作日志 ============ -->
<ElTabPane label="操作日志" name="operation">
<div class="tab-scroll">
<ElTimeline v-loading="loading" class="log-timeline">
<ElTimelineItem
v-for="log in logList"
:key="log.id"
:timestamp="formatDateTime(log.created_time)"
placement="top"
:type="logType(log.action_type)"
:hollow="log.action_type === 'start_review'"
>
<div class="log-card">
<div class="log-header">
<span class="log-action">{{ log.action_name }}</span>
<ElTag
size="small"
:type="logTagType(log.action_type)"
effect="light"
>
{{ logStatusText(log.action_type) }}
</ElTag>
</div>
<div class="log-operator">
<ElIcon><User /></ElIcon>
{{ log.operator_name || "系统" }}
</div>
<div v-if="log.description" class="log-desc">
{{ log.description }}
</div>
</div>
</ElTimelineItem>
<div v-if="!loading && logList.length === 0" class="empty-logs">
暂无操作记录
</div>
</ElTimeline>
</div>
</ElTabPane>
</ElTabs>
</template>
</ElDrawer>
</template>
<script setup lang="ts">
import { ref, watch, computed, nextTick, onUnmounted } from "vue";
import { useRoute } from "vue-router";
import { User, Loading, CircleCheck, RefreshRight } from "@element-plus/icons-vue";
import { ReviewTaskAPI } from "@/api/module_report/review";
import type { ReviewTaskItem, ReviewLogItem, ReviewLogActionType } from "@/api/module_report/review";
import type { ReportType } from "@/api/module_report/report";
defineOptions({ name: "LogDrawer" });
const props = defineProps<{
visible: boolean;
taskId?: number | null;
task?: ReviewTaskItem | null;
defaultTab?: "review" | "operation";
/** 是否在展示审查日志时自动启动审查流程(审查入口为 true日志入口为 false */
startReview?: boolean;
}>();
const route = useRoute();
/** 报告类型1 能源报告 / 2 光伏报告(由路由参数传入) */
const reportType = computed<ReportType>(() => {
const t = route.query.report_type;
return t === "2" ? 2 : 1;
});
const emit = defineEmits<{
"update:visible": [v: boolean];
}>();
// ========== Tab 状态 ==========
const activeTab = ref<"review" | "operation">(props.defaultTab || "operation");
watch(
() => props.visible,
(v) => {
if (v) {
activeTab.value = props.defaultTab || "operation";
isAutoScroll.value = true;
if (activeTab.value === "review" && props.startReview) {
restartReview();
}
scrollToBottomDelayed();
} else {
stopReviewTimer();
}
}
);
watch(
() => props.defaultTab,
(tab) => {
if (tab && props.visible) {
activeTab.value = tab;
}
}
);
watch(activeTab, (tab) => {
if (tab === "review" && props.visible) {
if (props.startReview) {
ensureReviewRunning();
}
isAutoScroll.value = true;
scrollToBottomDelayed();
}
});
// ========== 操作日志 ==========
const loading = ref(false);
const logList = ref<ReviewLogItem[]>([]);
watch(
() => [props.visible, props.taskId] as const,
([v, id]) => {
if (v && id) {
loadLogs(id);
}
},
{ immediate: true }
);
async function loadLogs(taskId: number) {
loading.value = true;
logList.value = [];
try {
const resp = await ReviewTaskAPI.listLogs({
report_review_id: taskId,
page_no: 1,
page_size: 100,
});
logList.value = resp?.data?.data?.items ?? [];
} catch (e) {
logList.value = [];
} finally {
loading.value = false;
}
}
function formatDateTime(value?: string): string {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function logType(t: ReviewLogActionType): "primary" | "success" | "warning" | "danger" {
switch (t) {
case "create_task": return "primary";
case "start_review": return "warning";
case "review_pass": return "success";
case "review_reject": return "danger";
default: return "primary";
}
}
function logTagType(t: ReviewLogActionType): "primary" | "success" | "warning" | "danger" | "info" {
switch (t) {
case "create_task": return "primary";
case "start_review": return "warning";
case "review_pass": return "success";
case "review_reject": return "danger";
default: return "info";
}
}
function logStatusText(t: ReviewLogActionType): string {
switch (t) {
case "create_task": return "已创建";
case "start_review": return "进行中";
case "review_pass": return "已完成";
case "review_reject": return "已驳回";
default: return t;
}
}
// ========== 审查日志Mock 演示数据) ==========
type ReviewLogLevel = "info" | "success" | "warning" | "error";
type ReviewStep = "vectorize" | "search" | "model_load" | "model_output" | "record";
interface MockReviewLog {
phase: string;
title: string;
detail?: string;
level: ReviewLogLevel;
/** 步骤类型,用于控制产出间隔 */
step?: ReviewStep;
/** 子步骤(分段审查时用子 timeline 展示调用链路) */
children?: MockReviewLog[];
}
/** 单个分段审查数据,用于构建完整调用链路子日志 */
interface SegData {
seg: string;
vectorize: string;
dify: string;
local?: string;
modelInput: string;
output: string;
outputLevel: ReviewLogLevel;
}
/** 将分段审查数据构建为一条父日志 + 5条子步骤日志 */
function buildSegLog(d: SegData): MockReviewLog {
const searchDetail = [
`Dify知识库${d.dify}`,
d.local ? `本地记忆库:${d.local}` : "本地记忆库无高相似分段",
].join("\n");
return {
phase: "分段审查",
title: d.seg,
level: "info",
children: [
{ phase: "分段审查", title: "向量化编码", detail: d.vectorize, level: "info", step: "vectorize" },
{ phase: "分段审查", title: "知识库检索", detail: searchDetail, level: "info", step: "search" },
{ phase: "分段审查", title: "大模型加载", detail: d.modelInput, level: "info", step: "model_load" },
{ phase: "分段审查", title: "推理输出", detail: d.output, level: d.outputLevel, step: "model_output" },
{ phase: "分段审查", title: "记录落池", detail: "", level: "success", step: "record" },
],
};
}
/** 9个分段审查数据严格依据审查日志7.23.md */
const segDataList: SegData[] = [
{ seg: "Seg01第一章 项目概况", vectorize: "分段文本向量化编码向量写入本地记忆RAG库生成唯一索引", dify: "检索标签项目概况公共机构能源托管召回JS/T 30120245.1条款医院可研编制范本", local: "召回附件Att01规划批复向量相似度0.88", modelInput: "Seg01原文 + 第一章审查规则地址精确到门牌号+ Dify规范条文 + Att01附件文本", output: "章节框架完整仅标注街道无精确门牌号违反业务规则院区面积描述合规可研地址与规划附件文字存在偏差", outputLevel: "warning" },
{ seg: "Seg02第二章 商业合作模式", vectorize: "分段向量化写入本地记忆RAG库", dify: "检索能源托管合作计费结算行业规范", local: "检索Seg01面积相关段落相似度0.77", modelInput: "大模型加载本章规则规范跨段面积数据执行校验", output: "15年托管周期符合标准缺少能源单价涨跌调整约定保底用能条款", outputLevel: "warning" },
{ seg: "Seg03第三章 项目建设方案", vectorize: "向量编码存入本地记忆库", dify: "高效冷站空气源热泵光伏设计国标", local: "召回Att01规划图纸分段相似度0.82", modelInput: "大模型校验负荷装机分期建设面积与规划文件一致性", output: "冷站EER热泵参数满足一级能效屋面光伏缺少荷载安全鉴定说明", outputLevel: "warning" },
{ seg: "Seg04第四章 项目运行和保障措施", vectorize: "分段向量化入库本地记忆库", dify: "检索运维维保相关企业标准", modelInput: "大模型校验人员配置维保周期维保成本测算逻辑", output: "人工维保测算逻辑合规未明确空调末端故障责任免责条款", outputLevel: "warning" },
{ seg: "Seg05第五章 投资估算", vectorize: "向量写入本地记忆RAG库", dify: "高效机房光伏造价企业标准", local: "召回Seg03建设面积分段相似度0.78", modelInput: "大模型交叉核对单位造价总投资与建设规模匹配度", output: "冷站造价与公司标准基本持平管路电缆主材材质明细缺失", outputLevel: "warning" },
{ seg: "Seg06第六章 经济可行性分析", vectorize: "分段向量化入库本地记忆库", dify: "检索能源项目经济评价规范", local: "召回Seg04运维Seg05投资分段", modelInput: "大模型联动多段校验收入成本IRR回收期测算", output: "全投资资本金指标计算逻辑合规缺少完整25年逐年明细表格", outputLevel: "warning" },
{ seg: "Seg07第七章 不确定性分析", vectorize: "向量存入本地记忆库", dify: "项目敏感性分析规范检索", local: "召回Seg05投资Seg04成本分段", modelInput: "大模型校验三类敏感因素分析完整性", output: "初投资维保能效敏感性完整缺失电价单独敏感性测算", outputLevel: "warning" },
{ seg: "Seg08第八章 风险分析与防控措施", vectorize: "分段向量化入库本地记忆库", dify: "能源项目风险编制规范检索", modelInput: "大模型逐条校验回款技术实施运维续签风险条款", output: "现有风险防控完整未单独设置能源价格波动风险专项分析", outputLevel: "warning" },
{ seg: "Seg09第九章 结论与建议", vectorize: "向量写入本地记忆RAG库", dify: "可研结论编制规范检索", local: "召回前8段问题记录向量", modelInput: "大模型结合全章节问题校验结论合理性", output: "投资可行结论表述标准未针对识别缺陷补充专项整改建议", outputLevel: "warning" },
];
/** 审查日志 Mock 数据(结构化:独立日志 + 分段父日志含子步骤) */
const mockReviewData: MockReviewLog[] = [
// —— 基础资源加载 ——
{ phase: "基础资源加载", title: "系统初始化", detail: "加载Dify能源专业RAG知识库分类国标库/暖通行标/综合能源企业范本存量规范92份向量模型bge-large-zh语义检索阈值0.7", level: "info" },
{ phase: "基础资源加载", title: "规则引擎加载", detail: "载入人工预设9章多级大纲模板全章节业务审查规则47条每章节绑定专属知识库检索过滤标签", level: "info" },
{ phase: "基础资源加载", title: "本地记忆库初始化", detail: "创建本项目独立临时RAG向量库清空历史向量数据生成分段索引存储表", level: "info" },
// —— 多模态文件解析 & 分段拆分 ——
{ phase: "文件解析", title: "MinerUR引擎启动", detail: "并行解析主DOCX可研2份PDF附件执行版面分层文字/表格/图像/公式识别关键数值提取", level: "info" },
{ phase: "文件解析", title: "多模态解析完成", detail: "提取面积托管单价总投资负荷营收等核心数据识别造价财务工期表格11张过滤页眉空白冗余内容", level: "success" },
{ phase: "文件解析", title: "长文档拆分", detail: "按9大章边界切割生成Seg01~Seg09共9个独立分段一章对应一个分段附件拆分为Att01Att02", level: "info" },
{ phase: "文件解析", title: "分段预处理完毕", detail: "9个主分段完成文本清洗进入循环审查队列", level: "success" },
// —— 分段循环审查 Seg01~Seg09父日志 + 子timeline展示调用链路 ——
...segDataList.map(d => buildSegLog(d)),
// —— 全章节汇总 & 生成最终审查报告 ——
{ phase: "数据汇总", title: "汇总推理任务启动", detail: "本地大模型读取9个分段全部初审记录全部Dify知识库检索原文本地记忆跨段比对数据", level: "info" },
{ phase: "数据汇总", title: "综合归集分类", detail: "大模型统一归类所有问题章节内容缺失规范条文遗漏跨段数据不一致合同条款缺失四大类每条绑定对应Seg分段ID知识库来源附件溯源", level: "info" },
{ phase: "数据汇总", title: "报告输出落地", detail: "导出标准化审查报告每条问题附带检索来源分段编号校验时间戳", level: "success" },
// —— 全流程技术调用统计 ——
{ phase: "流程统计", title: "Dify专业知识库调用", detail: "9每章1次检索", level: "info" },
{ phase: "流程统计", title: "本地记忆临时RAG库检索", detail: "7存在跨章/附件比对章节触发", level: "info" },
{ phase: "流程统计", title: "本地大模型推理", detail: "分段推理43次", level: "info" },
{ phase: "流程统计", title: "MinerUR多模态解析总耗时", detail: "3分37秒", level: "success" },
{ phase: "流程统计", title: "全流程运行状态", detail: "全流程运行稳定无算力不足上下文溢出报错", level: "success" },
];
/** 播放队列:将结构化数据展开为扁平的播放序列 */
interface PlaybackItem {
isChild: boolean;
log: MockReviewLog;
}
const playbackQueue: PlaybackItem[] = [];
for (const item of mockReviewData) {
if (item.children && item.children.length > 0) {
playbackQueue.push({ isChild: false, log: { ...item, children: [] } });
for (const child of item.children) {
playbackQueue.push({ isChild: true, log: child });
}
} else {
playbackQueue.push({ isChild: false, log: item });
}
}
interface ReviewLogEntry extends MockReviewLog {
time: string;
children?: ReviewLogEntry[];
}
const reviewLogs = ref<ReviewLogEntry[]>([]);
const reviewWaiting = ref(false);
const reviewFinished = ref(false);
const reviewIndex = ref(0);
let reviewTimer: ReturnType<typeof setTimeout> | null = null;
const reviewScrollRef = ref<HTMLElement | null>(null);
const scrollSentinelRef = ref<HTMLElement | null>(null);
/** 智能自动滚动:用户手动上滚时暂停,回到底部时恢复 */
const isAutoScroll = ref(true);
/** 任务切换时重置审查日志 */
watch(
() => props.taskId,
() => {
stopReviewTimer();
reviewLogs.value = [];
reviewIndex.value = 0;
reviewFinished.value = false;
reviewWaiting.value = false;
isAutoScroll.value = true;
}
);
function nowTime(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** 根据步骤类型返回不同的日志产出间隔ms模拟真实处理耗时 */
function getIntervalForLog(log: MockReviewLog): number {
switch (log.step) {
case "vectorize": return 1500 + Math.random() * 1500;
case "search": return 2500 + Math.random() * 2000;
case "model_load": return 2000 + Math.random() * 1500;
case "model_output": return 3500 + Math.random() * 3000;
case "record": return 1000 + Math.random() * 1000;
default:
switch (log.phase) {
case "基础资源加载": return 3000 + Math.random() * 2000;
case "文件解析": return 4000 + Math.random() * 3000;
case "数据汇总": return 6000 + Math.random() * 4000;
case "流程统计": return 3000 + Math.random() * 2000;
default: return 3000 + Math.random() * 2000;
}
}
}
function ensureReviewRunning() {
if (reviewFinished.value) return;
if (reviewTimer !== null) return;
scheduleNextReviewLog();
}
function scheduleNextReviewLog() {
if (reviewIndex.value >= playbackQueue.length) {
reviewWaiting.value = false;
reviewFinished.value = true;
return;
}
reviewWaiting.value = true;
const item = playbackQueue[reviewIndex.value]!;
const interval = getIntervalForLog(item.log);
reviewTimer = setTimeout(() => {
reviewTimer = null;
if (item.isChild) {
// 追加到最后一条父日志的 children 中
const parent = reviewLogs.value[reviewLogs.value.length - 1];
if (parent && parent.children) {
const { children: _c, ...childRest } = item.log;
parent.children.push({ ...childRest, time: nowTime() });
}
} else {
const { children: _children, ...rest } = item.log;
const entry: ReviewLogEntry = { ...rest, time: nowTime() };
if (item.log.children) {
entry.children = [];
}
reviewLogs.value.push(entry);
}
reviewIndex.value++;
scheduleNextReviewLog();
}, interval);
}
function stopReviewTimer() {
if (reviewTimer !== null) {
clearTimeout(reviewTimer);
reviewTimer = null;
}
}
function restartReview() {
stopReviewTimer();
reviewLogs.value = [];
reviewIndex.value = 0;
reviewFinished.value = false;
reviewWaiting.value = false;
isAutoScroll.value = true;
ensureReviewRunning();
}
/** 用户滚动时判断是否在底部附近 */
function onReviewScroll() {
const el = reviewScrollRef.value;
if (!el) return;
const threshold = 50;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
isAutoScroll.value = isAtBottom;
}
function scrollToBottom() {
const sentinel = scrollSentinelRef.value;
if (sentinel) {
sentinel.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}
}
function scrollToBottomDelayed() {
if (!isAutoScroll.value) return;
nextTick(() => {
if (!isAutoScroll.value) return;
scrollToBottom();
setTimeout(() => {
if (isAutoScroll.value) scrollToBottom();
}, 400);
});
}
/** 监听日志总数变化(父日志 + 子日志)触发自动滚动 */
const totalLogCount = computed(() => {
return reviewLogs.value.reduce((sum, log) => sum + 1 + (log.children?.length ?? 0), 0);
});
watch(
() => totalLogCount.value,
() => {
scrollToBottomDelayed();
}
);
onUnmounted(() => {
stopReviewTimer();
});
function reviewLevelType(level: ReviewLogLevel): "primary" | "success" | "warning" | "danger" {
switch (level) {
case "info": return "primary";
case "success": return "success";
case "warning": return "warning";
case "error": return "danger";
default: return "primary";
}
}
function phaseTagType(phase: string): "primary" | "success" | "warning" | "danger" | "info" {
switch (phase) {
case "基础资源加载": return "primary";
case "文件解析": return "success";
case "分段审查": return "warning";
case "数据汇总": return "info";
case "流程统计": return "info";
default: return "info";
}
}
</script>
<style lang="scss" scoped>
/* 抽屉 body 改为 flex 布局,使 Tab 内容区可独立滚动 */
:deep(.el-drawer__body) {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.task-summary {
flex-shrink: 0;
padding: 16px 20px 0;
}
/* Tab 区域 */
.log-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 20px 16px;
:deep(.el-tabs__header) {
margin-bottom: 0;
flex-shrink: 0;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.el-tab-pane) {
height: 100%;
}
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tab-status {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-left: 2px;
&--running {
background: #e6a23c;
animation: tab-pulse 1.5s infinite;
}
&--done {
background: #67c23a;
}
}
@keyframes tab-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.tab-scroll {
height: 100%;
overflow-y: auto;
padding: 12px 4px 0 0;
}
/* ===== 审查日志卡片 ===== */
.review-timeline {
padding-left: 4px;
}
.review-card {
background: #f9fafb;
border-radius: 6px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
animation: review-card-in 0.35s ease;
&--success {
background: #f0fdf4;
border-color: #dcfce7;
}
&--warning {
background: #fffbeb;
border-color: #fef3c7;
}
&--error {
background: #fef2f2;
border-color: #fee2e2;
}
}
@keyframes review-card-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.review-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.review-card-title {
font-weight: 600;
font-size: 13px;
color: #303133;
}
.review-card-detail {
font-size: 12px;
color: #606266;
line-height: 1.6;
background: #fff;
padding: 6px 8px;
border-radius: 4px;
white-space: pre-line;
}
/* ===== 子 timeline分段审查调用链路 ===== */
.sub-timeline {
margin-top: 8px;
padding-left: 8px;
:deep(.el-timeline-item__timestamp) {
font-size: 11px;
color: #c0c4cc;
}
:deep(.el-timeline-item__node--normal) {
left: -1px;
width: 8px;
height: 8px;
}
:deep(.el-timeline-item__tail) {
left: 3px;
}
:deep(.el-timeline-item:last-child .el-timeline-item__tail) {
display: none;
}
}
.review-sub-card {
background: #fff;
border-radius: 4px;
padding: 6px 10px;
border: 1px solid #f0f0f0;
animation: review-card-in 0.3s ease;
&--success {
border-color: #dcfce7;
background: #f0fdf4;
}
&--warning {
border-color: #fef3c7;
background: #fffbeb;
}
&--error {
border-color: #fee2e2;
background: #fef2f2;
}
}
.review-sub-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.review-sub-step {
font-weight: 600;
font-size: 12px;
color: #606266;
}
.review-sub-detail {
font-size: 11px;
color: #909399;
line-height: 1.5;
white-space: pre-line;
}
/* 等待新日志加载动画 */
.review-loading {
display: flex;
align-items: center;
gap: 6px;
padding: 14px 0 8px 28px;
color: #409eff;
.loading-text {
font-size: 12px;
color: #909399;
}
}
/* 审查完成 */
.review-finished {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0 8px 28px;
font-size: 13px;
color: #67c23a;
font-weight: 500;
}
/* ===== 操作日志卡片(保持原有样式) ===== */
.log-card {
background: #f9fafb;
border-radius: 6px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.log-action {
font-weight: 600;
font-size: 14px;
color: #303133;
}
.log-operator {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.log-desc {
font-size: 12px;
color: #606266;
line-height: 1.6;
background: #fff;
padding: 6px 8px;
border-radius: 4px;
}
.empty-logs {
text-align: center;
color: #909399;
font-size: 13px;
padding: 24px 0;
}
:deep(.el-timeline-item__timestamp) {
color: #909399;
font-size: 12px;
}
</style>