2
0
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:
2026-01-13 23:31:37 +08:00
parent 908509e06b
commit c07a60d1c8
28 changed files with 866 additions and 47 deletions

View 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`)
}

View File

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

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

View File

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

View File

@@ -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[]
}

View File

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

View File

@@ -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')">