Files
MyTool/frontend/src/components/IngestTab.vue
T

1538 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>