From 154b512106bf9216326cda781527839437a1bc38 Mon Sep 17 00:00:00 2001 From: yqz <144-yqz@users.noreply.gitlab.topviewclub.cn> Date: Wed, 27 May 2026 12:06:02 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat=EF=BC=9A=E5=B0=86=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=96=87=E7=AB=A0=E7=BC=96=E8=BE=91=EF=BC=8C=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vue/src/api/media.js | 8 +- vue/src/api/mediaFolder.js | 32 +++ vue/src/assets/blog.css | 252 +++++++++++++++++++++++ vue/src/components/MediaTreeBranch.vue | 53 +++++ vue/src/components/MediaTreeSidebar.vue | 204 ++++++++++++++++++ vue/src/utils/mediaTree.js | 37 ++++ vue/src/views/ConsoleAttachmentsView.vue | 251 ++++++++-------------- 7 files changed, 665 insertions(+), 172 deletions(-) create mode 100644 vue/src/api/mediaFolder.js create mode 100644 vue/src/components/MediaTreeBranch.vue create mode 100644 vue/src/components/MediaTreeSidebar.vue create mode 100644 vue/src/utils/mediaTree.js diff --git a/vue/src/api/media.js b/vue/src/api/media.js index 82e601a..6a1de65 100644 --- a/vue/src/api/media.js +++ b/vue/src/api/media.js @@ -7,10 +7,10 @@ function getAuthHeader() { return { Authorization: `Bearer ${token}` } } -export async function uploadMedia(file, category = 'general') { +export async function uploadMedia(file, folderId) { const fd = new FormData() fd.append('file', file) - fd.append('category', category) + if (folderId != null) fd.append('folderId', folderId) const res = await fetch(`${API_BASE}/api/v1/media/upload`, { method: 'POST', @@ -42,9 +42,9 @@ export async function uploadMedia(file, category = 'general') { return json?.data ?? json } -export async function fetchMediaList(page = 0, size = 20, category) { +export async function fetchMediaList(page = 0, size = 20, folderId) { let url = `${API_BASE}/api/v1/media?page=${page}&size=${size}` - if (category) url += `&category=${encodeURIComponent(category)}` + if (folderId != null) url += `&folderId=${folderId}` const res = await fetch(url, { method: 'GET', headers: { diff --git a/vue/src/api/mediaFolder.js b/vue/src/api/mediaFolder.js new file mode 100644 index 0000000..2ffea66 --- /dev/null +++ b/vue/src/api/mediaFolder.js @@ -0,0 +1,32 @@ +import { request } from './http' + +export function fetchFolderTree() { + return request('/api/v1/media-folders/tree') +} + +export function fetchFolderList() { + return request('/api/v1/media-folders') +} + +export function createFolder(payload) { + return request('/api/v1/media-folders', { + method: 'POST', + withAuth: true, + body: JSON.stringify(payload) + }) +} + +export function updateFolder(id, payload) { + return request(`/api/v1/media-folders/${id}`, { + method: 'PUT', + withAuth: true, + body: JSON.stringify(payload) + }) +} + +export function deleteFolder(id) { + return request(`/api/v1/media-folders/${id}`, { + method: 'DELETE', + withAuth: true + }) +} diff --git a/vue/src/assets/blog.css b/vue/src/assets/blog.css index 4d35466..5fed7b7 100644 --- a/vue/src/assets/blog.css +++ b/vue/src/assets/blog.css @@ -1396,6 +1396,258 @@ body { } } +/* ---- media folder tree (附件管理) ---- */ + +.media-tree-sidebar { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.media-tree-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 14px 8px; +} + +.media-tree-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--console-muted, var(--muted)); +} + +.media-tree-add-btn { + border: 1px solid var(--border, rgba(15, 23, 42, 0.12)); + background: transparent; + color: var(--console-muted, var(--muted)); + cursor: pointer; + width: 24px; + height: 24px; + border-radius: 4px; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.media-tree-add-btn:hover { + border-color: var(--primary, #2563eb); + color: var(--primary, #2563eb); +} + +.media-tree-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 4px 8px 12px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.media-tree-hint { + padding: 16px 10px; + font-size: 13px; + color: var(--console-muted, var(--muted)); +} + +.media-tree-root-btn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + border: none; + background: transparent; + color: var(--text); + cursor: pointer; + padding: 9px 12px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + text-align: left; +} + +.media-tree-root-btn:hover { + background: rgba(74, 163, 255, 0.08); +} + +.media-tree-root-btn.active { + background: var(--primary, #2563eb); + color: #fff; +} + +.media-tree-root-btn.active .media-tree-group-count { + color: rgba(255, 255, 255, 0.7); +} + +.media-tree-group { + display: flex; + flex-direction: column; +} + +.media-tree-group-head { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + border: none; + background: transparent; + color: var(--text); + cursor: pointer; + padding: 8px 10px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + line-height: 1.45; + text-align: left; + transition: background 0.12s; +} + +.media-tree-group-head:hover { + background: rgba(74, 163, 255, 0.08); +} + +.media-tree-group-head.active { + background: var(--primary, #2563eb); + color: #fff; +} + +.media-tree-group-head.active .media-tree-group-count, +.media-tree-group-head.active .media-tree-chev { + color: rgba(255, 255, 255, 0.7); +} + +.media-tree-chev { + flex: 0 0 auto; + width: 14px; + font-size: 11px; + color: var(--muted); + line-height: 1; + text-align: center; +} + +.media-tree-chev--empty { + visibility: hidden; +} + +.media-tree-group-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.media-tree-group-count { + flex: 0 0 auto; + font-size: 10px; + color: var(--console-muted, var(--muted)); + min-width: 18px; + text-align: center; +} + +.media-tree-group-body { + display: flex; + flex-direction: column; + padding-left: 14px; +} + +.media-tree-group-body .media-tree-group-body { + padding-left: 12px; +} + +.media-tree-edit-row { + padding: 6px 10px; +} + +.media-tree-edit-input { + width: 100%; + padding: 6px 8px; + font-size: 13px; + border: 1px solid var(--primary, #2563eb); + border-radius: 4px; + outline: none; +} + +.media-tree-footer { + padding: 12px 10px; + border-top: 1px solid var(--border, rgba(15, 23, 42, 0.08)); +} + +.media-tree-upload-btn { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border, rgba(15, 23, 42, 0.12)); + border-radius: 8px; + background: var(--card, #ffffff); + color: var(--text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + font-family: inherit; +} + +.media-tree-upload-btn:hover:not(:disabled) { + border-color: var(--primary, #2563eb); + color: var(--primary, #2563eb); +} + +.media-tree-upload-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* context menu */ +.media-tree-context-overlay { + position: fixed; + inset: 0; + z-index: 250; +} + +.media-tree-context-menu { + position: absolute; + background: var(--card, #ffffff); + border: 1px solid var(--border, rgba(15, 23, 42, 0.1)); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + padding: 4px; + min-width: 140px; + display: flex; + flex-direction: column; +} + +.media-tree-context-menu button { + display: block; + width: 100%; + border: none; + background: transparent; + color: var(--text); + cursor: pointer; + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + text-align: left; + font-family: inherit; +} + +.media-tree-context-menu button:hover { + background: rgba(74, 163, 255, 0.08); +} + +.media-tree-context-menu button.danger { + color: #dc2626; +} + +.media-tree-context-menu button.danger:hover { + background: rgba(220, 38, 38, 0.08); +} + /* 文章详情:正文保持居中阅读列,右侧大纲悬浮在空白区 */ .article-detail-page .article-detail-wrap { max-width: 720px; diff --git a/vue/src/components/MediaTreeBranch.vue b/vue/src/components/MediaTreeBranch.vue new file mode 100644 index 0000000..3a0e8e6 --- /dev/null +++ b/vue/src/components/MediaTreeBranch.vue @@ -0,0 +1,53 @@ + + + diff --git a/vue/src/components/MediaTreeSidebar.vue b/vue/src/components/MediaTreeSidebar.vue new file mode 100644 index 0000000..d471a44 --- /dev/null +++ b/vue/src/components/MediaTreeSidebar.vue @@ -0,0 +1,204 @@ + + + diff --git a/vue/src/utils/mediaTree.js b/vue/src/utils/mediaTree.js new file mode 100644 index 0000000..c6a8811 --- /dev/null +++ b/vue/src/utils/mediaTree.js @@ -0,0 +1,37 @@ +export function mapFolderNode(node) { + const children = (node.children || []).map(mapFolderNode) + const childFileCount = children.reduce((sum, c) => sum + (c.fileCount || 0), 0) + return { + key: `folder-${node.id}`, + label: node.name, + folderId: node.id, + fileCount: (node.fileCount || 0) + childFileCount, + children + } +} + +/** 将后端返回的树节点转为前端树(添加 key、递归处理) */ +export function buildFolderTree(folders) { + return (folders || []).map(mapFolderNode) +} + +/** 收集从根到指定文件夹的展开路径 */ +export function collectExpandedKeysForFolder(tree, folderId) { + if (!folderId) return [] + + function walk(node, path) { + const nextPath = [...path, node.key] + if (node.folderId === Number(folderId)) return nextPath + for (const child of node.children) { + const found = walk(child, nextPath) + if (found) return found + } + return null + } + + for (const node of tree) { + const found = walk(node, []) + if (found) return found + } + return [] +} diff --git a/vue/src/views/ConsoleAttachmentsView.vue b/vue/src/views/ConsoleAttachmentsView.vue index 7cc1e52..08a7e5d 100644 --- a/vue/src/views/ConsoleAttachmentsView.vue +++ b/vue/src/views/ConsoleAttachmentsView.vue @@ -9,38 +9,24 @@
@@ -59,7 +45,6 @@
-
{{ catLabel(m.category) }}
{{ m.width }}x{{ m.height }}
{{ formatSize(m.size) }}
{{ formatDate(m.createdAt) }}
@@ -73,8 +58,8 @@
- {{ page + 1 }} - + {{ page + 1 }} / {{ totalPages || 1 }} +
@@ -94,8 +79,10 @@ @@ -234,76 +251,13 @@ onMounted(() => { /* sidebar */ .attachments-sidebar { - width: 200px; + width: 220px; flex-shrink: 0; border-right: 1px solid var(--border, rgba(15, 23, 42, 0.08)); - padding: 20px 16px; display: flex; flex-direction: column; } -.attachments-sidebar-title { - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--console-muted, var(--muted)); - margin-bottom: 12px; -} - -.attachments-folder-list { - list-style: none; - padding: 0; - margin: 0; - flex: 1; -} - -.attachments-folder-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 10px; - border-radius: 6px; - cursor: pointer; - font-size: 13px; - transition: background 0.15s; - margin-bottom: 2px; -} - -.attachments-folder-item:hover { - background: var(--bg-muted, rgba(15, 23, 42, 0.04)); -} - -.attachments-folder-item.active { - background: var(--primary, #2563eb); - color: #fff; -} - -.attachments-folder-item.active .attachments-folder-count { - color: rgba(255, 255, 255, 0.7); -} - -.attachments-folder-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attachments-folder-count { - font-size: 11px; - color: var(--console-muted, var(--muted)); - flex-shrink: 0; - margin-left: 8px; -} - -.attachments-sidebar-upload { - margin-top: 16px; -} - -.attachments-sidebar-upload .btn { - width: 100%; -} - /* main */ .attachments-main { flex: 1; @@ -352,12 +306,6 @@ onMounted(() => { padding: 10px 12px; } -.attachments-card-cat { - font-size: 10px; - color: var(--primary, #2563eb); - margin-bottom: 2px; -} - .attachments-card-dims { font-size: 12px; color: var(--text, #111827); @@ -398,7 +346,7 @@ onMounted(() => { .attachments-page-num { font-size: 13px; color: var(--console-muted, var(--muted)); - min-width: 32px; + min-width: 64px; text-align: center; } @@ -448,39 +396,6 @@ onMounted(() => { width: 100%; border-right: none; border-bottom: 1px solid var(--border, rgba(15, 23, 42, 0.08)); - padding: 12px 16px; - flex-direction: row; - flex-wrap: wrap; - gap: 8px; - align-items: center; - } - - .attachments-sidebar-title { - display: none; - } - - .attachments-folder-list { - display: flex; - gap: 6px; - flex: unset; - } - - .attachments-folder-item { - padding: 4px 10px; - font-size: 12px; - } - - .attachments-folder-count { - display: none; - } - - .attachments-sidebar-upload { - margin-top: 0; - margin-left: auto; - } - - .attachments-sidebar-upload .btn { - width: auto; } .attachments-main { From dd052b781041ad5142617a1c3755bb29d925fb39 Mon Sep 17 00:00:00 2001 From: yqz <144-yqz@users.noreply.gitlab.topviewclub.cn> Date: Wed, 27 May 2026 12:19:59 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat=EF=BC=9A=E5=B0=86=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=96=87=E7=AB=A0=E7=BC=96=E8=BE=91=EF=BC=8C=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/controller/MediaController.java | 8 +- .../controller/MediaFolderController.java | 55 +++++ .../dto/MediaFolderFlatItemResponse.java | 53 +++++ .../dto/MediaFolderTreeNodeResponse.java | 62 +++++ .../media/dto/MediaFolderUpsertRequest.java | 39 ++++ .../media/dto/MediaListItemResponse.java | 4 + .../media/dto/MediaUploadResponse.java | 9 + .../com/yqz/openblog/media/entity/Media.java | 11 + .../openblog/media/entity/MediaFolder.java | 86 +++++++ .../media/repo/MediaFolderRepository.java | 13 ++ .../media/service/MediaFolderService.java | 215 ++++++++++++++++++ .../openblog/media/service/MediaService.java | 20 +- .../src/main/resources/sql/media_folder.sql | 9 + vue/src/api/mediaFolder.js | 4 +- vue/src/components/MediaTreeSidebar.vue | 6 +- vue/src/views/ConsoleAttachmentsView.vue | 6 +- 16 files changed, 586 insertions(+), 14 deletions(-) create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaFolderController.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderFlatItemResponse.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderTreeNodeResponse.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderUpsertRequest.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/media/entity/MediaFolder.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/media/repo/MediaFolderRepository.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaFolderService.java create mode 100644 OpenBlog-business/src/main/resources/sql/media_folder.sql diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaController.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaController.java index 17e10cc..b383584 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaController.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaController.java @@ -31,16 +31,16 @@ public MediaController(MediaService mediaService) { @PostMapping(value = "/upload", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse upload( @RequestParam("file") MultipartFile file, - @RequestParam(defaultValue = "general") String category) throws IOException { - return ApiResponse.ok(mediaService.upload(file, category)); + @RequestParam(required = false) Long folderId) throws IOException { + return ApiResponse.ok(mediaService.upload(file, folderId)); } @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse> list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) String category) { - return ApiResponse.ok(mediaService.listMyMedia(page, size, category)); + @RequestParam(required = false) Long folderId) { + return ApiResponse.ok(mediaService.listMyMedia(page, size, folderId)); } @GetMapping(value = "/categories", produces = MediaType.APPLICATION_JSON_VALUE) diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaFolderController.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaFolderController.java new file mode 100644 index 0000000..e8b16fb --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaFolderController.java @@ -0,0 +1,55 @@ +package com.yqz.openblog.media.controller; + +import com.yqz.openblog.common.ApiResponse; +import com.yqz.openblog.media.dto.MediaFolderFlatItemResponse; +import com.yqz.openblog.media.dto.MediaFolderTreeNodeResponse; +import com.yqz.openblog.media.dto.MediaFolderUpsertRequest; +import com.yqz.openblog.media.service.MediaFolderService; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/media-folders") +@CrossOrigin(origins = "*") +public class MediaFolderController { + + private final MediaFolderService folderService; + + public MediaFolderController(MediaFolderService folderService) { + this.folderService = folderService; + } + + @GetMapping("/tree") + public ApiResponse> tree() { + return ApiResponse.ok(folderService.listTree()); + } + + @GetMapping + public ApiResponse> listFlat() { + return ApiResponse.ok(folderService.listFlat()); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse create(@RequestBody @Valid MediaFolderUpsertRequest req) { + return ApiResponse.ok(folderService.create(req)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse update( + @PathVariable Long id, + @RequestBody @Valid MediaFolderUpsertRequest req) { + return ApiResponse.ok(folderService.update(id, req)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse delete(@PathVariable Long id) { + folderService.delete(id); + return ApiResponse.ok(); + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderFlatItemResponse.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderFlatItemResponse.java new file mode 100644 index 0000000..e54f037 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderFlatItemResponse.java @@ -0,0 +1,53 @@ +package com.yqz.openblog.media.dto; + +import java.util.ArrayList; +import java.util.List; + +public class MediaFolderFlatItemResponse { + + private Long id; + private String name; + private Long parentId; + private Integer sortOrder; + private List path = new ArrayList<>(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public List getPath() { + return path; + } + + public void setPath(List path) { + this.path = path; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderTreeNodeResponse.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderTreeNodeResponse.java new file mode 100644 index 0000000..e020a66 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderTreeNodeResponse.java @@ -0,0 +1,62 @@ +package com.yqz.openblog.media.dto; + +import java.util.ArrayList; +import java.util.List; + +public class MediaFolderTreeNodeResponse { + + private Long id; + private String name; + private Long parentId; + private Integer sortOrder; + private long fileCount; + private List children = new ArrayList<>(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public long getFileCount() { + return fileCount; + } + + public void setFileCount(long fileCount) { + this.fileCount = fileCount; + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderUpsertRequest.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderUpsertRequest.java new file mode 100644 index 0000000..c8984fa --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaFolderUpsertRequest.java @@ -0,0 +1,39 @@ +package com.yqz.openblog.media.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class MediaFolderUpsertRequest { + + @NotBlank + @Size(max = 80) + private String name; + + private Long parentId; + + private Integer sortOrder; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaListItemResponse.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaListItemResponse.java index 1c8ca89..c6ca180 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaListItemResponse.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaListItemResponse.java @@ -14,6 +14,7 @@ public class MediaListItemResponse { private Integer thumbWidth; private Integer thumbHeight; private String category; + private Long folderId; private Instant createdAt; public String getKey() { return key; } @@ -46,6 +47,9 @@ public class MediaListItemResponse { public String getCategory() { return category; } public void setCategory(String category) { this.category = category; } + public Long getFolderId() { return folderId; } + public void setFolderId(Long folderId) { this.folderId = folderId; } + public Instant getCreatedAt() { return createdAt; } public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } } diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaUploadResponse.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaUploadResponse.java index d16119c..f7501e4 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaUploadResponse.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/dto/MediaUploadResponse.java @@ -9,6 +9,7 @@ public class MediaUploadResponse { private Integer thumbWidth; private Integer thumbHeight; private String category; + private Long folderId; public String getUrl() { return url; @@ -73,5 +74,13 @@ public String getCategory() { public void setCategory(String category) { this.category = category; } + + public Long getFolderId() { + return folderId; + } + + public void setFolderId(Long folderId) { + this.folderId = folderId; + } } diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/entity/Media.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/entity/Media.java index 34de0d8..e3344ef 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/media/entity/Media.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/entity/Media.java @@ -49,6 +49,9 @@ public class Media { @Column(name = "category", length = 32) private String category; + @Column(name = "folder_id") + private Long folderId; + @Column(nullable = false) @TableField(fill = FieldFill.INSERT) private Instant createdAt; @@ -161,6 +164,14 @@ public void setCategory(String category) { this.category = category; } + public Long getFolderId() { + return folderId; + } + + public void setFolderId(Long folderId) { + this.folderId = folderId; + } + public Instant getCreatedAt() { return createdAt; } diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/entity/MediaFolder.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/entity/MediaFolder.java new file mode 100644 index 0000000..78a0dc4 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/entity/MediaFolder.java @@ -0,0 +1,86 @@ +package com.yqz.openblog.media.entity; + +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity +@Table(name = "media_folders", indexes = { + @Index(name = "idx_media_folders_parent_id", columnList = "parent_id") +}) +public class MediaFolder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 80) + private String name; + + @Column + private Long parentId; + + @Column(nullable = false) + private Integer sortOrder = 0; + + @Column(nullable = false) + private Instant createdAt; + + @Column(nullable = false) + private Instant updatedAt; + + @PrePersist + public void prePersist() { + Instant now = Instant.now(); + createdAt = now; + updatedAt = now; + if (sortOrder == null) { + sortOrder = 0; + } + } + + @PreUpdate + public void preUpdate() { + updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/repo/MediaFolderRepository.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/repo/MediaFolderRepository.java new file mode 100644 index 0000000..f198660 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/repo/MediaFolderRepository.java @@ -0,0 +1,13 @@ +package com.yqz.openblog.media.repo; + +import com.yqz.openblog.media.entity.MediaFolder; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MediaFolderRepository extends JpaRepository { + + List findAllByOrderBySortOrderAscIdAsc(); + + boolean existsByParentId(Long parentId); +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaFolderService.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaFolderService.java new file mode 100644 index 0000000..fcae42d --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaFolderService.java @@ -0,0 +1,215 @@ +package com.yqz.openblog.media.service; + +import com.yqz.openblog.common.BizException; +import com.yqz.openblog.media.dto.MediaFolderFlatItemResponse; +import com.yqz.openblog.media.dto.MediaFolderTreeNodeResponse; +import com.yqz.openblog.media.dto.MediaFolderUpsertRequest; +import com.yqz.openblog.media.entity.Media; +import com.yqz.openblog.media.entity.MediaFolder; +import com.yqz.openblog.media.repo.MediaFolderRepository; +import com.yqz.openblog.media.repo.MediaMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Service +public class MediaFolderService { + + private final MediaFolderRepository folderRepository; + private final MediaMapper mediaMapper; + + public MediaFolderService(MediaFolderRepository folderRepository, MediaMapper mediaMapper) { + this.folderRepository = folderRepository; + this.mediaMapper = mediaMapper; + } + + public List listTree() { + List all = folderRepository.findAllByOrderBySortOrderAscIdAsc(); + return buildTree(all); + } + + public List listFlat() { + List all = folderRepository.findAllByOrderBySortOrderAscIdAsc(); + Map byId = indexById(all); + List items = new ArrayList<>(); + for (MediaFolder f : all) { + MediaFolderFlatItemResponse r = new MediaFolderFlatItemResponse(); + r.setId(f.getId()); + r.setName(f.getName()); + r.setParentId(f.getParentId()); + r.setSortOrder(f.getSortOrder()); + r.setPath(buildPathNames(f.getId(), byId)); + items.add(r); + } + return items; + } + + @Transactional + public MediaFolderFlatItemResponse create(MediaFolderUpsertRequest req) { + MediaFolder f = new MediaFolder(); + apply(f, req, null); + f = folderRepository.save(f); + return toFlatItem(f, indexById(folderRepository.findAllByOrderBySortOrderAscIdAsc())); + } + + @Transactional + public MediaFolderFlatItemResponse update(Long id, MediaFolderUpsertRequest req) { + MediaFolder f = folderRepository.findById(id) + .orElseThrow(() -> new BizException(4041, "文件夹不存在")); + apply(f, req, id); + f = folderRepository.save(f); + return toFlatItem(f, indexById(folderRepository.findAllByOrderBySortOrderAscIdAsc())); + } + + @Transactional + public void delete(Long id) { + MediaFolder f = folderRepository.findById(id) + .orElseThrow(() -> new BizException(4041, "文件夹不存在")); + if (folderRepository.existsByParentId(f.getId())) { + throw new BizException(4091, "请先删除或移动子文件夹"); + } + LambdaQueryWrapper w = Wrappers.lambdaQuery(); + w.eq(Media::getFolderId, f.getId()); + Long count = mediaMapper.selectCount(w); + if (count != null && count > 0) { + throw new BizException(4091, "该文件夹下仍有文件,无法删除"); + } + folderRepository.deleteById(id); + } + + public Set collectSelfAndDescendantIds(Long folderId) { + if (folderId == null) { + return Collections.emptySet(); + } + List all = folderRepository.findAllByOrderBySortOrderAscIdAsc(); + Map> childrenMap = new HashMap<>(); + for (MediaFolder f : all) { + if (f.getParentId() == null) { + continue; + } + childrenMap.computeIfAbsent(f.getParentId(), k -> new ArrayList<>()).add(f.getId()); + } + Set out = new LinkedHashSet<>(); + collectDescendants(folderId, childrenMap, out); + return out; + } + + private void collectDescendants(Long id, Map> childrenMap, Set out) { + if (id == null || !out.add(id)) { + return; + } + List children = childrenMap.getOrDefault(id, List.of()); + for (Long childId : children) { + collectDescendants(childId, childrenMap, out); + } + } + + private void apply(MediaFolder f, MediaFolderUpsertRequest req, Long excludeId) { + String name = req.getName() == null ? "" : req.getName().trim(); + if (name.isEmpty()) { + throw new BizException(4000, "文件夹名称不能为空"); + } + Long parentId = req.getParentId(); + if (parentId != null) { + if (excludeId != null && parentId.equals(excludeId)) { + throw new BizException(4000, "父文件夹不能是自己"); + } + if (!folderRepository.existsById(parentId)) { + throw new BizException(4000, "父文件夹不存在"); + } + if (excludeId != null && isDescendant(excludeId, parentId)) { + throw new BizException(4000, "父文件夹不能是当前文件夹的子节点"); + } + } + f.setName(name); + f.setParentId(parentId); + f.setSortOrder(req.getSortOrder() == null ? 0 : req.getSortOrder()); + } + + private boolean isDescendant(Long ancestorId, Long nodeId) { + Map byId = indexById(folderRepository.findAllByOrderBySortOrderAscIdAsc()); + Long current = nodeId; + while (current != null) { + if (current.equals(ancestorId)) { + return true; + } + MediaFolder f = byId.get(current); + current = f == null ? null : f.getParentId(); + } + return false; + } + + private List buildTree(List all) { + Map nodes = new LinkedHashMap<>(); + // count files per folder + Map fileCounts = new HashMap<>(); + List allMedia = mediaMapper.selectList(Wrappers.lambdaQuery(Media.class).isNotNull(Media::getFolderId)); + for (Media m : allMedia) { + Long fid = m.getFolderId(); + if (fid != null) { + fileCounts.merge(fid, 1L, Long::sum); + } + } + + for (MediaFolder f : all) { + MediaFolderTreeNodeResponse n = new MediaFolderTreeNodeResponse(); + n.setId(f.getId()); + n.setName(f.getName()); + n.setParentId(f.getParentId()); + n.setSortOrder(f.getSortOrder()); + n.setFileCount(fileCounts.getOrDefault(f.getId(), 0L)); + nodes.put(f.getId(), n); + } + List roots = new ArrayList<>(); + for (MediaFolder f : all) { + MediaFolderTreeNodeResponse n = nodes.get(f.getId()); + if (f.getParentId() == null) { + roots.add(n); + continue; + } + MediaFolderTreeNodeResponse parent = nodes.get(f.getParentId()); + if (parent == null) { + roots.add(n); + } else { + parent.getChildren().add(n); + } + } + return roots; + } + + private Map indexById(List all) { + Map map = new HashMap<>(); + for (MediaFolder f : all) { + map.put(f.getId(), f); + } + return map; + } + + private List buildPathNames(Long folderId, Map byId) { + List path = new ArrayList<>(); + Set visited = new HashSet<>(); + Long current = folderId; + while (current != null && visited.add(current)) { + MediaFolder f = byId.get(current); + if (f == null) { + break; + } + path.add(0, f.getName()); + current = f.getParentId(); + } + return path; + } + + private MediaFolderFlatItemResponse toFlatItem(MediaFolder f, Map byId) { + MediaFolderFlatItemResponse r = new MediaFolderFlatItemResponse(); + r.setId(f.getId()); + r.setName(f.getName()); + r.setParentId(f.getParentId()); + r.setSortOrder(f.getSortOrder()); + r.setPath(buildPathNames(f.getId(), byId)); + return r; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaService.java b/OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaService.java index b4b5eef..3a581dd 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaService.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaService.java @@ -57,7 +57,7 @@ public MediaService(MediaMapper mediaMapper, this.minioMediaStorage = minioMediaStorage; } - public MediaUploadResponse upload(MultipartFile file, String category) throws IOException { + public MediaUploadResponse upload(MultipartFile file, Long folderId) throws IOException { if (file == null || file.isEmpty()) { throw new BizException(4002, "文件不能为空"); } @@ -75,7 +75,7 @@ public MediaUploadResponse upload(MultipartFile file, String category) throws IO throw new BizException(4002, "无法识别图片后缀"); } - String cat = StringUtils.hasText(category) ? category : "general"; + String cat = resolveFolderPrefix(folderId); String key = cat + "/" + UUID.randomUUID().toString() + "." + ext; MediaStorage storage = activeStorage(); @@ -133,6 +133,7 @@ public MediaUploadResponse upload(MultipartFile file, String category) throws IO media.setThumbWidth(thumbWidth); media.setThumbHeight(thumbHeight); media.setCategory(cat); + media.setFolderId(folderId); Long uid = currentUser.userId(); media.setUploadedBy(uid); mediaMapper.insert(media); @@ -146,6 +147,7 @@ public MediaUploadResponse upload(MultipartFile file, String category) throws IO resp.setThumbWidth(thumbWidth); resp.setThumbHeight(thumbHeight); resp.setCategory(cat); + resp.setFolderId(folderId); return resp; } finally { Files.deleteIfExists(originalPath); @@ -197,13 +199,13 @@ public ThumbInfoResponse thumbInfo(String key) { return resp; } - public IPage listMyMedia(int page, int size, String category) { + public IPage listMyMedia(int page, int size, Long folderId) { Long uid = currentUser.userId(); Page mpPage = new Page<>(page + 1L, size); var w = Wrappers.lambdaQuery(Media.class) .eq(Media::getUploadedBy, uid); - if (StringUtils.hasText(category)) { - w.eq(Media::getCategory, category); + if (folderId != null) { + w.eq(Media::getFolderId, folderId); } w.orderByDesc(Media::getCreatedAt); IPage p = mediaMapper.selectPage(mpPage, w); @@ -225,6 +227,13 @@ public List listCategories() { .collect(Collectors.toList()); } + private String resolveFolderPrefix(Long folderId) { + if (folderId == null) { + return "general"; + } + return "folder-" + folderId; + } + public void deleteMyMedia(String key) { Long uid = currentUser.userId(); Media media = mediaMapper.selectOne(Wrappers.lambdaQuery(Media.class) @@ -253,6 +262,7 @@ private MediaListItemResponse toListItem(Media m) { r.setThumbWidth(m.getThumbWidth()); r.setThumbHeight(m.getThumbHeight()); r.setCategory(m.getCategory()); + r.setFolderId(m.getFolderId()); r.setCreatedAt(m.getCreatedAt()); return r; } diff --git a/OpenBlog-business/src/main/resources/sql/media_folder.sql b/OpenBlog-business/src/main/resources/sql/media_folder.sql new file mode 100644 index 0000000..4ae2268 --- /dev/null +++ b/OpenBlog-business/src/main/resources/sql/media_folder.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS media_folders ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(80) NOT NULL, + parent_id BIGINT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + INDEX idx_media_folders_parent_id (parent_id) +); diff --git a/vue/src/api/mediaFolder.js b/vue/src/api/mediaFolder.js index 2ffea66..3eff7f2 100644 --- a/vue/src/api/mediaFolder.js +++ b/vue/src/api/mediaFolder.js @@ -1,11 +1,11 @@ import { request } from './http' export function fetchFolderTree() { - return request('/api/v1/media-folders/tree') + return request('/api/v1/media-folders/tree', { withAuth: true }) } export function fetchFolderList() { - return request('/api/v1/media-folders') + return request('/api/v1/media-folders', { withAuth: true }) } export function createFolder(payload) { diff --git a/vue/src/components/MediaTreeSidebar.vue b/vue/src/components/MediaTreeSidebar.vue index d471a44..ff8578c 100644 --- a/vue/src/components/MediaTreeSidebar.vue +++ b/vue/src/components/MediaTreeSidebar.vue @@ -7,10 +7,9 @@
加载中...
-
暂无文件夹
+
{{ loadError }}