Toggle navigation
Toggle navigation
This project
Loading...
Sign in
万朱浩
/
Venue-Ops
Go to a project
Toggle navigation
Projects
Groups
Snippets
Help
Toggle navigation pinning
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
马一丁
2025-12-20 15:25:22 +0800
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
b60ee3dad385cdf3d0f88e4c93b0d2340ac71396
b60ee3da
1 parent
927c41c7
Enhance the full-screen viewing style
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
892 additions
and
846 deletions
templates/graph_viewer.html
templates/graph_viewer.html
View file @
b60ee3d
...
...
@@ -6,377 +6,425 @@
<title>
知识图谱可视化 - BettaFish
</title>
<!-- Vis.js -->
<script
src=
"https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"
></script>
<!-- Google Fonts -->
<link
href=
"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap"
rel=
"stylesheet"
>
<style>
:root
{
--primary-color
:
#4F46E5
;
--primary-light
:
#818CF8
;
--bg-color
:
#0F172A
;
--card-bg
:
#1E293B
;
--text-color
:
#F1F5F9
;
--text-muted
:
#94A3B8
;
--bg-color
:
#0f172a
;
--sidebar-bg
:
#1e293b
;
--card-bg
:
#1e293b
;
--border-color
:
#334155
;
--success-color
:
#10B981
;
--warning-color
:
#F59E0B
;
--error-color
:
#EF4444
;
--primary-color
:
#6366f1
;
--primary-hover
:
#4f46e5
;
--text-main
:
#f8fafc
;
--text-sec
:
#94a3b8
;
--success
:
#10b981
;
--warning
:
#f59e0b
;
--error
:
#ef4444
;
--shadow-sm
:
0
1px
2px
0
rgb
(
0
0
0
/
0.05
);
--shadow-md
:
0
4px
6px
-1px
rgb
(
0
0
0
/
0.1
),
0
2px
4px
-2px
rgb
(
0
0
0
/
0.1
);
--shadow-lg
:
0
10px
15px
-3px
rgb
(
0
0
0
/
0.1
),
0
4px
6px
-4px
rgb
(
0
0
0
/
0.1
);
}
*
{
margin
:
0
;
padding
:
0
;
box-sizing
:
border-box
;
outline
:
none
;
}
body
{
font-family
:
-apple-system
,
BlinkMacSystemFont
,
'Segoe UI'
,
Roboto
,
'Helvetica Neue'
,
Arial
,
sans-serif
;
font-family
:
'Inter'
,
-apple-system
,
BlinkMacSystemFont
,
sans-serif
;
background-color
:
var
(
--bg-color
);
color
:
var
(
--text-color
);
min-height
:
100vh
;
color
:
var
(
--text-main
);
height
:
100vh
;
overflow
:
hidden
;
font-size
:
14px
;
}
/* 顶部工具栏 */
.toolbar
{
position
:
fixed
;
top
:
0
;
left
:
0
;
right
:
0
;
height
:
60px
;
background
:
var
(
--card-bg
);
border-bottom
:
1px
solid
var
(
--border-color
);
/* 布局容器 */
.app-container
{
display
:
flex
;
align-items
:
center
;
padding
:
0
20px
;
gap
:
16px
;
z-index
:
1000
;
height
:
100vh
;
width
:
100vw
;
}
.toolbar
h1
{
font-size
:
1.25rem
;
font-weight
:
600
;
/* 侧边栏 */
.sidebar
{
width
:
320px
;
background
:
var
(
--sidebar-bg
);
border-right
:
1px
solid
var
(
--border-color
);
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
flex-direction
:
column
;
z-index
:
20
;
transition
:
transform
0.3s
ease
;
box-shadow
:
var
(
--shadow-lg
);
}
.toolbar
h1
svg
{
width
:
24px
;
height
:
24px
;
color
:
var
(
--primary-color
);
.sidebar.collapsed
{
transform
:
translateX
(
-320px
);
position
:
absolute
;
height
:
100%
;
}
.toolbar-divider
{
width
:
1px
;
height
:
30px
;
background
:
var
(
--border-color
);
.sidebar-header
{
padding
:
20px
;
border-bottom
:
1px
solid
var
(
--border-color
);
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
}
.btn
{
.app-title
{
font-size
:
1.1rem
;
font-weight
:
600
;
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
8px
16px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
6px
;
background
:
transparent
;
color
:
var
(
--text-color
);
cursor
:
pointer
;
font-size
:
0.875rem
;
transition
:
all
0.2s
;
gap
:
10px
;
color
:
var
(
--text-main
);
}
.btn
:hover
{
background
:
var
(
--primary-color
);
border-color
:
var
(
--primary-color
);
.app-title
svg
{
color
:
var
(
--primary-color
);
width
:
24px
;
height
:
24px
;
}
.btn-primary
{
background
:
var
(
--primary-color
);
border-color
:
var
(
--primary-color
);
/* 侧边栏内容区 */
.sidebar-content
{
flex
:
1
;
overflow-y
:
auto
;
padding
:
0
;
}
.btn
svg
{
width
:
16px
;
height
:
16px
;
.section
{
padding
:
20px
;
border-bottom
:
1px
solid
var
(
--border-color
);
}
.search-box
{
flex
:
1
;
max-width
:
400px
;
.section-title
{
font-size
:
0.75rem
;
font-weight
:
600
;
text-transform
:
uppercase
;
letter-spacing
:
0.05em
;
color
:
var
(
--text-sec
);
margin-bottom
:
12px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
}
/* 搜索框 */
.search-wrapper
{
position
:
relative
;
margin-bottom
:
10px
;
}
.search-
box
input
{
.search-input
{
width
:
100%
;
padding
:
8px
16px
8px
40px
;
background
:
rgba
(
15
,
23
,
42
,
0.5
)
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
6px
;
background
:
var
(
--bg-color
);
color
:
var
(
--text-color
);
font-size
:
0.875rem
;
padding
:
10px
36px
10px
12px
;
border-radius
:
8px
;
color
:
var
(
--text-main
);
font-size
:
0.9rem
;
transition
:
all
0.2s
;
}
.search-box
input
:focus
{
outline
:
none
;
.search-input
:focus
{
border-color
:
var
(
--primary-color
);
box-shadow
:
0
0
0
2px
rgba
(
99
,
102
,
241
,
0.2
);
}
.search-
box
svg
{
.search-
icon
{
position
:
absolute
;
lef
t
:
12px
;
righ
t
:
12px
;
top
:
50%
;
transform
:
translateY
(
-50%
);
width
:
16px
;
height
:
16px
;
color
:
var
(
--text-muted
);
}
.search-group
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
flex
:
1
;
max-width
:
560px
;
color
:
var
(
--text-sec
);
pointer-events
:
none
;
}
.search-
action
s
{
.search-
control
s
{
display
:
flex
;
gap
:
8px
;
align-items
:
center
;
gap
:
6
px
;
margin-top
:
8
px
;
}
.search-status
{
min-width
:
44px
;
text-align
:
center
;
font-size
:
0.8rem
;
color
:
var
(
--text-muted
);
color
:
var
(
--text-sec
);
margin-left
:
auto
;
}
/* 统计信息 */
.stats
{
/* 过滤器 */
.filter-list
{
display
:
flex
;
gap
:
16px
;
margin-left
:
auto
;
flex-direction
:
column
;
gap
:
8px
;
}
.
stat
-item
{
.
filter
-item
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
0.875rem
;
padding
:
8px
12px
;
background
:
rgba
(
15
,
23
,
42
,
0.3
);
border-radius
:
6px
;
cursor
:
pointer
;
transition
:
background
0.2s
;
border
:
1px
solid
transparent
;
}
.stat-item
.label
{
color
:
var
(
--text-muted
);
.filter-item
:hover
{
background
:
rgba
(
15
,
23
,
42
,
0.6
);
border-color
:
var
(
--border-color
);
}
.stat-item
.value
{
font-weight
:
600
;
color
:
var
(
--primary-light
);
.filter-item
input
{
display
:
none
;
}
/* 左侧面板 */
.sidebar
{
position
:
fixed
;
top
:
60px
;
left
:
0
;
width
:
300px
;
bottom
:
0
;
background
:
var
(
--card-bg
);
border-right
:
1px
solid
var
(
--border-color
);
overflow-y
:
auto
;
padding
:
16px
;
transition
:
transform
0.3s
;
z-index
:
100
;
.filter-item.active
{
background
:
rgba
(
99
,
102
,
241
,
0.1
);
border-color
:
rgba
(
99
,
102
,
241
,
0.3
);
}
.sidebar.collapsed
{
transform
:
translateX
(
-100%
);
/* 未选中时灰显彩点 */
.filter-item
:not
(
.active
)
.filter-dot
{
background-color
:
var
(
--text-sec
)
!important
;
box-shadow
:
none
;
}
.sidebar
h3
{
font-size
:
0.875rem
;
font-weight
:
600
;
color
:
var
(
--text-muted
);
text-transform
:
uppercase
;
letter-spacing
:
0.05em
;
margin-bottom
:
12px
;
.filter-item
:not
(
.active
)
.filter-name
,
.filter-item
:not
(
.active
)
.filter-count
{
color
:
var
(
--text-sec
);
opacity
:
0.7
;
}
.filter-group
{
margin-bottom
:
20px
;
.filter-dot
{
width
:
10px
;
height
:
10px
;
border-radius
:
50%
;
margin-right
:
10px
;
box-shadow
:
0
0
8px
currentColor
;
}
.filter-item
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
padding
:
8px
0
;
cursor
:
pointer
;
.filter-name
{
flex
:
1
;
font-weight
:
500
;
}
.filter-item
input
[
type
=
"checkbox"
]
{
width
:
16px
;
height
:
16px
;
accent-color
:
var
(
--primary-color
);
.filter-count
{
font-size
:
0.75rem
;
color
:
var
(
--text-sec
);
background
:
rgba
(
255
,
255
,
255
,
0.1
);
padding
:
2px
6px
;
border-radius
:
4px
;
}
.filter-item
.color-dot
{
width
:
12px
;
height
:
12px
;
border-radius
:
50%
;
/* 节点详情卡片 */
.node-details
{
display
:
none
;
animation
:
fadeIn
0.3s
ease
;
}
.filter-item
.count
{
margin-left
:
auto
;
font-size
:
0.75rem
;
color
:
var
(
--text-muted
);
.detail-card
{
background
:
rgba
(
15
,
23
,
42
,
0.3
);
border-radius
:
8px
;
padding
:
16px
;
border
:
1px
solid
var
(
--border-color
);
}
/* 节点详情 */
.node-detail
{
margin-top
:
20px
;
padding-top
:
20px
;
border-top
:
1px
solid
var
(
--border-color
);
.detail-header
{
margin-bottom
:
12px
;
padding-bottom
:
12px
;
border-bottom
:
1px
solid
rgba
(
255
,
255
,
255
,
0.1
);
}
.node-detail
.detail-title
{
.detail-title
{
font-size
:
1rem
;
font-weight
:
600
;
color
:
var
(
--primary-color
);
margin-bottom
:
4px
;
word-break
:
break-all
;
}
.detail-badge
{
display
:
inline-block
;
font-size
:
0.7rem
;
padding
:
2px
8px
;
border-radius
:
12px
;
background
:
var
(
--border-color
);
color
:
var
(
--text-sec
);
}
.prop-row
{
margin-bottom
:
8px
;
color
:
var
(
--primary-light
);
}
.
node-detail
.detail-type
{
.
prop-label
{
font-size
:
0.75rem
;
color
:
var
(
--text-muted
);
margin-bottom
:
12px
;
color
:
var
(
--text-sec
);
margin-bottom
:
2px
;
}
.node-detail
.detail-props
{
font-size
:
0.875rem
;
.prop-value
{
font-size
:
0.85rem
;
color
:
var
(
--text-main
);
word-break
:
break-word
;
line-height
:
1.4
;
}
.node-detail
.prop-item
{
padding
:
6px
0
;
border-bottom
:
1px
solid
var
(
--border-color
);
/* 统计数据 */
.stats-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
12px
;
}
.node-detail
.prop-key
{
color
:
var
(
--text-muted
);
font-size
:
0.75rem
;
.stat-box
{
background
:
rgba
(
15
,
23
,
42
,
0.3
);
padding
:
12px
;
border-radius
:
8px
;
text-align
:
center
;
border
:
1px
solid
var
(
--border-color
);
}
.node-detail
.prop-value
{
margin-top
:
2px
;
word-break
:
break-all
;
.stat-value
{
font-size
:
1.25rem
;
font-weight
:
700
;
color
:
var
(
--text-main
);
}
/* 图谱容器 */
.graph-container
{
position
:
fixed
;
top
:
60px
;
left
:
300px
;
right
:
0
;
bottom
:
0
;
transition
:
left
0.3s
;
.stat-label
{
font-size
:
0.75rem
;
color
:
var
(
--text-sec
);
margin-top
:
4px
;
}
.graph-container.fullwidth
{
left
:
0
;
/* 主画布区 */
.main-content
{
flex
:
1
;
position
:
relative
;
background
:
var
(
--bg-color
);
overflow
:
hidden
;
}
#network
{
width
:
100%
;
height
:
100%
;
background
:
var
(
--bg-color
);
}
/* 加载状态 */
.loading-overlay
{
/* 浮动工具栏 */
.floating-toolbar
{
position
:
absolute
;
top
:
0
;
left
:
0
;
right
:
0
;
bottom
:
0
;
background
:
var
(
--card-bg
);
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
padding
:
6px
;
display
:
flex
;
gap
:
4px
;
box-shadow
:
var
(
--shadow-lg
);
z-index
:
10
;
}
.top-right
{
top
:
20px
;
right
:
20px
;
}
.bottom-right
{
bottom
:
20px
;
right
:
20px
;
flex-direction
:
column
;
align-items
:
center
;
justify-content
:
center
;
background
:
var
(
--bg-color
);
z-index
:
500
;
}
.bottom-left
{
bottom
:
20px
;
left
:
20px
;
/* 当侧边栏存在时,需要调整left */
left
:
340px
;
transition
:
left
0.3s
ease
;
}
.loading-spinner
{
width
:
48px
;
height
:
48px
;
border
:
4px
solid
var
(
--border-color
);
border-top-color
:
var
(
--primary-color
);
border-radius
:
50%
;
animation
:
spin
1s
linear
infinite
;
.sidebar.collapsed
~
.main-content
.bottom-left
{
left
:
20px
;
}
.top-left-toggle
{
position
:
absolute
;
top
:
20px
;
left
:
20px
;
z-index
:
30
;
display
:
none
;
/* 默认隐藏,折叠时显示 */
}
@keyframes
spin
{
to
{
transform
:
rotate
(
360deg
);
}
.sidebar.collapsed
~
.main-content
.top-left-toggle
{
display
:
block
;
}
.loading-text
{
margin-top
:
16px
;
color
:
var
(
--text-muted
);
/* 按钮样式 */
.btn-icon
{
width
:
36px
;
height
:
36px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
border
:
1px
solid
transparent
;
background
:
transparent
;
color
:
var
(
--text-sec
);
border-radius
:
6px
;
cursor
:
pointer
;
transition
:
all
0.2s
;
}
/* 空状态 */
.empty-state
{
position
:
absolute
;
top
:
50%
;
left
:
50%
;
transform
:
translate
(
-50%
,
-50%
);
text-align
:
center
;
color
:
var
(
--text-muted
);
.btn-icon
:hover
{
background
:
rgba
(
255
,
255
,
255
,
0.1
);
color
:
var
(
--text-main
);
}
.empty-state
svg
{
width
:
64px
;
height
:
64px
;
margin-bottom
:
16px
;
opacity
:
0.5
;
.btn-icon.primary
{
background
:
var
(
--primary-color
);
color
:
white
;
}
.btn-icon.primary
:hover
{
background
:
var
(
--primary-hover
);
}
/* 提示信息 */
.toast
{
position
:
fixed
;
bottom
:
20px
;
right
:
20px
;
padding
:
12px
20px
;
background
:
var
(
--card-bg
);
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
display
:
none
;
animation
:
slideIn
0.3s
;
z-index
:
2000
;
.btn-icon
:disabled
{
opacity
:
0.5
;
cursor
:
not-allowed
;
}
@keyframes
slideIn
{
from
{
transform
:
translateX
(
100%
);
opacity
:
0
;
}
.btn-sm
{
padding
:
4px
12px
;
height
:
32px
;
width
:
auto
;
font-size
:
0.8rem
;
}
/* 图例 */
.legend
{
position
:
fixed
;
bottom
:
20px
;
left
:
320px
;
background
:
var
(
--card-bg
);
.legend-panel
{
background
:
rgba
(
30
,
41
,
59
,
0.9
);
backdrop-filter
:
blur
(
8px
);
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
padding
:
12px
16px
;
display
:
flex
;
gap
:
16px
;
z-index
:
100
;
transition
:
left
0.3s
;
padding
:
12px
;
}
.legend.fullwidth
{
left
:
20px
;
.legend-title
{
font-size
:
0.75rem
;
font-weight
:
600
;
color
:
var
(
--text-sec
);
margin-bottom
:
8px
;
}
.legend-items
{
display
:
flex
;
gap
:
16px
;
flex-wrap
:
wrap
;
}
.legend-item
{
...
...
@@ -384,729 +432,727 @@
align-items
:
center
;
gap
:
6px
;
font-size
:
0.75rem
;
color
:
var
(
--text-main
);
}
.legend-item
.dot
{
width
:
10px
;
height
:
10px
;
.legend-dot
{
width
:
8px
;
height
:
8px
;
border-radius
:
2px
;
}
/* 加载与空状态 */
.overlay-message
{
position
:
absolute
;
top
:
0
;
left
:
0
;
right
:
0
;
bottom
:
0
;
background
:
rgba
(
15
,
17
,
42
,
0.8
);
backdrop-filter
:
blur
(
4px
);
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
justify-content
:
center
;
z-index
:
50
;
}
.spinner
{
width
:
40px
;
height
:
40px
;
border
:
3px
solid
rgba
(
99
,
102
,
241
,
0.3
);
border-radius
:
50%
;
border-top-color
:
var
(
--primary-color
);
animation
:
spin
1s
ease-in-out
infinite
;
margin-bottom
:
16px
;
}
.empty-icon
{
width
:
64px
;
height
:
64px
;
color
:
var
(
--text-sec
);
opacity
:
0.5
;
margin-bottom
:
16px
;
}
/* 全屏模式 */
.fullscreen-btn
{
.toast
{
position
:
fixed
;
bottom
:
20px
;
right
:
20px
;
left
:
50%
;
transform
:
translateX
(
-50%
)
translateY
(
100px
);
background
:
var
(
--card-bg
);
border
:
1px
solid
var
(
--primary-color
);
color
:
var
(
--text-main
);
padding
:
10px
24px
;
border-radius
:
50px
;
box-shadow
:
var
(
--shadow-lg
);
z-index
:
100
;
transition
:
transform
0.3s
cubic-bezier
(
0.175
,
0.885
,
0.32
,
1.275
);
font-weight
:
500
;
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
}
.toast.show
{
transform
:
translateX
(
-50%
)
translateY
(
0
);
}
/* 节点类型颜色 */
.color-topic
{
background-color
:
#EF4444
;
}
.color-engine
{
background-color
:
#F59E0B
;
}
.color-section
{
background-color
:
#10B981
;
}
.color-search_query
{
background-color
:
#3B82F6
;
}
.color-source
{
background-color
:
#8B5CF6
;
}
@keyframes
spin
{
to
{
transform
:
rotate
(
360deg
);
}
}
@keyframes
fadeIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
5px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
/* 颜色定义 */
.color-topic
{
color
:
#EF4444
;
background-color
:
#EF4444
;
}
.color-engine
{
color
:
#F59E0B
;
background-color
:
#F59E0B
;
}
.color-section
{
color
:
#10B981
;
background-color
:
#10B981
;
}
.color-search_query
{
color
:
#3B82F6
;
background-color
:
#3B82F6
;
}
.color-source
{
color
:
#8B5CF6
;
background-color
:
#8B5CF6
;
}
</style>
</head>
<body>
<!-- 顶部工具栏 -->
<div
class=
"toolbar"
>
<h1>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<circle
cx=
"12"
cy=
"5"
r=
"3"
/>
<circle
cx=
"5"
cy=
"19"
r=
"3"
/>
<circle
cx=
"19"
cy=
"19"
r=
"3"
/>
<line
x1=
"12"
y1=
"8"
x2=
"5"
y2=
"16"
/>
<line
x1=
"12"
y1=
"8"
x2=
"19"
y2=
"16"
/>
</svg>
知识图谱
</h1>
<div
class=
"toolbar-divider"
></div>
<button
class=
"btn"
id=
"toggleSidebar"
title=
"切换侧边栏"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<rect
x=
"3"
y=
"3"
width=
"18"
height=
"18"
rx=
"2"
/>
<line
x1=
"9"
y1=
"3"
x2=
"9"
y2=
"21"
/>
</svg>
</button>
<button
class=
"btn"
id=
"fitBtn"
title=
"适应视图"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"
/>
</svg>
适应
</button>
<button
class=
"btn"
id=
"zoomInBtn"
title=
"放大"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<circle
cx=
"11"
cy=
"11"
r=
"8"
/>
<line
x1=
"21"
y1=
"21"
x2=
"16.65"
y2=
"16.65"
/>
<line
x1=
"11"
y1=
"8"
x2=
"11"
y2=
"14"
/>
<line
x1=
"8"
y1=
"11"
x2=
"14"
y2=
"11"
/>
</svg>
</button>
<button
class=
"btn"
id=
"zoomOutBtn"
title=
"缩小"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<circle
cx=
"11"
cy=
"11"
r=
"8"
/>
<line
x1=
"21"
y1=
"21"
x2=
"16.65"
y2=
"16.65"
/>
<line
x1=
"8"
y1=
"11"
x2=
"14"
y2=
"11"
/>
</svg>
</button>
<button
class=
"btn"
id=
"manualRefreshBtn"
title=
"手动刷新图谱"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<polyline
points=
"23 4 23 10 17 10"
/>
<polyline
points=
"1 20 1 14 7 14"
/>
<path
d=
"M3.51 9a9 9 0 0 1 14.85-3.36L23 10"
/>
<path
d=
"M20.49 15a9 9 0 0 1-14.85 3.36L1 14"
/>
</svg>
刷新
</button>
<div
class=
"search-group"
>
<div
class=
"search-box"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<circle
cx=
"11"
cy=
"11"
r=
"8"
/>
<line
x1=
"21"
y1=
"21"
x2=
"16.65"
y2=
"16.65"
/>
</svg>
<input
type=
"text"
id=
"searchInput"
placeholder=
"搜索节点..."
>
<div
class=
"app-container"
>
<!-- 左侧边栏 -->
<aside
class=
"sidebar"
id=
"sidebar"
>
<div
class=
"sidebar-header"
>
<div
class=
"app-title"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline
points=
"3.27 6.96 12 12.01 20.73 6.96"
></polyline>
<line
x1=
"12"
y1=
"22.08"
x2=
"12"
y2=
"12"
></line>
</svg>
<span>
知识图谱
</span>
</div>
<button
class=
"btn-icon"
id=
"toggleSidebar"
title=
"收起侧边栏"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M15 18l-6-6 6-6"
/>
</svg>
</button>
</div>
<div
class=
"search-actions"
>
<button
class=
"btn"
id=
"searchBtn"
title=
"搜索"
>
搜索
</button>
<button
class=
"btn"
id=
"searchPrevBtn"
title=
"上一个"
>
↑
</button>
<button
class=
"btn"
id=
"searchNextBtn"
title=
"下一个"
>
↓
</button>
<span
class=
"search-status"
id=
"searchStatus"
>
0/0
</span>
<div
class=
"sidebar-content"
>
<!-- 搜索区域 -->
<div
class=
"section"
>
<div
class=
"section-title"
>
搜索节点
</div>
<div
class=
"search-wrapper"
>
<input
type=
"text"
id=
"searchInput"
class=
"search-input"
placeholder=
"输入关键词..."
>
<svg
class=
"search-icon"
width=
"16"
height=
"16"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<circle
cx=
"11"
cy=
"11"
r=
"8"
></circle>
<line
x1=
"21"
y1=
"21"
x2=
"16.65"
y2=
"16.65"
></line>
</svg>
</div>
<div
class=
"search-controls"
>
<button
class=
"btn-icon btn-sm"
id=
"searchPrevBtn"
disabled
title=
"上一个"
>
<svg
width=
"16"
height=
"16"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
><polyline
points=
"18 15 12 9 6 15"
></polyline></svg>
</button>
<button
class=
"btn-icon btn-sm"
id=
"searchNextBtn"
disabled
title=
"下一个"
>
<svg
width=
"16"
height=
"16"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
><polyline
points=
"6 9 12 15 18 9"
></polyline></svg>
</button>
<span
class=
"search-status"
id=
"searchStatus"
></span>
</div>
</div>
<!-- 节点详情 -->
<div
class=
"section node-details"
id=
"nodeDetailPanel"
>
<div
class=
"section-title"
>
<span>
选定节点
</span>
<button
class=
"btn-icon btn-sm"
onclick=
"network.unselectAll(); hideNodeDetail();"
title=
"取消选择"
>
<svg
width=
"14"
height=
"14"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
><line
x1=
"18"
y1=
"6"
x2=
"6"
y2=
"18"
></line><line
x1=
"6"
y1=
"6"
x2=
"18"
y2=
"18"
></line></svg>
</button>
</div>
<div
class=
"detail-card"
>
<div
class=
"detail-header"
>
<div
class=
"detail-title"
id=
"detailTitle"
></div>
<span
class=
"detail-badge"
id=
"detailType"
></span>
</div>
<div
id=
"detailProps"
></div>
</div>
</div>
<!-- 过滤器 -->
<div
class=
"section"
>
<div
class=
"section-title"
>
显示节点
</div>
<div
class=
"filter-list"
id=
"filterList"
>
<!-- 动态生成 -->
<label
class=
"filter-item active"
>
<input
type=
"checkbox"
checked
data-type=
"topic"
>
<span
class=
"filter-dot color-topic"
></span>
<span
class=
"filter-name"
>
主题 (Topic)
</span>
<span
class=
"filter-count"
id=
"count-topic"
>
0
</span>
</label>
<label
class=
"filter-item active"
>
<input
type=
"checkbox"
checked
data-type=
"engine"
>
<span
class=
"filter-dot color-engine"
></span>
<span
class=
"filter-name"
>
分析引擎 (Engine)
</span>
<span
class=
"filter-count"
id=
"count-engine"
>
0
</span>
</label>
<label
class=
"filter-item active"
>
<input
type=
"checkbox"
checked
data-type=
"section"
>
<span
class=
"filter-dot color-section"
></span>
<span
class=
"filter-name"
>
报告段落 (Section)
</span>
<span
class=
"filter-count"
id=
"count-section"
>
0
</span>
</label>
<label
class=
"filter-item active"
>
<input
type=
"checkbox"
checked
data-type=
"search_query"
>
<span
class=
"filter-dot color-search_query"
></span>
<span
class=
"filter-name"
>
搜索词 (Query)
</span>
<span
class=
"filter-count"
id=
"count-search_query"
>
0
</span>
</label>
<label
class=
"filter-item active"
>
<input
type=
"checkbox"
checked
data-type=
"source"
>
<span
class=
"filter-dot color-source"
></span>
<span
class=
"filter-name"
>
数据来源 (Source)
</span>
<span
class=
"filter-count"
id=
"count-source"
>
0
</span>
</label>
</div>
</div>
<!-- 统计信息 -->
<div
class=
"section"
>
<div
class=
"section-title"
>
图谱统计
</div>
<div
class=
"stats-grid"
>
<div
class=
"stat-box"
>
<div
class=
"stat-value"
id=
"totalNodes"
>
0
</div>
<div
class=
"stat-label"
>
节点总数
</div>
</div>
<div
class=
"stat-box"
>
<div
class=
"stat-value"
id=
"totalEdges"
>
0
</div>
<div
class=
"stat-label"
>
关系总数
</div>
</div>
</div>
</div>
</div>
</div>
<div
class=
"stats"
id=
"statsContainer"
>
<div
class=
"stat-item"
>
<span
class=
"label"
>
节点
</span>
<span
class=
"value"
id=
"nodeCount"
>
0
</span>
</aside>
<!-- 主画布 -->
<main
class=
"main-content"
>
<!-- 侧边栏展开按钮 -->
<button
class=
"btn-icon floating-toolbar top-left-toggle"
id=
"expandSidebar"
title=
"展开侧边栏"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M9 18l6-6-6-6"
/>
</svg>
</button>
<!-- 右上角工具栏 -->
<div
class=
"floating-toolbar top-right"
>
<button
class=
"btn-icon"
id=
"refreshBtn"
title=
"刷新图谱"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M23 4v6h-6"
></path><path
d=
"M1 20v-6h6"
></path>
<path
d=
"M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
></path>
</svg>
</button>
<button
class=
"btn-icon"
id=
"fullscreenBtn"
title=
"全屏模式"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2 2v-3M3 16v3a2 2 0 0 0 2 2h3"
></path>
</svg>
</button>
</div>
<div
class=
"stat-item"
>
<span
class=
"label"
>
关系
</span>
<span
class=
"value"
id=
"edgeCount"
>
0
</span>
<!-- 右下角缩放控制 -->
<div
class=
"floating-toolbar bottom-right"
>
<button
class=
"btn-icon"
id=
"zoomInBtn"
title=
"放大"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<line
x1=
"12"
y1=
"5"
x2=
"12"
y2=
"19"
></line><line
x1=
"5"
y1=
"12"
x2=
"19"
y2=
"12"
></line>
</svg>
</button>
<button
class=
"btn-icon"
id=
"zoomOutBtn"
title=
"缩小"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<line
x1=
"5"
y1=
"12"
x2=
"19"
y2=
"12"
></line>
</svg>
</button>
<div
style=
"height: 1px; background: var(--border-color); margin: 4px 2px;"
></div>
<button
class=
"btn-icon"
id=
"fitBtn"
title=
"适应视图"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"
></path>
</svg>
</button>
</div>
</div>
</div>
<!-- 左侧面板 -->
<div
class=
"sidebar"
id=
"sidebar"
>
<div
class=
"filter-group"
>
<h3>
节点类型
</h3>
<label
class=
"filter-item"
>
<input
type=
"checkbox"
checked
data-type=
"topic"
>
<span
class=
"color-dot color-topic"
></span>
<span>
主题
</span>
<span
class=
"count"
id=
"count-topic"
>
0
</span>
</label>
<label
class=
"filter-item"
>
<input
type=
"checkbox"
checked
data-type=
"engine"
>
<span
class=
"color-dot color-engine"
></span>
<span>
分析引擎
</span>
<span
class=
"count"
id=
"count-engine"
>
0
</span>
</label>
<label
class=
"filter-item"
>
<input
type=
"checkbox"
checked
data-type=
"section"
>
<span
class=
"color-dot color-section"
></span>
<span>
报告段落
</span>
<span
class=
"count"
id=
"count-section"
>
0
</span>
</label>
<label
class=
"filter-item"
>
<input
type=
"checkbox"
checked
data-type=
"search_query"
>
<span
class=
"color-dot color-search_query"
></span>
<span>
搜索关键词
</span>
<span
class=
"count"
id=
"count-search_query"
>
0
</span>
</label>
<label
class=
"filter-item"
>
<input
type=
"checkbox"
checked
data-type=
"source"
>
<span
class=
"color-dot color-source"
></span>
<span>
数据来源
</span>
<span
class=
"count"
id=
"count-source"
>
0
</span>
</label>
</div>
<div
class=
"node-detail"
id=
"nodeDetail"
style=
"display: none;"
>
<h3>
节点详情
</h3>
<div
class=
"detail-title"
id=
"detailTitle"
></div>
<div
class=
"detail-type"
id=
"detailType"
></div>
<div
class=
"detail-props"
id=
"detailProps"
></div>
</div>
</div>
<!-- 左下角图例 -->
<div
class=
"floating-toolbar bottom-left legend-panel"
>
<div
class=
"legend-title"
>
图例
</div>
<div
class=
"legend-items"
>
<div
class=
"legend-item"
><span
class=
"legend-dot color-topic"
></span>
主题
</div>
<div
class=
"legend-item"
><span
class=
"legend-dot color-engine"
></span>
引擎
</div>
<div
class=
"legend-item"
><span
class=
"legend-dot color-section"
></span>
段落
</div>
<div
class=
"legend-item"
><span
class=
"legend-dot color-search_query"
></span>
搜索词
</div>
<div
class=
"legend-item"
><span
class=
"legend-dot color-source"
></span>
来源
</div>
</div>
</div>
<!-- 图谱容器 -->
<div
class=
"graph-container"
id=
"graphContainer"
data-report-id=
"{{ report_id | e if report_id else '' }}"
>
<div
id=
"network"
></div>
<!-- 加载状态 -->
<div
class=
"loading-overlay"
id=
"loadingOverlay"
>
<div
class=
"loading-spinner"
></div>
<div
class=
"loading-text"
>
正在加载知识图谱...
</div>
</div>
<!-- 空状态 -->
<div
class=
"empty-state"
id=
"emptyState"
style=
"display: none;"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"1"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<path
d=
"M8 15h8"
/>
<path
d=
"M9 9h.01"
/>
<path
d=
"M15 9h.01"
/>
</svg>
<h3>
暂无图谱数据
</h3>
<p>
请先生成报告以创建知识图谱
</p>
</div>
</div>
<!-- 网络图容器 -->
<div
id=
"network"
data-report-id=
"{{ report_id | e if report_id else '' }}"
></div>
<!-- 图例 -->
<div
class=
"legend"
id=
"legend"
>
<div
class=
"legend-item"
>
<span
class=
"dot color-topic"
></span>
<span>
主题
</span>
</div>
<div
class=
"legend-item"
>
<span
class=
"dot color-engine"
></span>
<span>
引擎
</span>
</div>
<div
class=
"legend-item"
>
<span
class=
"dot color-section"
></span>
<span>
段落
</span>
</div>
<div
class=
"legend-item"
>
<span
class=
"dot color-search_query"
></span>
<span>
搜索词
</span>
</div>
<div
class=
"legend-item"
>
<span
class=
"dot color-source"
></span>
<span>
来源
</span>
</div>
</div>
<!-- 加载遮罩 -->
<div
class=
"overlay-message"
id=
"loadingOverlay"
>
<div
class=
"spinner"
></div>
<div>
正在构建知识图谱...
</div>
</div>
<!-- 全屏按钮 -->
<button
class=
"btn fullscreen-btn"
id=
"fullscreenBtn"
title=
"全屏"
>
<svg
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"
/>
</svg>
</button>
<!-- 空状态遮罩 -->
<div
class=
"overlay-message"
id=
"emptyState"
style=
"display: none;"
>
<svg
class=
"empty-icon"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"1"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
></circle>
<line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
></line>
<line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
></line>
</svg>
<h3>
暂无图谱数据
</h3>
<p
style=
"color: var(--text-sec); margin-top: 8px;"
>
请生成报告以查看关联分析
</p>
<button
class=
"btn-icon primary"
style=
"margin-top: 20px; width: auto; padding: 0 20px;"
onclick=
"loadGraphData({fromManual: true})"
>
重试
</button>
</div>
</main>
</div>
<!-- 提示 -->
<div
class=
"toast"
id=
"toast"
></div>
<!-- 消息提示 -->
<div
class=
"toast"
id=
"toast"
>
<div
id=
"toastIcon"
style=
"display: flex; align-items: center;"
></div>
<span
id=
"toastMsg"
>
操作成功
</span>
</div>
<script>
// 配置
const
NODE_COLORS
=
{
topic
:
'#EF4444'
,
engine
:
'#F59E0B'
,
section
:
'#10B981'
,
search_query
:
'#3B82F6'
,
source
:
'#8B5CF6'
};
const
NODE_SHAPES
=
{
topic
:
'star'
,
engine
:
'diamond'
,
section
:
'dot'
,
search_query
:
'triangle'
,
source
:
'square'
// 配置常量
const
NODE_CONFIG
=
{
topic
:
{
color
:
'#EF4444'
,
shape
:
'dot'
,
size
:
30
,
label
:
'主题'
},
engine
:
{
color
:
'#F59E0B'
,
shape
:
'diamond'
,
size
:
25
,
label
:
'引擎'
},
section
:
{
color
:
'#10B981'
,
shape
:
'dot'
,
size
:
15
,
label
:
'段落'
},
search_query
:
{
color
:
'#3B82F6'
,
shape
:
'triangle'
,
size
:
15
,
label
:
'搜索词'
},
source
:
{
color
:
'#8B5CF6'
,
shape
:
'square'
,
size
:
15
,
label
:
'来源'
}
};
// 全局
变量
// 全局
状态
let
network
=
null
;
let
allNodes
=
[];
let
allEdges
=
[];
let
graphData
=
{
nodes
:
[],
edges
:
[]
};
let
reportId
=
null
;
const
graphContainer
=
document
.
getElementById
(
'graphContainer'
);
if
(
graphContainer
)
{
reportId
=
graphContainer
.
dataset
.
reportId
||
null
;
}
let
graphReady
=
false
;
let
graphPollTimer
=
null
;
let
graphSearchResults
=
[];
let
graphSearchIndex
=
-
1
;
let
graphSearchKeyword
=
''
;
const
GRAPH_POLL_INTERVAL
=
4000
;
let
isGraphReady
=
false
;
let
pollTimer
=
null
;
// 搜索状态
let
searchState
=
{
keyword
:
''
,
results
:
[],
currentIndex
:
-
1
};
// DOM 元素
const
els
=
{
network
:
document
.
getElementById
(
'network'
),
sidebar
:
document
.
getElementById
(
'sidebar'
),
loading
:
document
.
getElementById
(
'loadingOverlay'
),
empty
:
document
.
getElementById
(
'emptyState'
),
stats
:
{
nodes
:
document
.
getElementById
(
'totalNodes'
),
edges
:
document
.
getElementById
(
'totalEdges'
),
counts
:
{}
},
search
:
{
input
:
document
.
getElementById
(
'searchInput'
),
prev
:
document
.
getElementById
(
'searchPrevBtn'
),
next
:
document
.
getElementById
(
'searchNextBtn'
),
status
:
document
.
getElementById
(
'searchStatus'
)
},
detail
:
{
panel
:
document
.
getElementById
(
'nodeDetailPanel'
),
title
:
document
.
getElementById
(
'detailTitle'
),
type
:
document
.
getElementById
(
'detailType'
),
props
:
document
.
getElementById
(
'detailProps'
)
}
};
// 初始化
document
.
addEventListener
(
'DOMContentLoaded'
,
()
=>
{
loadGraphData
();
startGraphPolling
();
// 获取 Report ID
const
container
=
document
.
getElementById
(
'network'
);
if
(
container
)
reportId
=
container
.
dataset
.
reportId
||
null
;
initGraph
();
setupEventListeners
();
startPolling
();
});
// 加载图谱数据
// 初始化图谱
function
initGraph
()
{
loadGraphData
();
}
// 加载数据
async
function
loadGraphData
(
options
=
{})
{
const
{
fromPoll
=
false
,
fromManual
=
false
,
allowFallback
=
true
}
=
options
;
// 仅在首次或未加载成功时展示大遮罩
if
(
!
graphReady
||
!
fromPoll
)
{
showLoading
(
true
);
}
const
{
fromPoll
=
false
,
fromManual
=
false
}
=
options
;
if
(
!
isGraphReady
&&
!
fromPoll
)
showLoading
(
true
);
try
{
const
fetch
Graph
=
async
(
id
)
=>
{
const
fetch
Url
=
async
(
id
)
=>
{
const
url
=
id
?
`
/
api
/
graph
/
$
{
id
}
`
:
'/api/graph/latest'
;
const
response
=
await
fetch
(
url
,
{
cache
:
'no-store'
});
const
data
=
await
response
.
json
();
if
(
!
response
.
ok
||
!
data
.
success
||
!
data
.
graph
)
{
return
null
;
}
return
data
;
const
res
=
await
fetch
(
url
,
{
cache
:
'no-store'
});
return
await
res
.
json
();
};
let
data
=
await
fetchGraph
(
reportId
);
let
usedFallback
=
false
;
const
allowLatestFallback
=
allowFallback
&&
!
reportId
;
if
((
!
data
||
!
data
.
graph
)
&&
allowLatestFallback
)
{
data
=
await
fetchGraph
(
null
);
usedFallback
=
!!
(
data
&&
data
.
graph
);
}
// 尝试获取数据
let
data
=
await
fetchUrl
(
reportId
);
if
(
data
&&
data
.
graph
)
{
if
(
data
.
report_id
)
{
reportId
=
data
.
report_id
;
}
allNodes
=
data
.
graph
.
nodes
;
allEdges
=
data
.
graph
.
edges
;
updateStats
(
data
.
graph
.
stats
);
resetGraphSearchState
();
renderGraph
();
const
input
=
document
.
getElementById
(
'searchInput'
);
const
currentKeyword
=
(
input
&&
input
.
value
)
?
input
.
value
:
''
;
if
(
currentKeyword
)
{
runGraphSearch
(
currentKeyword
);
}
showLoading
(
false
);
showEmpty
(
false
);
graphReady
=
true
;
stopGraphPolling
();
if
(
fromManual
)
{
showToast
(
usedFallback
?
'未找到指定图谱,已切换至最新版本'
:
'已刷新最新图谱'
);
}
}
else
{
showEmpty
(
true
);
graphReady
=
false
;
allNodes
=
[];
allEdges
=
[];
resetGraphSearchState
();
updateStats
({
total_nodes
:
0
,
total_edges
:
0
,
topic
:
0
,
engine
:
0
,
section
:
0
,
search_query
:
0
,
source
:
0
});
if
(
network
)
{
network
.
destroy
();
network
=
null
;
}
hideNodeDetail
();
showLoading
(
false
);
if
(
fromManual
)
{
showToast
(
'未找到图谱数据'
);
}
// 如果指定ID没有数据,尝试获取最新
if
((
!
data
||
!
data
.
success
||
!
data
.
graph
)
&&
!
reportId
)
{
data
=
await
fetchUrl
(
null
);
}
}
catch
(
error
)
{
console
.
error
(
'加载图谱失败:'
,
error
);
showToast
(
'加载图谱失败: '
+
error
.
message
);
if
(
!
graphReady
)
{
showEmpty
(
true
);
if
(
data
&&
data
.
success
&&
data
.
graph
)
{
handleGraphSuccess
(
data
,
fromManual
);
}
else
{
handleGraphEmpty
(
fromManual
);
}
showLoading
(
false
);
}
catch
(
err
)
{
console
.
error
(
"Graph load error:"
,
err
);
if
(
fromManual
)
showToast
(
'加载失败: '
+
err
.
message
,
'error'
);
if
(
!
isGraphReady
)
showEmpty
(
true
);
}
finally
{
if
(
!
isGraphReady
)
showLoading
(
false
);
}
}
function
startGraphPolling
()
{
if
(
graphPollTimer
)
return
;
graphPollTimer
=
setInterval
(()
=>
{
loadGraphData
({
fromPoll
:
true
});
},
GRAPH_POLL_INTERVAL
);
function
handleGraphSuccess
(
data
,
showMessage
)
{
const
newNodes
=
data
.
graph
.
nodes
||
[];
const
newEdges
=
data
.
graph
.
edges
||
[];
// 简单的Diff检查,避免频繁重绘
if
(
isGraphReady
&&
newNodes
.
length
===
graphData
.
nodes
.
length
&&
newEdges
.
length
===
graphData
.
edges
.
length
)
{
if
(
showMessage
)
showToast
(
'已是最新数据'
);
return
;
}
graphData
=
{
nodes
:
newNodes
,
edges
:
newEdges
};
if
(
data
.
report_id
)
reportId
=
data
.
report_id
;
updateStats
(
data
.
graph
.
stats
);
renderNetwork
();
// 恢复搜索状态
if
(
searchState
.
keyword
)
runSearch
(
searchState
.
keyword
);
isGraphReady
=
true
;
showEmpty
(
false
);
showLoading
(
false
);
stopPolling
();
// 成功加载后停止轮询,或者可以继续轮询以获得实时更新
if
(
showMessage
)
showToast
(
'图谱已更新'
);
}
function
stopGraphPolling
()
{
if
(
graphPollTimer
)
{
clearInterval
(
graphPollTimer
);
graphPollTimer
=
null
;
function
handleGraphEmpty
(
showMessage
)
{
if
(
!
isGraphReady
)
{
showEmpty
(
true
);
// 清空数据
graphData
=
{
nodes
:
[],
edges
:
[]
};
if
(
network
)
{
network
.
destroy
();
network
=
null
;
}
updateStats
({});
}
if
(
showMessage
)
showToast
(
'暂无数据'
);
}
// 渲染图谱
function
renderGraph
()
{
const
container
=
document
.
getElementById
(
'network'
);
// 处理节点
// 渲染 Vis Network
function
renderNetwork
()
{
const
visibleTypes
=
getVisibleTypes
();
const
filteredNodes
=
allNodes
.
filter
(
n
=>
visibleTypes
.
includes
(
n
.
group
));
const
filteredNodeIds
=
new
Set
(
filteredNodes
.
map
(
n
=>
n
.
id
));
const
nodes
=
new
vis
.
DataSet
(
filteredNodes
.
map
(
node
=>
({
id
:
node
.
id
,
label
:
truncateLabel
(
node
.
label
,
20
),
title
:
node
.
title
,
group
:
node
.
group
,
color
:
{
background
:
NODE_COLORS
[
node
.
group
]
||
'#6B7280'
,
border
:
NODE_COLORS
[
node
.
group
]
||
'#6B7280'
,
highlight
:
{
background
:
lightenColor
(
NODE_COLORS
[
node
.
group
]
||
'#6B7280'
),
border
:
NODE_COLORS
[
node
.
group
]
||
'#6B7280'
}
},
shape
:
NODE_SHAPES
[
node
.
group
]
||
'dot'
,
size
:
node
.
group
===
'topic'
?
30
:
(
node
.
group
===
'engine'
?
25
:
15
),
font
:
{
color
:
'#F1F5F9'
,
size
:
12
},
// 保存原始数据
_data
:
node
})));
// 处理边
const
edges
=
new
vis
.
DataSet
(
allEdges
.
filter
(
e
=>
filteredNodeIds
.
has
(
e
.
from
)
&&
filteredNodeIds
.
has
(
e
.
to
))
.
map
(
edge
=>
({
from
:
edge
.
from
,
to
:
edge
.
to
,
label
:
edge
.
label
,
arrows
:
edge
.
arrows
||
'to'
,
color
:
{
color
:
'#475569'
,
highlight
:
'#818CF8'
},
font
:
{
color
:
'#94A3B8'
,
size
:
10
,
strokeWidth
:
0
},
smooth
:
{
type
:
'continuous'
}
}))
);
const
nodes
=
graphData
.
nodes
.
filter
(
n
=>
visibleTypes
.
includes
(
n
.
group
))
.
map
(
n
=>
({
id
:
n
.
id
,
label
:
truncate
(
n
.
label
,
20
),
group
:
n
.
group
,
title
:
n
.
title
||
n
.
label
,
// Tooltip
_raw
:
n
,
// 保存原始数据
...
getNodeStyle
(
n
.
group
)
}));
const
nodeIds
=
new
Set
(
nodes
.
map
(
n
=>
n
.
id
));
const
edges
=
graphData
.
edges
.
filter
(
e
=>
nodeIds
.
has
(
e
.
from
)
&&
nodeIds
.
has
(
e
.
to
))
.
map
(
e
=>
({
from
:
e
.
from
,
to
:
e
.
to
,
arrows
:
'to'
,
color
:
{
color
:
'#475569'
,
opacity
:
0.6
},
width
:
1
}));
const
data
=
{
nodes
:
new
vis
.
DataSet
(
nodes
),
edges
:
new
vis
.
DataSet
(
edges
)
};
// 图谱配置
const
options
=
{
nodes
:
{
borderWidth
:
2
,
shadow
:
true
shadow
:
true
,
font
:
{
color
:
'#f8fafc'
,
size
:
12
,
face
:
'Inter'
}
},
edges
:
{
width
:
1
,
shadow
:
true
smooth
:
{
type
:
'continuous'
,
roundness
:
0.5
}
},
physics
:
{
enabled
:
true
,
solver
:
'forceAtlas2Based'
,
forceAtlas2Based
:
{
gravitationalConstant
:
-
100
,
centralGravity
:
0.01
,
springLength
:
150
,
springConstant
:
0.08
,
damping
:
0.5
},
stabilization
:
{
enabled
:
true
,
iterations
:
200
}
stabilization
:
{
enabled
:
true
,
iterations
:
100
},
barnesHut
:
{
gravitationalConstant
:
-
2000
,
springConstant
:
0.04
,
springLength
:
95
}
},
interaction
:
{
hover
:
true
,
tooltipDelay
:
100
,
zoomView
:
true
,
dragView
:
true
tooltipDelay
:
200
,
zoomView
:
true
}
};
// 创建网络
network
=
new
vis
.
Network
(
container
,
{
nodes
,
edges
},
options
);
// 节点点击事件
network
.
on
(
'click'
,
(
params
)
=>
{
if
(
params
.
nodes
.
length
>
0
)
{
const
nodeId
=
params
.
nodes
[
0
];
const
node
=
allNodes
.
find
(
n
=>
n
.
id
===
nodeId
);
if
(
node
)
{
showNodeDetail
(
node
);
if
(
!
network
)
{
network
=
new
vis
.
Network
(
els
.
network
,
data
,
options
);
// 事件绑定
network
.
on
(
'click'
,
params
=>
{
if
(
params
.
nodes
.
length
)
{
const
nodeId
=
params
.
nodes
[
0
];
const
node
=
graphData
.
nodes
.
find
(
n
=>
n
.
id
===
nodeId
);
showNodeDetails
(
node
);
}
else
{
hideNodeDetail
();
}
}
else
{
hideNodeDetail
();
}
});
// 稳定后适应视图
network
.
once
(
'stabilizationIterationsDone'
,
()
=>
{
network
.
fit
({
animation
:
true
});
});
});
// 如果已有搜索关键词,重新聚焦当前匹配;否则更新状态显示
if
(
graphSearchKeyword
)
{
runGraphSearch
(
graphSearchKeyword
);
network
.
on
(
'stabilizationIterationsDone'
,
()
=>
{
// 初次加载完成也可以fit一下,但有时会跳动,可视情况开启
// network.fit();
});
}
else
{
updateGraphSearchStatus
(
);
network
.
setData
(
data
);
}
}
// 显示节点详情
function
showNodeDetail
(
node
)
{
const
detailPanel
=
document
.
getElementById
(
'nodeDetail'
);
const
titleEl
=
document
.
getElementById
(
'detailTitle'
);
const
typeEl
=
document
.
getElementById
(
'detailType'
);
const
propsEl
=
document
.
getElementById
(
'detailProps'
);
titleEl
.
textContent
=
node
.
label
;
const
typeLabels
=
{
topic
:
'主题'
,
engine
:
'分析引擎'
,
section
:
'报告段落'
,
search_query
:
'搜索关键词'
,
source
:
'数据来源'
function
getNodeStyle
(
group
)
{
const
conf
=
NODE_CONFIG
[
group
]
||
{
color
:
'#94a3b8'
,
shape
:
'dot'
};
return
{
color
:
{
background
:
conf
.
color
,
border
:
conf
.
color
,
highlight
:
{
background
:
lighten
(
conf
.
color
,
20
),
border
:
conf
.
color
},
hover
:
{
background
:
lighten
(
conf
.
color
,
20
),
border
:
conf
.
color
}
},
shape
:
conf
.
shape
,
size
:
conf
.
size
};
typeEl
.
textContent
=
typeLabels
[
node
.
group
]
||
node
.
group
;
// 显示属性
let
propsHtml
=
''
;
const
props
=
node
.
properties
||
{};
for
(
const
[
key
,
value
]
of
Object
.
entries
(
props
))
{
if
(
value
)
{
propsHtml
+=
`
<
div
class
=
"prop-item"
>
<
div
class
=
"prop-key"
>
$
{
key
}
<
/div
>
<
div
class
=
"prop-value"
>
$
{
truncateText
(
String
(
value
),
200
)}
<
/div
>
<
/div
>
`
;
}
}
propsEl
.
innerHTML
=
propsHtml
||
'<div class="prop-item">无附加属性</div>'
;
detailPanel
.
style
.
display
=
'block'
;
}
// 隐藏节点详情
function
hideNodeDetail
()
{
document
.
getElementById
(
'nodeDetail'
).
style
.
display
=
'none'
;
}
// 交互逻辑
function
setupEventListeners
()
{
// 侧边栏切换
document
.
getElementById
(
'toggleSidebar'
).
addEventListener
(
'click'
,
()
=>
{
els
.
sidebar
.
classList
.
add
(
'collapsed'
);
});
document
.
getElementById
(
'expandSidebar'
).
addEventListener
(
'click'
,
()
=>
{
els
.
sidebar
.
classList
.
remove
(
'collapsed'
);
});
function
resetGraphSearchState
()
{
graphSearchResults
=
[];
graphSearchIndex
=
-
1
;
graphSearchKeyword
=
''
;
updateGraphSearchStatus
();
}
// 搜索
els
.
search
.
input
.
addEventListener
(
'input'
,
(
e
)
=>
runSearch
(
e
.
target
.
value
));
els
.
search
.
prev
.
addEventListener
(
'click'
,
()
=>
navSearch
(
-
1
));
els
.
search
.
next
.
addEventListener
(
'click'
,
()
=>
navSearch
(
1
));
function
updateGraphSearchStatus
()
{
const
statusEl
=
document
.
getElementById
(
'searchStatus'
);
const
prevBtn
=
document
.
getElementById
(
'searchPrevBtn'
);
const
nextBtn
=
document
.
getElementById
(
'searchNextBtn'
);
const
hasResults
=
graphSearchResults
.
length
>
0
&&
graphSearchIndex
>=
0
;
if
(
statusEl
)
{
statusEl
.
textContent
=
hasResults
?
`
$
{
graphSearchIndex
+
1
}
/${graphSearchResults.length}` : '0/
0
';
statusEl.style.visibility = hasResults ? '
visible
' : '
hidden
';
}
if (prevBtn) prevBtn.disabled = !hasResults;
if (nextBtn) nextBtn.disabled = !hasResults;
}
// 过滤器
document
.
querySelectorAll
(
'.filter-item input'
).
forEach
(
cb
=>
{
cb
.
addEventListener
(
'change'
,
()
=>
{
const
parent
=
cb
.
closest
(
'.filter-item'
);
if
(
cb
.
checked
)
parent
.
classList
.
add
(
'active'
);
else
parent
.
classList
.
remove
(
'active'
);
renderNetwork
();
});
});
function runGraphSearch(keyword) {
if (!network) return;
const term = (keyword || '').trim();
graphSearchKeyword = term;
// 工具栏
document
.
getElementById
(
'refreshBtn'
).
addEventListener
(
'click'
,
()
=>
loadGraphData
({
fromManual
:
true
}));
document
.
getElementById
(
'zoomInBtn'
).
addEventListener
(
'click'
,
()
=>
network
?.
moveTo
({
scale
:
network
.
getScale
()
*
1.2
,
animation
:
true
}));
document
.
getElementById
(
'zoomOutBtn'
).
addEventListener
(
'click'
,
()
=>
network
?.
moveTo
({
scale
:
network
.
getScale
()
/
1.2
,
animation
:
true
}));
document
.
getElementById
(
'fitBtn'
).
addEventListener
(
'click'
,
()
=>
network
?.
fit
({
animation
:
true
}));
document
.
getElementById
(
'fullscreenBtn'
).
addEventListener
(
'click'
,
toggleFullScreen
);
}
if (!term) {
resetGraphSearchState();
network.selectNodes([]);
// 搜索功能
function
runSearch
(
keyword
)
{
keyword
=
keyword
.
trim
().
toLowerCase
();
searchState
.
keyword
=
keyword
;
if
(
!
keyword
)
{
searchState
.
results
=
[];
searchState
.
currentIndex
=
-
1
;
updateSearchUI
();
if
(
network
)
network
.
unselectAll
();
return
;
}
const lower = term.toLowerCase();
const nodesDataset = network.body && network.body.data && network.body.data.nodes ? network.body.data.nodes.get() : [];
graphSearchResults = nodesDataset.filter(n => (n.label || '').toLowerCase().includes(lower));
graphSearchResults.sort((a, b) => {
const aLabel = (a.label || '').toLowerCase();
const bLabel = (b.label || '').toLowerCase();
if (aLabel === bLabel) {
return String(a.id).localeCompare(String(b.id), '
zh
');
}
return aLabel.localeCompare(bLabel, '
zh
');
});
graphSearchIndex = graphSearchResults.length ? 0 : -1;
if (!graphSearchResults.length) {
network.selectNodes([]);
hideNodeDetail();
updateGraphSearchStatus();
return;
// 在当前显示的数据集中搜索
const
visibleTypes
=
getVisibleTypes
();
searchState
.
results
=
graphData
.
nodes
.
filter
(
n
=>
visibleTypes
.
includes
(
n
.
group
)
&&
n
.
label
.
toLowerCase
().
includes
(
keyword
))
.
map
(
n
=>
n
.
id
);
searchState
.
currentIndex
=
searchState
.
results
.
length
?
0
:
-
1
;
updateSearchUI
();
if
(
searchState
.
results
.
length
>
0
)
{
focusNode
(
searchState
.
results
[
0
]);
}
focusGraphSearchIndex(graphSearchIndex);
}
function focusGraphSearchIndex(index) {
if (!network || !graphSearchResults.length) return;
const total = graphSearchResults.length;
graphSearchIndex = ((index % total) + total) % total;
const target = graphSearchResults[graphSearchIndex];
network.selectNodes([target.id]);
network.focus(target.id, { animation: true, scale: 1.4 });
showNodeDetail(target._data || target);
updateGraphSearchStatus();
function
navSearch
(
direction
)
{
if
(
!
searchState
.
results
.
length
)
return
;
const
len
=
searchState
.
results
.
length
;
searchState
.
currentIndex
=
(
searchState
.
currentIndex
+
direction
+
len
)
%
len
;
focusNode
(
searchState
.
results
[
searchState
.
currentIndex
]);
updateSearchUI
();
}
function stepGraphSearch(delta) {
if (!graphSearchResults.length) return;
focusGraphSearchIndex(graphSearchIndex + delta);
function
focusNode
(
nodeId
)
{
if
(
!
network
)
return
;
network
.
selectNodes
([
nodeId
]);
network
.
focus
(
nodeId
,
{
scale
:
1.2
,
animation
:
true
});
const
node
=
graphData
.
nodes
.
find
(
n
=>
n
.
id
===
nodeId
);
if
(
node
)
showNodeDetails
(
node
);
}
// 更新统计
function updateStats(stats) {
document.getElementById('
nodeCount
').textContent = stats.total_nodes || 0;
document.getElementById('
edgeCount
').textContent = stats.total_edges || 0;
function
updateSearchUI
()
{
const
{
results
,
currentIndex
}
=
searchState
;
const
hasResults
=
results
.
length
>
0
;
// 更新各类型计数
document.getElementById('
count
-
topic
').textContent = stats.topic || 0;
document.getElementById('
count
-
engine
').textContent = stats.engine || 0;
document.getElementById('
count
-
section
').textContent = stats.section || 0;
document.getElementById('
count
-
search_query
').textContent = stats.search_query || 0;
document.getElementById('
count
-
source
').textContent = stats.source || 0;
}
// 获取可见类型
function getVisibleTypes() {
const types = [];
document.querySelectorAll('
.
filter
-
item
input
[
type
=
"checkbox"
]
').forEach(cb => {
if (cb.checked) {
types.push(cb.dataset.type);
els
.
search
.
prev
.
disabled
=
!
hasResults
;
els
.
search
.
next
.
disabled
=
!
hasResults
;
els
.
search
.
status
.
textContent
=
hasResults
?
`
$
{
currentIndex
+
1
}
/ ${results.length}`
:
(
searchState
.
keyword
?
'无结果'
:
''
);
}
// 详情面板
function
showNodeDetails
(
node
)
{
if
(
!
node
)
return
;
els
.
detail
.
panel
.
style
.
display
=
'block'
;
els
.
detail
.
title
.
textContent
=
node
.
label
;
els
.
detail
.
type
.
textContent
=
(
NODE_CONFIG
[
node
.
group
]
||
{}).
label
||
node
.
group
;
let
html
=
''
;
const
props
=
node
.
properties
||
{};
if
(
Object
.
keys
(
props
).
length
===
0
)
{
html
=
'<div class="prop-label" style="text-align:center; padding:10px;">暂无额外属性</div>'
;
}
else
{
for
(
const
[
k
,
v
]
of
Object
.
entries
(
props
))
{
html
+=
`
<
div
class
=
"prop-row"
>
<
div
class
=
"prop-label"
>
$
{
k
}
<
/div
>
<
div
class
=
"prop-value"
>
$
{
v
}
<
/div
>
<
/div
>
`
;
}
});
return types;
}
els
.
detail
.
props
.
innerHTML
=
html
;
}
// 设置事件监听
function setupEventListeners() {
// 侧边栏切换
document.getElementById('
toggleSidebar
').addEventListener('
click
', () => {
const sidebar = document.getElementById('
sidebar
');
const container = document.getElementById('
graphContainer
');
const legend = document.getElementById('
legend
');
sidebar.classList.toggle('
collapsed
');
container.classList.toggle('
fullwidth
');
legend.classList.toggle('
fullwidth
');
});
// 适应视图
document.getElementById('
fitBtn
').addEventListener('
click
', () => {
if (network) network.fit({ animation: true });
});
// 放大
document.getElementById('
zoomInBtn
').addEventListener('
click
', () => {
if (network) {
const scale = network.getScale() * 1.2;
network.moveTo({ scale, animation: true });
}
});
function
hideNodeDetail
()
{
els
.
detail
.
panel
.
style
.
display
=
'none'
;
}
// 缩小
document.getElementById('
zoomOutBtn
').addEventListener('
click
', () => {
if (network) {
const scale = network.getScale() / 1.2;
network.moveTo({ scale, animation: true });
}
// 辅助功能
function
updateStats
(
stats
=
{})
{
els
.
stats
.
nodes
.
textContent
=
stats
.
total_nodes
||
0
;
els
.
stats
.
edges
.
textContent
=
stats
.
total_edges
||
0
;
[
'topic'
,
'engine'
,
'section'
,
'search_query'
,
'source'
].
forEach
(
type
=>
{
const
el
=
document
.
getElementById
(
`
count
-
$
{
type
}
`
);
if
(
el
)
el
.
textContent
=
stats
[
type
]
||
0
;
});
}
// 手动刷新
const manualRefreshBtn = document.getElementById('
manualRefreshBtn
');
if (manualRefreshBtn) {
manualRefreshBtn.addEventListener('
click
', () => {
loadGraphData({ fromManual: true });
});
}
function
getVisibleTypes
()
{
return
Array
.
from
(
document
.
querySelectorAll
(
'.filter-item input:checked'
))
.
map
(
cb
=>
cb
.
dataset
.
type
);
}
// 全屏
document.getElementById('
fullscreenBtn
').addEventListener('
click
', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
});
function
startPolling
()
{
if
(
pollTimer
)
return
;
pollTimer
=
setInterval
(()
=>
loadGraphData
({
fromPoll
:
true
}),
5000
);
}
// 搜索
const searchInput = document.getElementById('
searchInput
');
const searchBtn = document.getElementById('
searchBtn
');
const searchPrevBtn = document.getElementById('
searchPrevBtn
');
const searchNextBtn = document.getElementById('
searchNextBtn
');
if (searchInput) {
searchInput.addEventListener('
keydown
', (e) => {
if (e.key === '
Enter
') {
runGraphSearch(searchInput.value);
}
});
searchInput.addEventListener('
input
', () => {
if (!searchInput.value) {
resetGraphSearchState();
if (network) network.selectNodes([]);
}
});
}
if (searchBtn) {
searchBtn.addEventListener('
click
', () => runGraphSearch(searchInput ? searchInput.value : ''));
}
if (searchPrevBtn) {
searchPrevBtn.addEventListener('
click
', () => stepGraphSearch(-1));
function
stopPolling
()
{
if
(
pollTimer
)
{
clearInterval
(
pollTimer
);
pollTimer
=
null
;
}
if (searchNextBtn) {
searchNextBtn.addEventListener('
click
', () => stepGraphSearch(1));
}
// 筛选
document.querySelectorAll('
.
filter
-
item
input
[
type
=
"checkbox"
]
').forEach(cb => {
cb.addEventListener('
change
', () => {
renderGraph();
});
});
}
// 辅助函数
function
showLoading
(
show
)
{
document.getElementById('
loadingOverlay
')
.style.display = show ? '
flex
' : '
none
';
els
.
loading
.
style
.
display
=
show
?
'flex'
:
'none'
;
}
function
showEmpty
(
show
)
{
document.getElementById('
emptyState
').style.display = show ? '
block
' : '
none
';
els
.
empty
.
style
.
display
=
show
?
'flex'
:
'none'
;
if
(
show
)
els
.
loading
.
style
.
display
=
'none'
;
}
function showToast(m
essage
) {
function
showToast
(
m
sg
,
type
=
'info'
)
{
const
toast
=
document
.
getElementById
(
'toast'
);
toast.textContent = message;
toast.style.display = '
block
';
setTimeout(() => {
toast.style.display = '
none
';
}, 3000);
const
iconContainer
=
document
.
getElementById
(
'toastIcon'
);
document
.
getElementById
(
'toastMsg'
).
textContent
=
msg
;
toast
.
style
.
borderColor
=
type
===
'error'
?
'#EF4444'
:
'#6366f1'
;
// 图标定义
const
icons
=
{
success
:
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>'
,
error
:
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'
};
// 如果是 error 则显示错号,否则显示对号
const
isError
=
type
===
'error'
;
iconContainer
.
innerHTML
=
isError
?
icons
.
error
:
icons
.
success
;
iconContainer
.
style
.
color
=
isError
?
'#EF4444'
:
'#10B981'
;
toast
.
classList
.
add
(
'show'
);
setTimeout
(()
=>
toast
.
classList
.
remove
(
'show'
),
3000
);
}
function truncateLabel(text, maxLen) {
if (!text) return '';
return text.length > maxLen ? text.slice(0, maxLen) + '
...
' : text;
function
toggleFullScreen
()
{
if
(
!
document
.
fullscreenElement
)
{
document
.
documentElement
.
requestFullscreen
();
}
else
{
if
(
document
.
exitFullscreen
)
document
.
exitFullscreen
();
}
}
function truncateText(text, maxLen) {
if (!text) return '';
return text.length > maxLen ? text.slice(0, maxLen) + '
...
' : text;
// Utils
function
truncate
(
str
,
n
)
{
return
(
str
&&
str
.
length
>
n
)
?
str
.
substr
(
0
,
n
-
1
)
+
'...'
:
str
;
}
function lightenColor(color) {
// 简单的颜色变亮
const hex = color.replace('
#
', '
'
);
const
r
=
Math
.
min
(
255
,
parseInt
(
hex
.
slice
(
0
,
2
),
16
)
+
40
);
const
g
=
Math
.
min
(
255
,
parseInt
(
hex
.
slice
(
2
,
4
),
16
)
+
40
);
const
b
=
Math
.
min
(
255
,
parseInt
(
hex
.
slice
(
4
,
6
),
16
)
+
40
);
return
`
rgb
(
$
{
r
},
$
{
g
},
$
{
b
})
`
;
function
lighten
(
col
,
amt
)
{
var
usePound
=
false
;
if
(
col
[
0
]
==
"#"
)
{
col
=
col
.
slice
(
1
);
usePound
=
true
;
}
var
num
=
parseInt
(
col
,
16
);
var
r
=
(
num
>>
16
)
+
amt
;
if
(
r
>
255
)
r
=
255
;
else
if
(
r
<
0
)
r
=
0
;
var
b
=
((
num
>>
8
)
&
0x00FF
)
+
amt
;
if
(
b
>
255
)
b
=
255
;
else
if
(
b
<
0
)
b
=
0
;
var
g
=
(
num
&
0x0000FF
)
+
amt
;
if
(
g
>
255
)
g
=
255
;
else
if
(
g
<
0
)
g
=
0
;
return
(
usePound
?
"#"
:
""
)
+
(
g
|
(
b
<<
8
)
|
(
r
<<
16
)).
toString
(
16
);
}
</script>
</body>
...
...
Please
register
or
login
to post a comment