add app页面

This commit is contained in:
yumaojun03 2025-08-17 18:25:13 +08:00
parent ec0ee66af2
commit 78990c6094
21 changed files with 467 additions and 61 deletions

View File

@ -0,0 +1,57 @@
package api
import (
"122.51.31.227/go-course/go18/devcloud/audit/audit"
"122.51.31.227/go-course/go18/devcloud/mcenter/apps/label"
"122.51.31.227/go-course/go18/devcloud/mcenter/permission"
"github.com/infraboard/mcube/v2/ioc"
"github.com/infraboard/mcube/v2/ioc/config/gorestful"
restfulspec "github.com/emicklei/go-restful-openapi/v2"
"github.com/emicklei/go-restful/v3"
)
func init() {
ioc.Api().Registry(&LabelRestfulApiHandler{})
}
type LabelRestfulApiHandler struct {
ioc.ObjectImpl
// 依赖控制器
svc label.Service
}
func (h *LabelRestfulApiHandler) Name() string {
return "labels"
}
func (h *LabelRestfulApiHandler) Init() error {
h.svc = label.GetService()
tags := []string{"标签管理"}
ws := gorestful.ObjectRouter(h)
// required_auth=true/false
ws.Route(ws.GET("").To(h.QueryLabel).
Doc("标签列表查询").
Metadata(restfulspec.KeyOpenAPITags, tags).
// 这个开关怎么生效
// 中间件需求读取接口的描述信息,来决定是否需要认证
Metadata(permission.Auth(true)).
Metadata(permission.Permission(false)).
Metadata(permission.Resource("labels")).
Metadata(permission.Action("list")).
Metadata(audit.Enable(true)).
Param(restful.QueryParameter("page_size", "分页大小").DataType("integer")).
Param(restful.QueryParameter("page_number", "页码").DataType("integer")).
Writes(Set{}).
Returns(200, "OK", Set{}))
return nil
}
// *types.Set[*Label]
// 返回的泛型, API Doc这个工具 不支持泛型
type Set struct {
Total int64 `json:"total"`
Items []label.Label `json:"items"`
}

View File

@ -0,0 +1,32 @@
package api
import (
"122.51.31.227/go-course/go18/devcloud/mcenter/apps/label"
"122.51.31.227/go-course/go18/devcloud/mcenter/apps/token"
"github.com/emicklei/go-restful/v3"
"github.com/gin-gonic/gin/binding"
"github.com/infraboard/mcube/v2/http/restful/response"
"github.com/infraboard/mcube/v2/ioc/config/log"
)
func (h *LabelRestfulApiHandler) QueryLabel(r *restful.Request, w *restful.Response) {
req := label.NewQueryLabelRequest()
// 过滤条件在认证完成后的上下文中
tk := token.GetTokenFromCtx(r.Request.Context())
log.L().Debug().Msgf("resource scope: %s", tk.ResourceScope)
// 用户的参数
if err := binding.Query.Bind(r.Request, &req); err != nil {
response.Failed(w, err)
return
}
set, err := h.svc.QueryLabel(r.Request.Context(), req)
if err != nil {
response.Failed(w, err)
return
}
response.Success(w, set)
}

View File

@ -30,6 +30,11 @@ func (i *LabelServiceImpl) QueryLabel(ctx context.Context, in *label.QueryLabelR
set := types.New[*label.Label]()
query := datasource.DBFromCtx(ctx).Model(&label.Label{})
if in.Key != "" {
query = query.Where("`key` = ?", in.Key)
}
err := query.Count(&set.Total).Error
if err != nil {
return nil, err

View File

@ -29,6 +29,7 @@ func TestCreateLabel(t *testing.T) {
func TestQueryLabel(t *testing.T) {
req := label.NewQueryLabelRequest()
req.Key = "team"
set, err := svc.QueryLabel(ctx, req)
if err != nil {
t.Fatal(err)

View File

@ -49,6 +49,7 @@ func NewQueryLabelRequest() *QueryLabelRequest {
type QueryLabelRequest struct {
*request.PageRequest
Key string `json:"key" form:"key"`
}
type DescribeLabelRequest struct {

View File

@ -9,6 +9,7 @@ import (
// 鉴权
_ "122.51.31.227/go-course/go18/devcloud/mcenter/apps/endpoint/impl"
_ "122.51.31.227/go-course/go18/devcloud/mcenter/apps/label/api"
_ "122.51.31.227/go-course/go18/devcloud/mcenter/apps/label/impl"
_ "122.51.31.227/go-course/go18/devcloud/mcenter/apps/namespace/impl"
_ "122.51.31.227/go-course/go18/devcloud/mcenter/apps/policy/impl"

View File

@ -31,6 +31,18 @@ func (h *UserRestfulApiHandler) Init() error {
tags := []string{"应用管理"}
ws := gorestful.ObjectRouter(h)
ws.Route(ws.POST("").To(h.CreateApplication).
Doc("应用创建").
Metadata(restfulspec.KeyOpenAPITags, tags).
Metadata(permission.Auth(true)).
Metadata(permission.Permission(true)).
Metadata(permission.Resource("application")).
Metadata(permission.Action("create")).
Metadata(audit.Enable(true)).
Writes(application.Application{}).
Returns(200, "OK", application.Application{}))
// required_auth=true/false
ws.Route(ws.GET("").To(h.QueryApplication).
Doc("应用列表查询").
@ -47,6 +59,17 @@ func (h *UserRestfulApiHandler) Init() error {
Writes(Set{}).
Returns(200, "OK", Set{}))
ws.Route(ws.DELETE("/{id}").To(h.DeleteApplication).
Doc("应用删除").
Metadata(restfulspec.KeyOpenAPITags, tags).
Metadata(permission.Auth(true)).
Metadata(permission.Permission(true)).
Metadata(permission.Resource("application")).
Metadata(permission.Action("delete")).
Metadata(audit.Enable(true)).
Writes().
Returns(200, "OK", nil))
return nil
}

View File

@ -31,3 +31,41 @@ func (h *UserRestfulApiHandler) QueryApplication(r *restful.Request, w *restful.
response.Success(w, set)
}
func (h *UserRestfulApiHandler) CreateApplication(r *restful.Request, w *restful.Response) {
req := application.NewCreateApplicationRequest()
if err := r.ReadEntity(req); err != nil {
response.Failed(w, err)
return
}
// 过滤条件在认证完成后的上下文中
tk := token.GetTokenFromCtx(r.Request.Context())
req.SetNamespaceId(*tk.NamespaceId)
req.CreateBy = tk.UserName
set, err := h.svc.CreateApplication(r.Request.Context(), req)
if err != nil {
response.Failed(w, err)
return
}
response.Success(w, set)
}
func (h *UserRestfulApiHandler) DeleteApplication(r *restful.Request, w *restful.Response) {
req := application.NewDeleteApplicationRequest(r.PathParameter("id"))
// 过滤条件在认证完成后的上下文中
tk := token.GetTokenFromCtx(r.Request.Context())
req.ResourceScope = tk.ResourceScope
log.L().Debug().Msgf("resource scope: %s", tk.ResourceScope)
set, err := h.svc.DeleteApplication(r.Request.Context(), req)
if err != nil {
response.Failed(w, err)
return
}
response.Success(w, set)
}

View File

@ -40,6 +40,9 @@ func (i *ApplicationServiceImpl) QueryApplication(ctx context.Context, in *appli
if in.Ready != nil {
query = query.Where("ready = ?", *in.Ready)
}
if in.Keywords != "" {
query = query.Where("name LIKE ?", "%"+in.Keywords+"%")
}
err := query.Count(&set.Total).Error
if err != nil {

View File

@ -29,6 +29,7 @@ func TestQueryApplication(t *testing.T) {
// dev01.%
req.SetNamespaceId(1)
req.SetScope("team", []string{"dev01%"})
req.Keywords = "dev01"
// req.SetScope("env", []string{"prod"})
ins, err := svc.QueryApplication(ctx, req)
if err != nil {

View File

@ -46,11 +46,13 @@ type QueryApplicationRequest struct {
type QueryApplicationRequestSpec struct {
*request.PageRequest
// 应用ID
Id string `json:"id" bson:"_id"`
Id string `json:"id" form:"id"`
// 应用名称
Name string `json:"name" bson:"name"`
Name string `json:"name" form:"name"`
// 应用是否就绪
Ready *bool `json:"ready" bson:"ready"`
Ready *bool `json:"ready" form:"ready"`
// 关键字
Keywords string `json:"keywords" form:"keywords"`
}
type UpdateApplicationRequest struct {
@ -60,10 +62,22 @@ type UpdateApplicationRequest struct {
CreateApplicationSpec
}
func NewDeleteApplicationRequest(appId string) *DeleteApplicationRequest {
return &DeleteApplicationRequest{
DescribeApplicationRequest: *NewDescribeApplicationRequest(appId),
}
}
type DeleteApplicationRequest struct {
DescribeApplicationRequest
}
func NewDescribeApplicationRequest(appId string) *DescribeApplicationRequest {
return &DescribeApplicationRequest{
Id: appId,
}
}
type DescribeApplicationRequest struct {
policy.ResourceScope
// 应用ID

View File

@ -4,7 +4,7 @@ import { Message } from '@arco-design/web-vue'
// 封装一个axios的实例http cient实例
// https://axios-http.com/zh/docs/instance
const client = axios.create({
timeout: 3000,
timeout: 5000,
})
// 拦截API的返回结果, 如果是异常 提取异常信息,并展示

View File

@ -4,6 +4,9 @@ var MCENTER_API = {
Login: (data) => {
return client.post('/api/devcloud/v1/token', data)
},
LabelList: (params) => {
return client.get('/api/devcloud/v1/labels', { params })
},
}
export default MCENTER_API

View File

@ -4,6 +4,15 @@ var MPAAS_API = {
AppList: (params) => {
return client.get('/api/devcloud/v1/applications', { params })
},
AppCreate: (data) => {
return client.post('/api/devcloud/v1/applications', data)
},
AppUpdate: (id, data) => {
return client.put(`/api/devcloud/v1/applications/${id}`, data)
},
AppDelete: (id) => {
return client.delete(`/api/devcloud/v1/applications/${id}`)
},
}
export default MPAAS_API

View File

@ -134,7 +134,7 @@ const handleUserOption = (option) => {
.router-view-wrapper {
flex: 1;
padding: 20px;
// min-height: calc(100vh - 180px);
min-height: calc(100vh - 180px);
/* 调整最小高度 */
}

View File

@ -174,7 +174,7 @@
.router-view-wrapper {
flex: 1;
padding: 20px;
// min-height: calc(100vh - 180px);
min-height: calc(100vh - 180px);
/* 调整最小高度 */
}

View File

@ -418,7 +418,7 @@ const systemMenus = shallowReactive({
.router-view-wrapper {
flex: 1;
padding: 0px 20px 0px 40px;
// min-height: calc(100vh - 180px);
min-height: calc(100vh - 180px);
/* 动态计算最小高度 */
}

View File

@ -1,63 +1,71 @@
<template>
<div>
<div class="action-bar">
<a-space>
<a-button type="primary">
<template #icon><icon-plus /></template>
新建项目空间
</a-button>
</a-space>
<a-space>
<a-select v-model="query.ready" :style="{ width: '220px', backgroundColor: '#fff' }" @change="fetchAppList"
placeholder="选择状态" allow-clear>
<a-option :value="true">就绪</a-option>
<a-option :value="false">未就绪</a-option>
</a-select>
<a-input-search v-model="searchKey" placeholder="搜索项目名称/描述" allow-clear
style="width: 300px;background-color: #fff;" />
</a-space>
</div>
<a-table :data="data.items" :loading="fetchAppLoading">
<template #columns>
<a-table-column title="名称" data-index="name"></a-table-column>
<a-table-column title="描述" data-index="description"></a-table-column>
<a-table-column title="团队" data-index="label.team"></a-table-column>
<a-table-column title="创建时间" data-index="create_at"></a-table-column>
<a-table-column title="类型" data-index="type"></a-table-column>
<a-table-column title="仓库地址" data-index="code_repository.ssh_url"></a-table-column>
<a-table-column title="是否就绪" data-index="ready">
<template #cell="{ record }">
<a-switch type="round" disabled v-model="record.ready">
<template #checked>
就绪
</template>
<template #unchecked>
未就绪
</template>
</a-switch>
</template>
</a-table-column>
<a-table-column title="操作">
<template #cell="{ record }">
<a-space>
<a-button @click="editApp(record)">编辑</a-button>
<a-button @click="deleteApp(record)" type="primary">删除</a-button>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
<a-card>
<div class="action-bar">
<a-space>
<a-button type="outline" @click="addApp()">
<template #icon><icon-plus /></template>
新建应用
</a-button>
</a-space>
<a-space>
<a-select v-model="query.ready" :style="{ width: '220px' }" @change="fetchAppList" @clear="query.ready = null"
placeholder="选择状态" allow-clear>
<a-option :value="true">就绪</a-option>
<a-option :value="false">未就绪</a-option>
</a-select>
<a-input-search v-model="query.keywords" placeholder="搜索项目名称/描述" allow-clear @press-enter="fetchAppList"
@search="fetchAppList" style="width: 300px" />
</a-space>
</div>
<a-table :data="data.items" :loading="fetchAppLoading">
<template #columns>
<a-table-column title="名称" data-index="name"></a-table-column>
<a-table-column title="描述" data-index="description"></a-table-column>
<a-table-column title="团队" data-index="label.team"></a-table-column>
<a-table-column title="创建时间" data-index="create_at"></a-table-column>
<a-table-column title="类型" data-index="type"></a-table-column>
<a-table-column title="仓库地址" data-index="code_repository.ssh_url"></a-table-column>
<a-table-column title="是否就绪" data-index="ready">
<template #cell="{ record }">
<a-switch type="round" disabled v-model="record.ready">
<template #checked>
就绪
</template>
<template #unchecked>
未就绪
</template>
</a-switch>
</template>
</a-table-column>
<a-table-column title="操作">
<template #cell="{ record }">
<a-space>
<a-button @click="editApp(record)" type="primary">编辑</a-button>
<a-popconfirm :content="`确定要删除${record.name}吗?`" :on-before-ok="() => doDeleteApp(record)"
:ok-loading="deleteAppLoading" @ok="deleteApp(record)" type="warning">
<a-button status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
<AppFormModel v-model:visible="formVisible" @update:visible="(v) => console.log(v)" :appData="currentApp"
@submit="fetchAppList" />
</a-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { onMounted, ref } from 'vue';
import API from '@/api'
import AppFormModel from './components/AppFormModel.vue';
const query = reactive({
const query = ref({
page_size: 20,
page_number: 1,
keywords: '',
});
// API
@ -67,9 +75,13 @@ const data = ref({
});
const fetchAppLoading = ref(false);
const fetchAppList = async () => {
console.log(query.value.ready)
if (query.value.ready === null || query.value.ready === undefined || query.value.ready === '') {
delete query.value.ready;
}
try {
fetchAppLoading.value = true;
const resp = await API.mpaas.AppList(query);
const resp = await API.mpaas.AppList(query.value);
data.value = resp;
console.log('Fetched app list:', data.value);
} catch (error) {
@ -82,4 +94,35 @@ const fetchAppList = async () => {
onMounted(() => {
fetchAppList();
});
//
const formVisible = ref(false);
const currentApp = ref(null);
const addApp = () => {
formVisible.value = true;
currentApp.value = null
}
const editApp = (app) => {
currentApp.value = app;
formVisible.value = true;
};
const deleteAppLoading = ref(false);
const doDeleteApp = async (app) => {
try {
deleteAppLoading.value = true;
await API.mpaas.AppDelete(app.id);
fetchAppList();
} catch (error) {
console.error('Error deleting app:', error);
} finally {
deleteAppLoading.value = false;
}
}
const deleteApp = (app) => {
doDeleteApp(app);
};
</script>

View File

@ -0,0 +1,175 @@
<template>
<a-modal draggable v-model:visible="modelVisible" :ok-loading="createAppLoading" :title="editMode ? '编辑应用' : '新建应用'"
@ok="handleOk" :on-before-ok="handleBeforeOk" @cancel="handleCancel" :ok-text="editMode ? '保存' : '创建'"
unmount-on-close>
<a-form ref="appFormRef" :model="form" layout="vertical">
<a-form-item label="应用名称" field="name" :rules="[{ required: true, message: '请输入应用名称' }]"
:validate-trigger="['change', 'blur']">
<a-input v-model="form.name" placeholder="请输入应用名称" allow-clear />
</a-form-item>
<a-form-item label="应用描述" field="description">
<a-textarea v-model="form.description" placeholder="请输入应用描述" :auto-size="{ minRows: 3, maxRows: 5 }"
allow-clear />
</a-form-item>
<a-form-item label="应用类型" field="type" :rules="[{ required: true, message: '请选择应用类型' }]">
<a-select v-model="form.type" placeholder="应用类型" allow-clear>
<a-option value="SOURCE_CODE">源代码</a-option>
<a-option value="CONTAINER_IMAGE">容器镜像</a-option>
<a-option value="OTHER">其他</a-option>
</a-select>
</a-form-item>
<div v-if="form.type === 'SOURCE_CODE'">
<a-form-item label="代码仓库" field="code_repository.ssh_url" :rules="[{ required: true, message: '请输入代码仓库地址' }]">
<a-input v-model="form.code_repository.ssh_url" placeholder="请输入代码仓库地址" allow-clear />
</a-form-item>
</div>
<a-form-item label="团队" field="label.team" :rules="[{ required: true, message: '请输入团队名称' }]">
<a-tree-select v-model="form.label.team" :field-names="{
key: 'value',
title: 'label'
}" :data="teamOptions" :loading="fetchAppTeamsLoading" placeholder="请选择团队"></a-tree-select>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref, watch, computed, onMounted } from 'vue';
import API from '@/api'
import { useTemplateRef } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
required: true
},
appData: {
type: Object,
default: null
}
});
const emit = defineEmits(['update:visible', 'submit']);
const appFormRef = useTemplateRef('appFormRef')
// 1.
const resetForm = () => {
form.value = {
name: '',
description: '',
code_repository: {
ssh_url: ''
},
label: {
team: ''
}
};
};
// 2.
const modelVisible = computed({
get: () => props.visible,
set: (v) => emit('update:visible', v)
});
const editMode = computed(() => !!props.appData);
const form = ref({
name: '',
description: '',
type: 'SOURCE_CODE',
code_repository: {}
});
//
const fetchAppTeamsLoading = ref(false);
const teamOptions = ref([])
const fetchAppTeams = async () => {
try {
fetchAppTeamsLoading.value = true
const resp = await API.mcenter.LabelList({
key: 'team',
});
console.log(resp.items)
if (resp.items.length > 0) {
teamOptions.value = resp.items[0].enum_options
}
} catch (error) {
console.error('Error fetching app teams:', error);
return [];
} finally {
fetchAppTeamsLoading.value = false
}
};
onMounted(() => {
if (props.appData) {
form.value = { ...props.appData };
} else {
resetForm();
}
fetchAppTeams()
});
// 3. watchresetForm
watch(() => props.appData, (newVal) => {
if (newVal) {
form.value = { ...newVal };
} else {
resetForm();
}
}, { immediate: true });
// ,
const createAppLoading = ref(false)
const handleBeforeOk = async () => {
//
const resp = await appFormRef.value.validate();
if (resp) {
return false;
}
//
createAppLoading.value = true
try {
createAppLoading.value = true
await API.mpaas.AppCreate({
...form.value,
name: form.value.name.trim()
});
return true
} catch (error) {
console.error('Error creating app:', error);
} finally {
createAppLoading.value = false
}
return false
}
const handleOk = async () => {
emit('submit', {
...form.value,
name: form.value.name.trim()
});
resetForm(); //
};
const handleCancel = () => {
resetForm(); //
};
</script>
<style scoped>
/* 可以添加自定义样式 */
:deep(.arco-form-item-label) {
font-weight: 500;
}
</style>

2
go.mod
View File

@ -6,7 +6,7 @@ require (
github.com/caarlos0/env/v6 v6.10.1
github.com/emicklei/go-restful-openapi/v2 v2.11.0
github.com/emicklei/go-restful/v3 v3.12.2
github.com/gin-gonic/gin v1.10.0
github.com/gin-gonic/gin v1.10.1
github.com/google/uuid v1.6.0
github.com/infraboard/devops v0.0.6
github.com/infraboard/mcube/v2 v2.0.63

4
go.sum
View File

@ -36,8 +36,8 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=