Initial commit

main
icter99 8 months ago
parent 1d08cbdb98
commit 9649795e05

@ -0,0 +1,6 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

30
.gitignore vendored

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig"
]
}

@ -1,2 +1,40 @@
# SoftwareEngineeringKG
# SK_front
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
neo4j_uri: str = "bolt://localhost:7687" # 替换为你的 Neo4j 地址
neo4j_user: str = "neo4j" # 替换为你的用户名
neo4j_password: str = "12345678" # 替换为你的密码
class Config:
env_file = ".env"
settings = Settings()

@ -0,0 +1,22 @@
from neo4j import GraphDatabase
from app.config import settings
class Neo4jConnection:
def __init__(self, uri, user, password):
# 初始化连接,只负责连接
self.driver = GraphDatabase.driver(uri, auth=(user, password))
def close(self):
# 关闭连接
self.driver.close()
def get_session(self):
# 提供 session 对象,供外部查询使用
return self.driver.session()
# 初始化全局 Neo4j 连接
neo4j_conn = Neo4jConnection(
uri=settings.neo4j_uri,
user=settings.neo4j_user,
password=settings.neo4j_password,
)

@ -0,0 +1,23 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import user
app = FastAPI()
# 注册用户路由
app.include_router(user.router)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允许所有来源,可以设置特定域名,如 ["http://localhost:3000"]
allow_credentials=True, # 支持发送 cookies
allow_methods=["*"], # 允许所有方法,例如 ["GET", "POST"]
allow_headers=["*"], # 允许所有请求头
)
@app.get("/")
def read_root():
return {"message": "FastAPI with Neo4j"}
if __name__=='__main__':
uvicorn.run('main:app',host='127.0.0.1',port=8000,reload=True)

@ -0,0 +1,71 @@
from fastapi import APIRouter
from typing import List
from fastapi import APIRouter, HTTPException
from app.schemas.user import ChapterRelationCreate, ChapterRelationResponse
from app.services.user_service import create_chapter_relation, get_graph_relations, search_chapters, \
get_relations_to_level_simple, get_chapter_relations
router = APIRouter(
prefix="/chapters",
tags=["Chapters"],
)
@router.post("/relation/", response_model=ChapterRelationResponse)
def add_chapter_relation(relation: ChapterRelationCreate):
"""
创建章节之间的关系
"""
return create_chapter_relation(relation)
@router.get("/relations/", response_model=List[ChapterRelationResponse])
def list_chapter_relations():
"""
查询所有章节关系
"""
return get_graph_relations()
@router.get("/search_chapters", response_model=List[ChapterRelationResponse])
def search_chapters_endpoint(q: str):
"""
API 端点根据搜索关键字模糊查询章节及其相关章节
:param q: 搜索关键字
:return: 匹配的章节关系列表
"""
if not q:
raise HTTPException(status_code=400, detail="搜索关键字不能为空")
results = search_chapters(q)
if not results:
raise HTTPException(status_code=404, detail="未找到匹配的章节关系")
return results
@router.get("/relations/level/{level}", response_model=List[ChapterRelationResponse])
def get_relations_by_level_simple(level: int):
"""
简化版根据目标层级查询从 Root 到目标层级的所有节点和关系
:param level: 目标层级1 -> Root & Subject, 2 -> Root, Subject, Topic, ..., 5 -> Problem
:return: 对应层级的节点和关系
"""
if level < 0 or level > 5:
raise HTTPException(status_code=400, detail="层级参数必须在 0 到 5 之间")
try:
relations = get_relations_to_level_simple(level)
if not relations:
raise HTTPException(status_code=404, detail="未找到相关节点关系")
return relations
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{chapter}/relations", response_model=List[ChapterRelationResponse])
async def get_relations_by_chapter(chapter: str):
"""
根据章节名称获取该章节及其相关关系但排除 label Subject next_SB 关系
:param chapter_name: 章节名称
:return: 章节关系列表
"""
try:
relations = get_chapter_relations(chapter)
if not relations:
raise HTTPException(status_code=404, detail=f"No relations found for chapter {chapter}")
return relations
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error fetching relations: {str(e)}")

@ -0,0 +1,24 @@
from pydantic import BaseModel
from typing import Dict, List, Optional
# 定义关系创建模型
class ChapterRelationCreate(BaseModel):
start_chapter: str # 起始章节的名称
end_chapter: str # 终止章节的名称
relation: str # 关系类型(如 "FOLLOWS", "RELATED_TO" 等)
# 定义关系查询响应模型
class ChapterRelationResponse(BaseModel):
# start_id: str
start_labels: List[str]
start_properties: Dict
relation: str
# relation_properties: Dict
#
# end_id: str
end_labels: List[str]
end_properties: Dict

@ -0,0 +1,216 @@
import logging
from typing import List
from app.database import neo4j_conn
from app.schemas.user import ChapterRelationCreate, ChapterRelationResponse
def create_chapter_relation(relation_data: ChapterRelationCreate):
"""
创建章节关系
:param relation_data: 包含起始章节结束章节和关系类型
:return: 创建的关系数据
"""
query = """
MERGE (start:Chapter {name: $start_chapter})
MERGE (end:Chapter {name: $end_chapter})
CREATE (start)-[r:%s]->(end)
RETURN start.name AS start_chapter, TYPE(r) AS relation, end.name AS end_chapter
""" % relation_data.relation.upper() # 使用动态关系类型
# 获取 session 进行查询
with neo4j_conn.get_session() as session:
result = session.run(query, parameters={
"start_chapter": relation_data.start_chapter,
"end_chapter": relation_data.end_chapter,
})
record = result.single()
return {
"start_chapter": record["start_chapter"],
"relation": record["relation"],
"end_chapter": record["end_chapter"],
}
def get_graph_relations():
"""
查询知识图谱中的所有节点及关系的详细信息
:return: 列表每一项为 ChapterRelationResponse 对象
"""
query = """
MATCH (start)-[r]->(end)
RETURN
properties(start) AS start_properties,
labels(start) AS start_labels,
TYPE(r) AS relation,
properties(end) AS end_properties,
labels(end) AS end_labels
"""
with neo4j_conn.get_session() as session:
result = session.run(query)
records = result.data()
responses = []
for record in records:
response = ChapterRelationResponse(
# start_id=record["start_id"],
start_labels=record["start_labels"],
start_properties=record["start_properties"],
relation=record["relation"],
# relation_properties=record["relation_properties"],
#
# end_id=record["end_id"],
end_labels=record["end_labels"],
end_properties=record["end_properties"]
)
responses.append(response)
return responses
def search_chapters(search_term: str) -> List[ChapterRelationResponse]:
"""
根据搜索关键字模糊查询章节及其相关章节
:param search_term: 搜索关键字
:return: 列表每一项为 ChapterRelationResponse 对象
"""
# 使用参数化查询以防止Cypher注入
query = """
MATCH (start)-[r]->(end)
WHERE toLower(start.name) CONTAINS toLower($search_term)
or toLower(end.name) CONTAINS toLower($search_term)
RETURN
properties(start) AS start_properties,
labels(start) AS start_labels,
TYPE(r) AS relation,
properties(end) AS end_properties,
labels(end) AS end_labels
"""
try:
with neo4j_conn.get_session() as session:
result = session.run(query, parameters={"search_term": search_term})
records = result.data()
responses = [
ChapterRelationResponse(
start_labels=record.get("start_labels", []),
start_properties=record.get("start_properties", {}),
relation=record.get("relation", ""),
end_labels=record.get("end_labels", []),
end_properties=record.get("end_properties", {}),
)
for record in records
]
return responses
except Exception as e:
logging.error(f"Error during search_chapters: {e}")
return []
def get_relations_to_level_simple(level: int) -> List[ChapterRelationResponse]:
"""
简化版根据目标层级查询从 Root 到目标层级的所有节点和关系
:param level: 目标层级1 -> Root & Subject, 2 -> Root, Subject, Topic, ..., 5 -> Problem
:return: 列表每一项为 ChapterRelationResponse 对象
"""
hierarchy = ["Root", "Subject", "Topic", "Section", "SubSection", "Problem"]
if level < 0 or level > len(hierarchy):
raise ValueError("层级参数必须在 0 到 5 之间")
# 特殊处理 level=0 的情况
if level == 0:
query = """
MATCH (start)
WHERE ANY(label IN labels(start) WHERE label = 'Root')
RETURN
properties(start) AS start_properties,
labels(start) AS start_labels
"""
try:
with neo4j_conn.get_session() as session:
result = session.run(query)
records = result.data()
return [
ChapterRelationResponse(
start_labels=record.get("start_labels", []),
start_properties=record.get("start_properties", {}),
relation="", # 没有关系信息
end_labels=[], # 没有终点节点
end_properties={}, # 没有终点节点属性
)
for record in records
]
except Exception as e:
logging.error(f"Error during get_relations_to_level_simple (level=0): {e}")
return []
# 获取从 Root 到目标层级的所有标签
target_labels = hierarchy[:level + 1]
query = """
MATCH (start)-[r]->(end)
WHERE ANY(label IN labels(start) WHERE label IN $target_labels)
AND ANY(label IN labels(end) WHERE label IN $target_labels)
RETURN
properties(start) AS start_properties,
labels(start) AS start_labels,
TYPE(r) AS relation,
properties(end) AS end_properties,
labels(end) AS end_labels
"""
try:
with neo4j_conn.get_session() as session:
result = session.run(query, parameters={"target_labels": target_labels})
records = result.data()
return [
ChapterRelationResponse(
start_labels=record.get("start_labels", []),
start_properties=record.get("start_properties", {}),
relation=record.get("relation", ""),
end_labels=record.get("end_labels", []),
end_properties=record.get("end_properties", {}),
)
for record in records
]
except Exception as e:
logging.error(f"Error during get_relations_to_level_simple: {e}")
return []
def get_chapter_relations(chapter: str) -> List[ChapterRelationResponse]:
"""
根据章节名称查找该章节及其相关节点和关系
:param chapter_name: 章节名称
:return: 列表每一项为 ChapterRelationResponse 对象
"""
query = """
MATCH (start)-[r]->(end)
WHERE start.chapter = $chapter and end.chapter = $chapter
RETURN
properties(start) AS start_properties,
labels(start) AS start_labels,
TYPE(r) AS relation,
properties(end) AS end_properties,
labels(end) AS end_labels
"""
try:
with neo4j_conn.get_session() as session:
result = session.run(query, parameters={"chapter": chapter})
records = result.data()
return [
ChapterRelationResponse(
start_labels=record.get("start_labels", []),
start_properties=record.get("start_properties", {}),
relation=record.get("relation", ""),
end_labels=record.get("end_labels", []),
end_properties=record.get("end_properties", {}),
)
for record in records
]
except Exception as e:
logging.error(f"Error during get_chapter_relations: {e}")
return []

@ -0,0 +1,21 @@
import js from '@eslint/js'
import pluginVue, { rules } from 'eslint-plugin-vue'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
{
rules:{
'vue/multi-word-component-names': 'off'
}
}
]

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

5337
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,31 @@
{
"name": "sk-front",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix"
},
"dependencies": {
"axios": "^1.7.9",
"d3": "^7.9.0",
"element-plus": "^2.9.1",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@vitejs/plugin-vue": "^5.2.1",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"sass": "^1.83.0",
"unplugin-auto-import": "^0.19.0",
"unplugin-vue-components": "^0.28.0",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^7.6.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@ -0,0 +1,5 @@
fastapi==0.115.6
neo4j==5.26.0
pydantic==2.10.4
pydantic_settings==2.7.0
uvicorn==0.34.0

@ -0,0 +1,71 @@
<script setup>
</script>
<template>
<RouterView/>
</template>
<style scoped>
header {
line-height: 1.5;
max-height: 100vh;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

@ -0,0 +1,14 @@
import '@/styles/common.scss'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

@ -0,0 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/views/Layout/index.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path:'/',
component:Layout
}
],
})
export default router

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

@ -0,0 +1,104 @@
//
* {
box-sizing: border-box;
}
html {
height: 100%;
font-size: 14px;
}
body {
height: 100%;
color: #333;
min-width: 1240px;
font: 1em/1.4 'Microsoft Yahei', 'PingFang SC', 'Avenir', 'Segoe UI',
'Hiragino Sans GB', 'STHeiti', 'Microsoft Sans Serif', 'WenQuanYi Micro Hei',
sans-serif;
}
body,
ul,
h1,
h3,
h4,
p,
dl,
dd {
padding: 0;
margin: 0;
}
a {
text-decoration: none;
color: #333;
outline: none;
}
i {
font-style: normal;
}
input[type='text'],
input[type='search'],
input[type='password'],
input[type='checkbox'] {
padding: 0;
outline: none;
border: none;
-webkit-appearance: none;
&::placeholder {
color: #ccc;
}
}
img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
background: #ebebeb url('@/assets/images/200.png') no-repeat center / contain;
}
ul {
list-style: none;
}
#app {
background: #f5f5f5;
user-select: none;
}
.container {
width: 1240px;
margin: 0 auto;
position: relative;
}
.ellipsis {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.ellipsis-2 {
word-break: break-all;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.fl {
float: left;
}
.fr {
float: right;
}
.clearfix:after {
content: '.';
display: block;
visibility: hidden;
height: 0;
line-height: 0;
clear: both;
}
// reset element
.el-breadcrumb__inner.is-link {
font-weight: 400 !important;
}

@ -0,0 +1,25 @@
/* 只需要重写你需要的即可 */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: (
'primary': (
//
'base': #1a4279,
),
'success': (
//
'base': #1d75c7,
),
'warning': (
//
'base': #ffb302,
),
'danger': (
//
'base': #e26237,
),
'error': (
//
'base': #cf4444,
),
)
)

@ -0,0 +1,5 @@
$xtxColor: #1a4279;
$helpColor: #e26237;
$sucColor: #1d75c7;
$warnColor: #ffb302;
$priceColor: #cf4444;

@ -0,0 +1,20 @@
import axios from 'axios'
// 创建axios实例
const httpInstance = axios.create({
baseURL: 'http://127.0.0.1:8000',
timeout: 50000
})
// axios请求拦截器
httpInstance.interceptors.request.use(config => {
return config
}, e => Promise.reject(e))
// axios响应式拦截器
httpInstance.interceptors.response.use(res => res.data, e => {
return Promise.reject(e)
})
export default httpInstance

@ -0,0 +1,826 @@
<template>
<div
ref="d3Container"
:style="{
width: width + 'px',
height: height + 'px',
backgroundColor: 'white'
}"
></div>
<el-drawer
:title="selectedNode ? selectedNode.name : '节点详情'"
v-model="drawerVisible"
:with-header="true"
close-on-press-escape="true"
style="opacity: 0.95;
padding-left: 36px;
.el-drawer {
width: 100% !important;
.el-drawer__header {
padding: 16px;
margin-bottom: 6px;
border-bottom: 1px solid #dcdfe6;
}
.el-drawer__header > :first-child {
font-size: 18px;
color: #303133;
font-weight: 600;
}
}
"
>
<div v-if="selectedNode">
<p class="node-info">节点名称: {{ selectedNode.name }}</p>
<p v-if="selectedNode.content" class="node-info"><br>内容: </p>
<p v-else></p>
<p v-if="selectedNode.content" class="content-style">&nbsp;&nbsp;&nbsp;&nbsp;{{ selectedNode.content }}<br></p>
</div>
<div v-else>
<p>请双击一个节点查看详情</p>
</div>
<div v-if="selectedNode">
<div v-if="selectedNode.url && selectedNode.mode === '图片'">
<p class="node-info"><br>图片资源: </p>
<img
:src="getImageUrl(selectedNode.url)"
alt="Node Image"
style="width: 100%; height: auto; margin-bottom: 20px;"
@dblclick="openImageViewer(selectedNode.url)"
/>
</div>
<div v-else><p class="node-info"><br>暂无图片资源</p>
</div>
</div>
<div v-if="selectedNode">
<div v-if="selectedNode.url && selectedNode.mode === '视频'">
<p class="node-info"><br>视频资源: </p>
<video
:src="getVideoUrl(selectedNode.url)"
ref="videoPlayer"
controls
style="max-width: 100%; margin-bottom: 10px;"
></video>
</div>
<div v-else>
<p class="node-info">暂无视频资源</p>
</div>
</div>
</el-drawer>
<!-- 图片查看器弹窗 -->
<el-dialog
title="图片查看器"
v-model="imageViewerVisible"
width="80%"
:close-on-click-modal="false"
:close-on-press-escape="true"
>
<img
:src="currentImageUrl"
alt="Full Screen Image"
style="width: 50%; height: auto;"
/>
</el-dialog>
</template>
<script>
import * as d3 from 'd3';
import emitter from './eventBus.js';
import axios from 'axios';
import 'element-plus/theme-chalk/el-message.css'
import {ElMessage} from 'element-plus';
const response = await axios.get('http://127.0.0.1:8000/chapters/relations/');
// const colors = ["#679fca", "#7fc97e", "#b15076","#6964ad","#d7a4b6"];
const colors = ["#f16667", "#f79767", "#8dcc93","#57c7e3","#d7c8df"];
export default {
name: 'D3ForceGraph',
data() {
return {
width: window.innerWidth,
height: window.innerHeight * 0.8,
nodes: [],
edges: [],
tempEdges: [],
nodesMap: {},
linkMap: {},
nodesData: [],
forceSimulation: null,
svg: null,
g: null,
links: null,
linksText: null,
gs: null,
menuVisible: false,
menuX: 0,
menuY: 0,
isGraphVisible: true, //
buttons: [
{ text: '收起', action: 'action1' },
{ text: '展开', action: 'action2' }
],
chapterCenters: {},
drawerVisible: false, // drawer
selectedNode: null, //
response: response.data,
imageViewerVisible: false, //
currentImageUrl: "", // URL
};
},
mounted() {
this.initData().then(() => {
if (this.isGraphVisible) {
this.initGraph(); //
console.log('Graph initialized.');
}
});
emitter.on('expand-to-level', this.expandToLevel);
emitter.on('search', this.search);
emitter.on('showAll',this.showAll);
emitter.on('chapter-nodes', this.chapterNodes);
},
beforeUnmount() {
//
emitter.off('expand-to-level', this.expandToLevel);
},
watch: {
drawerVisible(newVal) {
if (!newVal) {
// drawer
const videoPlayer = this.$refs.videoPlayer;
if (videoPlayer && !videoPlayer.paused) {
videoPlayer.pause();
}
}
}},
methods: {
async showAll(){
const response = await axios.get('http://127.0.0.1:8000/chapters/relations/');
const relations = response.data;
this.process_data(relations);
this.initGraph();
},
async expandToLevel(level) {
//
const response = await axios.get(`http://127.0.0.1:8000/chapters/relations/level/${level}`);
const relations = response.data;
this.process_data(relations);
this.initGraph(); //
},
async search(value) {
try{
const response = await axios.get(`http://127.0.0.1:8000/chapters/search_chapters/?q=${value}`);
const relations = response.data;
this.process_data(relations);
this.initGraph(); //
}catch(error){
ElMessage({ type: 'error', message: '未搜索到结果' })
}
},
async chapterNodes(chapter){
try{
const response = await axios.get(`http://127.0.0.1:8000/chapters/${chapter}/relations`);
const relations = response.data;
this.process_data(relations);
this.initGraph(); //
}catch(error){
ElMessage({ type: 'error', message: '未搜索到结果' })
}
},
getWeight(labels) {
//
if (labels.includes('Root')) return 6;
if (labels.includes('Subject')) return 5;
if (labels.includes('Topic')) return 4;
if (labels.includes('Section')) return 3;
if (labels.includes('SubSection')) return 2;
if (labels.includes('Problem')) return 1;
return 1; //
},
process_data(relations) {
try {
this.nodes = [];
this.edges = [];
this.tempEdges=[];
this.nodesMap={};
this.nodesData=[];
this.linkMap={};
let edgeId = 0; // ID
//
const nodesSet = new Set(); //
//
relations.forEach(item => {
const startNode = item.start_properties;
const endNode = item.end_properties;
const hasResourceLabel = (labels) => labels.includes('Resource');
if (hasResourceLabel(item.start_labels) || hasResourceLabel(item.end_labels)) {
return; //
}
if (!nodesSet.has(startNode.id)) {
nodesSet.add(startNode.id);
this.nodes.push({
id: startNode.id,
name: startNode.name,
weight: this.getWeight(item.start_labels), //
chapter: startNode.chapter, //
visible: true //
});
}
if (!nodesSet.has(endNode.id)) {
nodesSet.add(endNode.id);
this.nodes.push({
id: endNode.id,
name: endNode.name,
weight: this.getWeight(item.end_labels),
chapter: startNode.chapter, //
visible: true //
});
}
//
this.tempEdges.push({
id: edgeId++, // ID
source: startNode.id,
target: endNode.id,
relation: item.relation,
value: 1, //
visible: true //
});
});
const uniqueChapters = [...new Set(this.nodes.map(node => node.chapter))];
const gridSize = Math.ceil(Math.sqrt(uniqueChapters.length));
const centerSpacing = 400;
//
this.chapterCenters = {};
uniqueChapters.forEach((chapter, index) => {
const row = Math.floor(index / gridSize);
const col = index % gridSize;
this.chapterCenters[chapter] = {
x: (col + 1) * centerSpacing,
y: (row + 1) * centerSpacing
};
});
console.log('Generated Chapter Centers:', this.chapterCenters);
//
this.nodesMap = this.genNodesMap(this.nodes);
this.nodesData = Object.values(this.nodesMap);
this.linkMap = this.genLinkMap(this.tempEdges);
this.edges = this.genLinks(this.tempEdges);
} catch (error) {
console.error(`Error fetching level ${level} data:`, error);
}
},
async initData() {
const response = await axios.get('http://127.0.0.1:8000/chapters/relations/');
const relations = response.data;
this.process_data(relations);
},
initGraph() {
d3.select(this.$refs.d3Container).select('svg').remove();
this.svg = d3.select(this.$refs.d3Container)
.append('svg')
.attr('width', this.width)
.attr('height', this.height);
this.g = this.svg.append('g');
this.zoom = d3.zoom()
.scaleExtent([0.1, 4]) // 0.55
.on('zoom', (event) => {
this.g.attr('transform', event.transform);
});
this.svg.call(this.zoom);
this.forceSimulation = d3.forceSimulation(this.nodesData)
.force('link', d3.forceLink(this.edges).id(d => d.id).distance(d => d.value * 200))
// .force('charge', d3.forceManyBody())
.force('charge', d3.forceManyBody().strength(-300)) //
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
.force('cluster', d3.forceManyBody().strength(-50).distanceMin(50).distanceMax(100))
// .force('x', d3.forceX(d => this.chapterCenters[d.chapter]?.x || this.width / 2).strength(0.1))
// .force('y', d3.forceY(d => this.chapterCenters[d.chapter]?.y || this.height / 2).strength(0.1));
.force('x', d3.forceX(d => this.chapterCenters[d.chapter]?.x || this.width / 2).strength(0.00001)) // X
.force('y', d3.forceY(d => this.chapterCenters[d.chapter]?.y || this.height / 2).strength(0.00001))
.alpha(1) //
.alphaDecay(0.02) // 减缓模拟的冷却速度; // 按章节 Y 分簇
this.g.append('svg:defs').selectAll('marker')
.data(['end']) // "end"marker
.enter().append('svg:marker') // marker
.attr('id', d => d) //
.attr('viewBox', '0 -5 10 10')
.attr('refX', 44)
.attr('refY', 0)
.attr('markerWidth', 10)
.attr('markerHeight', 10)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#000000');
this.links = this.g.append('g')
.attr('class', 'links')
.selectAll('line')
.data(this.edges)
.enter()
.append('line')
.attr('stroke-width', d => Math.sqrt(d.value))
.style('stroke', '#999')
.attr('marker-end', 'url(#end)')
.attr('visibility', d => d.visible ? 'visible' : 'hidden');
this.linksText = this.g.append('g')
.selectAll('text')
.data(this.edges)
.enter()
.append('text')
.attr('class', 'linksText')
.text(d => d.relation)
.style('font-size', '10px')
.attr('fill-opacity', 1)
.attr('visibility', d => d.visible ? 'visible' : 'hidden')
this.gs = this.g.append('g')
.selectAll('.circleText')
.data(this.nodesData)
.enter()
.append('g')
.attr('class', 'singleNode')
.attr('id', d => 'singleNode' + d.id)
.style('cursor', 'pointer')
.attr('visibility', d => d.visible ? 'visible' : 'hidden');
//
this.gs.append('circle')
.attr('r', d => (40 + (d.name.split(' ')[0].split('.')).length * -3))
.style('fill', d => colors[(d.name.split(' ')[0].split('.')).length - 1])
.style('stroke', d => d3.color(colors[(d.name.split(' ')[0].split('.')).length - 1]).darker(1))
.style('stroke-width', 2) //
.style('transition', 'all 0.3s ease') //
//
.on('mouseover', function() {
d3.select(this)
.style('fill', '#ff6347') //
.style('r', d => (50 + (d.name.split(' ')[0].split('.')).length * -3)) //
.style('filter', 'url(#shadow)'); //
})
.on('mouseout', function() {
d3.select(this)
.style('fill', d => colors[(d.name.split(' ')[0].split('.')).length - 1]) //
.style('r', d => (40 + (d.name.split(' ')[0].split('.')).length * -3)) //
.style('filter', 'none'); //
})
.on('click', function() {
//
d3.selectAll('.singleNode')
.select('circle')
.style('stroke', '#333') //
.style('stroke-width', 2);
d3.select(this)
.select('circle')
.style('stroke', '#00bcd4') //
.style('stroke-width', 4); //
})
.on('dblclick', (event, d) => {
this.selectedNode = d; //
this.selectedNode.content = this.findNodeContentById(this.selectedNode.id);
this.selectedNode.mode = this.findNodeModeById(this.selectedNode.id);
this.selectedNode.url = this.findNodeURLById(this.selectedNode.id);
this.drawerVisible = true; // drawer
});
//
this.g.append('defs')
.append('filter')
.attr('id', 'shadow')
.append('feDropShadow')
.attr('dx', 0)
.attr('dy', 0)
.attr('stdDeviation', 5) //
.attr('flood-color', '#888'); //
this.gs.append('text')
.attr('text-anchor', 'middle') //
.attr('alignment-baseline', 'middle') //
.attr('font-size', '12px') //
.attr('fill', '#000000') // 便
this.gs.append('text')
.attr('text-anchor', 'middle') //
.attr('alignment-baseline', 'middle') //
.attr('font-size', '12px') //
.attr('fill', '#000000') //
.style('pointer-events', 'none') //
.each(function(d) {
function truncateText(text, maxLength) {
//
const lastNumberIndex = text.split('').reverse().findIndex(char => /\d/.test(char));
const numberSplitPoint = lastNumberIndex >= 0 ? text.length - lastNumberIndex - 1 : -1;
//
const chapterSplitPoint = text.indexOf('章');
//
let splitPoint = maxLength; //
if (numberSplitPoint >= 0 && chapterSplitPoint >= 0) {
//
splitPoint = Math.max(numberSplitPoint + 1, chapterSplitPoint + 1);
} else if (numberSplitPoint >= 0) {
splitPoint = numberSplitPoint + 1; //
} else if (chapterSplitPoint >= 0) {
splitPoint = chapterSplitPoint + 1; //
}
//
const firstPart = text.slice(0, splitPoint).trim();
// 5
const remainingText = text.slice(splitPoint).trim();
const secondPart = remainingText.length > 5 ? remainingText.slice(0, 5) + "..." : remainingText;
//
return secondPart ? [firstPart, secondPart] : [firstPart];
}
// rect
const rectNode = d3.select(this.parentNode).select('rect').node();
const nodeWidth = rectNode ? rectNode.getBBox().width : 100; // 100
const charWidth = 7; //
const maxLength = Math.floor(nodeWidth / charWidth); //
const lines = truncateText(d.name, maxLength);
lines.forEach((line, index) => {
d3.select(this)
.append('tspan')
.attr('x', 0)
.attr('dy', index === 0 ? 0 : '1.2em')
.text(line);
});
});
this.gs.call(d3.drag()
.on('start', (event, d) => this.started(d, event))
.on('drag', (event, d) => this.dragged(d, event))
.on('end', (event, d) => this.ended(d, event)));
this.forceSimulation.on('tick', () => this.ticked());
this.gs.on('click', (event, d) => {event.stopPropagation(); //
this.showMenu(event, d);
});
this.svg.on('click', () => this.hideMenu());//
this.updateGraph();
},
findNodeContentById(nodeId) {
// response nodeId
for (const item of this.response) {
console.log(item.start_properties);
if (item.start_properties.id === nodeId && item.start_properties.content) {
console.log(item.start_properties.content);
return item.start_properties.content;
}
}
for (const item of this.response) {
if (item.end_properties.id === nodeId && item.end_properties.content) {
return item.end_properties.content;
}
}
return null;
},
findNodeModeById(nodeId) {
// response nodeId Mode
for (const item of this.response) {
if (item.end_properties.id === nodeId && item.start_properties.Mode) {
return item.start_properties.Mode;
}
}
return null;
},
findNodeURLById(nodeId) {
// response nodeId URL
for (const item of this.response) {
if (item.end_properties.id === nodeId && item.start_properties.URL) {
return item.start_properties.URL;
}
}
return null;
},
getImageUrl(url) {
// URL
return `https://icter.oss-cn-beijing.aliyuncs.com/img/${url}`;
},
openImageViewer(url) {
this.currentImageUrl = this.getImageUrl(url);
this.imageViewerVisible = true;
},
getVideoUrl(url) {
// URL
return `https://icter.oss-cn-beijing.aliyuncs.com/video/${url}`;
},
showMenu(event, node) {
//
this.hideMenu();
//
const menuCenterX = node.x;
const menuCenterY = node.y;
//
this.menuVisible = true;
const menu = this.g.append('g')
.attr('class', 'menu')
.attr('id', `menu-${node.id}`)
.attr('transform', `translate(${menuCenterX},${menuCenterY})`);
//
const outerRadius = 63; //
const innerRadius = 40; //
//
const arcGenerator = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
//
const topButton = this.buttons[0]; //
const bottomButton = this.buttons[1]; //
//
menu.append('path')
.attr('d', arcGenerator({
startAngle: -Math.PI / 2, //
endAngle: Math.PI / 2 //
}))
.attr('fill', '#555555') //
.attr('stroke', '#333333') //
.attr('cursor', 'pointer')
.on('click', () => this.handleButtonClick(topButton.action, node)); //
//
menu.append('path')
.attr('d', arcGenerator({
startAngle: Math.PI / 2, //
endAngle: (3 * Math.PI) / 2 //
}))
.attr('fill', '#555555') //
.attr('stroke', '#333333') //
.attr('cursor', 'pointer')
.on('click', () => this.handleButtonClick(bottomButton.action, node)); //
//
menu.append('text')
.text(topButton.text)
.attr('x', 0) //
.attr('y', -(innerRadius + outerRadius) / 2 + 10) //
.attr('dy', '-0.35em') //
.style('font-size', '12px')
.style('text-anchor', 'middle')
.style('cursor', 'pointer')
.style('fill', '#FFFFFF') //
.on('click', () => this.handleButtonClick(topButton.action, node)); //
//
menu.append('text')
.text(bottomButton.text)
.attr('x', 0) //
.attr('y', (innerRadius + outerRadius) / 2 - 10) //
.attr('dy', '0.75em') //
.style('font-size', '12px')
.style('text-anchor', 'middle')
.style('cursor', 'pointer')
.style('fill', '#FFFFFF') //
.on('click', () => this.handleButtonClick(bottomButton.action, node)); //
}
,
hideMenu() {
//
this.svg.selectAll('.menu').remove();
this.menuVisible = false;
},
handleButtonClick(action, node) { //
console.log(`点击了按钮: ${action}`);
console.log('当前节点:', node);
// action
switch (action) {
case 'action1':
this.collapseNode(node);
break;
case 'action2':
this.expandNode(node);
break;
default:
console.warn('未知的按钮操作');
}
},
genNodesMap(nodes) {
const hash = {};
nodes.forEach(node => {
hash[node.id] = { id: node.id, name: node.name,visible: true };
});
return hash;
},
genLinkMap(tempEdges) {
const hash = {};
tempEdges.forEach(edge => {
const key = edge.source + '-' + edge.target;
if (hash[key]) {
hash[key] += 1;
hash[key + '-relation'] += '、' + edge.relation;
} else {
hash[key] = 1;
hash[key + '-relation'] = edge.relation;
}
});
return hash;
},
genLinks(tempEdges) {
const indexHash = {};
return tempEdges.map(edge => {
const linkKey = edge.source + '-' + edge.target;
const count = this.linkMap[linkKey];
if (indexHash[linkKey]) {
indexHash[linkKey] -= 1;
} else {
indexHash[linkKey] = count - 1;
}
return {
...edge,
source: this.nodesMap[edge.source],
target: this.nodesMap[edge.target],
relations: this.linkMap[linkKey + '-relation'],
count: this.linkMap[linkKey],
index: indexHash[linkKey],
};
});
},
updateGraph() {
if (!this.gs || !this.links) {
console.warn('Graph elements are not initialized yet.');
return;
}
this.gs.attr('visibility', d => (d.visible ? 'visible' : 'hidden'));
this.links.attr('visibility', d => (d.visible ? 'visible' : 'hidden'));
this.linksText.attr('visibility', d => (d.visible ? 'visible' : 'hidden'));
},
collapseNode(node) {
//
const edgesToHide = this.edges.filter(edge => edge.target.id === node.id && edge.relation !== 'Next_ST' && edge.relation !== 'Next_TP' && edge.relation !== 'Next_SB' && edge.relation !== 'Next_P' && edge.relation !== 'Next_SS');
//
edgesToHide.forEach(edge => {
edge.visible = false; // visible
this.hideEdge(edge);
});
//
edgesToHide.forEach(edge => {
const sourceNode = edge.source;
this.hideNodeRecursive(sourceNode);
});
},
hideNodeRecursive(node) {
//
if (!node.visible) return;
//
node.visible = false;
this.hideNode(node);
//
const edgesToHide = this.edges.filter(edge => edge.target.id === node.id);
//
edgesToHide.forEach(edge => {
edge.visible = false;
this.hideEdge(edge);
});
//
edgesToHide.forEach(edge => {
const sourceNode = edge.source;
this.hideNodeRecursive(sourceNode);
});
},
hideEdge(edge) {
edge.visible = false;
this.links.filter(d => d === edge).attr('visibility', 'hidden');
this.linksText.filter(d => d === edge).attr('visibility', 'hidden');
},
hideNode(node) {
node.visible = false;
this.gs.filter(d => d.id === node.id).attr('visibility', 'hidden');
},
expandNode(node) {
//
const edgesToShow = this.edges.filter(edge => edge.target.id === node.id && !edge.visible);
//
edgesToShow.forEach(edge => {
edge.visible = true; // visible
this.showEdge(edge);
});
//
edgesToShow.forEach(edge => {
const sourceNode = edge.source;
sourceNode.visible = true; // visible
this.showNode(sourceNode);
});
},
showEdge(edge) {
edge.visible = true; // visible
this.links.filter(d => d === edge).attr('visibility', 'visible');
this.linksText.filter(d => d === edge).attr('visibility', 'visible');
},
showNode(node) {
node.visible = true; // visible
this.gs.filter(d => d.id === node.id).attr('visibility', 'visible');
},
genLinkPath(link) {
return `M${link.source.x},${link.source.y} L${link.target.x},${link.target.y}`;
},
started(d, event) {
if (!event.active) {
this.forceSimulation.alphaTarget(0.8).restart();
}
d.fx = d.x;
d.fy = d.y;
},
dragged(d, event) {
d.fx = event.x;
d.fy = event.y;
},
ended(d, event) {
if (!event.active) {
this.forceSimulation.alphaTarget(0);
}
d.fx = null;
d.fy = null;
},
ticked() {
this.links
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
.attr('visibility', d => d.visible ? 'visible' : 'hidden');
this.linksText
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2)
.attr('visibility', d => d.visible ? 'visible' : 'hidden');
this.gs
.attr('transform', d => `translate(${d.x},${d.y})`)
.attr('visibility', d => d.visible ? 'visible' : 'hidden');
d3.selectAll('.menu').attr('transform', (d, i, nodes) => {
const nodeId = d3.select(nodes[i]).attr('id').replace('menu-', '');
const node = this.nodesData.find(n => n.id === nodeId);
return `translate(${node.x}, ${node.y})`;
});
}
},
};
</script>
<style scoped lang="scss">
.node-info {
margin-bottom: 15px; /* 增加间距 */
}
.content-style {
font-weight: normal;
}
</style>

@ -0,0 +1,136 @@
<script setup>
import { Search} from '@element-plus/icons-vue'
import { ref } from 'vue';
import emitter from './eventBus.js';
const dataInfo = ref({
search_term:''
})
//
const rules = {
search_term: [
{ required: true, message: '搜索不能为空' }
]
}
//
const formRef = ref(null)
const doSearch = ()=>{
const value = dataInfo.value.search_term
formRef.value.validate(async (valid) => {
if (valid) {
emitter.emit('search', value);
}
})
}
</script>
<template>
<header class='app-header'>
<div class="container">
<h1 class="logo">
<RouterLink to="/"></RouterLink>
</h1>
<ul class="app-header-nav">
<li class="home">
<RouterLink to="/">图谱仓库</RouterLink>
</li>
</ul>
<div class="search">
<i class="iconfont icon-search"></i>
<el-form ref="formRef" :model="dataInfo" :rule="rules">
<el-form-item prop="search_term">
<el-input v-model="dataInfo.search_term" type="text" placeholder="搜一搜"/>
</el-form-item>
</el-form>
<el-button type="primary" :icon="Search" @click="doSearch" class="btn"></el-button>
</div>
</div>
</header>
</template>
<style scoped lang='scss'>
.app-header {
background: #fff;
margin-right: 0px;
.container {
width: 100%;
display: flex;
align-items: center;
padding-left: 75px;
margin-left: 0px;
margin-right: 0px;
}
.logo {
width: 150px;
a {
display: block;
height: 125px;
width: 100%;
text-indent: -9999px;
background: url('@/assets/images/logo.png') no-repeat center 18px / contain;
}
}
.app-header-nav {
width: 60%;
display: flex;
padding-left: 40px;
position: relative;
z-index: 998;
li {
margin-right: 40px;
width: 38px;
text-align: center;
a {
margin-top: 10px;
font-weight: bolder;
font-size: 16px;
line-height: 32px;
height: 32px;
width: 70px;
display: inline-block;
&:hover {
color: $xtxColor;
border-bottom: 1px solid $xtxColor;
}
}
.active {
color: $xtxColor;
border-bottom: 1px solid $xtxColor;
}
}
}
.search {
height: 32px;
position: relative;
line-height: 32px;
display: flex;
.icon-search {
font-size: 18px;
margin-left: 5px;
}
input {
width: 140px;
padding-left: 5px;
color: #666;
border-style:solid;
border-width:0px;
border-bottom: 2px solid #e7e7e7;
}
.btn{
margin-left: 6px;
}
}
}
</style>

@ -0,0 +1,122 @@
<script setup>
import {
Document,
Menu as IconMenu,
Location,
} from '@element-plus/icons-vue'
</script>
<script>
import emitter from './eventBus.js';
export default {
methods: {
async onActionClick(level) {
emitter.emit('expand-to-level', level);
},
async onAllClick() {
emitter.emit('showAll');
},
async onActionClick1(level) {
emitter.emit('chapter-nodes', level);
}
}
}
</script>
<template>
<el-row class="tac">
<el-col :span="24">
<h5 class="mb-2">功能列表</h5>
<el-menu
default-active="2"
class="el-menu-vertical-demo menu-responsive"
@open="handleOpen"
@close="handleClose"
>
<el-sub-menu index="1">
<template #title>
<el-icon><icon-menu /></el-icon>
<span>节点查询</span>
</template>
<el-menu-item index="1-1" @click="onAllClick()">
<el-icon><location /></el-icon>
<span>所有节点</span>
</el-menu-item>
<el-menu-item index="1-2" @click="onActionClick(1)">
<el-icon><location /></el-icon>
<span>一级节点</span>
</el-menu-item>
<el-menu-item index="1-3" @click="onActionClick(2)">
<el-icon><location /></el-icon>
<span>二级节点</span>
</el-menu-item>
<el-menu-item index="1-4" @click="onActionClick(3)">
<el-icon><location /></el-icon>
<span>三级节点小节</span>
</el-menu-item>
<el-menu-item index="1-5" @click="onActionClick(4)">
<el-icon><location /></el-icon>
<span>四级节点</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="2">
<template #title>
<el-icon><icon-menu /></el-icon>
<span>章节查询</span>
</template>
<el-menu-item index="2-1" @click="onActionClick1(1)">
<el-icon><Document /></el-icon>
<span>第一章</span>
</el-menu-item>
<el-menu-item index="2-2" @click="onActionClick1(3)">
<el-icon><Document /></el-icon>
<span>第三章</span>
</el-menu-item>
<el-menu-item index="2-3" @click="onActionClick1(6)">
<el-icon><Document /></el-icon>
<span>第六章</span>
</el-menu-item>
<el-menu-item index="2-4" @click="onActionClick1(8)">
<el-icon><Document /></el-icon>
<span>第八章</span>
</el-menu-item>
<el-menu-item index="2-5" @click="onActionClick1(15)">
<el-icon><Document /></el-icon>
<span>第十五章</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-col>
</el-row>
</template>
<style scoped lang="scss">
.tac {
height: 100%;
background-color: white;
.mb-2 {
text-align: center;
justify-content: center;
font-size: 20px;
}
}
.menu-responsive {
max-width: 300px; /* 默认宽度 */
width: 100%;
}
@media screen and (max-width: 768px) {
.menu-responsive {
max-width: 100%; /* 小屏设备时占满宽度 */
}
}
@media screen and (min-width: 769px) and (max-width: 1200px) {
.menu-responsive {
max-width: 400px; /* 中等屏设备时适当增加宽度 */
}
}
</style>

@ -0,0 +1,4 @@
import mitt from 'mitt';
const emitter = mitt();
export default emitter;

@ -0,0 +1,29 @@
<script setup>
import LayoutHeader from './components/LayoutHeader.vue';
import LayoutMeum from './components/LayoutMeum.vue';
import LayoutGraph from './components/LayoutGraph.vue';
</script>
<template>
<div class="common-layout">
<el-container>
<el-header height="150px" class="header">
<LayoutHeader></LayoutHeader>
</el-header>
<el-container>
<el-aside width="200px" style="box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;">
<LayoutMeum/>
</el-aside>
<el-main style="padding-top: 0px; padding-bottom: 1px; box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;"><LayoutGraph/></el-main>
</el-container>
</el-container>
</div>
</template>
<style scoped lang='scss'>
.header{
padding: 0px;
}
</style>

@ -0,0 +1,42 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// elementPlus
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [
// 配置elementPlus采用sass样式配色系统
ElementPlusResolver({importStyle:"sass"}),
],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
css: {
preprocessorOptions: {
scss: {
// 自动导入定制化样式文件进行样式覆盖
additionalData: `
@use "@/styles/element/index.scss" as *;
@use "@/styles/var.scss" as *;
`,
}
}
}
})
Loading…
Cancel
Save