542 lines
20 KiB
Vue
542 lines
20 KiB
Vue
<template>
|
||
<div class="task-log" :style="{ height: `${height}px` }">
|
||
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
|
||
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
|
||
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
|
||
<div v-for="(log, index) in list" :key="log.id || index" class="task-log__item">
|
||
<div class="task-log__time">{{ formatDate(log.logTime) }}</div>
|
||
<div class="task-log__body">
|
||
<div class="task-log__content">{{ log.logContent }}</div>
|
||
|
||
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
|
||
<div v-if="analysisOf(log)" class="task-log__analysis">
|
||
<!-- 标题行展示分析类型、执行状态、模型名称和耗时;缺失字段不会留下空占位。 -->
|
||
<div class="task-log__analysis-heading">
|
||
<el-tag :type="analysisOf(log).tagType" size="small">
|
||
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
|
||
</el-tag>
|
||
<span v-if="analysisOf(log).modelName" class="task-log__analysis-meta">
|
||
模型:{{ analysisOf(log).modelName }}
|
||
</span>
|
||
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
|
||
耗时:{{ analysisOf(log).timingMs }}ms
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 JSON 区域。 -->
|
||
<div class="task-log__analysis-grid">
|
||
<div v-if="analysisOf(log).targetLabel" class="task-log__analysis-field">
|
||
<span>目标声音</span>
|
||
<strong>{{ analysisOf(log).targetLabel }}</strong>
|
||
</div>
|
||
<div v-if="analysisOf(log).label" class="task-log__analysis-field">
|
||
<span>{{ analysisOf(log).isAudio ? '实际声音' : '识别结果' }}</span>
|
||
<strong>{{ analysisOf(log).label }}</strong>
|
||
</div>
|
||
<div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field">
|
||
<span>匹配状态</span>
|
||
<strong>{{ analysisOf(log).matchLabel }}</strong>
|
||
</div>
|
||
<div v-if="analysisOf(log).confidence !== null" class="task-log__analysis-field">
|
||
<span>置信度</span>
|
||
<strong>{{ analysisOf(log).confidence }}%</strong>
|
||
</div>
|
||
<div v-if="analysisOf(log).passedLabel" class="task-log__analysis-field">
|
||
<span>判断结果</span>
|
||
<strong>{{ analysisOf(log).passedLabel }}</strong>
|
||
</div>
|
||
</div>
|
||
<div v-if="analysisOf(log).summary" class="task-log__analysis-summary">
|
||
{{ analysisOf(log).summary }}
|
||
</div>
|
||
|
||
<!-- 关键事件可能来自模型输出数组,analysisOf 已限制最多展示前 20 条。 -->
|
||
<div v-if="analysisOf(log).events.length" class="task-log__analysis-events">
|
||
<div class="task-log__analysis-label">关键事件</div>
|
||
<div v-for="(event, eventIndex) in analysisOf(log).events" :key="eventIndex" class="task-log__analysis-event">
|
||
{{ event }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未被结构化消费的字段以格式化 JSON 展示,保留排查模型输出的能力。 -->
|
||
<pre v-if="analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
|
||
</div>
|
||
|
||
<!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
|
||
<div v-if="mediaOf(log).length" class="task-log__media">
|
||
<template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
|
||
<el-image
|
||
v-if="media.type === 'image'"
|
||
class="task-log__image"
|
||
:src="media.url"
|
||
:preview-src-list="imageUrlsOf(log)"
|
||
preview-teleported
|
||
fit="cover"
|
||
/>
|
||
<!-- 视频和音频只预加载元数据,避免日志列表初次渲染时下载完整媒体文件。 -->
|
||
<video v-else-if="media.type === 'video'" class="task-log__video" :src="media.url" controls preload="metadata" />
|
||
<audio v-else class="task-log__audio" :src="media.url" controls preload="metadata" />
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 -->
|
||
<el-tooltip v-if="!isAtBottom" content="跳到最新日志" placement="left">
|
||
<el-button
|
||
class="task-log__jump-latest"
|
||
type="primary"
|
||
:icon="Bottom"
|
||
circle
|
||
aria-label="跳到最新日志"
|
||
@click.stop="scrollToBottom"
|
||
/>
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||
import { Bottom } from '@element-plus/icons-vue'
|
||
import { formatDate } from '@/utils/index'
|
||
|
||
/**
|
||
* list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。
|
||
* height: 日志容器固定高度;内容超出后在容器内滚动,默认 150px。
|
||
*/
|
||
const props = defineProps({
|
||
list: { type: Array, default: () => [] },
|
||
height: { type: Number, default: 150 },
|
||
})
|
||
|
||
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
|
||
const messageContainer = ref(null)
|
||
|
||
// 使用少量像素容差吸收浏览器对子像素和缩放比例的取整误差,避免视觉上已到底部却仍显示按钮。
|
||
const BOTTOM_THRESHOLD = 8
|
||
|
||
// 初始状态视为位于底部;组件挂载后会根据真实滚动尺寸完成第一次定位。
|
||
const isAtBottom = ref(true)
|
||
|
||
// 以日志对象本身作为弱引用键缓存媒体解析结果;日志对象回收后缓存会自动释放,不产生长期内存占用。
|
||
const mediaCache = new WeakMap()
|
||
|
||
/**
|
||
* 将扩展结果安全转换为普通对象。
|
||
* 后端历史数据可能返回 JSON 字符串,新版接口可能直接返回对象;空值、基础类型或非法 JSON 均降级为空对象,
|
||
* 避免一条格式异常的日志阻断整个列表渲染。
|
||
*/
|
||
const parseObject = (value) => {
|
||
if (!value) return {}
|
||
if (typeof value === 'object') return value
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把一个或多个媒体 URL 追加到结果数组。
|
||
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
|
||
* 非字符串、空白字符串和已经添加过的条目会被忽略。
|
||
*/
|
||
const addMedia = (result, seen, type, value) => {
|
||
const values = Array.isArray(value) ? value : [value]
|
||
values.forEach((item) => {
|
||
const url = typeof item === 'string' ? item.trim() : ''
|
||
const key = `${type}:${url}`
|
||
if (url && !seen.has(key)) {
|
||
seen.add(key)
|
||
result.push({ type, url })
|
||
}
|
||
})
|
||
}
|
||
|
||
// 分析结果的计算相对复杂,使用 WeakMap 保证模板多次调用 analysisOf(log) 时只解析一次。
|
||
const analysisCache = new WeakMap()
|
||
|
||
/**
|
||
* 把日志中的音视频分析结果转换成模板直接使用的展示模型。
|
||
* 返回 null 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。
|
||
*/
|
||
const analysisOf = (log) => {
|
||
// 防御非对象输入,同时优先读取缓存,避免模板中多个字段重复做 JSON.parse 和格式化工作。
|
||
if (!log || typeof log !== 'object') return null
|
||
if (analysisCache.has(log)) return analysisCache.get(log)
|
||
|
||
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
|
||
const output = {
|
||
...parseObject(log.actualResult),
|
||
...parseObject(log.extraInfo),
|
||
}
|
||
|
||
// 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。
|
||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||
const isAudio = analysisType.includes('AUDIO') || profileCode.includes('audio') || Number(log.logType) === 4
|
||
const isVideo = analysisType.includes('VIDEO') || profileCode.includes('video') || Number(log.logType) === 3
|
||
// 图片分析没有历史 logType 枚举,只依赖新协议中的 analysisType/profileCode 识别。
|
||
const isImage = analysisType.includes('IMAGE') || profileCode.includes('image')
|
||
|
||
// 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。
|
||
const hasResult = Boolean(
|
||
output.analysisStatus
|
||
|| output.analysisType
|
||
|| output.profileCode
|
||
|| output.label
|
||
|| output.labelName
|
||
|| output.summary
|
||
|| output.conclusion
|
||
|| output.resultText
|
||
|| output.result !== undefined
|
||
|| output.passed !== undefined
|
||
)
|
||
|
||
// 非分析日志也缓存 null,避免之后每次渲染都重复执行同一套识别逻辑。
|
||
if (!hasResult || (!isAudio && !isVideo && !isImage)) {
|
||
analysisCache.set(log, null)
|
||
return null
|
||
}
|
||
|
||
const status = String(output.analysisStatus || '').toUpperCase()
|
||
const matched = output.matched
|
||
const passed = output.passed
|
||
const confidenceValue = Number(output.confidence)
|
||
|
||
// 置信度兼容 0~1 和 0~100 两种协议,统一限制到 0~100 并保留一位小数。
|
||
// 非数字值返回 null,模板据此隐藏置信度字段,而不是展示 NaN。
|
||
const confidence = Number.isFinite(confidenceValue)
|
||
? Math.max(0, Math.min(100, confidenceValue <= 1 ? confidenceValue * 100 : confidenceValue)).toFixed(1)
|
||
: null
|
||
|
||
// 常见成功/失败枚举转换为中文;未知状态保留原值,完全缺失时使用中性的“已返回结果”。
|
||
const statusLabel = status === 'SUCCEEDED' || status === 'SUCCESS'
|
||
|| status === 'COMPLETED'
|
||
? '分析完成'
|
||
: status === 'FAILED' || status === 'ERROR'
|
||
? '分析失败'
|
||
: status || '已返回结果'
|
||
|
||
// 复制一份原始输出用于生成扩展详情,后续删除操作不会修改日志本身或合并后的 output。
|
||
const detailSource = { ...output }
|
||
|
||
// _executionContext 只用于前端把日志归属到任务中的检测项和执行次数,属于内部路由元数据。
|
||
// 它与已经结构化展示的分析字段一起从“详情”中移除,避免用户看到重复且难以理解的底层上下文。
|
||
;[
|
||
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
|
||
'mediaUrl', 'mediaUrls', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
|
||
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
|
||
'expectedLabel', 'expectedLabelName', 'decisionReason', 'resultText', 'result',
|
||
].forEach((key) => delete detailSource[key])
|
||
|
||
// 所有已知字段删除后还有剩余内容时,才生成详情 JSON,避免显示空对象。
|
||
const hasDetails = Object.keys(detailSource).length > 0
|
||
|
||
// 统一生成展示模型,模板不需要了解各版本日志字段和状态枚举的差异。
|
||
const result = {
|
||
title: isAudio ? '声音类型检测' : isImage ? '通用图片分析' : '视频分析',
|
||
statusLabel,
|
||
|
||
// 分析失败或业务判断未通过使用红色;仅匹配失败使用警告色,其余使用成功色。
|
||
tagType: status === 'FAILED' || status === 'ERROR' || passed === false
|
||
? 'danger'
|
||
: matched === false ? 'warning' : 'success',
|
||
modelName: typeof output.analysisModel === 'string'
|
||
? output.analysisModel
|
||
: output.analysisModel?.name || '',
|
||
timingMs: Number.isFinite(Number(output.analysisTimingMs)) ? Number(output.analysisTimingMs) : null,
|
||
label: output.labelName || output.label || '',
|
||
targetLabel: output.expectedLabelName || output.expectedLabel || '',
|
||
matchLabel: isAudio ? '' : matched === true ? '匹配' : matched === false ? '未匹配' : '',
|
||
confidence,
|
||
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
|
||
// 图片分析由提示词决定输出形态,优先展示模型文本或字符串结果,避免把枚举型原因当摘要。
|
||
summary: output.summary || output.conclusion || output.resultText
|
||
|| (typeof output.result === 'string' ? output.result : '')
|
||
|| (/^[A-Z_]+$/.test(String(output.decisionReason || '')) ? '' : output.decisionReason)
|
||
|| '',
|
||
isAudio,
|
||
events: Array.isArray(output.events)
|
||
? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event))
|
||
: [],
|
||
details: hasDetails ? JSON.stringify(detailSource, null, 2) : '',
|
||
}
|
||
|
||
// 正常分析结果也写入缓存,后续模板读取状态、模型、摘要等字段时直接复用同一对象。
|
||
analysisCache.set(log, result)
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 从日志的新旧字段中提取所有可播放/预览的媒体资源。
|
||
* 返回值格式统一为 { type, url },模板无需关心 URL 原来位于 actualResult、extraInfo 还是日志顶层。
|
||
*/
|
||
const mediaOf = (log) => {
|
||
if (!log || typeof log !== 'object') return []
|
||
if (mediaCache.has(log)) return mediaCache.get(log)
|
||
|
||
const result = []
|
||
const seen = new Set()
|
||
|
||
// 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。
|
||
const output = {
|
||
...parseObject(log.actualResult),
|
||
...parseObject(log.extraInfo),
|
||
}
|
||
|
||
// 优先读取类型明确的新字段,它们不需要额外推断媒体类型。
|
||
addMedia(result, seen, 'image', output.imageUrl)
|
||
addMedia(result, seen, 'video', output.videoUrl)
|
||
addMedia(result, seen, 'audio', output.audioUrl)
|
||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||
|
||
// mediaUrl 本身没有类型,必须结合分析类型或 profileCode 后才能作为视频/音频加入。
|
||
if (analysisType.includes('VIDEO') || profileCode.includes('video')) addMedia(result, seen, 'video', output.mediaUrl)
|
||
if (analysisType.includes('AUDIO') || profileCode.includes('audio')) addMedia(result, seen, 'audio', output.mediaUrl)
|
||
if (analysisType.includes('IMAGE') || profileCode.includes('image')) {
|
||
addMedia(result, seen, 'image', output.mediaUrls)
|
||
addMedia(result, seen, 'image', output.mediaUrl)
|
||
}
|
||
addMedia(result, seen, 'image', log.screenshotUrl)
|
||
addMedia(result, seen, 'video', log.videoUrl)
|
||
|
||
// 旧版日志把媒体统一放在顶层 mediaUrl,并通过 logType=2/3/4 区分图片、视频和音频。
|
||
const legacyType = Number(log.logType) === 2 ? 'image'
|
||
: Number(log.logType) === 3 ? 'video'
|
||
: Number(log.logType) === 4 ? 'audio' : ''
|
||
if (legacyType) addMedia(result, seen, legacyType, log.mediaUrl)
|
||
|
||
// 即使没有找到媒体也缓存空数组,避免无媒体日志在每次渲染时重复扫描全部字段。
|
||
mediaCache.set(log, result)
|
||
return result
|
||
}
|
||
|
||
// Element Plus 图片预览需要同一条日志下的纯图片 URL 数组,视频和音频不会进入预览列表。
|
||
const imageUrlsOf = (log) => mediaOf(log)
|
||
.filter((item) => item.type === 'image')
|
||
.map((item) => item.url)
|
||
|
||
/**
|
||
* 根据滚动层的剩余距离更新“是否位于底部”。
|
||
* 用户拖动滚动条、滚轮滚动以及程序修改 scrollTop 都会触发 scroll 事件,因此只需要维护一个状态源。
|
||
*/
|
||
const updateBottomState = () => {
|
||
const container = messageContainer.value
|
||
if (!container) {
|
||
isAtBottom.value = true
|
||
return
|
||
}
|
||
|
||
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
|
||
isAtBottom.value = distanceToBottom <= BOTTOM_THRESHOLD
|
||
}
|
||
|
||
// 滚动事件只做轻量尺寸计算,不写入 scrollTop,避免干扰用户正在进行的历史日志浏览。
|
||
const handleScroll = () => updateBottomState()
|
||
|
||
/**
|
||
* 将滚动位置移动到最新日志,并恢复自动跟随状态。
|
||
* nextTick 确保新日志已经渲染,scrollHeight 是更新后的完整内容高度。
|
||
*/
|
||
const scrollToBottom = async () => {
|
||
await nextTick()
|
||
const container = messageContainer.value
|
||
if (!container) return
|
||
container.scrollTop = container.scrollHeight
|
||
isAtBottom.value = true
|
||
}
|
||
|
||
/**
|
||
* 日志变化时先读取更新前的滚动状态:
|
||
* - 原本在底部,说明用户正在追踪最新日志,DOM 更新后继续滚到底部;
|
||
* - 原本不在底部,说明用户正在查看历史内容,只更新按钮状态,不改变其 scrollTop。
|
||
* deep 监听同时覆盖追加日志和现有日志内容更新。
|
||
*/
|
||
watch(() => props.list, () => {
|
||
if (isAtBottom.value) {
|
||
scrollToBottom()
|
||
} else {
|
||
nextTick(updateBottomState)
|
||
}
|
||
}, { deep: true })
|
||
|
||
// 首次进入日志视图仍默认展示最新一条,此时尚不存在用户主动选择的历史位置。
|
||
onMounted(scrollToBottom)
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
/* 外层容器固定高度并裁剪内容,同时作为“跳到最新日志”悬浮按钮的定位上下文。 */
|
||
.task-log {
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
margin-top: 12px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
border: 1px solid #eee;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* 只有内部区域负责滚动,悬浮按钮不会随日志内容一起移出可视区域。 */
|
||
.task-log__scroll {
|
||
box-sizing: border-box;
|
||
height: 100%;
|
||
padding: 12px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* 固定在滚动视口右下角,不参与日志排版;稳定尺寸避免按钮出现时挤压或移动内容。 */
|
||
.task-log__jump-latest {
|
||
position: absolute;
|
||
right: 16px;
|
||
bottom: 16px;
|
||
z-index: 2;
|
||
width: 36px;
|
||
height: 36px;
|
||
box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
|
||
}
|
||
|
||
/* 桌面端使用“时间 + 内容”两列布局;内容列 minmax(0, 1fr) 允许长文本正确收缩。 */
|
||
.task-log__item {
|
||
display: grid;
|
||
grid-template-columns: 145px minmax(0, 1fr);
|
||
gap: 12px;
|
||
align-items: start;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.task-log__time {
|
||
color: #999;
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.task-log__body {
|
||
min-width: 0;
|
||
}
|
||
|
||
.task-log__content {
|
||
color: #333;
|
||
font-size: 14px;
|
||
line-height: 20px;
|
||
}
|
||
|
||
/* 分析结果使用左侧强调线与普通日志正文区分,但保持在同一条日志的内容列中。 */
|
||
.task-log__analysis {
|
||
margin-top: 9px;
|
||
padding: 10px 12px;
|
||
border-left: 3px solid #5b8ff9;
|
||
background: #f6f8fc;
|
||
color: #344054;
|
||
}
|
||
|
||
.task-log__analysis-heading {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.task-log__analysis-meta,
|
||
.task-log__analysis-field span,
|
||
.task-log__analysis-label {
|
||
color: #667085;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 四列摘要分别承载识别结果、匹配状态、置信度和判断结果。 */
|
||
.task-log__analysis-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin-top: 9px;
|
||
}
|
||
|
||
.task-log__analysis-field {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.task-log__analysis-field strong {
|
||
overflow: hidden;
|
||
color: #1f2937;
|
||
font-size: 13px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.task-log__analysis-summary {
|
||
margin-top: 9px;
|
||
color: #344054;
|
||
line-height: 20px;
|
||
}
|
||
|
||
.task-log__analysis-events {
|
||
margin-top: 9px;
|
||
}
|
||
|
||
.task-log__analysis-event {
|
||
margin-top: 3px;
|
||
color: #475467;
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
}
|
||
|
||
/* 原始扩展详情限制最大高度并允许内部滚动,长 JSON 不会无限撑高单条日志。 */
|
||
.task-log__analysis-details {
|
||
max-height: 180px;
|
||
margin: 9px 0 0;
|
||
overflow: auto;
|
||
padding: 8px;
|
||
background: #fff;
|
||
color: #475467;
|
||
font-size: 11px;
|
||
line-height: 16px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* 媒体资源允许自动换行;每种播放器使用稳定尺寸,资源加载后不会造成明显布局跳动。 */
|
||
.task-log__media {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.task-log__image {
|
||
width: 96px;
|
||
height: 72px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.task-log__video {
|
||
width: min(320px, 100%);
|
||
max-height: 220px;
|
||
background: #111;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.task-log__audio {
|
||
width: min(340px, 100%);
|
||
height: 40px;
|
||
}
|
||
|
||
/* 窄屏改为上下布局,并把分析摘要缩减为两列,保证字段名称和结果仍有可读宽度。 */
|
||
@media (max-width: 640px) {
|
||
.task-log__item {
|
||
grid-template-columns: 1fr;
|
||
gap: 3px;
|
||
}
|
||
|
||
.task-log__analysis-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|