2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 11:27:17 +08:00
Files
panel/web/src/views/project/EditModal.vue
Copilot 9ab01dc64b feat(project): 运行用户支持自定义输入 (#1323)
* Initial plan

* feat(project): 运行用户选择器支持自定义输入

为创建项目和编辑项目的"运行用户"选择器添加 filterable 和 tag 属性,
使用户可以在选择预设用户(www/root/nobody)的同时也能输入自定义用户名。

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
2026-02-03 00:15:47 +08:00

588 lines
21 KiB
Vue

<script setup lang="ts">
import { useGettext } from 'vue3-gettext'
import project from '@/api/panel/project'
import PathSelector from '@/components/common/PathSelector.vue'
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const editId = defineModel<number>('editId', { type: Number, required: true })
const { $gettext } = useGettext()
const currentTab = ref('basic')
const model = ref({
id: 0,
name: '',
description: '',
root_dir: '',
working_dir: '',
exec_start_pre: '',
exec_start_post: '',
exec_start: '',
exec_stop: '',
exec_reload: '',
user: 'www',
restart: 'on-failure',
restart_sec: '5s',
restart_max: 3,
timeout_start_sec: 90,
timeout_stop_sec: 90,
environments: [] as { key: string; value: string }[],
standard_output: 'journal',
standard_error: 'journal',
requires: [] as string[],
wants: [] as string[],
after: [] as string[],
before: [] as string[],
memory_limit: 0,
cpu_quota: '',
no_new_privileges: false,
protect_tmp: false,
protect_home: false,
protect_system: '',
read_write_paths: [] as string[],
read_only_paths: [] as string[]
})
const loading = ref(false)
// Restart 策略选项
const restartOptions = [
{ label: $gettext('No restart'), value: 'no' },
{ label: $gettext('Always restart'), value: 'always' },
{ label: $gettext('Restart on failure'), value: 'on-failure' },
{ label: $gettext('Restart on abnormal'), value: 'on-abnormal' },
{ label: $gettext('Restart on abort'), value: 'on-abort' },
{ label: $gettext('Restart on success'), value: 'on-success' }
]
// 输出选项
const outputOptions = [
{ label: 'journal', value: 'journal' },
{ label: 'syslog', value: 'syslog' },
{ label: 'kmsg', value: 'kmsg' },
{ label: 'null', value: 'null' },
{ label: $gettext('File (append)'), value: 'append:/var/log/' },
{ label: $gettext('File (truncate)'), value: 'truncate:/var/log/' }
]
// ProtectSystem 选项
const protectSystemOptions = [
{ label: $gettext('Disabled'), value: '' },
{ label: 'true', value: 'true' },
{ label: 'full', value: 'full' },
{ label: 'strict', value: 'strict' }
]
// 目录选择器
const showPathSelector = ref(false)
const pathSelectorPath = ref('')
const pathSelectorTarget = ref<'root_dir' | 'working_dir'>('root_dir')
const handleSelectPath = (target: 'root_dir' | 'working_dir') => {
pathSelectorTarget.value = target
pathSelectorPath.value = model.value[target] || '/opt/ace/projects'
showPathSelector.value = true
}
watch(showPathSelector, (val) => {
if (!val && pathSelectorPath.value) {
model.value[pathSelectorTarget.value] = pathSelectorPath.value
}
})
// 加载项目数据
const loadProject = async () => {
if (!editId.value) return
loading.value = true
useRequest(project.get(editId.value))
.onSuccess(({ data }) => {
model.value = {
id: data.id,
name: data.name || '',
description: data.description || '',
root_dir: data.root_dir || '',
working_dir: data.working_dir || '',
exec_start_pre: data.exec_start_pre || '',
exec_start_post: data.exec_start_post || '',
exec_start: data.exec_start || '',
exec_stop: data.exec_stop || '',
exec_reload: data.exec_reload || '',
user: data.user || 'www',
restart: data.restart || 'on-failure',
restart_sec: data.restart_sec || '5s',
restart_max: data.restart_max || 3,
timeout_start_sec: data.timeout_start_sec || 90,
timeout_stop_sec: data.timeout_stop_sec || 90,
environments: data.environments || [],
standard_output: data.standard_output || 'journal',
standard_error: data.standard_error || 'journal',
requires: data.requires || [],
wants: data.wants || [],
after: data.after || [],
before: data.before || [],
memory_limit: data.memory_limit || 0,
cpu_quota: data.cpu_quota || '',
no_new_privileges: data.no_new_privileges || false,
protect_tmp: data.protect_tmp || false,
protect_home: data.protect_home || false,
protect_system: data.protect_system || '',
read_write_paths: data.read_write_paths || [],
read_only_paths: data.read_only_paths || []
}
})
.onComplete(() => {
loading.value = false
})
}
// 监听 show 变化加载数据
watch(show, (val) => {
if (val && editId.value) {
currentTab.value = 'basic'
loadProject()
}
})
// 环境变量操作
const onCreateEnv = () => {
return { key: '', value: '' }
}
// 保存
const handleSave = async () => {
useRequest(project.update(model.value.id, model.value)).onSuccess(() => {
window.$bus.emit('project:refresh')
window.$message.success($gettext('Saved successfully'))
show.value = false
})
}
</script>
<template>
<n-modal
v-model:show="show"
:title="$gettext('Edit Project - %{ name }', { name: model.name })"
preset="card"
style="width: 70vw"
size="huge"
:bordered="false"
:segmented="false"
@close="show = false"
>
<n-spin :show="loading">
<n-tabs v-model:value="currentTab" type="line" animated>
<!-- 基本设置 -->
<n-tab-pane name="basic" :tab="$gettext('Basic Settings')">
<n-form :model="model" label-placement="left" label-width="120">
<n-form-item path="name" :label="$gettext('Project Name')">
<n-input
v-model:value="model.name"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Project name, used as service identifier')"
/>
</n-form-item>
<n-form-item path="description" :label="$gettext('Description')">
<n-input
v-model:value="model.description"
type="textarea"
:rows="2"
@keydown.enter.prevent
:placeholder="$gettext('Project description')"
/>
</n-form-item>
<n-form-item path="root_dir" :label="$gettext('Project Directory')">
<n-input-group>
<n-input
v-model:value="model.root_dir"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Project root directory')"
/>
<n-button @click="handleSelectPath('root_dir')">
<template #icon>
<i-mdi-folder-open />
</template>
</n-button>
</n-input-group>
</n-form-item>
<n-form-item path="working_dir" :label="$gettext('Working Directory')">
<n-input-group>
<n-input
v-model:value="model.working_dir"
type="text"
@keydown.enter.prevent
:placeholder="
$gettext('Working directory (optional, defaults to project directory)')
"
/>
<n-button @click="handleSelectPath('working_dir')">
<template #icon>
<i-mdi-folder-open />
</template>
</n-button>
</n-input-group>
</n-form-item>
<n-form-item path="user" :label="$gettext('Run User')">
<n-select
v-model:value="model.user"
:options="[
{ label: 'www', value: 'www' },
{ label: 'root', value: 'root' },
{ label: 'nobody', value: 'nobody' }
]"
:placeholder="$gettext('Select or enter user')"
filterable
tag
@keydown.enter.prevent
/>
</n-form-item>
</n-form>
</n-tab-pane>
<!-- 运行设置 -->
<n-tab-pane name="runtime" :tab="$gettext('Runtime Settings')">
<n-form :model="model" label-placement="left" label-width="140">
<n-form-item path="exec_start" :label="$gettext('Start Command')">
<n-input
v-model:value="model.exec_start"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('e.g., php artisan serve, node app.js')"
/>
</n-form-item>
<n-form-item path="exec_start_pre" :label="$gettext('Pre-start Command')">
<n-input
v-model:value="model.exec_start_pre"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Command to run before starting (optional)')"
/>
</n-form-item>
<n-form-item path="exec_start_post" :label="$gettext('Post-start Command')">
<n-input
v-model:value="model.exec_start_post"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Command to run after starting (optional)')"
/>
</n-form-item>
<n-form-item path="exec_stop" :label="$gettext('Stop Command')">
<n-input
v-model:value="model.exec_stop"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Custom stop command (optional)')"
/>
</n-form-item>
<n-form-item path="exec_reload" :label="$gettext('Reload Command')">
<n-input
v-model:value="model.exec_reload"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Custom reload command (optional)')"
/>
</n-form-item>
<n-divider title-placement="left">{{ $gettext('Restart Policy') }}</n-divider>
<n-row :gutter="[24, 0]">
<n-col :span="12">
<n-form-item path="restart" :label="$gettext('Restart Strategy')">
<n-select
v-model:value="model.restart"
:options="restartOptions"
@keydown.enter.prevent
/>
</n-form-item>
</n-col>
<n-col :span="12">
<n-form-item path="restart_sec" :label="$gettext('Restart Interval')">
<n-input
v-model:value="model.restart_sec"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('e.g., 5s, 1min')"
/>
</n-form-item>
</n-col>
</n-row>
<n-row :gutter="[24, 0]">
<n-col :span="8">
<n-form-item path="restart_max" :label="$gettext('Max Restarts')">
<n-input-number
v-model:value="model.restart_max"
:min="0"
:max="100"
style="width: 100%"
/>
</n-form-item>
</n-col>
<n-col :span="8">
<n-form-item path="timeout_start_sec" :label="$gettext('Start Timeout (s)')">
<n-input-number
v-model:value="model.timeout_start_sec"
:min="0"
:max="3600"
style="width: 100%"
/>
</n-form-item>
</n-col>
<n-col :span="8">
<n-form-item path="timeout_stop_sec" :label="$gettext('Stop Timeout (s)')">
<n-input-number
v-model:value="model.timeout_stop_sec"
:min="0"
:max="3600"
style="width: 100%"
/>
</n-form-item>
</n-col>
</n-row>
<n-divider title-placement="left">{{ $gettext('Other') }}</n-divider>
<n-row :gutter="[24, 0]">
<n-col :span="12">
<n-form-item path="standard_output" :label="$gettext('Standard Output')">
<n-select
v-model:value="model.standard_output"
:options="outputOptions"
tag
filterable
@keydown.enter.prevent
/>
</n-form-item>
</n-col>
<n-col :span="12">
<n-form-item path="standard_error" :label="$gettext('Standard Error')">
<n-select
v-model:value="model.standard_error"
:options="outputOptions"
tag
filterable
@keydown.enter.prevent
/>
</n-form-item>
</n-col>
</n-row>
<n-form-item :label="$gettext('Environment Variables')">
<n-dynamic-input
v-model:value="model.environments"
:on-create="onCreateEnv"
show-sort-button
>
<template #default="{ value }">
<div flex gap-2 w-full items-center>
<n-input
v-model:value="value.key"
:placeholder="$gettext('Variable name')"
style="flex: 1"
/>
<span>=</span>
<n-input
v-model:value="value.value"
:placeholder="$gettext('Variable value')"
style="flex: 2"
/>
</div>
</template>
</n-dynamic-input>
</n-form-item>
</n-form>
</n-tab-pane>
<!-- 依赖设置 -->
<n-tab-pane name="dependencies" :tab="$gettext('Dependencies')">
<n-form :model="model" label-placement="left" label-width="140">
<n-alert type="info" style="margin-bottom: 16px">
{{
$gettext(
'Configure service dependencies to control startup order. Common services: network.target, mysqld.service, postgresql.service, redis.service'
)
}}
</n-alert>
<n-form-item path="requires" :label="$gettext('Requires')">
<n-dynamic-tags v-model:value="model.requires" />
<template #feedback>
<span class="text-gray-400">
{{
$gettext('Strong dependencies, service will fail if these are not available')
}}
</span>
</template>
</n-form-item>
<n-form-item path="wants" :label="$gettext('Wants')">
<n-dynamic-tags v-model:value="model.wants" />
<template #feedback>
<span class="text-gray-400">
{{ $gettext('Weak dependencies, service will still start if these fail') }}
</span>
</template>
</n-form-item>
<n-form-item path="after" :label="$gettext('After')">
<n-dynamic-tags v-model:value="model.after" />
<template #feedback>
<span class="text-gray-400">
{{ $gettext('Start this service after the specified services') }}
</span>
</template>
</n-form-item>
<n-form-item path="before" :label="$gettext('Before')">
<n-dynamic-tags v-model:value="model.before" />
<template #feedback>
<span class="text-gray-400">
{{ $gettext('Start this service before the specified services') }}
</span>
</template>
</n-form-item>
</n-form>
</n-tab-pane>
<!-- 资源限制 -->
<n-tab-pane name="resources" :tab="$gettext('Resource Limits')">
<n-form :model="model" label-placement="left" label-width="140">
<n-alert type="info" style="margin-bottom: 16px">
{{
$gettext(
'Set resource limits to prevent the service from consuming too many system resources'
)
}}
</n-alert>
<n-row :gutter="[24, 0]">
<n-col :span="12">
<n-form-item path="memory_limit" :label="$gettext('Memory Limit (MB)')">
<n-input-number
v-model:value="model.memory_limit"
:min="0"
:max="1024000"
style="width: 100%"
:placeholder="$gettext('0 means no limit')"
/>
<template #feedback>
<span class="text-gray-400">
{{ $gettext('Set to 0 to disable memory limit') }}
</span>
</template>
</n-form-item>
</n-col>
<n-col :span="12">
<n-form-item path="cpu_quota" :label="$gettext('CPU Quota')">
<n-input
v-model:value="model.cpu_quota"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('e.g., 50% or 200%')"
/>
<template #feedback>
<span class="text-gray-400">
{{ $gettext('100% = 1 CPU core, 200% = 2 cores') }}
</span>
</template>
</n-form-item>
</n-col>
</n-row>
</n-form>
</n-tab-pane>
<!-- 安全设置 -->
<n-tab-pane name="security" :tab="$gettext('Security Settings')">
<n-form :model="model" label-placement="left" label-width="160">
<n-alert type="warning" style="margin-bottom: 16px">
{{
$gettext(
'Security settings can enhance service isolation but may affect functionality. Please test thoroughly before enabling.'
)
}}
</n-alert>
<n-divider title-placement="left">{{ $gettext('Privilege Control') }}</n-divider>
<n-row :gutter="[24, 0]">
<n-col :span="8">
<n-form-item path="no_new_privileges" :label="$gettext('No New Privileges')">
<n-switch v-model:value="model.no_new_privileges" />
</n-form-item>
</n-col>
<n-col :span="8">
<n-form-item path="protect_tmp" :label="$gettext('Protect /tmp')">
<n-switch v-model:value="model.protect_tmp" />
</n-form-item>
</n-col>
<n-col :span="8">
<n-form-item path="protect_home" :label="$gettext('Protect /home')">
<n-switch v-model:value="model.protect_home" />
</n-form-item>
</n-col>
</n-row>
<n-form-item path="protect_system" :label="$gettext('Protect System')">
<n-select
v-model:value="model.protect_system"
:options="protectSystemOptions"
@keydown.enter.prevent
/>
<template #feedback>
<span class="text-gray-400">
{{
$gettext(
'true: /usr, /boot read-only; full: + /etc read-only; strict: entire filesystem read-only'
)
}}
</span>
</template>
</n-form-item>
<n-divider title-placement="left">{{ $gettext('Path Access Control') }}</n-divider>
<n-form-item path="read_write_paths" :label="$gettext('Read-Write Paths')">
<n-dynamic-tags v-model:value="model.read_write_paths" />
<template #feedback>
<span class="text-gray-400">
{{ $gettext('Paths that the service can read and write to') }}
</span>
</template>
</n-form-item>
<n-form-item path="read_only_paths" :label="$gettext('Read-Only Paths')">
<n-dynamic-tags v-model:value="model.read_only_paths" />
<template #feedback>
<span class="text-gray-400">
{{ $gettext('Paths that the service can only read from') }}
</span>
</template>
</n-form-item>
</n-form>
</n-tab-pane>
</n-tabs>
</n-spin>
<template #footer>
<n-flex justify="end">
<n-button @click="show = false">
{{ $gettext('Cancel') }}
</n-button>
<n-button type="primary" @click="handleSave" :loading="loading">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</template>
</n-modal>
<!-- 目录选择器 -->
<path-selector v-model:show="showPathSelector" v-model:path="pathSelectorPath" :dir="true" />
</template>
<style scoped lang="scss"></style>