refactor: refresh editor ui

This commit is contained in:
Jacky
2025-06-01 23:08:17 +08:00
parent 555fa4b6d1
commit c4bb484c6e
37 changed files with 945 additions and 697 deletions

9
.cursor/mcp.json Normal file
View File

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

2
app/components.d.ts vendored
View File

@@ -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']

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,
}
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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>
<BaseEditor :loading="leftPanelRef?.loading">
<template #left>
<ConfigLeftPanel ref="leftPanel" />
</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>
<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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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

View File

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

View File

@@ -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;
padding: 0 24px;
height: 55px;
}
}
:deep(.ant-tabs) {
margin-bottom: 0;
.ant-tabs-nav-wrap {
height: 55px;
padding: 0 24px;
}
.ant-tabs-content {
padding-top: 24px;
overflow-y: auto;
}
:deep(.ant-tabs-content) {
padding-top: 24px;
overflow-y: auto;
}
</style>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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>
</ASpace>
</template>
<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>
<template v-if="advanceMode">
<div>{{ $gettext('Advance Mode') }}</div>
</template>
<template v-else>
<div>{{ $gettext('Basic Mode') }}</div>
</template>
</div>
</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>
</Transition>
</div>
<div
v-else
key="basic"
class="domain-edit-container"
>
<NgxConfigEditor
:enabled="status === ConfigStatus.Enabled"
context="stream"
/>
</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>

View File

@@ -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>

View File

@@ -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 })