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 @@
@@ -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 }}
-
+ 暂无文件夹,点击右上角 + 创建
+
[] },
selectedFolderId: { type: [String, Number], default: null },
loading: { type: Boolean, default: false },
+ loadError: { type: String, default: '' },
uploading: { type: Boolean, default: false }
})
diff --git a/vue/src/views/ConsoleAttachmentsView.vue b/vue/src/views/ConsoleAttachmentsView.vue
index 08a7e5d..890442b 100644
--- a/vue/src/views/ConsoleAttachmentsView.vue
+++ b/vue/src/views/ConsoleAttachmentsView.vue
@@ -13,6 +13,7 @@
:folders="folders"
:selected-folder-id="selectedFolderId"
:loading="foldersLoading"
+ :load-error="foldersError"
:uploading="uploading"
@select="switchFolder"
@create-folder="createFolder"
@@ -98,6 +99,7 @@ const totalPages = ref(1)
const folders = ref([])
const foldersLoading = ref(false)
+const foldersError = ref('')
const selectedFolderId = ref(null)
const viewOriginal = ref(null)
@@ -131,10 +133,12 @@ async function onUploadFile(e) {
async function loadFolders() {
foldersLoading.value = true
+ foldersError.value = ''
try {
folders.value = await fetchFolderTree()
- } catch {
+ } catch (err) {
folders.value = []
+ foldersError.value = err?.message || '文件夹加载失败'
} finally {
foldersLoading.value = false
}
From 29b9d691bb6cf5edff89fffef8f396dc6b36a4ea Mon Sep 17 00:00:00 2001
From: yqz <144-yqz@users.noreply.gitlab.topviewclub.cn>
Date: Wed, 27 May 2026 12:38:08 +0800
Subject: [PATCH 3/7] =?UTF-8?q?feat=EF=BC=9A=E6=9B=B4=E6=96=B0=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
vue/src/components/MediaTreeBranch.vue | 16 ++++++++--------
vue/src/components/MediaTreeSidebar.vue | 9 ++++++++-
2 files changed, 16 insertions(+), 9 deletions(-)
diff --git a/vue/src/components/MediaTreeBranch.vue b/vue/src/components/MediaTreeBranch.vue
index 3a0e8e6..259bb28 100644
--- a/vue/src/components/MediaTreeBranch.vue
+++ b/vue/src/components/MediaTreeBranch.vue
@@ -2,26 +2,26 @@
@@ -162,6 +162,13 @@ function cancelEdit() {
editTarget.value = null
}
+function onEditBlur() {
+ if (!editing.value) return
+ const name = editName.value.trim()
+ if (name) submitEdit()
+ else cancelEdit()
+}
+
async function submitEdit() {
const name = editName.value.trim()
if (!name) {
From 154e06947795dae860402ab2abdfbf99521ded76 Mon Sep 17 00:00:00 2001
From: yqz <144-yqz@users.noreply.gitlab.topviewclub.cn>
Date: Wed, 27 May 2026 13:24:56 +0800
Subject: [PATCH 4/7] =?UTF-8?q?feat=EF=BC=9A=E4=BC=98=E5=8C=96=E5=89=8D?=
=?UTF-8?q?=E7=AB=AFUI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
vue/src/assets/blog.css | 252 -------------
vue/src/components/MediaTreeBranch.vue | 97 ++++-
vue/src/components/MediaTreeSidebar.vue | 245 ++++++++++++
vue/src/views/ConsoleAttachmentsView.vue | 457 ++++++++++++++++++-----
4 files changed, 701 insertions(+), 350 deletions(-)
diff --git a/vue/src/assets/blog.css b/vue/src/assets/blog.css
index 5fed7b7..4d35466 100644
--- a/vue/src/assets/blog.css
+++ b/vue/src/assets/blog.css
@@ -1396,258 +1396,6 @@ 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
index 259bb28..e33981d 100644
--- a/vue/src/components/MediaTreeBranch.vue
+++ b/vue/src/components/MediaTreeBranch.vue
@@ -14,7 +14,9 @@
aria-hidden="true"
@click.stop="$emit('toggle', folder.key)"
>{{ isExpanded ? '▾' : '▸' }}
-
+
{{ folder.label }}
{{ folder.fileCount }}
@@ -51,3 +53,96 @@ const isExpanded = computed(() => {
return props.expandedMap[props.folder.key] !== false
})
+
+
diff --git a/vue/src/components/MediaTreeSidebar.vue b/vue/src/components/MediaTreeSidebar.vue
index a9908f6..3975e3f 100644
--- a/vue/src/components/MediaTreeSidebar.vue
+++ b/vue/src/components/MediaTreeSidebar.vue
@@ -1,6 +1,9 @@