ForcePilot/web/src/views/GraphView.vue
Zhao Minhao 55f2e020b5
Add dark/light theme toggle feature (#343)
* Add dark/light theme toggle feature

* update: refine dark/light theme and related components

* 调整语义化主题变量,优化暗/亮模式切换效果

* Revert "调整语义化主题变量,优化暗/亮模式切换效果"

This reverts commit 85d9373297686fdbacb252a880b2847d20a39641.

* 深色模式适配:减少 !important,迁移 CSS 变量,优化布局

* style: 替换硬编码颜色值为CSS变量以支持主题切换

将多处硬编码的颜色值(如#fff、#f0f0f0等)替换为CSS变量(如--gray-0、--gray-150等),统一管理颜色样式,便于主题切换和样式维护。主要修改包括背景色、边框色、文字色等视觉元素,同时移除不再需要的深色模式适配代码。

* style: 使用CSS变量替换硬编码颜色值

* refactor(theme): 重构主题系统,统一使用CSS变量并优化暗色模式实现

- 移除冗余的theme.js配置文件,将主题配置集中到theme store
- 使用ant-design-vue的darkAlgorithm实现暗色模式
- 在多个组件中替换硬编码颜色值为CSS变量
- 优化用户信息组件,整合文档中心和主题切换功能
- 更新基础CSS样式,完善颜色系统和滚动条样式

* reset: 恢复被覆盖的修改(96d257a7ace38c94e2b113d56472d211d1141087)

* feat(theme): 实现深色主题支持并重构样式系统

重构颜色变量系统,添加深色主题支持
移除硬编码颜色值,统一使用CSS变量
优化图表组件以响应主题切换
清理无用样式代码,提升可维护性

* docs: 更新开发规范文档

* style: 调整边框和背景颜色样式

---------

Co-authored-by: Wenjie Zhang <xerrors@163.com>
2025-11-23 01:39:44 +08:00

473 lines
13 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="database-empty" v-if="!state.showPage">
<a-empty>
<template #description>
<span>
前往 <router-link to="/setting" style="color: var(--main-color); font-weight: bold;">设置</router-link> 页面启用知识图谱。
</span>
</template>
</a-empty>
</div>
<div class="graph-container layout-container" v-else>
<HeaderComponent
title="图数据库"
>
<template #actions>
<div class="status-wrapper">
<div class="status-indicator" :class="graphStatusClass"></div>
<span class="status-text">{{ graphStatusText }}</span>
</div>
<a-button type="default" @click="openLink('http://localhost:7474/')" :icon="h(GlobalOutlined)">
Neo4j 浏览器
</a-button>
<a-button type="primary" @click="state.showModal = true" ><UploadOutlined/> 上传文件</a-button>
<a-button v-if="unindexedCount > 0" type="primary" @click="indexNodes" :loading="state.indexing">
<SyncOutlined v-if="!state.indexing"/> 为{{ unindexedCount }}个节点添加索引
</a-button>
</template>
</HeaderComponent>
<div class="container-outter">
<GraphCanvas
ref="graphRef"
:graph-data="graphData"
:highlight-keywords="[state.searchInput]"
>
<template #top>
<div class="actions">
<div class="actions-left">
<a-input
v-model:value="state.searchInput"
placeholder="输入要查询的实体"
style="width: 300px"
@keydown.enter="onSearch"
allow-clear
>
<template #suffix>
<component :is="state.searchLoading ? LoadingOutlined : SearchOutlined" @click="onSearch" />
</template>
</a-input>
</div>
<div class="actions-right">
<a-button type="default" @click="state.showInfoModal = true" :icon="h(InfoCircleOutlined)">
说明
</a-button>
<a-input
v-model:value="sampleNodeCount"
placeholder="查询三元组数量"
style="width: 100px"
@keydown.enter="loadSampleNodes"
:loading="state.fetching"
>
<template #suffix>
<component :is="state.fetching ? LoadingOutlined : ReloadOutlined" @click="loadSampleNodes" />
</template>
</a-input>
</div>
</div>
</template>
<template #content>
<a-empty v-show="graphData.nodes.length === 0" style="padding: 4rem 0;"/>
</template>
<template #bottom>
<div class="footer">
<GraphInfoPanel
:graph-info="graphInfo"
:graph-data="graphData"
:unindexed-count="unindexedCount"
:model-matched="modelMatched"
@index-nodes="indexNodes"
@export-data="exportGraphData"
/>
</div>
</template>
</GraphCanvas>
</div>
<a-modal
:open="state.showModal" title="上传文件"
@ok="addDocumentByFile"
@cancel="() => state.showModal = false"
ok-text="添加到图数据库" cancel-text="取消"
:confirm-loading="state.processing">
<div class="upload">
<div class="note">
<p>上传的文件内容参考 test/data/A_Dream_of_Red_Mansions_tiny.jsonl 中的格式:</p>
</div>
<a-upload-dragger
class="upload-dragger"
v-model:fileList="fileList"
name="file"
:fileList="fileList"
:max-count="1"
accept=".jsonl"
action="/api/knowledge/files/upload?allow_jsonl=true"
:headers="getAuthHeaders()"
@change="handleFileUpload"
@drop="handleDrop"
>
<p class="ant-upload-text">点击或者把文件拖拽到这里上传</p>
<p class="ant-upload-hint">
目前仅支持上传 jsonl 文件。
</p>
</a-upload-dragger>
</div>
</a-modal>
<!-- 说明弹窗 -->
<a-modal
:open="state.showInfoModal"
title="图数据库说明"
@cancel="() => state.showInfoModal = false"
:footer="null"
width="600px"
>
<div class="info-content">
<p>本页面展示的是 Neo4j 图数据库中的知识图谱信息</p>
<p>具体展示内容包括</p>
<ul>
<li>带有 <code>Entity</code> 标签的节点</li>
<li>带有 <code>RELATION</code> 类型的关系边</li>
</ul>
<p>注意</p>
<ul>
<li>这里仅展示用户上传的实体和关系不包含知识库中自动创建的图谱</li>
<li>查询逻辑基于 <code>graphbase.py</code> 中的 <code>get_sample_nodes</code> 方法实现</li>
</ul>
<pre><code>MATCH (n:Entity)-[r]-&gt;(m:Entity)
RETURN
{id: elementId(n), name: n.name} AS h,
{type: r.type, source_id: elementId(n), target_id: elementId(m)} AS r,
{id: elementId(m), name: m.name} AS t
LIMIT $num</code></pre>
<p>如需查看完整的 Neo4j 数据库内容请使用 "Neo4j 浏览器" 按钮访问原生界面</p>
</div>
</a-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, h } from 'vue';
import { message } from 'ant-design-vue';
import { useConfigStore } from '@/stores/config';
import { UploadOutlined, SyncOutlined, GlobalOutlined, InfoCircleOutlined, SearchOutlined, ReloadOutlined, LoadingOutlined, HighlightOutlined } from '@ant-design/icons-vue';
import HeaderComponent from '@/components/HeaderComponent.vue';
import { neo4jApi } from '@/apis/graph_api';
import { useUserStore } from '@/stores/user';
import GraphCanvas from '@/components/GraphCanvas.vue';
import GraphInfoPanel from '@/components/GraphInfoPanel.vue';
const configStore = useConfigStore();
const cur_embed_model = computed(() => configStore.config?.embed_model);
const modelMatched = computed(() => !graphInfo?.value?.embed_model_name || graphInfo.value.embed_model_name === cur_embed_model.value)
const graphRef = ref(null)
const graphInfo = ref(null)
const fileList = ref([]);
const sampleNodeCount = ref(100);
const graphData = reactive({
nodes: [],
edges: [],
});
const state = reactive({
fetching: false,
loadingGraphInfo: false,
searchInput: '',
searchLoading: false,
showModal: false,
showInfoModal: false,
processing: false,
indexing: false,
showPage: true,
})
// 计算未索引节点数量
const unindexedCount = computed(() => {
return graphInfo.value?.unindexed_node_count || 0;
});
const loadGraphInfo = () => {
state.loadingGraphInfo = true
neo4jApi.getInfo()
.then(data => {
console.log(data)
graphInfo.value = data.data
state.loadingGraphInfo = false
})
.catch(error => {
console.error(error)
message.error(error.message || '加载图数据库信息失败')
state.loadingGraphInfo = false
})
}
const addDocumentByFile = () => {
state.processing = true
const files = fileList.value.filter(file => file.status === 'done').map(file => file.response.file_path)
neo4jApi.addEntities(files[0])
.then((data) => {
if (data.status === 'success') {
message.success(data.message);
state.showModal = false;
} else {
throw new Error(data.message);
}
})
.catch((error) => {
console.error(error)
message.error(error.message || '添加文件失败');
})
.finally(() => state.processing = false)
};
const loadSampleNodes = () => {
state.fetching = true
neo4jApi.getSampleNodes('neo4j', sampleNodeCount.value)
.then((data) => {
graphData.nodes = data.result.nodes
graphData.edges = data.result.edges
console.log(graphData)
// 初次加载后兜底刷新一次,避免容器初次可见尺寸未稳定
setTimeout(() => graphRef.value?.refreshGraph?.(), 500)
})
.catch((error) => {
console.error(error)
message.error(error.message || '加载节点失败');
})
.finally(() => state.fetching = false)
}
const onSearch = () => {
if (state.searchLoading) {
message.error('请稍后再试')
return
}
if (graphInfo?.value?.embed_model_name !== cur_embed_model.value) {
// 可选:提示模型不一致
}
if (!state.searchInput) {
message.error('请输入要查询的实体')
return
}
state.searchLoading = true
neo4jApi.queryNode(state.searchInput)
.then((data) => {
if (!data.result || !data.result.nodes || !data.result.edges) {
throw new Error('返回数据格式不正确');
}
graphData.nodes = data.result.nodes
graphData.edges = data.result.edges
if (graphData.nodes.length === 0) {
message.info('未找到相关实体')
}
console.log(data)
console.log(graphData)
graphRef.value?.refreshGraph?.()
})
.catch((error) => {
console.error('查询错误:', error);
message.error(`查询出错:${error.message || '未知错误'}`);
})
.finally(() => state.searchLoading = false)
};
onMounted(() => {
loadGraphInfo();
loadSampleNodes();
});
const handleFileUpload = (event) => {
console.log(event)
console.log(fileList.value)
}
const handleDrop = (event) => {
console.log(event)
console.log(fileList.value)
}
const graphStatusClass = computed(() => {
if (state.loadingGraphInfo) return 'loading';
return graphInfo.value?.status === 'open' ? 'open' : 'closed';
});
const graphStatusText = computed(() => {
if (state.loadingGraphInfo) return '加载中';
return graphInfo.value?.status === 'open' ? '已连接' : '已关闭';
});
// 为未索引节点添加索引
const indexNodes = () => {
// 判断 embed_model_name 是否相同
if (!modelMatched.value) {
message.error(`向量模型不匹配,无法添加索引,当前向量模型为 ${cur_embed_model.value},图数据库向量模型为 ${graphInfo.value?.embed_model_name}`)
return
}
if (state.processing) {
message.error('后台正在处理,请稍后再试')
return
}
state.indexing = true;
neo4jApi.indexEntities('neo4j')
.then(data => {
message.success(data.message || '索引添加成功');
// 刷新图谱信息
loadGraphInfo();
})
.catch(error => {
console.error(error);
message.error(error.message || '添加索引失败');
})
.finally(() => {
state.indexing = false;
});
};
const exportGraphData = () => {
const dataStr = JSON.stringify({
nodes: graphData.nodes,
edges: graphData.edges,
graphInfo: graphInfo.value,
exportTime: new Date().toISOString()
}, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `graph-data-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('图谱数据已导出');
};
const getAuthHeaders = () => {
const userStore = useUserStore();
return userStore.getAuthHeaders();
};
const openLink = (url) => {
window.open(url, '_blank')
}
</script>
<style lang="less" scoped>
@graph-header-height: 55px;
.graph-container {
padding: 0;
background-color: var(--gray-0);
.header-container {
height: @graph-header-height;
}
}
.status-wrapper {
display: flex;
align-items: center;
margin-right: 16px;
font-size: 14px;
color: var(--color-text-secondary);
}
.status-text {
margin-left: 8px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
&.loading {
background-color: var(--color-warning);
animation: pulse 1.5s infinite ease-in-out;
}
&.open {
background-color: var(--color-success);
}
&.closed {
background-color: var(--color-error);
}
}
@keyframes pulse {
0% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(0.8);
opacity: 0.5;
}
}
.upload {
margin-bottom: 20px;
.upload-dragger {
margin: 0px;
}
}
.container-outter {
width: 100%;
height: calc(100vh - @graph-header-height);
overflow: hidden;
background: var(--gray-10);
.actions,
.footer {
display: flex;
justify-content: space-between;
margin: 20px 0;
padding: 0 24px;
width: 100%;
}
.tags {
display: flex;
gap: 8px;
}
}
.actions {
top: 0;
.actions-left, .actions-right {
display: flex;
align-items: center;
gap: 10px;
}
:deep(.ant-input) {
padding: 2px 10px;
}
button {
height: 37px;
box-shadow: none;
}
}
</style>