feat:关系图谱优化

This commit is contained in:
dzc committed 2026-07-20 17:51:14 +08:00
1 parent c69e87d1db
commit dcf3d2fd6a
5 files changed
+515 -24

No files matched your search

+13
View File
@@ -70,6 +70,10 @@ export interface RelationCreateForm {
remark?: string
}
export interface RelationUpdateForm extends RelationCreateForm {
relationId: string
}
// 关系图谱
export function getRelationGraph(userId?: string) {
return request({
@@ -95,3 +99,12 @@ export function createRelation(data: RelationCreateForm) {
data,
})
}
// 修改已有用户关系
export function updateRelation(data: RelationUpdateForm) {
return request({
url: "/admin/sysUserRelation/update",
method: "put",
data,
})
}
@@ -1,6 +1,24 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row>
<el-form ref="queryRef" :inline="true" :model="state.queryForm" @keyup.enter="getDataList">
<el-form-item style="width: 250px" label="关系类型" prop="relationName">
<el-input v-model="state.queryForm.relationName" placeholder="请输入关系类型" clearable />
</el-form-item>
<el-form-item style="width: 250px" label="关系分类" prop="category">
<dict-select v-model="state.queryForm.category" dict-code="sys_user_relation_category" />
</el-form-item>
<el-form-item>
<el-button icon="Search" type="primary" @click="getDataList">
{{ $t("common.queryBtn") }}
</el-button>
<el-button icon="Refresh" @click="resetQuery">
{{ $t("common.resetBtn") }}
</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button class="ml10" icon="folder-add" type="primary" v-auth="'dzc_sysUserRelationType_add'"
@@ -80,6 +98,8 @@ const { sys_user_relation_category, sys_user_relation_type, yes_no_type } = useD
"yes_no_type",
)
// 搜索变量
const queryRef = ref()
const relationStatusOptions = [
{ label: "正常", value: "0", elTagType: "success" },
{ label: "停用", value: "1", elTagType: "danger" },
@@ -101,6 +121,15 @@ const selectionChangeHandle = (rows: { relationTypeId: string }[]) => {
multiple.value = !rows.length
}
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields()
// 清空多选
selectObjs.value = []
getDataList()
}
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm("此操作将永久删除")
@@ -58,7 +58,8 @@
<user-info-form ref="userInfoFormRef" :model="newUserForm" :loading="submitting" />
</el-tab-pane>
<el-tab-pane label="选择已有用户" name="existing">
<el-form ref="existingUserFormRef" :model="existingUserForm" :rules="existingUserRules" label-width="100px">
<el-form ref="existingUserFormRef" :model="existingUserForm" :rules="existingUserRules"
label-width="100px">
<el-form-item label="关联用户" prop="userId">
<select-user v-model="existingUserForm.userId"
:exclude-user-ids="targetUser ? [targetUser.userId] : []" placeholder="输入姓名或手机号选择用户"
@@ -0,0 +1,350 @@
<template>
<el-dialog v-model="visible" title="修改用户关系" width="760px" :close-on-click-modal="false" destroy-on-close>
<div class="relation-summary">
<div class="summary-user">
<div class="summary-avatar">{{ getInitial(subjectUserName) }}</div>
<div>
<div class="summary-name">{{ subjectUserName }}</div>
<div class="summary-role">关系主语</div>
</div>
</div>
<el-icon class="summary-arrow">
<Right />
</el-icon>
<div class="summary-user">
<div class="summary-avatar is-object">{{ getInitial(objectUserName) }}</div>
<div>
<div class="summary-name">{{ objectUserName }}</div>
<div class="summary-role">关系对象</div>
</div>
</div>
</div>
<el-form ref="formRef" v-loading="relationOptionsLoading" :model="form" :rules="rules" label-width="100px">
<el-divider content-position="center">绑定用户</el-divider>
<el-row :gutter="20">
<el-col :span="11" class="mb20">
<el-form-item label="关系主语" prop="subjectUserId">
<select-user
v-model="form.subjectUserId"
:exclude-user-ids="form.objectUserId ? [form.objectUserId] : []"
placeholder="请选择关系主语用户"
@change="selectedSubjectUser = $event"
/>
</el-form-item>
</el-col>
<el-col :span="2" class="swap-column">
<el-tooltip content="交换关系双方" placement="top">
<el-button circle icon="Switch" @click="swapUsers" />
</el-tooltip>
</el-col>
<el-col :span="11" class="mb20">
<el-form-item label="关系对象" prop="objectUserId">
<select-user
v-model="form.objectUserId"
:exclude-user-ids="form.subjectUserId ? [form.subjectUserId] : []"
placeholder="请选择关系对象用户"
@change="selectedObjectUser = $event"
/>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="center">关系信息</el-divider>
<el-row :gutter="20">
<el-col :span="12" class="mb20">
<el-form-item label="关系类型" prop="relationTypeId">
<el-select v-model="form.relationTypeId" filterable placeholder="请选择关系类型" class="w100">
<el-option
v-for="item in relationTypeOptions"
:key="item.relationTypeId"
:label="formatRelationType(item)"
:value="item.relationTypeId"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="生效时间" prop="effectiveTime">
<el-date-picker
v-model="form.effectiveTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择生效时间"
class="w100"
/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="失效时间" prop="expireTime">
<el-date-picker
v-model="form.expireTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择失效时间"
class="w100"
/>
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="关系备注" prop="remark">
<el-input v-model.trim="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入关系备注" />
</el-form-item>
</el-col>
</el-row>
<el-alert type="info" :closable="false" show-icon>
<template #title>{{ relationDescription }}</template>
</el-alert>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存修改</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="RelationEditForm">
import type { FormInstance, FormRules } from "element-plus"
import { type RelationGraphNode, type RelationUpdateForm, updateRelation } from "/@/api/admin/sysUserRelation"
import { fetchList as fetchRelationTypes } from "/@/api/admin/sysUserRelationType"
import { useMessage } from "/@/hooks/message"
interface RelationTypeOption {
relationTypeId: string
relationName: string
subjectRoleName?: string
objectRoleName?: string
directedFlag?: string
status: string
}
interface RelationUserNames {
subjectUserName?: string
objectUserName?: string
}
const emit = defineEmits<{
(e: "success"): void
}>()
const visible = ref(false)
const submitting = ref(false)
const relationOptionsLoading = ref(false)
const relationOptionsLoaded = ref(false)
const formRef = ref<FormInstance>()
const relationTypeOptions = ref<RelationTypeOption[]>([])
const selectedSubjectUser = ref<any>()
const selectedObjectUser = ref<any>()
const initialUserNames = reactive<Required<RelationUserNames>>({
subjectUserName: "关系主语",
objectUserName: "关系对象",
})
const createInitialForm = (): RelationUpdateForm => ({
relationId: "",
subjectUserId: "",
objectUserId: "",
relationTypeId: "",
effectiveTime: "",
expireTime: "",
remark: "",
})
const form = reactive<RelationUpdateForm>(createInitialForm())
const selectedRelationType = computed(() =>
relationTypeOptions.value.find((item) => String(item.relationTypeId) === String(form.relationTypeId)),
)
const subjectUserName = computed(() => getSelectedUserName(selectedSubjectUser.value, initialUserNames.subjectUserName, form.subjectUserId))
const objectUserName = computed(() => getSelectedUserName(selectedObjectUser.value, initialUserNames.objectUserName, form.objectUserId))
const relationDescription = computed(() => {
const relationType = selectedRelationType.value
const roles = formatDirectionRoles(relationType?.subjectRoleName, relationType?.objectRoleName)
const connector = relationType?.directedFlag === "1" ? " → " : " ↔ "
return `将把关系调整为“${subjectUserName.value}${connector}${objectUserName.value}”${roles}`
})
const validateDifferentUsers = (_rule: any, _value: string, callback: (error?: Error) => void) => {
if (form.subjectUserId && form.objectUserId && String(form.subjectUserId) === String(form.objectUserId)) {
callback(new Error("关系双方不能是同一个用户"))
return
}
callback()
}
const validateExpireTime = (_rule: any, value: string, callback: (error?: Error) => void) => {
if (value && form.effectiveTime && value < form.effectiveTime) {
callback(new Error("失效时间不能早于生效时间"))
return
}
callback()
}
const rules: FormRules = {
subjectUserId: [
{ required: true, message: "请选择关系主语用户", trigger: "change" },
{ validator: validateDifferentUsers, trigger: "change" },
],
objectUserId: [
{ required: true, message: "请选择关系对象用户", trigger: "change" },
{ validator: validateDifferentUsers, trigger: "change" },
],
relationTypeId: [{ required: true, message: "请选择关系类型", trigger: "change" }],
expireTime: [{ validator: validateExpireTime, trigger: "change" }],
}
const loadRelationTypes = async () => {
if (relationOptionsLoaded.value) return
relationOptionsLoading.value = true
try {
const { data }: any = await fetchRelationTypes({ current: 1, size: 1000 })
relationTypeOptions.value = (data?.records || []).filter((item: RelationTypeOption) => item.status === "0")
relationOptionsLoaded.value = true
} catch (error: any) {
useMessage().error(error?.msg || "加载关系类型失败")
} finally {
relationOptionsLoading.value = false
}
}
const openDialog = async (relation: RelationGraphNode, names: RelationUserNames = {}) => {
if (!relation.relationId || !relation.subjectUserId || !relation.objectUserId) {
useMessage().warning("当前节点没有可修改的关系")
return
}
Object.assign(form, createInitialForm(), {
relationId: String(relation.relationId),
subjectUserId: String(relation.subjectUserId),
objectUserId: String(relation.objectUserId),
relationTypeId: String(relation.relationTypeId || ""),
effectiveTime: relation.effectiveTime || "",
expireTime: relation.expireTime || "",
remark: relation.remark || "",
})
initialUserNames.subjectUserName = names.subjectUserName || String(relation.subjectUserId)
initialUserNames.objectUserName = names.objectUserName || String(relation.objectUserId)
selectedSubjectUser.value = undefined
selectedObjectUser.value = undefined
visible.value = true
await nextTick()
formRef.value?.clearValidate()
await loadRelationTypes()
}
const swapUsers = () => {
const subjectUserId = form.subjectUserId
const subjectUser = selectedSubjectUser.value
const subjectName = initialUserNames.subjectUserName
form.subjectUserId = form.objectUserId
form.objectUserId = subjectUserId
selectedSubjectUser.value = selectedObjectUser.value
selectedObjectUser.value = subjectUser
initialUserNames.subjectUserName = initialUserNames.objectUserName
initialUserNames.objectUserName = subjectName
nextTick(() => formRef.value?.validateField(["subjectUserId", "objectUserId"]).catch(() => undefined))
}
const submit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await updateRelation({ ...form })
useMessage().success("用户关系修改成功")
visible.value = false
emit("success")
} catch (error: any) {
useMessage().error(error?.msg || "修改用户关系失败")
} finally {
submitting.value = false
}
}
const formatRelationType = (item: RelationTypeOption) => {
const roles = [item.subjectRoleName, item.objectRoleName].filter(Boolean).join(" / ")
return roles ? `${item.relationName}(${roles})` : item.relationName
}
const formatDirectionRoles = (subjectRoleName?: string, objectRoleName?: string) => {
if (!subjectRoleName && !objectRoleName) return ""
return `(主语:${subjectRoleName || "-"},对象:${objectRoleName || "-"})`
}
const getSelectedUserName = (user: any, fallback: string, userId: string) => {
if (!userId) return "未选择用户"
return user?.name || user?.nickname || user?.username || fallback || userId
}
const getInitial = (name?: string) => name?.trim().slice(0, 1) || "用"
defineExpose({ openDialog })
</script>
<style scoped lang="scss">
.relation-summary {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 16px;
margin-bottom: 12px;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 12px;
}
.summary-user {
display: flex;
align-items: center;
gap: 10px;
min-width: 180px;
}
.summary-avatar {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
color: #fff;
font-size: 17px;
font-weight: 600;
background: var(--el-color-primary);
border-radius: 50%;
}
.summary-avatar.is-object {
background: #7c3aed;
}
.summary-name {
font-weight: 600;
color: var(--el-text-color-primary);
}
.summary-role {
margin-top: 3px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.summary-arrow {
color: var(--el-color-primary);
font-size: 22px;
}
.swap-column {
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 1px;
}
</style>
+121 -23
View File
@@ -5,8 +5,8 @@
<div class="toolbar-left">
<el-button class="back-pill" icon="ArrowLeft" @click="goBack">返回用户列表</el-button>
<div class="title-block">
<div class="page-title">{{ graphRoot?.displayName || "用户" }}的关系图谱</div>
<div class="page-subtitle">节点布局固定,拖动画布或缩放可查看完整关系</div>
<div class="page-title">{{ entryNode?.displayName || "用户" }}的关系图谱</div>
<div class="page-subtitle">绿色标记当前进入用户,蓝色标记图谱根节点</div>
</div>
</div>
<div class="toolbar-actions">
@@ -90,13 +90,17 @@
class="relation-node"
:class="{
'is-root': data.graph.root,
'is-entry': isEntryNode(data.graph),
'is-repeated': data.graph.repeated,
'is-selected': selectedNode?.nodeId === data.graph.nodeId,
}"
>
<Handle type="target" :position="Position.Left" class="relation-handle" />
<div v-if="data.graph.root" class="root-badge">核心用户</div>
<div v-else-if="data.graph.repeated" class="repeat-badge">重复引用</div>
<div class="node-badges">
<div v-if="data.graph.root" class="root-badge">图谱根节点</div>
<div v-if="isEntryNode(data.graph)" class="entry-badge">当前用户</div>
<div v-if="data.graph.repeated" class="repeat-badge">重复引用</div>
</div>
<div class="node-avatar" :class="`gender-${data.graph.gender ?? 0}`">
<img v-if="data.avatarUrl" :src="data.avatarUrl" :alt="data.graph.displayName" />
<span v-else>{{ getInitial(data.graph.displayName) }}</span>
@@ -108,13 +112,22 @@
{{ data.graph.userRoleName || data.graph.relationName || "关联用户" }}
</div>
</div>
<el-tooltip content="以此人为关联对象新增用户" placement="top">
<button class="node-add" type="button" @click.stop="openCreateDialog(data.graph)">
<el-icon>
<Plus />
</el-icon>
</button>
</el-tooltip>
<div class="node-actions">
<el-tooltip v-if="!data.graph.root" content="修改当前绑定关系" placement="top">
<button class="node-action node-edit" type="button" @click.stop="openEditDialog(data.graph)">
<el-icon>
<EditPen />
</el-icon>
</button>
</el-tooltip>
<el-tooltip content="以此人为关联对象新增用户" placement="top">
<button class="node-action node-add" type="button" @click.stop="openCreateDialog(data.graph)">
<el-icon>
<Plus />
</el-icon>
</button>
</el-tooltip>
</div>
<Handle type="source" :position="Position.Right" class="relation-handle" />
</div>
</template>
@@ -151,7 +164,8 @@
<div class="detail-user-name">{{ selectedNode.displayName }}</div>
<div class="detail-user-id">用户ID:{{ selectedNode.userId }}</div>
</div>
<el-tag v-if="selectedNode.root" type="primary">根节点</el-tag>
<el-tag v-if="isEntryNode(selectedNode)" type="success">当前用户</el-tag>
<el-tag v-else-if="selectedNode.root" type="primary">图谱根节点</el-tag>
<el-tag v-else-if="selectedNode.repeated" type="info">重复节点</el-tag>
</div>
@@ -209,9 +223,14 @@
</div>
</template>
<el-button type="primary" icon="UserFilled" class="detail-add-button" @click="openCreateDialog()">
以此人为对象新增关系用户
</el-button>
<div class="detail-actions">
<el-button v-if="!selectedNode.root" icon="EditPen" class="detail-action-button" @click="openEditDialog()">
修改关系
</el-button>
<el-button type="primary" icon="UserFilled" class="detail-action-button" @click="openCreateDialog()">
新增关系用户
</el-button>
</div>
</template>
<el-empty v-else description="点击图谱节点查看详情" />
</aside>
@@ -219,6 +238,7 @@
</div>
<relation-create-user ref="createUserRef" @success="handleCreateSuccess" />
<relation-edit-form ref="editFormRef" @success="handleEditSuccess" />
</div>
</template>
@@ -230,6 +250,7 @@ import { MiniMap } from "@vue-flow/minimap"
import { getRelationGraph, type RelationGraphNode } from "/@/api/admin/sysUserRelation"
import { useMessage } from "/@/hooks/message"
import RelationCreateUser from "./relation-create-user.vue"
import RelationEditForm from "./relation-edit-form.vue"
import "@vue-flow/core/dist/style.css"
import "@vue-flow/core/dist/theme-default.css"
@@ -245,12 +266,14 @@ const route = useRoute()
const router = useRouter()
const loading = ref(false)
const graphRoot = ref<RelationGraphNode>()
const entryNode = ref<RelationGraphNode>()
const selectedNode = ref<RelationGraphNode>()
const flowNodes = ref<Node<RelationNodeData>[]>([])
const flowEdges = ref<Edge[]>([])
const graphDepth = ref(0)
const flowStore = ref<VueFlowStore>()
const createUserRef = ref<InstanceType<typeof RelationCreateUser>>()
const editFormRef = ref<InstanceType<typeof RelationEditForm>>()
const nodeWidth = 230
const levelGap = 320
@@ -271,15 +294,17 @@ const loadGraph = async () => {
try {
const { data } = await getRelationGraph(String(userId.value))
graphRoot.value = data
selectedNode.value = data
const graph = buildFlowElements(data)
flowNodes.value = graph.nodes
flowEdges.value = graph.edges
graphDepth.value = graph.depth
entryNode.value = findGraphNodeByUserId(data, String(userId.value)) || data
selectedNode.value = entryNode.value
await nextTick()
fitGraph()
} catch (error: any) {
graphRoot.value = undefined
entryNode.value = undefined
selectedNode.value = undefined
flowNodes.value = []
flowEdges.value = []
@@ -374,10 +399,25 @@ const openCreateDialog = (target = selectedNode.value) => {
createUserRef.value?.openDialog(target)
}
const openEditDialog = (target = selectedNode.value) => {
if (!target || target.root) {
useMessage().warning("请选择一条需要修改的关系")
return
}
editFormRef.value?.openDialog(target, {
subjectUserName: getGraphUserName(target.subjectUserId),
objectUserName: getGraphUserName(target.objectUserId),
})
}
const handleCreateSuccess = async () => {
await loadGraph()
}
const handleEditSuccess = async () => {
await loadGraph()
}
const goBack = () => {
if (window.history.length > 1) {
router.back()
@@ -388,6 +428,23 @@ const goBack = () => {
const getInitial = (name?: string) => name?.trim().slice(0, 1) || "用"
const isEntryNode = (node: RelationGraphNode) => entryNode.value?.nodeId === node.nodeId
const findGraphNodeByUserId = (node: RelationGraphNode, targetUserId: string): RelationGraphNode | undefined => {
if (String(node.userId) === targetUserId) return node
for (const child of node.children || []) {
const targetNode = findGraphNodeByUserId(child, targetUserId)
if (targetNode) return targetNode
}
return undefined
}
const getGraphUserName = (targetUserId?: string) => {
if (!targetUserId) return "用户"
const graphNode = flowNodes.value.find((node) => String((node.data as RelationNodeData).graph.userId) === String(targetUserId))
return (graphNode?.data as RelationNodeData | undefined)?.graph.displayName || String(targetUserId)
}
const resolveImageUrl = (url?: string) => {
if (!url) return ""
return url.includes("http") ? url : `${import.meta.env.VITE_IMAGE_BASE_PATH}${url}`
@@ -617,7 +674,7 @@ watch(userId, loadGraph, { immediate: true })
gap: 12px;
width: 230px;
height: 96px;
padding: 15px 38px 15px 14px;
padding: 15px 68px 15px 14px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 2px solid #cbd5e1;
@@ -633,7 +690,18 @@ watch(userId, loadGraph, { immediate: true })
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.18);
}
.relation-node.is-entry {
border-color: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.16), 0 12px 30px rgba(5, 150, 105, 0.2);
}
.relation-node.is-entry.is-selected {
transform: translateY(-2px);
box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.2), 0 16px 34px rgba(5, 150, 105, 0.24);
}
.relation-node.is-root {
padding-right: 38px;
color: #fff;
background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
border-color: #1d4ed8;
@@ -646,11 +714,17 @@ watch(userId, loadGraph, { immediate: true })
opacity: 0.82;
}
.root-badge,
.repeat-badge {
.node-badges {
position: absolute;
top: -10px;
left: 14px;
display: flex;
gap: 5px;
}
.root-badge,
.entry-badge,
.repeat-badge {
padding: 3px 9px;
font-size: 11px;
font-weight: 600;
@@ -662,6 +736,11 @@ watch(userId, loadGraph, { immediate: true })
background: #dbeafe;
}
.entry-badge {
color: #047857;
background: #d1fae5;
}
.repeat-badge {
color: #475569;
background: #e2e8f0;
@@ -748,9 +827,15 @@ watch(userId, loadGraph, { immediate: true })
border-radius: 10px;
}
.node-add {
.node-actions {
position: absolute;
right: 8px;
display: flex;
flex-direction: column;
gap: 7px;
}
.node-action {
display: flex;
align-items: center;
justify-content: center;
@@ -764,6 +849,12 @@ watch(userId, loadGraph, { immediate: true })
cursor: pointer;
}
.node-edit {
color: #7c3aed;
background: #f5f3ff;
border-color: #ddd6fe;
}
.is-root .node-add {
color: #fff;
background: rgba(255, 255, 255, 0.16);
@@ -888,10 +979,17 @@ watch(userId, loadGraph, { immediate: true })
margin-top: 7px;
}
.detail-add-button {
width: 100%;
height: 34px;
.detail-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.detail-action-button {
flex: 1;
height: 34px;
margin-left: 0;
padding: 0 10px;
border-radius: 999px;
}