Skip to content

Commit bfe030e

Browse files
author
yqz
committed
feat:将优化文章编辑,附件管理
1 parent 487f1d6 commit bfe030e

11 files changed

Lines changed: 1002 additions & 27 deletions

File tree

OpenBlog-business/src/main/java/com/yqz/openblog/media/controller/MediaController.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.yqz.openblog.media.controller;
22

33
import com.yqz.openblog.common.ApiResponse;
4+
import com.yqz.openblog.media.dto.MediaListItemResponse;
45
import com.yqz.openblog.media.dto.MediaUploadResponse;
56
import com.yqz.openblog.media.dto.ThumbInfoResponse;
67
import com.yqz.openblog.media.entity.Media;
78
import com.yqz.openblog.media.service.MediaService;
9+
import com.baomidou.mybatisplus.core.metadata.IPage;
810
import jakarta.servlet.http.HttpServletResponse;
911
import org.springframework.http.HttpHeaders;
1012
import org.springframework.http.MediaType;
@@ -29,6 +31,19 @@ public ApiResponse<MediaUploadResponse> upload(@RequestParam("file") MultipartFi
2931
return ApiResponse.ok(mediaService.upload(file));
3032
}
3133

34+
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
35+
public ApiResponse<IPage<MediaListItemResponse>> list(
36+
@RequestParam(defaultValue = "0") int page,
37+
@RequestParam(defaultValue = "20") int size) {
38+
return ApiResponse.ok(mediaService.listMyMedia(page, size));
39+
}
40+
41+
@DeleteMapping(value = "/{key}", produces = MediaType.APPLICATION_JSON_VALUE)
42+
public ApiResponse<Void> delete(@PathVariable("key") String key) {
43+
mediaService.deleteMyMedia(key);
44+
return ApiResponse.ok();
45+
}
46+
3247
@GetMapping(value = "/{key}/thumb", produces = MediaType.APPLICATION_JSON_VALUE)
3348
public ApiResponse<ThumbInfoResponse> thumbInfo(@PathVariable("key") String key) {
3449
return ApiResponse.ok(mediaService.thumbInfo(key));
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.yqz.openblog.media.dto;
2+
3+
import java.time.Instant;
4+
5+
public class MediaListItemResponse {
6+
7+
private String key;
8+
private String url;
9+
private String thumbUrl;
10+
private String contentType;
11+
private Long size;
12+
private Integer width;
13+
private Integer height;
14+
private Integer thumbWidth;
15+
private Integer thumbHeight;
16+
private Instant createdAt;
17+
18+
public String getKey() { return key; }
19+
public void setKey(String key) { this.key = key; }
20+
21+
public String getUrl() { return url; }
22+
public void setUrl(String url) { this.url = url; }
23+
24+
public String getThumbUrl() { return thumbUrl; }
25+
public void setThumbUrl(String thumbUrl) { this.thumbUrl = thumbUrl; }
26+
27+
public String getContentType() { return contentType; }
28+
public void setContentType(String contentType) { this.contentType = contentType; }
29+
30+
public Long getSize() { return size; }
31+
public void setSize(Long size) { this.size = size; }
32+
33+
public Integer getWidth() { return width; }
34+
public void setWidth(Integer width) { this.width = width; }
35+
36+
public Integer getHeight() { return height; }
37+
public void setHeight(Integer height) { this.height = height; }
38+
39+
public Integer getThumbWidth() { return thumbWidth; }
40+
public void setThumbWidth(Integer thumbWidth) { this.thumbWidth = thumbWidth; }
41+
42+
public Integer getThumbHeight() { return thumbHeight; }
43+
public void setThumbHeight(Integer thumbHeight) { this.thumbHeight = thumbHeight; }
44+
45+
public Instant getCreatedAt() { return createdAt; }
46+
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
47+
}

OpenBlog-business/src/main/java/com/yqz/openblog/media/service/MediaService.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.yqz.openblog.common.BizException;
44
import com.yqz.openblog.media.MediaProperties;
5+
import com.yqz.openblog.media.dto.MediaListItemResponse;
56
import com.yqz.openblog.media.dto.MediaUploadResponse;
67
import com.yqz.openblog.media.dto.ThumbInfoResponse;
78
import com.yqz.openblog.media.entity.Media;
@@ -11,7 +12,9 @@
1112
import com.yqz.openblog.media.storage.MinioMediaStorage;
1213
import com.yqz.openblog.media.storage.LocalMediaStorage;
1314
import com.yqz.openblog.security.CurrentUser;
15+
import com.baomidou.mybatisplus.core.metadata.IPage;
1416
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
17+
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
1518
import net.coobird.thumbnailator.Thumbnails;
1619
import org.springframework.beans.factory.ObjectProvider;
1720
import org.springframework.stereotype.Service;
@@ -23,8 +26,10 @@
2326
import java.io.IOException;
2427
import java.nio.file.Files;
2528
import java.nio.file.Path;
29+
import java.util.List;
2630
import java.util.Locale;
2731
import java.util.UUID;
32+
import java.util.stream.Collectors;
2833

2934
@Service
3035
public class MediaService {
@@ -187,6 +192,47 @@ public ThumbInfoResponse thumbInfo(String key) {
187192
return resp;
188193
}
189194

195+
public IPage<MediaListItemResponse> listMyMedia(int page, int size) {
196+
Long uid = currentUser.userId();
197+
Page<Media> mpPage = new Page<>(page + 1L, size);
198+
IPage<Media> p = mediaMapper.selectPage(mpPage,
199+
Wrappers.lambdaQuery(Media.class)
200+
.eq(Media::getUploadedBy, uid)
201+
.orderByDesc(Media::getCreatedAt));
202+
return p.convert(this::toListItem);
203+
}
204+
205+
public void deleteMyMedia(String key) {
206+
Long uid = currentUser.userId();
207+
Media media = mediaMapper.selectOne(Wrappers.lambdaQuery(Media.class)
208+
.eq(Media::getStorageKey, key)
209+
.eq(Media::getUploadedBy, uid));
210+
if (media == null) {
211+
throw new BizException(4042, "媒体不存在或无权删除");
212+
}
213+
try {
214+
mediaFileAccess.delete(media);
215+
} catch (IOException e) {
216+
throw new BizException(5002, "删除媒体文件失败: " + e.getMessage());
217+
}
218+
mediaMapper.deleteById(media.getId());
219+
}
220+
221+
private MediaListItemResponse toListItem(Media m) {
222+
MediaListItemResponse r = new MediaListItemResponse();
223+
r.setKey(m.getStorageKey());
224+
r.setUrl(normalizePublicMediaUrl(m.getUrl()));
225+
r.setThumbUrl(normalizePublicMediaUrl(m.getThumbUrl()));
226+
r.setContentType(m.getContentType());
227+
r.setSize(m.getSize());
228+
r.setWidth(m.getWidth());
229+
r.setHeight(m.getHeight());
230+
r.setThumbWidth(m.getThumbWidth());
231+
r.setThumbHeight(m.getThumbHeight());
232+
r.setCreatedAt(m.getCreatedAt());
233+
return r;
234+
}
235+
190236
private MediaStorage activeStorage() {
191237
if (properties.isMinioEnabled()) {
192238
MinioMediaStorage minio = minioMediaStorage.getIfAvailable();

OpenBlog-business/src/main/java/com/yqz/openblog/media/storage/LocalMediaStorage.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ public byte[] readThumb(String key) throws IOException {
4646
return Files.readAllBytes(resolveThumb(key));
4747
}
4848

49+
@Override
50+
public void delete(String key) throws IOException {
51+
Files.deleteIfExists(resolveOriginal(key));
52+
Files.deleteIfExists(resolveThumb(key));
53+
}
54+
4955
private Path root() {
5056
return Path.of(properties.getRootPath()).toAbsolutePath().normalize();
5157
}

OpenBlog-business/src/main/java/com/yqz/openblog/media/storage/MediaFileAccess.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ public byte[] read(Media media, boolean thumb) throws IOException {
2424
return thumb ? storage.readThumb(media.getStorageKey()) : storage.readOriginal(media.getStorageKey());
2525
}
2626

27+
public void delete(Media media) throws IOException {
28+
resolve(media.getStorageType()).delete(media.getStorageKey());
29+
}
30+
2731
private MediaStorage resolve(String storageType) {
2832
if ("MINIO".equalsIgnoreCase(storageType)) {
2933
MinioMediaStorage minio = minioMediaStorage.getIfAvailable();

OpenBlog-business/src/main/java/com/yqz/openblog/media/storage/MediaStorage.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ public interface MediaStorage {
1414
byte[] readOriginal(String key) throws IOException;
1515

1616
byte[] readThumb(String key) throws IOException;
17+
18+
void delete(String key) throws IOException;
1719
}

OpenBlog-business/src/main/java/com/yqz/openblog/media/storage/MinioMediaStorage.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.minio.MakeBucketArgs;
88
import io.minio.MinioClient;
99
import io.minio.PutObjectArgs;
10+
import io.minio.RemoveObjectArgs;
1011
import io.minio.errors.ErrorResponseException;
1112
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
1213
import org.springframework.stereotype.Component;
@@ -57,6 +58,24 @@ public byte[] readThumb(String key) throws IOException {
5758
return readObject(THUMB_PREFIX + key);
5859
}
5960

61+
@Override
62+
public void delete(String key) throws IOException {
63+
try {
64+
minioClient.removeObject(
65+
RemoveObjectArgs.builder()
66+
.bucket(bucket())
67+
.object(ORIGINAL_PREFIX + key)
68+
.build());
69+
minioClient.removeObject(
70+
RemoveObjectArgs.builder()
71+
.bucket(bucket())
72+
.object(THUMB_PREFIX + key)
73+
.build());
74+
} catch (Exception e) {
75+
throw new IOException("删除 MinIO 文件失败: " + key, e);
76+
}
77+
}
78+
6079
private void putObject(String objectName, Path file, String contentType) throws IOException {
6180
ensureBucket();
6281
try (InputStream in = Files.newInputStream(file)) {

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
| 模块 | 说明 |
1010
|------|------|
1111
| 账号 | 注册、登录、JWT 刷新、当前用户资料查询与更新 |
12-
| 文章 | 草稿创建/编辑、发布(支持定时发布)、下架、删除、Markdown 导入/导出、公开列表与详情、作者侧「我的文章」与详情;正文独立存储并在发布时预渲染 HTML |
12+
| 文章 | 草稿创建/编辑、发布(支持定时发布)、下架、删除、Markdown 导入/导出、公开列表与详情、作者侧「我的文章」与详情;正文独立存储并在发布时预渲染 HTML;编辑器支持粘贴/拖拽插图、媒体库一键插入 |
1313
| 搜索 | MySQL FULLTEXT 索引支持文章正文搜索(`article_bodies.content_markdown`|
1414
| 互动 | 文章点赞/取消、收藏/取消、关注/取消关注 |
1515
| 评论 | 文章下评论列表、发表评论、回复评论、删除评论 |
16-
| 媒体 | 上传、原图/缩略图访问、缩略图元数据查询 |
16+
| 媒体 | 上传、列表浏览(分页)、删除、原图/缩略图访问、缩略图元数据查询、全屏预览、一键复制 URL |
1717
| 首页 | 聚合数据接口(如 `/api/v1/home`|
1818
| 公开资料 | `/api/v1/profile`:未登录展示站点作者信息,已登录可优先展示当前用户公开资料 |
1919

@@ -99,6 +99,11 @@ Redis 操作统一封装在 `OpenBlog-framework-redis` 模块中,提供 `Redis
9999
- **首页布局**:两栏布局,左侧为个人信息栏(吸顶),右侧为文章内容。
100100
- **顶栏导航**:提供主题切换、项目源码入口等快捷操作。
101101
- **GitHub(项目源码)**`https://github.com/yyyCode/OpenBlog.git`
102+
- **文章编辑器**
103+
- Ctrl+V 粘贴截图 / 拖拽图片到编辑区,自动上传并插入 Markdown 图片语法
104+
- 工具栏「插入图片」按钮可直接选文件上传
105+
- 「媒体库」弹窗浏览已上传图片,点击缩略图一键插入
106+
- **附件管理**:缩略图网格展示、分页浏览、全屏预览、复制 URL、删除确认
102107

103108
---
104109

vue/src/api/media.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,63 @@ export async function uploadMedia(file) {
4141
return json?.data ?? json
4242
}
4343

44+
export async function fetchMediaList(page = 0, size = 20) {
45+
const res = await fetch(`${API_BASE}/api/v1/media?page=${page}&size=${size}`, {
46+
method: 'GET',
47+
headers: {
48+
...getAuthHeader()
49+
}
50+
})
51+
52+
const text = await res.text()
53+
let json = null
54+
try {
55+
json = text ? JSON.parse(text) : null
56+
} catch {
57+
json = null
58+
}
59+
60+
if (!res.ok) {
61+
const msg = json?.message || `HTTP ${res.status}`
62+
const err = new Error(msg)
63+
err.httpStatus = res.status
64+
throw err
65+
}
66+
67+
if (json && typeof json.code === 'number' && json.code !== 0) {
68+
throw new Error(json.message || '请求失败')
69+
}
70+
71+
return json?.data ?? json
72+
}
73+
74+
export async function deleteMedia(key) {
75+
const res = await fetch(`${API_BASE}/api/v1/media/${encodeURIComponent(key)}`, {
76+
method: 'DELETE',
77+
headers: {
78+
...getAuthHeader()
79+
}
80+
})
81+
82+
const text = await res.text()
83+
let json = null
84+
try {
85+
json = text ? JSON.parse(text) : null
86+
} catch {
87+
json = null
88+
}
89+
90+
if (!res.ok) {
91+
const msg = json?.message || `HTTP ${res.status}`
92+
const err = new Error(msg)
93+
err.httpStatus = res.status
94+
throw err
95+
}
96+
97+
if (json && typeof json.code === 'number' && json.code !== 0) {
98+
throw new Error(json.message || '请求失败')
99+
}
100+
101+
return true
102+
}
103+

0 commit comments

Comments
 (0)