PlantAI-rag-new 首次提交:文献知识工作台
- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化 - Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab - 四档响应式(桌面/笔记本/平板/手机)、深浅主题 - 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测 - 真实凭据已脱敏,运行时配置在 backend/.env(不入 git) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
<!-- BenchPanel.vue —— 右工作台四 tab(B3 归属)
|
||||
FR-RETRIEVAL/SOURCE/EXTRACT/KG。类名复用 UI_SPEC §7:bench-panel / bench-tabs / bench-tab / bench-body / tab-pane。
|
||||
折叠(右栏 28px 手柄条)由 B1 AppShell 的 .bench-collapsed 控制;本组件提供 `collapsed` props
|
||||
与 setCollapsed 事件对接 B1 把手;折叠(含 <1024 收起态)时暂停提取轮询(FR-EXTRACT-005)。 -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { BENCH_TABS } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import RetrievalPane from './RetrievalPane.vue';
|
||||
import SourcesPane from './SourcesPane.vue';
|
||||
import ExtractionPane from './ExtractionPane.vue';
|
||||
import KgPane from './KgPane.vue';
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['collapse']);
|
||||
|
||||
const bench = useBenchStore();
|
||||
const collapsedRef = ref(props.collapsed);
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsedRef.value = !collapsedRef.value;
|
||||
bench.setCollapsed(collapsedRef.value);
|
||||
// 收起态(含 <1024 强制收起)时暂停提取轮询(FR-EXTRACT-005)
|
||||
if (collapsedRef.value) bench.pausePolling();
|
||||
emit('collapse', collapsedRef.value);
|
||||
}
|
||||
|
||||
function selectTab(key) {
|
||||
bench.setActiveTab(key);
|
||||
}
|
||||
|
||||
// 工作台按钮(移动端全屏覆盖层入口)需要的来源数角标(FR-RESP-003)
|
||||
const sourceCount = computed(() => {
|
||||
const s = bench.sources;
|
||||
return s ? s.length : 0;
|
||||
});
|
||||
defineExpose({ sourceCount, activeTab: computed(() => bench.activeTab) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="bench-panel" :class="{ 'collapsed-local': collapsedRef }">
|
||||
<div class="inner">
|
||||
<div class="bench-tabs" role="tablist">
|
||||
<button
|
||||
v-for="tb in BENCH_TABS"
|
||||
:key="tb.key"
|
||||
role="tab"
|
||||
:aria-selected="bench.activeTab === tb.key"
|
||||
:class="['bench-tab', { on: bench.activeTab === tb.key }]"
|
||||
@click="selectTab(tb.key)"
|
||||
>{{ tb.label }}</button>
|
||||
</div>
|
||||
<div class="bench-body">
|
||||
<div v-if="bench.activeTab === 'retrieval'" class="tab-pane"><RetrievalPane /></div>
|
||||
<div v-else-if="bench.activeTab === 'sources'" class="tab-pane"><SourcesPane /></div>
|
||||
<div v-else-if="bench.activeTab === 'extract'" class="tab-pane"><ExtractionPane /></div>
|
||||
<div v-else class="tab-pane"><KgPane /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bench-handle">
|
||||
<button
|
||||
class="bench-handle-btn"
|
||||
type="button"
|
||||
:aria-label="collapsedRef ? BENCH_LABELS.benchExpand : BENCH_LABELS.benchCollapse"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 B1 base.css 的 .bench-* 类;此处仅补 B3 专属细节 */
|
||||
.bench-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--pa-surface-1);
|
||||
border-left: 1px solid var(--pa-hairline);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s var(--pa-ease-out);
|
||||
}
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 380px;
|
||||
transition: opacity 0.2s var(--pa-ease);
|
||||
flex: 1;
|
||||
}
|
||||
.bench-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 10px 14px 0;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.bench-tab {
|
||||
position: relative;
|
||||
padding: 8px 10px 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s var(--pa-ease);
|
||||
}
|
||||
.bench-tab:hover { color: var(--pa-ink); }
|
||||
.bench-tab.on { color: var(--pa-ink); font-weight: 600; }
|
||||
.bench-tab.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background: var(--pa-gradient);
|
||||
border-radius: 2px;
|
||||
animation: fade-slide 0.25s var(--pa-ease-out);
|
||||
}
|
||||
.bench-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
.tab-pane { animation: fade-slide 0.2s var(--pa-ease-out); }
|
||||
.bench-handle {
|
||||
display: none;
|
||||
width: 28px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.bench-handle-btn {
|
||||
width: 28px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bench-handle-btn:hover { color: var(--pa-ink); }
|
||||
/* 折叠态由 AppShell 的 .bench-collapsed 控制(B1),此处以 collapsed prop 同步 */
|
||||
.collapsed-local .inner { opacity: 0; pointer-events: none; }
|
||||
.collapsed-local .bench-handle { display: flex; }
|
||||
.collapsed-local .bench-handle svg { transform: rotate(180deg); }
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!-- ChainGroup.vue —— 证据链分组(B3 归属)
|
||||
FR-SOURCE-004:按 chain_id 分组,可展开为逐跳列表:源实体 —[关系]→ 目标实体,
|
||||
每跳附 paper_id/PMID,跳数以徽章显示(labels.chainBadge)。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
chain: { type: Object, required: true },
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
/** 归一化 hop 列表:兼容 {hops:[…]} 与直接数组两种形态(API.md §9) */
|
||||
const hops = computed(() => {
|
||||
const c = props.chain || {};
|
||||
if (Array.isArray(c)) return c;
|
||||
return Array.isArray(c.hops) ? c.hops : [];
|
||||
});
|
||||
|
||||
const hopCount = computed(() => hops.value.length);
|
||||
|
||||
/** 每跳附 paper_id/PMID(真实 hop 字段见 API.md §7 链条容错) */
|
||||
function hopRef(h) {
|
||||
const refs = [];
|
||||
if (h.pmid) refs.push(`PMID:${h.pmid}`);
|
||||
else if (h.paper_id) refs.push(h.paper_id);
|
||||
return refs.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chain-group">
|
||||
<button class="chain-head" type="button" @click="emit('toggle')">
|
||||
<svg class="chain-chev" :class="{ open }" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
<span class="chain-id">{{ chain.chain_id || 'chain' }}</span>
|
||||
<span class="badge chain-badge"><span class="dot"></span>{{ hopCount }} {{ T.chainBadge }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="chain-body">
|
||||
<div v-for="(h, i) in hops" :key="i" class="hop-item">
|
||||
<span class="hop-idx">{{ i + 1 }}</span>
|
||||
<span class="hop-text">
|
||||
<span class="hop-ent">{{ h.source || h.from_name }}</span>
|
||||
<span class="hop-rel">—[{{ h.relation || h.target_relation || '?' }}]→</span>
|
||||
<span class="hop-ent">{{ h.target || h.to_name }}</span>
|
||||
</span>
|
||||
<span v-if="hopRef(h)" class="hop-ref">{{ hopRef(h) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chain-group {
|
||||
margin-bottom: 6px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chain-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--pa-ink);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.chain-head:hover { background: var(--pa-surface-2); }
|
||||
.chain-chev {
|
||||
color: var(--pa-ink-subtle);
|
||||
transition: transform 0.25s var(--pa-ease);
|
||||
flex: none;
|
||||
}
|
||||
.chain-chev.open { transform: rotate(90deg); }
|
||||
.chain-id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.chain-badge {
|
||||
color: var(--pa-ink-subtle);
|
||||
background: var(--pa-surface-3);
|
||||
flex: none;
|
||||
}
|
||||
.chain-badge .dot { display: none; }
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.chain-body {
|
||||
border-top: 1px solid var(--pa-hairline);
|
||||
padding: 6px 10px 8px;
|
||||
animation: fade-slide 0.2s var(--pa-ease-out);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.hop-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hop-idx {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.hop-text { min-width: 0; word-break: break-word; }
|
||||
.hop-ent { color: var(--pa-ink-muted); font-family: var(--pa-font-mono); }
|
||||
.hop-rel { color: var(--pa-g-to); font-family: var(--pa-font-mono); margin: 0 2px; }
|
||||
.hop-ref { color: var(--pa-ink-subtle); font-size: 10.5px; font-family: var(--pa-font-mono); flex: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
<!-- ExtractionPane.vue —— 工作台 Tab3 提取任务(B3 归属)
|
||||
FR-EXTRACT-001~006:prog-item 列表、详情展开(stats + plan 仅 completed)、run-log mono 滚动贴底、
|
||||
上传(PDF ≤50MB 校验)、5s 轮询(存在 running 时;tab 切走由 BenchPanel 暂停)、离线降级。 -->
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { getRunLog, uploadPaper } from '../../api/extraction.js';
|
||||
import { STATUS_LABEL, T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import ProgressItem from './ProgressItem.vue';
|
||||
|
||||
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const bench = useBenchStore();
|
||||
const openId = ref(null);
|
||||
const fileInput = ref(null);
|
||||
const uploadMsg = ref('');
|
||||
const uploadOk = ref(false);
|
||||
const uploading = ref(false);
|
||||
const runLog = ref('');
|
||||
const logOpen = ref(false);
|
||||
const logError = ref(false);
|
||||
const logBox = ref(null);
|
||||
|
||||
const jobs = computed(() => bench.jobs);
|
||||
const detail = computed(() => (openId.value ? bench.getJobDetail(openId.value) : null));
|
||||
const detailLoading = computed(() => bench.detailLoading);
|
||||
const detailError = computed(() => bench.detailError);
|
||||
|
||||
function toggleJob(job) {
|
||||
if (openId.value === job.job_id) {
|
||||
openId.value = null;
|
||||
return;
|
||||
}
|
||||
openId.value = job.job_id;
|
||||
bench.loadJobDetail(job.job_id).catch(() => {});
|
||||
}
|
||||
|
||||
function statsRows(job) {
|
||||
const s = job?.stats || job || {};
|
||||
return [
|
||||
['stage', s.stage_count],
|
||||
['species', s.species_count],
|
||||
['figure_table', s.figure_table_caption_count],
|
||||
['figure_table_stage', s.figure_table_stage_count],
|
||||
].filter(([, v]) => v != null);
|
||||
}
|
||||
|
||||
async function fetchRunLog() {
|
||||
logError.value = false;
|
||||
try {
|
||||
const data = await getRunLog();
|
||||
runLog.value = data?.text || '';
|
||||
if (logOpen.value) await scrollLogToBottom();
|
||||
} catch {
|
||||
logError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollLogToBottom() {
|
||||
await nextTick();
|
||||
if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight;
|
||||
}
|
||||
|
||||
function toggleLog() {
|
||||
logOpen.value = !logOpen.value;
|
||||
if (logOpen.value && !runLog.value) fetchRunLog();
|
||||
else if (logOpen.value) scrollLogToBottom();
|
||||
}
|
||||
|
||||
// ---- 上传(FR-EXTRACT-004:仅 PDF,单文件 ≤50MB;失败显示明确文案) ----
|
||||
function pickFile() {
|
||||
uploadMsg.value = '';
|
||||
uploadOk.value = false;
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFileChange(e) {
|
||||
uploadMsg.value = '';
|
||||
uploadOk.value = false;
|
||||
const file = e.target.files?.[0];
|
||||
if (e.target) e.target.value = '';
|
||||
if (!file) return;
|
||||
const isPdf = file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
|
||||
if (!isPdf) {
|
||||
uploadMsg.value = T.extractUploadLimit;
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
uploadMsg.value = T.extractUploadLimit;
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
await uploadPaper(file);
|
||||
uploadOk.value = true;
|
||||
uploadMsg.value = '';
|
||||
await bench.loadJobs({ force: true }).catch(() => {});
|
||||
if (bench.isPolling()) bench.startPolling();
|
||||
} catch (err) {
|
||||
// 上游 message 已中文化,直接展示(PRD §2.2)
|
||||
uploadMsg.value = (T.extractUploadFail || '上传失败:') + (err?.message || err?.error?.message || String(err));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 轮询生命周期(FR-EXTRACT-005)----
|
||||
onMounted(() => {
|
||||
bench.loadJobs({ force: true })
|
||||
.then(() => {
|
||||
if (!bench.hasActiveJobs) return;
|
||||
if (!bench.isPolling()) bench.startPolling();
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => bench.hasActiveJobs,
|
||||
(has) => {
|
||||
if (has && !bench.isPolling() && !bench.pollPaused && bench.activeTab === 'extract') {
|
||||
bench.startPolling();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function retryLoad() {
|
||||
bench.loadJobs({ force: true }).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="extraction-pane">
|
||||
<!-- 离线降级(FR-EXTRACT-006,仅此 tab 受影响) -->
|
||||
<div v-if="bench.jobsError" class="offline-block">
|
||||
<span class="offline-dot"></span>
|
||||
<span class="offline-text">{{ T.extractionDown }}</span>
|
||||
<button class="retry-btn" @click="retryLoad">{{ T.retry }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 上传入口(FR-EXTRACT-004) -->
|
||||
<div class="upload-row">
|
||||
<button class="upload-btn" :disabled="uploading" @click="pickFile">{{ T.extractUpload }}</button>
|
||||
<span class="upload-limit">{{ T.extractUploadLimit }}</span>
|
||||
<input ref="fileInput" type="file" accept="application/pdf,.pdf" class="hidden-input" @change="onFileChange" />
|
||||
</div>
|
||||
<div v-if="uploading" class="upload-msg">{{ T.loading }}</div>
|
||||
<div v-else-if="uploadMsg" class="upload-msg err">{{ uploadMsg }}</div>
|
||||
<div v-else-if="uploadOk" class="upload-msg ok">{{ '✓' }}</div>
|
||||
|
||||
<!-- 运行日志(FR-EXTRACT-003,mono 滚动贴底) -->
|
||||
<button class="log-toggle" @click="toggleLog">
|
||||
<svg class="log-chev" :class="{ open: logOpen }" width="11" height="11" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
<span>{{ BENCH_LABELS.extractRunLog }}</span>
|
||||
</button>
|
||||
<div v-if="logOpen" class="log-wrap">
|
||||
<div v-if="logError" class="offline-text">{{ T.upstreamError }}</div>
|
||||
<div v-else ref="logBox" class="run-log">{{ runLog || T.loading }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div v-if="!jobs.length && !bench.jobsLoading" class="empty-note">{{ T.extractEmpty }}</div>
|
||||
|
||||
<!-- 任务列表(FR-EXTRACT-001) -->
|
||||
<div v-for="j in jobs" :key="j.job_id" class="job-block">
|
||||
<ProgressItem :job="j" :open="openId === j.job_id" @toggle="toggleJob(j)" />
|
||||
|
||||
<!-- 详情展开(FR-EXTRACT-002) -->
|
||||
<div v-if="openId === j.job_id" class="job-detail fade-slide">
|
||||
<div v-if="detailLoading" class="detail-loading">{{ T.loading }}</div>
|
||||
<div v-else-if="detailError || !detail" class="detail-error">{{ T.upstreamError }}
|
||||
<button class="retry-btn" @click="bench.loadJobDetail(j.job_id).catch(() => {})">{{ T.retry }}</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="detail-row status-row">
|
||||
<span class="dl-label">{{ T.labelGroup }}</span>
|
||||
<span class="badge" :class="detail.status"><span class="dot"></span>{{ STATUS_LABEL[detail.status] || detail.status }}</span>
|
||||
</div>
|
||||
<div v-if="detail.message" class="detail-row">
|
||||
<span class="dl-label">message</span>
|
||||
<span class="dl-value">{{ detail.message }}</span>
|
||||
</div>
|
||||
<div class="detail-stats">
|
||||
<div v-for="[k, v] in statsRows(detail)" :key="k" class="stat-item">
|
||||
<span class="stat-v">{{ v }}</span>
|
||||
<span class="stat-k">{{ k }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- plan 仅 completed 有(FR-EXTRACT-002:未完成置 null 不显示且不报错) -->
|
||||
<template v-if="detail.plan">
|
||||
<div class="dl-label">plan</div>
|
||||
<div class="plan-code">
|
||||
<pre>{{ JSON.stringify(detail.plan, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.extraction-pane { display: flex; flex-direction: column; }
|
||||
.offline-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255, 92, 122, 0.08);
|
||||
border: 1px solid rgba(255, 92, 122, 0.35);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pa-error); flex: none; }
|
||||
.offline-text { font-size: 12px; color: var(--pa-error); }
|
||||
.retry-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
.retry-btn:hover { color: var(--pa-ink); }
|
||||
.upload-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.upload-btn {
|
||||
padding: 5px 14px;
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--pa-gradient);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-btn:hover { box-shadow: 0 0 24px rgba(0, 212, 255, 0.4); transform: translateY(-1px); }
|
||||
.upload-btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; transform: none; }
|
||||
.upload-limit { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.hidden-input { display: none; }
|
||||
.upload-msg { font-size: 11.5px; margin: 4px 0 8px; color: var(--pa-ink-subtle); }
|
||||
.upload-msg.err { color: var(--pa-error); word-break: break-word; }
|
||||
.upload-msg.ok { color: var(--pa-success); }
|
||||
.log-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.log-toggle:hover { color: var(--pa-ink); }
|
||||
.log-chev { transition: transform 0.25s var(--pa-ease); }
|
||||
.log-chev.open { transform: rotate(90deg); }
|
||||
.log-wrap { margin-bottom: 10px; }
|
||||
.run-log {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.8;
|
||||
color: var(--pa-ink-muted);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 8px 10px;
|
||||
max-height: 130px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.job-block { margin-bottom: 4px; }
|
||||
.job-detail {
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
padding: 10px 12px;
|
||||
margin: -8px 0 14px;
|
||||
}
|
||||
.fade-slide { animation: fade-slide 0.2s var(--pa-ease-out); }
|
||||
.detail-loading, .detail-error { font-size: 12px; color: var(--pa-ink-subtle); padding: 4px 0; }
|
||||
.detail-error { color: var(--pa-error); display: flex; align-items: center; gap: 8px; }
|
||||
.detail-error .retry-btn { margin-left: 0; }
|
||||
.detail-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; font-size: 12px; }
|
||||
.dl-label { font-size: 11px; letter-spacing: 0.5px; color: var(--pa-ink-subtle); margin: 8px 0 5px; flex: none; }
|
||||
.status-row .dl-label { margin: 0; }
|
||||
.dl-value { color: var(--pa-ink-muted); word-break: break-word; }
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.badge.running, .badge.queued { color: var(--pa-warning); background: rgba(245, 184, 92, 0.12); }
|
||||
.badge.running .dot { animation: pulse 2s infinite; }
|
||||
.badge.completed { color: var(--pa-success); background: rgba(46, 211, 160, 0.12); }
|
||||
.badge.failed { color: var(--pa-error); background: rgba(255, 92, 122, 0.12); }
|
||||
.detail-stats { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
background: var(--pa-surface-2);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.stat-v { font-family: var(--pa-font-mono); font-size: 12.5px; color: var(--pa-ink); }
|
||||
.stat-k { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.plan-code {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink-muted);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 8px 10px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.plan-code pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<!-- KgEntityDetail.vue —— 实体详情侧滑面板(B3 归属)
|
||||
FR-KG-003:点击节点 → 侧滑详情面板展示实体属性;Neo4j 可用时调 GET /api/kg/entities/{name}
|
||||
拉全量属性;不可用时仅显示消息内已有属性。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { getEntity } from '../../api/kg.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null }, // {id,label,type} 或消息 graph 节点
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
const extra = ref(null); // Neo4j 全量属性
|
||||
|
||||
const localProps = computed(() => {
|
||||
const n = props.node || {};
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(n)) {
|
||||
if (k === 'id' || k === 'label' || k === 'type') continue;
|
||||
if (v == null || v === '') continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const propEntries = computed(() => {
|
||||
const merged = { ...localProps.value, ...(extra.value?.properties || {}) };
|
||||
return Object.entries(merged).filter(([, v]) => v != null && v !== '');
|
||||
});
|
||||
|
||||
const nodeKey = computed(() => (props.node?.id != null ? String(props.node.id) : props.node?.label));
|
||||
|
||||
onMounted(() => {
|
||||
fetchExtra();
|
||||
});
|
||||
watch(nodeKey, () => {
|
||||
extra.value = null;
|
||||
failed.value = false;
|
||||
fetchExtra();
|
||||
});
|
||||
|
||||
async function fetchExtra() {
|
||||
const n = props.node;
|
||||
if (!n) return;
|
||||
const key = n.id != null ? String(n.id) : n.label;
|
||||
if (!key) return;
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const data = await getEntity(key);
|
||||
extra.value = data;
|
||||
} catch {
|
||||
failed.value = true; // Neo4j 不可用:仍显示消息内已有属性(FR-KG-003)
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(v) {
|
||||
if (v && typeof v === 'object') {
|
||||
try { return JSON.stringify(v); } catch { return String(v); }
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-entity-detail">
|
||||
<div class="detail-head">
|
||||
<span class="detail-title">{{ T.kgEntityDetail }}</span>
|
||||
<button class="close-btn" type="button" :aria-label="BENCH_LABELS.close" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<div v-if="node" class="detail-body">
|
||||
<div class="ent-name">{{ node.label || node.id }}</div>
|
||||
<div class="ent-meta">
|
||||
<span class="ent-type">{{ node.type }}</span>
|
||||
<span v-if="node.id && node.id !== node.label" class="ent-id">{{ node.id }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="detail-note">{{ T.loading }}</div>
|
||||
<div class="prop-list">
|
||||
<div v-for="[k, v] in propEntries" :key="k" class="prop-row">
|
||||
<span class="prop-k">{{ k }}</span>
|
||||
<span class="prop-v">{{ fmt(v) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!propEntries.length && !loading" class="detail-note">—</div>
|
||||
</div>
|
||||
<div v-else class="detail-body">
|
||||
<div class="detail-note">{{ T.upstreamError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-entity-detail {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
right: 0;
|
||||
bottom: var(--statusbar-h);
|
||||
width: 300px;
|
||||
max-width: 90vw;
|
||||
background: var(--pa-surface-1);
|
||||
border-left: 1px solid var(--pa-hairline-strong);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slide-in 0.3s var(--pa-ease-out);
|
||||
box-shadow: -8px 0 24px rgba(10, 10, 16, 0.35);
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(24px); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.detail-title { font-size: 12px; font-weight: 600; }
|
||||
.close-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
}
|
||||
.close-btn:hover { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.detail-body { flex: 1; overflow-y: auto; padding: 14px; }
|
||||
.ent-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ent-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||
.ent-type {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
background: var(--pa-surface-3);
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.ent-id { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.detail-note { font-size: 12px; color: var(--pa-ink-subtle); padding: 8px 0; }
|
||||
.prop-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.prop-row { display: flex; gap: 8px; align-items: baseline; font-size: 12px; line-height: 1.5; }
|
||||
.prop-k {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--pa-g-to);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 1px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.prop-v { color: var(--pa-ink-muted); word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!-- KgGraphView.vue —— 手写 SVG 力导向图谱渲染器(B3 归属)
|
||||
FR-KG-001/002/006:节点圆点+标签、渐变边+关系名、type→稳定调色板、图例;
|
||||
缩放 0.3–3×、拖拽平移、双击复位、触摸双指缩放/单指拖拽。
|
||||
节点数超过上限时由 KgPane 决定降级列表视图(本组件仍可渲染,由父层把控)。 -->
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { forceLayout, buildPalette } from './graphLayout.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, default: () => [] },
|
||||
edges: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 320 },
|
||||
});
|
||||
const emit = defineEmits(['node-click']);
|
||||
|
||||
const viewW = ref(800);
|
||||
const viewH = ref(600);
|
||||
const scale = ref(1);
|
||||
const tx = ref(0);
|
||||
const ty = ref(0);
|
||||
const container = ref(null);
|
||||
|
||||
const layout = computed(() =>
|
||||
forceLayout({ nodes: props.nodes, edges: props.edges }, { width: viewW.value, height: viewH.value }),
|
||||
);
|
||||
const colorOf = computed(() => {
|
||||
const types = (props.nodes || []).map((n) => n?.type || 'unknown');
|
||||
return buildPalette(types);
|
||||
});
|
||||
const renderEdges = computed(() => layout.value.edges);
|
||||
const renderNodes = computed(() => layout.value.nodes);
|
||||
const nodeById = computed(() => {
|
||||
const m = new Map();
|
||||
for (const n of props.nodes || []) m.set(String(n.id), n);
|
||||
return m;
|
||||
});
|
||||
const viewBox = computed(() => `0 0 ${viewW.value} ${viewH.value}`);
|
||||
|
||||
// 图例:type → 颜色(按名称稳定排序)
|
||||
const legendItems = computed(() => {
|
||||
const counts = new Map();
|
||||
for (const n of props.nodes || []) {
|
||||
const t = n.type || 'unknown';
|
||||
counts.set(t, (counts.get(t) || 0) + 1);
|
||||
}
|
||||
return [...counts.keys()].sort().map((t) => ({ type: t, color: colorOf.value.get(t), count: counts.get(t) }));
|
||||
});
|
||||
|
||||
const transform = computed(() => `translate(${tx.value}px, ${ty.value}px) scale(${scale.value})`);
|
||||
|
||||
const clampScale = (v) => Math.min(3, Math.max(0.3, v));
|
||||
|
||||
// ---- 手势(指针事件统一处理鼠标与触摸)----
|
||||
const pointers = new Map();
|
||||
const pan = ref(null); // {x, y, tx, ty}
|
||||
|
||||
function setTransform(ns, nx, ny) {
|
||||
scale.value = clampScale(ns);
|
||||
tx.value = nx;
|
||||
ty.value = ny;
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
container.value?.setPointerCapture?.(e.pointerId);
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
// 双指开始:记录初始距离与变换
|
||||
const [p1, p2] = [...pointers.values()];
|
||||
pan.value = {
|
||||
x: (p1.x + p2.x) / 2,
|
||||
y: (p1.y + p2.y) / 2,
|
||||
tx: tx.value,
|
||||
ty: ty.value,
|
||||
dist: Math.hypot(p1.x - p2.x, p1.y - p2.y),
|
||||
scale: scale.value,
|
||||
};
|
||||
} else if (pointers.size === 1) {
|
||||
pan.value = { x: e.clientX, y: e.clientY, tx: tx.value, ty: ty.value, dist: null, scale: scale.value };
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size >= 2) {
|
||||
// 双指:缩放 + 平移(FR-KG-006)
|
||||
const [p1, p2] = [...pointers.values()];
|
||||
const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||
if (pan.value && pan.value.dist != null && pan.value.dist > 0) {
|
||||
const ns = clampScale(pan.value.scale * (dist / pan.value.dist));
|
||||
const cx = (p1.x + p2.x) / 2;
|
||||
const cy = (p1.y + p2.y) / 2;
|
||||
const nx = pan.value.tx + (cx - pan.value.x);
|
||||
const ny = pan.value.ty + (cy - pan.value.y);
|
||||
setTransform(ns, nx, ny);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pointers.size === 1 && pan.value) {
|
||||
tx.value = pan.value.tx + (e.clientX - pan.value.x);
|
||||
ty.value = pan.value.ty + (e.clientY - pan.value.y);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size === 1) {
|
||||
const [p] = [...pointers.values()];
|
||||
pan.value = { x: p.x, y: p.y, tx: tx.value, ty: ty.value, dist: null, scale: scale.value };
|
||||
} else if (pointers.size === 0) {
|
||||
pan.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚轮缩放(以鼠标位置为锚点)
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
const rect = container.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const ns = clampScale(scale.value * factor);
|
||||
const px = e.clientX - rect.left;
|
||||
const py = e.clientY - rect.top;
|
||||
const nx = px - ((px - tx.value) * ns) / scale.value;
|
||||
const ny = py - ((py - ty.value) * ns) / scale.value;
|
||||
setTransform(ns, nx, ny);
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
setTransform(1, 0, 0);
|
||||
}
|
||||
|
||||
function onClickNode(n) {
|
||||
emit('node-click', nodeById.value.get(n.id) || { id: n.id, label: n.label, type: n.type });
|
||||
}
|
||||
|
||||
// 挂载时测量容器宽度以设定视口宽
|
||||
let ro = null;
|
||||
onMounted(() => {
|
||||
const el = container.value;
|
||||
if (!el) return;
|
||||
const apply = () => {
|
||||
const w = el.clientWidth || 348;
|
||||
viewW.value = Math.max(320, Math.round(w * 2));
|
||||
viewH.value = Math.max(300, Math.round(props.height * 2));
|
||||
};
|
||||
apply();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(apply);
|
||||
ro.observe(el);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-graph">
|
||||
<div
|
||||
ref="container"
|
||||
class="graph-canvas"
|
||||
:style="{ height: height + 'px' }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@wheel.prevent="onWheel"
|
||||
@dblclick="resetView"
|
||||
>
|
||||
<svg class="graph-svg" :viewBox="viewBox" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="pa-edge-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5E6AD2" />
|
||||
<stop offset="100%" stop-color="#00D4FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g :style="{ transform: transform, transformOrigin: '0 0' }">
|
||||
<g v-for="(e, i) in renderEdges" :key="'e' + i" class="edge-g">
|
||||
<line
|
||||
:x1="renderNodes.find(n => n.id === e.source)?.x ?? 0"
|
||||
:y1="renderNodes.find(n => n.id === e.source)?.y ?? 0"
|
||||
:x2="renderNodes.find(n => n.id === e.target)?.x ?? 0"
|
||||
:y2="renderNodes.find(n => n.id === e.target)?.y ?? 0"
|
||||
stroke="url(#pa-edge-grad)"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.65"
|
||||
/>
|
||||
<text
|
||||
v-if="e.relation"
|
||||
class="edge-label"
|
||||
:x="((renderNodes.find(n => n.id === e.source)?.x ?? 0) + (renderNodes.find(n => n.id === e.target)?.x ?? 0)) / 2"
|
||||
:y="((renderNodes.find(n => n.id === e.source)?.y ?? 0) + (renderNodes.find(n => n.id === e.target)?.y ?? 0)) / 2"
|
||||
>{{ e.relation }}</text>
|
||||
</g>
|
||||
<g v-for="n in renderNodes" :key="'n' + n.id" class="node-g" @click="onClickNode(n)">
|
||||
<circle :cx="n.x" :cy="n.y" :r="7" :fill="colorOf.get(n.type)" stroke="var(--pa-surface-1)" stroke-width="1.5" />
|
||||
<text :x="n.x" :y="n.y + 3.2" text-anchor="middle" class="node-label">{{ n.label }}</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="graph-tools">
|
||||
<button class="reset-btn" type="button" :title="BENCH_LABELS.resetView" @click="resetView">⟲</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="legendItems.length" class="kg-legend">
|
||||
<span v-for="l in legendItems" :key="l.type" class="lg-item">
|
||||
<span class="lg-dot" :style="{ background: l.color }"></span>
|
||||
<span class="lg-type">{{ l.type }}</span>
|
||||
<span class="lg-count">{{ l.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-graph { display: flex; flex-direction: column; }
|
||||
.graph-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
background: var(--pa-code-bg);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.graph-canvas:active { cursor: grabbing; }
|
||||
.graph-svg { width: 100%; height: 100%; display: block; }
|
||||
.graph-svg :deep(g) { transition: transform 0.08s linear; }
|
||||
.node-g { cursor: pointer; }
|
||||
.node-g circle { transition: fill 0.15s var(--pa-ease); }
|
||||
.node-g:hover circle { stroke: var(--pa-ink); stroke-width: 2; }
|
||||
.node-label {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
fill: var(--pa-ink);
|
||||
paint-order: stroke;
|
||||
stroke: var(--pa-code-bg);
|
||||
stroke-width: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edge-label {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 9px;
|
||||
fill: var(--pa-ink-subtle);
|
||||
paint-order: stroke;
|
||||
stroke: var(--pa-code-bg);
|
||||
stroke-width: 2.5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.graph-tools { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; }
|
||||
.reset-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-1);
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.reset-btn:hover { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.kg-legend { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.lg-item { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--pa-ink-muted); }
|
||||
.lg-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.lg-type { font-family: var(--pa-font-ui); }
|
||||
.lg-count { font-family: var(--pa-font-mono); color: var(--pa-ink-subtle); font-size: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<!-- KgPane.vue —— 工作台 Tab4 知识图谱(B3 归属)
|
||||
FR-KG-001~008:答案图谱渲染(graph 块)、节点着色图例、节点详情侧滑、实体搜索、
|
||||
邻域深度 1/2、离线降级(答案图谱仍可用)、空态、>50 节点(移动端 >30)降级列表视图。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { searchEntities, getNeighborhood } from '../../api/kg.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import KgGraphView from './KgGraphView.vue';
|
||||
import KgEntityDetail from './KgEntityDetail.vue';
|
||||
|
||||
const bench = useBenchStore();
|
||||
|
||||
const query = ref('');
|
||||
const results = ref([]);
|
||||
const searching = ref(false);
|
||||
const searchError = ref(false);
|
||||
const detailNode = ref(null);
|
||||
const graphData = ref(null); // 当前渲染的 {nodes, edges}(答案图谱或邻域结果)
|
||||
const graphSource = ref(null); // 'answer' | 'neighborhood'
|
||||
const listModeForced = ref(false);
|
||||
|
||||
const isMobile = () => (typeof window !== 'undefined' ? window.innerWidth < 768 : false);
|
||||
|
||||
// 答案图谱(永远可用,FR-KG-007)
|
||||
const answerGraph = computed(() => {
|
||||
const g = bench.currentGraph;
|
||||
if (!g) return null;
|
||||
const nodes = Array.isArray(g.nodes) ? g.nodes : [];
|
||||
const edges = Array.isArray(g.edges) ? g.edges : [];
|
||||
return nodes.length ? { nodes, edges } : null;
|
||||
});
|
||||
|
||||
// 节点上限:桌面 50 / 移动端 30(FR-KG-001、FR-RESP-012)
|
||||
const nodeLimit = computed(() => bench.kgNodeLimit || 50);
|
||||
const isDegradedList = computed(() => {
|
||||
if (listModeForced.value) return true;
|
||||
const g = graphData.value;
|
||||
return !!g && g.nodes.length > nodeLimit.value;
|
||||
});
|
||||
|
||||
const depth = ref(1);
|
||||
const neighborhoodMeta = ref(null);
|
||||
|
||||
const itemsWithEdges = computed(() => {
|
||||
const g = graphData.value;
|
||||
if (!g) return [];
|
||||
const edges = g.edges || [];
|
||||
return g.nodes.map((n) => {
|
||||
const related = edges.filter((e) => e.source === n.id || e.target === n.id);
|
||||
return { ...n, edgeCount: related.length, relations: [...new Set(related.map((e) => e.relation || e.label))] };
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isMobile()) bench.setKgNodeLimit(30);
|
||||
// 答案图谱为空时,若 Neo4j 可用可自由浏览;状态探测放 kg tab 激活时
|
||||
if (bench.kgStatus == null) {
|
||||
bench.checkKgStatus().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 切会话/切消息后重置浏览态(答案图谱跟随变化)
|
||||
watch(answerGraph, (g) => {
|
||||
if (g) {
|
||||
graphData.value = g;
|
||||
graphSource.value = 'answer';
|
||||
listModeForced.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 实体搜索(FR-KG-004,精确/前缀匹配,无全文索引)----
|
||||
async function onSearch() {
|
||||
const q = query.value.trim();
|
||||
if (!q) return;
|
||||
if (!bench.kgReady) {
|
||||
searchError.value = true;
|
||||
return;
|
||||
}
|
||||
searching.value = true;
|
||||
searchError.value = false;
|
||||
try {
|
||||
const data = await searchEntities(q, 20);
|
||||
results.value = data?.results || [];
|
||||
} catch {
|
||||
searchError.value = true;
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 邻域展开(FR-KG-005,depth 1/2 默认 1;两跳约 13s 需提示较慢)----
|
||||
const loadingNeighborhood = ref(false);
|
||||
const neighborhoodError = ref(false);
|
||||
const slowNotice = ref(false);
|
||||
|
||||
async function loadNeighborhood(id, name) {
|
||||
if (!bench.kgReady) {
|
||||
neighborhoodError.value = true;
|
||||
return;
|
||||
}
|
||||
loadingNeighborhood.value = true;
|
||||
neighborhoodError.value = false;
|
||||
slowNotice.value = false;
|
||||
try {
|
||||
const data = await getNeighborhood(id, { depth: depth.value, limit: 50 });
|
||||
graphData.value = { nodes: data.nodes || [], edges: data.edges || [] };
|
||||
graphSource.value = 'neighborhood';
|
||||
neighborhoodMeta.value = data;
|
||||
results.value = [];
|
||||
slowNotice.value = depth.value === 2;
|
||||
} catch {
|
||||
neighborhoodError.value = true;
|
||||
} finally {
|
||||
loadingNeighborhood.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectResult(r) {
|
||||
detailNode.value = null;
|
||||
loadNeighborhood(r.id, r.name);
|
||||
}
|
||||
|
||||
function onNodeClick(n) {
|
||||
detailNode.value = n;
|
||||
}
|
||||
|
||||
function onDepthChange(v) {
|
||||
depth.value = v;
|
||||
const meta = neighborhoodMeta.value;
|
||||
if (meta?.center) loadNeighborhood(meta.center, meta.center_name);
|
||||
}
|
||||
|
||||
// 离线态:Neo4j 不可用 → 搜索与邻域禁用(FR-KG-007)
|
||||
const kgDown = computed(() => !bench.kgReady && bench.kgStatus != null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-pane">
|
||||
<!-- 离线降级(FR-KG-007:答案图谱仍可用) -->
|
||||
<div v-if="kgDown" class="offline-block">
|
||||
<span class="offline-dot"></span>
|
||||
<span class="offline-text">{{ T.kgDown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 答案图谱 / 邻域结果 -->
|
||||
<template v-if="graphData && graphData.nodes.length">
|
||||
<div v-if="isDegradedList" class="degrade-note">
|
||||
{{ BENCH_LABELS.kgDegradedListPrefix }} {{ nodeLimit }}{{ BENCH_LABELS.kgDegradedListSuffix }}
|
||||
</div>
|
||||
<template v-if="isDegradedList">
|
||||
<div class="node-list">
|
||||
<div v-for="n in itemsWithEdges" :key="n.id" class="node-list-item" @click="onNodeClick(n)">
|
||||
<span class="nl-name">{{ n.label }}</span>
|
||||
<span class="nl-meta">
|
||||
<span class="nl-type">{{ n.type }}</span>
|
||||
<span v-if="n.edgeCount" class="nl-edges">{{ n.edgeCount }} {{ BENCH_LABELS.kgRelationUnit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<KgGraphView :nodes="graphData.nodes" :edges="graphData.edges" :height="320" @node-click="onNodeClick" />
|
||||
</template>
|
||||
<div v-if="graphSource === 'neighborhood'" class="nb-meta">
|
||||
<span class="nb-center">{{ neighborhoodMeta?.center_name }}</span>
|
||||
<span class="nb-count">{{ graphData.nodes.length }} {{ BENCH_LABELS.kgNodeUnit }} · {{ (graphData.edges || []).length }} {{ BENCH_LABELS.kgEdgeUnit }}</span>
|
||||
<span v-if="slowNotice" class="nb-slow">{{ BENCH_LABELS.kgDepth2NeighborhoodSlow }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空态(FR-KG-008,不显示空画布) -->
|
||||
<div v-else-if="!loadingNeighborhood" class="empty-note">{{ T.kgEmpty }}</div>
|
||||
|
||||
<!-- 自由浏览区(FR-KG-004/005) -->
|
||||
<div class="kg-browse">
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="query"
|
||||
class="search-box"
|
||||
type="text"
|
||||
:placeholder="T.kgSearchPlaceholder"
|
||||
:disabled="kgDown"
|
||||
@keydown.enter="onSearch"
|
||||
/>
|
||||
<button class="search-btn" :disabled="kgDown || searching" @click="onSearch">
|
||||
{{ searching ? T.loading : BENCH_LABELS.kgSearch }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchError" class="offline-text">{{ T.kgDown }}</div>
|
||||
<div v-if="results.length" class="search-results">
|
||||
<div v-for="r in results" :key="r.id" class="result-item" @click="onSelectResult(r)">
|
||||
<span class="rs-name">{{ r.name }}</span>
|
||||
<span class="rs-meta">{{ r.type }} · {{ r.support_count ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="neighborhoodError" class="offline-text">{{ T.kgDown }}</div>
|
||||
|
||||
<!-- 邻域深度选择(默认 1,FR-KG-005) -->
|
||||
<div v-if="neighborhoodMeta" class="depth-row">
|
||||
<span class="depth-label">{{ T.kgDepth }}</span>
|
||||
<button :class="['chip', { on: depth === 1 }]" @click="onDepthChange(1)">1</button>
|
||||
<button :class="['chip', { on: depth === 2 }]" @click="onDepthChange(2)">2</button>
|
||||
<span class="depth-hint">{{ BENCH_LABELS.kgDepth2SlowHint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实体详情侧滑(FR-KG-003) -->
|
||||
<KgEntityDetail v-if="detailNode" :node="detailNode" @close="detailNode = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-pane { display: flex; flex-direction: column; gap: 10px; }
|
||||
.offline-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 92, 122, 0.08);
|
||||
border: 1px solid rgba(255, 92, 122, 0.35);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pa-error); flex: none; }
|
||||
.offline-text { font-size: 12px; color: var(--pa-error); }
|
||||
.degrade-note { font-size: 11.5px; color: var(--pa-ink-subtle); }
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.node-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.node-list-item:hover { background: var(--pa-surface-2); }
|
||||
.nl-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.nl-meta { display: flex; gap: 6px; align-items: center; flex: none; }
|
||||
.nl-type { font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.nl-edges { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-muted); }
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.kg-browse { display: flex; flex-direction: column; gap: 8px; }
|
||||
.search-row { display: flex; gap: 6px; }
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.search-box:focus { outline: none; border-color: var(--pa-g-to); box-shadow: 0 0 14px rgba(0, 212, 255, 0.18); }
|
||||
.search-box:disabled { opacity: 0.5; }
|
||||
.search-btn {
|
||||
flex: none;
|
||||
padding: 0 14px;
|
||||
height: 34px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: none;
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.search-results {
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.result-item:hover { background: var(--pa-surface-2); }
|
||||
.rs-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rs-meta { flex: none; font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.depth-row { display: flex; align-items: center; gap: 6px; }
|
||||
.depth-label { font-size: 11.5px; color: var(--pa-ink-subtle); margin-right: 2px; }
|
||||
.chip {
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.chip:hover { color: var(--pa-ink); }
|
||||
.chip.on { color: #fff; background: var(--pa-g-from); border-color: transparent; }
|
||||
.depth-hint { font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.nb-meta { display: flex; gap: 8px; align-items: baseline; font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.nb-center { font-family: var(--pa-font-mono); color: var(--pa-ink-muted); }
|
||||
.nb-count { font-family: var(--pa-font-mono); }
|
||||
.nb-slow { color: var(--pa-warning); }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<!-- ProgressItem.vue —— 提取任务进度条(B3 归属)
|
||||
FR-EXTRACT-001:prog-item 视觉——任务名 + mono 百分比 + 6px 轨道 + 渐变填充 + sweep 扫光;
|
||||
100% 转 success 停扫光;failed 显示错误态不显示进度条。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { STATUS_LABEL } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
job: { type: Object, required: true },
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const pct = computed(() => props.job?.progress_pct);
|
||||
const isFailed = computed(() => props.job?.status === 'failed');
|
||||
const isFull = computed(() => pct.value != null && pct.value >= 100);
|
||||
|
||||
function toggle() {
|
||||
emit('toggle');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prog-item" :class="{ failed: isFailed, clickable: true }">
|
||||
<div class="pi-head" role="button" tabindex="0" @click="toggle" @keydown.enter="toggle" @keydown.space.prevent="toggle">
|
||||
<span class="pi-name" :title="job.title">{{ job.title }}</span>
|
||||
<span v-if="isFailed" class="pi-pct err">{{ STATUS_LABEL.error }}</span>
|
||||
<span v-else-if="pct != null" class="pi-pct" :class="{ ok: isFull }">{{ pct }}%</span>
|
||||
</div>
|
||||
<div v-if="!isFailed && pct != null" class="prog-track">
|
||||
<div class="prog-fill" :class="{ full: isFull }" :style="{ width: Math.min(100, Math.max(0, pct)) + '%' }"></div>
|
||||
</div>
|
||||
<div v-if="isFailed && job.message" class="pi-errmsg">{{ job.message }}</div>
|
||||
<div v-if="job.message && !isFailed" class="pi-msg">{{ job.message }}</div>
|
||||
<div v-if="open" class="pi-chev-row">
|
||||
<svg class="pi-chev open" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prog-item { margin-bottom: 16px; }
|
||||
.prog-item.failed { padding: 8px 10px; border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); background: var(--pa-surface-1); }
|
||||
.pi-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pi-head:focus-visible { outline: 2px solid rgba(94, 106, 210, 0.5); outline-offset: 1px; }
|
||||
.pi-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pi-pct { font-family: var(--pa-font-mono); font-size: 12px; color: var(--pa-ink-muted); flex: none; }
|
||||
.pi-pct.ok { color: var(--pa-success); }
|
||||
.pi-pct.err { color: var(--pa-error); font-weight: 600; }
|
||||
.prog-track { height: 6px; border-radius: 3px; background: var(--pa-surface-3); overflow: hidden; position: relative; }
|
||||
.prog-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--pa-gradient);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: width 0.5s var(--pa-ease);
|
||||
}
|
||||
.prog-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
background: linear-gradient(100deg, transparent 20%, rgba(255, 255, 255, 0.45) 50%, transparent 80%);
|
||||
will-change: transform;
|
||||
animation: sweep 2.4s infinite;
|
||||
}
|
||||
.prog-fill.full { background: var(--pa-success); }
|
||||
.prog-fill.full::after { animation: none; }
|
||||
.pi-msg { font-size: 11.5px; color: var(--pa-ink-subtle); margin-top: 5px; }
|
||||
.pi-errmsg {
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-error);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<!-- RetrievalPane.vue —— 工作台 Tab1 检索过程(B3 归属)
|
||||
FR-RETRIEVAL-001~006:阶段卡列表(每 SSE progress 事件一张)、相位筛选 chips、
|
||||
retrieval_plan 信息块、运行中 shimmer、空态。 -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { getActivePinia } from 'pinia';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { TOOL_FILTERS, PHASE_LABEL, PHASE_COLOR_VAR, PHASE_DESC, T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const bench = useBenchStore();
|
||||
|
||||
const phaseIcons = { planning: '◇', retrieval: '⌕', evidence: '⚖', generation: '✎', done: '✓' };
|
||||
|
||||
/** 流是否已终态(done/error/停止)——终态后所有阶段卡必须收口为 done,
|
||||
* 否则非 done 相位的卡片会永远停留在「运行中」+ shimmer(2026-08-25 修复)。
|
||||
* 只认 completed 事件收口是不够的:上游 completed 只影响它自己那张卡。 */
|
||||
const streamFinished = computed(() => {
|
||||
const msg = bench.currentAssistantMessage;
|
||||
if (!msg) return false;
|
||||
try {
|
||||
const chat = getActivePinia()?._s.get('chat');
|
||||
return !!chat && !chat.busy && msg.status !== 'streaming';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 每张卡的状态:流已终态 → 全部 done;否则仅完成相位是 done,其余 running(FR-RETRIEVAL-004)
|
||||
const cardStates = computed(() => {
|
||||
const byStage = new Map();
|
||||
const order = [];
|
||||
for (const e of bench.stageEvents) {
|
||||
if (!byStage.has(e.stage)) {
|
||||
byStage.set(e.stage, { count: 0, last: e, first: e });
|
||||
order.push(e.stage);
|
||||
}
|
||||
const g = byStage.get(e.stage);
|
||||
g.count += 1;
|
||||
g.last = e;
|
||||
}
|
||||
const isDone = (g) => streamFinished.value || g.last.phase === 'done';
|
||||
return order.map((stage) => {
|
||||
const g = byStage.get(stage);
|
||||
return { stage, group: g, status: isDone(g) ? 'done' : 'running' };
|
||||
});
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const f = bench.phaseFilter;
|
||||
return f === 'all'
|
||||
? cardStates.value
|
||||
: cardStates.value.filter((c) => c.group.last.phase === f);
|
||||
});
|
||||
|
||||
const plan = computed(() => {
|
||||
const msg = bench.currentAssistantMessage;
|
||||
if (!msg) return null;
|
||||
return msg.retrieval_plan || msg.raw?.retrieval_plan || null;
|
||||
});
|
||||
|
||||
// 展开状态本地管理:运行中的卡默认展开,完成后自动折叠(FR-RETRIEVAL-003)
|
||||
const open = ref({});
|
||||
function isOpen(stage) {
|
||||
if (stage in open.value) return open.value[stage];
|
||||
const c = cardStates.value.find((x) => x.stage === stage);
|
||||
return c ? c.status === 'running' : false;
|
||||
}
|
||||
function toggle(stage) {
|
||||
open.value = { ...open.value, [stage]: !isOpen(stage) };
|
||||
}
|
||||
|
||||
// 耗时 mono(mm:ss 或 s,未完成带 +)
|
||||
function fmtDur(ms) {
|
||||
if (!ms || !isFinite(ms) || ms <= 0) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
const base = m > 0 ? `${m}m${String(r).padStart(2, '0')}s` : `${r}s`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function detailLines(details) {
|
||||
if (!details) return [];
|
||||
return Object.entries(details).map(([k, v]) => {
|
||||
let val = v;
|
||||
if (Array.isArray(v)) val = v.join(', ');
|
||||
else if (v && typeof v === 'object') {
|
||||
try { val = JSON.stringify(v); } catch { val = String(v); }
|
||||
}
|
||||
return `${k}: ${val}`;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="retrieval-pane">
|
||||
<!-- 检索计划信息块(FR-RETRIEVAL-005,无则整块不显示) -->
|
||||
<div v-if="plan" class="preview-block plan-block">
|
||||
<div class="pb-title">{{ T.retrievalPlanTitle }}</div>
|
||||
<div class="plan-rows">
|
||||
<div v-for="(v, k) in plan" :key="k" class="plan-row">
|
||||
<span class="plan-k">{{ k }}</span>
|
||||
<span class="plan-v">{{ Array.isArray(v) ? v.join('、') : String(v) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相位筛选 chips(FR-RETRIEVAL-002) -->
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
v-for="f in TOOL_FILTERS"
|
||||
:key="f.key"
|
||||
:class="['chip', { on: bench.phaseFilter === f.key }]"
|
||||
@click="bench.setPhaseFilter(f.key)"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态(FR-RETRIEVAL-006,不显示假卡片) -->
|
||||
<div v-if="!filtered.length" class="empty-note">{{ T.retrievalEmpty }}</div>
|
||||
|
||||
<!-- 阶段卡(FR-RETRIEVAL-001) -->
|
||||
<div
|
||||
v-for="c in filtered"
|
||||
:key="c.stage"
|
||||
:class="['tool-card', c.status, { open: isOpen(c.stage) }]"
|
||||
>
|
||||
<div class="tool-head" @click="toggle(c.stage)">
|
||||
<span class="t-ico" :style="{ color: `var(${PHASE_COLOR_VAR[c.group.last.phase]})` }">
|
||||
{{ phaseIcons[c.group.last.phase] || '•' }}
|
||||
</span>
|
||||
<span class="t-name" :title="c.group.last.label">{{ c.group.last.label }}</span>
|
||||
<span class="badge" :class="c.status"><span class="dot"></span>{{ c.status === 'running' ? T.stepRunning : T.stepDone }}</span>
|
||||
<span v-if="c.group.last.ts" class="t-dur">{{ fmtDur(c.group.last.ts - c.group.first.ts) }}</span>
|
||||
<svg class="t-chev" width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
</div>
|
||||
<div v-if="isOpen(c.stage)" class="tool-body">
|
||||
<div class="tb-label">{{ BENCH_LABELS.stageLabel }}</div>
|
||||
<div class="tb-phase">
|
||||
<span class="phase-dot" :style="{ background: `var(${PHASE_COLOR_VAR[c.group.last.phase]})` }"></span>
|
||||
{{ PHASE_LABEL[c.group.last.phase] || c.group.last.phase }} · {{ PHASE_DESC[c.group.last.phase] || '' }}
|
||||
</div>
|
||||
<template v-if="detailLines(c.group.last.details).length">
|
||||
<div class="tb-label">{{ BENCH_LABELS.paramsLabel }}</div>
|
||||
<div class="tb-params"><div v-for="l in detailLines(c.group.last.details)" :key="l">{{ l }}</div></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.retrieval-pane { display: flex; flex-direction: column; }
|
||||
.plan-block { margin-bottom: 12px; }
|
||||
.plan-rows { display: flex; flex-direction: column; gap: 5px; }
|
||||
.plan-row { display: flex; gap: 8px; font-size: 12px; line-height: 1.5; }
|
||||
.plan-k {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--pa-g-to);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 1px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.plan-v { color: var(--pa-ink-muted); min-width: 0; word-break: break-word; }
|
||||
.filter-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.chip {
|
||||
padding: 3px 11px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.chip:hover { color: var(--pa-ink); }
|
||||
.chip.on { color: #fff; background: var(--pa-g-from); border-color: transparent; }
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.tool-card {
|
||||
margin: 0 0 10px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-card.running { position: relative; }
|
||||
.tool-card.running::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(100deg, transparent 30%, rgba(0, 212, 255, 0.05) 50%, transparent 70%);
|
||||
will-change: transform;
|
||||
animation: shimmer 1.8s infinite;
|
||||
}
|
||||
.tool-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 11px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.t-ico {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 7px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11.5px;
|
||||
background: color-mix(in srgb, var(--pa-g-from) 16%, transparent);
|
||||
color: var(--pa-g-to);
|
||||
}
|
||||
.t-name {
|
||||
flex: 1;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.t-dur { font-family: var(--pa-font-mono); font-size: 11.5px; color: var(--pa-ink-subtle); }
|
||||
.t-chev { color: var(--pa-ink-subtle); transition: transform 0.25s var(--pa-ease); flex: none; }
|
||||
.tool-card.open .t-chev { transform: rotate(90deg); }
|
||||
.tool-body {
|
||||
padding: 0 11px 10px;
|
||||
border-top: 1px solid var(--pa-hairline);
|
||||
padding-top: 11px;
|
||||
animation: fade-slide 0.2s var(--pa-ease-out);
|
||||
}
|
||||
.tb-label { font-size: 11px; letter-spacing: 0.5px; color: var(--pa-ink-subtle); margin: 8px 0 5px; }
|
||||
.tb-phase { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--pa-ink-muted); }
|
||||
.phase-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.tb-params {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-g-to);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 7px 10px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
/* 徽章(复用 B1 .badge;此处补 bench 内联兜底) */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
flex: none;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.badge.running { color: var(--pa-warning); background: rgba(245, 184, 92, 0.12); }
|
||||
.badge.running .dot { animation: pulse 2s infinite; }
|
||||
.badge.done { color: var(--pa-success); background: rgba(46, 211, 160, 0.12); }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- SourceRow.vue —— 文献来源单条(B3 归属)
|
||||
FR-SOURCE-001/002/003:mono 列表视觉、字段缺失不渲染占位、外链优先级 url>doi>pmid>pmcid。 -->
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: Object, required: true },
|
||||
index: { type: Number, default: 0 },
|
||||
count: { type: Number, default: null }, // 会话级聚合的出现次数
|
||||
highlighted: { type: Boolean, default: false }, // FR-CHAT-015:引用 chip 定位高亮
|
||||
});
|
||||
|
||||
const expanded = ref(false);
|
||||
const rootEl = ref(null);
|
||||
|
||||
// 高亮激活时滚动到可视区(FR-CHAT-015 滚动定位)
|
||||
watch(
|
||||
() => props.highlighted,
|
||||
(on) => {
|
||||
if (on && rootEl.value) {
|
||||
rootEl.value.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 外链优先级:url > doi > pmid > pmcid;均无 → null(不显示链接图标,FR-SOURCE-003) */
|
||||
const linkHref = computed(() => {
|
||||
const s = props.source || {};
|
||||
if (s.url) return s.url;
|
||||
if (s.doi) return `https://doi.org/${s.doi}`;
|
||||
if (s.pmid) return `https://pubmed.ncbi.nlm.nih.gov/${s.pmid}/`;
|
||||
if (s.pmcid) return `https://www.ncbi.nlm.nih.gov/pmc/articles/${s.pmcid}/`;
|
||||
return null;
|
||||
});
|
||||
|
||||
const metaParts = computed(() => {
|
||||
const s = props.source || {};
|
||||
const parts = [];
|
||||
if (s.pmid) parts.push(`PMID:${s.pmid}`);
|
||||
if (s.doi) parts.push(`DOI:${s.doi}`);
|
||||
if (s.score != null) parts.push(`${T.score} ${Number(s.score).toFixed(2)}`);
|
||||
if (props.count != null && props.count > 1) parts.push(`×${props.count}`);
|
||||
return parts;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="ft-item source-row" :class="{ 'hl-active': highlighted }">
|
||||
<span class="ft-ico idx">{{ index + 1 }}</span>
|
||||
<div class="src-main">
|
||||
<div class="src-line1">
|
||||
<span class="ft-name" :title="source.title || ''">{{ source.title }}</span>
|
||||
</div>
|
||||
<div class="ft-size src-meta">{{ metaParts.join(' · ') }}</div>
|
||||
<div v-if="source.snippet" class="src-snippet" :class="{ clamp: !expanded }" @click="expanded = !expanded">
|
||||
{{ source.snippet }}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="linkHref"
|
||||
class="ft-dl"
|
||||
:href="linkHref"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:title="linkHref"
|
||||
@click.stop
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14L21 3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-row {
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.source-row:hover { background: var(--pa-surface-2); }
|
||||
.source-row.hl-active {
|
||||
background: color-mix(in srgb, var(--pa-g-to) 14%, transparent);
|
||||
border-radius: var(--pa-r-sm);
|
||||
animation: hl-fade 2s var(--pa-ease-out) forwards;
|
||||
}
|
||||
@keyframes hl-fade {
|
||||
0% { background: color-mix(in srgb, var(--pa-g-to) 26%, transparent); }
|
||||
100% { background: color-mix(in srgb, var(--pa-g-to) 6%, transparent); }
|
||||
}
|
||||
.ft-item { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.ft-ico { flex: none; width: 16px; text-align: center; }
|
||||
.idx { color: var(--pa-ink-subtle); font-size: 11px; line-height: 1.7; }
|
||||
.src-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.src-line1 { display: flex; align-items: baseline; min-width: 0; }
|
||||
.ft-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.src-meta { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.ft-size { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.src-snippet {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink-muted);
|
||||
font-family: var(--pa-font-ui);
|
||||
cursor: pointer;
|
||||
word-break: break-word;
|
||||
}
|
||||
.src-snippet.clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ft-dl {
|
||||
flex: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-top: 2px;
|
||||
transition: opacity 0.15s var(--pa-ease), color 0.15s var(--pa-ease);
|
||||
}
|
||||
.ft-dl svg { width: 13px; height: 13px; }
|
||||
.ft-dl:hover { color: var(--pa-g-to); }
|
||||
/* 移动端触摸:链接图标常驻(FR-RESP-007 由 media (hover:none) 兜底,此处对齐) */
|
||||
@media (hover: none) {
|
||||
.ft-dl { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!-- SourcesPane.vue —— 工作台 Tab2 文献来源(B3 归属)
|
||||
FR-SOURCE-001~006:mono 列表、字段缺失不渲染占位、外链优先级 url>doi>pmid>pmcid、
|
||||
证据链 ChainGroup 逐跳展开、本次/会话切换、空态。 -->
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import SourceRow from './SourceRow.vue';
|
||||
import ChainGroup from './ChainGroup.vue';
|
||||
|
||||
const bench = useBenchStore();
|
||||
const chains = ref({}); // chain_id → open
|
||||
|
||||
// 会话级聚合按需拉取(仅当用户切到「本会话全部」且尚无缓存时)
|
||||
watch(
|
||||
() => [bench.sourceScope, bench.currentAssistantMessage],
|
||||
([scope]) => {
|
||||
if (scope === 'session' && !bench.sessionSources && bench._activeSessionId()) {
|
||||
bench.loadSessionSources().catch(() => {});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const items = computed(() => bench.sources);
|
||||
const chainList = computed(() => {
|
||||
const msg = bench.currentAssistantMessage;
|
||||
if (!msg) return [];
|
||||
const blk = (msg.blocks || []).find((b) => b?.type === 'graph');
|
||||
return Array.isArray(blk?.chains) ? blk.chains : [];
|
||||
});
|
||||
const hasChains = computed(() => chainList.value.length > 0);
|
||||
|
||||
function toggleChain(id) {
|
||||
chains.value = { ...chains.value, [id]: !chains.value[id] };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sources-pane">
|
||||
<!-- 本次回答 / 本会话全部(FR-SOURCE-005) -->
|
||||
<div class="scope-switch">
|
||||
<button
|
||||
:class="['scope-btn', { on: bench.sourceScope === 'current' }]"
|
||||
@click="bench.setSourceScope('current')"
|
||||
>{{ T.sessionSources }}</button>
|
||||
<button
|
||||
:class="['scope-btn', { on: bench.sourceScope === 'session' }]"
|
||||
@click="bench.setSourceScope('session')"
|
||||
>{{ T.sessionAllSources }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 会话级加载失败提示(失败必须可见,PRD §2.2) -->
|
||||
<div v-if="bench.sourceScope === 'session' && bench.sourcesError" class="scope-error">
|
||||
{{ T.upstreamError }}
|
||||
<button class="retry-btn" @click="bench.loadSessionSources({ force: true }).catch(() => {})">{{ T.retry }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态(FR-SOURCE-006,不显示空列表框) -->
|
||||
<div v-if="!items || !items.length" class="empty-note">{{ T.sourceEmpty }}</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 证据链(FR-SOURCE-004,按 chain_id 分组) -->
|
||||
<div v-if="hasChains" class="chain-section">
|
||||
<div class="bench-sec-title">{{ T.streamEvents }}</div>
|
||||
<ChainGroup
|
||||
v-for="c in chainList"
|
||||
:key="c.chain_id || JSON.stringify(c)"
|
||||
:chain="c"
|
||||
:open="!!chains[c.chain_id]"
|
||||
@toggle="toggleChain(c.chain_id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- mono 来源列表(FR-SOURCE-001/002;FR-CHAT-015 高亮由 highlightSource 驱动) -->
|
||||
<div class="bench-sec-title">{{ T.sourcesTitle }} · {{ items.length }}</div>
|
||||
<div class="source-list">
|
||||
<SourceRow
|
||||
v-for="(it, i) in items"
|
||||
:key="it.src.id || `${i}-${it.src.paper_id}`"
|
||||
:index="i"
|
||||
:source="it.src"
|
||||
:count="it.label"
|
||||
:highlighted="bench.highlightSource != null && bench.highlightSource.index === i && bench.highlightSource.until > Date.now()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sources-pane { display: flex; flex-direction: column; }
|
||||
.scope-switch {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-pill);
|
||||
padding: 2px;
|
||||
width: fit-content;
|
||||
}
|
||||
.scope-btn {
|
||||
padding: 3px 12px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.scope-btn.on { color: #fff; background: var(--pa-g-from); }
|
||||
.scope-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--pa-error);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.retry-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.retry-btn:hover { color: var(--pa-ink); }
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.chain-section { margin-bottom: 4px; }
|
||||
.bench-sec-title { font-size: 11px; letter-spacing: 0.6px; color: var(--pa-ink-subtle); margin: 10px 0 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
// bench/benchLabels.js —— B3 本地文案补充(已并入 labels.js,2026-08-25 PM 集成)
|
||||
// 本文件现为薄别名:所有键已合并进 src/i18n/labels.js 的 T 表(唯一事实源)。
|
||||
// 组件可继续 import { BENCH_LABELS },后续清理时可改为直接引用 T。
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const KEYS = [
|
||||
'extractRunLog', 'kgSearch', 'kgDepth2SlowHint', 'kgDepth2NeighborhoodSlow',
|
||||
'kgDegradedListPrefix', 'kgDegradedListSuffix', 'kgNodeUnit', 'kgEdgeUnit',
|
||||
'kgRelationUnit', 'benchExpand', 'benchCollapse', 'close', 'resetView',
|
||||
'stageLabel', 'paramsLabel',
|
||||
];
|
||||
|
||||
export const BENCH_LABELS = Object.fromEntries(KEYS.map((k) => [k, T[k]]));
|
||||
@@ -0,0 +1,188 @@
|
||||
// bench/graphLayout.js —— 手写 SVG 力导向布局(无第三方图库;B3 归属)
|
||||
// DESIGN.md 禁 CDN,图谱由纯手写算法完成:圆形种子初始化 → 有限迭代斥力/弹簧 → 包围盒归一化。
|
||||
// 输入/输出均为与 Vue 无关的纯数据,便于单元测试。
|
||||
|
||||
/**
|
||||
* 节点类型 → 调色板稳定映射(FR-KG-002:品牌色 + 辅助紫 + 五相位色)。
|
||||
* 未知类型归入 'unknown'。注意顺序固定:保证同一 type 会话内颜色稳定。
|
||||
*/
|
||||
const PALETTE = [
|
||||
'#5E6AD2', // 品牌 g-from
|
||||
'#00D4FF', // 品牌 g-to
|
||||
'#A78BFA', // 辅助紫
|
||||
'#C0A8DD', // 相位 规划
|
||||
'#9FBBE0', // 相位 检索
|
||||
'#9FC9A2', // 相位 证据
|
||||
'#DFA88F', // 相位 生成
|
||||
'#C08532', // 相位 完成
|
||||
];
|
||||
|
||||
/**
|
||||
* 对一组 type 做稳定配色(FR-KG-002)。
|
||||
* 调用方可预传入稳定基数 base=1,避免不同图之间同一 type 撞不同颜色。
|
||||
*/
|
||||
export function buildPalette(types, base = 1) {
|
||||
const seen = new Set();
|
||||
const sorted = [...types].sort();
|
||||
for (const t of sorted) if (t) seen.add(t);
|
||||
const map = new Map();
|
||||
let i = 0;
|
||||
for (const t of [...seen, 'unknown']) {
|
||||
if (!map.has(t)) map.set(t, PALETTE[(base + i) % PALETTE.length]);
|
||||
i += 1;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 稳定索引(字符串哈希),供确定着色 */
|
||||
export function hashString(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 力导向布局主入口。
|
||||
* @param {{nodes:Array<{id,label?,type?}>, edges:Array<{source,target,relation?}>}} graph
|
||||
* @param {{width?:number, height?:number, seed?:number, iterations?:number}} opts
|
||||
* @returns {Array<{id,label,type,x,y,vx,vy}>}
|
||||
*/
|
||||
export function forceLayout(graph, { width = 800, height = 600, seed = 0, iterations = 180 } = {}) {
|
||||
const nodes = (graph?.nodes || [])
|
||||
.filter((n) => n && n.id != null)
|
||||
.map((n) => ({
|
||||
id: String(n.id),
|
||||
label: n.label != null ? String(n.label) : String(n.id),
|
||||
type: n.type || 'unknown',
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
}));
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const edges = (graph?.edges || [])
|
||||
.filter((e) => e && ids.has(String(e.source)) && ids.has(String(e.target)))
|
||||
.map((e) => ({ source: String(e.source), target: String(e.target), relation: e.relation || e.label || '' }));
|
||||
|
||||
if (!nodes.length) return { nodes: [], edges: [], width, height };
|
||||
|
||||
const n = nodes.length;
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
|
||||
// 圆形种子初始化(确定性,不依赖 Math.random 顺序)
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const ang = (i / n) * Math.PI * 2 + seed * 0.13;
|
||||
nodes[i].x = cx + Math.cos(ang) * 120;
|
||||
nodes[i].y = cy + Math.sin(ang) * 120;
|
||||
nodes[i].vx = 0;
|
||||
nodes[i].vy = 0;
|
||||
}
|
||||
|
||||
// 邻接表(用于跳过相邻节点间的斥力,避免边把邻居完全推开)
|
||||
const adj = new Map();
|
||||
for (const nd of nodes) adj.set(nd.id, new Set());
|
||||
for (const e of edges) {
|
||||
adj.get(e.source)?.add(e.target);
|
||||
adj.get(e.target)?.add(e.source);
|
||||
}
|
||||
|
||||
const k = Math.sqrt((width * height) / Math.max(n, 1)) * 0.9;
|
||||
const iters = Math.max(60, Math.min(iterations, 300));
|
||||
|
||||
for (let it = 0; it < iters; it += 1) {
|
||||
const cooling = 1 - it / iters; // 温度线性退火
|
||||
|
||||
// 斥力(库仑):相邻节点跳过,减少抖动
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
for (let j = i + 1; j < n; j += 1) {
|
||||
const a = nodes[i];
|
||||
const b = nodes[j];
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 < 1) {
|
||||
dx = (hashString(a.id + b.id) % 100) - 50;
|
||||
dy = (hashString(b.id + a.id) % 100) - 50;
|
||||
d2 = dx * dx + dy * dy + 1;
|
||||
}
|
||||
if (adj.get(a.id).has(b.id)) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const force = (k * k) / d2 * 1.2;
|
||||
const fx = (dx / d) * force;
|
||||
const fy = (dy / d) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// 弹簧(胡克):边相连节点保持理想距离
|
||||
for (const e of edges) {
|
||||
const a = nodes.find((nd) => nd.id === e.source);
|
||||
const b = nodes.find((nd) => nd.id === e.target);
|
||||
if (!a || !b) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const target = k * 0.7;
|
||||
const force = (d - target) * 0.04;
|
||||
const fx = (dx / d) * force;
|
||||
const fy = (dy / d) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// 中心引力
|
||||
for (const nd of nodes) {
|
||||
nd.vx += (cx - nd.x) * 0.004;
|
||||
nd.vy += (cy - nd.y) * 0.004;
|
||||
}
|
||||
|
||||
// 积分 + 阻尼 + 限速
|
||||
for (const nd of nodes) {
|
||||
nd.vx *= 0.55;
|
||||
nd.vy *= 0.55;
|
||||
const sp = Math.hypot(nd.vx, nd.vy);
|
||||
const maxV = k * 0.3;
|
||||
if (sp > maxV) {
|
||||
nd.vx = (nd.vx / sp) * maxV;
|
||||
nd.vy = (nd.vy / sp) * maxV;
|
||||
}
|
||||
nd.x += nd.vx * cooling + nd.vx * 0.4;
|
||||
nd.y += nd.vy * cooling + nd.vy * 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
// 包围盒归一化:平移到 (0,0),缩放适配画布,留 40px 标签边距
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const nd of nodes) {
|
||||
minX = Math.min(minX, nd.x);
|
||||
minY = Math.min(minY, nd.y);
|
||||
maxX = Math.max(maxX, nd.x);
|
||||
maxY = Math.max(maxY, nd.y);
|
||||
}
|
||||
const bw = Math.max(maxX - minX, 1);
|
||||
const bh = Math.max(maxY - minY, 1);
|
||||
const pad = 40;
|
||||
const sx = (width - pad * 2) / bw;
|
||||
const sy = (height - pad * 2) / bh;
|
||||
const s = Math.min(sx, sy, 1.6);
|
||||
const offX = (width - bw * s) / 2;
|
||||
const offY = (height - bh * s) / 2;
|
||||
for (const nd of nodes) {
|
||||
nd.x = (nd.x - minX) * s + offX;
|
||||
nd.y = (nd.y - minY) * s + offY;
|
||||
}
|
||||
|
||||
return { nodes, edges, width, height };
|
||||
}
|
||||
|
||||
/** 每边最多渲染的关系名标签数(防 hub 拥挤) */
|
||||
export const MAX_EDGE_LABELS = 12;
|
||||
@@ -0,0 +1,69 @@
|
||||
// kgPaneCompat.js —— 测试适配层(B3 单测使用,不含生产逻辑)
|
||||
// 作用:把 KgPane 组件的关键判定逻辑抽取为纯函数,
|
||||
// 供 Vitest 直接验证(组件内逻辑与本文件保持同源复制,测试聚焦判定规则本身)。
|
||||
// 组件内实现是权威,本文件仅作测试探针;若两者不一致,以组件为准并修正本文件。
|
||||
|
||||
export const NODE_LIMIT_DESKTOP = 50;
|
||||
export const NODE_LIMIT_MOBILE = 30;
|
||||
|
||||
export const LINK_PRIORITY = ['url', 'doi', 'pmid', 'pmcid'];
|
||||
|
||||
/**
|
||||
* 外链优先级(FR-SOURCE-003):url > doi > pmid > pmcid;均无 → null(不显示链接图标)。
|
||||
*/
|
||||
export function resolveSourceLink(s) {
|
||||
if (!s) return null;
|
||||
if (s.url) return s.url;
|
||||
if (s.doi) return `https://doi.org/${s.doi}`;
|
||||
if (s.pmid) return `https://pubmed.ncbi.nlm.nih.gov/${s.pmid}/`;
|
||||
if (s.pmcid) return `https://www.ncbi.nlm.nih.gov/pmc/articles/${s.pmcid}/`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* progress_pct 派生(FR-EXTRACT-001,BFF 已派生,前端兜底同规则):
|
||||
* queued=5 / mineru=35 / llm=75 / completed=100 / failed=null。
|
||||
*/
|
||||
export function deriveProgressPct(job) {
|
||||
if (!job) return null;
|
||||
if (job.progress_pct != null) return job.progress_pct;
|
||||
switch (job.stage) {
|
||||
case 'queued': return 5;
|
||||
case 'mineru': return 35;
|
||||
case 'llm': return 75;
|
||||
case 'completed': return 100;
|
||||
case 'failed': return null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要轮询(FR-EXTRACT-005):存在 running/queued 任务 → true;
|
||||
* 全部终态(completed/failed)→ false。切走 tab 由 store 层暂停。
|
||||
*/
|
||||
export function shouldPoll(jobs) {
|
||||
return (jobs || []).some((j) => j.status === 'running' || j.status === 'queued');
|
||||
}
|
||||
|
||||
/** 图谱节点降级判定(FR-KG-001):节点数超过上限(桌面 50 / 移动端 30)→ 列表视图 */
|
||||
export function isDegradedGraph(nodeCount, limit = NODE_LIMIT_DESKTOP) {
|
||||
return nodeCount > limit;
|
||||
}
|
||||
|
||||
/** 离线态判定(FR-KG-007):kgReady=false 且状态已探测过 → 搜索/邻域禁用,答案图谱仍可用 */
|
||||
export function isKgOffline(kgReady, kgStatus) {
|
||||
return !kgReady && kgStatus != null;
|
||||
}
|
||||
|
||||
/** 会话来源按 paper_id 去重并累计出现次数(FR-SOURCE-005) */
|
||||
export function aggregateSessionSources(sources) {
|
||||
if (!Array.isArray(sources)) return [];
|
||||
const seen = new Map();
|
||||
for (const it of sources) {
|
||||
const key = it.paper_id || it.id || JSON.stringify(it);
|
||||
const prev = seen.get(key);
|
||||
if (prev) prev.count += it.count ?? 1;
|
||||
else seen.set(key, { ...it, count: it.count ?? 1 });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
Reference in New Issue
Block a user