diff --git a/admin/src/api/admin.js b/admin/src/api/admin.js index 1c14a98..c2d2ace 100644 --- a/admin/src/api/admin.js +++ b/admin/src/api/admin.js @@ -8,7 +8,9 @@ export const getMyPermissions = () => request.get('/admin/my-permissions') // 角色权限管理 export const getRolePermissionsList = () => request.get('/admin/role-permissions') +export const createRole = (data) => request.post('/admin/role-permissions', data) export const updateRolePermissions = (roleId, data) => request.put(`/admin/role-permissions/${roleId}`, data) +export const deleteRole = (roleId) => request.delete(`/admin/role-permissions/${roleId}`) // 后台注册(手机号+验证码) export const sendCode = (mobile) => request.post('/admin/send-code', { mobile }) @@ -63,11 +65,15 @@ export const getHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homep export const updateHomepage = (siteId, data) => request.put(`/admin/sites/${siteId}/homepage`, data) export const downloadHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage/download`, { responseType: 'blob' }) -// 功能模块上传 -export const getSiteAssets = (siteId) => request.get(`/admin/sites/${siteId}/assets`) -export const uploadSiteAsset = (siteId, file) => { +// 文件管理(功能模块:多级目录、可下载) +export const getSiteAssets = (siteId, path) => + request.get(`/admin/sites/${siteId}/assets`, { params: path ? { path } : {} }) +export const uploadSiteAsset = (siteId, file, opts = {}) => { const form = new FormData() form.append('file', file) + if (opts.folder != null) form.append('folder', opts.folder) + form.append('downloadable', opts.downloadable ? 'true' : 'false') return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) } +export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path }) export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`) diff --git a/admin/src/layouts/AdminLayout.vue b/admin/src/layouts/AdminLayout.vue index 2b0a300..c4aa425 100644 --- a/admin/src/layouts/AdminLayout.vue +++ b/admin/src/layouts/AdminLayout.vue @@ -71,16 +71,7 @@ const menuItems = computed(() => { { index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' }, { index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' }, { index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' }, - { - index: 'files', - title: '文件管理', - icon: Folder, - permission: null, - children: [ - { index: '/files/images', title: '图片管理(含图标)', permission: null }, - { index: '/module-upload', title: '功能模块上传', permission: 'module:upload' } - ] - }, + { index: '/files', title: '文件管理', icon: Folder, permission: null }, { index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' } ] return all.filter((item) => { diff --git a/admin/src/router/index.js b/admin/src/router/index.js index 78af748..bf4b8da 100644 --- a/admin/src/router/index.js +++ b/admin/src/router/index.js @@ -67,16 +67,10 @@ const routes = [ meta: { title: '首页编辑', permission: 'homepage:edit' } }, { - path: 'files/images', - name: 'FileImages', - component: () => import('../views/files/FileImages.vue'), - meta: { title: '图片管理', permission: null } - }, - { - path: 'module-upload', - name: 'ModuleUpload', - component: () => import('../views/sites/ModuleUpload.vue'), - meta: { title: '功能模块上传', permission: 'module:upload' } + path: 'files', + name: 'FileManage', + component: () => import('../views/files/FileManage.vue'), + meta: { title: '文件管理', permission: null } }, { path: 'role-permissions', diff --git a/admin/src/views/files/FileManage.vue b/admin/src/views/files/FileManage.vue new file mode 100644 index 0000000..1c24d06 --- /dev/null +++ b/admin/src/views/files/FileManage.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/admin/src/views/settings/RolePermissions.vue b/admin/src/views/settings/RolePermissions.vue index be84215..1876bc7 100644 --- a/admin/src/views/settings/RolePermissions.vue +++ b/admin/src/views/settings/RolePermissions.vue @@ -4,14 +4,22 @@ -

超级管理员(9527)拥有全部权限且不可修改。为其他角色勾选其可用的后台权限。

+

超级管理员(9527)拥有全部权限且不可修改。为其他角色勾选其可用的后台权限;可创建自定义角色并赋权。

- + + + - + + + + + + + + + + + +
+ + {{ p.name }} + +
+
+
+ +
diff --git a/docker-compose.yml b/docker-compose.yml index ccb1bb3..e5f9a51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: container_name: yh_api volumes: - ./deploy/api:/app:ro + - ./data/uploads:/app/uploads env_file: - ./server/.env environment: diff --git a/server/handlers/module_upload.go b/server/handlers/module_upload.go index 7ff83ce..1d29cfa 100644 --- a/server/handlers/module_upload.go +++ b/server/handlers/module_upload.go @@ -5,6 +5,9 @@ import ( "net/http" "os" "path/filepath" + "regexp" + "sort" + "strings" "time" "go.mongodb.org/mongo-driver/v2/bson" @@ -18,20 +21,35 @@ import ( const uploadDir = "uploads" -// ListSiteAssets 站点功能模块/上传文件列表 +// pathPrefix 站点下相对路径前缀,用于多级目录 +func pathPrefix(siteID string) string { + return "sites/" + siteID + "/" +} + +// ListSiteAssets 站点功能模块/上传文件列表;query path 为当前目录相对路径(空为根) func ListSiteAssets(c *gin.Context) { siteID := c.Param("site_id") if siteID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"}) return } + path := c.Query("path") + prefix := pathPrefix(siteID) + if path != "" { + prefix = prefix + path + if prefix[len(prefix)-1] != '/' { + prefix += "/" + } + } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() coll := config.GetDB(config.DBName).Collection("site_assets") + // 仅当前目录下直接文件(file_path 为 prefix + 不含 / 的文件名) + filter := bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix) + "[^/]+$"}} opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) - cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts) + cursor, err := coll.Find(ctx, filter, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -43,11 +61,60 @@ func ListSiteAssets(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - total, _ := coll.CountDocuments(ctx, bson.M{"site_id": siteID}) - c.JSON(http.StatusOK, gin.H{"list": list, "total": total}) + total, _ := coll.CountDocuments(ctx, filter) + // 子目录列表:从 file_path 中提取当前 path 下的一级子目录名 + subDirs := listSubDirs(c, siteID, path) + c.JSON(http.StatusOK, gin.H{"list": list, "total": total, "sub_dirs": subDirs}) } -// UploadSiteAsset 上传功能模块/文件 +func listSubDirs(c *gin.Context, siteID, currentPath string) []string { + prefix := pathPrefix(siteID) + if currentPath != "" { + prefix = prefix + currentPath + if prefix[len(prefix)-1] != '/' { + prefix += "/" + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + coll := config.GetDB(config.DBName).Collection("site_assets") + cursor, err := coll.Find(ctx, bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix)}}) + if err != nil { + return nil + } + defer cursor.Close(ctx) + var docs []struct { + FilePath string `bson:"file_path"` + } + _ = cursor.All(ctx, &docs) + seen := make(map[string]bool) + for _, d := range docs { + rel := strings.TrimPrefix(d.FilePath, prefix) + if rel == "" || rel == d.FilePath { + continue + } + parts := strings.SplitN(rel, "/", 2) + if len(parts) > 0 && parts[0] != "" { + seen[parts[0]] = true + } + } + // 再扫描物理目录 + baseDir := filepath.Join(uploadDir, filepath.FromSlash(prefix)) + entries, _ := os.ReadDir(baseDir) + for _, e := range entries { + if e.IsDir() { + seen[e.Name()] = true + } + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/false) func UploadSiteAsset(c *gin.Context) { siteID := c.Param("site_id") if siteID == "" { @@ -61,19 +128,21 @@ func UploadSiteAsset(c *gin.Context) { return } - baseDir := filepath.Join(uploadDir, "sites", siteID) + folder := c.PostForm("folder") + downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1" + baseDir := filepath.Join(uploadDir, "sites", siteID, filepath.Clean(folder)) if err := os.MkdirAll(baseDir, 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"}) return } - // 避免覆盖:加时间戳 name := file.Filename ext := filepath.Ext(name) nameNoExt := name[:len(name)-len(ext)] saveName := nameNoExt + "_" + time.Now().Format("20060102150405") + ext - relPath := filepath.Join("sites", siteID, saveName) - destPath := filepath.Join(uploadDir, relPath) + relPath := filepath.Join("sites", siteID, filepath.Clean(folder), saveName) + relPath = filepath.ToSlash(relPath) + destPath := filepath.Join(uploadDir, filepath.FromSlash(relPath)) if err := c.SaveUploadedFile(file, destPath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) @@ -84,12 +153,13 @@ func UploadSiteAsset(c *gin.Context) { defer cancel() doc := models.SiteAsset{ - SiteID: siteID, - Name: file.Filename, - FilePath: relPath, - Size: file.Size, - ContentType: file.Header.Get("Content-Type"), - CreatedAt: time.Now().Format(time.RFC3339), + SiteID: siteID, + Name: file.Filename, + FilePath: relPath, + Size: file.Size, + ContentType: file.Header.Get("Content-Type"), + Downloadable: downloadable, + CreatedAt: time.Now().Format(time.RFC3339), } res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{ "site_id": doc.SiteID, @@ -97,6 +167,7 @@ func UploadSiteAsset(c *gin.Context) { "file_path": doc.FilePath, "size": doc.Size, "content_type": doc.ContentType, + "downloadable": doc.Downloadable, "created_at": doc.CreatedAt, }) if err != nil { @@ -133,7 +204,7 @@ func DeleteSiteAsset(c *gin.Context) { return } - fullPath := filepath.Join(uploadDir, asset.FilePath) + fullPath := filepath.Join(uploadDir, filepath.FromSlash(asset.FilePath)) os.Remove(fullPath) _, err = coll.DeleteOne(ctx, bson.M{"_id": oid}) @@ -143,3 +214,33 @@ func DeleteSiteAsset(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) } + +// CreateSiteFolderInput 创建目录 +type CreateSiteFolderInput struct { + Path string `json:"path" binding:"required"` +} + +// CreateSiteFolder 在站点下创建多级目录 +func CreateSiteFolder(c *gin.Context) { + siteID := c.Param("site_id") + if siteID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"}) + return + } + var input CreateSiteFolderInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请填写目录路径"}) + return + } + clean := filepath.Clean(input.Path) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"}) + return + } + baseDir := filepath.Join(uploadDir, "sites", siteID, clean) + if err := os.MkdirAll(baseDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "创建成功", "path": filepath.ToSlash(clean)}) +} diff --git a/server/handlers/role_permission.go b/server/handlers/role_permission.go index ad1e9ac..e835716 100644 --- a/server/handlers/role_permission.go +++ b/server/handlers/role_permission.go @@ -15,17 +15,9 @@ import ( "github.com/gin-gonic/gin" ) -// 预定义角色(与 users.role_id 对应) -var roleMeta = []struct { - RoleID int `json:"role_id"` - RoleName string `json:"role_name"` -}{ - {models.RoleIDSuperAdmin, "超级管理员"}, - {models.RoleIDSuperUser, "超级用户"}, - {models.RoleIDUser, "普通用户"}, -} +const customRoleIDStart = 1000 // 自定义角色 role_id 从此值起 -// GetRolePermissionsList 返回所有角色及其权限(用于角色权限管理页) +// GetRolePermissionsList 返回所有角色及其权限(含预定义与自定义) func GetRolePermissionsList(c *gin.Context) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -44,23 +36,56 @@ func GetRolePermissionsList(c *gin.Context) { return } permMap := make(map[int][]string) + nameMap := make(map[int]string) for _, d := range docs { permMap[d.RoleID] = d.Permissions + if d.RoleName != "" { + nameMap[d.RoleID] = d.RoleName + } } - - list := make([]gin.H, 0, len(roleMeta)) - for _, r := range roleMeta { - perms := permMap[r.RoleID] + allKeys := allPermissionKeys() + // 预定义角色固定在前(9527, 0, 1),再按 role_id 排自定义 + predef := []int{models.RoleIDSuperAdmin, models.RoleIDSuperUser, models.RoleIDUser} + seen := make(map[int]bool) + list := make([]gin.H, 0) + for _, rid := range predef { + seen[rid] = true + perms := permMap[rid] if perms == nil { perms = []string{} } - if r.RoleID == models.RoleIDSuperAdmin { - perms = allPermissionKeys() + if rid == models.RoleIDSuperAdmin { + perms = allKeys + } + name := nameMap[rid] + if name == "" { + name = models.DefaultRoleNames[rid] } list = append(list, gin.H{ - "role_id": r.RoleID, - "role_name": r.RoleName, + "role_id": rid, + "role_name": name, "permissions": perms, + "is_custom": false, + }) + } + for _, d := range docs { + if seen[d.RoleID] { + continue + } + seen[d.RoleID] = true + name := d.RoleName + if name == "" { + name = "角色" + strconv.Itoa(d.RoleID) + } + perms := d.Permissions + if perms == nil { + perms = []string{} + } + list = append(list, gin.H{ + "role_id": d.RoleID, + "role_name": name, + "permissions": perms, + "is_custom": true, }) } c.JSON(http.StatusOK, gin.H{ @@ -69,11 +94,6 @@ func GetRolePermissionsList(c *gin.Context) { }) } -// UpdateRolePermissionsInput 更新某角色权限 -type UpdateRolePermissionsInput struct { - Permissions []string `json:"permissions"` -} - // UpdateRolePermissions 更新指定角色的权限 func UpdateRolePermissions(c *gin.Context) { roleIDStr := c.Param("role_id") @@ -87,7 +107,10 @@ func UpdateRolePermissions(c *gin.Context) { return } - var input UpdateRolePermissionsInput + var input struct { + RoleName string `json:"role_name"` + Permissions []string `json:"permissions"` + } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -98,7 +121,11 @@ func UpdateRolePermissions(c *gin.Context) { coll := config.GetDB(config.DBName).Collection("role_permissions") filter := bson.M{"role_id": roleID} - update := bson.M{"$set": bson.M{"role_id": roleID, "permissions": input.Permissions}} + set := bson.M{"role_id": roleID, "permissions": input.Permissions} + if input.RoleName != "" && roleID >= customRoleIDStart { + set["role_name"] = input.RoleName + } + update := bson.M{"$set": set} opts := options.UpdateOne().SetUpsert(true) _, err = coll.UpdateOne(ctx, filter, update, opts) if err != nil { @@ -107,3 +134,72 @@ func UpdateRolePermissions(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "保存成功", "role_id": roleID, "permissions": input.Permissions}) } + +// CreateRoleInput 创建角色 +type CreateRoleInput struct { + RoleName string `json:"role_name" binding:"required"` + Permissions []string `json:"permissions"` +} + +// CreateRole 创建自定义角色 +func CreateRole(c *gin.Context) { + var input CreateRoleInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请填写角色名称"}) + return + } + if input.Permissions == nil { + input.Permissions = []string{} + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + coll := config.GetDB(config.DBName).Collection("role_permissions") + cursor, _ := coll.Find(ctx, bson.M{"role_id": bson.M{"$gte": customRoleIDStart}}, options.Find().SetSort(bson.D{{Key: "role_id", Value: -1}}).SetLimit(1)) + var docs []models.RolePermissionsDoc + _ = cursor.All(ctx, &docs) + cursor.Close(ctx) + nextID := customRoleIDStart + for _, d := range docs { + if d.RoleID >= customRoleIDStart { + nextID = d.RoleID + 1 + break + } + } + + doc := models.RolePermissionsDoc{ + RoleID: nextID, + RoleName: input.RoleName, + Permissions: input.Permissions, + } + _, err := coll.InsertOne(ctx, bson.M{"role_id": doc.RoleID, "role_name": doc.RoleName, "permissions": doc.Permissions}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "创建成功", "role_id": doc.RoleID, "role_name": doc.RoleName, "permissions": doc.Permissions}) +} + +// DeleteRole 删除自定义角色(仅 role_id >= customRoleIDStart) +func DeleteRole(c *gin.Context) { + roleIDStr := c.Param("role_id") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 role_id"}) + return + } + if roleID < customRoleIDStart { + c.JSON(http.StatusBadRequest, gin.H{"error": "预定义角色不可删除"}) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + coll := config.GetDB(config.DBName).Collection("role_permissions") + _, err = coll.DeleteOne(ctx, bson.M{"role_id": roleID}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/server/main.go b/server/main.go index 5ea46e7..a60c8df 100644 --- a/server/main.go +++ b/server/main.go @@ -163,6 +163,7 @@ func main() { admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage) admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets) admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset) + admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder) admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset) admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites) admin.GET("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.GetSiteByID) @@ -174,7 +175,9 @@ func main() { // 角色权限管理 admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList) + admin.POST("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.CreateRole) admin.PUT("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.UpdateRolePermissions) + admin.DELETE("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.DeleteRole) // 网页管理(按站点) admin.GET("/pages", handlers.RequirePermission(models.PermPageManage), handlers.GetPages) diff --git a/server/models/permission.go b/server/models/permission.go index 7543e4c..fcb8e89 100644 --- a/server/models/permission.go +++ b/server/models/permission.go @@ -31,8 +31,16 @@ var AllPermissions = []struct { {PermRolePermission, "角色权限管理"}, } -// RolePermissionsDoc MongoDB 文档:角色 ID -> 权限列表 +// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色) type RolePermissionsDoc struct { RoleID int `bson:"role_id" json:"role_id"` + RoleName string `bson:"role_name,omitempty" json:"role_name"` Permissions []string `bson:"permissions" json:"permissions"` } + +// 预定义角色 ID 的默认名称(未在 DB 中存 role_name 时使用) +var DefaultRoleNames = map[int]string{ + RoleIDSuperAdmin: "超级管理员", + RoleIDSuperUser: "超级用户", + RoleIDUser: "普通用户", +} diff --git a/server/models/site.go b/server/models/site.go index d66e28a..647a402 100644 --- a/server/models/site.go +++ b/server/models/site.go @@ -56,11 +56,12 @@ type FeatureItem struct { // SiteAsset 站点功能模块/上传文件 type SiteAsset struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"id"` - SiteID string `bson:"site_id" json:"site_id"` - Name string `bson:"name" json:"name"` - FilePath string `bson:"file_path" json:"file_path"` // 相对路径 - Size int64 `bson:"size" json:"size"` - ContentType string `bson:"content_type" json:"content_type"` - CreatedAt string `bson:"created_at" json:"created_at"` + ID bson.ObjectID `bson:"_id,omitempty" json:"id"` + SiteID string `bson:"site_id" json:"site_id"` + Name string `bson:"name" json:"name"` + FilePath string `bson:"file_path" json:"file_path"` // 相对路径,可含多级目录 + Size int64 `bson:"size" json:"size"` + ContentType string `bson:"content_type" json:"content_type"` + Downloadable bool `bson:"downloadable" json:"downloadable"` // 是否允许下载 + CreatedAt string `bson:"created_at" json:"created_at"` }