mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 14:57:16 +08:00
feat: 初步实现compose template
This commit is contained in:
17
web/src/api/panel/template/index.ts
Normal file
17
web/src/api/panel/template/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { http } from '@/utils'
|
||||
|
||||
export default {
|
||||
// 获取模版列表
|
||||
list: (): any => http.Get('/template'),
|
||||
// 获取模版详情
|
||||
get: (slug: string): any => http.Get(`/template/${slug}`),
|
||||
// 使用模版创建编排
|
||||
create: (data: {
|
||||
slug: string
|
||||
name: string
|
||||
envs: { key: string; value: string }[]
|
||||
auto_firewall: boolean
|
||||
}): any => http.Post('/template', data),
|
||||
// 模版下载回调
|
||||
callback: (slug: string): any => http.Post(`/template/${slug}/callback`)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
<template>
|
||||
<app-page :show-footer="showFooter">
|
||||
<div class="flex flex-col flex-1 gap-10 min-h-0">
|
||||
<div class="flex flex-col flex-1 gap-10" :class="{ 'min-h-0': flex }">
|
||||
<header v-if="showHeader">
|
||||
<slot v-if="$slots.header" name="header" />
|
||||
<n-card v-else size="small">
|
||||
@@ -24,7 +24,7 @@ withDefaults(defineProps<Props>(), {
|
||||
</n-card>
|
||||
</header>
|
||||
<n-card
|
||||
class="flex-1 min-h-0 overflow-auto"
|
||||
:class="flex ? 'flex-1 min-h-0' : 'flex-1'"
|
||||
:content-class="flex ? 'flex flex-col min-h-0 h-full' : undefined"
|
||||
>
|
||||
<slot />
|
||||
|
||||
252
web/src/views/app/TemplateDeployModal.vue
Normal file
252
web/src/views/app/TemplateDeployModal.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import templateApi from '@/api/panel/template'
|
||||
import PtyTerminalModal from '@/components/common/PtyTerminalModal.vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import type { Template, TemplateEnvironment } from './types'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps<{
|
||||
template: Template | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
|
||||
const doSubmit = ref(false)
|
||||
const currentTab = ref('basic')
|
||||
|
||||
// 启动终端
|
||||
const upModal = ref(false)
|
||||
const upCommand = ref('')
|
||||
|
||||
const deployModel = reactive({
|
||||
name: '',
|
||||
autoStart: true,
|
||||
autoFirewall: false,
|
||||
envs: {} as Record<string, string>
|
||||
})
|
||||
|
||||
// 初始化环境变量默认值
|
||||
const initEnvDefaults = () => {
|
||||
if (!props.template?.environments) return
|
||||
const envs: Record<string, string> = {}
|
||||
props.template.environments.forEach((env: TemplateEnvironment) => {
|
||||
envs[env.name] = env.default || ''
|
||||
})
|
||||
deployModel.envs = envs
|
||||
}
|
||||
|
||||
// 根据类型渲染环境变量输入组件
|
||||
const getEnvInputType = (env: TemplateEnvironment) => {
|
||||
switch (env.type) {
|
||||
case 'password':
|
||||
return 'password'
|
||||
case 'number':
|
||||
case 'port':
|
||||
return 'number'
|
||||
default:
|
||||
return 'text'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 select 选项
|
||||
const getSelectOptions = (env: TemplateEnvironment) => {
|
||||
if (!env.options) return []
|
||||
return Object.entries(env.options).map(([value, label]) => ({
|
||||
label,
|
||||
value
|
||||
}))
|
||||
}
|
||||
|
||||
// 提交部署
|
||||
const handleSubmit = async () => {
|
||||
if (!props.template) return
|
||||
|
||||
if (!deployModel.name.trim()) {
|
||||
window.$message.warning($gettext('Please enter compose name'))
|
||||
return
|
||||
}
|
||||
|
||||
doSubmit.value = true
|
||||
|
||||
try {
|
||||
// 构建环境变量数组
|
||||
const envs = Object.entries(deployModel.envs).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value)
|
||||
}))
|
||||
|
||||
// 创建 compose
|
||||
await templateApi.create({
|
||||
slug: props.template.slug,
|
||||
name: deployModel.name,
|
||||
envs,
|
||||
auto_firewall: deployModel.autoFirewall
|
||||
})
|
||||
|
||||
window.$message.success($gettext('Created successfully'))
|
||||
|
||||
if (deployModel.autoStart) {
|
||||
// 自动启动
|
||||
upCommand.value = `docker compose -f /opt/ace/server/compose/${deployModel.name}/docker-compose.yml up -d`
|
||||
upModal.value = true
|
||||
} else {
|
||||
show.value = false
|
||||
emit('success')
|
||||
}
|
||||
} finally {
|
||||
doSubmit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 启动完成
|
||||
const handleUpComplete = () => {
|
||||
show.value = false
|
||||
emit('success')
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
deployModel.name = ''
|
||||
deployModel.autoStart = true
|
||||
deployModel.autoFirewall = false
|
||||
deployModel.envs = {}
|
||||
currentTab.value = 'basic'
|
||||
initEnvDefaults()
|
||||
}
|
||||
|
||||
watch(show, (val) => {
|
||||
if (val) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.template,
|
||||
() => {
|
||||
if (props.template) {
|
||||
initEnvDefaults()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
:title="$gettext('Deploy Template') + (template ? ` - ${template.name}` : '')"
|
||||
preset="card"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
:mask-closable="!doSubmit"
|
||||
:closable="!doSubmit"
|
||||
>
|
||||
<n-tabs v-model:value="currentTab" type="line" animated>
|
||||
<!-- 基本设置 -->
|
||||
<n-tab-pane name="basic" :tab="$gettext('Basic Settings')">
|
||||
<n-form :model="deployModel" label-placement="left" label-width="120">
|
||||
<n-form-item path="name" :label="$gettext('Compose Name')">
|
||||
<n-input
|
||||
v-model:value="deployModel.name"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter compose name')"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">{{ $gettext('Deploy Options') }}</n-divider>
|
||||
|
||||
<n-row :gutter="[24, 0]">
|
||||
<n-col :span="8">
|
||||
<n-form-item path="autoStart" :label="$gettext('Auto Start')">
|
||||
<n-switch v-model:value="deployModel.autoStart" />
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="8">
|
||||
<n-form-item path="autoFirewall" :label="$gettext('Auto Firewall')">
|
||||
<n-switch v-model:value="deployModel.autoFirewall" />
|
||||
<template #feedback>
|
||||
<span>
|
||||
{{ $gettext('Automatically allow ports defined in compose') }}
|
||||
</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
</n-row>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 环境变量 -->
|
||||
<n-tab-pane
|
||||
v-if="template?.environments?.length"
|
||||
name="environment"
|
||||
:tab="$gettext('Environment Variables')"
|
||||
>
|
||||
<n-form :model="deployModel" label-placement="left" label-width="160">
|
||||
<n-form-item v-for="env in template.environments" :key="env.name" :label="env.name">
|
||||
<!-- Select 类型 -->
|
||||
<n-select
|
||||
v-if="env.type === 'select'"
|
||||
v-model:value="deployModel.envs[env.name]"
|
||||
:options="getSelectOptions(env)"
|
||||
:placeholder="$gettext('Select value')"
|
||||
/>
|
||||
<!-- Number/Port 类型 -->
|
||||
<n-input-number
|
||||
v-else-if="env.type === 'number' || env.type === 'port'"
|
||||
v-model:value="deployModel.envs[env.name]"
|
||||
:min="env.type === 'port' ? 1 : undefined"
|
||||
:max="env.type === 'port' ? 65535 : undefined"
|
||||
style="width: 100%"
|
||||
:placeholder="env.default || ''"
|
||||
/>
|
||||
<!-- Password 类型 -->
|
||||
<n-input
|
||||
v-else-if="env.type === 'password'"
|
||||
v-model:value="deployModel.envs[env.name]"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="env.default || ''"
|
||||
/>
|
||||
<!-- Text 类型 (默认) -->
|
||||
<n-input
|
||||
v-else
|
||||
v-model:value="deployModel.envs[env.name]"
|
||||
:type="getEnvInputType(env)"
|
||||
:placeholder="env.default || ''"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- Compose 预览 -->
|
||||
<n-tab-pane name="compose" :tab="$gettext('Compose Preview')">
|
||||
<common-editor :value="template?.compose || ''" lang="yaml" height="50vh" read-only />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="show = false" :disabled="doSubmit">
|
||||
{{ $gettext('Cancel') }}
|
||||
</n-button>
|
||||
<n-button type="primary" :loading="doSubmit" :disabled="doSubmit" @click="handleSubmit">
|
||||
{{ $gettext('Deploy') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<pty-terminal-modal
|
||||
v-model:show="upModal"
|
||||
:title="$gettext('Starting Compose') + ' - ' + deployModel.name"
|
||||
:command="upCommand"
|
||||
@complete="handleUpComplete"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,7 +1,111 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCard, NEllipsis, NFlex, NGrid, NGridItem, NSpin, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import template from '@/api/panel/template'
|
||||
import TemplateDeployModal from './TemplateDeployModal.vue'
|
||||
import type { Template } from './types'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const selectedCategory = ref<string>('')
|
||||
const deployModalShow = ref(false)
|
||||
const selectedTemplate = ref<Template | null>(null)
|
||||
|
||||
const { loading, data, refresh } = usePagination(template.list, {
|
||||
initialData: []
|
||||
})
|
||||
|
||||
// 获取所有分类
|
||||
const categories = computed(() => {
|
||||
const cats = new Set<string>()
|
||||
data.value?.forEach((t: Template) => {
|
||||
t.categories?.forEach((c) => cats.add(c))
|
||||
})
|
||||
return Array.from(cats)
|
||||
})
|
||||
|
||||
// 过滤后的模版列表
|
||||
const filteredTemplates = computed(() => {
|
||||
if (!selectedCategory.value) {
|
||||
return data.value || []
|
||||
}
|
||||
return (data.value || []).filter((t: Template) => t.categories?.includes(selectedCategory.value))
|
||||
})
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
selectedCategory.value = category
|
||||
}
|
||||
|
||||
const handleDeploy = (tpl: Template) => {
|
||||
selectedTemplate.value = tpl
|
||||
deployModalShow.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-empty></n-empty>
|
||||
</template>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex>
|
||||
<n-tag
|
||||
:type="selectedCategory === '' ? 'primary' : 'default'"
|
||||
:bordered="selectedCategory !== ''"
|
||||
style="cursor: pointer"
|
||||
@click="handleCategoryChange('')"
|
||||
>
|
||||
{{ $gettext('All') }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-for="cat in categories"
|
||||
:key="cat"
|
||||
:type="selectedCategory === cat ? 'primary' : 'default'"
|
||||
:bordered="selectedCategory !== cat"
|
||||
style="cursor: pointer"
|
||||
@click="handleCategoryChange(cat)"
|
||||
>
|
||||
{{ cat }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<n-spin :show="loading">
|
||||
<n-grid :x-gap="16" :y-gap="16" cols="1 s:2 m:3 l:4" responsive="screen">
|
||||
<n-grid-item v-for="tpl in filteredTemplates" :key="tpl.slug">
|
||||
<n-card hoverable style="height: 100%">
|
||||
<n-flex vertical :size="12">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<span>{{ tpl.name }}</span>
|
||||
<n-tag size="small" type="info">{{ tpl.version }}</n-tag>
|
||||
</n-flex>
|
||||
<n-ellipsis :line-clamp="2" :tooltip="{ width: 300 }">
|
||||
{{ tpl.description }}
|
||||
</n-ellipsis>
|
||||
<n-flex :size="4" style="margin-top: auto">
|
||||
<n-tag v-for="cat in tpl.categories" :key="cat" size="small">
|
||||
{{ cat }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<template #action>
|
||||
<n-flex justify="end">
|
||||
<n-button size="small" type="primary" @click="handleDeploy(tpl)">
|
||||
{{ $gettext('Deploy') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-empty v-if="!loading && filteredTemplates.length === 0" />
|
||||
</n-spin>
|
||||
</n-flex>
|
||||
|
||||
<template-deploy-modal
|
||||
v-model:show="deployModalShow"
|
||||
:template="selectedTemplate"
|
||||
@success="refresh"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -17,3 +17,23 @@ export interface Channel {
|
||||
version: string
|
||||
log: string
|
||||
}
|
||||
|
||||
export interface TemplateEnvironment {
|
||||
name: string
|
||||
type: 'text' | 'password' | 'number' | 'port' | 'select'
|
||||
options?: Record<string, string>
|
||||
default: string
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
slug: string
|
||||
icon: string
|
||||
name: string
|
||||
description: string
|
||||
categories: string[]
|
||||
version: string
|
||||
compose: string
|
||||
environments: TemplateEnvironment[]
|
||||
}
|
||||
|
||||
@@ -76,19 +76,21 @@ const dropdownOptions = computed<DropdownOption[]>(() => {
|
||||
// 渲染状态标签
|
||||
const renderStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'R':
|
||||
case 'running':
|
||||
return h(NTag, { type: 'success' }, { default: () => $gettext('Running') })
|
||||
case 'S':
|
||||
case 'blocked':
|
||||
return h(NTag, { type: 'error' }, { default: () => $gettext('Blocked') })
|
||||
case 'sleep':
|
||||
return h(NTag, { type: 'warning' }, { default: () => $gettext('Sleeping') })
|
||||
case 'T':
|
||||
case 'stop':
|
||||
return h(NTag, { type: 'error' }, { default: () => $gettext('Stopped') })
|
||||
case 'I':
|
||||
case 'idle':
|
||||
return h(NTag, { type: 'primary' }, { default: () => $gettext('Idle') })
|
||||
case 'Z':
|
||||
case 'zombie':
|
||||
return h(NTag, { type: 'error' }, { default: () => $gettext('Zombie') })
|
||||
case 'W':
|
||||
case 'wait':
|
||||
return h(NTag, { type: 'warning' }, { default: () => $gettext('Waiting') })
|
||||
case 'L':
|
||||
case 'lock':
|
||||
return h(NTag, { type: 'info' }, { default: () => $gettext('Locked') })
|
||||
default:
|
||||
return h(NTag, { type: 'default' }, { default: () => status })
|
||||
|
||||
@@ -666,7 +666,7 @@ const updateTimeoutUnit = (proxy: any, unit: string) => {
|
||||
<n-form-item-gi :span="12" :label="$gettext('Proxy Host')">
|
||||
<n-input
|
||||
v-model:value="proxy.host"
|
||||
:placeholder="$gettext('Default: $host, or extracted from Proxy Pass')"
|
||||
:placeholder="$gettext('Default: $proxy_host, or extracted from Proxy Pass')"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Proxy SNI')">
|
||||
|
||||
Reference in New Issue
Block a user