Bläddra i källkod

feat: 添加深色/浅色主题切换功能

- 在 store 中添加 theme 状态管理(toggleTheme/setTheme)
- nodeStyle.js 和 edgeStyle.js 支持按主题返回不同颜色
- 所有视图组件传递 theme 参数并监听主题变化重新渲染
- App.vue 图例颜色随主题动态变化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 1 dag sedan
förälder
incheckning
bce9e27a90

+ 23 - 8
script/visualization/src/App.vue

@@ -1,5 +1,5 @@
 <template>
-  <div data-theme="dark" class="flex flex-col h-screen">
+  <div :data-theme="store.theme" class="flex flex-col h-screen">
     <!-- 顶部栏 -->
     <header class="navbar bg-base-200 min-h-0 px-4 py-2 shrink-0">
       <!-- Tab 切换 -->
@@ -20,16 +20,16 @@
       <div class="flex gap-3 text-xs text-base-content/60 ml-6 border-l border-base-content/20 pl-4">
         <span class="text-base-content font-medium">颜色:</span>
         <div class="flex items-center gap-1">
-          <span class="w-2.5 h-2.5 rounded-full bg-dim-persona"></span>人设
+          <span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: currentDimColors['人设'] }"></span>人设
         </div>
         <div class="flex items-center gap-1">
-          <span class="w-2.5 h-2.5 rounded-full bg-dim-inspiration"></span>灵感点
+          <span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: currentDimColors['灵感点'] }"></span>灵感点
         </div>
         <div class="flex items-center gap-1">
-          <span class="w-2.5 h-2.5 rounded-full bg-dim-purpose"></span>目的点
+          <span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: currentDimColors['目的点'] }"></span>目的点
         </div>
         <div class="flex items-center gap-1">
-          <span class="w-2.5 h-2.5 rounded-full bg-dim-key"></span>关键点
+          <span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: currentDimColors['关键点'] }"></span>关键点
         </div>
       </div>
       <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
@@ -46,14 +46,24 @@
       <!-- 边图例 -->
       <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
         <span class="text-base-content font-medium">边:</span>
-        <div v-for="(color, type) in edgeTypeColors" :key="type" class="flex items-center gap-1">
+        <div v-for="(color, type) in currentEdgeColors" :key="type" class="flex items-center gap-1">
           <span class="w-4 h-0.5" :style="{ backgroundColor: color }"></span>
           <span>{{ type }}</span>
         </div>
       </div>
 
-      <!-- 右侧:视图配置下拉 -->
+      <!-- 右侧:视图配置下拉 + 主题切换 -->
       <div class="flex-1"></div>
+
+      <!-- 主题切换按钮 -->
+      <button @click="store.toggleTheme" class="btn btn-ghost btn-sm btn-circle mr-2" :title="store.theme === 'dark' ? '切换到亮色' : '切换到暗色'">
+        <svg v-if="store.theme === 'dark'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
+        </svg>
+        <svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
+        </svg>
+      </button>
       <div v-if="activeTab === 'match'" class="dropdown dropdown-end">
         <label tabindex="0" class="btn btn-ghost btn-sm gap-1">
           <span class="text-xs">视图配置</span>
@@ -218,13 +228,18 @@ import DetailPanel from './components/DetailPanel.vue'
 import DerivationView from './components/DerivationView.vue'
 import RelationView from './components/RelationView.vue'
 import { useGraphStore } from './stores/graph'
-import { edgeTypeColors } from './config/edgeStyle'
+import { getEdgeTypeColors } from './config/edgeStyle'
+import { getDimColors } from './config/nodeStyle'
 
 const store = useGraphStore()
 
 // 当前激活的 Tab
 const activeTab = ref('match')
 
+// 主题相关的颜色(响应式)
+const currentEdgeColors = computed(() => getEdgeTypeColors(store.theme))
+const currentDimColors = computed(() => getDimColors(store.theme))
+
 // 视图配置(默认全部勾选)
 const viewConfig = reactive({
   personaTree: true,      // 人设树

+ 15 - 7
script/visualization/src/components/DerivationView.vue

@@ -1018,10 +1018,10 @@ function render() {
     .data(links)
     .join('line')
     .attr('class', 'graph-link')
-    .attr('stroke', d => getEdgeStyle(d).color)
-    .attr('stroke-opacity', d => getEdgeStyle(d).opacity)
-    .attr('stroke-width', d => getEdgeStyle(d).strokeWidth)
-    .attr('stroke-dasharray', d => getEdgeStyle(d).strokeDasharray)
+    .attr('stroke', d => getEdgeStyle(d, { theme: store.theme }).color)
+    .attr('stroke-opacity', d => getEdgeStyle(d, { theme: store.theme }).opacity)
+    .attr('stroke-width', d => getEdgeStyle(d, { theme: store.theme }).strokeWidth)
+    .attr('stroke-dasharray', d => getEdgeStyle(d, { theme: store.theme }).strokeDasharray)
     .attr('marker-end', d => `url(#arrow-${d.type})`)
     .style('cursor', 'pointer')
     .on('mouseenter', (e, d) => {
@@ -1048,19 +1048,22 @@ function render() {
     .join('g')
     .attr('class', 'graph-link-label')
 
+  // 标签背景颜色根据主题
+  const labelBgColor = store.theme === 'light' ? '#f5f5f5' : '#1d232a'
+
   linkLabelSelection.append('rect')
     .attr('x', -14)
     .attr('y', -6)
     .attr('width', 28)
     .attr('height', 12)
     .attr('rx', 2)
-    .attr('fill', '#1d232a')
+    .attr('fill', labelBgColor)
     .attr('opacity', 0.9)
 
   linkLabelSelection.append('text')
     .attr('text-anchor', 'middle')
     .attr('dy', '0.35em')
-    .attr('fill', d => getEdgeStyle(d).color)
+    .attr('fill', d => getEdgeStyle(d, { theme: store.theme }).color)
     .attr('font-size', '8px')
     .text(d => d.score.toFixed(2))
 
@@ -1082,7 +1085,7 @@ function render() {
 
   // 节点形状
   nodeSelection.each(function(d) {
-    const style = getNodeStyle(d)
+    const style = getNodeStyle(d, { theme: store.theme })
     applyNodeShape(d3.select(this), style)
   })
 
@@ -1231,6 +1234,11 @@ watch(() => store.expandedPanel, () => {
   })
 })
 
+// 监听主题变化,重新渲染
+watch(() => store.theme, () => {
+  nextTick(() => render())
+})
+
 // 监听 hover 状态变化(联动)
 watch([() => store.hoverPathNodes.size, () => store.hoverNodeId, () => store.hoverSource], () => {
   if (!nodeSelection || !linkSelection) return

+ 18 - 8
script/visualization/src/components/GraphView.vue

@@ -106,7 +106,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
-import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
+import { getEdgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
 import { applyHighlight, applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
 
 const props = defineProps({
@@ -119,6 +119,8 @@ const containerRef = ref(null)
 const svgRef = ref(null)
 const showConfig = ref(false)
 
+// 主题相关的颜色(响应式)
+const edgeTypeColors = computed(() => getEdgeTypeColors(store.theme))
 
 // 中间步骤可选的边类型(排除匹配边)
 const middleEdgeTypeOptions = computed(() => {
@@ -266,7 +268,7 @@ function renderGraph() {
     .data(links)
     .join('line')
     .attr('class', 'graph-link')
-    .attr('stroke', d => getEdgeStyle(d).color)
+    .attr('stroke', d => getEdgeStyle(d, { theme: store.theme }).color)
     .attr('stroke-width', 1.5)
     .style('cursor', 'pointer')
     .on('click', (e, d) => {
@@ -281,28 +283,31 @@ function renderGraph() {
     })
 
   // 边的分数标签
-  const linkLabelData = links.filter(d => getEdgeStyle(d).scoreText)
+  const linkLabelData = links.filter(d => getEdgeStyle(d, { theme: store.theme }).scoreText)
   const linkLabel = g.append('g')
     .selectAll('g')
     .data(linkLabelData)
     .join('g')
     .attr('class', 'graph-link-label')
 
+  // 标签背景颜色根据主题
+  const labelBgColor = store.theme === 'light' ? '#f5f5f5' : '#1d232a'
+
   linkLabel.append('rect')
     .attr('x', -14)
     .attr('y', -6)
     .attr('width', 28)
     .attr('height', 12)
     .attr('rx', 2)
-    .attr('fill', '#1d232a')
+    .attr('fill', labelBgColor)
     .attr('opacity', 0.9)
 
   linkLabel.append('text')
     .attr('text-anchor', 'middle')
     .attr('dy', '0.35em')
-    .attr('fill', d => getEdgeStyle(d).color)
+    .attr('fill', d => getEdgeStyle(d, { theme: store.theme }).color)
     .attr('font-size', '8px')
-    .text(d => getEdgeStyle(d).scoreText)
+    .text(d => getEdgeStyle(d, { theme: store.theme }).scoreText)
 
   // 节点组
   const node = g.append('g')
@@ -368,13 +373,13 @@ function renderGraph() {
   // 节点形状(使用统一配置)
   node.each(function(d) {
     const el = d3.select(this)
-    const style = getNodeStyle(d, { isCenter: d.isCenter })
+    const style = getNodeStyle(d, { isCenter: d.isCenter, theme: store.theme })
     applyNodeShape(el, style)
   })
 
   // 节点标签
   node.append('text')
-    .attr('dy', d => getNodeStyle(d, { isCenter: d.isCenter }).size / 2 + 12)
+    .attr('dy', d => getNodeStyle(d, { isCenter: d.isCenter, theme: store.theme }).size / 2 + 12)
     .attr('text-anchor', 'middle')
     .text(d => d.name.length > 8 ? d.name.slice(0, 8) + '...' : d.name)
 
@@ -685,6 +690,11 @@ watch(() => store.postWalkConfig, () => {
   }
 }, { deep: true })
 
+// 监听主题变化,重新渲染
+watch(() => store.theme, () => {
+  nextTick(renderGraph)
+})
+
 // 监听 CSS 过渡结束后重新渲染
 function handleTransitionEnd(e) {
   if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {

+ 42 - 31
script/visualization/src/components/PostTreeView.vue

@@ -370,8 +370,8 @@
 import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
-import { getNodeStyle, applyNodeShape, dimColors } from '../config/nodeStyle'
-import { getEdgeStyle, edgeTypeColors } from '../config/edgeStyle'
+import { getNodeStyle, applyNodeShape, getDimColors } from '../config/nodeStyle'
+import { getEdgeStyle, getEdgeTypeColors } from '../config/edgeStyle'
 import { applyHighlight, applyHoverHighlight } from '../utils/highlight'
 
 const props = defineProps({
@@ -404,6 +404,10 @@ const hiddenNodeFields = ['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy', 'children',
 
 const store = useGraphStore()
 
+// 主题相关的颜色(响应式)
+const dimColors = computed(() => getDimColors(store.theme))
+const edgeTypeColors = computed(() => getEdgeTypeColors(store.theme))
+
 const containerRef = ref(null)
 const svgRef = ref(null)
 
@@ -577,7 +581,7 @@ const sortedMatchEdges = computed(() => {
 
 // 获取匹配边的分数颜色
 function getScoreColor(score) {
-  return getEdgeStyle({ type: '匹配', score }).color
+  return getEdgeStyle({ type: '匹配', score }, { theme: store.theme }).color
 }
 
 // 获取源节点颜色(帖子域节点)
@@ -609,13 +613,13 @@ const displayNode = computed(() => {
 // 显示节点的样式
 const displayNodeStyle = computed(() => {
   if (!displayNode.value) return { color: '#888', shape: 'circle', hollow: false }
-  return getNodeStyle(displayNode.value)
+  return getNodeStyle(displayNode.value, { theme: store.theme })
 })
 
 // 选中节点的样式(兼容旧代码)
 const selectedNodeStyle = computed(() => {
   if (!store.selectedNode) return { color: '#888', shape: 'circle', hollow: false }
-  return getNodeStyle(store.selectedNode)
+  return getNodeStyle(store.selectedNode, { theme: store.theme })
 })
 
 // 选中节点的颜色(兼容)
@@ -914,7 +918,7 @@ function renderTree() {
     const el = d3.select(this)
     // 帖子树节点属于帖子域(空心)
     d.data.domain = '帖子'
-    const style = getNodeStyle(d)
+    const style = getNodeStyle(d, { theme: store.theme })
     applyNodeShape(el, style).attr('class', 'tree-shape')
     nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
   })
@@ -924,9 +928,9 @@ function renderTree() {
     .attr('dy', d => d.children ? -10 : 4)
     .attr('dx', d => d.children ? 0 : 10)
     .attr('text-anchor', d => d.children ? 'middle' : 'start')
-    .attr('fill', d => getNodeStyle(d).text.fill)
-    .attr('font-size', d => getNodeStyle(d).text.fontSize)
-    .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
+    .attr('fill', d => getNodeStyle(d, { theme: store.theme }).text.fill)
+    .attr('font-size', d => getNodeStyle(d, { theme: store.theme }).text.fontSize)
+    .attr('font-weight', d => getNodeStyle(d, { theme: store.theme }).text.fontWeight)
     .text(d => {
       const name = d.data.name
       const maxLen = 10
@@ -1037,10 +1041,10 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     .join('path')
     .attr('class', 'match-link')
     .attr('fill', 'none')
-    .attr('stroke', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
-    .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }).opacity)
-    .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
-    .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
+    .attr('stroke', d => getEdgeStyle({ type: '匹配', score: d.score }, { theme: store.theme }).color)
+    .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }, { theme: store.theme }).opacity)
+    .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }, { theme: store.theme }).strokeWidth)
+    .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }, { theme: store.theme }).strokeDasharray)
     .style('cursor', 'pointer')
     .on('mouseenter', (e, d) => {
       // hover 边,显示边详情并高亮
@@ -1073,7 +1077,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     })
 
   // 绘制分数标签(使用 data binding)
-  const scoreData = matchLinksData.filter(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
+  const scoreData = matchLinksData.filter(d => getEdgeStyle({ type: '匹配', score: d.score }, { theme: store.theme }).scoreText)
 
   const scoreGroups = matchLinksG.selectAll('.match-score')
     .data(scoreData)
@@ -1097,9 +1101,9 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
   scoreGroups.append('text')
     .attr('text-anchor', 'middle')
     .attr('dy', '0.35em')
-    .attr('fill', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
+    .attr('fill', d => getEdgeStyle({ type: '匹配', score: d.score }, { theme: store.theme }).color)
     .attr('font-size', '8px')
-    .text(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
+    .text(d => getEdgeStyle({ type: '匹配', score: d.score }, { theme: store.theme }).scoreText)
 
   // 绘制匹配节点
   const matchNodesG = contentG.append('g').attr('class', 'match-nodes')
@@ -1115,7 +1119,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
   // 匹配节点形状(使用统一配置)
   matchNodes.each(function(d) {
     const el = d3.select(this)
-    const style = getNodeStyle(d, { isMatch: true })
+    const style = getNodeStyle(d, { isMatch: true, theme: store.theme })
     applyNodeShape(el, style).attr('class', 'tree-shape')
     nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
   })
@@ -1125,9 +1129,9 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     .attr('dy', 4)
     .attr('dx', 10)
     .attr('text-anchor', 'start')
-    .attr('fill', d => getNodeStyle(d, { isMatch: true }).text.fill)
-    .attr('font-size', d => getNodeStyle(d, { isMatch: true }).text.fontSize)
-    .attr('font-weight', d => getNodeStyle(d, { isMatch: true }).text.fontWeight)
+    .attr('fill', d => getNodeStyle(d, { isMatch: true, theme: store.theme }).text.fill)
+    .attr('font-size', d => getNodeStyle(d, { isMatch: true, theme: store.theme }).text.fontSize)
+    .attr('font-weight', d => getNodeStyle(d, { isMatch: true, theme: store.theme }).text.fontWeight)
     .text(d => {
       const name = d.name
       const maxLen = 10
@@ -1310,10 +1314,10 @@ function renderWalkedLayer() {
     .join('path')
     .attr('class', 'walked-link')
     .attr('fill', 'none')
-    .attr('stroke', d => getEdgeStyle({ type: d.type, score: d.score }).color)
-    .attr('stroke-opacity', d => getEdgeStyle({ type: d.type, score: d.score }).opacity)
-    .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
-    .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
+    .attr('stroke', d => getEdgeStyle({ type: d.type, score: d.score }, { theme: store.theme }).color)
+    .attr('stroke-opacity', d => getEdgeStyle({ type: d.type, score: d.score }, { theme: store.theme }).opacity)
+    .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }, { theme: store.theme }).strokeWidth)
+    .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }, { theme: store.theme }).strokeDasharray)
     .style('cursor', 'pointer')
     .on('mouseenter', (e, d) => {
       // hover 边,显示边详情并高亮
@@ -1354,7 +1358,7 @@ function renderWalkedLayer() {
     })
 
   // 绘制分数标签
-  const scoreData = edgesData.filter(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
+  const scoreData = edgesData.filter(d => getEdgeStyle({ type: d.type, score: d.score }, { theme: store.theme }).scoreText)
   const scoreGroups = walkedG.selectAll('.walked-score')
     .data(scoreData)
     .join('g')
@@ -1372,9 +1376,9 @@ function renderWalkedLayer() {
 
   scoreGroups.append('text')
     .attr('text-anchor', 'middle').attr('dy', '0.35em')
-    .attr('fill', d => getEdgeStyle({ type: d.type, score: d.score }).color)
+    .attr('fill', d => getEdgeStyle({ type: d.type, score: d.score }, { theme: store.theme }).color)
     .attr('font-size', '8px')
-    .text(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
+    .text(d => getEdgeStyle({ type: d.type, score: d.score }, { theme: store.theme }).scoreText)
 
   // ========== 绘制新节点(nodePositions 中的都是新节点) ==========
   const newNodesData = []
@@ -1405,7 +1409,7 @@ function renderWalkedLayer() {
   // 节点形状
   walkedNodeGroups.each(function(d) {
     const el = d3.select(this)
-    const style = getNodeStyle(d)
+    const style = getNodeStyle(d, { theme: store.theme })
     applyNodeShape(el, style).attr('class', 'walked-shape')
     nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
   })
@@ -1413,9 +1417,9 @@ function renderWalkedLayer() {
   // 节点标签
   walkedNodeGroups.append('text')
     .attr('dy', 4).attr('dx', 10).attr('text-anchor', 'start')
-    .attr('fill', d => getNodeStyle(d).text.fill)
-    .attr('font-size', d => getNodeStyle(d).text.fontSize)
-    .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
+    .attr('fill', d => getNodeStyle(d, { theme: store.theme }).text.fill)
+    .attr('font-size', d => getNodeStyle(d, { theme: store.theme }).text.fontSize)
+    .attr('font-weight', d => getNodeStyle(d, { theme: store.theme }).text.fontWeight)
     .text(d => {
       const name = d.name
       const maxLen = 10
@@ -2082,6 +2086,13 @@ watch(() => store.currentPostGraph, () => {
   })
 }, { immediate: false })
 
+// 监听主题变化,重新渲染
+watch(() => store.theme, () => {
+  nextTick(() => {
+    renderTree()
+  })
+})
+
 // 监听 selectedPostIndex 变化,同步下拉框
 watch(() => store.selectedPostIndex, (newIdx) => {
   selectedPostIdx.value = newIdx

+ 14 - 6
script/visualization/src/components/RelationView.vue

@@ -26,10 +26,13 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
-import { edgeTypeColors, getEdgeStyle, createArrowMarkers } from '../config/edgeStyle'
+import { getEdgeTypeColors, getEdgeStyle, createArrowMarkers } from '../config/edgeStyle'
 import { applyHoverHighlight as applyHoverHighlightUtil, applyEdgeHoverHighlight as applyEdgeHoverHighlightUtil, clearHoverHighlight } from '../utils/highlight'
 
 const store = useGraphStore()
+
+// 主题相关的颜色(响应式)
+const edgeTypeColors = computed(() => getEdgeTypeColors(store.theme))
 const containerRef = ref(null)
 const svgRef = ref(null)
 
@@ -404,7 +407,7 @@ function clearHighlight() {
 
   // 恢复箭头
   linkSelection.each(function(d) {
-    const style = getEdgeStyle(d)
+    const style = getEdgeStyle(d, { theme: store.theme })
     d3.select(this).attr('marker-end', style.markerId ? `url(#${style.markerId})` : null)
   })
 }
@@ -427,7 +430,7 @@ function renderGraph() {
 
   // 创建箭头标记(统一配置)
   const defs = svg.append('defs')
-  createArrowMarkers(defs)
+  createArrowMarkers(defs, { theme: store.theme })
 
   // 创建主组
   mainG = svg.append('g')
@@ -464,7 +467,7 @@ function renderGraph() {
     .join('line')
     .attr('class', 'graph-link')
     .each(function(d) {
-      const style = getEdgeStyle(d)
+      const style = getEdgeStyle(d, { theme: store.theme })
       d3.select(this)
         .attr('stroke', style.color)
         .attr('stroke-width', style.strokeWidth)
@@ -495,7 +498,7 @@ function renderGraph() {
 
   // 应用统一节点样式
   nodeSelection.each(function(d) {
-    const style = getNodeStyle(d)
+    const style = getNodeStyle(d, { theme: store.theme })
     applyNodeShape(d3.select(this), style)
   })
 
@@ -504,7 +507,7 @@ function renderGraph() {
     .attr('dy', 20)
     .attr('text-anchor', 'middle')
     .each(function(d) {
-      const style = getNodeStyle(d)
+      const style = getNodeStyle(d, { theme: store.theme })
       d3.select(this)
         .attr('font-size', style.text.fontSize)
         .attr('fill', style.text.fill)
@@ -586,6 +589,11 @@ watch(() => store.currentPostGraph, () => {
   nextTick(() => renderGraph())
 }, { deep: true })
 
+// 监听主题变化,重新渲染
+watch(() => store.theme, () => {
+  nextTick(() => renderGraph())
+})
+
 // 监听选中状态变化(联动其他视图的点击)
 watch(() => store.selectedNodeId, (newId, oldId) => {
   console.log('[RelationView] watch selectedNodeId:', newId, 'oldId:', oldId, 'local:', selectedNodeId.value)

+ 11 - 6
script/visualization/src/components/TreeView.vue

@@ -65,7 +65,7 @@
 import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
-import { dimColors, getNodeStyle, applyNodeShape } from '../config/nodeStyle'
+import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
 import { applyHighlight, applyHoverHighlight } from '../utils/highlight'
 
 const props = defineProps({
@@ -139,7 +139,7 @@ function selectNode(node) {
 
 // 根据节点数据获取颜色(用于搜索列表)
 function getNodeColorById(node) {
-  return getNodeStyle(node).color
+  return getNodeStyle(node, { theme: store.theme }).color
 }
 
 // 节点元素映射
@@ -236,7 +236,7 @@ function renderTree() {
   // 节点形状(使用统一配置)
   nodes.each(function(d) {
     const el = d3.select(this)
-    const style = getNodeStyle(d)
+    const style = getNodeStyle(d, { theme: store.theme })
     applyNodeShape(el, style).attr('class', 'tree-shape')
     nodeElements[d.data.id] = { element: this, x: d.y + 25, y: d.x + 25 }
   })
@@ -246,9 +246,9 @@ function renderTree() {
     .attr('dy', '0.31em')
     .attr('x', d => d.children ? -8 : 8)
     .attr('text-anchor', d => d.children ? 'end' : 'start')
-    .attr('fill', d => getNodeStyle(d).text.fill)
-    .attr('font-size', d => getNodeStyle(d).text.fontSize)
-    .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
+    .attr('fill', d => getNodeStyle(d, { theme: store.theme }).text.fill)
+    .attr('font-size', d => getNodeStyle(d, { theme: store.theme }).text.fontSize)
+    .attr('font-weight', d => getNodeStyle(d, { theme: store.theme }).text.fontWeight)
     .text(d => {
       const name = d.data.name
       const maxLen = d.children ? 6 : 8
@@ -428,6 +428,11 @@ function handleTransitionEnd(e) {
   }
 }
 
+// 监听主题变化,重新渲染
+watch(() => store.theme, () => {
+  nextTick(renderTree)
+})
+
 let transitionParent = null
 
 onMounted(() => {

+ 39 - 12
script/visualization/src/config/edgeStyle.js

@@ -1,7 +1,7 @@
 // 边样式统一配置
 
-// 所有边类型及颜色(使用 Tailwind 调色板,确保区分度
-export const edgeTypeColors = {
+// 暗色主题边颜色(使用 Tailwind 调色板 500 色阶
+const edgeTypeColorsDark = {
   '属于': '#8b5cf6',    // violet-500 - 紫罗兰
   '包含': '#3b82f6',    // blue-500 - 蓝色
   '标签共现': '#10b981', // emerald-500 - 翠绿
@@ -13,12 +13,37 @@ export const edgeTypeColors = {
   '关联': '#ec4899'     // pink-500 - 粉色
 }
 
+// 亮色主题边颜色(使用更深的 600 色阶以在亮背景上清晰可见)
+const edgeTypeColorsLight = {
+  '属于': '#7c3aed',    // violet-600 - 深紫罗兰
+  '包含': '#2563eb',    // blue-600 - 深蓝色
+  '标签共现': '#059669', // emerald-600 - 深翠绿
+  '分类共现': '#ca8a04', // yellow-600 - 深黄色
+  '匹配': '#e11d48',    // rose-600 - 深玫瑰红
+  '推导': '#0891b2',    // cyan-600 - 深青色
+  '组成': '#65a30d',    // lime-600 - 深柠檬绿
+  '支撑': '#ea580c',    // orange-600 - 深橙色
+  '关联': '#db2777'     // pink-600 - 深粉色
+}
+
+// 根据主题获取边颜色配置
+export function getEdgeTypeColors(theme = 'dark') {
+  return theme === 'light' ? edgeTypeColorsLight : edgeTypeColorsDark
+}
+
+// 保持向后兼容的默认导出
+export const edgeTypeColors = edgeTypeColorsDark
+
 // 获取边样式(统一入口)
 // edge: { type, score, ... }
-export function getEdgeStyle(edge) {
+// options: { theme }
+export function getEdgeStyle(edge, options = {}) {
+  const { theme = 'dark' } = options
+  const colors = getEdgeTypeColors(theme)
+
   const type = edge.type || ''
   const score = edge.score
-  const color = edgeTypeColors[type] || '#666'
+  const color = colors[type] || (theme === 'light' ? '#999' : '#666')
 
   // 匹配边:>=0.8 实线,否则虚线
   let strokeDasharray = 'none'
@@ -30,17 +55,17 @@ export function getEdgeStyle(edge) {
   // 组成边:实线样式(和推导边一样)
 
   // 不同边类型的透明度计算
-  let opacity = 0.3
+  let opacity = theme === 'light' ? 0.5 : 0.3
   if (type === '匹配') {
-    opacity = Math.max(0.3, score * 0.7)
+    opacity = Math.max(theme === 'light' ? 0.5 : 0.3, score * (theme === 'light' ? 0.9 : 0.7))
   } else if (type === '推导') {
-    opacity = Math.max(0.4, score * 0.8)
+    opacity = Math.max(theme === 'light' ? 0.6 : 0.4, score * (theme === 'light' ? 1 : 0.8))
   } else if (type === '组成') {
-    opacity = 0.6
+    opacity = theme === 'light' ? 0.8 : 0.6
   } else if (type === '支撑') {
-    opacity = 0.7
+    opacity = theme === 'light' ? 0.85 : 0.7
   } else if (type === '关联') {
-    opacity = 0.6
+    opacity = theme === 'light' ? 0.8 : 0.6
   }
 
 
@@ -63,11 +88,13 @@ export function getEdgeStyle(edge) {
 }
 
 // 创建箭头标记定义(供 SVG defs 使用)
-export function createArrowMarkers(defs) {
+// options: { theme }
+export function createArrowMarkers(defs, options = {}) {
+  const { theme = 'dark' } = options
   const arrowTypes = ['推导', '支撑']
 
   arrowTypes.forEach(type => {
-    const style = getEdgeStyle({ type, score: 1 })
+    const style = getEdgeStyle({ type, score: 1 }, { theme })
 
     // 正常箭头
     defs.append('marker')

+ 36 - 9
script/visualization/src/config/nodeStyle.js

@@ -3,7 +3,9 @@
 // 节点类型/维度颜色映射(使用 Tailwind 调色板,与边颜色区分)
 // 边用 500 色阶,节点用 600 色阶以区分
 // 注意:标签和分类节点的颜色由其 dimension 决定,不单独定义
-export const dimColors = {
+
+// 暗色主题颜色(与当前颜色一致)
+const dimColorsDark = {
   '人设': '#dc2626',    // red-600 - 深红
   '帖子': '#dc2626',    // red-600 - 深红
   '灵感点': '#7c3aed',  // violet-600 - 深紫
@@ -12,19 +14,40 @@ export const dimColors = {
   '组合': '#d97706'     // amber-600 - 深琥珀
 }
 
+// 亮色主题颜色(使用更深的色阶以在亮背景上清晰可见)
+const dimColorsLight = {
+  '人设': '#b91c1c',    // red-700 - 更深红
+  '帖子': '#b91c1c',    // red-700 - 更深红
+  '灵感点': '#6d28d9',  // violet-700 - 更深紫
+  '目的点': '#1d4ed8',  // blue-700 - 更深蓝
+  '关键点': '#0f766e',  // teal-700 - 更深青绿
+  '组合': '#b45309'     // amber-700 - 更深琥珀
+}
+
+// 根据主题获取颜色配置
+export function getDimColors(theme = 'dark') {
+  return theme === 'light' ? dimColorsLight : dimColorsDark
+}
+
+// 保持向后兼容的默认导出
+export const dimColors = dimColorsDark
+
 // 获取节点样式(统一入口)
 // node: d3 hierarchy 节点或普通对象
-// options: { isCenter, isMatch }
+// options: { isCenter, isMatch, theme }
 export function getNodeStyle(node, options = {}) {
   const data = node.data || node
-  const { isCenter = false, isMatch = false } = options
+  const { isCenter = false, isMatch = false, theme = 'dark' } = options
+
+  // 根据主题获取颜色配置
+  const colors = getDimColors(theme)
 
   // 颜色
-  let color = '#888'
-  if (data.type && dimColors[data.type]) {
-    color = dimColors[data.type]
-  } else if (data.dimension && dimColors[data.dimension]) {
-    color = dimColors[data.dimension]
+  let color = theme === 'light' ? '#666' : '#888'
+  if (data.type && colors[data.type]) {
+    color = colors[data.type]
+  } else if (data.dimension && colors[data.dimension]) {
+    color = colors[data.dimension]
   }
 
   // 形状(分类用方形,标签和其他用圆形)
@@ -43,8 +66,12 @@ export function getNodeStyle(node, options = {}) {
 
   // 文字样式
   const isHighlight = isRoot || isDimension
+  const textColor = theme === 'light'
+    ? (isMatch || !isHighlight ? '#555' : color)
+    : (isMatch || !isHighlight ? '#bbb' : color)
+
   const text = {
-    fill: (isMatch || !isHighlight) ? '#bbb' : color,
+    fill: textColor,
     fontSize: isRoot ? '11px' : (isDimension ? '10px' : '9px'),
     fontWeight: isHighlight ? 'bold' : 'normal'
   }

+ 15 - 0
script/visualization/src/stores/graph.js

@@ -16,6 +16,17 @@ console.log('帖子图谱数:', postGraphListRaw.length)
 console.log('推导图谱版本:', derivationVersionsRaw)
 
 export const useGraphStore = defineStore('graph', () => {
+  // ==================== 主题 ====================
+  const theme = ref('dark')  // 'dark' | 'light'
+
+  function toggleTheme() {
+    theme.value = theme.value === 'dark' ? 'light' : 'dark'
+  }
+
+  function setTheme(newTheme) {
+    theme.value = newTheme
+  }
+
   // ==================== 数据 ====================
   const graphData = ref(graphDataRaw || { nodes: {}, edges: {}, index: {}, tree: {} })
 
@@ -1174,6 +1185,10 @@ export const useGraphStore = defineStore('graph', () => {
   }
 
   return {
+    // 主题
+    theme,
+    toggleTheme,
+    setTheme,
     // 数据
     graphData,
     treeData,

+ 1 - 1
script/visualization/tailwind.config.js

@@ -17,6 +17,6 @@ export default {
   },
   plugins: [require("daisyui")],
   daisyui: {
-    themes: ["dark"],
+    themes: ["dark", "light"],
   },
 }