This commit is contained in:
2025-12-20 20:30:03 +08:00
parent d390dff19a
commit 2f2dcf580f
21 changed files with 1051 additions and 121 deletions

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -7,11 +7,6 @@
"id": "0i69gg5", "id": "0i69gg5",
"title": "资源管理器", "title": "资源管理器",
"content": "左侧面板内容1" "content": "左侧面板内容1"
},
{
"id": "9uc5qy1",
"title": "新窗口 2",
"content": "新窗口内容"
} }
], ],
"activeTabId": "0i69gg5" "activeTabId": "0i69gg5"
@@ -20,22 +15,25 @@
"id": "center", "id": "center",
"tabs": [ "tabs": [
{ {
"id": "auteqok", "id": "2emt1si",
"title": "欢迎页", "title": "文本编辑器",
"content": "中间面板内容1" "content": "新窗口内容",
"materialId": "TextEditor"
}, },
{ {
"id": "c08lqdq", "id": "mxfx11j",
"title": "新窗口 3", "title": "树形展示器",
"content": "新窗口内容" "content": "新窗口内容",
"materialId": "TreeViewer"
}, },
{ {
"id": "c9nw7xj", "id": "jln5iq9",
"title": "新窗口 3", "title": "测试组件B",
"content": "新窗口内容" "content": "新窗口内容",
"materialId": "TestWidget2"
} }
], ],
"activeTabId": "cibltif" "activeTabId": "2emt1si"
}, },
"rightPanel": { "rightPanel": {
"id": "right", "id": "right",
@@ -46,13 +44,20 @@
"content": "新窗口内容" "content": "新窗口内容"
}, },
{ {
"id": "cibltif", "id": "vrh9bl2",
"title": "新窗口 4", "title": "数据表格",
"content": "新窗口内容" "content": "新窗口内容",
"materialId": "DataTable"
},
{
"id": "y2iwzgl",
"title": "测试组件A",
"content": "新窗口内容",
"materialId": "TestWidget1"
} }
], ],
"activeTabId": "ojaw0e3" "activeTabId": "ojaw0e3"
} }
}, },
"lastUpdated": "2025-12-20T11:47:26.835Z" "lastUpdated": "2025-12-20T12:28:20.283Z"
} }

View File

@@ -1,80 +1,179 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
import { usePanelStore } from '../stores/panelStore' import { usePanelStore } from '../stores/panelStore'
import { materialList } from '../materials'
const panelStore = usePanelStore() const panelStore = usePanelStore()
const handleAddWindow = () => { // 菜单状态
panelStore.addNewWindow() const isWindowMenuOpen = ref(false)
// 获取所有物料组件列表
const allMaterials = computed(() => materialList)
// 获取可用的物料组件(未打开的)
const availableMaterials = computed(() => panelStore.availableMaterials)
// 检查物料是否已打开
const isMaterialOpened = (materialId: string) => {
return panelStore.isMaterialOpened(materialId)
}
// 打开物料组件
const handleOpenMaterial = (materialId: string) => {
panelStore.openMaterial(materialId)
isWindowMenuOpen.value = false
}
// 打开项目(无实际功能)
const handleOpenProject = () => {
console.log('打开项目功能待实现')
} }
</script> </script>
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="header-left"> <div class="menu-bar">
<span class="app-title">拖拽面板编辑器</span> <!-- 打开项目菜单 -->
<div class="menu-item" @click="handleOpenProject">
<span>打开项目</span>
</div> </div>
<div class="header-center">
<button class="add-tab-btn" @click="handleAddWindow"> <!-- 窗口菜单 -->
<span class="icon">+</span> <div
<span>新增子窗口</span> class="menu-item"
</button> @mouseenter="isWindowMenuOpen = true"
@mouseleave="isWindowMenuOpen = false"
>
<span>窗口</span>
<!-- 下拉菜单 -->
<div class="dropdown-menu" v-if="isWindowMenuOpen">
<div class="menu-section-title">可用组件</div>
<div
v-for="material in allMaterials"
:key="material.id"
class="dropdown-item"
:class="{ disabled: isMaterialOpened(material.id) }"
@click="!isMaterialOpened(material.id) && handleOpenMaterial(material.id)"
>
<span class="item-name">{{ material.name }}</span>
<span class="item-status" v-if="isMaterialOpened(material.id)">已打开</span>
</div> </div>
<div v-if="allMaterials.length === 0" class="dropdown-empty">
暂无可用组件
</div>
</div>
</div>
</div>
<div class="header-title">
<span>拖拽面板编辑器</span>
</div>
<div class="header-right"></div> <div class="header-right"></div>
</header> </header>
</template> </template>
<style scoped> <style scoped>
.app-header { .app-header {
height: 48px; height: 32px;
background: #1e1e1e; background: #3c3c3c;
border-bottom: 1px solid #3c3c3c; border-bottom: 1px solid #2d2d2d;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; padding: 0;
padding: 0 16px;
flex-shrink: 0; flex-shrink: 0;
} }
.header-left { .menu-bar {
flex: 1;
}
.app-title {
color: #cccccc;
font-size: 14px;
font-weight: 500;
}
.header-center {
flex: 1;
display: flex; display: flex;
justify-content: center; align-items: center;
height: 100%;
}
.menu-item {
position: relative;
padding: 0 12px;
height: 100%;
display: flex;
align-items: center;
color: #cccccc;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover {
background: #505050;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: #252526;
border: 1px solid #3c3c3c;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
padding: 4px 0;
}
.menu-section-title {
padding: 6px 12px;
color: #888888;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
color: #cccccc;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover:not(.disabled) {
background: #094771;
}
.dropdown-item.disabled {
color: #666666;
cursor: not-allowed;
}
.item-name {
flex: 1;
}
.item-status {
font-size: 11px;
color: #888888;
margin-left: 12px;
}
.dropdown-empty {
padding: 12px;
color: #666666;
font-size: 12px;
text-align: center;
}
.header-title {
flex: 1;
text-align: center;
color: #999999;
font-size: 12px;
} }
.header-right { .header-right {
flex: 1; width: 100px;
}
.add-tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: #0e639c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.add-tab-btn:hover {
background: #1177bb;
}
.add-tab-btn .icon {
font-size: 16px;
font-weight: bold;
} }
</style> </style>

View File

@@ -1,28 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed } from 'vue'
import { usePanelStore } from '../stores/panelStore' import { usePanelStore } from '../stores/panelStore'
import Panel from './Panel.vue' import Panel from './Panel.vue'
import Resizer from './Resizer.vue'
const panelStore = usePanelStore() const panelStore = usePanelStore()
const leftPanel = computed(() => panelStore.layout.leftPanel) const leftPanel = computed(() => panelStore.layout.leftPanel)
const centerPanel = computed(() => panelStore.layout.centerPanel) const centerPanel = computed(() => panelStore.layout.centerPanel)
const rightPanel = computed(() => panelStore.layout.rightPanel) const rightPanel = computed(() => panelStore.layout.rightPanel)
// 面板宽度
const leftWidth = ref(250)
const rightWidth = ref(250)
const MIN_WIDTH = 150
const MAX_WIDTH = 500
const onLeftResize = (delta: number) => {
const newWidth = leftWidth.value + delta
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
leftWidth.value = newWidth
}
}
const onRightResize = (delta: number) => {
const newWidth = rightWidth.value - delta
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
rightWidth.value = newWidth
}
}
</script> </script>
<template> <template>
<div class="main-layout"> <div class="main-layout">
<div class="panel-container left-panel"> <div class="panel-container left-panel" :style="{ width: leftWidth + 'px' }">
<div class="panel-header">左侧面板</div> <div class="panel-header">左侧面板</div>
<Panel :panel="leftPanel" /> <Panel :panel="leftPanel" />
</div> </div>
<Resizer @resize="onLeftResize" />
<div class="panel-container center-panel"> <div class="panel-container center-panel">
<div class="panel-header">中间面板</div> <div class="panel-header">中间面板</div>
<Panel :panel="centerPanel" /> <Panel :panel="centerPanel" />
</div> </div>
<div class="panel-container right-panel"> <Resizer @resize="onRightResize" />
<div class="panel-container right-panel" :style="{ width: rightWidth + 'px' }">
<div class="panel-header">右侧面板</div> <div class="panel-header">右侧面板</div>
<Panel :panel="rightPanel" /> <Panel :panel="rightPanel" />
</div> </div>
@@ -33,7 +59,6 @@ const rightPanel = computed(() => panelStore.layout.rightPanel)
.main-layout { .main-layout {
display: flex; display: flex;
flex: 1; flex: 1;
gap: 4px;
padding: 4px; padding: 4px;
background: #181818; background: #181818;
overflow: hidden; overflow: hidden;
@@ -48,18 +73,18 @@ const rightPanel = computed(() => panelStore.layout.rightPanel)
} }
.left-panel { .left-panel {
flex: 0 0 250px; flex-shrink: 0;
min-width: 200px; min-width: 150px;
} }
.center-panel { .center-panel {
flex: 1; flex: 1;
min-width: 300px; min-width: 200px;
} }
.right-panel { .right-panel {
flex: 0 0 250px; flex-shrink: 0;
min-width: 200px; min-width: 150px;
} }
.panel-header { .panel-header {

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { usePanelStore } from '../stores/panelStore' import { usePanelStore } from '../stores/panelStore'
import { getMaterialComponent } from '../materials'
import type { Panel, TabItem } from '../types' import type { Panel, TabItem } from '../types'
const props = defineProps<{ const props = defineProps<{
@@ -10,20 +11,20 @@ const props = defineProps<{
const panelStore = usePanelStore() const panelStore = usePanelStore()
// 使用computed来获取tabs的响应式引用
const tabs = computed({
get: () => props.panel.tabs,
set: (value: TabItem[]) => {
props.panel.tabs = value
}
})
const activeTabId = computed(() => props.panel.activeTabId) const activeTabId = computed(() => props.panel.activeTabId)
const activeTab = computed(() => const activeTab = computed(() =>
props.panel.tabs.find(t => t.id === activeTabId.value) props.panel.tabs.find(t => t.id === activeTabId.value)
) )
// 获取当前活跃Tab的物料组件
const activeMaterialComponent = computed(() => {
if (activeTab.value?.materialId) {
return getMaterialComponent(activeTab.value.materialId)
}
return null
})
const handleTabClick = (tabId: string) => { const handleTabClick = (tabId: string) => {
panelStore.setActiveTab(props.panel.id, tabId) panelStore.setActiveTab(props.panel.id, tabId)
} }
@@ -32,26 +33,19 @@ const handleCloseTab = (tabId: string, event: Event) => {
event.stopPropagation() event.stopPropagation()
panelStore.closeTab(props.panel.id, tabId) panelStore.closeTab(props.panel.id, tabId)
} }
// 拖拽结束后更新状态
const onDragEnd = () => {
// vuedraggable会自动更新数组Pinia的watch会自动触发保存
}
</script> </script>
<template> <template>
<div class="panel"> <div class="panel">
<div class="panel-tabs"> <div class="panel-tabs">
<draggable <draggable
v-model="tabs" :list="panel.tabs"
:group="{ name: 'tabs', pull: true, put: true }" group="tabs"
item-key="id" item-key="id"
class="tabs-container" class="tabs-container"
:animation="200" :animation="150"
ghost-class="tab-ghost" ghost-class="tab-ghost"
chosen-class="tab-chosen"
drag-class="tab-drag" drag-class="tab-drag"
@end="onDragEnd"
> >
<template #item="{ element }"> <template #item="{ element }">
<div <div
@@ -71,10 +65,18 @@ const onDragEnd = () => {
</draggable> </draggable>
</div> </div>
<div class="panel-content"> <div class="panel-content">
<div v-if="activeTab" class="content-wrapper"> <!-- 物料组件渲染 -->
<component
v-if="activeMaterialComponent"
:is="activeMaterialComponent"
class="material-wrapper"
/>
<!-- 普通内容 -->
<div v-else-if="activeTab" class="content-wrapper">
<p>{{ activeTab.content }}</p> <p>{{ activeTab.content }}</p>
<p class="tab-info">Tab ID: {{ activeTab.id }}</p> <p class="tab-info">Tab ID: {{ activeTab.id }}</p>
</div> </div>
<!-- 空状态 -->
<div v-else class="empty-panel"> <div v-else class="empty-panel">
<span>暂无内容</span> <span>暂无内容</span>
</div> </div>
@@ -100,9 +102,8 @@ const onDragEnd = () => {
.tabs-container { .tabs-container {
display: flex; display: flex;
flex-wrap: wrap;
min-height: 35px; min-height: 35px;
align-items: flex-end; align-items: stretch;
} }
.tab-item { .tab-item {
@@ -113,12 +114,16 @@ const onDragEnd = () => {
background: #2d2d2d; background: #2d2d2d;
color: #969696; color: #969696;
border-right: 1px solid #3c3c3c; border-right: 1px solid #3c3c3c;
cursor: pointer; cursor: grab;
user-select: none; user-select: none;
transition: all 0.15s; transition: background 0.15s, color 0.15s;
font-size: 13px; font-size: 13px;
} }
.tab-item:active {
cursor: grabbing;
}
.tab-item:hover { .tab-item:hover {
background: #323232; background: #323232;
color: #cccccc; color: #cccccc;
@@ -135,6 +140,7 @@ const onDragEnd = () => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
pointer-events: none;
} }
.tab-close { .tab-close {
@@ -159,11 +165,15 @@ const onDragEnd = () => {
.panel-content { .panel-content {
flex: 1; flex: 1;
padding: 16px;
overflow: auto; overflow: auto;
} }
.material-wrapper {
height: 100%;
}
.content-wrapper { .content-wrapper {
padding: 16px;
color: #cccccc; color: #cccccc;
} }
@@ -183,17 +193,20 @@ const onDragEnd = () => {
/* 拖拽样式 */ /* 拖拽样式 */
.tab-ghost { .tab-ghost {
opacity: 0.5; opacity: 0.4;
background: #0e639c !important;
}
.tab-chosen {
background: #0e639c !important; background: #0e639c !important;
color: white !important; color: white !important;
} }
.tab-drag { .tab-drag {
opacity: 0.9; opacity: 1;
transform: rotate(2deg); background: #094771 !important;
color: white !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* sortable 拖拽占位符 */
.sortable-chosen {
background: #0e639c !important;
} }
</style> </style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
(e: 'resize', delta: number): void
}>()
const isDragging = ref(false)
const startX = ref(0)
const onMouseDown = (e: MouseEvent) => {
isDragging.value = true
startX.value = e.clientX
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
const onMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return
const delta = e.clientX - startX.value
startX.value = e.clientX
emit('resize', delta)
}
const onMouseUp = () => {
isDragging.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
</script>
<template>
<div
class="resizer"
:class="{ dragging: isDragging }"
@mousedown="onMouseDown"
></div>
</template>
<style scoped>
.resizer {
width: 4px;
background: #181818;
cursor: col-resize;
flex-shrink: 0;
transition: background 0.2s;
}
.resizer:hover,
.resizer.dragging {
background: #007acc;
}
</style>

View File

@@ -0,0 +1,17 @@
{
"name": "数据表格",
"description": "展示属性和值的简单表格组件",
"props": {
"columns": ["属性", "值"],
"data": [
{ "property": "项目名称", "value": "draggable-panels" },
{ "property": "框架", "value": "Vue 3" },
{ "property": "语言", "value": "TypeScript" },
{ "property": "构建工具", "value": "Vite" },
{ "property": "状态管理", "value": "Pinia" },
{ "property": "版本", "value": "1.0.0" },
{ "property": "作者", "value": "Developer" },
{ "property": "许可证", "value": "MIT" }
]
}
}

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import config from './index.json'
const props = defineProps<{
materialProps?: Record<string, any>
}>()
const mergedProps = { ...config.props, ...props.materialProps }
</script>
<template>
<div class="data-table">
<div class="table-header">
<span class="title">{{ config.name }}</span>
</div>
<div class="table-body">
<table class="table">
<thead>
<tr>
<th v-for="col in mergedProps.columns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in mergedProps.data" :key="index">
<td>{{ row.property }}</td>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.data-table {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
}
.table-header {
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
}
.title {
color: #cccccc;
font-size: 13px;
font-weight: 500;
}
.table-body {
flex: 1;
padding: 12px;
overflow: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th,
.table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #3c3c3c;
}
.table th {
background: #2d2d2d;
color: #cccccc;
font-weight: 500;
}
.table td {
color: #d4d4d4;
}
.table tbody tr:hover {
background: #2a2d2e;
}
</style>

View File

@@ -0,0 +1,9 @@
{
"name": "测试组件A",
"description": "用于测试的简单文本显示组件A",
"props": {
"title": "测试组件 A",
"content": "这是测试组件A的内容区域。\n\n此组件用于验证物料系统的正确性。",
"color": "#4fc3f7"
}
}

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import config from './index.json'
const props = defineProps<{
materialProps?: Record<string, any>
}>()
const mergedProps = { ...config.props, ...props.materialProps }
</script>
<template>
<div class="test-widget">
<div class="widget-header" :style="{ borderLeftColor: mergedProps.color }">
<span class="title">{{ mergedProps.title }}</span>
</div>
<div class="widget-body">
<p class="content">{{ mergedProps.content }}</p>
<div class="badge" :style="{ background: mergedProps.color }">
{{ config.name }}
</div>
</div>
</div>
</template>
<style scoped>
.test-widget {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
}
.widget-header {
padding: 12px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
border-left: 3px solid;
}
.title {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.widget-body {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.content {
color: #cccccc;
font-size: 14px;
line-height: 1.6;
text-align: center;
white-space: pre-line;
margin-bottom: 20px;
}
.badge {
padding: 6px 16px;
border-radius: 20px;
color: white;
font-size: 12px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,9 @@
{
"name": "测试组件B",
"description": "用于测试的简单文本显示组件B",
"props": {
"title": "测试组件 B",
"content": "这是测试组件B的内容区域。\n\n组件之间可以通过拖拽进行位置调整。",
"color": "#81c784"
}
}

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import config from './index.json'
const props = defineProps<{
materialProps?: Record<string, any>
}>()
const mergedProps = { ...config.props, ...props.materialProps }
</script>
<template>
<div class="test-widget">
<div class="widget-header" :style="{ borderLeftColor: mergedProps.color }">
<span class="title">{{ mergedProps.title }}</span>
</div>
<div class="widget-body">
<p class="content">{{ mergedProps.content }}</p>
<div class="badge" :style="{ background: mergedProps.color }">
{{ config.name }}
</div>
</div>
</div>
</template>
<style scoped>
.test-widget {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
}
.widget-header {
padding: 12px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
border-left: 3px solid;
}
.title {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.widget-body {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.content {
color: #cccccc;
font-size: 14px;
line-height: 1.6;
text-align: center;
white-space: pre-line;
margin-bottom: 20px;
}
.badge {
padding: 6px 16px;
border-radius: 20px;
color: white;
font-size: 12px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,9 @@
{
"name": "测试组件C",
"description": "用于测试的简单文本显示组件C",
"props": {
"title": "测试组件 C",
"content": "这是测试组件C的内容区域。\n\n每个组件全局只能显示一次。",
"color": "#ffb74d"
}
}

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import config from './index.json'
const props = defineProps<{
materialProps?: Record<string, any>
}>()
const mergedProps = { ...config.props, ...props.materialProps }
</script>
<template>
<div class="test-widget">
<div class="widget-header" :style="{ borderLeftColor: mergedProps.color }">
<span class="title">{{ mergedProps.title }}</span>
</div>
<div class="widget-body">
<p class="content">{{ mergedProps.content }}</p>
<div class="badge" :style="{ background: mergedProps.color }">
{{ config.name }}
</div>
</div>
</div>
</template>
<style scoped>
.test-widget {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
}
.widget-header {
padding: 12px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
border-left: 3px solid;
}
.title {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.widget-body {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.content {
color: #cccccc;
font-size: 14px;
line-height: 1.6;
text-align: center;
white-space: pre-line;
margin-bottom: 20px;
}
.badge {
padding: 6px 16px;
border-radius: 20px;
color: white;
font-size: 12px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,9 @@
{
"name": "文本编辑器",
"description": "一个简单的文本编辑框组件,支持多行文本输入",
"props": {
"placeholder": "请输入文本内容...",
"defaultValue": "这是默认的文本内容。\n\n你可以在这里编辑任何文本。",
"rows": 10
}
}

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref } from 'vue'
import config from './index.json'
const props = defineProps<{
materialProps?: Record<string, any>
}>()
const mergedProps = { ...config.props, ...props.materialProps }
const textContent = ref(mergedProps.defaultValue)
</script>
<template>
<div class="text-editor">
<div class="editor-header">
<span class="title">{{ config.name }}</span>
</div>
<div class="editor-body">
<textarea
v-model="textContent"
:placeholder="mergedProps.placeholder"
:rows="mergedProps.rows"
class="editor-textarea"
></textarea>
</div>
</div>
</template>
<style scoped>
.text-editor {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
}
.editor-header {
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
}
.title {
color: #cccccc;
font-size: 13px;
font-weight: 500;
}
.editor-body {
flex: 1;
padding: 12px;
overflow: hidden;
}
.editor-textarea {
width: 100%;
height: 100%;
background: #252526;
color: #d4d4d4;
border: 1px solid #3c3c3c;
border-radius: 4px;
padding: 12px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.5;
resize: none;
outline: none;
}
.editor-textarea:focus {
border-color: #007acc;
}
.editor-textarea::placeholder {
color: #666666;
}
</style>

View File

@@ -0,0 +1,36 @@
{
"name": "树形展示器",
"description": "用于展示树形结构数据的组件",
"props": {
"treeData": [
{
"id": "1",
"label": "项目根目录",
"expanded": true,
"children": [
{
"id": "1-1",
"label": "src",
"expanded": true,
"children": [
{ "id": "1-1-1", "label": "components" },
{ "id": "1-1-2", "label": "stores" },
{ "id": "1-1-3", "label": "types" },
{ "id": "1-1-4", "label": "App.vue" },
{ "id": "1-1-5", "label": "main.ts" }
]
},
{
"id": "1-2",
"label": "public",
"children": [
{ "id": "1-2-1", "label": "favicon.ico" }
]
},
{ "id": "1-3", "label": "package.json" },
{ "id": "1-4", "label": "vite.config.ts" }
]
}
]
}
}

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref } from 'vue'
import config from './index.json'
interface TreeNode {
id: string
label: string
expanded?: boolean
children?: TreeNode[]
}
const props = defineProps<{
materialProps?: Record<string, any>
}>()
const mergedProps = { ...config.props, ...props.materialProps }
const expandedNodes = ref<Set<string>>(new Set())
// 初始化展开状态
const initExpanded = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.expanded) {
expandedNodes.value.add(node.id)
}
if (node.children) {
initExpanded(node.children)
}
})
}
initExpanded(mergedProps.treeData)
const toggleNode = (nodeId: string) => {
if (expandedNodes.value.has(nodeId)) {
expandedNodes.value.delete(nodeId)
} else {
expandedNodes.value.add(nodeId)
}
}
const isExpanded = (nodeId: string) => expandedNodes.value.has(nodeId)
</script>
<template>
<div class="tree-viewer">
<div class="viewer-header">
<span class="title">{{ config.name }}</span>
</div>
<div class="viewer-body">
<div class="tree-container">
<template v-for="node in mergedProps.treeData" :key="node.id">
<div class="tree-node-wrapper">
<div
class="tree-node"
@click="toggleNode(node.id)"
>
<span class="expand-icon" v-if="node.children?.length">
{{ isExpanded(node.id) ? '' : '' }}
</span>
<span class="expand-icon empty" v-else></span>
<span class="node-label">{{ node.label }}</span>
</div>
<div class="tree-children" v-if="node.children && isExpanded(node.id)">
<template v-for="child in node.children" :key="child.id">
<div class="tree-node-wrapper">
<div
class="tree-node level-1"
@click="toggleNode(child.id)"
>
<span class="expand-icon" v-if="child.children?.length">
{{ isExpanded(child.id) ? '' : '' }}
</span>
<span class="expand-icon empty" v-else></span>
<span class="node-label">{{ child.label }}</span>
</div>
<div class="tree-children" v-if="child.children && isExpanded(child.id)">
<div
v-for="subChild in child.children"
:key="subChild.id"
class="tree-node level-2"
>
<span class="expand-icon empty"></span>
<span class="node-label">{{ subChild.label }}</span>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.tree-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
}
.viewer-header {
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
}
.title {
color: #cccccc;
font-size: 13px;
font-weight: 500;
}
.viewer-body {
flex: 1;
padding: 8px;
overflow: auto;
}
.tree-container {
font-size: 13px;
}
.tree-node {
display: flex;
align-items: center;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
color: #cccccc;
}
.tree-node:hover {
background: #2a2d2e;
}
.tree-node.level-1 {
padding-left: 24px;
}
.tree-node.level-2 {
padding-left: 48px;
}
.expand-icon {
width: 16px;
font-size: 10px;
color: #888888;
margin-right: 4px;
}
.expand-icon.empty {
visibility: hidden;
}
.node-label {
flex: 1;
}
.tree-children {
margin-left: 0;
}
</style>

View File

@@ -0,0 +1,40 @@
import { defineAsyncComponent, type Component } from 'vue'
import type { MaterialInfo } from '../types'
// 导入所有物料配置
import TextEditorConfig from './TextEditor/index.json'
import TreeViewerConfig from './TreeViewer/index.json'
import DataTableConfig from './DataTable/index.json'
import TestWidget1Config from './TestWidget1/index.json'
import TestWidget2Config from './TestWidget2/index.json'
import TestWidget3Config from './TestWidget3/index.json'
// 物料组件映射表
export const materialComponents: Record<string, Component> = {
TextEditor: defineAsyncComponent(() => import('./TextEditor/index.vue')),
TreeViewer: defineAsyncComponent(() => import('./TreeViewer/index.vue')),
DataTable: defineAsyncComponent(() => import('./DataTable/index.vue')),
TestWidget1: defineAsyncComponent(() => import('./TestWidget1/index.vue')),
TestWidget2: defineAsyncComponent(() => import('./TestWidget2/index.vue')),
TestWidget3: defineAsyncComponent(() => import('./TestWidget3/index.vue')),
}
// 物料信息列表
export const materialList: MaterialInfo[] = [
{ id: 'TextEditor', ...TextEditorConfig },
{ id: 'TreeViewer', ...TreeViewerConfig },
{ id: 'DataTable', ...DataTableConfig },
{ id: 'TestWidget1', ...TestWidget1Config },
{ id: 'TestWidget2', ...TestWidget2Config },
{ id: 'TestWidget3', ...TestWidget3Config },
]
// 获取物料组件
export const getMaterialComponent = (id: string): Component | undefined => {
return materialComponents[id]
}
// 获取物料信息
export const getMaterialInfo = (id: string): MaterialInfo | undefined => {
return materialList.find(m => m.id === id)
}

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, watch } from 'vue' import { ref, watch, computed } from 'vue'
import type { LayoutConfig, TabItem, Panel } from '../types' import type { LayoutConfig, TabItem, Panel } from '../types'
import { materialList, getMaterialInfo } from '../materials'
// 生成唯一ID // 生成唯一ID
const generateId = () => Math.random().toString(36).substring(2, 9) const generateId = () => Math.random().toString(36).substring(2, 9)
@@ -9,23 +10,17 @@ const generateId = () => Math.random().toString(36).substring(2, 9)
const getDefaultLayout = (): LayoutConfig => ({ const getDefaultLayout = (): LayoutConfig => ({
leftPanel: { leftPanel: {
id: 'left', id: 'left',
tabs: [ tabs: [],
{ id: generateId(), title: '资源管理器', content: '左侧面板内容1' }
],
activeTabId: null activeTabId: null
}, },
centerPanel: { centerPanel: {
id: 'center', id: 'center',
tabs: [ tabs: [],
{ id: generateId(), title: '欢迎页', content: '中间面板内容1' }
],
activeTabId: null activeTabId: null
}, },
rightPanel: { rightPanel: {
id: 'right', id: 'right',
tabs: [ tabs: [],
{ id: generateId(), title: '大纲', content: '右侧面板内容1' }
],
activeTabId: null activeTabId: null
} }
}) })
@@ -49,6 +44,28 @@ export const usePanelStore = defineStore('panel', () => {
// 是否已加载配置 // 是否已加载配置
const isLoaded = ref(false) const isLoaded = ref(false)
// 获取所有已打开的物料组件ID
const openedMaterialIds = computed(() => {
const allTabs = [
...layout.value.leftPanel.tabs,
...layout.value.centerPanel.tabs,
...layout.value.rightPanel.tabs
]
return allTabs
.filter(tab => tab.materialId)
.map(tab => tab.materialId as string)
})
// 获取可用的物料组件列表(未打开的)
const availableMaterials = computed(() => {
return materialList.filter(m => !openedMaterialIds.value.includes(m.id))
})
// 检查物料组件是否已打开
const isMaterialOpened = (materialId: string) => {
return openedMaterialIds.value.includes(materialId)
}
// 加载配置 // 加载配置
const loadConfig = async () => { const loadConfig = async () => {
try { try {
@@ -98,16 +115,37 @@ export const usePanelStore = defineStore('panel', () => {
const addTab = (panelId: string, tab?: Partial<TabItem>) => { const addTab = (panelId: string, tab?: Partial<TabItem>) => {
const panel = getPanel(panelId) const panel = getPanel(panelId)
if (panel) { if (panel) {
// 检查物料组件是否已打开
if (tab?.materialId && isMaterialOpened(tab.materialId)) {
console.warn('该物料组件已打开')
return
}
const newTab: TabItem = { const newTab: TabItem = {
id: generateId(), id: generateId(),
title: tab?.title || `新窗口 ${panel.tabs.length + 1}`, title: tab?.title || `新窗口 ${panel.tabs.length + 1}`,
content: tab?.content || '新窗口内容' content: tab?.content || '新窗口内容',
materialId: tab?.materialId
} }
panel.tabs.push(newTab) panel.tabs.push(newTab)
panel.activeTabId = newTab.id panel.activeTabId = newTab.id
} }
} }
// 打开物料组件
const openMaterial = (materialId: string, panelId: string = 'center') => {
if (isMaterialOpened(materialId)) {
console.warn('该物料组件已打开')
return
}
const materialInfo = getMaterialInfo(materialId)
if (materialInfo) {
addTab(panelId, {
title: materialInfo.name,
materialId: materialId
})
}
}
// 关闭Tab // 关闭Tab
const closeTab = (panelId: string, tabId: string) => { const closeTab = (panelId: string, tabId: string) => {
const panel = getPanel(panelId) const panel = getPanel(panelId)
@@ -169,9 +207,13 @@ export const usePanelStore = defineStore('panel', () => {
return { return {
layout, layout,
isLoaded, isLoaded,
openedMaterialIds,
availableMaterials,
isMaterialOpened,
loadConfig, loadConfig,
saveConfig, saveConfig,
addTab, addTab,
openMaterial,
closeTab, closeTab,
setActiveTab, setActiveTab,
moveTab, moveTab,

View File

@@ -3,6 +3,7 @@ export interface TabItem {
id: string id: string
title: string title: string
content?: string content?: string
materialId?: string // 关联的物料组件ID
} }
// 面板接口 // 面板接口
@@ -24,3 +25,18 @@ export interface AppConfig {
layout: LayoutConfig layout: LayoutConfig
lastUpdated: string lastUpdated: string
} }
// 物料组件配置接口
export interface MaterialConfig {
name: string
description: string
props: Record<string, any>
}
// 物料组件信息
export interface MaterialInfo {
id: string
name: string
description: string
props: Record<string, any>
}