MID/src/components/TreeView.vue

2153 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import {
Folder,
FolderOpen,
FileText,
Database,
Copy,
GitBranch,
Trash2,
X,
Plus,
Minus,
CornerUpLeft,
CornerDownLeft,
Link
} from 'lucide-vue-next'
import { initializeTreeData, saveTreeDataToStorage } from '../utils/treeStorage'
import { generateNodeSpecificContent } from '../utils/mockDataGenerator'
import { generateMockReviewHistory } from '../utils/mockReviewData'
import type { EditorBlock, ImageBlock, TableBlock, VideoBlock, TextBlock } from '../utils/mockDataGenerator'
export interface TreeNode {
id: string
label: string
type: 'folder'
status?: 'work' | 'released' | 'approved' | 'rejected' | 'pending' | 'CS Released' | 'mapped'
version?: string
nextVersion?: string
pss?: string
expanded?: boolean
selected?: boolean
children?: TreeNode[]
isClone?: boolean
cloneSourceId?: string
isTerminal?: boolean
// 元数据字段
createDate?: string
access?: string
owner?: string
department?: string
lastChangedBy?: string
lastChangedDate?: string
// Inbox状态仅末端节点
inboxStatus?: 'mapped' | 'ongoing' | 'rejected' | 'agreed' | 'accepted'
reviewer?: string
// 富文本内容(仅末端节点)
content?: EditorBlock[]
// 版本历史(仅末端节点)
versions?: VersionData[]
}
// 版本数据
export interface VersionData {
version: string
date: string
author: string
content: EditorBlock[]
}
export type { EditorBlock, ImageBlock, TableBlock, VideoBlock, TextBlock }
// 判断节点是否为映射节点
const isMappedNode = (node: TreeNode): boolean => {
if (!node.versions?.length) return false
const author = node.versions[0].author
return author === '系统映射' || author === 'System Mapping'
}
// 节点权限配置 - 允许所有节点进行所有操作(作为目标)
const getNodePermissions = (node: TreeNode) => {
return {
copy: true,
clone: true,
branch: true
}
}
// Mock 用户列表
const mockUsers = ['zhangsan', 'lisi', 'wangwu', 'hongwei', 'admin']
const mockAccessLevels = ['Read', 'Write', 'Admin']
// 生成随机日期
const generateDate = (daysOffset: number = 0) => {
const date = new Date()
date.setDate(date.getDate() - daysOffset)
return date.toISOString().split('T')[0]
}
// 生成版本历史
const generateVersions = (nodeName: string, count: number = 10): VersionData[] => {
const versions: VersionData[] = []
for (let i = 0; i < count; i++) {
versions.push({
version: `0.0.${i}`,
date: generateDate((count - 1 - i) * 7),
author: mockUsers[i % mockUsers.length],
content: generateNodeSpecificContent(nodeName, i + 1)
})
}
return versions.reverse()
}
// 全局计数器,确保每个状态至少分配一次
const globalStatusCounter = {
mapped: 0,
ongoing: 0,
rejected: 0,
agreed: 0,
accepted: 0
}
const buildTreeData = (items: any[], parentId: string, depth: number = 0): TreeNode[] => {
return items.map((item, i) => {
const id = `${parentId}-${i}`
const isTerminal = item.isTerminal || false
const randomUser = mockUsers[Math.floor(Math.random() * mockUsers.length)]
const createDate = generateDate(Math.floor(Math.random() * 30))
// Inbox 状态分配逻辑
let inboxStatus: 'mapped' | 'ongoing' | 'rejected' | 'agreed' | 'accepted' = 'mapped'
if (isTerminal) {
// 确保每个状态至少出现一次
const statusKeys = Object.keys(globalStatusCounter) as Array<keyof typeof globalStatusCounter>
const minCount = Math.min(...statusKeys.map(key => globalStatusCounter[key]))
// 找到还没有达到最小数量的状态
const availableStatuses = statusKeys.filter(key => globalStatusCounter[key] === minCount)
// 随机选择一个可用的状态
const selectedKey = availableStatuses[Math.floor(Math.random() * availableStatuses.length)]
inboxStatus = selectedKey
globalStatusCounter[selectedKey]++
}
// 如果是末端节点,先生成版本历史
const versions = isTerminal ? generateVersions(item.name, 10) : []
const node: TreeNode = {
id,
label: item.name,
type: 'folder',
status: 'work',
version: isTerminal ? versions[versions.length - 1].version : '(1)',
expanded: depth <= 2,
children: item.children ? buildTreeData(item.children, id, depth + 1) : undefined,
isTerminal,
// 添加元数据字段
createDate,
access: mockAccessLevels[i % mockAccessLevels.length],
owner: randomUser,
lastChangedBy: randomUser,
lastChangedDate: createDate,
reviewer: isTerminal ? mockUsers[(i + 2) % mockUsers.length] : undefined,
// 如果是末端节点添加inbox状态、内容和版本历史
...(isTerminal && {
inboxStatus,
content: generateNodeSpecificContent(item.name, 2),
versions
})
}
return node
})
}
const treeDataSource = [
{
name: "Inbox",
children: [
{ name: "Review", children: [] },
{ name: "Map", children: [] }
]
},
{
name: "Standards",
children: [
{ name: "Product Scenario", children: [] },
{
name: "Product Definition Context",
children: [
{ name: "Competitors", children: [] },
{ name: "Production Vision & Mission", children: [] },
{ name: "Product Concepts", children: [] }
]
},
{
name: "Product Strategy and Targets",
children: [
{ name: "Design", children: [] },
{ name: "Accommodation & Usage", children: [] },
{ name: "FE", children: [] }
]
},
{
name: "Attributes",
children: [
{
name: "Safety",
children: [
{
name: "Passive Safety",
children: [
{ name: "64km ODB", isTerminal: true },
{ name: "112km ODB", isTerminal: true },
{ name: "80km ODB", isTerminal: true },
{ name: "Low Speed Crash", children: [] }
]
}
]
},
{ name: "Vehicle Energy Efficient", children: [] },
{ name: "Weight", children: [] },
{ name: "Aerodynamics", children: [] }
]
},
{
name: "Function",
children: [
{ name: "Connectivity Infrastructure Domain", children: [] },
{ name: "Electrical Platform Domain", children: [] },
{ name: "HMI Domain", children: [] }
]
},
{
name: "System",
children: [
{
name: "Powertrain",
children: [
{
name: "Engine",
children: [
{ name: "Crankshaft", isTerminal: true },
{ name: "Ignition", isTerminal: true },
{ name: "Engine Body", isTerminal: true }
]
},
{ name: "Transmission", children: [] },
{ name: "Motor", children: [] },
{ name: "incoming requirements", children: [] },
{ name: "attribute", children: [] },
{ name: "NVH 1", isTerminal: true },
{ name: "NVH 2", isTerminal: true },
{ name: "Safety 1", isTerminal: true },
{ name: "function", children: [] }
]
},
{ name: "Powertrain Integration", children: [] },
{
name: "Exterior",
children: [
{ name: "Incoming Requirements", children: [] },
{
name: "Product targets", children: [
{ name: "requirements 1", isTerminal: true },
{ name: "requirements 2", isTerminal: true }
]
},
{ name: "attribute", children: [] },
{ name: "function", children: [] }
]
}
]
}
]
},
{
name: "Projects",
children: [
{
name: "A",
children: [
{ name: "Product Scenario", children: [] },
{
name: "Product Definition Context",
children: [
{ name: "Competitors", children: [] },
{ name: "Production Vision & Mission", children: [] },
{ name: "Product Concepts", children: [] }
]
},
{
name: "Product Strategy and Targets",
children: [
{ name: "Design", children: [] },
{ name: "Accommodation & Usage", children: [] },
{ name: "FE", children: [] }
]
},
{
name: "Attributes",
children: [
{
name: "Safety",
children: [
{
name: "Passive Safety",
children: [
{ name: "64km ODB", isTerminal: true },
{ name: "112km ODB", isTerminal: true },
{ name: "80km ODB", isTerminal: true },
{ name: "Low Speed Crash", children: [] }
]
}
]
},
{ name: "Vehicle Energy Efficient", children: [] },
{ name: "Weight", children: [] },
{ name: "Aerodynamics", children: [] }
]
},
{
name: "Function",
children: [
{ name: "Connectivity Infrastructure Domain", children: [] },
{ name: "Electrical Platform Domain", children: [] },
{ name: "HMI Domain", children: [] }
]
},
{
name: "System",
children: [
{
name: "Powertrain",
children: [
{
name: "Engine",
children: [
{ name: "Crankshaft", isTerminal: true },
{ name: "Ignition", isTerminal: true },
{ name: "Engine Body", isTerminal: true }
]
},
{ name: "Transmission", children: [] },
{ name: "Motor", children: [] },
{ name: "incoming requirements", children: [] },
{ name: "attribute", children: [] },
{ name: "NVH 1", isTerminal: true },
{ name: "NVH 2", isTerminal: true },
{ name: "Safety 1", isTerminal: true },
{ name: "function", children: [] }
]
},
{ name: "Powertrain Integration", children: [] },
{
name: "Exterior",
children: [
{ name: "Incoming Requirements", children: [] },
{
name: "Product targets", children: [
{ name: "requirements 1", isTerminal: true },
{ name: "requirements 2", isTerminal: true }
]
},
{ name: "attribute", children: [] },
{ name: "function", children: [] }
]
}
]
}
]
},
{
name: "B",
children: [
{
name: "Attribute",
children: [
{
name: "Safety",
children: [{ name: "64 ODB", isTerminal: true }]
},
{
name: "NVH",
children: [
{ name: "PWT 40dB", isTerminal: true },
{ name: "PWT 38dB", isTerminal: true }
]
}
]
},
{
name: "System",
children: [
{
name: "Subsystem 1",
children: [
{
name: "Component",
children: [
{ name: "Incoming Requirements", children: [] },
{ name: "NVH1 Accepted", children: [] }
]
}
]
},
{
name: "Subsystem 2",
children: [
{ name: "Component 2", children: [] },
{
name: "Component 3",
children: [
{ name: "Incoming Requirements", children: [] },
{ name: "NVH2 Accepted", children: [] }
]
}
]
}
]
},
{ name: "Incoming Requirements", children: [] }
]
},
{ name: "C", children: [] },
{ name: "XX", children: [] }
]
}
]
const cloneNode = (node: TreeNode, newId?: string): TreeNode => {
return {
...node,
id: newId || node.id,
children: node.children ? node.children.map(child => cloneNode(child)) : undefined
}
}
const findNode = (nodes: TreeNode[], id: string): TreeNode | null => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
const deleteNodeFromTree = (nodes: TreeNode[], id: string): boolean => {
const index = nodes.findIndex(n => n.id === id)
if (index !== -1) {
nodes.splice(index, 1)
return true
}
for (const node of nodes) {
if (node.children && deleteNodeFromTree(node.children, id)) {
return true
}
}
return false
}
const addChildNode = (parentNode: TreeNode, childNode: TreeNode) => {
if (!parentNode.children) {
parentNode.children = []
}
parentNode.children.push(childNode)
}
// 初始化树数据:优先从 localStorage 读取,否则使用 mockData
// 重置全局状态计数器,确保每次初始化都能正确分配状态
// globalStatusCounter 是局部常量,每次组件初始化都会重新创建,无需手动重置
const mockTreeData = ref<TreeNode[]>(initializeTreeData(buildTreeData(treeDataSource)))
// Ensure Inbox has Review and Map subfolders (Data Migration)
const inboxNode = mockTreeData.value.find(n => n.label === 'Inbox')
if (inboxNode) {
const hasReview = inboxNode.children?.some(c => c.label === 'Review')
const hasMap = inboxNode.children?.some(c => c.label === 'Map')
if (!hasReview || !hasMap) {
inboxNode.children = inboxNode.children || []
if (!hasReview) {
inboxNode.children.push({
id: `${inboxNode.id}-review`,
label: 'Review',
type: 'folder',
children: [],
isTerminal: false
})
}
if (!hasMap) {
inboxNode.children.push({
id: `${inboxNode.id}-map`,
label: 'Map',
type: 'folder',
children: [],
isTerminal: false
})
}
saveTreeDataToStorage(mockTreeData.value)
}
}
// 从树数据中获取所有末端节点作为Inbox数据
const inboxData = computed(() => {
const terminals: TreeNode[] = []
const traverse = (nodes: TreeNode[]) => {
for (const node of nodes) {
if (node.isTerminal) {
terminals.push(node)
}
if (node.children) {
traverse(node.children)
}
}
}
traverse(mockTreeData.value)
return terminals.map((node, index) => ({
id: index + 1,
nodeName: node.label,
nodeOwner: node.owner || 'unknown',
requirementName: getRequirementName(node.label),
owner: node.lastChangedBy || node.owner || 'unknown',
type: getNodeType(node.label),
version: node.version || '1.0',
status: node.inboxStatus || 'mapped',
mappedTime: node.createDate || '2023-01-01',
latestRevisionTime: node.lastChangedDate || node.createDate || '2023-01-01'
}))
})
// 根据节点名称推断需求名称
const getRequirementName = (nodeName: string): string => {
const nameMap: Record<string, string> = {
'64km ODB': 'Passive Safety',
'112km ODB': 'Passive Safety',
'80km ODB': 'Passive Safety',
'Low Speed Crash': 'Passive Safety',
'Crankshaft': 'Engine Performance',
'Ignition': 'Engine Performance',
'Engine Body': 'Engine Performance',
'NVH 1': 'Noise Control',
'NVH 2': 'Noise Control',
'Safety 1': 'Vehicle Safety',
'requirements 1': 'Product Requirements',
'requirements 2': 'Product Requirements'
}
return nameMap[nodeName] || nodeName
}
// 根据节点名称推断类型
const getNodeType = (nodeName: string): string => {
const typeMap: Record<string, string> = {
'64km ODB': 'Attribute',
'112km ODB': 'Attribute',
'80km ODB': 'Attribute',
'Low Speed Crash': 'Attribute',
'Crankshaft': 'Attribute',
'Ignition': 'Attribute',
'Engine Body': 'Attribute',
'NVH 1': 'Function',
'NVH 2': 'Function',
'Safety 1': 'Function',
'requirements 1': 'Function',
'requirements 2': 'Function'
}
return typeMap[nodeName] || 'Attribute'
}
// 计算Inbox中mapped状态的数量
const mappedCount = computed(() => {
return inboxData.value.filter(item => item.status === 'mapped').length
})
// 计算待review的节点数量
const pendingReviewCount = computed(() => {
let count = 0
const traverse = (nodes: TreeNode[]) => {
for (const node of nodes) {
if (node.isTerminal) {
// 检查是否有pending或submitted状态的review记录
const reviewHistoryKey = `review_history_${node.id}`
const storedHistory = localStorage.getItem(reviewHistoryKey)
if (storedHistory) {
const reviewData = JSON.parse(storedHistory)
// pending 或 submitted 状态都需要处理
const needsReview = reviewData.some((record: any) =>
record.status === 'pending' || record.status === 'submitted'
)
if (needsReview) {
count++
}
}
}
if (node.children) {
traverse(node.children)
}
}
}
traverse(mockTreeData.value)
return count
})
// 计算 Inbox 总数Review + Map 的和)
const inboxTotalCount = computed(() => {
return mappedCount.value + pendingReviewCount.value
})
defineProps<{
modelValue?: string | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
(e: 'select', node: TreeNode): void
(e: 'tree-data-update', data: TreeNode[]): void
(e: 'switch-view', viewMode: string): void
}>()
// 监听数据变化,自动保存到 localStorage
watch(mockTreeData, (newData) => {
saveTreeDataToStorage(newData)
emit('tree-data-update', newData)
}, { deep: true, immediate: true })
const selectedId = ref<string | null>('1')
const expandedIds = ref<Set<string>>(new Set())
const dialogExpandedIds = ref<Set<string>>(new Set())
const contextMenu = ref({
show: false,
x: 0,
y: 0,
nodeId: ''
})
const operationDialog = ref({
show: false,
mode: 'copy' as 'copy' | 'clone' | 'branch',
sourceNodeId: '',
selectedSourceIds: [] as string[],
owner: 'admin' // default owner
})
const toggleSourceSelection = (node: TreeNode) => {
// Only allow terminal nodes
if (!isTerminalNode(node)) return
const ids = operationDialog.value.selectedSourceIds
if (ids.includes(node.id)) {
// Toggle off if already selected
operationDialog.value.selectedSourceIds = []
} else {
// Single select: replace any existing selection
operationDialog.value.selectedSourceIds = [node.id]
}
}
const getAllTerminalDescendants = (node: TreeNode): string[] => {
const results: string[] = []
const traverse = (n: TreeNode) => {
if (isTerminalNode(n)) {
results.push(n.id)
}
if (n.children) {
n.children.forEach(traverse)
}
}
traverse(node)
return results
}
const isIdsSelected = (node: TreeNode): boolean => {
if (isTerminalNode(node)) {
return operationDialog.value.selectedSourceIds.includes(node.id)
}
// For folders, checked if all terminal descendants are checked
const descendants = getAllTerminalDescendants(node)
return descendants.length > 0 && descendants.every(id => operationDialog.value.selectedSourceIds.includes(id))
}
const isIdsIndeterminate = (node: TreeNode): boolean => {
if (isTerminalNode(node)) return false
const descendants = getAllTerminalDescendants(node)
if (descendants.length === 0) return false
const selectedCount = descendants.filter(id => operationDialog.value.selectedSourceIds.includes(id)).length
return selectedCount > 0 && selectedCount < descendants.length
}
const addNodeDialog = ref({
show: false,
mode: 'sibling' as 'sibling' | 'child',
parentNodeId: '',
targetNodeId: '',
nodeType: '',
owner: '',
reviewer: ''
})
// 节点类型选项
const nodeTypes = ['folder', 'document']
const initExpanded = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.expanded) {
expandedIds.value.add(node.id)
}
if (node.children) {
initExpanded(node.children)
}
})
}
initExpanded(mockTreeData.value)
// 初始化时emit tree data
emit('tree-data-update', mockTreeData.value)
// 查找 Review 和 Map 节点用于分类判断
const findInboxChildren = () => {
const inboxNode = mockTreeData.value.find(n => n.label === 'Inbox')
return {
reviewNode: inboxNode?.children?.find(c => c.label === 'Review'),
mapNode: inboxNode?.children?.find(c => c.label === 'Map')
}
}
// 检查节点是否在某个父节点下
const isDescendantOf = (ancestor: TreeNode | undefined, node: TreeNode): boolean => {
if (!ancestor) return false
if (node.id === ancestor.id) return true
if (node.children) {
return node.children.some(child => isDescendantOf(ancestor, child))
}
return false
}
// 查找节点的父节点
const findParent = (nodes: TreeNode[], targetNode: TreeNode): TreeNode | null => {
for (const node of nodes) {
if (node.children) {
if (node.children.some(child => child.id === targetNode.id)) {
return node
}
const found = findParent(node.children, targetNode)
if (found) return found
}
}
return null
}
// 初始化 review_history每个终端节点生成多种类型的初始数据
onMounted(() => {
let terminalNodeIndex = 0
const traverse = (nodes: TreeNode[]) => {
for (const node of nodes) {
// 跳过 Inbox 及其直接子节点
const isInboxOrDirectChild = node.label === 'Inbox' ||
node.label === 'Review' ||
node.label === 'Map' ||
mockTreeData.value.find(n => n.label === 'Inbox')?.children?.some(c => c.id === node.id)
if (!isInboxOrDirectChild && node.isTerminal) {
const reviewHistoryKey = `review_history_${node.id}`
const existingHistory = localStorage.getItem(reviewHistoryKey)
if (!existingHistory) {
// 使用 generateMockReviewHistory 生成完整的审核历史
const reviewRecords = generateMockReviewHistory(node.id, node.label, node.lastChangedBy || 'zhangsan')
// 为一些节点添加特殊的 Map 状态
if (terminalNodeIndex % 3 === 1) { // 每第三个终端节点添加 Map Approved
// 添加 Map Approved 记录
const mapApprovedRecord = {
id: `${node.id}-map-approved`,
type: 'response',
reviewer: 'admin',
status: 'map approved',
comment: 'Map Approved - 此文档已通过地图审核,可以进行下一步处理',
date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + ' 16:30:00',
version: '0.0.9',
reviewType: 'map'
}
reviewRecords.unshift(mapApprovedRecord)
} else if (terminalNodeIndex % 3 === 2) { // 每第四个终端节点添加 Map Reviewed
// 添加 Map Reviewed 记录
const mapReviewedRecord = {
id: `${node.id}-map-reviewed`,
type: 'response',
reviewer: 'admin',
status: 'map reviewed',
comment: 'Map Reviewed - 地图审核已完成,等待最终批准',
date: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + ' 14:15:00',
version: '0.0.9',
reviewType: 'map'
}
reviewRecords.unshift(mapReviewedRecord)
}
localStorage.setItem(reviewHistoryKey, JSON.stringify(reviewRecords))
}
terminalNodeIndex++
}
if (node.children) {
traverse(node.children)
}
}
}
traverse(mockTreeData.value)
})
const handleToggle = (id: string, isDialog = false) => {
const targetSet = isDialog ? dialogExpandedIds : expandedIds
if (targetSet.value.has(id)) {
targetSet.value.delete(id)
} else {
targetSet.value.add(id)
}
}
const handleDoubleClick = (id: string, node: TreeNode) => {
if (hasChildren(node)) {
handleToggle(id)
}
}
const handleSelect = (id: string, node: TreeNode) => {
selectedId.value = id
emit('update:modelValue', id)
emit('select', node)
}
// 展开所有节点
const expandAll = () => {
const expandNode = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.children && node.children.length > 0) {
expandedIds.value.add(node.id)
expandNode(node.children)
}
})
}
expandNode(mockTreeData.value)
}
// 折叠所有节点
const collapseAll = () => {
expandedIds.value.clear()
}
const isExpanded = (id: string, isDialog = false) => {
const targetSet = isDialog ? dialogExpandedIds : expandedIds
return targetSet.value.has(id)
}
const isSelected = (id: string) => selectedId.value === id
const hasChildren = (node: TreeNode) => node.children && node.children.length > 0
// 终端节点只根据显式标记的isTerminal判断
const isTerminalNode = (node: TreeNode) => {
return node.isTerminal === true
}
// 检查节点权限 - 基于末端节点状态
const canCopy = (node: TreeNode) => getNodePermissions(node).copy
const canClone = (node: TreeNode) => getNodePermissions(node).clone
const canBranch = (node: TreeNode) => getNodePermissions(node).branch
// 右键菜单 - 添加边界检查
const handleContextMenu = (e: MouseEvent, nodeId: string) => {
e.preventDefault()
const menuWidth = 180
const menuHeight = 300 // 增加高度以适应更多菜单项
let x = e.clientX
let y = e.clientY
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 10
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 10
}
if (x < 0) x = 10
if (y < 0) y = 10
contextMenu.value = {
show: true,
x,
y,
nodeId
}
}
const closeContextMenu = () => {
contextMenu.value.show = false
}
const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => {
const currentSourceNodeId = contextMenu.value?.nodeId || ''
const currentNode = findNode(mockTreeData.value, currentSourceNodeId)
operationDialog.value = {
show: true,
mode,
sourceNodeId: currentSourceNodeId,
selectedSourceIds: [],
owner: currentNode?.owner || 'admin'
}
dialogExpandedIds.value = new Set(expandedIds.value)
closeContextMenu()
}
const handleDelete = () => {
const nodeId = contextMenu.value?.nodeId || ''
if (nodeId === '1') {
alert('Cannot delete root node')
closeContextMenu()
return
}
if (confirm('Are you sure you want to delete this node?')) {
deleteNodeFromTree(mockTreeData.value, nodeId)
if (selectedId.value === nodeId) {
selectedId.value = null
}
}
closeContextMenu()
}
// 添加同级节点
const handleAddSibling = () => {
console.log('handleAddSibling called')
const nodeId = contextMenu.value?.nodeId || ''
console.log('nodeId:', nodeId)
const node = findNode(mockTreeData.value, nodeId)
console.log('node found:', node)
if (!node) return
// 找到父节点
const parentNode = findParentNode(mockTreeData.value, nodeId)
console.log('parentNode found:', parentNode)
// 打开添加节点对话框
// 如果没有父节点则使用空字符串作为parentNodeId
addNodeDialog.value = {
show: true,
mode: 'sibling',
parentNodeId: parentNode?.id || '',
targetNodeId: nodeId,
nodeType: 'Product Scenario',
owner: 'admin',
reviewer: ''
}
console.log('addNodeDialog set to show')
closeContextMenu()
}
// 添加下级节点
const handleAddChild = () => {
console.log('handleAddChild called')
const nodeId = contextMenu.value?.nodeId || ''
console.log('nodeId:', nodeId)
const node = findNode(mockTreeData.value, nodeId)
console.log('node found:', node)
if (!node) return
// 打开添加节点对话框
addNodeDialog.value = {
show: true,
mode: 'child',
parentNodeId: nodeId,
targetNodeId: nodeId,
nodeType: 'Product Scenario',
owner: 'admin',
reviewer: ''
}
console.log('addNodeDialog set to show')
closeContextMenu()
}
// 查找节点在父节点中的索引位置
const findNodeIndex = (parent: TreeNode, targetId: string): number => {
if (!parent.children) return -1
return parent.children.findIndex(child => child.id === targetId)
}
// 确认添加节点
const confirmAddNode = () => {
const { mode, parentNodeId, targetNodeId } = addNodeDialog.value
const targetNode = findNode(mockTreeData.value, targetNodeId)
if (!targetNode) {
alert('Node does not exist')
return
}
// 获取输入的节点名称
const nodeName = (document.getElementById('new-node-name') as HTMLInputElement)?.value ||
(mode === 'sibling' ? '新建节点' : '新建子节点')
// 获取owner
const owner = addNodeDialog.value.owner || 'admin'
// 获取reviewer
const reviewer = addNodeDialog.value.nodeType === 'document' ? addNodeDialog.value.reviewer || '' : ''
// 创建新节点
const newNode: TreeNode = {
id: `${targetNodeId}-${Date.now()}`,
label: nodeName,
type: 'folder',
status: 'work',
version: '(1)',
expanded: false,
children: undefined,
isTerminal: false,
createDate: new Date().toISOString().split('T')[0],
access: 'Read',
owner: owner,
lastChangedBy: owner,
lastChangedDate: new Date().toISOString().split('T')[0],
reviewer: reviewer
}
// 根据模式添加节点
if (mode === 'sibling') {
// 如果没有父节点ID说明是在根级别添加同级节点
if (!parentNodeId) {
// 在 mockTreeData 根级别添加
const targetIndex = mockTreeData.value.findIndex(n => n.id === targetNodeId)
if (targetIndex !== -1) {
mockTreeData.value.splice(targetIndex + 1, 0, newNode)
} else {
mockTreeData.value.push(newNode)
}
} else {
// 有父节点在父节点的children中添加
const parentNode = findNode(mockTreeData.value, parentNodeId)
if (!parentNode) {
alert('Parent node does not exist')
return
}
if (!parentNode.children) {
parentNode.children = []
}
// 找到目标节点在父节点中的位置
const targetIndex = findNodeIndex(parentNode, targetNodeId)
if (targetIndex !== -1) {
// 在目标节点后插入新节点
parentNode.children.splice(targetIndex + 1, 0, newNode)
} else {
// 如果没找到,添加到末尾
parentNode.children.push(newNode)
}
}
} else {
// 添加下级节点
if (!targetNode.children) {
targetNode.children = []
}
targetNode.children.push(newNode)
// 展开目标节点
expandedIds.value.add(targetNodeId)
}
// 关闭对话框
addNodeDialog.value.show = false
}
// 查找父节点的辅助函数
const findParentNode = (nodes: TreeNode[], childId: string, parent: TreeNode | null = null): TreeNode | null => {
for (const node of nodes) {
if (node.id === childId) {
return parent
}
if (node.children) {
const found = findParentNode(node.children, childId, node)
if (found) return found
}
}
return null
}
// 检查操作是否可用 - 基于节点类型
const isCopyEnabled = (node: TreeNode | null) => {
return node ? canCopy(node) : false
}
const isCloneEnabled = (node: TreeNode | null) => {
return node ? canClone(node) : false
}
const isBranchEnabled = (node: TreeNode | null) => {
return node ? canBranch(node) : false
}
// Check if a node is in Projects tree
const isInProjects = (node: TreeNode): boolean => {
// Find Projects root
// Projects is usually at the top level in mockTreeData
const projectsRoot = mockTreeData.value.find(n => n.label === 'Projects')
if (!projectsRoot) return false
// Helper to check descendant
const isDescendant = (parent: TreeNode, targetId: string): boolean => {
if (parent.id === targetId) return true
if (parent.children) {
return parent.children.some(child => isDescendant(child, targetId))
}
return false
}
return isDescendant(projectsRoot, node.id)
}
const isMapEnabled = (node: TreeNode | null) => {
if (!node) return false
// Map allowed if: is terminal AND is in Projects tree AND not already mapped
const isMapped = node.status === 'mapped' || (node.versions && node.versions[0] && node.versions[0].author === '系统映射')
return isTerminalNode(node) && isInProjects(node) && !isMapped
}
const handleMapTo = () => {
if (!contextMenuNode.value || !isMapEnabled(contextMenuNode.value)) return
// Select the node
handleSelect(contextMenuNode.value.id, contextMenuNode.value)
// Switch to Map view
emit('switch-view', 'map')
// Close menu
closeContextMenu()
}
const executeOperation = () => {
// destinationNode (Context Menu Node) is where we want to add the new node (TARGET)
const destinationNode = findNode(mockTreeData.value, operationDialog.value.sourceNodeId)
if (!destinationNode) {
return
}
// Determine actual parent for valid insertion
// If destination is a Terminal, we add as sibling (to its parent)
// If destination is a Folder (or Branch), we add as child
let targetParent = destinationNode
if (isTerminalNode(destinationNode)) {
const parent = findParentNode(mockTreeData.value, destinationNode.id)
if (parent) {
targetParent = parent
} else {
// Should not happen for valid tree structure unless root is terminal
console.warn('Cannot add to terminal root node')
return
}
}
const sourceIds = operationDialog.value.selectedSourceIds
if (sourceIds.length === 0) {
alert('Please select at least one source node')
return
}
const { mode } = operationDialog.value
let processedCount = 0
sourceIds.forEach(sourceId => {
// sourceNode (Dialog Selection) is where we want to copy FROM (SOURCE)
const sourceNode = findNode(mockTreeData.value, sourceId)
if (!sourceNode) return
// Strict constraint: Copy/Clone/Branch only allow terminal nodes as source
// even if user selected a folder (for UI convenience).
if (!isTerminalNode(sourceNode)) {
return
}
if (mode === 'copy') {
const newNode = cloneNode(sourceNode, `${destinationNode.id}-${Date.now()}-${processedCount}`)
newNode.label = `${newNode.label} (Copy)`
newNode.owner = operationDialog.value.owner
addChildNode(targetParent, newNode)
} else if (mode === 'clone') {
const newNode: TreeNode = {
...sourceNode,
id: `${destinationNode.id}-${Date.now()}-${processedCount}`,
label: `${sourceNode.label} (Clone)`,
isClone: true,
cloneSourceId: sourceNode.id,
owner: operationDialog.value.owner
}
addChildNode(targetParent, newNode)
} else if (mode === 'branch') {
// Even if source is terminal, "Branch" op might function like copy
const newNode = cloneNode(sourceNode, `${destinationNode.id}-${Date.now()}-${processedCount}`)
newNode.label = `${newNode.label} (Branch)`
newNode.owner = operationDialog.value.owner
addChildNode(targetParent, newNode)
}
processedCount++
})
operationDialog.value.show = false
expandedIds.value.add(targetParent.id)
}
const flattenedNodes = computed(() => {
const result: Array<{ node: TreeNode; level: number }> = []
const flatten = (nodes: TreeNode[], level: number, isDialog = false) => {
nodes.forEach(node => {
result.push({ node, level })
if (node.children && isExpanded(node.id, isDialog)) {
flatten(node.children, level + 1, isDialog)
}
})
}
flatten(mockTreeData.value, 0)
return result
})
const dialogFlattenedNodes = computed(() => {
const result: Array<{ node: TreeNode; level: number }> = []
const flatten = (nodes: TreeNode[], level: number) => {
nodes.forEach(node => {
result.push({ node, level })
if (node.children && isExpanded(node.id, true)) {
flatten(node.children, level + 1)
}
})
}
flatten(mockTreeData.value, 0)
return result
})
const sourceNodeForDialog = computed(() => {
return findNode(mockTreeData.value, operationDialog.value.sourceNodeId)
})
const contextMenuNode = computed(() => {
return findNode(mockTreeData.value, contextMenu.value?.nodeId || '')
})
// 更新节点内容的方法
const updateNodeContent = (nodeId: string, content: any) => {
const node = findNode(mockTreeData.value, nodeId)
if (node) {
node.content = content
// 更新最后修改信息
node.lastChangedBy = node.owner || 'admin'
node.lastChangedDate = new Date().toISOString().split('T')[0]
console.log('Node content updated:', nodeId)
}
}
// 初始化review_history
const initializeReviewHistory = () => {
const traverse = (nodes: TreeNode[]) => {
for (const node of nodes) {
if (node.isTerminal && node.versions && node.versions.length > 0) {
const reviewHistoryKey = `review_history_${node.id}`
const existingHistory = localStorage.getItem(reviewHistoryKey)
if (!existingHistory) {
// 使用 generateMockReviewHistory 生成完整的审核历史
const reviewRecords = generateMockReviewHistory(node.id, node.label, node.lastChangedBy || 'zhangsan')
localStorage.setItem(reviewHistoryKey, JSON.stringify(reviewRecords))
}
}
if (node.children) {
traverse(node.children)
}
}
}
traverse(mockTreeData.value)
}
onMounted(() => {
initializeReviewHistory()
})
// 暴露方法给父组件
defineExpose({
updateNodeContent
})
</script>
<template>
<div class="tree-view" @click="closeContextMenu">
<div class="tree-header">
<div class="tree-header-title">
<span class="tree-header-icon">
<Database :size="14" />
</span>
<span>Vehicle level - Default(modified)</span>
</div>
<div class="tree-header-actions">
<button class="tree-header-btn" title="Expand All" @click="expandAll">
<Plus :size="12" />
</button>
<button class="tree-header-btn" title="Collapse All" @click="collapseAll">
<Minus :size="12" />
</button>
</div>
</div>
<div class="tree-content">
<div class="tree-column-headers">
<span class="tree-col-name">Name</span>
<span class="tree-col-status">Status</span>
<span class="tree-col-version">Version</span>
</div>
<div class="tree-nodes">
<div v-for="{ node, level } in flattenedNodes" :key="node.id"
:class="['tree-node-wrapper', { 'has-children': hasChildren(node) }]">
<div :class="['tree-node', { selected: isSelected(node.id) }]" :style="{ paddingLeft: `${level * 16 + 4}px` }"
@click="handleSelect(node.id, node)" @dblclick="handleDoubleClick(node.id, node)"
@contextmenu="handleContextMenu($event, node.id)">
<span class="tree-node-toggle" @click.stop="handleToggle(node.id)">
<Minus v-if="hasChildren(node) && isExpanded(node.id)" :size="12" />
<Plus v-else-if="hasChildren(node)" :size="12" />
<span v-else class="tree-node-spacer" />
</span>
<span class="tree-node-icon">
<Link v-if="isMappedNode(node)" :size="14" class="icon-mapped" />
<FileText v-else-if="isTerminalNode(node)" :size="14" class="icon-terminal" />
<FolderOpen v-else-if="isExpanded(node.id)" :size="14" class="icon-folder-open" />
<Folder v-else :size="14" class="icon-folder" />
</span>
<span class="tree-node-label" :title="node.label">
{{ node.label }}
<span v-if="node.department" class="dept-badge">[{{ node.department }}]</span>
<span v-if="node.label === 'Inbox' && inboxTotalCount > 0" class="inbox-count">({{ inboxTotalCount }})</span>
<span v-if="node.label === 'Map' && mappedCount > 0" class="inbox-count">({{ mappedCount }})</span>
<span v-if="node.label === 'Review' && pendingReviewCount > 0" class="inbox-count">({{ pendingReviewCount }})</span>
<span v-if="node.isClone" class="clone-badge">(Clone)</span>
</span>
</div>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div v-if="contextMenu.show" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop>
<div class="menu-group">
<div class="menu-item" @click="handleAddSibling">
<CornerUpLeft :size="14" />
<span>添加同级节点</span>
</div>
<div class="menu-item" @click="handleAddChild">
<CornerDownLeft :size="14" />
<span>添加下级节点</span>
</div>
<div class="menu-item delete" @click="handleDelete">
<Trash2 :size="14" />
<span>edit</span>
</div>
<div class="menu-item delete" @click="handleDelete">
<Trash2 :size="14" />
<span>Delete</span>
</div>
</div>
<div class="menu-divider"></div>
<div class="menu-group">
<div class="menu-item" :class="{ disabled: !isCopyEnabled(contextMenuNode) }"
@click="isCopyEnabled(contextMenuNode) && openOperationDialog('copy')">
<Copy :size="14" />
<span>添加文档 (Copy From)...</span>
</div>
<div class="menu-item" :class="{ disabled: !isCloneEnabled(contextMenuNode) }"
@click="isCloneEnabled(contextMenuNode) && openOperationDialog('clone')">
<Database :size="14" />
<span>添加文档 (Clone From)...</span>
</div>
<div class="menu-item" :class="{ disabled: !isBranchEnabled(contextMenuNode) }"
@click="isBranchEnabled(contextMenuNode) && openOperationDialog('branch')">
<GitBranch :size="14" />
<span>Branch From...</span>
</div>
<div class="menu-item" :class="{ disabled: !isMapEnabled(contextMenuNode) }"
@click="isMapEnabled(contextMenuNode) && handleMapTo()">
<Plus :size="14" />
<span>Map To...</span>
</div>
</div>
</div>
<!-- 添加节点对话框 -->
<div v-if="addNodeDialog.show" class="dialog-overlay" @click="addNodeDialog.show = false">
<div class="add-node-dialog" @click.stop>
<div class="dialog-header">
<h3>{{ addNodeDialog.mode === 'sibling' ? '添加同级节点' : '添加下级节点' }}</h3>
<button class="close-btn" @click="addNodeDialog.show = false">
<X :size="18" />
</button>
</div>
<div class="dialog-body">
<div class="input-group">
<label for="new-node-name">节点名称:</label>
<input
type="text"
id="new-node-name"
class="node-name-input"
:placeholder="addNodeDialog.mode === 'sibling' ? '请输入同级节点名称' : '请输入子节点名称'"
autofocus
@keyup.enter="confirmAddNode"
/>
</div>
<div class="input-group">
<label for="new-node-type">节点类型:</label>
<div class="radio-group">
<label
v-for="type in nodeTypes"
:key="type"
class="radio-option"
>
<input
type="radio"
:value="type"
v-model="addNodeDialog.nodeType"
name="node-type"
/>
<span>{{ type }}</span>
</label>
</div>
</div>
<div class="input-group">
<label for="new-node-owner">Owner:</label>
<input
type="text"
id="new-node-owner"
class="node-owner-input"
v-model="addNodeDialog.owner"
placeholder="请输入owner"
/>
</div>
<div class="input-group" v-if="addNodeDialog.nodeType === 'document'">
<label for="new-node-reviewer">Reviewer:</label>
<input
type="text"
id="new-node-reviewer"
class="node-owner-input"
v-model="addNodeDialog.reviewer"
placeholder="请输入reviewer"
/>
</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="addNodeDialog.show = false">取消</button>
<button class="btn btn-primary" @click="confirmAddNode">确定</button>
</div>
</div>
</div>
<!-- 操作选择弹窗 -->
<div v-if="operationDialog.show" class="dialog-overlay" @click="operationDialog.show = false">
<div class="operation-dialog" @click.stop>
<div class="dialog-header">
<h3>{{ operationDialog.mode === 'copy' ? 'Copy Node' : operationDialog.mode === 'clone' ? 'Clone Node' :
'Branch Node' }}</h3>
<button class="close-btn" @click="operationDialog.show = false">
<X :size="18" />
</button>
</div>
<div class="dialog-body">
<div class="source-info">
<label>Target (Destination):</label>
<span class="source-name">{{ sourceNodeForDialog?.label }}</span>
<span class="source-type">{{ isTerminalNode(sourceNodeForDialog!) ? '(Terminal)' : '(Branch)' }}</span>
</div>
<div class="target-selection">
<label>Select Source:</label>
<div class="target-tree">
<div v-for="{ node, level } in dialogFlattenedNodes" :key="node.id"
:class="['tree-node']"
:style="{ paddingLeft: `${level * 16 + 8}px` }">
<span class="tree-node-toggle" @click.stop="handleToggle(node.id, true)">
<Minus v-if="hasChildren(node) && isExpanded(node.id, true)" :size="10" />
<Plus v-else-if="hasChildren(node)" :size="10" />
<span v-else class="tree-node-spacer-small" />
</span>
<input
type="checkbox"
:checked="isIdsSelected(node)"
:indeterminate.prop="isIdsIndeterminate(node)"
:disabled="!isTerminalNode(node)"
@change="toggleSourceSelection(node)"
style="margin-right: 6px;"
/>
<span class="tree-node-icon">
<Link v-if="isMappedNode(node)" :size="12" class="icon-mapped" />
<FileText v-else-if="isTerminalNode(node)" :size="12" class="icon-terminal" />
<FolderOpen v-else-if="isExpanded(node.id, true)" :size="12" class="icon-folder-open" />
<Folder v-else :size="12" class="icon-folder" />
</span>
<span class="tree-node-label-small" @click="toggleSourceSelection(node)">{{ node.label }}</span>
</div>
</div>
</div>
</div>
<div class="dialog-footer">
<div class="owner-input-group">
<label>Owner:</label>
<input type="text" v-model="operationDialog.owner" class="dialog-input" />
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" @click="operationDialog.show = false">Cancel</button>
<button class="btn btn-primary" @click="executeOperation" :disabled="operationDialog.selectedSourceIds.length === 0">
{{ operationDialog.mode === 'copy' ? 'Copy' : operationDialog.mode === 'clone' ? 'Clone' : 'Branch' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tree-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
}
.tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(to bottom, #f5f5f5, #e8e8e8);
border-bottom: 1px solid #a0a0a0;
}
.tree-header-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #333;
}
.tree-header-icon {
color: #0078d4;
}
.tree-header-actions {
display: flex;
gap: 4px;
}
.tree-header-btn {
padding: 4px;
background: transparent;
border: 1px solid #c0c0c0;
border-radius: 2px;
cursor: pointer;
color: #666;
display: flex;
align-items: center;
justify-content: center;
}
.tree-header-btn:hover {
background: #d0d0d0;
color: #333;
}
.tree-content {
flex: 1;
overflow: auto;
}
.tree-column-headers {
display: flex;
padding: 4px 8px;
background: #e8e8e8;
border-bottom: 1px solid #a0a0a0;
font-size: 11px;
font-weight: 600;
color: #666;
position: sticky;
top: 0;
z-index: 10;
}
.tree-col-name {
flex: 1;
min-width: 150px;
}
.tree-col-status,
.tree-col-version {
width: 70px;
text-align: center;
}
.tree-nodes {
padding: 2px 0;
}
.tree-node-wrapper {
display: flex;
flex-direction: column;
}
.tree-node {
display: flex;
align-items: center;
padding: 4px 8px;
cursor: pointer;
transition: all 0.1s;
border-bottom: 1px solid transparent;
user-select: none;
}
.tree-node:hover {
background: #d0d0d0;
}
.tree-node.selected {
background: #cde8ff;
border-bottom-color: #0078d4;
}
.tree-node-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin-right: 2px;
cursor: pointer;
color: #666;
}
.tree-node-spacer,
.tree-node-spacer-small {
width: 14px;
}
.tree-node-spacer-small {
width: 10px;
}
.tree-node-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 4px;
}
.icon-folder,
.icon-folder-open {
color: #daa520;
}
.icon-requirement {
color: #0078d4;
}
.icon-subsystem {
color: #6b8e23;
}
.icon-component {
color: #cd853f;
}
.icon-attribute {
color: #9370db;
}
.icon-issue {
color: #dc3545;
}
.icon-terminal {
color: #666;
}
.icon-mapped {
color: #0078d4;
}
.icon-version {
color: #17a2b8;
}
.icon-default {
color: #999;
}
.tree-node-label,
.tree-node-label-small {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: #333;
}
.tree-node-label-small {
font-size: 11px;
}
.clone-badge {
font-size: 10px;
color: #0078d4;
margin-left: 4px;
}
.inbox-count {
color: #dc3545;
font-weight: bold;
margin-left: 4px;
}
.dept-badge {
color: #666;
font-size: 0.85em;
margin-left: 6px;
background: #eaeaea;
padding: 0 4px;
border-radius: 3px;
border: 1px solid #ddd;
}
/* 右键菜单 */
.context-menu {
position: fixed;
background: #fff;
border: 1px solid #c0c0c0;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 160px;
padding: 4px 0;
}
.menu-group {
padding: 0;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: #333;
transition: background 0.15s;
}
.menu-item:hover:not(.disabled) {
background: #f0f8ff;
}
.menu-item.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.menu-item.delete {
color: #dc3545;
}
.menu-item.delete:hover {
background: #fff5f5;
}
.menu-divider {
height: 1px;
background: #e0e0e0;
margin: 4px 0;
}
/* 操作选择弹窗 */
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.operation-dialog {
background: #fff;
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
width: 600px;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.close-btn {
background: transparent;
border: none;
cursor: pointer;
color: #666;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
.dialog-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.source-info {
margin-bottom: 20px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
}
.source-info label {
font-size: 12px;
color: #666;
display: block;
margin-bottom: 4px;
}
.source-name {
font-weight: 600;
color: #333;
margin-right: 8px;
}
.source-type {
font-size: 11px;
color: #0078d4;
background: #e8f4fc;
padding: 2px 6px;
border-radius: 3px;
}
.operation-buttons {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.op-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.op-btn:hover:not(.disabled) {
border-color: #0078d4;
background: #f0f8ff;
}
.op-btn.active {
border-color: #0078d4;
background: #e8f4fc;
}
.op-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.op-btn span {
font-weight: 600;
color: #333;
}
.op-btn small {
font-size: 11px;
color: #666;
}
.target-selection {
margin-top: 20px;
}
.target-selection label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.target-tree {
max-height: 250px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fafafa;
}
.target-tree .tree-node {
padding: 3px 8px;
border-bottom: 1px solid #f0f0f0;
}
.target-tree .tree-node:last-child {
border-bottom: none;
}
.target-tree .tree-node:hover {
background: #f0f0f0;
}
.target-tree .tree-node.selected {
background: #cde8ff;
border-bottom-color: #0078d4;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
}
.owner-input-group {
display: flex;
align-items: center;
gap: 8px;
}
.owner-input-group label {
font-size: 13px;
font-weight: 500;
color: #333;
}
.dialog-input {
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
width: 150px;
outline: none;
transition: border-color 0.2s;
}
.dialog-input:focus {
border-color: #0078d4;
}
.dialog-actions {
display: flex;
gap: 12px;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
}
.btn-secondary {
background: #f5f5f5;
border-color: #c0c0c0;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.btn-primary {
background: #0078d4;
border-color: #0078d4;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #106ebe;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 添加节点对话框 */
.add-node-dialog {
background: #fff;
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
width: 400px;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.input-group {
margin-bottom: 20px;
}
.input-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.node-name-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
color: #333;
outline: none;
transition: border-color 0.2s;
}
.node-name-input:focus {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2);
}
.node-name-input::placeholder {
color: #999;
}
/* 节点类型单选按钮组样式 */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.radio-option {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: #555;
transition: all 0.2s;
background: #fff;
}
.radio-option:hover {
border-color: #0078d4;
background: #f0f8ff;
}
.radio-option input[type="radio"] {
margin: 0;
cursor: pointer;
}
.radio-option input[type="radio"]:checked + span {
color: #0078d4;
font-weight: 500;
}
.radio-option:has(input[type="radio"]:checked) {
border-color: #0078d4;
background: #e6f2ff;
}
/* Owner输入框样式 - 与节点名称输入框统一 */
.node-owner-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
color: #333;
outline: none;
transition: border-color 0.2s;
}
.node-owner-input:focus {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2);
}
.node-owner-input::placeholder {
color: #999;
}
</style>