2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 19:37:18 +08:00

feat: 项目管理完成

This commit is contained in:
2026-01-11 01:17:52 +08:00
parent 6ea1295f86
commit b85ca6fd70
5 changed files with 391 additions and 51 deletions

View File

@@ -37,7 +37,7 @@ func (r *projectRepo) List(typ types.ProjectType, page, limit uint) ([]*types.Pr
var total int64
query := r.db.Model(&biz.Project{})
if typ != "" {
if typ != "" && typ != "all" {
query = query.Where("type = ?", typ)
}

View File

@@ -3,11 +3,12 @@ import file from '@/api/panel/file'
import TheIcon from '@/components/custom/TheIcon.vue'
import { checkName, checkPath, getExt, getIconByExt } from '@/utils'
import type { DataTableColumns, InputInst } from 'naive-ui'
import { NButton, NDataTable, NEllipsis, NFlex, NTag } from 'naive-ui'
import { NButton, NDataTable, NEllipsis, NFlex, NSpin, NTag, useThemeVars } from 'naive-ui'
import type { RowData } from 'naive-ui/es/data-table/src/interface'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const themeVars = useThemeVars()
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const path = defineModel<string>('path', { type: String, required: true })
const props = defineProps({
@@ -17,12 +18,18 @@ const props = defineProps({
}
})
const currentPath = ref('/')
// 目录大小计算状态
const sizeLoading = ref<Map<string, boolean>>(new Map())
const sizeCache = ref<Map<string, string>>(new Map())
const title = computed(() => (props.dir ? $gettext('Select Directory') : $gettext('Select File')))
const isInput = ref(false)
const pathInput = ref<InputInst | null>(null)
const input = ref('www')
const sort = ref<string>('')
const selected = defineModel<any[]>('selected', { type: Array, default: () => [] })
const selected = ref<any[]>([])
const create = ref(false)
const createModel = ref({
dir: false,
@@ -58,9 +65,7 @@ const columns: DataTableColumns<RowData> = [
class: 'cursor-pointer hover:opacity-60',
onClick: () => {
if (row.dir) {
path.value = row.full
} else {
selected.value = [row.full]
currentPath.value = row.full
}
}
},
@@ -106,9 +111,41 @@ const columns: DataTableColumns<RowData> = [
{
title: $gettext('Size'),
key: 'size',
minWidth: 80,
minWidth: 100,
render(row: any): any {
return h(NTag, { type: 'info', size: 'small', bordered: false }, { default: () => row.size })
// 文件
if (!row.dir) {
return h(
NTag,
{ type: 'info', size: 'small', bordered: false },
{ default: () => row.size }
)
}
// 目录
const cachedSize = sizeCache.value.get(row.full)
if (cachedSize) {
return h(
NTag,
{ type: 'info', size: 'small', bordered: false },
{ default: () => cachedSize }
)
}
const isLoading = sizeLoading.value.get(row.full)
if (isLoading) {
return h(NSpin, { size: 16, style: { paddingTop: '4px' } })
}
return h(
'span',
{
style: { cursor: 'pointer', fontSize: '14px', color: themeVars.value.primaryColor },
onClick: (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
calculateDirSize(row.full)
}
},
$gettext('Calculate')
)
}
},
{
@@ -125,9 +162,9 @@ const columns: DataTableColumns<RowData> = [
}
]
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
const { loading, data, page, total, pageSize, pageCount, reload } = usePagination(
(page, pageSize) =>
file.list(encodeURIComponent(path.value), '', false, sort.value, page, pageSize),
file.list(encodeURIComponent(currentPath.value), '', false, sort.value, page, pageSize),
{
initialData: { total: 0, list: [] },
initialPageSize: 100,
@@ -151,11 +188,11 @@ const handleBlur = () => {
}
isInput.value = false
path.value = '/' + input.value
currentPath.value = '/' + input.value
}
const handleUp = () => {
const count = splitPath(path.value, '/').length
const count = splitPath(currentPath.value, '/').length
setPath(count - 2)
}
@@ -167,10 +204,10 @@ const splitPath = (str: string, delimiter: string) => {
}
const setPath = (index: number) => {
const newPath = splitPath(path.value, '/')
const newPath = splitPath(currentPath.value, '/')
.slice(0, index + 1)
.join('/')
path.value = '/' + newPath
currentPath.value = '/' + newPath
input.value = newPath
}
@@ -183,15 +220,15 @@ const handleSorterChange = (sorter: {
switch (sorter.order) {
case 'ascend':
sort.value = 'asc'
refresh()
reload()
break
case 'descend':
sort.value = 'desc'
refresh()
reload()
break
default:
sort.value = ''
refresh()
reload()
break
}
}
@@ -210,32 +247,51 @@ const handleCreate = () => {
return
}
const fullPath = path.value + '/' + createModel.value.path
const fullPath = currentPath.value + '/' + createModel.value.path
useRequest(file.create(fullPath, createModel.value.dir)).onSuccess(() => {
create.value = false
refresh()
reload()
window.$message.success($gettext('Created successfully'))
})
}
const closeWatch = watch(
path,
(value) => {
input.value = value.slice(1)
selected.value = []
refresh()
},
{ immediate: true }
)
// 计算目录大小
const calculateDirSize = (dirPath: string) => {
sizeLoading.value.set(dirPath, true)
useRequest(file.size(dirPath))
.onSuccess(({ data }) => {
sizeCache.value.set(dirPath, data)
})
.onComplete(() => {
sizeLoading.value.set(dirPath, false)
})
}
const handleClose = () => {
closeWatch()
if (selected.value.length) {
// 打开选择器时用外部path初始化内部currentPath
watch(show, (val) => {
if (val) {
currentPath.value = path.value || '/'
}
})
// 监听内部路径变化,刷新列表
watch(currentPath, (value) => {
if (!value) return
input.value = value.slice(1)
selected.value = []
sizeCache.value.clear()
sizeLoading.value.clear()
reload()
})
// 选择后更新外部path并关闭
watch(selected, (val) => {
if (val.length > 0) {
path.value = selected.value[0]
selected.value = []
show.value = false
}
show.value = false
}
})
</script>
<template>
@@ -247,8 +303,8 @@ const handleClose = () => {
size="huge"
:bordered="false"
:segmented="false"
@close="handleClose"
@mask-click="handleClose"
@close="show = false"
@mask-click="show = false"
>
<n-flex>
<n-popselect
@@ -270,7 +326,7 @@ const handleClose = () => {
{{ $gettext('Root Directory') }}
</n-breadcrumb-item>
<n-breadcrumb-item
v-for="(item, index) in splitPath(path, '/')"
v-for="(item, index) in splitPath(currentPath, '/')"
:key="index"
@click.stop="setPath(index)"
>
@@ -287,7 +343,7 @@ const handleClose = () => {
@blur="handleBlur"
/>
</n-input-group>
<n-button @click="refresh">
<n-button @click="reload">
<i-mdi-refresh :size="16" />
</n-button>
</n-flex>

View File

@@ -1,8 +1,223 @@
<script setup lang="ts">
import { useGettext } from 'vue3-gettext'
import home from '@/api/panel/home'
import project from '@/api/panel/project'
import PathSelector from '@/components/common/PathSelector.vue'
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const type = defineModel<string>('type', { type: String, required: true }) // 项目类型
const type = defineModel<string>('type', { type: String, required: true })
const { $gettext } = useGettext()
// PHP 框架预设
const phpFrameworks = [
{ label: $gettext('Custom'), value: 'custom', command: '' },
{ label: 'Laravel Octane', value: 'laravel-octane', command: 'artisan octane:start' },
{ label: 'Laravel (Artisan Serve)', value: 'laravel-serve', command: 'artisan serve' },
{ label: 'ThinkPHP', value: 'thinkphp', command: 'think run' },
{ label: 'Webman', value: 'webman', command: 'start.php start' },
{ label: 'Hyperf', value: 'hyperf', command: 'bin/hyperf.php start' },
{ label: 'Swoole HTTP', value: 'swoole', command: 'server.php' },
{ label: 'RoadRunner', value: 'roadrunner', command: 'vendor/bin/rr serve' }
]
const createModel = ref({
name: '',
type: '',
root_dir: '/opt/ace',
working_dir: '',
exec_start: '',
user: 'www'
})
// PHP 特有字段
const phpOptions = ref({
version: null as number | null,
framework: 'custom'
})
const showPathSelector = ref(false)
const pathSelectorPath = ref('/opt/ace')
const { data: installedEnvironment } = useRequest(home.installedEnvironment, {
initialData: {
php: []
}
})
// PHP 版本选项
const phpVersionOptions = computed(() => {
return installedEnvironment.value?.php || []
})
// 根据 PHP 版本和框架生成启动命令
const generateCommand = () => {
if (type.value !== 'php' || !phpOptions.value.version) {
return
}
const framework = phpFrameworks.find((f) => f.value === phpOptions.value.framework)
if (!framework || framework.value === 'custom') {
return
}
const phpBin = `php${phpOptions.value.version}`
createModel.value.exec_start = `${phpBin} ${framework.command}`
}
// 监听 PHP 版本和框架变化
watch(
() => [phpOptions.value.version, phpOptions.value.framework],
() => {
generateCommand()
}
)
// 处理目录选择
const handleSelectPath = () => {
pathSelectorPath.value = createModel.value.root_dir || '/opt/ace'
showPathSelector.value = true
}
// 目录选择完成
watch(showPathSelector, (val) => {
if (!val && pathSelectorPath.value) {
createModel.value.root_dir = pathSelectorPath.value
}
})
const handleCreate = async () => {
createModel.value.type = type.value == 'all' ? 'general' : type.value
useRequest(project.create(createModel.value)).onSuccess(() => {
window.$bus.emit('project:refresh')
window.$message.success($gettext('Project created successfully'))
show.value = false
// 重置表单
createModel.value = {
name: '',
type: '',
root_dir: '/opt/ace',
working_dir: '',
exec_start: '',
user: 'www'
}
phpOptions.value = {
version: null,
framework: 'custom'
}
})
}
// 根据类型获取标题
const modalTitle = computed(() => {
const titles: Record<string, string> = {
general: $gettext('Create General Project'),
php: $gettext('Create PHP Project')
}
return titles[type.value] || $gettext('Create Project')
})
</script>
<template></template>
<template>
<n-modal
v-model:show="show"
:title="modalTitle"
preset="card"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
@close="show = false"
>
<n-form :model="createModel" label-placement="left" label-width="100">
<n-form-item path="name" :label="$gettext('Project Name')">
<n-input
v-model:value="createModel.name"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Project name, used as service identifier')"
/>
</n-form-item>
<n-form-item path="root_dir" :label="$gettext('Project Directory')" required>
<n-input-group>
<n-input
v-model:value="createModel.root_dir"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Project root directory')"
/>
<n-button @click="handleSelectPath">
<template #icon>
<i-mdi-folder-open />
</template>
</n-button>
</n-input-group>
</n-form-item>
<!-- PHP 类型特有字段 -->
<template v-if="type === 'php'">
<n-row :gutter="[24, 0]">
<n-col :span="12">
<n-form-item :label="$gettext('PHP Version')">
<n-select
v-model:value="phpOptions.version"
:options="phpVersionOptions"
:placeholder="$gettext('Select PHP Version')"
@keydown.enter.prevent
/>
</n-form-item>
</n-col>
<n-col :span="12">
<n-form-item :label="$gettext('Framework')">
<n-select
v-model:value="phpOptions.framework"
:options="phpFrameworks"
:placeholder="$gettext('Select Framework')"
@keydown.enter.prevent
/>
</n-form-item>
</n-col>
</n-row>
</template>
<n-form-item path="user" :label="$gettext('Run User')">
<n-select
v-model:value="createModel.user"
:options="[
{ label: 'www', value: 'www' },
{ label: 'root', value: 'root' },
{ label: 'nobody', value: 'nobody' }
]"
:placeholder="$gettext('Select User')"
@keydown.enter.prevent
/>
<template #feedback>
<span class="text-gray-400">
{{ $gettext('Select www user if no special requirements') }}
</span>
</template>
</n-form-item>
<n-form-item path="exec_start" :label="$gettext('Start Command')" required>
<n-input
v-model:value="createModel.exec_start"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('e.g., php artisan serve, node app.js')"
/>
</n-form-item>
</n-form>
<n-button type="info" block @click="handleCreate">
{{ $gettext('Create') }}
</n-button>
</n-modal>
<!-- 目录选择器 -->
<path-selector v-model:show="showPathSelector" v-model:path="pathSelectorPath" :dir="true" />
</template>
<style scoped lang="scss"></style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { TreeSelectOption } from 'naive-ui'
import PathSelector from '@/components/common/PathSelector.vue'
import { translateTitle } from '@/locales/menu'
import { usePermissionStore } from '@/store'
import { locales as availableLocales } from '@/utils'
@@ -12,6 +13,30 @@ const permissionStore = usePermissionStore()
const model = defineModel<any>('model', { type: Object, required: true })
// 目录选择器
const showPathSelector = ref(false)
const pathSelectorPath = ref('/opt/ace')
const pathSelectorTarget = ref<'website' | 'backup'>('website')
const handleSelectPath = (target: 'website' | 'backup') => {
pathSelectorTarget.value = target
pathSelectorPath.value =
target === 'website'
? model.value.website_path || '/opt/ace/sites'
: model.value.backup_path || '/opt/ace/backup'
showPathSelector.value = true
}
watch(showPathSelector, (val) => {
if (!val && pathSelectorPath.value) {
if (pathSelectorTarget.value === 'website') {
model.value.website_path = pathSelectorPath.value
} else {
model.value.backup_path = pathSelectorPath.value
}
}
})
const locales = computed(() => {
return Object.entries(availableLocales).map(([code, name]: [string, string]) => {
return {
@@ -86,10 +111,24 @@ const menus = computed<TreeSelectOption[]>(() => {
<n-input-number v-model:value="model.port" :placeholder="$gettext('8888')" w-full />
</n-form-item>
<n-form-item :label="$gettext('Default Website Directory')">
<n-input v-model:value="model.website_path" :placeholder="$gettext('/opt/ace/sites')" />
<n-input-group>
<n-input v-model:value="model.website_path" :placeholder="$gettext('/opt/ace/sites')" />
<n-button @click="handleSelectPath('website')">
<template #icon>
<i-mdi-folder-open />
</template>
</n-button>
</n-input-group>
</n-form-item>
<n-form-item :label="$gettext('Default Backup Directory')">
<n-input v-model:value="model.backup_path" :placeholder="$gettext('/opt/ace/backup')" />
<n-input-group>
<n-input v-model:value="model.backup_path" :placeholder="$gettext('/opt/ace/backup')" />
<n-button @click="handleSelectPath('backup')">
<template #icon>
<i-mdi-folder-open />
</template>
</n-button>
</n-input-group>
</n-form-item>
<n-form-item :label="$gettext('Custom Logo')">
<n-input
@@ -109,6 +148,9 @@ const menus = computed<TreeSelectOption[]>(() => {
</n-form-item>
</n-form>
</n-flex>
<!-- 目录选择器 -->
<path-selector v-model:show="showPathSelector" v-model:path="pathSelectorPath" :dir="true" />
</template>
<style scoped lang="scss"></style>

View File

@@ -4,6 +4,7 @@ import { useGettext } from 'vue3-gettext'
import home from '@/api/panel/home'
import website from '@/api/panel/website'
import PathSelector from '@/components/common/PathSelector.vue'
import { generateRandomString } from '@/utils'
const show = defineModel<boolean>('show', { type: Boolean, required: true })
@@ -28,6 +29,9 @@ const createModel = ref({
proxy: ''
})
const showPathSelector = ref(false)
const pathSelectorPath = ref('/opt/ace')
const { data: installedEnvironment } = useRequest(home.installedEnvironment, {
initialData: {
php: [
@@ -89,6 +93,19 @@ const formatDbValue = (value: string) => {
return value
}
// 处理目录选择
const handleSelectPath = () => {
pathSelectorPath.value = createModel.value.path || '/opt/ace'
showPathSelector.value = true
}
// 目录选择完成
watch(showPathSelector, (val) => {
if (!val && pathSelectorPath.value && pathSelectorPath.value !== '/opt/ace') {
createModel.value.path = pathSelectorPath.value
}
})
</script>
<template>
@@ -208,16 +225,23 @@ const formatDbValue = (value: string) => {
</n-col>
</n-row>
<n-form-item v-if="type != 'proxy'" path="path" :label="$gettext('Directory')">
<n-input
v-model:value="createModel.path"
type="text"
@keydown.enter.prevent
:placeholder="
$gettext(
'Website root directory (if left empty, defaults to website directory/website name/public)'
)
"
/>
<n-input-group>
<n-input
v-model:value="createModel.path"
type="text"
@keydown.enter.prevent
:placeholder="
$gettext(
'Website root directory (if left empty, defaults to website directory/website name/public)'
)
"
/>
<n-button @click="handleSelectPath">
<template #icon>
<i-mdi-folder-open />
</template>
</n-button>
</n-input-group>
</n-form-item>
<n-form-item v-if="type == 'proxy'" path="path" :label="$gettext('Proxy Target')">
<n-input
@@ -240,6 +264,9 @@ const formatDbValue = (value: string) => {
{{ $gettext('Create') }}
</n-button>
</n-modal>
<!-- 目录选择器 -->
<path-selector v-model:show="showPathSelector" v-model:path="pathSelectorPath" :dir="true" />
</template>
<style scoped lang="scss"></style>