1
This commit is contained in:
25
draggable-panels/.gitignore
vendored
Normal file
25
draggable-panels/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
.idea/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
5
draggable-panels/README.md
Normal file
5
draggable-panels/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
58
draggable-panels/config.json
Normal file
58
draggable-panels/config.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"layout": {
|
||||||
|
"leftPanel": {
|
||||||
|
"id": "left",
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"id": "0i69gg5",
|
||||||
|
"title": "资源管理器",
|
||||||
|
"content": "左侧面板内容1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9uc5qy1",
|
||||||
|
"title": "新窗口 2",
|
||||||
|
"content": "新窗口内容"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activeTabId": "0i69gg5"
|
||||||
|
},
|
||||||
|
"centerPanel": {
|
||||||
|
"id": "center",
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"id": "auteqok",
|
||||||
|
"title": "欢迎页",
|
||||||
|
"content": "中间面板内容1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c08lqdq",
|
||||||
|
"title": "新窗口 3",
|
||||||
|
"content": "新窗口内容"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c9nw7xj",
|
||||||
|
"title": "新窗口 3",
|
||||||
|
"content": "新窗口内容"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activeTabId": "cibltif"
|
||||||
|
},
|
||||||
|
"rightPanel": {
|
||||||
|
"id": "right",
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"id": "ojaw0e3",
|
||||||
|
"title": "新窗口 3",
|
||||||
|
"content": "新窗口内容"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cibltif",
|
||||||
|
"title": "新窗口 4",
|
||||||
|
"content": "新窗口内容"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activeTabId": "ojaw0e3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lastUpdated": "2025-12-20T11:47:26.835Z"
|
||||||
|
}
|
||||||
13
draggable-panels/index.html
Normal file
13
draggable-panels/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>draggable-panels</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1596
draggable-panels/package-lock.json
generated
Normal file
1596
draggable-panels/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
draggable-panels/package.json
Normal file
24
draggable-panels/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "draggable-panels",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vue-tsc": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
draggable-panels/public/vite.svg
Normal file
1
draggable-panels/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
31
draggable-panels/src/App.vue
Normal file
31
draggable-panels/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { usePanelStore } from './stores/panelStore'
|
||||||
|
import Header from './components/Header.vue'
|
||||||
|
import Footer from './components/Footer.vue'
|
||||||
|
import MainLayout from './components/MainLayout.vue'
|
||||||
|
|
||||||
|
const panelStore = usePanelStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
panelStore.loadConfig()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<Header />
|
||||||
|
<MainLayout />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
draggable-panels/src/assets/vue.svg
Normal file
1
draggable-panels/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
65
draggable-panels/src/components/Footer.vue
Normal file
65
draggable-panels/src/components/Footer.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const currentTime = ref('')
|
||||||
|
let timer: number | null = null
|
||||||
|
|
||||||
|
const updateTime = () => {
|
||||||
|
const now = new Date()
|
||||||
|
currentTime.value = now.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateTime()
|
||||||
|
timer = window.setInterval(updateTime, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="footer-left"></div>
|
||||||
|
<div class="footer-right">
|
||||||
|
<span class="time">{{ currentTime }}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-footer {
|
||||||
|
height: 24px;
|
||||||
|
background: #007acc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
draggable-panels/src/components/Header.vue
Normal file
80
draggable-panels/src/components/Header.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { usePanelStore } from '../stores/panelStore'
|
||||||
|
|
||||||
|
const panelStore = usePanelStore()
|
||||||
|
|
||||||
|
const handleAddWindow = () => {
|
||||||
|
panelStore.addNewWindow()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="app-title">拖拽面板编辑器</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-center">
|
||||||
|
<button class="add-tab-btn" @click="handleAddWindow">
|
||||||
|
<span class="icon">+</span>
|
||||||
|
<span>新增子窗口</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-right"></div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-header {
|
||||||
|
height: 48px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
color: #cccccc;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
41
draggable-panels/src/components/HelloWorld.vue
Normal file
41
draggable-panels/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{ msg: string }>()
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<button type="button" @click="count++">count is {{ count }}</button>
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Check out
|
||||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
|
>create-vue</a
|
||||||
|
>, the official Vue + Vite starter
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Learn more about IDE Support for Vue in the
|
||||||
|
<a
|
||||||
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
|
target="_blank"
|
||||||
|
>Vue Docs Scaling up Guide</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
draggable-panels/src/components/MainLayout.vue
Normal file
74
draggable-panels/src/components/MainLayout.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { usePanelStore } from '../stores/panelStore'
|
||||||
|
import Panel from './Panel.vue'
|
||||||
|
|
||||||
|
const panelStore = usePanelStore()
|
||||||
|
|
||||||
|
const leftPanel = computed(() => panelStore.layout.leftPanel)
|
||||||
|
const centerPanel = computed(() => panelStore.layout.centerPanel)
|
||||||
|
const rightPanel = computed(() => panelStore.layout.rightPanel)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="main-layout">
|
||||||
|
<div class="panel-container left-panel">
|
||||||
|
<div class="panel-header">左侧面板</div>
|
||||||
|
<Panel :panel="leftPanel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-container center-panel">
|
||||||
|
<div class="panel-header">中间面板</div>
|
||||||
|
<Panel :panel="centerPanel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-container right-panel">
|
||||||
|
<div class="panel-header">右侧面板</div>
|
||||||
|
<Panel :panel="rightPanel" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.main-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #181818;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #252526;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
flex: 0 0 250px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
flex: 0 0 250px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #333333;
|
||||||
|
color: #888888;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
199
draggable-panels/src/components/Panel.vue
Normal file
199
draggable-panels/src/components/Panel.vue
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
import { usePanelStore } from '../stores/panelStore'
|
||||||
|
import type { Panel, TabItem } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
panel: Panel
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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 activeTab = computed(() =>
|
||||||
|
props.panel.tabs.find(t => t.id === activeTabId.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTabClick = (tabId: string) => {
|
||||||
|
panelStore.setActiveTab(props.panel.id, tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseTab = (tabId: string, event: Event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
panelStore.closeTab(props.panel.id, tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽结束后更新状态
|
||||||
|
const onDragEnd = () => {
|
||||||
|
// vuedraggable会自动更新数组,Pinia的watch会自动触发保存
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<draggable
|
||||||
|
v-model="tabs"
|
||||||
|
:group="{ name: 'tabs', pull: true, put: true }"
|
||||||
|
item-key="id"
|
||||||
|
class="tabs-container"
|
||||||
|
:animation="200"
|
||||||
|
ghost-class="tab-ghost"
|
||||||
|
chosen-class="tab-chosen"
|
||||||
|
drag-class="tab-drag"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: element.id === activeTabId }"
|
||||||
|
@click="handleTabClick(element.id)"
|
||||||
|
>
|
||||||
|
<span class="tab-title">{{ element.title }}</span>
|
||||||
|
<button
|
||||||
|
class="tab-close"
|
||||||
|
@click="handleCloseTab(element.id, $event)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div v-if="activeTab" class="content-wrapper">
|
||||||
|
<p>{{ activeTab.content }}</p>
|
||||||
|
<p class="tab-info">Tab ID: {{ activeTab.id }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-panel">
|
||||||
|
<span>暂无内容</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tabs {
|
||||||
|
background: #252526;
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
min-height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 35px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #969696;
|
||||||
|
border-right: 1px solid #3c3c3c;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
background: #323232;
|
||||||
|
color: #cccccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: 2px solid #007acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-title {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #969696;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
background: #4a4a4a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
color: #cccccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-info {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽样式 */
|
||||||
|
.tab-ghost {
|
||||||
|
opacity: 0.5;
|
||||||
|
background: #0e639c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-chosen {
|
||||||
|
background: #0e639c !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-drag {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
draggable-panels/src/main.ts
Normal file
10
draggable-panels/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.mount('#app')
|
||||||
180
draggable-panels/src/stores/panelStore.ts
Normal file
180
draggable-panels/src/stores/panelStore.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import type { LayoutConfig, TabItem, Panel } from '../types'
|
||||||
|
|
||||||
|
// 生成唯一ID
|
||||||
|
const generateId = () => Math.random().toString(36).substring(2, 9)
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const getDefaultLayout = (): LayoutConfig => ({
|
||||||
|
leftPanel: {
|
||||||
|
id: 'left',
|
||||||
|
tabs: [
|
||||||
|
{ id: generateId(), title: '资源管理器', content: '左侧面板内容1' }
|
||||||
|
],
|
||||||
|
activeTabId: null
|
||||||
|
},
|
||||||
|
centerPanel: {
|
||||||
|
id: 'center',
|
||||||
|
tabs: [
|
||||||
|
{ id: generateId(), title: '欢迎页', content: '中间面板内容1' }
|
||||||
|
],
|
||||||
|
activeTabId: null
|
||||||
|
},
|
||||||
|
rightPanel: {
|
||||||
|
id: 'right',
|
||||||
|
tabs: [
|
||||||
|
{ id: generateId(), title: '大纲', content: '右侧面板内容1' }
|
||||||
|
],
|
||||||
|
activeTabId: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化每个面板的activeTabId
|
||||||
|
const initActiveTabIds = (layout: LayoutConfig): LayoutConfig => {
|
||||||
|
const panels: (keyof LayoutConfig)[] = ['leftPanel', 'centerPanel', 'rightPanel']
|
||||||
|
panels.forEach(key => {
|
||||||
|
const panel = layout[key]
|
||||||
|
if (panel.tabs.length > 0 && !panel.activeTabId) {
|
||||||
|
panel.activeTabId = panel.tabs[0].id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePanelStore = defineStore('panel', () => {
|
||||||
|
// 布局配置
|
||||||
|
const layout = ref<LayoutConfig>(initActiveTabIds(getDefaultLayout()))
|
||||||
|
|
||||||
|
// 是否已加载配置
|
||||||
|
const isLoaded = ref(false)
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config')
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json()
|
||||||
|
if (config && config.layout) {
|
||||||
|
layout.value = initActiveTabIds(config.layout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('使用默认配置')
|
||||||
|
}
|
||||||
|
isLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
const saveConfig = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
layout: layout.value,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存配置失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听变化并自动保存
|
||||||
|
watch(layout, () => {
|
||||||
|
if (isLoaded.value) {
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 获取面板
|
||||||
|
const getPanel = (panelId: string): Panel | undefined => {
|
||||||
|
const panels = [layout.value.leftPanel, layout.value.centerPanel, layout.value.rightPanel]
|
||||||
|
return panels.find(p => p.id === panelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新Tab
|
||||||
|
const addTab = (panelId: string, tab?: Partial<TabItem>) => {
|
||||||
|
const panel = getPanel(panelId)
|
||||||
|
if (panel) {
|
||||||
|
const newTab: TabItem = {
|
||||||
|
id: generateId(),
|
||||||
|
title: tab?.title || `新窗口 ${panel.tabs.length + 1}`,
|
||||||
|
content: tab?.content || '新窗口内容'
|
||||||
|
}
|
||||||
|
panel.tabs.push(newTab)
|
||||||
|
panel.activeTabId = newTab.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭Tab
|
||||||
|
const closeTab = (panelId: string, tabId: string) => {
|
||||||
|
const panel = getPanel(panelId)
|
||||||
|
if (panel) {
|
||||||
|
const index = panel.tabs.findIndex(t => t.id === tabId)
|
||||||
|
if (index > -1) {
|
||||||
|
panel.tabs.splice(index, 1)
|
||||||
|
// 更新激活的Tab
|
||||||
|
if (panel.activeTabId === tabId) {
|
||||||
|
panel.activeTabId = panel.tabs.length > 0
|
||||||
|
? panel.tabs[Math.max(0, index - 1)].id
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置激活的Tab
|
||||||
|
const setActiveTab = (panelId: string, tabId: string) => {
|
||||||
|
const panel = getPanel(panelId)
|
||||||
|
if (panel) {
|
||||||
|
panel.activeTabId = tabId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动Tab到另一个面板
|
||||||
|
const moveTab = (fromPanelId: string, toPanelId: string, tabId: string, toIndex?: number) => {
|
||||||
|
const fromPanel = getPanel(fromPanelId)
|
||||||
|
const toPanel = getPanel(toPanelId)
|
||||||
|
|
||||||
|
if (fromPanel && toPanel) {
|
||||||
|
const tabIndex = fromPanel.tabs.findIndex(t => t.id === tabId)
|
||||||
|
if (tabIndex > -1) {
|
||||||
|
const [tab] = fromPanel.tabs.splice(tabIndex, 1)
|
||||||
|
|
||||||
|
if (toIndex !== undefined) {
|
||||||
|
toPanel.tabs.splice(toIndex, 0, tab)
|
||||||
|
} else {
|
||||||
|
toPanel.tabs.push(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新激活状态
|
||||||
|
toPanel.activeTabId = tab.id
|
||||||
|
|
||||||
|
if (fromPanel.activeTabId === tabId && fromPanel.tabs.length > 0) {
|
||||||
|
fromPanel.activeTabId = fromPanel.tabs[0].id
|
||||||
|
} else if (fromPanel.tabs.length === 0) {
|
||||||
|
fromPanel.activeTabId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在默认面板添加新窗口
|
||||||
|
const addNewWindow = () => {
|
||||||
|
addTab('center')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
isLoaded,
|
||||||
|
loadConfig,
|
||||||
|
saveConfig,
|
||||||
|
addTab,
|
||||||
|
closeTab,
|
||||||
|
setActiveTab,
|
||||||
|
moveTab,
|
||||||
|
addNewWindow
|
||||||
|
}
|
||||||
|
})
|
||||||
31
draggable-panels/src/style.css
Normal file
31
draggable-panels/src/style.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
color-scheme: dark;
|
||||||
|
color: #cccccc;
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
26
draggable-panels/src/types/index.ts
Normal file
26
draggable-panels/src/types/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Tab项接口
|
||||||
|
export interface TabItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 面板接口
|
||||||
|
export interface Panel {
|
||||||
|
id: string
|
||||||
|
tabs: TabItem[]
|
||||||
|
activeTabId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 布局配置接口
|
||||||
|
export interface LayoutConfig {
|
||||||
|
leftPanel: Panel
|
||||||
|
centerPanel: Panel
|
||||||
|
rightPanel: Panel
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局配置接口
|
||||||
|
export interface AppConfig {
|
||||||
|
layout: LayoutConfig
|
||||||
|
lastUpdated: string
|
||||||
|
}
|
||||||
16
draggable-panels/tsconfig.app.json
Normal file
16
draggable-panels/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
draggable-panels/tsconfig.json
Normal file
7
draggable-panels/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
draggable-panels/tsconfig.node.json
Normal file
26
draggable-panels/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
58
draggable-panels/vite.config.ts
Normal file
58
draggable-panels/vite.config.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
// 配置文件路径
|
||||||
|
const CONFIG_FILE = path.resolve(__dirname, 'config.json')
|
||||||
|
|
||||||
|
// 自定义插件:处理配置文件的读写
|
||||||
|
function configApiPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'config-api',
|
||||||
|
configureServer(server) {
|
||||||
|
// 读取配置
|
||||||
|
server.middlewares.use('/api/config', (req, res, next) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const config = fs.readFileSync(CONFIG_FILE, 'utf-8')
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
res.end(config)
|
||||||
|
} else {
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end(JSON.stringify({ error: 'Config not found' }))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 500
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to read config' }))
|
||||||
|
}
|
||||||
|
} else if (req.method === 'POST') {
|
||||||
|
let body = ''
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
body += chunk.toString()
|
||||||
|
})
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(body)
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
res.end(JSON.stringify({ success: true }))
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 500
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to save config' }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), configApiPlugin()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user