Browse Source

sqlflow页面提交

master
si@aidatagov.com 5 days ago
parent
commit
bb8dd8f4fd
  1. 78
      vue-fastapi-frontend/src/components/codemirror/SQLCodeMirrorSqlFlow.vue
  2. 744
      vue-fastapi-frontend/src/views/meta/metaInfo/bloodRelationSql.vue
  3. 172
      vue-fastapi-frontend/src/views/sqlFlow/index.vue

78
vue-fastapi-frontend/src/components/codemirror/SQLCodeMirrorSqlFlow.vue

@ -0,0 +1,78 @@
<template>
<codemirror v-model:value="value" :options="sqlOptions" :dbType="dbType" />
</template>
<script setup>
// sql
import * as sqlFormatter from "sql-formatter";
import Codemirror from 'codemirror-editor-vue3';
import 'codemirror/mode/sql/sql.js';
import "codemirror/mode/javascript/javascript.js";
// language
import 'codemirror/mode/javascript/javascript.js';
// theme
import 'codemirror/theme/monokai.css';
//
import 'codemirror/addon/fold/foldcode.js';
import 'codemirror/addon/fold/foldgutter.js';
import 'codemirror/addon/fold/foldgutter.css';
import 'codemirror/addon/fold/brace-fold.js';
//
import 'codemirror/addon/hint/show-hint.js';
import 'codemirror/addon/hint/show-hint.css';
import 'codemirror/addon/hint/javascript-hint.js';
// lint
import 'codemirror/addon/lint/lint.js';
import 'codemirror/addon/lint/lint.css';
import 'codemirror/addon/lint/json-lint';
//
import 'codemirror/addon/edit/matchbrackets.js';
import 'codemirror/addon/edit/closebrackets.js';
import "codemirror/addon/lint/json-lint.js";
import {watch,ref} from "vue";
const props = defineProps({
data: String,
dbType: String,
})
const sqlOptions = {
autorefresh: true, //
smartIndent: true, //
tabSize: 4, // 4
mode: "text/x-sql", //
line: true, //
viewportMargin: Infinity, //
highlightDifferences: true,
autofocus: false,
indentUnit: 2,
readOnly: false, //
showCursorWhenSelecting: true,
firstLineNumber: 1,
matchBrackets: true,//
lineWrapping: true, //
gutters: [
"CodeMirror-linenumbers",
"CodeMirror-foldgutter",
"CodeMirror-lint-markers",
],
lineNumbers: true, //
lint: true, // json
}
const value = ref("")
watch(() => value.value,
(val) =>{
console.log(props.dbType)
value.value = sqlFormatter.format(val,{ language: props.dbType?.toLowerCase() || "sql" })
}
)
onMounted(()=>{
value.value = props.data
}
)
</script>
<style scoped lang="scss">
</style>

744
vue-fastapi-frontend/src/views/meta/metaInfo/bloodRelationSql.vue

@ -0,0 +1,744 @@
<template>
<div id="bloodRelationG6" style="width: 100%;height: 100%;overflow: auto;position: relative"></div>
<div id="bloodmini-container"></div>
</template>
<script setup>
import G6 from '@antv/g6';
import {watch} from "vue";
const props = defineProps({
data: {
type: Object,
default: () => {}
},
currentTable: {
type:Object,
default:()=>{}
}
})
const g6data = ref([])
function initG6() {
const {
Util,
registerBehavior,
registerEdge,
registerNode
} = G6;
const isInBBox = (point, bbox) => {
const {
x,
y
} = point;
const {
minX,
minY,
maxX,
maxY
} = bbox;
return x < maxX && x > minX && y > minY && y < maxY;
};
const itemHeight = 20;
registerBehavior("dice-er-scroll", {
getDefaultCfg() {
return {
multiple: true,
};
},
getEvents() {
return {
itemHeight,
wheel: "scorll",
click: "click",
"node:mousemove": "move",
"node:mousedown": "mousedown",
"node:mouseup": "mouseup"
};
},
scorll(e) {
// e.preventDefault();
const {
graph
} = this;
const nodes = graph.getNodes().filter((n) => {
const bbox = n.getBBox();
return isInBBox(graph.getPointByClient(e.clientX, e.clientY), bbox);
});
const x = e.deltaX || e.movementX;
let y = e.deltaY || e.movementY;
if (!y && navigator.userAgent.indexOf('Firefox') > -1) y = (-e.wheelDelta * 125) / 3
if (nodes) {
const edgesToUpdate = new Set();
nodes.forEach((node) => {
return;
const model = node.getModel();
if (model.attrs.length < 2) {
return;
}
const idx = model.startIndex || 0;
let startX = model.startX || 0.5;
let startIndex = idx + y * 0.02;
startX -= x;
if (startIndex < 0) {
startIndex = 0;
}
if (startX > 0) {
startX = 0;
}
if (startIndex > model.attrs.length - 1) {
startIndex = model.attrs.length - 1;
}
graph.updateItem(node, {
startIndex,
startX,
});
node.getEdges().forEach(edge => edgesToUpdate.add(edge))
});
// G6 update the related edges when graph.updateItem with a node according to the new properties
// here you need to update the related edges manualy since the new properties { startIndex, startX } for the nodes are custom, and cannot be recognized by G6
edgesToUpdate.forEach(edge => edge.refresh())
}
},
click(e) {
const {
graph
} = this;
const item = e.item;
const shape = e.shape;
if (!item) {
return;
}
if (shape.get("name") === "collapse") {
graph.updateItem(item, {
collapsed: true,
size: [300, 50],
});
setTimeout(() => {
graph.layout();
}, 50);
} else if (shape.get("name") === "expand") {
graph.updateItem(item, {
collapsed: false,
size: [300, 120],
});
setTimeout(() => {
graph.layout();
}, 50);
}
else if (shape.get("name") && shape.get("name").startsWith("item")) {
let edges = graph.getEdges()
let columnName = ''
g6data.value.forEach(data => {
if (data.id === item.getModel().id) {
columnName = data.attrs[shape.get("name").split("-")[1]].key
}
})
edges.forEach(edg => {
if (edg.getModel().sourceKey === columnName && edg.getModel().source === item.getModel().id) {
edg.show()
} else if(edg.getModel().targetKey === columnName && edg.getModel().target === item.getModel().id){
edg.show()
} else{
edg.hide()
}
})
let nodes = graph.getNodes()
let index = Number(shape.get("name").split("-")[1])
nodes.forEach(node => {
let model = node.getModel()
model.selectedIndex = NaN
graph.updateItem(node, model)
})
this.graph.updateItem(item, {
selectedIndex: index,
});
} else {
graph.updateItem(item, {
selectedIndex: NaN,
});
let edges = graph.getEdges()
edges.forEach(edg => {
edg.show()
})
}
},
mousedown(e) {
this.isMousedown = true;
},
mouseup(e) {
this.isMousedown = false;
},
move(e) {
if (this.isMousedown) return;
// const name = e.shape.get("name");
// const item = e.item;
//
// if (name && name.startsWith("item")) {
// this.graph.updateItem(item, {
// selectedIndex: Number(name.split("-")[1]),
// });
// } else {
// this.graph.updateItem(item, {
// selectedIndex: NaN,
// });
// }
},
});
registerEdge("dice-er-edge", {
draw(cfg, group) {
const edge = group.cfg.item;
const sourceNode = edge.getSource().getModel();
const targetNode = edge.getTarget().getModel();
const sourceIndex = sourceNode.attrs.findIndex(
(e) => e.key === cfg.sourceKey
);
const sourceStartIndex = sourceNode.startIndex || 0;
let sourceY = 15;
if (!sourceNode.collapsed && sourceIndex > sourceStartIndex - 1) {
sourceY = 30 + (sourceIndex - sourceStartIndex + 0.5) * itemHeight;
}
const targetIndex = targetNode.attrs.findIndex(
(e) => e.key === cfg.targetKey
);
const targetStartIndex = targetNode.startIndex || 0;
let targetY = 15;
if (!targetNode.collapsed && targetIndex > targetStartIndex - 1) {
targetY = (targetIndex - targetStartIndex + 0.5) * itemHeight + 30;
}
const startPoint = {
...cfg.startPoint
};
const endPoint = {
...cfg.endPoint
};
startPoint.y = startPoint.y + sourceY;
endPoint.y = endPoint.y + targetY;
let shape;
if (sourceNode.id !== targetNode.id) {
shape = group.addShape("path", {
attrs: {
stroke: "#5B8FF9",
path: [
["M", startPoint.x, startPoint.y],
[
"C",
endPoint.x / 3 + (2 / 3) * startPoint.x,
startPoint.y,
endPoint.x / 3 + (2 / 3) * startPoint.x,
endPoint.y,
endPoint.x,
endPoint.y,
],
],
endArrow: true,
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: "path-shape",
});
} else {
let gap = Math.abs((startPoint.y - endPoint.y) / 3);
if (startPoint["index"] === 1) {
gap = -gap;
}
shape = group.addShape("path", {
attrs: {
stroke: "#5B8FF9",
path: [
["M", startPoint.x, startPoint.y],
[
"C",
startPoint.x - gap,
startPoint.y,
startPoint.x - gap,
endPoint.y,
startPoint.x,
endPoint.y,
],
],
endArrow: props.type === 'er'? {path: G6.Arrow.triangle(10,-10,6),fill:'#5B8FF9'} : false//cfg.endArrow,
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: "path-shape",
});
}
return shape;
},
afterDraw(cfg, group) {
const labelCfg = cfg.labelCfg || {};
const edge = group.cfg.item;
const sourceNode = edge.getSource().getModel();
const targetNode = edge.getTarget().getModel();
if (sourceNode.collapsed && targetNode.collapsed) {
return;
}
const path = group.find(
(element) => element.get("name") === "path-shape"
);
const labelStyle = Util.getLabelPosition(path, 0.5, 0, 0, true);
const label = group.addShape("text", {
attrs: {
...labelStyle,
text: cfg.label || '',
fill: "#000",
textAlign: "center",
stroke: "#fff",
lineWidth: 1,
},
});
label.rotateAtStart(labelStyle.rotate);
},
});
registerNode("dice-er-box", {
draw(cfg, group) {
const width = 300;
const {
attrs = [],
startIndex = 0,
selectedIndex,
collapsed,
icon,
} = cfg;
let currentTableLabel = props.currentTable.tabEngName
if (props.currentTable.tabCnName && props.currentTable.tabCnName.length>0){
currentTableLabel += "("+props.currentTable.tabCnName+")"
}else if (props.currentTable.tabCrrctName && props.currentTable.tabCnName.tabCrrctName>0){
currentTableLabel += "("+props.currentTable.tabCrrctName+")"
}
const list = attrs;
const itemCount = list.length;
const boxStyle = {
stroke: currentTableLabel === cfg.label?"#67C23A":"#096DD9",
radius: 4,
};
const afterList = list.slice(
Math.floor(startIndex),
Math.floor(startIndex + itemCount)
);
const offsetY = (0.5 - (startIndex % 1)) * itemHeight + 30;
//
group.addShape("rect", {
attrs: {
fill: boxStyle.stroke,
height: 30,
width,
radius: [boxStyle.radius, boxStyle.radius, 0, 0],
},
draggable: true,
});
let fontLeft = 12;
//
if (icon && icon.show !== false) {
group.addShape("image", {
attrs: {
x: 8,
y: 8,
height: 16,
width: 16,
...icon,
},
});
fontLeft += 18;
}
//
group.addShape("text", {
attrs: {
y: 22,
x: fontLeft,
fill: "#fff",
text: cfg.label,
fontSize: 12,
fontWeight: 500,
},
});
//div
group.addShape("rect", {
attrs: {
x: 0,
y: collapsed ? 30 : (list.length + 1) * itemHeight + 15,
height: 15,
width,
fill: "#eee",
radius: [0, 0, boxStyle.radius, boxStyle.radius],
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: collapsed ? "expand" : "collapse",
});
//
group.addShape("text", {
attrs: {
x: width / 2 - 6,
y: (collapsed ? 30 : (list.length + 1) * itemHeight + 15) + 12,
text: collapsed ? "+" : "-",
width,
fill: "#000",
radius: [0, 0, boxStyle.radius, boxStyle.radius],
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: collapsed ? "expand" : "collapse",
});
//
const keyshape = group.addShape("rect", {
attrs: {
x: 0,
y: 0,
width,
height: collapsed ? 45 : (list.length + 1) * itemHeight + 15 + 15,
...boxStyle,
},
draggable: true,
});
if (collapsed) {
return keyshape;
}
const listContainer = group.addGroup({});
//
listContainer.setClip({
type: "rect",
attrs: {
x: -8,
y: 30,
width: width + 16,
height: (list.length + 1) * itemHeight + 15 - 30,
},
});
//
listContainer.addShape({
type: "rect",
attrs: {
x: 1,
y: 30,
width: width - 2,
height: (list.length + 1) * itemHeight + 15 - 30,
fill: "#fff",
},
draggable: true,
});
if (list.length + 1 > itemCount) {
const barStyle = {
width: 4,
padding: 0,
boxStyle: {
stroke: "#00000022",
},
innerStyle: {
fill: "#00000022",
},
};
//
listContainer.addShape("rect", {
attrs: {
y: 30,
x: width - barStyle.padding - barStyle.width,
width: barStyle.width,
height: (list.length + 1) * itemHeight - 30,
...barStyle.boxStyle,
},
});
const indexHeight =
afterList.length > itemCount ?
(afterList.length / list.length + 1) * (list.length + 1) * itemHeight :
10;
//
listContainer.addShape("rect", {
attrs: {
y: 30 +
barStyle.padding +
(startIndex / list.length + 1) * ((list.length + 1) * itemHeight - 30),
x: width - barStyle.padding - barStyle.width,
width: barStyle.width,
height: Math.min((list.length + 1) * itemHeight, indexHeight),
...barStyle.innerStyle,
},
});
}
if (afterList) {
afterList.forEach((e, i) => {
const isSelected =
Math.floor(startIndex) + i === Number(selectedIndex);
let {
key = "", type
} = e;
if (type) {
key += " - " + type;
}
const label = key;
//
listContainer.addShape("rect", {
attrs: {
x: 1,
y: i * itemHeight - itemHeight / 2 + offsetY,
width: width - 4,
height: itemHeight,
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: `item-${Math.floor(startIndex) + i}-content`,
draggable: true,
});
if (!cfg.hideDot) {
//
listContainer.addShape("circle", {
attrs: {
x: 0,
y: i * itemHeight + offsetY,
r: 3,
stroke: boxStyle.stroke,
fill: "white",
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
});
//
listContainer.addShape("circle", {
attrs: {
x: width,
y: i * itemHeight + offsetY,
r: 3,
stroke: boxStyle.stroke,
fill: "white",
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
});
}
//
listContainer.addShape("text", {
attrs: {
x: 12,
y: i * itemHeight + offsetY + 6,
text: label,
fontSize: 12,
fill: "#000",
fontFamily: "Avenir,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol",
full: e,
fontWeight: isSelected ? 500 : 100,
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: `item-${Math.floor(startIndex) + i}`,
});
});
}
return keyshape;
},
getAnchorPoints() {
return [
[0, 0],
[1, 0],
];
},
});
const dataTransform = (data) => {
const nodes = [];
let edges = [];
data.map((node) => {
nodes.push({
...node
});
if (node.attrs) {
node.attrs.forEach((attr) => {
if (attr.relation) {
attr.relation.forEach((relation) => {
edges.push({
source: node.id,
target: relation.nodeId,
sourceKey: attr.key,
endArrow: relation.endArrow,
targetKey: relation.key,
label: relation.label,
});
});
}
});
}
});
return {
nodes,
edges,
};
}
const container = document.getElementById("bloodRelationG6");
const mini_container = document.getElementById("bloodmini-container");
const width = container.scrollWidth;
const height = container.scrollHeight || container.offsetHeight || 500;
// minimap
const minimap = new G6.Minimap({
size: [200, 200],
container: mini_container,
type: 'delegate',
});
const graph = new G6.Graph({
container: container,
plugins: [minimap],
width,
height,
defaultNode: {
size: [350, 200],
type: 'dice-er-box',
color: '#5B8FF9',
style: {
fill: '#9EC9FF',
lineWidth: 3,
},
labelCfg: {
style: {
fill: 'black',
fontSize: 20,
},
},
},
defaultEdge: {
type: 'dice-er-edge',
style: {
stroke: '#e2e2e2',
lineWidth: 4,
endArrow: {path: G6.Arrow.triangle(10,-10,6),fill:'#5B8FF9'},
},
},
modes: {
default: ['dice-er-scroll', 'drag-node', 'drag-canvas', 'zoom-canvas'],
},
layout: {
type: 'dagre',
rankdir: 'LR',
align: 'UL',
controlPoints: true,
nodesep: 1,
ranksep: 1
},
animate: true,
// fitView: true
})
graph.data(dataTransform(g6data.value))
graph.render();
}
watch(
() => props.data,
(val) => {
g6data.value = []
if (props.data.tableList && props.data.tableList.length>0){
for (let i = 0; i < props.data.tableList.length; i++) {
let table = props.data.tableList[i]
let g6Tab = {
id: table.ssys_id+"-"+table.mdl_name+"-"+table.tab_eng_name,
label: table.tab_eng_name + ((table.tab_cn_name && table.tab_cn_name.length>0)?"("+table.tab_cn_name+")":""),
attrs:[],
collapsed:true
}
for (let j = 0; j < table.column.length; j++) {
g6Tab.attrs.push({
key: table.column[j].fldEngName,
type: table.column[j].fldType
})
}
g6data.value.push(g6Tab)
}
if (props.data.relation && props.data.relation.length>0){
for (let i = 0; i < props.data.relation.length; i++) {
let relation = props.data.relation[i]
let key = relation.targetColName.toLowerCase()
let tableKey = relation.sourceSysId+"-"+relation.sourceMdlName.toLowerCase()+"-"+relation.sourceTableName.toLowerCase()
let nodeId = relation.targetSysId+"-"+relation.targetMdlName.toLowerCase()+"-"+relation.targetTableName.toLowerCase()
if (g6data.value.length > 0){
for (let j = 0; j < g6data.value.length; j++) {
if (g6data.value[j].id === tableKey){
for (let k = 0; k < g6data.value[j].attrs.length; k++) {
if (g6data.value[j].attrs[k].key === relation.sourceColName.toLowerCase()){
if (g6data.value[j].attrs[k].relation && g6data.value[j].attrs[k].relation.length>0){
let hasRelation = false
for (let l = 0; l < g6data.value[j].attrs[k].relation.length; l++) {
if (g6data.value[j].attrs[k].relation[l].key === key && g6data.value[j].attrs[k].relation[l].nodeId === nodeId){
hasRelation = true
}
}
if (!hasRelation){
g6data.value[j].attrs[k].relation.push({
key: key,
nodeId: nodeId,
endArrow: true
})
}
}else {
g6data.value[j].attrs[k].relation = [{
key: key,
nodeId: nodeId,
endArrow: true
}]
}
}
}
}
}
}
}
}
let g6 = document.getElementById("bloodRelationG6")
const mini_container = document.getElementById("bloodmini-container");
if (mini_container){
mini_container.innerHTML=''
}
if (g6){
g6.innerHTML=''
initG6()
}
}
},
{ deep: true,immediate: true }
)
</script>
<style scoped lang="scss">
#bloodmini-container{
position: absolute !important;
right: 20px;
bottom: 20px;
border: 1px solid #e2e2e2;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.9);
z-index: 10;
}
</style>

172
vue-fastapi-frontend/src/views/sqlFlow/index.vue

@ -1,22 +1,182 @@
<template>
<div class="sql-container">
<SQLCodeMirror v-if="activeColumnTab === 'proc'" :data="procStr" :dbType="dbType" />
<div class="app-container" ref="containerRef">
<!-- 左侧 SQL 编辑区 -->
<div class="sql-container" :style="{ width: leftWidth + 'px' }">
<!-- 工具栏 -->
<div class="toolbar">
<el-select
v-model="dbType"
placeholder="选择数据库类型"
size="small"
style="width: 160px"
>
<el-option label="MySQL" value="MYSQL" />
<el-option label="PostgreSQL" value="PG" />
<el-option label="SQL Server" value="MSSQL" />
<el-option label="Oracle" value="ORACLE" />
</el-select>
<el-button
type="primary"
size="small"
style="margin-left: 10px"
@click="executeSql"
>
执行
</el-button>
</div>
<!-- SQL 编辑器 -->
<SQLCodeMirror
v-if="activeColumnTab === 'proc'"
:data="procStr"
:dbType="dbType"
/>
</div>
<!-- 分隔条 -->
<div class="divider" @mousedown="startDragging"></div>
<!-- 右侧 血缘关系图 -->
<div class="relation-container">
<BloodRelation :currentTable="currentMetaData" :data="bloodRelation" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import SQLCodeMirror from '@/components/codemirror/SQLCodeMirror.vue'
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { getMetaDataBloodRelship } from '@/api/meta/metaInfo'
import BloodRelation from '@/views/meta/metaInfo/bloodRelationSql.vue'
import SQLCodeMirror from '@/components/codemirror/SQLCodeMirrorSqlFlow.vue'
// ========================= =========================
const activeColumnTab = ref('proc')
const procStr = ref('SELECT * FROM users LIMIT 100;')
const dbType = ref('MYSQL')
const containerRef = ref(null)
//
const currentMetaData = reactive({
tabEngName: 't_dim_comp',
tabCnName: '公司维度表',
ssysCd: 'PG_CONN',
ssysId: 1,
mdlName: 'public',
tabCrrctName: '',
tabDesc: '',
govFlag: null,
pic: '',
tags: []
})
//
const bloodRelation = ref([])
// ========================= =========================
const leftWidth = ref(600)
const isDragging = ref(false)
let startX = 0
let startWidth = 0
const startDragging = e => {
isDragging.value = true
startX = e.clientX
startWidth = leftWidth.value
document.body.style.userSelect = 'none' //
document.addEventListener('mousemove', handleDragging)
document.addEventListener('mouseup', stopDragging)
}
const handleDragging = e => {
if (!isDragging.value) return
const containerLeft = containerRef.value?.getBoundingClientRect().left || 0
const delta = e.clientX - startX
const newWidth = startWidth + delta
const minWidth = 300
const maxWidth = window.innerWidth - 400 //
leftWidth.value = Math.min(Math.max(newWidth, minWidth), maxWidth)
}
const stopDragging = () => {
isDragging.value = false
document.body.style.userSelect = '' //
document.removeEventListener('mousemove', handleDragging)
document.removeEventListener('mouseup', stopDragging)
}
// ========================= =========================
const changeBloodOption = () => {
getMetaDataBloodRelship(currentMetaData.ssysId).then(res => {
bloodRelation.value = res.data
})
}
const executeSql = () => {
console.log('执行 SQL:', procStr.value)
console.log('数据库类型:', dbType.value)
ElMessage.success(`已在 ${dbType.value} 数据库中执行 SQL`)
}
// ========================= =========================
onMounted(() => {
changeBloodOption()
})
onBeforeUnmount(() => {
stopDragging()
})
</script>
<style scoped>
.app-container {
display: flex;
flex-direction: row;
height: 100vh;
width: 100%;
overflow: hidden;
}
/* 左侧 SQL 编辑区 */
.sql-container {
padding: 16px;
height: 100%;
background: #fafafa;
height: 100vh;
border-right: 1px solid #e0e0e0;
overflow: auto;
transition: width 0.1s ease-out;
display: flex;
flex-direction: column;
}
/* 工具栏 */
.toolbar {
display: flex;
align-items: center;
background: #f5f7fa;
border-bottom: 1px solid #e0e0e0;
padding: 8px 12px;
height: 45px;
}
/* 分隔条 */
.divider {
width: 6px;
cursor: col-resize;
background-color: #dcdcdc;
transition: background-color 0.2s;
flex-shrink: 0;
}
.divider:hover {
background-color: #aaa;
}
/* 右侧 血缘关系图 */
.relation-container {
flex: 1;
height: 100%;
overflow: hidden;
padding: 8px;
background: #fff;
}
</style>

Loading…
Cancel
Save