feat: workspace

This commit is contained in:
Jacky 2025-04-18 13:26:11 +08:00
parent 82da0ef05e
commit df95386088
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
13 changed files with 833 additions and 669 deletions

7
app/env.d.ts vendored
View file

@ -1,3 +1,10 @@
/// <reference types="vite/client" />
// Extend Window interface
interface Window {
inWorkspace?: boolean
}
declare module '*.svg' { declare module '*.svg' {
import type React from 'react' import type React from 'react'

View file

@ -14,7 +14,7 @@
color: #fff; color: #fff;
} }
#app { #app {
height: 100%; height: 100vh;
} }
</style> </style>
<title>Nginx UI</title> <title>Nginx UI</title>

View file

@ -2,14 +2,14 @@
"name": "nginx-ui-app-next", "name": "nginx-ui-app-next",
"type": "module", "type": "module",
"version": "2.0.0-rc.5", "version": "2.0.0-rc.5",
"packageManager": "pnpm@10.8.1+sha512.c50088ba998c67b8ca8c99df8a5e02fd2ae2e2b29aaf238feaa9e124248d3f48f9fb6db2424949ff901cffbb5e0f0cc1ad6aedb602cd29450751d11c35023677", "packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint --fix .", "lint:fix": "eslint --fix .",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host", "preview": "vite preview",
"gettext:extract": "vue-gettext-extract" "gettext:extract": "vue-gettext-extract"
}, },
"dependencies": { "dependencies": {
@ -25,7 +25,6 @@
"@xterm/addon-attach": "^0.11.0", "@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"ace-builds": "^1.40.0",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"apexcharts": "^4.5.0", "apexcharts": "^4.5.0",
"axios": "^1.8.4", "axios": "^1.8.4",
@ -40,10 +39,10 @@
"pinia-plugin-persistedstate": "^4.2.0", "pinia-plugin-persistedstate": "^4.2.0",
"reconnecting-websocket": "^4.4.0", "reconnecting-websocket": "^4.4.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"splitpanes": "^4.0.3",
"sse.js": "^2.6.0", "sse.js": "^2.6.0",
"universal-cookie": "^8.0.1", "universal-cookie": "^8.0.1",
"unocss": "^66.0.0", "unocss": "^66.0.0",
"uuid": "^11.1.0",
"vite-plugin-build-id": "0.5.0", "vite-plugin-build-id": "0.5.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-dompurify-html": "^5.2.0", "vue-dompurify-html": "^5.2.0",
@ -69,16 +68,17 @@
"@vitejs/plugin-vue-jsx": "^4.1.2", "@vitejs/plugin-vue-jsx": "^4.1.2",
"@vue/compiler-sfc": "^3.5.13", "@vue/compiler-sfc": "^3.5.13",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"ace-builds": "^1.40.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "9.24.0", "eslint": "9.23.0",
"eslint-plugin-sonarjs": "^3.0.2", "eslint-plugin-sonarjs": "^3.0.2",
"less": "^4.3.0", "less": "^4.3.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"typescript": "5.8.3", "typescript": "5.8.2",
"unplugin-auto-import": "^19.1.2", "unplugin-auto-import": "^19.1.2",
"unplugin-vue-components": "^28.5.0", "unplugin-vue-components": "^28.5.0",
"unplugin-vue-define-options": "^1.5.5", "unplugin-vue-define-options": "^1.5.5",
"vite": "^6.3.0", "vite": "^6.3.2",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.2.8"
} }

1257
app/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ import zh_TW from 'ant-design-vue/es/locale/zh_TW'
// This starter template is using Vue 3 <script setup> SFCs // This starter template is using Vue 3 <script setup> SFCs
// Check out https://vuejs.org/api/sfc-script-setup.html#script-setup // Check out https://vuejs.org/api/sfc-script-setup.html#script-setup
import { computed, provide } from 'vue' import { computed, provide } from 'vue'
import router from './routes'
const route = useRoute() const route = useRoute()
@ -52,6 +53,14 @@ const settings = useSettingsStore()
const is_theme_dark = computed(() => settings.theme === 'dark') const is_theme_dark = computed(() => settings.theme === 'dark')
loadTranslations(route) loadTranslations(route)
watch(route, () => {
settings.route_path = route.path
})
onMounted(() => {
router.push(settings.route_path)
})
</script> </script>
<template> <template>

4
app/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
// This file is used to extend global interfaces
declare interface Window {
inWorkspace?: boolean
}

View file

@ -33,7 +33,7 @@ function getClientWidth() {
} }
function collapse() { function collapse() {
return getClientWidth() < 1280 return getClientWidth() < 1080
} }
const { server_name } = storeToRefs(useSettingsStore()) const { server_name } = storeToRefs(useSettingsStore())

View file

@ -5,7 +5,7 @@ import NginxControl from '@/components/NginxControl/NginxControl.vue'
import Notification from '@/components/Notification/Notification.vue' import Notification from '@/components/Notification/Notification.vue'
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue' import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue' import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
import { HomeOutlined, LogoutOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue' import { DesktopOutlined, HomeOutlined, LogoutOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@ -24,6 +24,10 @@ function logout() {
} }
const headerRef = useTemplateRef('headerRef') as Readonly<ShallowRef<HTMLDivElement>> const headerRef = useTemplateRef('headerRef') as Readonly<ShallowRef<HTMLDivElement>>
const isWorkspace = computed(() => {
return !!window.inWorkspace
})
</script> </script>
<template> <template>
@ -31,12 +35,19 @@ const headerRef = useTemplateRef('headerRef') as Readonly<ShallowRef<HTMLDivElem
<div class="tool"> <div class="tool">
<MenuUnfoldOutlined @click="emit('clickUnFold')" /> <MenuUnfoldOutlined @click="emit('clickUnFold')" />
</div> </div>
<div v-if="!isWorkspace" class="workspace-entry">
<RouterLink to="/workspace">
<ATooltip :title="$gettext('Workspace')">
<DesktopOutlined />
</ATooltip>
</RouterLink>
</div>
<ASpace <ASpace
class="user-wrapper" class="user-wrapper"
:size="24" :size="24"
> >
<SetLanguage class="set_lang" /> <SetLanguage v-if="!isWorkspace" class="set_lang" />
<SwitchAppearance /> <SwitchAppearance />
@ -48,7 +59,7 @@ const headerRef = useTemplateRef('headerRef') as Readonly<ShallowRef<HTMLDivElem
<HomeOutlined /> <HomeOutlined />
</a> </a>
<a @click="logout"> <a v-if="!isWorkspace" @click="logout">
<LogoutOutlined /> <LogoutOutlined />
</a> </a>
</ASpace> </ASpace>
@ -86,6 +97,14 @@ const headerRef = useTemplateRef('headerRef') as Readonly<ShallowRef<HTMLDivElem
} }
} }
.workspace-entry {
position: absolute;
left: 20px;
@media (max-width: 600px) {
display: none;
}
}
.user-wrapper { .user-wrapper {
position: absolute; position: absolute;
right: 28px; right: 28px;

View file

@ -10,6 +10,7 @@ export const useSettingsStore = defineStore('settings', {
name: 'Local', name: 'Local',
}, },
server_name: '', server_name: '',
route_path: '',
}), }),
getters: { getters: {
is_remote(): boolean { is_remote(): boolean {
@ -32,5 +33,15 @@ export const useSettingsStore = defineStore('settings', {
this.environment.name = 'Local' this.environment.name = 'Local'
}, },
}, },
persist: true, persist: [
{
key: `LOCAL_${window.name || 'main'}`,
storage: localStorage,
pick: ['environment', 'server_name', 'route_path'],
},
{
storage: localStorage,
pick: ['language', 'theme', 'preference_theme'],
},
],
}) })

View file

@ -48,6 +48,14 @@ export const routes: RouteRecordRaw[] = [
}, },
children: mainLayoutChildren, children: mainLayoutChildren,
}, },
{
path: '/workspace',
name: 'Workspace',
component: () => import('@/views/workspace/WorkSpace.vue'),
meta: {
name: () => $gettext('Workspace'),
},
},
...authRoutes, ...authRoutes,
...errorRoutes, ...errorRoutes,
] ]

4
app/src/types.d.ts vendored
View file

@ -1 +1,5 @@
export type CheckedType = boolean | string | number export type CheckedType = boolean | string | number
interface Window {
inWorkspace?: boolean
}

View file

@ -0,0 +1,141 @@
<script lang="ts" setup>
import { CloseOutlined } from '@ant-design/icons-vue'
import { Pane, Splitpanes } from 'splitpanes'
import { useRouter } from 'vue-router'
import 'splitpanes/dist/splitpanes.css'
const router = useRouter()
const src = computed(() => {
return location.pathname
})
const paneSize = ref(localStorage.paneSize ?? 50) // Read from persistent localStorage.
function storePaneSize({ prevPane }) {
localStorage.paneSize = prevPane.size // Store in persistent localStorage.
}
function closeSplitView() {
router.push('/')
}
const leftFrame = useTemplateRef('leftFrame')
const rightFrame = useTemplateRef('rightFrame')
function handleLoad(iframeRef: HTMLIFrameElement | null) {
if (!iframeRef) {
return
}
iframeRef.addEventListener('load', () => {
if (iframeRef.contentWindow) {
iframeRef.contentWindow.inWorkspace = true
}
})
}
onMounted(() => {
handleLoad(leftFrame.value)
handleLoad(rightFrame.value)
})
</script>
<template>
<div class="h-100vh macos-window">
<div class="macos-titlebar flex items-center p-2 relative">
<div class="traffic-lights flex ml-2">
<div class="traffic-light close" @click="closeSplitView">
<CloseOutlined class="traffic-icon" />
</div>
</div>
<div class="window-title absolute left-0 right-0 text-center">
{{ $gettext('Workspace') }}
</div>
</div>
<Splitpanes class="default-theme split-container" @resized="storePaneSize">
<Pane :size="paneSize" :min-size="20">
<iframe ref="leftFrame" name="split-view-left" :src class="w-full h-full iframe-no-border" />
</Pane>
<Pane :size="100 - paneSize">
<iframe ref="rightFrame" name="split-view-right" :src class="w-full h-full iframe-no-border" />
</Pane>
</Splitpanes>
</div>
</template>
<style scoped>
.macos-window {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.macos-titlebar {
background: linear-gradient(to bottom, #f9f9f9, #ececec);
height: 32px;
border-bottom: 1px solid #e1e1e1;
-webkit-app-region: drag;
user-select: none;
}
.dark .macos-titlebar {
background: linear-gradient(to bottom, #323232, #282828);
border-bottom: 1px solid #3a3a3a;
}
.split-container {
height: calc(100vh - 32px);
}
.traffic-lights {
-webkit-app-region: no-drag;
z-index: 10;
}
.traffic-light {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.traffic-light.close {
background-color: #ff5f57;
border: 1px solid #e0443e;
}
.traffic-icon {
opacity: 0;
font-size: 9px;
color: rgba(0, 0, 0, 0.5);
}
.traffic-light:hover .traffic-icon {
opacity: 1;
}
.window-title {
font-size: 13px;
font-weight: 500;
color: #333;
pointer-events: none;
}
.dark .window-title {
color: #e0e0e0;
}
:deep(.splitpanes__splitter) {
background-color: #ececec !important;
}
.iframe-no-border {
border: none;
outline: none;
}
</style>

View file

@ -1,4 +1,3 @@
import { Agent } from 'node:http'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
@ -83,21 +82,6 @@ export default defineConfig(({ mode }) => {
secure: false, secure: false,
ws: true, ws: true,
timeout: 60000, timeout: 60000,
agent: new Agent({
keepAlive: false,
}),
onProxyReq(proxyReq, req) {
proxyReq.setHeader('Connection', 'keep-alive')
if (req.headers.accept === 'text/event-stream') {
proxyReq.setHeader('Cache-Control', 'no-cache')
proxyReq.setHeader('Content-Type', 'text/event-stream')
}
},
onProxyReqWs(proxyReq, req, socket) {
socket.on('close', () => {
proxyReq.destroy()
})
},
}, },
}, },
}, },