Template
1538 lines
38 KiB
Vue
1538 lines
38 KiB
Vue
<template>
|
||
<div class="ingest-view">
|
||
<!-- 产品说明横幅 -->
|
||
<section class="banner">
|
||
<div class="banner-text">
|
||
<div class="banner-title-row">
|
||
<h3 class="banner-title">一键导入</h3>
|
||
<span class="banner-tag">AUTOMATED</span>
|
||
</div>
|
||
<p class="banner-desc">
|
||
自动扫描输入目录,完成音频完整性校验、格式转码、指纹去重与元数据检查,
|
||
合格文件整理入库,异常文件归入拒绝目录。全流程状态实时回传。
|
||
</p>
|
||
</div>
|
||
<span class="banner-batch">PIPELINE: INPUT → LIBRARY / REJECTED</span>
|
||
</section>
|
||
|
||
<!-- 5/7 分栏 -->
|
||
<div class="split">
|
||
<!-- 左:控制卡 -->
|
||
<div class="split-left">
|
||
<div class="panel control-card">
|
||
<div class="panel-head">
|
||
<app-icon name="folder" :size="16" class="panel-head-icon" />
|
||
<h4 class="panel-head-title">当前挂载路径</h4>
|
||
<button
|
||
class="icon-btn"
|
||
type="button"
|
||
title="刷新配置"
|
||
aria-label="刷新配置"
|
||
:disabled="configLoading"
|
||
@click="reloadConfig"
|
||
>
|
||
<app-icon name="refresh" :size="14" :class="{ spin: configLoading }" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="path-list">
|
||
<div class="path-field">
|
||
<span class="path-label">输入目录 (INPUT)</span>
|
||
<div class="path-value">
|
||
<app-icon name="login" :size="14" class="path-value-icon" />
|
||
<span class="path-text selectable" :title="appState.config.inputDir || '未配置'">
|
||
{{ appState.config.inputDir || '未配置' }}
|
||
</span>
|
||
<button
|
||
v-if="appState.config.inputDir"
|
||
class="copy-btn"
|
||
type="button"
|
||
title="复制路径"
|
||
aria-label="复制输入目录"
|
||
@click="copyPath(appState.config.inputDir)"
|
||
>
|
||
<app-icon name="check" :size="12" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="path-field">
|
||
<span class="path-label">入库目录 (LIBRARY)</span>
|
||
<div class="path-value">
|
||
<app-icon name="shield" :size="14" class="path-value-icon" />
|
||
<span class="path-text selectable" :title="appState.config.libraryDir || '未配置'">
|
||
{{ appState.config.libraryDir || '未配置' }}
|
||
</span>
|
||
<button
|
||
v-if="appState.config.libraryDir"
|
||
class="copy-btn"
|
||
type="button"
|
||
title="复制路径"
|
||
aria-label="复制入库目录"
|
||
@click="copyPath(appState.config.libraryDir)"
|
||
>
|
||
<app-icon name="check" :size="12" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="path-field">
|
||
<span class="path-label">拒绝目录 (REJECTED)</span>
|
||
<div class="path-value">
|
||
<app-icon name="reject" :size="14" class="path-value-icon" />
|
||
<span class="path-text selectable" :title="appState.config.rejectedDir || '未配置'">
|
||
{{ appState.config.rejectedDir || '未配置' }}
|
||
</span>
|
||
<button
|
||
v-if="appState.config.rejectedDir"
|
||
class="copy-btn"
|
||
type="button"
|
||
title="复制路径"
|
||
aria-label="复制拒绝目录"
|
||
@click="copyPath(appState.config.rejectedDir)"
|
||
>
|
||
<app-icon name="check" :size="12" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未配置提示 -->
|
||
<div v-if="appState.config.attempted && !isConfigured" class="notice notice-warn">
|
||
<app-icon name="warn" :size="16" class="notice-icon" />
|
||
<div class="notice-body">
|
||
<p class="notice-title">尚未配置工作根目录</p>
|
||
<p class="notice-desc">请前往「全局存储配置」设置根目录后再执行导入。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="control-actions">
|
||
<button
|
||
v-if="!running"
|
||
class="btn btn-primary"
|
||
type="button"
|
||
:disabled="!canStart"
|
||
@click="startIngest"
|
||
>
|
||
<app-icon name="play" :size="16" :filled="true" />
|
||
<span>执行一键入库</span>
|
||
</button>
|
||
<button
|
||
v-else
|
||
class="btn btn-running"
|
||
type="button"
|
||
disabled
|
||
>
|
||
<app-icon name="refresh" :size="16" class="spin" />
|
||
<span>正在导入分析… ({{ percentage }}%)</span>
|
||
</button>
|
||
|
||
<button
|
||
class="btn btn-ghost"
|
||
type="button"
|
||
:disabled="running || activity.length === 0"
|
||
:title="running ? '任务运行中不可清除' : '仅清除本地活动记录,不影响后端统计'"
|
||
@click="clearActivity"
|
||
>
|
||
<app-icon name="refresh" :size="14" />
|
||
<span>清除本地活动记录</span>
|
||
</button>
|
||
</div>
|
||
|
||
<p class="control-hint">
|
||
<app-icon name="info" :size="14" class="hint-icon" />
|
||
<span>路径来自「全局存储配置」。此处仅展示只读真值,修改请前往配置页。</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右:遥测面板 -->
|
||
<div class="split-right">
|
||
<div class="panel telemetry-card">
|
||
<div class="panel-head">
|
||
<app-icon name="chart" :size="16" class="panel-head-icon" />
|
||
<h4 class="panel-head-title">智能入库监控台</h4>
|
||
<span class="run-badge" :class="runBadgeClass">{{ runBadgeText }}</span>
|
||
</div>
|
||
|
||
<!-- 主统计 -->
|
||
<div class="stats-grid stats-primary">
|
||
<div class="stat-cell">
|
||
<span class="stat-name">已处理</span>
|
||
<span class="stat-num">{{ progress.processed ?? 0 }}</span>
|
||
</div>
|
||
<div class="stat-cell tone-ok">
|
||
<span class="stat-name">已入库</span>
|
||
<span class="stat-num">{{ progress.ingestedFiles ?? 0 }}</span>
|
||
</div>
|
||
<div class="stat-cell tone-warn">
|
||
<span class="stat-name">重复跳过</span>
|
||
<span class="stat-num">{{ progress.duplicateFiles ?? 0 }}</span>
|
||
</div>
|
||
<div class="stat-cell tone-purple">
|
||
<span class="stat-name">缺元数据</span>
|
||
<span class="stat-num">{{ progress.missingMetadataFiles ?? 0 }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 次级错误统计 -->
|
||
<div class="stats-grid stats-secondary">
|
||
<div class="stat-cell-sm tone-error">
|
||
<span class="stat-name-sm">损坏不可读</span>
|
||
<span class="stat-num-sm">{{ progress.unreadableFiles ?? 0 }}</span>
|
||
</div>
|
||
<div class="stat-cell-sm">
|
||
<span class="stat-name-sm">转码失败</span>
|
||
<span class="stat-num-sm">{{ progress.conversionFailedFiles ?? 0 }}</span>
|
||
</div>
|
||
<div class="stat-cell-sm">
|
||
<span class="stat-name-sm">其他拒绝</span>
|
||
<span class="stat-num-sm">{{ progress.otherRejectedFiles ?? 0 }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 进度条 -->
|
||
<div class="progress-block">
|
||
<div class="progress-meta">
|
||
<span>总体处理进度</span>
|
||
<span class="progress-pct" :class="progressToneClass">{{ percentage }}%</span>
|
||
</div>
|
||
<div class="progress-track">
|
||
<div
|
||
class="progress-fill"
|
||
:class="progressToneClass"
|
||
:style="{ width: percentage + '%' }"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 当前文件 / 最新消息 -->
|
||
<div class="current-line">
|
||
<template v-if="progress.currentFile">
|
||
<span class="current-tag">当前</span>
|
||
<span class="current-file" :title="progress.currentFile">{{ progress.currentFile }}</span>
|
||
</template>
|
||
<template v-else-if="latestMessage">
|
||
<span class="current-tag">消息</span>
|
||
<span class="current-file" :title="latestMessage">{{ latestMessage }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<span class="current-idle">等待任务开始…</span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 完成状态汇总 -->
|
||
<div v-if="progress.completed" class="result-banner" :class="resultClass">
|
||
<app-icon :name="resultIcon" :size="16" class="result-icon" />
|
||
<span>{{ resultText }}</span>
|
||
</div>
|
||
|
||
<!-- 活动记录 -->
|
||
<div class="console-head">
|
||
<div class="console-legend">
|
||
<span class="legend-dot dot-error"></span>
|
||
<span class="legend-dot dot-warn"></span>
|
||
<span class="legend-dot dot-ok"></span>
|
||
<span class="console-legend-label">活动记录</span>
|
||
</div>
|
||
<div class="filter-group" role="group" aria-label="活动记录筛选">
|
||
<button
|
||
v-for="f in filters"
|
||
:key="f.key"
|
||
type="button"
|
||
class="filter-btn"
|
||
:class="{ 'is-active': activeFilter === f.key }"
|
||
@click="activeFilter = f.key"
|
||
>
|
||
{{ f.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
ref="consoleEl"
|
||
class="console-body c-scroll"
|
||
@scroll="onConsoleScroll"
|
||
>
|
||
<div v-if="filteredActivity.length === 0" class="console-empty">
|
||
<app-icon name="wave" :size="30" class="console-empty-icon" />
|
||
<span>{{ activity.length === 0 ? '暂无活动记录' : '当前筛选无匹配记录' }}</span>
|
||
<span class="console-empty-hint">活动记录仅在本次会话内存中构建,刷新页面后无法恢复历史</span>
|
||
</div>
|
||
<div v-else class="console-lines">
|
||
<div
|
||
v-for="entry in filteredActivity"
|
||
:key="entry.id"
|
||
class="log-line"
|
||
>
|
||
<span class="log-time">{{ entry.time }}</span>
|
||
<span class="log-badge" :class="'badge-' + entry.level">{{ entry.badge }}</span>
|
||
<span class="log-text">
|
||
<span v-if="entry.file" class="log-file">{{ entry.file }}</span>
|
||
<span class="log-msg">{{ entry.message }}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="console-note">
|
||
共 {{ activity.length }} 条本地记录(上限 300)。历史记录随页面刷新清空,无法重建。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||
import { ElMessage } from 'element-plus';
|
||
import AppIcon from './common/AppIcon.vue';
|
||
import { startIngest as apiStartIngest, getIngestStatus, getIngestCurrentStatus } from '../api/ingest';
|
||
import type { IngestStatusResponse } from '../api/ingest';
|
||
import { useWebSocket } from '../composables/useWebSocket';
|
||
import type { ProgressMessage } from '../composables/useWebSocket';
|
||
import { useAppState } from '../composables/useAppState';
|
||
|
||
const {
|
||
state: appState,
|
||
isConfigured,
|
||
refreshConfig,
|
||
setIngestRunning,
|
||
setIngestPercentage,
|
||
claimIngestViewOwnership,
|
||
releaseIngestViewOwnership
|
||
} = useAppState();
|
||
|
||
const submitting = ref(false);
|
||
const configLoading = ref(false);
|
||
const taskId = ref<string | null>(null);
|
||
|
||
/** 任务运行态:提交中或后端上报 running。 */
|
||
const running = computed(() => submitting.value);
|
||
|
||
const progress = reactive<ProgressMessage>({
|
||
taskId: '',
|
||
type: '',
|
||
total: 0,
|
||
processed: 0,
|
||
success: 0,
|
||
failed: 0,
|
||
currentFile: '',
|
||
message: '',
|
||
completed: false,
|
||
ingestedFiles: 0,
|
||
duplicateFiles: 0,
|
||
missingMetadataFiles: 0,
|
||
unreadableFiles: 0,
|
||
conversionFailedFiles: 0,
|
||
otherRejectedFiles: 0
|
||
});
|
||
|
||
const canStart = computed(() => isConfigured.value && !submitting.value);
|
||
|
||
const percentage = computed(() => {
|
||
if (progress.completed) return 100;
|
||
if (!progress.total || !progress.processed) return 0;
|
||
return Math.min(100, Math.round((progress.processed / progress.total) * 100));
|
||
});
|
||
|
||
/**
|
||
* 被拒绝类别之和(不含已入库、不含重复跳过)。
|
||
* 重复文件是设计上的跳过,不计入拒绝/部分失败。
|
||
*/
|
||
const totalRejected = computed(() =>
|
||
(progress.missingMetadataFiles ?? 0)
|
||
+ (progress.unreadableFiles ?? 0)
|
||
+ (progress.conversionFailedFiles ?? 0)
|
||
+ (progress.otherRejectedFiles ?? 0)
|
||
);
|
||
|
||
const isFatalError = computed(() => {
|
||
const msg = progress.message || '';
|
||
return msg.startsWith('导入任务失败') || msg.startsWith('任务内部错误') || msg.includes('异常终止');
|
||
});
|
||
|
||
const latestMessage = computed(() => progress.message || '');
|
||
|
||
/** 运行状态徽章。 */
|
||
const runBadgeText = computed(() => {
|
||
if (submitting.value) return 'INGESTING';
|
||
if (progress.completed) {
|
||
if (isFatalError.value) return 'FAILED';
|
||
if (totalRejected.value > 0) return 'PARTIAL';
|
||
return 'DONE';
|
||
}
|
||
if (progress.taskId) return 'IDLE';
|
||
return 'STANDBY';
|
||
});
|
||
|
||
const runBadgeClass = computed(() => {
|
||
if (submitting.value) return 'badge-run-active';
|
||
if (progress.completed) {
|
||
if (isFatalError.value) return 'badge-run-error';
|
||
if (totalRejected.value > 0) return 'badge-run-warn';
|
||
return 'badge-run-ok';
|
||
}
|
||
return 'badge-run-idle';
|
||
});
|
||
|
||
const progressToneClass = computed(() => {
|
||
if (progress.completed && isFatalError.value) return 'tone-error';
|
||
if (progress.completed && totalRejected.value > 0) return 'tone-warn';
|
||
return 'tone-ok';
|
||
});
|
||
|
||
const resultClass = computed(() => {
|
||
if (isFatalError.value) return 'result-error';
|
||
if (totalRejected.value > 0) return 'result-warn';
|
||
return 'result-ok';
|
||
});
|
||
|
||
const resultIcon = computed(() => {
|
||
if (isFatalError.value) return 'warn';
|
||
if (totalRejected.value > 0) return 'info';
|
||
return 'check';
|
||
});
|
||
|
||
const resultText = computed(() => {
|
||
if (isFatalError.value) {
|
||
return progress.message || '导入任务失败';
|
||
}
|
||
if (totalRejected.value > 0) {
|
||
return `导入完成:成功入库 ${progress.ingestedFiles ?? 0} 首,${totalRejected.value} 首被拒绝`;
|
||
}
|
||
const dups = progress.duplicateFiles ?? 0;
|
||
if (dups > 0) {
|
||
return `导入完成:成功入库 ${progress.ingestedFiles ?? 0} 首,${dups} 首重复跳过`;
|
||
}
|
||
return `导入完成:全部 ${progress.ingestedFiles ?? progress.processed ?? 0} 首成功入库`;
|
||
});
|
||
|
||
/* ---------------- 活动记录(仅由真实状态转移构建) ---------------- */
|
||
|
||
type ActivityLevel = 'normal' | 'warning' | 'error';
|
||
|
||
interface ActivityEntry {
|
||
id: number;
|
||
time: string;
|
||
level: ActivityLevel;
|
||
badge: string;
|
||
file: string;
|
||
message: string;
|
||
}
|
||
|
||
const ACTIVITY_CAP = 300;
|
||
const activity = reactive<ActivityEntry[]>([]);
|
||
let activitySeq = 0;
|
||
/** 去重键:连续相同的(file+message)不重复入栈(应对轮询重复)。 */
|
||
let lastActivityKey = '';
|
||
|
||
const filters = [
|
||
{ key: 'all', label: '全部' },
|
||
{ key: 'normal', label: '正常' },
|
||
{ key: 'warning', label: '警告' },
|
||
{ key: 'error', label: '错误' }
|
||
] as const;
|
||
|
||
type FilterKey = (typeof filters)[number]['key'];
|
||
const activeFilter = ref<FilterKey>('all');
|
||
|
||
const filteredActivity = computed(() => {
|
||
if (activeFilter.value === 'all') return activity;
|
||
return activity.filter((e) => e.level === activeFilter.value);
|
||
});
|
||
|
||
/** 根据真实进度消息推断记录级别与徽章。 */
|
||
function classifyLevel(msg: ProgressMessage): { level: ActivityLevel; badge: string } {
|
||
const text = msg.message || '';
|
||
const fatal =
|
||
text.startsWith('导入任务失败') || text.startsWith('任务内部错误') || text.includes('异常终止');
|
||
if (fatal) return { level: 'error', badge: '[失败]' };
|
||
if (text.includes('损坏') || text.includes('不可读') || text.includes('转码失败')) {
|
||
return { level: 'error', badge: '[错误]' };
|
||
}
|
||
// 重复/跳过按设计是警告级事件记录,但不计入完成态「拒绝」统计。
|
||
if (text.includes('重复') || text.includes('跳过') || text.includes('缺') || text.includes('拒绝')) {
|
||
return { level: 'warning', badge: '[警告]' };
|
||
}
|
||
if (msg.completed) {
|
||
// 完成分类仅看真实拒绝类别,不含 duplicateFiles。
|
||
const rejected =
|
||
(msg.missingMetadataFiles ?? 0)
|
||
+ (msg.unreadableFiles ?? 0)
|
||
+ (msg.conversionFailedFiles ?? 0)
|
||
+ (msg.otherRejectedFiles ?? 0);
|
||
if (rejected > 0) return { level: 'warning', badge: '[部分]' };
|
||
return { level: 'normal', badge: '[完成]' };
|
||
}
|
||
return { level: 'normal', badge: '[进行]' };
|
||
}
|
||
|
||
/**
|
||
* 由真实状态转移追加活动记录。
|
||
* - 仅在 file 或 message 发生变化时记录,避免轮询重复刷屏。
|
||
* - 上限 300,超出丢弃最旧。
|
||
*/
|
||
function recordActivity(msg: ProgressMessage) {
|
||
const file = msg.currentFile || '';
|
||
const message = msg.message || '';
|
||
if (!file && !message) return;
|
||
|
||
const key = `${file}||${message}`;
|
||
if (key === lastActivityKey) return;
|
||
lastActivityKey = key;
|
||
|
||
const { level, badge } = classifyLevel(msg);
|
||
activity.push({
|
||
id: ++activitySeq,
|
||
time: new Date().toLocaleTimeString(),
|
||
level,
|
||
badge,
|
||
file,
|
||
message
|
||
});
|
||
|
||
if (activity.length > ACTIVITY_CAP) {
|
||
activity.splice(0, activity.length - ACTIVITY_CAP);
|
||
}
|
||
|
||
void scrollConsoleIfPinned();
|
||
}
|
||
|
||
function clearActivity() {
|
||
if (submitting.value) return;
|
||
activity.splice(0, activity.length);
|
||
lastActivityKey = '';
|
||
ElMessage.success('已清除本地活动记录');
|
||
}
|
||
|
||
/* ---------------- 控制台自动滚动(用户阅读旧记录时不强制滚动) ---------------- */
|
||
|
||
const consoleEl = ref<HTMLElement | null>(null);
|
||
const pinnedToBottom = ref(true);
|
||
|
||
function onConsoleScroll() {
|
||
const el = consoleEl.value;
|
||
if (!el) return;
|
||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||
pinnedToBottom.value = distance < 24;
|
||
}
|
||
|
||
async function scrollConsoleIfPinned() {
|
||
if (!pinnedToBottom.value) return;
|
||
await nextTick();
|
||
const el = consoleEl.value;
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
/* ---------------- 进度消息处理 ---------------- */
|
||
|
||
function handleProgressMessage(msg: ProgressMessage) {
|
||
Object.assign(progress, msg);
|
||
recordActivity(msg);
|
||
|
||
if (typeof msg.running === 'boolean') {
|
||
submitting.value = msg.running;
|
||
} else if (msg.completed) {
|
||
submitting.value = false;
|
||
}
|
||
|
||
setIngestRunning(submitting.value);
|
||
setIngestPercentage(percentage.value);
|
||
}
|
||
|
||
const { connect: wsConnect, disconnect: wsDisconnect } = useWebSocket(taskId, handleProgressMessage);
|
||
|
||
/* ---------------- 轮询兜底 ---------------- */
|
||
|
||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
||
function statusToProgress(resp: IngestStatusResponse): ProgressMessage {
|
||
return {
|
||
taskId: resp.taskId,
|
||
type: 'ingest',
|
||
total: resp.total,
|
||
processed: resp.processed,
|
||
success: resp.ingestedFiles,
|
||
failed: resp.otherRejectedFiles,
|
||
currentFile: '',
|
||
message: resp.message,
|
||
completed: resp.completed,
|
||
running: resp.running,
|
||
ingestedFiles: resp.ingestedFiles,
|
||
duplicateFiles: resp.duplicateFiles,
|
||
missingMetadataFiles: resp.missingMetadataFiles,
|
||
unreadableFiles: resp.unreadableFiles,
|
||
conversionFailedFiles: resp.conversionFailedFiles,
|
||
otherRejectedFiles: resp.otherRejectedFiles
|
||
};
|
||
}
|
||
|
||
function startPolling() {
|
||
stopPolling();
|
||
pollTimer = setInterval(async () => {
|
||
if (!taskId.value) return;
|
||
try {
|
||
const resp = await getIngestStatus(taskId.value);
|
||
if (resp) {
|
||
handleProgressMessage(statusToProgress(resp));
|
||
if (resp.completed || !resp.running) {
|
||
stopPolling();
|
||
wsDisconnect();
|
||
}
|
||
}
|
||
} catch {
|
||
// 忽略轮询错误,等待下次
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer !== null) {
|
||
clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
}
|
||
|
||
/* ---------------- 启动导入 ---------------- */
|
||
|
||
async function startIngest() {
|
||
if (submitting.value) return;
|
||
submitting.value = true;
|
||
setIngestRunning(true);
|
||
|
||
try {
|
||
const resp = await apiStartIngest();
|
||
taskId.value = resp.taskId;
|
||
|
||
Object.assign(progress, {
|
||
taskId: resp.taskId,
|
||
type: 'ingest',
|
||
total: 0,
|
||
processed: 0,
|
||
success: 0,
|
||
failed: 0,
|
||
currentFile: '',
|
||
message: '开始导入任务...',
|
||
completed: false,
|
||
ingestedFiles: 0,
|
||
duplicateFiles: 0,
|
||
missingMetadataFiles: 0,
|
||
unreadableFiles: 0,
|
||
conversionFailedFiles: 0,
|
||
otherRejectedFiles: 0
|
||
});
|
||
lastActivityKey = '';
|
||
recordActivity(progress);
|
||
setIngestPercentage(0);
|
||
|
||
wsConnect();
|
||
startPolling();
|
||
} catch (e: unknown) {
|
||
submitting.value = false;
|
||
setIngestRunning(false);
|
||
ElMessage.error(extractError(e, '启动任务失败'));
|
||
}
|
||
}
|
||
|
||
/* ---------------- 刷新后恢复当前任务 ---------------- */
|
||
|
||
async function resumeFromCurrentTask() {
|
||
let resp: IngestStatusResponse | null = null;
|
||
try {
|
||
resp = await getIngestCurrentStatus();
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
if (!resp || (!resp.taskId && !resp.running)) return;
|
||
|
||
taskId.value = resp.taskId;
|
||
submitting.value = resp.running;
|
||
setIngestRunning(resp.running);
|
||
|
||
handleProgressMessage(statusToProgress({
|
||
...resp,
|
||
message: resp.message || (resp.running ? '恢复任务状态...' : '任务已完成')
|
||
}));
|
||
|
||
if (resp.running) {
|
||
wsConnect();
|
||
startPolling();
|
||
}
|
||
}
|
||
|
||
/* ---------------- 配置加载 ---------------- */
|
||
|
||
async function reloadConfig() {
|
||
configLoading.value = true;
|
||
try {
|
||
await refreshConfig();
|
||
} catch {
|
||
ElMessage.error('加载配置失败');
|
||
} finally {
|
||
configLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/* ---------------- 工具 ---------------- */
|
||
|
||
function extractError(e: unknown, fallback: string): string {
|
||
if (typeof e === 'object' && e !== null) {
|
||
const maybe = e as { response?: { data?: { message?: unknown } }; message?: unknown };
|
||
const respMsg = maybe.response?.data?.message;
|
||
if (typeof respMsg === 'string' && respMsg) return respMsg;
|
||
if (typeof maybe.message === 'string' && maybe.message) return maybe.message;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
async function copyPath(text: string) {
|
||
if (!text) return;
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
ElMessage.success('路径已复制');
|
||
return;
|
||
}
|
||
throw new Error('clipboard unavailable');
|
||
} catch {
|
||
ElMessage.warning('复制失败,请手动选择路径文本');
|
||
}
|
||
}
|
||
|
||
/* ---------------- 生命周期 ---------------- */
|
||
|
||
onMounted(async () => {
|
||
// 接管细粒度进度源,暂停外壳后台监视(避免双重 HTTP 轮询)。
|
||
claimIngestViewOwnership();
|
||
|
||
// 若外壳尚未加载配置,则此处兜底加载真值。
|
||
if (!appState.config.attempted) {
|
||
await reloadConfig();
|
||
}
|
||
await resumeFromCurrentTask();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
wsDisconnect();
|
||
stopPolling();
|
||
// 交还所有权:若任务仍在跑,由外壳后台监视继续同步真实完成态。
|
||
releaseIngestViewOwnership(submitting.value);
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.ingest-view {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
/* ---------- 横幅 ---------- */
|
||
.banner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 18px 20px;
|
||
border: 1px solid rgba(16, 185, 129, 0.12);
|
||
border-radius: var(--c-radius-lg);
|
||
background: linear-gradient(120deg, rgba(6, 78, 59, 0.18), rgba(15, 23, 42, 0.6));
|
||
}
|
||
|
||
.banner-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.banner-title {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
|
||
.banner-tag {
|
||
padding: 2px 8px;
|
||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||
border-radius: 6px;
|
||
background: rgba(16, 185, 129, 0.16);
|
||
color: var(--c-accent-text);
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.banner-desc {
|
||
margin: 6px 0 0;
|
||
max-width: 640px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
color: var(--c-text-secondary);
|
||
}
|
||
|
||
.banner-batch {
|
||
flex-shrink: 0;
|
||
padding: 5px 10px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: 8px;
|
||
background: var(--c-bg-panel);
|
||
color: var(--c-text-muted);
|
||
font-family: var(--c-mono);
|
||
font-size: 10px;
|
||
}
|
||
|
||
/* ---------- 分栏 ---------- */
|
||
.split {
|
||
display: grid;
|
||
grid-template-columns: 5fr 7fr;
|
||
gap: 20px;
|
||
align-items: start;
|
||
}
|
||
|
||
/* ---------- 面板通用 ---------- */
|
||
.panel {
|
||
border: 1px solid var(--c-border);
|
||
border-radius: var(--c-radius-lg);
|
||
background: var(--c-bg-panel-soft);
|
||
padding: 18px;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding-bottom: 12px;
|
||
margin-bottom: 16px;
|
||
border-bottom: 1px solid var(--c-border);
|
||
}
|
||
|
||
.panel-head-icon {
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.panel-head-title {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--c-text-regular);
|
||
}
|
||
|
||
.icon-btn {
|
||
margin-left: auto;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: 8px;
|
||
background: var(--c-bg-elevated);
|
||
color: var(--c-text-secondary);
|
||
cursor: pointer;
|
||
transition: color 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
|
||
.icon-btn:hover:not(:disabled) {
|
||
color: var(--c-accent-text);
|
||
border-color: var(--c-border-strong);
|
||
}
|
||
|
||
.icon-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* ---------- 路径列表 ---------- */
|
||
.path-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.path-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
.path-label {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
color: var(--c-text-muted);
|
||
}
|
||
|
||
.path-value {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: var(--c-radius-sm);
|
||
background: var(--c-bg-inset);
|
||
}
|
||
|
||
.path-value-icon {
|
||
color: var(--c-text-muted);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.path-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-family: var(--c-mono);
|
||
font-size: 12px;
|
||
color: var(--c-text-regular);
|
||
}
|
||
|
||
.selectable {
|
||
user-select: all;
|
||
}
|
||
|
||
.copy-btn {
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 22px;
|
||
height: 22px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: 6px;
|
||
background: var(--c-bg-elevated);
|
||
color: var(--c-text-muted);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.copy-btn:hover {
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
/* ---------- 提示 ---------- */
|
||
.notice {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
padding: 12px;
|
||
border-radius: var(--c-radius-sm);
|
||
}
|
||
|
||
.notice-warn {
|
||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||
background: rgba(245, 158, 11, 0.08);
|
||
}
|
||
|
||
.notice-icon {
|
||
flex-shrink: 0;
|
||
color: var(--c-warning-text);
|
||
}
|
||
|
||
.notice-title {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: var(--c-warning-text);
|
||
}
|
||
|
||
.notice-desc {
|
||
margin: 2px 0 0;
|
||
font-size: 11px;
|
||
color: var(--c-text-secondary);
|
||
}
|
||
|
||
/* ---------- 操作按钮 ---------- */
|
||
.control-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 10px 16px;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--c-radius-sm);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||
}
|
||
|
||
.btn:disabled {
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-primary {
|
||
color: #fff;
|
||
background: linear-gradient(120deg, var(--c-accent-strong), var(--c-teal));
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled) {
|
||
opacity: 0.92;
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.45;
|
||
}
|
||
|
||
.btn-running {
|
||
color: var(--c-accent-text);
|
||
background: var(--c-bg-elevated);
|
||
border-color: rgba(16, 185, 129, 0.25);
|
||
}
|
||
|
||
.btn-ghost {
|
||
padding: 8px 16px;
|
||
color: var(--c-text-regular);
|
||
background: var(--c-bg-elevated);
|
||
border-color: var(--c-border);
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.btn-ghost:hover:not(:disabled) {
|
||
color: var(--c-text-primary);
|
||
border-color: var(--c-border-strong);
|
||
}
|
||
|
||
.btn-ghost:disabled {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.control-hint {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin: 16px 0 0;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: var(--c-radius-sm);
|
||
background: rgba(2, 6, 23, 0.4);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
color: var(--c-text-secondary);
|
||
}
|
||
|
||
.hint-icon {
|
||
flex-shrink: 0;
|
||
margin-top: 1px;
|
||
color: var(--c-text-muted);
|
||
}
|
||
|
||
/* ---------- 遥测卡 ---------- */
|
||
.telemetry-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.run-badge {
|
||
margin-left: auto;
|
||
padding: 3px 8px;
|
||
border-radius: 6px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.05em;
|
||
border: 1px solid var(--c-border);
|
||
}
|
||
|
||
.badge-run-idle {
|
||
background: var(--c-bg-inset);
|
||
color: var(--c-text-muted);
|
||
}
|
||
|
||
.badge-run-active {
|
||
background: rgba(16, 185, 129, 0.1);
|
||
border-color: rgba(16, 185, 129, 0.25);
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.badge-run-ok {
|
||
background: rgba(16, 185, 129, 0.1);
|
||
border-color: rgba(16, 185, 129, 0.25);
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.badge-run-warn {
|
||
background: rgba(245, 158, 11, 0.1);
|
||
border-color: rgba(245, 158, 11, 0.25);
|
||
color: var(--c-warning-text);
|
||
}
|
||
|
||
.badge-run-error {
|
||
background: rgba(244, 63, 94, 0.1);
|
||
border-color: rgba(244, 63, 94, 0.25);
|
||
color: var(--c-error-text);
|
||
}
|
||
|
||
/* ---------- 统计网格 ---------- */
|
||
.stats-grid {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.stats-primary {
|
||
grid-template-columns: repeat(4, 1fr);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.stats-secondary {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.stat-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 10px 6px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: var(--c-radius-sm);
|
||
background: rgba(2, 6, 23, 0.4);
|
||
}
|
||
|
||
.stat-name {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: var(--c-text-muted);
|
||
}
|
||
|
||
.stat-num {
|
||
font-family: var(--c-mono);
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--c-text-primary);
|
||
}
|
||
|
||
.stat-cell.tone-ok {
|
||
border-color: rgba(16, 185, 129, 0.16);
|
||
background: rgba(16, 185, 129, 0.06);
|
||
}
|
||
|
||
.stat-cell.tone-ok .stat-num {
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.stat-cell.tone-warn {
|
||
border-color: rgba(245, 158, 11, 0.16);
|
||
background: rgba(245, 158, 11, 0.06);
|
||
}
|
||
|
||
.stat-cell.tone-warn .stat-num {
|
||
color: var(--c-warning-text);
|
||
}
|
||
|
||
.stat-cell.tone-purple {
|
||
border-color: rgba(168, 85, 247, 0.16);
|
||
background: rgba(168, 85, 247, 0.06);
|
||
}
|
||
|
||
.stat-cell.tone-purple .stat-num {
|
||
color: var(--c-purple-text);
|
||
}
|
||
|
||
.stat-cell-sm {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 7px 6px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: var(--c-radius-sm);
|
||
background: rgba(2, 6, 23, 0.4);
|
||
}
|
||
|
||
.stat-name-sm {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
color: var(--c-text-muted);
|
||
}
|
||
|
||
.stat-num-sm {
|
||
font-family: var(--c-mono);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--c-text-regular);
|
||
}
|
||
|
||
.stat-cell-sm.tone-error {
|
||
border-color: rgba(244, 63, 94, 0.16);
|
||
background: rgba(244, 63, 94, 0.06);
|
||
}
|
||
|
||
.stat-cell-sm.tone-error .stat-name-sm {
|
||
color: var(--c-error-text);
|
||
}
|
||
|
||
.stat-cell-sm.tone-error .stat-num-sm {
|
||
color: var(--c-error-text);
|
||
}
|
||
|
||
/* ---------- 进度条 ---------- */
|
||
.progress-block {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.progress-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 6px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: var(--c-text-secondary);
|
||
}
|
||
|
||
.progress-pct {
|
||
font-family: var(--c-mono);
|
||
}
|
||
|
||
.progress-pct.tone-ok {
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.progress-pct.tone-warn {
|
||
color: var(--c-warning-text);
|
||
}
|
||
|
||
.progress-pct.tone-error {
|
||
color: var(--c-error-text);
|
||
}
|
||
|
||
.progress-track {
|
||
width: 100%;
|
||
height: 8px;
|
||
border-radius: 999px;
|
||
background: var(--c-bg-inset);
|
||
border: 1px solid var(--c-border);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
border-radius: 999px;
|
||
transition: width 0.3s ease;
|
||
background: linear-gradient(90deg, var(--c-accent), var(--c-teal));
|
||
}
|
||
|
||
.progress-fill.tone-warn {
|
||
background: linear-gradient(90deg, #d97706, var(--c-warning));
|
||
}
|
||
|
||
.progress-fill.tone-error {
|
||
background: linear-gradient(90deg, #e11d48, var(--c-error));
|
||
}
|
||
|
||
/* ---------- 当前文件行 ---------- */
|
||
.current-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
padding: 8px 10px;
|
||
margin-bottom: 12px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: var(--c-radius-sm);
|
||
background: rgba(2, 6, 23, 0.4);
|
||
}
|
||
|
||
.current-tag {
|
||
flex-shrink: 0;
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
background: var(--c-bg-elevated);
|
||
color: var(--c-text-muted);
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.current-file {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-family: var(--c-mono);
|
||
font-size: 11px;
|
||
color: var(--c-text-regular);
|
||
}
|
||
|
||
.current-idle {
|
||
font-size: 11px;
|
||
color: var(--c-text-muted);
|
||
}
|
||
|
||
/* ---------- 完成汇总 ---------- */
|
||
.result-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 14px;
|
||
border-radius: var(--c-radius-sm);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.result-icon {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.result-ok {
|
||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||
background: rgba(16, 185, 129, 0.08);
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.result-warn {
|
||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||
background: rgba(245, 158, 11, 0.08);
|
||
color: var(--c-warning-text);
|
||
}
|
||
|
||
.result-error {
|
||
border: 1px solid rgba(244, 63, 94, 0.25);
|
||
background: rgba(244, 63, 94, 0.08);
|
||
color: var(--c-error-text);
|
||
}
|
||
|
||
/* ---------- 活动记录控制台 ---------- */
|
||
.console-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--c-border);
|
||
border-bottom: none;
|
||
border-radius: var(--c-radius-sm) var(--c-radius-sm) 0 0;
|
||
background: var(--c-bg-inset);
|
||
}
|
||
|
||
.console-legend {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.legend-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.dot-error {
|
||
background: var(--c-error);
|
||
}
|
||
|
||
.dot-warn {
|
||
background: var(--c-warning);
|
||
}
|
||
|
||
.dot-ok {
|
||
background: var(--c-accent);
|
||
}
|
||
|
||
.console-legend-label {
|
||
margin-left: 4px;
|
||
font-family: var(--c-mono);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: var(--c-text-muted);
|
||
}
|
||
|
||
.filter-group {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.filter-btn {
|
||
padding: 3px 8px;
|
||
border: 1px solid transparent;
|
||
border-radius: 6px;
|
||
background: var(--c-bg-panel);
|
||
color: var(--c-text-muted);
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.filter-btn:hover {
|
||
color: var(--c-text-regular);
|
||
}
|
||
|
||
.filter-btn.is-active {
|
||
border-color: rgba(16, 185, 129, 0.25);
|
||
background: rgba(16, 185, 129, 0.1);
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.console-body {
|
||
flex: 1;
|
||
min-height: 220px;
|
||
max-height: 360px;
|
||
overflow-y: auto;
|
||
padding: 12px;
|
||
border: 1px solid var(--c-border);
|
||
border-radius: 0 0 var(--c-radius-sm) var(--c-radius-sm);
|
||
background: rgba(2, 6, 23, 0.7);
|
||
}
|
||
|
||
.console-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
height: 100%;
|
||
min-height: 200px;
|
||
color: var(--c-text-faint);
|
||
text-align: center;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.console-empty-icon {
|
||
color: var(--c-border-strong);
|
||
}
|
||
|
||
.console-empty-hint {
|
||
font-size: 10px;
|
||
color: var(--c-text-faint);
|
||
}
|
||
|
||
.console-lines {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.log-line {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px solid rgba(30, 41, 59, 0.4);
|
||
font-family: var(--c-mono);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.log-line:last-child {
|
||
border-bottom: none;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.log-time {
|
||
flex-shrink: 0;
|
||
color: var(--c-text-faint);
|
||
user-select: none;
|
||
}
|
||
|
||
.log-badge {
|
||
flex-shrink: 0;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.badge-normal {
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.badge-warning {
|
||
color: var(--c-warning-text);
|
||
}
|
||
|
||
.badge-error {
|
||
color: var(--c-error-text);
|
||
}
|
||
|
||
.log-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.log-file {
|
||
margin-right: 6px;
|
||
color: #fff;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.log-msg {
|
||
color: var(--c-text-secondary);
|
||
}
|
||
|
||
.console-note {
|
||
margin: 8px 0 0;
|
||
font-size: 10px;
|
||
color: var(--c-text-faint);
|
||
}
|
||
|
||
/* ---------- 响应式 ---------- */
|
||
@media (max-width: 1024px) {
|
||
.split {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.banner {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.banner-batch {
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.stats-primary {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
|
||
.stats-secondary {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.console-head {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.filter-group {
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 390px) {
|
||
.ingest-view {
|
||
gap: 14px;
|
||
}
|
||
|
||
.panel {
|
||
padding: 14px;
|
||
}
|
||
|
||
.stat-num {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.log-line {
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
</style>
|