2153 lines
57 KiB
Vue
2153 lines
57 KiB
Vue
<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>
|