Browse Source

refactor: refresh editor ui

Jacky 3 weeks ago
parent
commit
c4bb484c6e
37 changed files with 945 additions and 697 deletions
  1. 9 0
      .cursor/mcp.json
  2. 2 0
      app/components.d.ts
  3. 87 0
      app/src/components/BaseEditor/BaseEditor.vue
  4. 4 0
      app/src/components/BaseEditor/index.ts
  5. 3 1
      app/src/components/CertInfo/CertInfo.vue
  6. 16 4
      app/src/components/ChatGPT/ChatGPT.vue
  7. 1 2
      app/src/components/ChatGPT/ChatMessageInput.vue
  8. 4 52
      app/src/components/ChatGPT/ChatMessageList.vue
  9. 10 4
      app/src/components/ChatGPT/chatgpt.ts
  10. 12 17
      app/src/components/NgxConfigEditor/NgxConfigEditor.vue
  11. 41 2
      app/src/components/NgxConfigEditor/NgxServer.vue
  12. 22 5
      app/src/components/NgxConfigEditor/NgxUpstream.vue
  13. 1 1
      app/src/version.json
  14. 25 372
      app/src/views/config/ConfigEditor.vue
  15. 5 0
      app/src/views/config/InspectConfig.vue
  16. 306 0
      app/src/views/config/components/ConfigLeftPanel.vue
  17. 24 0
      app/src/views/config/components/ConfigRightPanel.vue
  18. 66 0
      app/src/views/config/components/ConfigRightPanel/Basic.vue
  19. 15 0
      app/src/views/config/components/ConfigRightPanel/Chat.vue
  20. 73 0
      app/src/views/config/components/ConfigRightPanel/ConfigRightPanel.vue
  21. 46 0
      app/src/views/config/components/ConfigRightPanel/Deploy.vue
  22. 3 0
      app/src/views/config/components/ConfigRightPanel/index.ts
  23. 4 0
      app/src/views/config/components/index.ts
  24. 2 3
      app/src/views/config/configColumns.tsx
  25. 13 23
      app/src/views/site/site_edit/SiteEdit.vue
  26. 2 2
      app/src/views/site/site_edit/components/EnableTLS/EnableTLS.vue
  27. 1 1
      app/src/views/site/site_edit/components/RightPanel/Chat.vue
  28. 7 16
      app/src/views/site/site_edit/components/RightPanel/RightPanel.vue
  29. 6 5
      app/src/views/site/site_edit/components/SiteEditor/SiteEditor.vue
  30. 5 0
      app/src/views/site/site_edit/components/SiteEditor/store.ts
  31. 16 35
      app/src/views/stream/StreamEdit.vue
  32. 1 43
      app/src/views/stream/components/RightPanel/Basic.vue
  33. 1 1
      app/src/views/stream/components/RightPanel/Chat.vue
  34. 3 9
      app/src/views/stream/components/RightPanel/RightPanel.vue
  35. 105 96
      app/src/views/stream/components/StreamEditor.vue
  36. 1 3
      app/src/views/stream/components/StreamStatusSelect.vue
  37. 3 0
      app/src/views/stream/store.ts

+ 9 - 0
.cursor/mcp.json

@@ -0,0 +1,9 @@
+{
+	"mcpServers": {
+		"eslint": {
+			"command": "npx",
+			"args": ["@eslint/mcp@latest"],
+			"env": {}
+		}
+	}
+}

+ 2 - 0
app/components.d.ts

@@ -68,6 +68,7 @@ declare module 'vue' {
     AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
     AutoCertFormAutoCertForm: typeof import('./src/components/AutoCertForm/AutoCertForm.vue')['default']
     AutoCertFormDNSChallenge: typeof import('./src/components/AutoCertForm/DNSChallenge.vue')['default']
+    BaseEditorBaseEditor: typeof import('./src/components/BaseEditor/BaseEditor.vue')['default']
     BreadcrumbBreadcrumb: typeof import('./src/components/Breadcrumb/Breadcrumb.vue')['default']
     CertInfoCertInfo: typeof import('./src/components/CertInfo/CertInfo.vue')['default']
     ChartAreaChart: typeof import('./src/components/Chart/AreaChart.vue')['default']
@@ -78,6 +79,7 @@ declare module 'vue' {
     ChatGPTChatMessageInput: typeof import('./src/components/ChatGPT/ChatMessageInput.vue')['default']
     ChatGPTChatMessageList: typeof import('./src/components/ChatGPT/ChatMessageList.vue')['default']
     CodeEditorCodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
+    CommonEditorCommonEditor: typeof import('./src/components/CommonEditor/CommonEditor.vue')['default']
     ConfigHistoryConfigHistory: typeof import('./src/components/ConfigHistory/ConfigHistory.vue')['default']
     ConfigHistoryDiffViewer: typeof import('./src/components/ConfigHistory/DiffViewer.vue')['default']
     EnvGroupTabsEnvGroupTabs: typeof import('./src/components/EnvGroupTabs/EnvGroupTabs.vue')['default']

+ 87 - 0
app/src/components/BaseEditor/BaseEditor.vue

@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import { LoadingOutlined } from '@ant-design/icons-vue'
+
+// Generic editor layout with left and right panels
+interface BaseEditorProps {
+  colRightClass?: string
+  loading?: boolean
+}
+
+const props = withDefaults(defineProps<BaseEditorProps>(), {
+  colRightClass: 'col-right',
+})
+
+const indicator = h(LoadingOutlined, {
+  style: {
+    fontSize: '32px',
+  },
+  spin: true,
+})
+
+const route = useRoute()
+const loading = computed(() =>
+  props.loading || (import.meta.env.DEV && route.query.loading === 'true'),
+)
+</script>
+
+<template>
+  <ASpin class="h-full base-editor-spin" :spinning="loading" :indicator="indicator">
+    <ARow :gutter="{ xs: 0, sm: 16 }">
+      <ACol
+        :xs="24"
+        :sm="24"
+        :md="24"
+        :lg="16"
+        :xl="17"
+      >
+        <!-- Left panel content (main editor) -->
+        <slot name="left" />
+      </ACol>
+
+      <ACol
+        :class="props.colRightClass"
+        :xs="24"
+        :sm="24"
+        :md="24"
+        :lg="8"
+        :xl="7"
+      >
+        <!-- Right panel content (settings/configuration) -->
+        <slot name="right" />
+      </ACol>
+    </ARow>
+  </ASpin>
+</template>
+
+<style lang="less" scoped>
+.col-right {
+  position: sticky;
+  top: 78px;
+}
+
+:deep(.ant-card) {
+  box-shadow: unset;
+}
+
+:deep(.card-body) {
+  max-height: calc(100vh - 260px);
+  overflow-y: scroll;
+  padding: 0;
+}
+
+:deep(.ant-spin) {
+  background: rgba(255, 255, 255, 0.8);
+  backdrop-filter: blur(10px);
+  -webkit-backdrop-filter: blur(10px);
+  max-height: 100% !important;
+  border-radius: 8px;
+}
+</style>
+
+<style lang="less">
+.dark {
+  .base-editor-spin {
+    background: rgba(30, 30, 30, 0.8);
+  }
+}
+</style>

+ 4 - 0
app/src/components/BaseEditor/index.ts

@@ -0,0 +1,4 @@
+import BaseEditor from './BaseEditor.vue'
+
+export { BaseEditor }
+export default BaseEditor

+ 3 - 1
app/src/components/CertInfo/CertInfo.vue

@@ -44,5 +44,7 @@ const isValid = computed(() => dayjs().isAfter(props.cert?.not_before) && dayjs(
 </template>
 
 <style lang="less" scoped>
-
+:deep(.ant-card-body) {
+  padding: 12px !important;
+}
 </style>

+ 16 - 4
app/src/components/ChatGPT/ChatGPT.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import Icon from '@ant-design/icons-vue'
+import { useElementVisibility } from '@vueuse/core'
 import { storeToRefs } from 'pinia'
 import ChatGPT_logo from '@/assets/svg/ChatGPT_logo.svg?component'
 import { useSettingsStore } from '@/pinia'
@@ -16,7 +17,7 @@ const { language: current } = storeToRefs(useSettingsStore())
 
 // Use ChatGPT store
 const chatGPTStore = useChatGPTStore()
-const { messageListRef, loading, shouldShowStartButton } = storeToRefs(chatGPTStore)
+const { messageContainerRef, loading, shouldShowStartButton } = storeToRefs(chatGPTStore)
 
 // Initialize messages when path changes
 watch(() => props.path, async () => {
@@ -28,6 +29,14 @@ watch(() => props.path, async () => {
 async function handleSend() {
   await chatGPTStore.send(props.content, current.value)
 }
+
+const isVisible = useElementVisibility(messageContainerRef)
+
+watch(isVisible, visible => {
+  if (visible) {
+    chatGPTStore.scrollToBottom()
+  }
+}, { immediate: true })
 </script>
 
 <template>
@@ -49,17 +58,20 @@ async function handleSend() {
 
   <div
     v-else
-    class="chatgpt-container"
+    ref="messageContainerRef"
+    class="message-container"
   >
-    <ChatMessageList ref="messageListRef" />
+    <ChatMessageList />
 
     <ChatMessageInput />
   </div>
 </template>
 
 <style lang="less" scoped>
-.chatgpt-container {
+.message-container {
   margin: 0 auto;
   max-width: 800px;
+  max-height: calc(100vh - 260px);
+  overflow-y: auto;
 }
 </style>

+ 1 - 2
app/src/components/ChatGPT/ChatMessageInput.vue

@@ -59,7 +59,7 @@ const messagesLength = computed(() => messages.value?.length ?? 0)
   backdrop-filter: blur(10px);
   -webkit-backdrop-filter: blur(10px);
   padding: 16px;
-  border-top: 1px solid rgba(255, 255, 255, 0.2);
+  border-radius: 0 0 8px 8px;
 
   .control-btn {
     display: flex;
@@ -76,7 +76,6 @@ const messagesLength = computed(() => messages.value?.length ?? 0)
 .dark {
   .input-msg {
     background: rgba(30, 30, 30, 0.8);
-    border-top: 1px solid rgba(255, 255, 255, 0.1);
   }
 }
 </style>

+ 4 - 52
app/src/components/ChatGPT/ChatMessageList.vue

@@ -6,57 +6,6 @@ import ChatMessage from './ChatMessage.vue'
 const chatGPTStore = useChatGPTStore()
 const { messages, editingIdx, editValue, loading } = storeToRefs(chatGPTStore)
 
-const messageListRef = useTemplateRef('messageList')
-let scrollTimeoutId: number | null = null
-
-function scrollToBottom() {
-  // Debounce scroll operations for better performance
-  if (scrollTimeoutId) {
-    clearTimeout(scrollTimeoutId)
-  }
-
-  scrollTimeoutId = window.setTimeout(() => {
-    requestAnimationFrame(() => {
-      if (messageListRef.value) {
-        let element = messageListRef.value.parentElement
-        while (element) {
-          const style = window.getComputedStyle(element)
-          if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
-            element.scrollTo({
-              top: element.scrollHeight,
-              behavior: 'smooth',
-            })
-            return
-          }
-          element = element.parentElement
-        }
-      }
-    })
-  }, 50) // 50ms debounce
-}
-
-// Watch for messages changes and auto scroll - with debouncing
-watch(() => messages.value, () => {
-  scrollToBottom()
-}, { deep: true, flush: 'post' })
-
-// Auto scroll when messages are loaded
-onMounted(() => {
-  scrollToBottom()
-})
-
-// Clean up on unmount
-onUnmounted(() => {
-  if (scrollTimeoutId) {
-    clearTimeout(scrollTimeoutId)
-  }
-})
-
-// Expose scroll function for parent component
-defineExpose({
-  scrollToBottom,
-})
-
 function handleEdit(index: number) {
   chatGPTStore.startEdit(index)
 }
@@ -77,7 +26,7 @@ async function handleRegenerate(index: number) {
 </script>
 
 <template>
-  <div ref="messageList" class="message-list-container">
+  <div class="message-list-container">
     <AList
       class="chatgpt-log"
       item-layout="horizontal"
@@ -102,6 +51,9 @@ async function handleRegenerate(index: number) {
 
 <style lang="less" scoped>
 .message-list-container {
+  overflow-y: auto;
+  height: 100%;
+
   .chatgpt-log {
     :deep(.ant-list-item) {
       padding: 0 12px;

+ 10 - 4
app/src/components/ChatGPT/chatgpt.ts

@@ -1,4 +1,3 @@
-import type { ChatMessageList } from '.'
 import type { ChatComplicationMessage } from '@/api/openai'
 import { defineStore } from 'pinia'
 import { computed, nextTick, ref } from 'vue'
@@ -9,7 +8,7 @@ export const useChatGPTStore = defineStore('chatgpt', () => {
   // State
   const path = ref<string>('') // Path to the chat record file
   const messages = ref<ChatComplicationMessage[]>([])
-  const messageListRef = ref<InstanceType<typeof ChatMessageList>>()
+  const messageContainerRef = ref<HTMLDivElement>()
   const loading = ref(false)
   const editingIdx = ref(-1)
   const editValue = ref('')
@@ -154,7 +153,10 @@ export const useChatGPTStore = defineStore('chatgpt', () => {
 
   // scroll to bottom
   function scrollToBottom() {
-    messageListRef.value?.scrollToBottom()
+    messageContainerRef.value?.scrollTo({
+      top: messageContainerRef.value.scrollHeight,
+      behavior: 'smooth',
+    })
   }
 
   // Set streaming message index
@@ -234,6 +236,9 @@ export const useChatGPTStore = defineStore('chatgpt', () => {
     await request()
   }
 
+  watch(messages, () => {
+    scrollToBottom()
+  }, { immediate: true })
   // Return all state, getters, and actions
   return {
     // State
@@ -242,7 +247,7 @@ export const useChatGPTStore = defineStore('chatgpt', () => {
     editingIdx,
     editValue,
     askBuffer,
-    messageListRef,
+    messageContainerRef,
     streamingMessageIndex,
 
     // Getters
@@ -271,5 +276,6 @@ export const useChatGPTStore = defineStore('chatgpt', () => {
     request,
     send,
     regenerate,
+    scrollToBottom,
   }
 })

+ 12 - 17
app/src/components/NgxConfigEditor/NgxConfigEditor.vue

@@ -45,24 +45,19 @@ const activeKey = ref(['3'])
       >
         <NgxUpstream />
       </ACollapsePanel>
-      <ACollapsePanel
-        key="3"
-        header="Server"
-      >
-        <NgxServer :context>
-          <template
-            v-for="(_, key) in $slots"
-            :key="key"
-            #[key]="slotProps"
-          >
-            <slot
-              :name="key"
-              v-bind="slotProps"
-            />
-          </template>
-        </NgxServer>
-      </ACollapsePanel>
     </ACollapse>
+    <NgxServer :context>
+      <template
+        v-for="(_, key) in $slots"
+        :key="key"
+        #[key]="slotProps"
+      >
+        <slot
+          :name="key"
+          v-bind="slotProps"
+        />
+      </template>
+    </NgxServer>
   </div>
 </template>
 

+ 41 - 2
app/src/components/NgxConfigEditor/NgxServer.vue

@@ -22,6 +22,10 @@ const serversLength = computed(() => {
   return ngxConfig.value.servers?.length ?? 0
 })
 
+const hasServers = computed(() => {
+  return serversLength.value > 0
+})
+
 watch(serversLength, () => {
   if (curServerIdx.value >= serversLength.value)
     curServerIdx.value = serversLength.value - 1
@@ -38,6 +42,9 @@ watch(curServerIdx, () => {
 })
 
 function addServer() {
+  if (!ngxConfig.value.servers)
+    ngxConfig.value.servers = []
+
   ngxConfig.value.servers.push({
     comments: '',
     locations: [],
@@ -63,7 +70,32 @@ function removeServer(index: number) {
 <template>
   <div>
     <ContextHolder />
-    <ATabs v-model:active-key="curServerIdx">
+
+    <!-- Empty State -->
+    <div v-if="!hasServers" class="empty-state">
+      <AEmpty
+        :description="$gettext('No servers configured')"
+        class="mb-6"
+      >
+        <template #image>
+          <div class="text-6xl mb-4 text-gray-300">
+            🖥️
+          </div>
+        </template>
+      </AEmpty>
+      <div class="text-center">
+        <AButton
+          type="primary"
+          @click="addServer"
+        >
+          <PlusOutlined />
+          {{ $gettext('Add Server') }}
+        </AButton>
+      </div>
+    </div>
+
+    <!-- Server Tabs -->
+    <ATabs v-else v-model:active-key="curServerIdx">
       <ATabPane
         v-for="(v, k) in ngxConfig.servers"
         :key="k"
@@ -117,5 +149,12 @@ function removeServer(index: number) {
 </template>
 
 <style scoped lang="less">
-
+.empty-state {
+  @apply px-8 text-center;
+  min-height: 400px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
 </style>

+ 22 - 5
app/src/components/NgxConfigEditor/NgxUpstream.vue

@@ -167,14 +167,24 @@ function getAvailabilityResult(directive: NgxDirective) {
         </AButton>
       </template>
     </ATabs>
-    <div v-else>
-      <AEmpty />
-      <div class="flex justify-center">
+    <div v-else class="empty-state">
+      <AEmpty
+        :description="$gettext('No upstreams configured')"
+        class="mb-6"
+      >
+        <template #image>
+          <div class="text-6xl mb-4 text-gray-300">
+            ⚖️
+          </div>
+        </template>
+      </AEmpty>
+      <div class="text-center">
         <AButton
           type="primary"
           @click="addUpstream"
         >
-          {{ $gettext('Create') }}
+          <PlusOutlined />
+          {{ $gettext('Add Upstream') }}
         </AButton>
       </div>
     </div>
@@ -195,5 +205,12 @@ function getAvailabilityResult(directive: NgxDirective) {
 </template>
 
 <style scoped lang="less">
-
+.empty-state {
+  @apply px-8 text-center;
+  min-height: 400px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
 </style>

+ 1 - 1
app/src/version.json

@@ -1 +1 @@
-{"version":"2.1.0","build_id":1,"total_build":427}
+{"version":"2.1.0","build_id":2,"total_build":428}

+ 25 - 372
app/src/views/config/ConfigEditor.vue

@@ -1,380 +1,29 @@
 <script setup lang="ts">
-import type { Config } from '@/api/config'
-import { HistoryOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
-import { message } from 'ant-design-vue'
-import { trim, trimEnd } from 'lodash'
-import config from '@/api/config'
-import ngx from '@/api/ngx'
-import ChatGPT from '@/components/ChatGPT'
-import CodeEditor from '@/components/CodeEditor'
-import { ConfigHistory } from '@/components/ConfigHistory'
-import FooterToolBar from '@/components/FooterToolbar'
-import NodeSelector from '@/components/NodeSelector'
-import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
-import { formatDateTime } from '@/lib/helper'
-import { useSettingsStore } from '@/pinia'
-import ConfigName from '@/views/config/components/ConfigName.vue'
-import InspectConfig from '@/views/config/InspectConfig.vue'
+import BaseEditor from '@/components/BaseEditor'
+import ConfigLeftPanel from '@/views/config/components/ConfigLeftPanel.vue'
+import ConfigRightPanel from '@/views/config/components/ConfigRightPanel.vue'
 
-const settings = useSettingsStore()
-const route = useRoute()
-const router = useRouter()
-
-// eslint-disable-next-line vue/require-typed-ref
-const refForm = ref()
-const origName = ref('')
-const addMode = computed(() => !route.params.name)
-
-const showHistory = ref(false)
-const basePath = computed(() => {
-  if (route.query.basePath)
-    return trim(route?.query?.basePath?.toString(), '/')
-  else if (typeof route.params.name === 'object')
-    return (route.params.name as string[]).slice(0, -1).join('/')
-  else
-    return ''
-})
-
-const data = ref({
-  name: '',
-  content: '',
-  filepath: '',
-  sync_node_ids: [] as number[],
-  sync_overwrite: false,
-} as Config)
-
-const activeKey = ref(['basic', 'deploy', 'chatgpt'])
-const modifiedAt = ref('')
-const nginxConfigBase = ref('')
-
-const newPath = computed(() => {
-  // Decode and display after combining paths
-  const path = [nginxConfigBase.value, basePath.value, data.value.name]
-    .filter(v => v)
-    .join('/')
-  return path
-})
-
-const relativePath = computed(() => (basePath.value ? `${basePath.value}/${route.params.name}` : route.params.name) as string)
-const breadcrumbs = useBreadcrumbs()
-
-// Use Vue 3.4+ useTemplateRef for InspectConfig component
-const inspectConfigRef = useTemplateRef<InstanceType<typeof InspectConfig>>('inspectConfig')
-
-async function init() {
-  const { name } = route.params
-
-  data.value.name = name?.[name?.length - 1] ?? ''
-  origName.value = data.value.name
-  if (!addMode.value) {
-    config.getItem(relativePath.value).then(r => {
-      data.value = r
-      modifiedAt.value = r.modified_at
-
-      const filteredPath = trimEnd(data.value.filepath
-        .replaceAll(`${nginxConfigBase.value}/`, ''), data.value.name)
-        .split('/')
-        .filter(v => v)
-
-      // Build accumulated path to maintain original encoding state
-      let accumulatedPath = ''
-      const path = filteredPath.map((segment, index) => {
-        // Decode for display
-        const decodedSegment = decodeURIComponent(segment)
-
-        // Accumulated path keeps original encoding state
-        if (index === 0) {
-          accumulatedPath = segment
-        }
-        else {
-          accumulatedPath = `${accumulatedPath}/${segment}`
-        }
-
-        return {
-          name: 'Manage Configs',
-          translatedName: () => decodedSegment,
-          path: '/config',
-          query: {
-            dir: accumulatedPath,
-          },
-          hasChildren: false,
-        }
-      })
-
-      breadcrumbs.value = [{
-        name: 'Dashboard',
-        translatedName: () => $gettext('Dashboard'),
-        path: '/dashboard',
-        hasChildren: false,
-      }, {
-        name: 'Manage Configs',
-        translatedName: () => $gettext('Manage Configs'),
-        path: '/config',
-        hasChildren: false,
-      }, ...path, {
-        name: 'Edit Config',
-        translatedName: () => origName.value,
-        hasChildren: false,
-      }]
-    })
-  }
-  else {
-    data.value.content = ''
-    data.value.filepath = ''
-
-    const pathSegments = basePath.value
-      .split('/')
-      .filter(v => v)
-
-    // Build accumulated path
-    let accumulatedPath = ''
-    const path = pathSegments.map((segment, index) => {
-      // Decode for display
-      const decodedSegment = decodeURIComponent(segment)
-
-      // Accumulated path keeps original encoding state
-      if (index === 0) {
-        accumulatedPath = segment
-      }
-      else {
-        accumulatedPath = `${accumulatedPath}/${segment}`
-      }
-
-      return {
-        name: 'Manage Configs',
-        translatedName: () => decodedSegment,
-        path: '/config',
-        query: {
-          dir: accumulatedPath,
-        },
-        hasChildren: false,
-      }
-    })
-
-    breadcrumbs.value = [{
-      name: 'Dashboard',
-      translatedName: () => $gettext('Dashboard'),
-      path: '/dashboard',
-      hasChildren: false,
-    }, {
-      name: 'Manage Configs',
-      translatedName: () => $gettext('Manage Configs'),
-      path: '/config',
-      hasChildren: false,
-    }, ...path, {
-      name: 'Add Config',
-      translatedName: () => $gettext('Add Configuration'),
-      hasChildren: false,
-    }]
-  }
-}
-
-onMounted(async () => {
-  await config.get_base_path().then(r => {
-    nginxConfigBase.value = r.base_path
-  })
-  await init()
-})
-
-function save() {
-  refForm.value?.validate().then(() => {
-    const payload = {
-      name: addMode.value ? data.value.name : undefined,
-      base_dir: addMode.value ? basePath.value : undefined,
-      content: data.value.content,
-      sync_node_ids: data.value.sync_node_ids,
-      sync_overwrite: data.value.sync_overwrite,
-    }
-
-    const api = addMode.value
-      ? config.createItem(payload)
-      : config.updateItem(relativePath.value, payload)
-
-    api.then(r => {
-      data.value.content = r.content
-      message.success($gettext('Saved successfully'))
-
-      if (addMode.value) {
-        router.push({
-          path: `/config/${data.value.name}/edit`,
-          query: {
-            basePath: basePath.value,
-          },
-        })
-      }
-      else {
-        data.value = r
-        // Run test after saving to verify configuration
-        inspectConfigRef.value?.test()
-      }
-    })
-  })
-}
-
-function formatCode() {
-  ngx.format_code(data.value.content).then(r => {
-    data.value.content = r.content
-    message.success($gettext('Format successfully'))
-  })
-}
-
-function goBack() {
-  // Keep original path with encoding state
-  const encodedPath = basePath.value || ''
-
-  router.push({
-    path: '/config',
-    query: {
-      dir: encodedPath || undefined,
-    },
-  })
-}
-
-function openHistory() {
-  showHistory.value = true
-}
+// Use Vue 3.4+ useTemplateRef to get reference to left panel
+const leftPanelRef = useTemplateRef<InstanceType<typeof ConfigLeftPanel>>('leftPanel')
 </script>
 
 <template>
-  <ARow :gutter="16">
-    <ACol
-      :xs="24"
-      :sm="24"
-      :md="18"
-    >
-      <ACard :title="addMode ? $gettext('Add Configuration') : $gettext('Edit Configuration')">
-        <template #extra>
-          <AButton
-            v-if="!addMode && data.filepath"
-            type="link"
-            @click="openHistory"
-          >
-            <template #icon>
-              <HistoryOutlined />
-            </template>
-            {{ $gettext('History') }}
-          </AButton>
-        </template>
-
-        <InspectConfig
-          v-show="!addMode"
-          ref="inspectConfig"
-        />
-        <CodeEditor v-model:content="data.content" />
-        <FooterToolBar>
-          <ASpace>
-            <AButton @click="goBack">
-              {{ $gettext('Back') }}
-            </AButton>
-            <AButton @click="formatCode">
-              {{ $gettext('Format Code') }}
-            </AButton>
-            <AButton
-              type="primary"
-              @click="save"
-            >
-              {{ $gettext('Save') }}
-            </AButton>
-          </ASpace>
-        </FooterToolBar>
-      </ACard>
-    </ACol>
-
-    <ACol
-      :xs="24"
-      :sm="24"
-      :md="6"
-    >
-      <ACard class="col-right">
-        <ACollapse
-          v-model:active-key="activeKey"
-          ghost
-        >
-          <ACollapsePanel
-            key="basic"
-            :header="$gettext('Basic')"
-          >
-            <AForm
-              ref="refForm"
-              layout="vertical"
-              :model="data"
-              :rules="{
-                name: [
-                  { required: true, message: $gettext('Please input a filename') },
-                  { pattern: /^[^\\/]+$/, message: $gettext('Invalid filename') },
-                ],
-              }"
-            >
-              <AFormItem
-                name="name"
-                :label="$gettext('Name')"
-              >
-                <AInput v-if="addMode" v-model:value="data.name" />
-                <ConfigName v-else :name="data.name" :dir="data.dir" />
-              </AFormItem>
-              <AFormItem
-                v-if="!addMode"
-                :label="$gettext('Path')"
-              >
-                {{ decodeURIComponent(data.filepath) }}
-              </AFormItem>
-              <AFormItem
-                v-show="data.name !== origName"
-                :label="addMode ? $gettext('New Path') : $gettext('Changed Path')"
-                required
-              >
-                {{ decodeURIComponent(newPath) }}
-              </AFormItem>
-              <AFormItem
-                v-if="!addMode"
-                :label="$gettext('Updated at')"
-              >
-                {{ formatDateTime(modifiedAt) }}
-              </AFormItem>
-            </AForm>
-          </ACollapsePanel>
-          <ACollapsePanel
-            v-if="!settings.is_remote"
-            key="deploy"
-            :header="$gettext('Deploy')"
-          >
-            <NodeSelector
-              v-model:target="data.sync_node_ids"
-              hidden-local
-            />
-            <div class="node-deploy-control">
-              <div class="overwrite">
-                <ACheckbox v-model:checked="data.sync_overwrite">
-                  {{ $gettext('Overwrite') }}
-                </ACheckbox>
-                <ATooltip placement="bottom">
-                  <template #title>
-                    {{ $gettext('Overwrite exist file') }}
-                  </template>
-                  <InfoCircleOutlined />
-                </ATooltip>
-              </div>
-            </div>
-          </ACollapsePanel>
-          <ACollapsePanel
-            key="chatgpt"
-            header="ChatGPT"
-          >
-            <ChatGPT
-              :content="data.content"
-              :path="data.filepath"
-            />
-          </ACollapsePanel>
-        </ACollapse>
-      </ACard>
-    </ACol>
-
-    <ConfigHistory
-      v-model:visible="showHistory"
-      v-model:current-content="data.content"
-      :filepath="data.filepath"
-    />
-  </ARow>
+  <BaseEditor :loading="leftPanelRef?.loading">
+    <template #left>
+      <ConfigLeftPanel ref="leftPanel" />
+    </template>
+
+    <template #right>
+      <ConfigRightPanel
+        v-if="leftPanelRef"
+        v-model:data="leftPanelRef.data"
+        :add-mode="leftPanelRef.addMode || false"
+        :new-path="leftPanelRef.newPath || ''"
+        :modified-at="leftPanelRef.modifiedAt || ''"
+        :orig-name="leftPanelRef.origName || ''"
+      />
+    </template>
+  </BaseEditor>
 </template>
 
 <style lang="less" scoped>
@@ -410,4 +59,8 @@ function openHistory() {
   margin-top: 10px;
   align-items: center;
 }
+
+:deep(.ant-card-body) {
+  padding: 0;
+}
 </style>

+ 5 - 0
app/src/views/config/InspectConfig.vue

@@ -2,6 +2,10 @@
 import ngx from '@/api/ngx'
 import { logLevel } from '@/views/config/constants'
 
+defineProps<{
+  banner?: boolean
+}>()
+
 const data = ref({
   level: 0,
   message: '',
@@ -24,6 +28,7 @@ defineExpose({
   <div class="inspect-container">
     <AAlert
       v-if="data?.level <= logLevel.Info"
+      :banner
       :message="$gettext('Configuration file is test successful')"
       type="success"
       show-icon

+ 306 - 0
app/src/views/config/components/ConfigLeftPanel.vue

@@ -0,0 +1,306 @@
+<script setup lang="ts">
+import type { Config } from '@/api/config'
+import { HistoryOutlined } from '@ant-design/icons-vue'
+import { message } from 'ant-design-vue'
+import { trim, trimEnd } from 'lodash'
+import config from '@/api/config'
+import ngx from '@/api/ngx'
+import CodeEditor from '@/components/CodeEditor'
+import { ConfigHistory } from '@/components/ConfigHistory'
+import FooterToolbar from '@/components/FooterToolbar'
+import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
+import InspectConfig from '@/views/config/InspectConfig.vue'
+
+const route = useRoute()
+const router = useRouter()
+
+// eslint-disable-next-line vue/require-typed-ref
+const refForm = ref()
+const origName = ref('')
+const addMode = computed(() => !route.params.name)
+const showHistory = ref(false)
+
+const basePath = computed(() => {
+  if (route.query.basePath)
+    return trim(route?.query?.basePath?.toString(), '/')
+  else if (typeof route.params.name === 'object')
+    return (route.params.name as string[]).slice(0, -1).join('/')
+  else
+    return ''
+})
+
+const data = ref({
+  name: '',
+  content: '',
+  filepath: '',
+  sync_node_ids: [] as number[],
+  sync_overwrite: false,
+} as Config)
+
+const modifiedAt = ref('')
+const nginxConfigBase = ref('')
+const loading = ref(true)
+
+const newPath = computed(() => {
+  // Decode and display after combining paths
+  const path = [nginxConfigBase.value, basePath.value, data.value.name]
+    .filter(v => v)
+    .join('/')
+  return path
+})
+
+const relativePath = computed(() => (basePath.value ? `${basePath.value}/${route.params.name}` : route.params.name) as string)
+const breadcrumbs = useBreadcrumbs()
+
+// Use Vue 3.4+ useTemplateRef for InspectConfig component
+const inspectConfigRef = useTemplateRef<InstanceType<typeof InspectConfig>>('inspectConfig')
+
+// Expose data for right panel
+defineExpose({
+  data,
+  refForm,
+  addMode,
+  newPath,
+  modifiedAt,
+  origName,
+  loading,
+})
+
+async function init() {
+  const { name } = route.params
+
+  data.value.name = name?.[name?.length - 1] ?? ''
+  origName.value = data.value.name
+
+  if (!addMode.value) {
+    config.getItem(relativePath.value).then(r => {
+      data.value = r
+      modifiedAt.value = r.modified_at
+
+      const filteredPath = trimEnd(data.value.filepath
+        .replaceAll(`${nginxConfigBase.value}/`, ''), data.value.name)
+        .split('/')
+        .filter(v => v)
+
+      // Build accumulated path to maintain original encoding state
+      let accumulatedPath = ''
+      const path = filteredPath.map((segment, index) => {
+        // Decode for display
+        const decodedSegment = decodeURIComponent(segment)
+
+        // Accumulated path keeps original encoding state
+        if (index === 0) {
+          accumulatedPath = segment
+        }
+        else {
+          accumulatedPath = `${accumulatedPath}/${segment}`
+        }
+
+        return {
+          name: 'Manage Configs',
+          translatedName: () => decodedSegment,
+          path: '/config',
+          query: {
+            dir: accumulatedPath,
+          },
+          hasChildren: false,
+        }
+      })
+
+      breadcrumbs.value = [{
+        name: 'Dashboard',
+        translatedName: () => $gettext('Dashboard'),
+        path: '/dashboard',
+        hasChildren: false,
+      }, {
+        name: 'Manage Configs',
+        translatedName: () => $gettext('Manage Configs'),
+        path: '/config',
+        hasChildren: false,
+      }, ...path, {
+        name: 'Edit Config',
+        translatedName: () => origName.value,
+        hasChildren: false,
+      }]
+    })
+  }
+  else {
+    data.value.content = ''
+    data.value.filepath = ''
+
+    const pathSegments = basePath.value
+      .split('/')
+      .filter(v => v)
+
+    // Build accumulated path
+    let accumulatedPath = ''
+    const path = pathSegments.map((segment, index) => {
+      // Decode for display
+      const decodedSegment = decodeURIComponent(segment)
+
+      // Accumulated path keeps original encoding state
+      if (index === 0) {
+        accumulatedPath = segment
+      }
+      else {
+        accumulatedPath = `${accumulatedPath}/${segment}`
+      }
+
+      return {
+        name: 'Manage Configs',
+        translatedName: () => decodedSegment,
+        path: '/config',
+        query: {
+          dir: accumulatedPath,
+        },
+        hasChildren: false,
+      }
+    })
+
+    breadcrumbs.value = [{
+      name: 'Dashboard',
+      translatedName: () => $gettext('Dashboard'),
+      path: '/dashboard',
+      hasChildren: false,
+    }, {
+      name: 'Manage Configs',
+      translatedName: () => $gettext('Manage Configs'),
+      path: '/config',
+      hasChildren: false,
+    }, ...path, {
+      name: 'Add Config',
+      translatedName: () => $gettext('Add Configuration'),
+      hasChildren: false,
+    }]
+  }
+  loading.value = false
+}
+
+onMounted(async () => {
+  await config.get_base_path().then(r => {
+    nginxConfigBase.value = r.base_path
+  })
+  await init()
+})
+
+function save() {
+  refForm.value?.validate().then(() => {
+    const payload = {
+      name: addMode.value ? data.value.name : undefined,
+      base_dir: addMode.value ? basePath.value : undefined,
+      content: data.value.content,
+      sync_node_ids: data.value.sync_node_ids,
+      sync_overwrite: data.value.sync_overwrite,
+    }
+
+    const api = addMode.value
+      ? config.createItem(payload)
+      : config.updateItem(relativePath.value, payload)
+
+    api.then(r => {
+      data.value.content = r.content
+      message.success($gettext('Saved successfully'))
+
+      if (addMode.value) {
+        router.push({
+          path: `/config/${data.value.name}/edit`,
+          query: {
+            basePath: basePath.value,
+          },
+        })
+      }
+      else {
+        data.value = r
+        // Run test after saving to verify configuration
+        inspectConfigRef.value?.test()
+      }
+    })
+  })
+}
+
+function formatCode() {
+  ngx.format_code(data.value.content).then(r => {
+    data.value.content = r.content
+    message.success($gettext('Format successfully'))
+  })
+}
+
+function goBack() {
+  // Keep orignal path with encoding state
+  const encodedPath = basePath.value || ''
+
+  router.push({
+    path: '/config',
+    query: {
+      dir: encodedPath || undefined,
+    },
+  })
+}
+
+function openHistory() {
+  showHistory.value = true
+}
+</script>
+
+<template>
+  <ACard
+    :title="addMode ? $gettext('Add Configuration') : $gettext('Edit Configuration')"
+    :bordered="false" :loading
+  >
+    <template #extra>
+      <AButton
+        v-if="!addMode && data.filepath"
+        type="link"
+        @click="openHistory"
+      >
+        <template #icon>
+          <HistoryOutlined />
+        </template>
+        {{ $gettext('History') }}
+      </AButton>
+    </template>
+
+    <InspectConfig
+      v-show="!addMode"
+      ref="inspectConfig"
+      class="mb-0!"
+      banner
+    />
+
+    <CodeEditor
+      v-model:content="data.content"
+      no-border-radius
+    />
+
+    <FooterToolbar>
+      <ASpace>
+        <AButton @click="goBack">
+          {{ $gettext('Back') }}
+        </AButton>
+        <AButton @click="formatCode">
+          {{ $gettext('Format Code') }}
+        </AButton>
+        <AButton
+          type="primary"
+          @click="save"
+        >
+          {{ $gettext('Save') }}
+        </AButton>
+      </ASpace>
+    </FooterToolbar>
+
+    <ConfigHistory
+      v-model:visible="showHistory"
+      v-model:current-content="data.content"
+      :filepath="data.filepath"
+    />
+  </ACard>
+</template>
+
+<style lang="less" scoped>
+:deep(.ant-card-body) {
+  max-height: calc(100vh - 260px);
+  overflow-y: scroll;
+  padding: 0;
+}
+</style>

+ 24 - 0
app/src/views/config/components/ConfigRightPanel.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import type { Config } from '@/api/config'
+import ConfigRightPanel from './ConfigRightPanel/ConfigRightPanel.vue'
+
+interface ConfigRightPanelProps {
+  addMode: boolean
+  newPath: string
+  modifiedAt: string
+  origName: string
+}
+
+const props = defineProps<ConfigRightPanelProps>()
+const data = defineModel<Config>('data', { required: true })
+</script>
+
+<template>
+  <ConfigRightPanel
+    v-model:data="data"
+    :add-mode="props.addMode"
+    :new-path="props.newPath"
+    :modified-at="props.modifiedAt"
+    :orig-name="props.origName"
+  />
+</template>

+ 66 - 0
app/src/views/config/components/ConfigRightPanel/Basic.vue

@@ -0,0 +1,66 @@
+<script setup lang="ts">
+import type { Config } from '@/api/config'
+import { formatDateTime } from '@/lib/helper'
+import { useSettingsStore } from '@/pinia'
+import ConfigName from '@/views/config/components/ConfigName.vue'
+import Deploy from './Deploy.vue'
+
+interface BasicProps {
+  addMode: boolean
+  newPath: string
+  modifiedAt: string
+  origName: string
+}
+
+const props = defineProps<BasicProps>()
+const data = defineModel<Config>('data', { required: true })
+const settings = useSettingsStore()
+</script>
+
+<template>
+  <div class="px-6">
+    <AForm
+      layout="vertical"
+      :model="data"
+      :rules="{
+        name: [
+          { required: true, message: $gettext('Please input a filename') },
+          { pattern: /^[^\\/]+$/, message: $gettext('Invalid filename') },
+        ],
+      }"
+    >
+      <AFormItem
+        name="name"
+        :label="$gettext('Name')"
+      >
+        <AInput v-if="props.addMode" v-model:value="data.name" />
+        <ConfigName v-else :name="data.name" :dir="data.dir" />
+      </AFormItem>
+      <AFormItem
+        v-if="!props.addMode"
+        :label="$gettext('Path')"
+      >
+        {{ decodeURIComponent(data.filepath) }}
+      </AFormItem>
+      <AFormItem
+        v-show="data.name !== props.origName"
+        :label="props.addMode ? $gettext('New Path') : $gettext('Changed Path')"
+        required
+      >
+        {{ decodeURIComponent(props.newPath) }}
+      </AFormItem>
+      <AFormItem
+        v-if="!props.addMode"
+        :label="$gettext('Updated at')"
+      >
+        {{ formatDateTime(props.modifiedAt) }}
+      </AFormItem>
+      <AFormItem
+        v-if="!settings.is_remote"
+        :label="$gettext('Deploy')"
+      >
+        <Deploy v-model:data="data" />
+      </AFormItem>
+    </AForm>
+  </div>
+</template>

+ 15 - 0
app/src/views/config/components/ConfigRightPanel/Chat.vue

@@ -0,0 +1,15 @@
+<script setup lang="ts">
+import type { Config } from '@/api/config'
+import ChatGPT from '@/components/ChatGPT'
+
+const data = defineModel<Config>('data', { required: true })
+</script>
+
+<template>
+  <div class="mt--6">
+    <ChatGPT
+      :content="data.content"
+      :path="data.filepath"
+    />
+  </div>
+</template>

+ 73 - 0
app/src/views/config/components/ConfigRightPanel/ConfigRightPanel.vue

@@ -0,0 +1,73 @@
+<script setup lang="ts">
+import type { Config } from '@/api/config'
+import Basic from './Basic.vue'
+import Chat from './Chat.vue'
+
+interface ConfigRightPanelProps {
+  addMode: boolean
+  newPath: string
+  modifiedAt: string
+  origName: string
+}
+
+const props = defineProps<ConfigRightPanelProps>()
+const data = defineModel<Config>('data', { required: true })
+
+const activeKey = ref('basic')
+</script>
+
+<template>
+  <div class="right-settings-container">
+    <ACard
+      class="right-settings"
+      :bordered="false"
+    >
+      <ATabs
+        v-model:active-key="activeKey"
+        size="small"
+      >
+        <ATabPane key="basic" :tab="$gettext('Basic')">
+          <Basic
+            v-model:data="data"
+            :add-mode="props.addMode"
+            :new-path="props.newPath"
+            :modified-at="props.modifiedAt"
+            :orig-name="props.origName"
+          />
+        </ATabPane>
+        <ATabPane key="chat" :tab="$gettext('Chat')">
+          <Chat v-model:data="data" />
+        </ATabPane>
+      </ATabs>
+    </ACard>
+  </div>
+</template>
+
+<style scoped lang="less">
+.right-settings-container {
+  position: relative;
+
+  .right-settings {
+    position: relative;
+  }
+
+  :deep(.ant-tabs-nav) {
+    margin: 0;
+    height: 55px;
+    padding: 0 24px;
+  }
+}
+
+:deep(.ant-tabs-content) {
+  padding-top: 24px;
+  overflow-y: auto;
+}
+
+:deep(.ant-card) {
+  box-shadow: unset;
+
+  .ant-tabs-content {
+    max-height: calc(100vh - 260px);
+  }
+}
+</style>

+ 46 - 0
app/src/views/config/components/ConfigRightPanel/Deploy.vue

@@ -0,0 +1,46 @@
+<script setup lang="ts">
+import type { Config } from '@/api/config'
+import { InfoCircleOutlined } from '@ant-design/icons-vue'
+import NodeSelector from '@/components/NodeSelector'
+
+const data = defineModel<Config>('data', { required: true })
+</script>
+
+<template>
+  <div>
+    <NodeSelector
+      v-model:target="data.sync_node_ids"
+      hidden-local
+    />
+    <div class="node-deploy-control">
+      <div class="overwrite">
+        <ACheckbox v-model:checked="data.sync_overwrite">
+          {{ $gettext('Overwrite') }}
+        </ACheckbox>
+        <ATooltip placement="bottom">
+          <template #title>
+            {{ $gettext('Overwrite exist file') }}
+          </template>
+          <InfoCircleOutlined />
+        </ATooltip>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="less" scoped>
+.overwrite {
+  margin-right: 15px;
+
+  span {
+    color: #9b9b9b;
+  }
+}
+
+.node-deploy-control {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 10px;
+  align-items: center;
+}
+</style>

+ 3 - 0
app/src/views/config/components/ConfigRightPanel/index.ts

@@ -0,0 +1,3 @@
+import ConfigRightPanel from './ConfigRightPanel.vue'
+
+export default ConfigRightPanel

+ 4 - 0
app/src/views/config/components/index.ts

@@ -0,0 +1,4 @@
+import ConfigLeftPanel from './ConfigLeftPanel.vue'
+import ConfigRightPanel from './ConfigRightPanel.vue'
+
+export { ConfigLeftPanel, ConfigRightPanel }

+ 2 - 3
app/src/views/config/configColumns.tsx

@@ -1,5 +1,4 @@
 import type { CustomRenderArgs, StdTableColumn } from '@uozi-admin/curd'
-import { FileFilled, FolderFilled } from '@ant-design/icons-vue'
 import { datetimeRender } from '@uozi-admin/curd'
 
 const configColumns: StdTableColumn[] = [{
@@ -15,8 +14,8 @@ const configColumns: StdTableColumn[] = [{
       return (
         <div class="mr-2 text-truegray-5">
           {isDir
-            ? <FolderFilled />
-            : <FileFilled />}
+            ? <div class="i-tabler-folder-filled" />
+            : <div class="i-tabler-file" />}
         </div>
       )
     }

+ 13 - 23
app/src/views/site/site_edit/SiteEdit.vue

@@ -1,34 +1,24 @@
 <script setup lang="ts">
+import BaseEditor from '@/components/BaseEditor'
 import RightSettings from '@/views/site/site_edit/components/RightPanel/RightPanel.vue'
 import SiteEditor from '@/views/site/site_edit/components/SiteEditor'
+import { useSiteEditorStore } from './components/SiteEditor/store'
+
+const editorStore = useSiteEditorStore()
+const { loading } = storeToRefs(editorStore)
 </script>
 
 <template>
   <div class="site-container">
-    <ARow :gutter="{ xs: 0, sm: 16 }">
-      <ACol
-        :xs="24"
-        :sm="24"
-        :md="24"
-        :lg="16"
-        :xl="17"
-      >
-        <div>
-          <SiteEditor />
-        </div>
-      </ACol>
+    <BaseEditor :loading>
+      <template #left>
+        <SiteEditor />
+      </template>
 
-      <ACol
-        class="col-right"
-        :xs="24"
-        :sm="24"
-        :md="24"
-        :lg="8"
-        :xl="7"
-      >
+      <template #right>
         <RightSettings />
-      </ACol>
-    </ARow>
+      </template>
+    </BaseEditor>
   </div>
 </template>
 
@@ -36,7 +26,7 @@ import SiteEditor from '@/views/site/site_edit/components/SiteEditor'
 :deep(.ant-card) {
   box-shadow: unset;
 
-  .card-body, .ant-tabs-content {
+  .ant-tabs-content {
     max-height: calc(100vh - 260px);
   }
 }

+ 2 - 2
app/src/views/site/site_edit/components/EnableTLS/EnableTLS.vue

@@ -7,7 +7,7 @@ import { useSiteEditorStore } from '@/views/site/site_edit/components/SiteEditor
 const [modal, ContextHolder] = Modal.useModal()
 
 const editorStore = useSiteEditorStore()
-const { ngxConfig, curServerIdx, curDirectivesMap } = storeToRefs(editorStore)
+const { ngxConfig, curServerIdx, curDirectivesMap, hasServers } = storeToRefs(editorStore)
 
 function confirmChangeTLS(status: CheckedType) {
   modal.confirm({
@@ -107,7 +107,7 @@ const supportSSL = computed(() => {
 </script>
 
 <template>
-  <div>
+  <div v-if="hasServers">
     <ContextHolder />
 
     <AFormItem

+ 1 - 1
app/src/views/site/site_edit/components/RightPanel/Chat.vue

@@ -10,7 +10,7 @@ const {
 </script>
 
 <template>
-  <div class="mt--4">
+  <div class="mt--6">
     <ChatGPT
       :content="configText"
       :path="filepath"

+ 7 - 16
app/src/views/site/site_edit/components/RightPanel/RightPanel.vue

@@ -8,7 +8,7 @@ import ConfigTemplate from './ConfigTemplate.vue'
 const activeKey = ref('basic')
 
 const editorStore = useSiteEditorStore()
-const { advanceMode } = storeToRefs(editorStore)
+const { advanceMode, loading } = storeToRefs(editorStore)
 
 watch(advanceMode, val => {
   if (val) {
@@ -22,10 +22,10 @@ watch(advanceMode, val => {
     <ACard
       class="right-settings"
       :bordered="false"
+      :loading
     >
       <ATabs
         v-model:active-key="activeKey"
-        class="mb-24px"
         size="small"
       >
         <ATabPane key="basic" :tab="$gettext('Basic')">
@@ -54,31 +54,22 @@ watch(advanceMode, val => {
   position: relative;
 
   .right-settings {
-    overflow-y: auto;
     position: relative;
   }
 
   :deep(.ant-card-body) {
     padding: 0;
-    position: relative;
   }
 
   :deep(.ant-tabs-nav) {
     margin: 0;
-  }
-}
-
-:deep(.ant-tabs) {
-  margin-bottom: 0;
-
-  .ant-tabs-nav-wrap {
-    height: 55px;
     padding: 0 24px;
+    height: 55px;
   }
+}
 
-  .ant-tabs-content {
-    padding-top: 24px;
-    overflow-y: auto;
-  }
+:deep(.ant-tabs-content) {
+  padding-top: 24px;
+  overflow-y: auto;
 }
 </style>

+ 6 - 5
app/src/views/site/site_edit/components/SiteEditor/SiteEditor.vue

@@ -46,7 +46,7 @@ async function save() {
 </script>
 
 <template>
-  <ACard class="mb-4 site-edit-container" :bordered="false">
+  <ACard class="site-edit-container" :bordered="false">
     <template #title>
       <span style="margin-right: 10px">{{ $gettext('Edit %{n}', { n: name }) }}</span>
       <ATag
@@ -111,6 +111,7 @@ async function save() {
             class="parse-error-alert-wrapper"
           >
             <AAlert
+              banner
               :message="$gettext('Nginx Configuration Parse Error')"
               :description="parseErrorMessage"
               type="error"
@@ -189,10 +190,6 @@ async function save() {
   padding: 24px 0;
 }
 
-.parse-error-alert-wrapper {
-  margin-bottom: 20px;
-}
-
 .site-edit-container {
   height: 100%;
   :deep(.ant-card-body) {
@@ -219,4 +216,8 @@ async function save() {
   transform: translateX(10px);
   opacity: 0;
 }
+
+:deep(.tab-content) {
+  padding-bottom: 24px;
+}
 </style>

+ 5 - 0
app/src/views/site/site_edit/components/SiteEditor/store.ts

@@ -31,6 +31,10 @@ export const useSiteEditorStore = defineStore('siteEditor', () => {
     },
   })
 
+  const hasServers = computed(() => {
+    return ngxConfig.value.servers && ngxConfig.value.servers.length > 0
+  })
+
   async function init(_name: string) {
     loading.value = true
     await nextTick()
@@ -168,6 +172,7 @@ export const useSiteEditorStore = defineStore('siteEditor', () => {
     configText,
     issuingCert,
     curSupportSSL,
+    hasServers,
     init,
     save,
     handleModeChange,

+ 16 - 35
app/src/views/stream/StreamEdit.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import BaseEditor from '@/components/BaseEditor'
 import RightSettings from '@/views/stream/components/RightPanel'
 import StreamEditor from '@/views/stream/components/StreamEditor.vue'
 import { useStreamEditorStore } from '@/views/stream/store'
@@ -8,6 +9,7 @@ const route = useRoute()
 const name = computed(() => decodeURIComponent(route.params?.name?.toString() ?? ''))
 
 const store = useStreamEditorStore()
+const { loading } = storeToRefs(store)
 
 onMounted(() => {
   store.init(name.value)
@@ -15,42 +17,19 @@ onMounted(() => {
 </script>
 
 <template>
-  <ARow :gutter="{ xs: 0, sm: 16 }">
-    <ACol
-      :xs="24"
-      :sm="24"
-      :md="24"
-      :lg="16"
-      :xl="17"
-    >
-      <div>
-        <StreamEditor />
-      </div>
-    </ACol>
+  <BaseEditor :loading>
+    <template #left>
+      <StreamEditor />
+    </template>
 
-    <ACol
-      class="col-right"
-      :xs="24"
-      :sm="24"
-      :md="24"
-      :lg="8"
-      :xl="7"
-    >
+    <template #right>
       <RightSettings />
-    </ACol>
-  </ARow>
+    </template>
+  </BaseEditor>
 </template>
 
 <style lang="less" scoped>
-.col-right {
-  position: sticky;
-  top: 78px;
-}
-
-:deep(.ant-card) {
-  box-shadow: unset;
-}
-
+// Animation styles for mode switching
 .slide-fade-enter-active {
   transition: all .3s ease-in-out;
 }
@@ -59,17 +38,19 @@ onMounted(() => {
   transition: all .3s cubic-bezier(1.0, 0.5, 0.8, 1.0);
 }
 
-.slide-fade-enter-from, .slide-fade-enter-to, .slide-fade-leave-to
-  /* .slide-fade-leave-active for below version 2.1.8 */ {
+.slide-fade-enter-from, .slide-fade-enter-to, .slide-fade-leave-to {
   transform: translateX(10px);
   opacity: 0;
 }
 
+// Stream-specific styles
 .directive-params-wrapper {
   margin: 10px 0;
 }
 
-.tab-content {
-  padding: 10px;
+:deep(.ant-card-body) {
+  max-height: 100%;
+  overflow-y: scroll;
+  padding: 0;
 }
 </style>

+ 1 - 43
app/src/views/stream/components/RightPanel/Basic.vue

@@ -1,13 +1,9 @@
 <script setup lang="ts">
-import type { SiteStatus } from '@/api/site'
 import { InfoCircleOutlined } from '@ant-design/icons-vue'
 import { StdSelector } from '@uozi-admin/curd'
-import { message, Modal } from 'ant-design-vue'
 import { storeToRefs } from 'pinia'
 import envGroup from '@/api/env_group'
-import stream from '@/api/stream'
 import NodeSelector from '@/components/NodeSelector'
-import { ConfigStatus } from '@/constants'
 import { formatDateTime } from '@/lib/helper'
 import { useSettingsStore } from '@/pinia'
 import envGroupColumns from '@/views/environments/group/columns'
@@ -19,53 +15,15 @@ const settings = useSettingsStore()
 const store = useStreamEditorStore()
 const { name, status, data } = storeToRefs(store)
 
-const [modal, ContextHolder] = Modal.useModal()
 const showSync = computed(() => !settings.is_remote)
-
-function enable() {
-  stream.enable(name.value).then(() => {
-    message.success($gettext('Enabled successfully'))
-    status.value = ConfigStatus.Enabled
-  }).catch(r => {
-    message.error($gettext('Failed to enable %{msg}', { msg: r.message ?? '' }), 10)
-  })
-}
-
-function disable() {
-  stream.disable(name.value).then(() => {
-    message.success($gettext('Disabled successfully'))
-    status.value = ConfigStatus.Disabled
-  }).catch(r => {
-    message.error($gettext('Failed to disable %{msg}', { msg: r.message ?? '' }))
-  })
-}
-
-function onChangeEnabled({ status }: { status: SiteStatus }) {
-  modal.confirm({
-    title: status === ConfigStatus.Enabled ? $gettext('Do you want to enable this stream?') : $gettext('Do you want to disable this stream?'),
-    mask: false,
-    centered: true,
-    okText: $gettext('OK'),
-    cancelText: $gettext('Cancel'),
-    async onOk() {
-      if (status === ConfigStatus.Enabled)
-        enable()
-      else
-        disable()
-    },
-  })
-}
 </script>
 
 <template>
-  <div>
-    <ContextHolder />
-
+  <div class="px-6">
     <AFormItem :label="$gettext('Enabled')">
       <StreamStatusSelect
         v-model:status="status"
         :stream-name="name"
-        @status-changed="onChangeEnabled"
       />
     </AFormItem>
 

+ 1 - 1
app/src/views/stream/components/RightPanel/Chat.vue

@@ -7,7 +7,7 @@ const { configText, filepath } = storeToRefs(store)
 </script>
 
 <template>
-  <div>
+  <div class="mt--6">
     <ChatGPT
       :content="configText"
       :path="filepath"

+ 3 - 9
app/src/views/stream/components/RightPanel/RightPanel.vue

@@ -14,7 +14,6 @@ const activeKey = ref('basic')
     >
       <ATabs
         v-model:active-key="activeKey"
-        class="mb-24px"
         size="small"
       >
         <ATabPane key="basic" :tab="$gettext('Basic')">
@@ -36,23 +35,18 @@ const activeKey = ref('basic')
   position: relative;
 
   .right-settings {
-    max-height: calc(100vh - 323px);
-    overflow-y: scroll;
     position: relative;
   }
 
-  :deep(.ant-card-body) {
-    padding: 19.5px 24px;
-  }
-
   :deep(.ant-tabs-nav) {
     margin: 0;
+    height: 55px;
+    padding: 0 24px;
   }
 }
 
 :deep(.ant-tabs-content) {
   padding-top: 24px;
-  max-height: calc(100vh - 425px);
-  overflow-y: scroll;
+  overflow-y: auto;
 }
 </style>

+ 105 - 96
app/src/views/stream/components/StreamEditor.vue

@@ -1,5 +1,5 @@
 <script lang="ts" setup>
-import { HistoryOutlined } from '@ant-design/icons-vue'
+import { HistoryOutlined, LoadingOutlined } from '@ant-design/icons-vue'
 import CodeEditor from '@/components/CodeEditor'
 import ConfigHistory from '@/components/ConfigHistory'
 import FooterToolBar from '@/components/FooterToolbar'
@@ -10,112 +10,117 @@ import { useStreamEditorStore } from '../store'
 const router = useRouter()
 
 const store = useStreamEditorStore()
-const { name, status, configText, filepath, saving, parseErrorStatus, parseErrorMessage, advanceMode } = storeToRefs(store)
+const { name, status, configText, filepath, saving, parseErrorStatus, parseErrorMessage, advanceMode, loading } = storeToRefs(store)
 const showHistory = ref(false)
 </script>
 
 <template>
-  <ACard class="mb-4" :bordered="false">
-    <template #title>
-      <span style="margin-right: 10px">{{ $gettext('Edit %{n}', { n: name }) }}</span>
-      <ATag
-        v-if="status === ConfigStatus.Enabled"
-        color="blue"
-      >
-        {{ $gettext('Enabled') }}
-      </ATag>
-      <ATag
-        v-else
-        color="orange"
-      >
-        {{ $gettext('Disabled') }}
-      </ATag>
-    </template>
-    <template #extra>
-      <ASpace>
-        <AButton
-          v-if="filepath"
-          type="link"
-          @click="showHistory = true"
+  <ASpin :spinning="loading" :indicator="LoadingOutlined">
+    <ACard class="mb-4" :bordered="false">
+      <template #title>
+        <span style="margin-right: 10px">{{ $gettext('Edit %{n}', { n: name }) }}</span>
+        <ATag
+          v-if="status === ConfigStatus.Enabled"
+          color="blue"
         >
-          <template #icon>
-            <HistoryOutlined />
-          </template>
-          {{ $gettext('History') }}
-        </AButton>
-        <div class="mode-switch">
-          <div class="switch">
-            <ASwitch
-              size="small"
-              :disabled="parseErrorStatus"
-              :checked="advanceMode"
-              @change="store.handleModeChange"
-            />
+          {{ $gettext('Enabled') }}
+        </ATag>
+        <ATag
+          v-else
+          color="orange"
+        >
+          {{ $gettext('Disabled') }}
+        </ATag>
+      </template>
+      <template #extra>
+        <ASpace>
+          <AButton
+            v-if="filepath"
+            type="link"
+            @click="showHistory = true"
+          >
+            <template #icon>
+              <HistoryOutlined />
+            </template>
+            {{ $gettext('History') }}
+          </AButton>
+          <div class="mode-switch">
+            <div class="switch">
+              <ASwitch
+                size="small"
+                :disabled="parseErrorStatus"
+                :checked="advanceMode"
+                @change="store.handleModeChange"
+              />
+            </div>
+            <template v-if="advanceMode">
+              <div>{{ $gettext('Advance Mode') }}</div>
+            </template>
+            <template v-else>
+              <div>{{ $gettext('Basic Mode') }}</div>
+            </template>
           </div>
-          <template v-if="advanceMode">
-            <div>{{ $gettext('Advance Mode') }}</div>
-          </template>
-          <template v-else>
-            <div>{{ $gettext('Basic Mode') }}</div>
-          </template>
-        </div>
-      </ASpace>
-    </template>
+        </ASpace>
+      </template>
 
-    <Transition name="slide-fade">
-      <div
-        v-if="advanceMode"
-        key="advance"
-      >
-        <div
-          v-if="parseErrorStatus"
-          class="mb-4"
-        >
-          <AAlert
-            :message="$gettext('Nginx Configuration Parse Error')"
-            :description="parseErrorMessage"
-            type="error"
-            show-icon
-          />
-        </div>
-        <div>
-          <CodeEditor v-model:content="configText" />
-        </div>
-      </div>
+      <div class="card-body">
+        <Transition name="slide-fade">
+          <div
+            v-if="advanceMode"
+            key="advance"
+          >
+            <div v-if="parseErrorStatus">
+              <AAlert
+                banner
+                :message="$gettext('Nginx Configuration Parse Error')"
+                :description="parseErrorMessage"
+                type="error"
+                show-icon
+              />
+            </div>
+            <div>
+              <CodeEditor
+                v-model:content="configText"
+                no-border-radius
+              />
+            </div>
+          </div>
 
-      <div
-        v-else
-        key="basic"
-        class="domain-edit-container"
-      >
-        <NgxConfigEditor
-          :enabled="status === ConfigStatus.Enabled"
-          context="stream"
-        />
+          <div
+            v-else
+            key="basic"
+            class="domain-edit-container"
+          >
+            <NgxConfigEditor
+              :enabled="status === ConfigStatus.Enabled"
+              context="stream"
+            />
+          </div>
+        </Transition>
       </div>
-    </Transition>
 
-    <ConfigHistory
-      v-model:visible="showHistory"
-      v-model:current-content="configText"
-      :filepath="filepath"
-    />
+      <ConfigHistory
+        v-model:visible="showHistory"
+        v-model:current-content="configText"
+        :filepath="filepath"
+      />
 
-    <FooterToolBar>
-      <ASpace>
-        <AButton @click="router.push('/streams')">
-          {{ $gettext('Back') }}
-        </AButton>
-        <AButton
-          type="primary"
-          :loading="saving"
-          @click="store.save"
-        >
-          {{ $gettext('Save') }}
-        </AButton>
-      </ASpace>
-    </FooterToolBar>
-  </ACard>
+      <FooterToolBar>
+        <ASpace>
+          <AButton @click="router.push('/streams')">
+            {{ $gettext('Back') }}
+          </AButton>
+          <AButton
+            type="primary"
+            :loading="saving"
+            @click="store.save"
+          >
+            {{ $gettext('Save') }}
+          </AButton>
+        </ASpace>
+      </FooterToolBar>
+    </ACard>
+  </ASpin>
 </template>
 
 <style scoped lang="less">
@@ -133,4 +138,8 @@ const showHistory = ref(false)
   max-width: 800px;
   margin: 0 auto;
 }
+
+:deep(.tab-content) {
+  padding-bottom: 24px;
+}
 </style>

+ 1 - 3
app/src/views/stream/components/StreamStatusSelect.vue

@@ -80,9 +80,7 @@ function onChangeStatus(checked: CheckedType) {
     <ContextHolder />
     <div class="status-display">
       <ASwitch
-        :checked="status === 'enabled'"
-        :checked-children="$gettext('Enabled')"
-        :un-checked-children="$gettext('Disabled')"
+        :checked="status === ConfigStatus.Enabled"
         @change="onChangeStatus"
       />
     </div>

+ 3 - 0
app/src/views/stream/store.ts

@@ -1,6 +1,7 @@
 import type { CertificateInfo } from '@/api/cert'
 import type { Stream } from '@/api/stream'
 import type { CheckedType } from '@/types'
+import { message } from 'ant-design-vue'
 import config from '@/api/config'
 import ngx from '@/api/ngx'
 import stream from '@/api/stream'
@@ -65,6 +66,8 @@ export const useStreamEditorStore = defineStore('streamEditor', () => {
       })
 
       handleResponse(response)
+
+      message.success($gettext('Saved successfully'))
     }
     catch (error) {
       handleParseError(error as { error?: string, message: string })