2026-02-06 14:07:11 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, watch } from 'vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Folder,
|
|
|
|
|
|
FolderOpen,
|
|
|
|
|
|
FileText,
|
|
|
|
|
|
Database,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
GitBranch,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
X,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Minus,
|
|
|
|
|
|
CornerUpLeft,
|
|
|
|
|
|
CornerDownLeft
|
|
|
|
|
|
} from 'lucide-vue-next'
|
|
|
|
|
|
import { initializeTreeData, saveTreeDataToStorage } from '../utils/treeStorage'
|
|
|
|
|
|
|
|
|
|
|
|
export interface TreeNode {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
type: 'folder'
|
|
|
|
|
|
status?: 'work' | 'released' | 'approved' | 'rejected' | 'pending' | 'CS Released'
|
|
|
|
|
|
version?: string
|
|
|
|
|
|
nextVersion?: string
|
|
|
|
|
|
pss?: string
|
|
|
|
|
|
expanded?: boolean
|
|
|
|
|
|
selected?: boolean
|
|
|
|
|
|
children?: TreeNode[]
|
|
|
|
|
|
isClone?: boolean
|
|
|
|
|
|
cloneSourceId?: string
|
|
|
|
|
|
isTerminal?: boolean
|
|
|
|
|
|
// 元数据字段
|
|
|
|
|
|
createDate?: string
|
|
|
|
|
|
access?: string
|
|
|
|
|
|
owner?: string
|
|
|
|
|
|
lastChangedBy?: string
|
|
|
|
|
|
lastChangedDate?: string
|
|
|
|
|
|
// 富文本内容(仅末端节点)
|
|
|
|
|
|
content?: EditorBlock[]
|
|
|
|
|
|
// 版本历史(仅末端节点)
|
|
|
|
|
|
versions?: VersionData[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 版本数据
|
|
|
|
|
|
export interface VersionData {
|
|
|
|
|
|
version: string
|
|
|
|
|
|
date: string
|
|
|
|
|
|
author: string
|
|
|
|
|
|
content: EditorBlock[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 富文本块类型
|
|
|
|
|
|
export interface ImageBlock {
|
|
|
|
|
|
type: 'image'
|
|
|
|
|
|
id: string
|
|
|
|
|
|
images: string[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface TableBlock {
|
|
|
|
|
|
type: 'table'
|
|
|
|
|
|
id: string
|
|
|
|
|
|
data: string[][]
|
|
|
|
|
|
hasHeaderRow: boolean
|
|
|
|
|
|
hasHeaderCol: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface VideoBlock {
|
|
|
|
|
|
type: 'video'
|
|
|
|
|
|
id: string
|
|
|
|
|
|
src: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface TextBlock {
|
|
|
|
|
|
type: 'text'
|
|
|
|
|
|
id: string
|
|
|
|
|
|
content: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type EditorBlock = TextBlock | ImageBlock | TableBlock | VideoBlock
|
|
|
|
|
|
|
|
|
|
|
|
// 节点权限配置 - 基于是否为末端节点
|
|
|
|
|
|
// 末端节点:可以 copy 和 clone,不能 branch
|
|
|
|
|
|
// 非末端节点:可以 branch,不能 copy 和 clone
|
|
|
|
|
|
const getNodePermissions = (node: TreeNode) => {
|
|
|
|
|
|
const isTerminal = isTerminalNode(node)
|
|
|
|
|
|
return {
|
|
|
|
|
|
copy: isTerminal,
|
|
|
|
|
|
clone: isTerminal,
|
|
|
|
|
|
branch: !isTerminal
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 generateNodeSpecificContent = (nodeName: string, version: number = 1): EditorBlock[] => {
|
|
|
|
|
|
const name = nodeName.toLowerCase()
|
|
|
|
|
|
|
|
|
|
|
|
// ODB 碰撞测试节点
|
|
|
|
|
|
if (name.includes('odb')) {
|
|
|
|
|
|
const speed = name.includes('64') ? '64' : name.includes('112') ? '112' : '80'
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
id: `text-${version}`,
|
|
|
|
|
|
content: `${speed}km ODB 碰撞测试要求(版本 ${version}):\n\n本文档定义了车辆以 ${speed}km/h 速度进行正面偏置碰撞测试的技术要求。测试应符合 Euro NCAP 标准,确保乘员舱完整性。`
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'table',
|
|
|
|
|
|
id: `table-${version}`,
|
|
|
|
|
|
data: version === 1
|
|
|
|
|
|
? [['测试参数', '要求值'], ['碰撞速度', `${speed} km/h`], ['侵入量', '< 150mm'], ['HIC值', '< 700']]
|
|
|
|
|
|
: [['测试参数', '要求值', '单位'], ['碰撞速度', `${speed}`, 'km/h'], ['侵入量', '< 150', 'mm'], ['HIC值', '< 700', '-'], ['胸部压缩', '< 50', 'mm']],
|
|
|
|
|
|
hasHeaderRow: true,
|
|
|
|
|
|
hasHeaderCol: false
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'image',
|
|
|
|
|
|
id: `image-${version}`,
|
|
|
|
|
|
images: [`https://picsum.photos/seed/odb${speed}v${version}/400/300`]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NVH 噪声节点
|
|
|
|
|
|
if (name.includes('nvh') || name.includes('db')) {
|
|
|
|
|
|
const dbValue = name.includes('40') ? '40' : name.includes('38') ? '38' : '35'
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
id: `text-${version}`,
|
|
|
|
|
|
content: `NVH 噪声控制要求 - ${dbValue}dB(版本 ${version}):\n\n本文档规定了动力系统噪声控制要求,在怠速工况下噪声不得超过 ${dbValue}dB。`
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'table',
|
|
|
|
|
|
id: `table-${version}`,
|
|
|
|
|
|
data: version === 1
|
|
|
|
|
|
? [['工况', '噪声限值'], ['怠速', `${dbValue} dB`], ['加速', '65 dB']]
|
|
|
|
|
|
: [['工况', '噪声限值', '频率范围'], ['怠速', `${dbValue} dB`, '20-20000Hz'], ['加速', '65 dB', '50-5000Hz'], ['巡航', '60 dB', '20-10000Hz']],
|
|
|
|
|
|
hasHeaderRow: true,
|
|
|
|
|
|
hasHeaderCol: false
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'image',
|
|
|
|
|
|
id: `image-${version}`,
|
|
|
|
|
|
images: [
|
|
|
|
|
|
`https://picsum.photos/seed/nvh${dbValue}v${version}a/400/300`,
|
|
|
|
|
|
`https://picsum.photos/seed/nvh${dbValue}v${version}b/400/300`
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Engine 发动机部件
|
|
|
|
|
|
if (name.includes('crankshaft')) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
id: `text-${version}`,
|
|
|
|
|
|
content: `曲轴设计规范(版本 ${version}):\n\n曲轴是发动机的核心部件,承受周期性变化的燃气压力和惯性力。材料采用高强度合金钢锻造。`
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'table',
|
|
|
|
|
|
id: `table-${version}`,
|
|
|
|
|
|
data: version === 1
|
|
|
|
|
|
? [['参数', '规格'], ['材料', '42CrMo'], ['硬度', 'HRC 55-60'], ['跳动', '< 0.05mm']]
|
|
|
|
|
|
: [['参数', '规格', '公差'], ['材料', '42CrMo', '-'], ['硬度', 'HRC 55-60', '±2'], ['跳动', '< 0.05', 'mm'], ['圆度', '< 0.01', 'mm']],
|
|
|
|
|
|
hasHeaderRow: true,
|
|
|
|
|
|
hasHeaderCol: false
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'video',
|
|
|
|
|
|
id: `video-${version}`,
|
|
|
|
|
|
src: `https://example.com/crankshaft-v${version}.mp4`,
|
|
|
|
|
|
name: `曲轴加工视频 v${version}.mp4`
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (name.includes('ignition')) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
id: `text-${version}`,
|
|
|
|
|
|
content: `点火系统技术规范(版本 ${version}):\n\n点火系统负责在正确的时间产生高压火花点燃混合气。采用独立点火线圈设计。`
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'table',
|
|
|
|
|
|
id: `table-${version}`,
|
|
|
|
|
|
data: version === 1
|
|
|
|
|
|
? [['参数', '规格'], ['点火电压', '30kV'], ['点火能量', '80mJ'], ['火花塞间隙', '1.1mm']]
|
|
|
|
|
|
: [['参数', '规格', '单位'], ['点火电压', '30', 'kV'], ['点火能量', '80', 'mJ'], ['火花塞间隙', '1.1', 'mm'], ['点火提前角', '10-35', '°BTDC']],
|
|
|
|
|
|
hasHeaderRow: true,
|
|
|
|
|
|
hasHeaderCol: false
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'image',
|
|
|
|
|
|
id: `image-${version}`,
|
|
|
|
|
|
images: [`https://picsum.photos/seed/ignitionv${version}/400/300`]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (name.includes('engine body')) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
id: `text-${version}`,
|
|
|
|
|
|
content: `发动机缸体设计要求(版本 ${version}):\n\n缸体是发动机的基础部件,需要承受高温高压燃气的作用。采用铝合金压铸工艺。`
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'table',
|
|
|
|
|
|
id: `table-${version}`,
|
|
|
|
|
|
data: version === 1
|
|
|
|
|
|
? [['参数', '规格'], ['材料', 'AlSi10Mg'], ['排量', '2.0L'], ['缸径', '82.5mm']]
|
|
|
|
|
|
: [['参数', '规格', '单位'], ['材料', 'AlSi10Mg', '-'], ['排量', '2.0', 'L'], ['缸径', '82.5', 'mm'], ['行程', '94.6', 'mm'], ['压缩比', '10.5:1', '-']],
|
|
|
|
|
|
hasHeaderRow: true,
|
|
|
|
|
|
hasHeaderCol: false
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'image',
|
|
|
|
|
|
id: `image-${version}`,
|
|
|
|
|
|
images: [
|
|
|
|
|
|
`https://picsum.photos/seed/enginebodyv${version}a/400/300`,
|
|
|
|
|
|
`https://picsum.photos/seed/enginebodyv${version}b/400/300`
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Requirements 需求节点
|
|
|
|
|
|
if (name.includes('requirements')) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
id: `text-${version}`,
|
|
|
|
|
|
content: `产品需求规格书(版本 ${version}):\n\n本文档详细描述了产品的功能需求、性能指标和验收标准。`
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'table',
|
|
|
|
|
|
id: `table-${version}`,
|
|
|
|
|
|
data: version === 1
|
|
|
|
|
|
? [['需求编号', '描述', '优先级'], ['REQ-001', '基本功能', '高'], ['REQ-002', '性能要求', '高']]
|
|
|
|
|
|
: [['需求编号', '描述', '优先级', '状态'], ['REQ-001', '基本功能', '高', '已实现'], ['REQ-002', '性能要求', '高', '已实现'], ['REQ-003', '安全要求', '高', '开发中'], ['REQ-004', '易用性', '中', '待开发']],
|
|
|
|
|
|
hasHeaderRow: true,
|
|
|
|
|
|
hasHeaderCol: false
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'image',
|
|
|
|
|
|
id: `image-${version}`,
|
|
|
|
|
|
images: [`https://picsum.photos/seed/reqv${version}/400/300`]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 默认内容
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
id: `text-${version}`,
|
|
|
|
|
|
content: `${nodeName} 技术文档(版本 ${version}):\n\n这是 ${nodeName} 的技术规范文档,描述了相关技术要求和参数。`
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'table',
|
|
|
|
|
|
id: `table-${version}`,
|
|
|
|
|
|
data: version === 1
|
|
|
|
|
|
? [['参数', '值'], ['参数1', '值1'], ['参数2', '值2']]
|
|
|
|
|
|
: [['参数', '值', '单位'], ['参数1', '值1', '单位1'], ['参数2', '值2', '单位2'], ['参数3', '值3', '单位3']],
|
|
|
|
|
|
hasHeaderRow: true,
|
|
|
|
|
|
hasHeaderCol: false
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'image',
|
|
|
|
|
|
id: `image-${version}`,
|
|
|
|
|
|
images: [`https://picsum.photos/seed/default${version}/400/300`]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 生成版本历史
|
|
|
|
|
|
const generateVersions = (nodeName: string, count: number = 3): VersionData[] => {
|
|
|
|
|
|
const versions: VersionData[] = []
|
|
|
|
|
|
for (let i = 1; i <= count; i++) {
|
|
|
|
|
|
versions.push({
|
|
|
|
|
|
version: `v${i}.0`,
|
|
|
|
|
|
date: generateDate((count - i) * 7),
|
|
|
|
|
|
author: mockUsers[i % mockUsers.length],
|
|
|
|
|
|
content: generateNodeSpecificContent(nodeName, i)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return versions
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换函数 - 简化版
|
|
|
|
|
|
const buildTreeData = (data: any, parentId: string = '', _index: number = 0): TreeNode[] => {
|
|
|
|
|
|
return data.map((item: any, i: number) => {
|
|
|
|
|
|
const id = parentId ? `${parentId}-${i + 1}` : `${i + 1}`
|
|
|
|
|
|
const depth = id.split('-').length
|
|
|
|
|
|
const isTerminal = item.isTerminal === true
|
|
|
|
|
|
const randomUser = mockUsers[i % mockUsers.length]
|
|
|
|
|
|
const createDate = generateDate(30 + i * 5)
|
|
|
|
|
|
|
|
|
|
|
|
const node: TreeNode = {
|
|
|
|
|
|
id,
|
|
|
|
|
|
label: item.name,
|
|
|
|
|
|
type: 'folder',
|
|
|
|
|
|
status: 'work',
|
|
|
|
|
|
version: '(1)',
|
|
|
|
|
|
expanded: depth <= 2,
|
|
|
|
|
|
children: item.children ? buildTreeData(item.children, id) : undefined,
|
|
|
|
|
|
isTerminal,
|
|
|
|
|
|
// 添加元数据字段
|
|
|
|
|
|
createDate,
|
|
|
|
|
|
access: mockAccessLevels[i % mockAccessLevels.length],
|
|
|
|
|
|
owner: randomUser,
|
|
|
|
|
|
lastChangedBy: randomUser,
|
|
|
|
|
|
lastChangedDate: createDate,
|
|
|
|
|
|
// 如果是末端节点,添加内容和版本历史
|
|
|
|
|
|
...(isTerminal && {
|
|
|
|
|
|
content: generateNodeSpecificContent(item.name, 2),
|
|
|
|
|
|
versions: generateVersions(item.name, 3)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return node
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const treeDataSource = [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Main Tree",
|
|
|
|
|
|
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
|
|
|
|
|
|
const mockTreeData = ref<TreeNode[]>(initializeTreeData(buildTreeData(treeDataSource)))
|
|
|
|
|
|
|
|
|
|
|
|
// 监听数据变化,自动保存到 localStorage
|
|
|
|
|
|
watch(mockTreeData, (newData) => {
|
|
|
|
|
|
saveTreeDataToStorage(newData)
|
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
|
|
|
|
|
|
|
defineProps<{
|
|
|
|
|
|
modelValue?: string | null
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
(e: 'update:modelValue', value: string | null): void
|
|
|
|
|
|
(e: 'select', node: TreeNode): void
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
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: '',
|
|
|
|
|
|
selectedTargetId: ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const addNodeDialog = ref({
|
|
|
|
|
|
show: false,
|
|
|
|
|
|
mode: 'sibling' as 'sibling' | 'child',
|
|
|
|
|
|
parentNodeId: '',
|
|
|
|
|
|
targetNodeId: '',
|
|
|
|
|
|
nodeType: '',
|
|
|
|
|
|
owner: ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 节点类型选项
|
2026-02-09 21:34:29 +08:00
|
|
|
|
const nodeTypes = ['folder', 'document']
|
2026-02-06 14:07:11 +08:00
|
|
|
|
|
|
|
|
|
|
const initExpanded = (nodes: TreeNode[]) => {
|
|
|
|
|
|
nodes.forEach(node => {
|
|
|
|
|
|
if (node.expanded) {
|
|
|
|
|
|
expandedIds.value.add(node.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.children) {
|
|
|
|
|
|
initExpanded(node.children)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
initExpanded(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 = 140
|
|
|
|
|
|
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') => {
|
|
|
|
|
|
operationDialog.value = {
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
mode,
|
|
|
|
|
|
sourceNodeId: contextMenu.value?.nodeId || '',
|
|
|
|
|
|
selectedTargetId: ''
|
|
|
|
|
|
}
|
|
|
|
|
|
dialogExpandedIds.value = new Set(expandedIds.value)
|
|
|
|
|
|
closeContextMenu()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = () => {
|
|
|
|
|
|
const nodeId = contextMenu.value?.nodeId || ''
|
|
|
|
|
|
if (nodeId === '1') {
|
|
|
|
|
|
alert('不能删除根节点')
|
|
|
|
|
|
closeContextMenu()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (confirm('确定要删除这个节点吗?')) {
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// 打开添加节点对话框
|
|
|
|
|
|
// 如果没有父节点(Main Tree层级),则使用空字符串作为parentNodeId
|
|
|
|
|
|
addNodeDialog.value = {
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
mode: 'sibling',
|
|
|
|
|
|
parentNodeId: parentNode?.id || '',
|
|
|
|
|
|
targetNodeId: nodeId,
|
|
|
|
|
|
nodeType: 'Product Scenario',
|
|
|
|
|
|
owner: 'admin'
|
|
|
|
|
|
}
|
|
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
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('节点不存在')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取输入的节点名称
|
|
|
|
|
|
const nodeName = (document.getElementById('new-node-name') as HTMLInputElement)?.value ||
|
|
|
|
|
|
(mode === 'sibling' ? '新建节点' : '新建子节点')
|
|
|
|
|
|
|
|
|
|
|
|
// 获取owner
|
|
|
|
|
|
const owner = addNodeDialog.value.owner || 'admin'
|
|
|
|
|
|
|
|
|
|
|
|
// 创建新节点
|
|
|
|
|
|
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]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据模式添加节点
|
|
|
|
|
|
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('父节点不存在')
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const executeOperation = () => {
|
|
|
|
|
|
const sourceNode = findNode(mockTreeData.value, operationDialog.value.sourceNodeId)
|
|
|
|
|
|
const targetNode = findNode(mockTreeData.value, operationDialog.value.selectedTargetId)
|
|
|
|
|
|
|
|
|
|
|
|
if (!sourceNode || !targetNode) {
|
|
|
|
|
|
alert('请选择目标节点')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { mode } = operationDialog.value
|
|
|
|
|
|
|
|
|
|
|
|
if (mode === 'copy') {
|
|
|
|
|
|
if (!canCopy(sourceNode)) {
|
|
|
|
|
|
alert('此节点类型不支持复制操作')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const newNode = cloneNode(sourceNode, `${targetNode.id}-${Date.now()}`)
|
|
|
|
|
|
newNode.label = `${newNode.label} (Copy)`
|
|
|
|
|
|
addChildNode(targetNode, newNode)
|
|
|
|
|
|
} else if (mode === 'clone') {
|
|
|
|
|
|
if (!canClone(sourceNode)) {
|
|
|
|
|
|
alert('此节点类型不支持克隆操作')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const newNode: TreeNode = {
|
|
|
|
|
|
...sourceNode,
|
|
|
|
|
|
id: `${targetNode.id}-${Date.now()}`,
|
|
|
|
|
|
label: `${sourceNode.label} (Clone)`,
|
|
|
|
|
|
isClone: true,
|
|
|
|
|
|
cloneSourceId: sourceNode.id
|
|
|
|
|
|
}
|
|
|
|
|
|
addChildNode(targetNode, newNode)
|
|
|
|
|
|
} else if (mode === 'branch') {
|
|
|
|
|
|
if (!canBranch(sourceNode)) {
|
|
|
|
|
|
alert('此节点类型不支持分支操作')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const newNode = cloneNode(sourceNode, `${targetNode.id}-${Date.now()}`)
|
|
|
|
|
|
newNode.label = `${newNode.label} (Branch)`
|
|
|
|
|
|
addChildNode(targetNode, newNode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
operationDialog.value.show = false
|
|
|
|
|
|
expandedIds.value.add(targetNode.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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 暴露方法给父组件
|
|
|
|
|
|
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">
|
|
|
|
|
|
<FileText v-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.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>
|
2026-02-09 21:34:29 +08:00
|
|
|
|
<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 To...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="menu-item" :class="{ disabled: !isCloneEnabled(contextMenuNode) }"
|
|
|
|
|
|
@click="isCloneEnabled(contextMenuNode) && openOperationDialog('clone')">
|
|
|
|
|
|
<Database :size="14" />
|
|
|
|
|
|
<span>Clone To...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="menu-item" :class="{ disabled: !isBranchEnabled(contextMenuNode) }"
|
|
|
|
|
|
@click="isBranchEnabled(contextMenuNode) && openOperationDialog('branch')">
|
|
|
|
|
|
<GitBranch :size="14" />
|
|
|
|
|
|
<span>Branch To...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="menu-item" :class="{ disabled: !isBranchEnabled(contextMenuNode) }"
|
|
|
|
|
|
@click="isBranchEnabled(contextMenuNode) && openOperationDialog('branch')">
|
|
|
|
|
|
<GitBranch :size="14" />
|
|
|
|
|
|
<span>Map To...</span>
|
|
|
|
|
|
</div>
|
2026-02-06 14:07:11 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<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>Source:</label>
|
|
|
|
|
|
<span class="source-name">{{ sourceNodeForDialog?.label }}</span>
|
|
|
|
|
|
<span class="source-type">{{ isTerminalNode(sourceNodeForDialog!) ? '(Terminal)' : '(Branch)' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="operation-buttons">
|
|
|
|
|
|
<button
|
|
|
|
|
|
:class="['op-btn', { active: operationDialog.mode === 'copy', disabled: !isCopyEnabled(sourceNodeForDialog) }]"
|
|
|
|
|
|
@click="operationDialog.mode = 'copy'" :disabled="!isCopyEnabled(sourceNodeForDialog)">
|
|
|
|
|
|
<Copy :size="16" />
|
|
|
|
|
|
<span>Copy</span>
|
|
|
|
|
|
<small>复制终端节点</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
:class="['op-btn', { active: operationDialog.mode === 'clone', disabled: !isCloneEnabled(sourceNodeForDialog) }]"
|
|
|
|
|
|
@click="operationDialog.mode = 'clone'" :disabled="!isCloneEnabled(sourceNodeForDialog)">
|
|
|
|
|
|
<Database :size="16" />
|
|
|
|
|
|
<span>Clone</span>
|
|
|
|
|
|
<small>引用终端节点</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
:class="['op-btn', { active: operationDialog.mode === 'branch', disabled: !isBranchEnabled(sourceNodeForDialog) }]"
|
|
|
|
|
|
@click="operationDialog.mode = 'branch'" :disabled="!isBranchEnabled(sourceNodeForDialog)">
|
|
|
|
|
|
<GitBranch :size="16" />
|
|
|
|
|
|
<span>Branch</span>
|
|
|
|
|
|
<small>复制子树</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="target-selection">
|
|
|
|
|
|
<label>Select Target:</label>
|
|
|
|
|
|
<div class="target-tree">
|
|
|
|
|
|
<div v-for="{ node, level } in dialogFlattenedNodes" :key="node.id"
|
|
|
|
|
|
:class="['tree-node', { selected: operationDialog.selectedTargetId === node.id }]"
|
|
|
|
|
|
:style="{ paddingLeft: `${level * 16 + 8}px` }" @click="operationDialog.selectedTargetId = node.id">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<span class="tree-node-icon">
|
|
|
|
|
|
<FileText v-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">{{ node.label }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="dialog-footer">
|
|
|
|
|
|
<button class="btn btn-secondary" @click="operationDialog.show = false">Cancel</button>
|
|
|
|
|
|
<button class="btn btn-primary" @click="executeOperation" :disabled="!operationDialog.selectedTargetId">
|
|
|
|
|
|
{{ operationDialog.mode === 'copy' ? 'Copy' : operationDialog.mode === 'clone' ? 'Clone' : 'Branch' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</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-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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 右键菜单 */
|
|
|
|
|
|
.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: flex-end;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
padding: 16px 20px;
|
|
|
|
|
|
border-top: 1px solid #e0e0e0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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>
|