diff --git a/api/openapi.yaml b/api/openapi.yaml index 86e905e2..f04db75a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1571,7 +1571,7 @@ paths: delete: summary: 删除社团讨论区话题或回复 - description: 删除话题(包括其所有回复)或删除回复。话题发布者或讨论区管理员可执行删除操作。删除操作会记录至审计日志。 + description: 删除话题或回复。删除话题时级联删除其所有后代回复(包括嵌套回复)。删除回复时仅删除该回复及其所有子后代。话题发布者或讨论区管理员可执行删除操作。所有删除操作均记录至审计日志。 operationId: deleteClubForumPost security: - bearerAuth: [] @@ -1590,7 +1590,7 @@ paths: minimum: 1 responses: "204": - description: 删除成功 + description: 删除成功(包括所有后代) "400": description: 请求参数不合法 content: @@ -1616,6 +1616,128 @@ paths: schema: $ref: "#/components/schemas/ApiError" + /api/v1/clubs/{clubId}/forum-posts/upload-image: + post: + summary: 上传论坛图片到 OSS + operationId: uploadForumImage + security: + - bearerAuth: [] + parameters: + - name: clubId + in: path + required: true + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + description: 图片文件,支持 jpg、png、gif、webp,文件大小 ≤ 5MB + required: [image] + responses: + "200": + description: 图片上传成功 + content: + application/json: + schema: + $ref: "#/components/schemas/ForumImageUploadResponse" + "400": + description: 图片文件不合法(类型或大小超限) + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "401": + description: 登录状态缺失或无效 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "403": + description: 当前用户不是该社团的有效成员 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "404": + description: 社团不存在 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "413": + description: 文件过大,超出大小限制 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "500": + description: OSS 上传失败 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + + /api/v1/clubs/{clubId}/forum-posts/delete-image: + delete: + summary: 删除已上传的论坛图片 + operationId: deleteForumImage + security: + - bearerAuth: [] + parameters: + - name: clubId + in: path + required: true + schema: + type: integer + minimum: 1 + - name: storageKey + in: query + required: true + schema: + type: string + description: 存储对象键,来自上传响应 + responses: + "200": + description: 图片删除成功 + "400": + description: storageKey 参数不能为空 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "401": + description: 登录状态缺失或无效 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "403": + description: 当前用户不是该社团的有效成员 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "404": + description: 社团不存在 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "500": + description: OSS 删除失败 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + /api/v1/clubs/{clubId}/award-rule-documents: get: summary: 查询评奖评优评定细则 @@ -7149,6 +7271,29 @@ components: example: published required: [isTop, postStatus] + ForumImageUploadResponse: + type: object + description: 论坛图片上传响应 + properties: + imageUrl: + type: string + description: 上传后的图片 OSS URL,可直接在 Markdown 中使用。 + example: https://clubhub-oss.aliyuncs.com/forum/2026/08/19/abc123.png + fileName: + type: string + description: 图片文件名。 + example: abc123.png + storageKey: + type: string + description: 存储对象键,用于后续删除操作。 + example: clubs/1/forum/2026/08/19/abc123def456.png + uploadedAt: + type: string + format: date-time + description: 上传时间。 + example: 2026-08-19T10:30:00Z + required: [imageUrl, fileName, storageKey, uploadedAt] + NoticeReadResult: type: object description: 通知已读记录结果;接口成功响应表示该用户已经完成已读标记。 diff --git a/backend.Tests/ForumPostsAuthorizationTests.cs b/backend.Tests/ForumPostsAuthorizationTests.cs index 33090e98..8ea3a913 100644 --- a/backend.Tests/ForumPostsAuthorizationTests.cs +++ b/backend.Tests/ForumPostsAuthorizationTests.cs @@ -40,13 +40,13 @@ public async Task CreateTopic_NonMember_IsForbidden() } [Fact] - public async Task CreateReply_ToReply_IsRejected() + public async Task CreateReply_ToReply_IsAllowed() { var (client, clubId) = await SeedAsync(member: true, moderate: false); var topic = await PostAndReadId(client, clubId, "{\"title\":\"topic\",\"content\":\"body\"}"); var reply = await PostAndReadId(client, clubId, $"{{\"parentPostId\":{topic},\"content\":\"reply\"}}"); using var response = await client.PostAsync($"/api/v1/clubs/{clubId}/forum-posts", Json($"{{\"parentPostId\":{reply},\"content\":\"nested\"}}")); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); } [Fact] @@ -143,6 +143,44 @@ public async Task DeleteTopic_DeletesRepliesAlso() Assert.Equal(0, afterDocument.RootElement.GetArrayLength()); } + [Fact] + public async Task NestedReplies_UpToThreeLevels_CreateQueryAndCascadeDelete() + { + var (client, clubId) = await SeedAsync(member: true, moderate: false); + + // Create topic -> reply level 1 -> reply level 2 -> reply level 3 + var topic = await PostAndReadId(client, clubId, "{\"title\":\"topic\",\"content\":\"body\"}"); + var level1Reply = await PostAndReadId(client, clubId, $"{{\"parentPostId\":{topic},\"content\":\"level1\"}}"); + var level2Reply = await PostAndReadId(client, clubId, $"{{\"parentPostId\":{level1Reply},\"content\":\"level2\"}}"); + var level3Reply = await PostAndReadId(client, clubId, $"{{\"parentPostId\":{level2Reply},\"content\":\"level3\"}}"); + + // Verify all created + Assert.NotEqual(0, level1Reply); + Assert.NotEqual(0, level2Reply); + Assert.NotEqual(0, level3Reply); + + // Verify structure in GET + using var getResponse = await client.GetAsync($"/api/v1/clubs/{clubId}/forum-posts"); + using var getDocument = System.Text.Json.JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync()); + var topicReply = getDocument.RootElement[0].GetProperty("replies")[0]; + var level1Nested = topicReply.GetProperty("replies")[0]; + var level2Nested = level1Nested.GetProperty("replies")[0]; + Assert.Equal("level1", topicReply.GetProperty("content").GetString()); + Assert.Equal("level2", level1Nested.GetProperty("content").GetString()); + Assert.Equal("level3", level2Nested.GetProperty("content").GetString()); + + // Delete level 2 reply -> should cascade delete level 3 + using var deleteLevel2 = await client.DeleteAsync($"/api/v1/clubs/{clubId}/forum-posts/{level2Reply}"); + Assert.Equal(HttpStatusCode.NoContent, deleteLevel2.StatusCode); + + // Verify level 2 and 3 deleted, but level 1 still exists + using var afterDeleteResponse = await client.GetAsync($"/api/v1/clubs/{clubId}/forum-posts"); + using var afterDeleteDocument = System.Text.Json.JsonDocument.Parse(await afterDeleteResponse.Content.ReadAsStringAsync()); + var topicReplyAfter = afterDeleteDocument.RootElement[0].GetProperty("replies")[0]; + Assert.Equal("level1", topicReplyAfter.GetProperty("content").GetString()); + Assert.Equal(0, topicReplyAfter.GetProperty("replies").GetArrayLength()); + } + [Fact] public async Task DeleteReply_ByOwner_Succeeds() { @@ -224,4 +262,53 @@ private static async Task PostAndReadId(HttpClient client, int clubId, stri using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync()); return document.RootElement.GetProperty("id").GetInt32(); } + + [Fact] + public async Task UploadImage_ExceedsRequestSizeLimit_Returns413() + { + var (client, clubId) = await SeedAsync(member: true, moderate: false); + + // Create a multipart request that exceeds 5 MB limit + using var content = new MultipartFormDataContent(); + var oversizeData = new byte[6 * 1024 * 1024]; // 6 MB + content.Add(new ByteArrayContent(oversizeData), "image", "large.jpg"); + + using var response = await client.PostAsync($"/api/v1/clubs/{clubId}/forum-posts/upload-image", content); + + // Should receive 413 Payload Too Large due to RequestSizeLimit + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + + [Fact] + public async Task DeleteImage_WithValidStorageKey_ReturnsServerError() + { + var (client, clubId) = await SeedAsync(member: true, moderate: false); + const string testStorageKey = "clubs/1/forum/2026/08/19/test.png"; + + using var response = await client.DeleteAsync($"/api/v1/clubs/{clubId}/forum-posts/delete-image?storageKey={Uri.EscapeDataString(testStorageKey)}"); + + // OSS not configured in tests, so deletion fails with 500; verifies endpoint exists and accepts storageKey + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Fact] + public async Task DeleteImage_WithoutStorageKey_ReturnsBadRequest() + { + var (client, clubId) = await SeedAsync(member: true, moderate: false); + + using var response = await client.DeleteAsync($"/api/v1/clubs/{clubId}/forum-posts/delete-image"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DeleteImage_NonMember_IsForbidden() + { + var (client, clubId) = await SeedAsync(member: false, moderate: false); + const string testStorageKey = "clubs/1/forum/2026/08/19/test.png"; + + using var response = await client.DeleteAsync($"/api/v1/clubs/{clubId}/forum-posts/delete-image?storageKey={Uri.EscapeDataString(testStorageKey)}"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } } diff --git a/backend.Tests/ProjectMembershipServiceTests.cs b/backend.Tests/ProjectMembershipServiceTests.cs new file mode 100644 index 00000000..eb0c073b --- /dev/null +++ b/backend.Tests/ProjectMembershipServiceTests.cs @@ -0,0 +1,407 @@ +using System.Net.Http.Headers; +using ClubHub.Api.Data; +using ClubHub.Api.Data.Entities; +using ClubHub.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace ClubHub.Api.Tests; + +public sealed class ProjectMembershipServiceTests : IClassFixture +{ + private readonly ClubHubWebApplicationFactory _factory; + private static int _sequence; + + public ProjectMembershipServiceTests(ClubHubWebApplicationFactory factory) => _factory = factory; + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("active")] + [InlineData("ACTIVE")] + [InlineData("Active")] + public async Task IsActiveUserAsync_WithVariousAccountStatuses_ReturnsCorrectly(string accountStatus) + { + var baseId = 1000 + Interlocked.Increment(ref _sequence) * 10; + var userId = baseId; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + db.Add(new User + { + UserId = userId, + Username = $"user-{baseId}", + PasswordHash = "unused", + RealName = "Test User", + AccountStatus = accountStatus, + CreatedAt = now + }); + await db.SaveChangesAsync(); + + var isActive = await service.IsActiveUserAsync(userId); + Assert.True(isActive, $"User with AccountStatus='{accountStatus}' should be considered active"); + } + + [Theory] + [InlineData("inactive")] + [InlineData("suspended")] + [InlineData("INACTIVE")] + [InlineData("deleted")] + public async Task IsActiveUserAsync_WithInactiveStatus_ReturnsFalse(string inactiveStatus) + { + var baseId = 1000 + Interlocked.Increment(ref _sequence) * 10; + var userId = baseId; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + db.Add(new User + { + UserId = userId, + Username = $"user-{baseId}", + PasswordHash = "unused", + RealName = "Test User", + AccountStatus = inactiveStatus, + CreatedAt = now + }); + await db.SaveChangesAsync(); + + var isActive = await service.IsActiveUserAsync(userId); + Assert.False(isActive, $"User with AccountStatus='{inactiveStatus}' should not be active"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("active")] + [InlineData("ACTIVE")] + public async Task IsActiveClubMemberAsync_WithVariousMemberStatuses_ReturnsCorrectly(string? memberStatus) + { + var baseId = 2000 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + var userId = baseId + 1; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + db.Add(new User { UserId = userId, Username = $"user-{baseId}", PasswordHash = "unused", RealName = "Test", AccountStatus = "active", CreatedAt = now }); + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = now }); + db.Add(new ClubMember + { + MemberId = baseId + 2, + ClubId = clubId, + UserId = userId, + MemberStatus = memberStatus, + TermStart = now.AddMonths(-1), + TermEnd = now.AddMonths(1) + }); + await db.SaveChangesAsync(); + + var isActive = await service.IsActiveClubMemberAsync(clubId, userId); + Assert.True(isActive, $"Member with MemberStatus='{memberStatus}' within term should be active"); + } + + [Theory] + [InlineData("inactive")] + [InlineData("quit")] + [InlineData("removed")] + [InlineData("INACTIVE")] + public async Task IsActiveClubMemberAsync_WithInactiveStatus_ReturnsFalse(string inactiveStatus) + { + var baseId = 2000 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + var userId = baseId + 1; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + db.Add(new User { UserId = userId, Username = $"user-{baseId}", PasswordHash = "unused", RealName = "Test", AccountStatus = "active", CreatedAt = now }); + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = now }); + db.Add(new ClubMember + { + MemberId = baseId + 2, + ClubId = clubId, + UserId = userId, + MemberStatus = inactiveStatus, + TermStart = now.AddMonths(-1), + TermEnd = now.AddMonths(1) + }); + await db.SaveChangesAsync(); + + var isActive = await service.IsActiveClubMemberAsync(clubId, userId); + Assert.False(isActive, $"Member with MemberStatus='{inactiveStatus}' should not be active"); + } + + [Fact] + public async Task IsActiveClubMemberAsync_BeforeTermStart_ReturnsFalse() + { + var baseId = 2100 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + var userId = baseId + 1; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + db.Add(new User { UserId = userId, Username = $"user-{baseId}", PasswordHash = "unused", RealName = "Test", AccountStatus = "active", CreatedAt = now }); + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = now }); + db.Add(new ClubMember + { + MemberId = baseId + 2, + ClubId = clubId, + UserId = userId, + MemberStatus = "active", + TermStart = now.AddMonths(1), + TermEnd = now.AddMonths(2) + }); + await db.SaveChangesAsync(); + + var isActive = await service.IsActiveClubMemberAsync(clubId, userId); + Assert.False(isActive, "Member before term start should not be active"); + } + + [Fact] + public async Task IsActiveClubMemberAsync_AfterTermEnd_ReturnsFalse() + { + var baseId = 2200 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + var userId = baseId + 1; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + db.Add(new User { UserId = userId, Username = $"user-{baseId}", PasswordHash = "unused", RealName = "Test", AccountStatus = "active", CreatedAt = now }); + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = now }); + db.Add(new ClubMember + { + MemberId = baseId + 2, + ClubId = clubId, + UserId = userId, + MemberStatus = "active", + TermStart = now.AddMonths(-2), + TermEnd = now.AddMonths(-1) + }); + await db.SaveChangesAsync(); + + var isActive = await service.IsActiveClubMemberAsync(clubId, userId); + Assert.False(isActive, "Member after term end should not be active"); + } + + [Fact] + public async Task IsActiveClubMemberAsync_OnTermBoundaryDates_ReturnsCorrectly() + { + var baseId = 2300 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + var userId = baseId + 1; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var businessDate = DateTime.UtcNow.Date; + + db.Add(new User { UserId = userId, Username = $"user-{baseId}", PasswordHash = "unused", RealName = "Test", AccountStatus = "active", CreatedAt = businessDate }); + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = businessDate }); + db.Add(new ClubMember + { + MemberId = baseId + 2, + ClubId = clubId, + UserId = userId, + MemberStatus = "active", + TermStart = businessDate, + TermEnd = businessDate + }); + await db.SaveChangesAsync(); + + var isActive = await service.IsActiveClubMemberAsync(clubId, userId); + Assert.True(isActive, "Member on exact term start and end dates should be active"); + } + + [Fact] + public async Task GetCandidateUsersQuery_ReturnsCorrectOrdering() + { + var baseId = 3000 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + var project = new Project + { + ProjectId = baseId, + ProjectName = "Test Project", + ClubId = clubId, + LeaderUserId = baseId + 10, + ProjectStatus = "active", + CreatedAt = now + }; + + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = now }); + db.Add(project); + + // Add candidate users in non-alphabetical order + var users = new[] + { + ("Zebra Zhang", "00002", baseId + 1), + ("Alice Wang", "00001", baseId + 2), + ("Bob Liu", null, baseId + 3) + }; + + foreach (var (realName, studentNo, userId) in users) + { + db.Add(new User + { + UserId = userId, + Username = $"user-{userId}", + PasswordHash = "unused", + RealName = realName, + StudentNo = studentNo, + AccountStatus = "active", + CreatedAt = now + }); + db.Add(new ClubMember + { + MemberId = userId + 100, + ClubId = clubId, + UserId = userId, + MemberStatus = "active", + TermStart = now.AddMonths(-1), + TermEnd = now.AddMonths(1) + }); + } + await db.SaveChangesAsync(); + + var candidates = await service.GetCandidateUsersQuery(project).ToListAsync(); + + // Verify ordering: RealName, then StudentNo, then UserId + Assert.Equal(3, candidates.Count); + Assert.Equal("Alice Wang", candidates[0].RealName); + Assert.Equal("Bob Liu", candidates[1].RealName); + Assert.Equal("Zebra Zhang", candidates[2].RealName); + } + + [Fact] + public async Task GetCandidateUsersQuery_ExcludesInactiveUsers() + { + var baseId = 3100 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + var project = new Project + { + ProjectId = baseId, + ProjectName = "Test Project", + ClubId = clubId, + LeaderUserId = baseId + 10, + ProjectStatus = "active", + CreatedAt = now + }; + + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = now }); + db.Add(project); + + // Active user + db.Add(new User { UserId = baseId + 1, Username = "active-user", PasswordHash = "unused", RealName = "Active", AccountStatus = "active", CreatedAt = now }); + db.Add(new ClubMember { MemberId = baseId + 100, ClubId = clubId, UserId = baseId + 1, MemberStatus = "active", TermStart = now.AddMonths(-1), TermEnd = now.AddMonths(1) }); + + // Inactive user + db.Add(new User { UserId = baseId + 2, Username = "inactive-user", PasswordHash = "unused", RealName = "Inactive", AccountStatus = "inactive", CreatedAt = now }); + db.Add(new ClubMember { MemberId = baseId + 101, ClubId = clubId, UserId = baseId + 2, MemberStatus = "active", TermStart = now.AddMonths(-1), TermEnd = now.AddMonths(1) }); + + await db.SaveChangesAsync(); + + var candidates = await service.GetCandidateUsersQuery(project).ToListAsync(); + + Assert.Single(candidates); + Assert.Equal("Active", candidates[0].RealName); + } + + [Fact] + public async Task GetCandidateUsersQuery_ExcludesExistingProjectMembers() + { + var baseId = 3200 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + var projectId = baseId; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + var project = new Project + { + ProjectId = projectId, + ProjectName = "Test Project", + ClubId = clubId, + LeaderUserId = baseId + 10, + ProjectStatus = "active", + CreatedAt = now + }; + + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = now }); + db.Add(project); + + // Candidate user not yet in project + db.Add(new User { UserId = baseId + 1, Username = "candidate", PasswordHash = "unused", RealName = "Candidate", AccountStatus = "active", CreatedAt = now }); + db.Add(new ClubMember { MemberId = baseId + 100, ClubId = clubId, UserId = baseId + 1, MemberStatus = "active", TermStart = now.AddMonths(-1), TermEnd = now.AddMonths(1) }); + + // Existing project member + db.Add(new User { UserId = baseId + 2, Username = "existing", PasswordHash = "unused", RealName = "Existing", AccountStatus = "active", CreatedAt = now }); + db.Add(new ClubMember { MemberId = baseId + 101, ClubId = clubId, UserId = baseId + 2, MemberStatus = "active", TermStart = now.AddMonths(-1), TermEnd = now.AddMonths(1) }); + db.Add(new ProjectMember { ProjectMemberId = baseId + 200, ProjectId = projectId, UserId = baseId + 2, MemberStatus = "active", JoinedAt = now }); + + await db.SaveChangesAsync(); + + var candidates = await service.GetCandidateUsersQuery(project).ToListAsync(); + + Assert.Single(candidates); + Assert.Equal("Candidate", candidates[0].RealName); + } + + [Fact] + public async Task GetCandidateUsersQuery_Defers_CompositionUntilMaterialization() + { + var baseId = 3300 + Interlocked.Increment(ref _sequence) * 10; + var clubId = baseId; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + var project = new Project + { + ProjectId = baseId, + ProjectName = "Test Project", + ClubId = clubId, + LeaderUserId = baseId + 10, + ProjectStatus = "active", + CreatedAt = now + }; + + db.Add(new Club { ClubId = clubId, ClubName = "Test Club", ClubStatus = "active", CreatedAt = now }); + db.Add(project); + await db.SaveChangesAsync(); + + var query = service.GetCandidateUsersQuery(project); + + // Query should be IQueryable, not materialized + Assert.IsAssignableFrom>(query); + + // Add data after query creation but before materialization + db.Add(new User { UserId = baseId + 1, Username = "user", PasswordHash = "unused", RealName = "User", AccountStatus = "active", CreatedAt = now }); + db.Add(new ClubMember { MemberId = baseId + 100, ClubId = clubId, UserId = baseId + 1, MemberStatus = "active", TermStart = now.AddMonths(-1), TermEnd = now.AddMonths(1) }); + await db.SaveChangesAsync(); + + // Materialization should include the newly added data + var candidates = await query.ToListAsync(); + Assert.Single(candidates); + } +} diff --git a/backend/Controllers/ForumPostsController.cs b/backend/Controllers/ForumPostsController.cs index 14150b18..ef01f037 100644 --- a/backend/Controllers/ForumPostsController.cs +++ b/backend/Controllers/ForumPostsController.cs @@ -9,6 +9,7 @@ using ApiCreateForumPostRequest = Org.OpenAPITools.Models.CreateForumPostRequest; using ApiForumPost = Org.OpenAPITools.Models.ForumPost; using ApiModerateForumPostRequest = Org.OpenAPITools.Models.ModerateForumPostRequest; +using ApiForumImageUploadResponse = Org.OpenAPITools.Models.ForumImageUploadResponse; using PermissionRole = ClubHub.Api.Services.AuthRole; namespace ClubHub.Api.Controllers; @@ -27,15 +28,18 @@ public sealed class ForumPostsController : ControllerBase private readonly ClubHubDbContext _db; private readonly AuthService _authService; private readonly ProjectMembershipService _projectMembershipService; + private readonly ForumImageUploadService _imageUploadService; public ForumPostsController( ClubHubDbContext db, AuthService authService, - ProjectMembershipService projectMembershipService) + ProjectMembershipService projectMembershipService, + ForumImageUploadService imageUploadService) { _db = db; _authService = authService; _projectMembershipService = projectMembershipService; + _imageUploadService = imageUploadService; } [HttpGet] @@ -46,34 +50,18 @@ public async Task GetAll(int clubId, [FromQuery] bool includeHidd var canModerate = Allows(context.Roles!, ForumModeratePermission, clubId); if (includeHidden && !canModerate) - return StatusCode(403, new { message = "\u5f53\u524d\u7528\u6237\u6ca1\u6709\u67e5\u770b\u5df2\u9690\u85cf\u5185\u5bb9\u7684\u6743\u9650\u3002" }); + return StatusCode(403, new { message = "当前用户没有查看已隐藏内容的权限。" }); - var topicQuery = _db.ForumPosts.AsNoTracking().Include(post => post.User) + // 加载所有帖子和用户信息 + var posts = await _db.ForumPosts.AsNoTracking().Include(post => post.User) .Where(post => post.ClubId == clubId) - .Where(post => post.ParentPostId == null) - .Where(post => includeHidden || post.PostStatus == Published); - var page = await ApiPaginationQuery.MaterializeAsync( - topicQuery - .OrderByDescending(post => post.IsTop) - .ThenByDescending(post => post.CreatedAt) - .ThenByDescending(post => post.PostId), - HttpContext, - HttpContext.RequestAborted); - if (page.Error is not null) return BadRequest(page.Error); - - var topicIds = page.Items.Select(topic => topic.PostId).ToArray(); - var replies = topicIds.Length == 0 - ? [] - : await _db.ForumPosts.AsNoTracking().Include(post => post.User) - .Where(reply => reply.ParentPostId != null && topicIds.Contains(reply.ParentPostId.Value)) - .Where(reply => includeHidden || reply.PostStatus == Published) - .OrderBy(reply => reply.CreatedAt).ThenBy(reply => reply.PostId) - .ToListAsync(HttpContext.RequestAborted); - var topics = page.Items - .Select(topic => ToApiPost(topic, replies.Where(reply => reply.ParentPostId == topic.PostId) - .Where(reply => includeHidden || (IsPublished(topic) && IsPublished(reply))) - .OrderBy(reply => reply.CreatedAt).ThenBy(reply => reply.PostId) - .Select(reply => ToApiPost(reply, [])).ToList())).ToList(); + .OrderByDescending(post => post.IsTop).ThenByDescending(post => post.CreatedAt).ThenByDescending(post => post.PostId) + .ToListAsync(); + + // 筛选顶级话题 + var topics = posts.Where(post => post.ParentPostId is null).Where(post => includeHidden || IsPublished(post)) + .Select(topic => ToApiPost(topic, BuildNestedReplies(posts, topic.PostId, includeHidden))).ToList(); + return Ok(topics); } @@ -96,8 +84,7 @@ public async Task Create(int clubId, [FromBody] ApiCreateForumPos parent = await _db.ForumPosts.FirstOrDefaultAsync(post => post.PostId == request.ParentPostId!.Value); if (parent is null) return NotFound(new { message = "\u7236\u7ea7\u8bdd\u9898\u4e0d\u5b58\u5728\u3002" }); if (parent.ClubId != clubId) return BadRequest(new { message = "\u4e0d\u80fd\u56de\u590d\u5176\u4ed6\u793e\u56e2\u7684\u8bdd\u9898\u3002" }); - if (parent.ParentPostId is not null) return BadRequest(new { message = "\u56de\u590d\u53ea\u80fd\u5173\u8054\u8bdd\u9898\u3002" }); - if (!IsPublished(parent)) return BadRequest(new { message = "\u5df2\u9690\u85cf\u7684\u8bdd\u9898\u4e0d\u80fd\u56de\u590d\u3002" }); + if (!IsPublished(parent)) return BadRequest(new { message = "\u5df2\u9690\u85cf\u7684\u5185\u5bb9\u4e0d\u80fd\u56de\u590d\u3002" }); } else if (string.IsNullOrWhiteSpace(title)) return BadRequest(new { message = "\u8bdd\u9898\u6807\u9898\u4e0d\u80fd\u4e3a\u7a7a\u3002" }); @@ -153,25 +140,43 @@ public async Task Delete(int clubId, int postId) var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var isTopicDelete = post.ParentPostId is null; + var descendantsToDelete = new List(); if (isTopicDelete) { - var replies = await _db.ForumPosts.Where(r => r.ParentPostId == postId).ToListAsync(); - _db.ForumPosts.RemoveRange(replies); - foreach (var reply in replies) + var allForumPosts = await _db.ForumPosts + .Where(item => item.ClubId == clubId) + .ToListAsync(); + var repliesByParent = allForumPosts.ToLookup(item => item.ParentPostId); + + var toProcess = new Queue(); + toProcess.Enqueue(postId); + while (toProcess.Count > 0) { - _db.OperationLogs.Add(new OperationLog + var currentId = toProcess.Dequeue(); + var children = repliesByParent[currentId]; + foreach (var child in children) { - UserId = context.User.UserId, - ModuleName = "forum", - OperationType = "reply_deleted", - TargetTable = "FORUM_POSTS", - TargetId = reply.PostId, - IpAddress = ipAddress, - CreatedAt = DateTime.UtcNow - }); + descendantsToDelete.Add(child); + toProcess.Enqueue(child.PostId); + } } } + foreach (var descendant in descendantsToDelete) + { + _db.ForumPosts.Remove(descendant); + _db.OperationLogs.Add(new OperationLog + { + UserId = context.User.UserId, + ModuleName = "forum", + OperationType = "reply_deleted", + TargetTable = "FORUM_POSTS", + TargetId = descendant.PostId, + IpAddress = ipAddress, + CreatedAt = DateTime.UtcNow + }); + } + _db.ForumPosts.Remove(post); _db.OperationLogs.Add(new OperationLog { @@ -188,6 +193,60 @@ public async Task Delete(int clubId, int postId) return NoContent(); } + [HttpPost("upload-image")] + [RequestSizeLimit(5 * 1024 * 1024 + 1024)] + public async Task UploadImage(int clubId, IFormFile? image) + { + var context = await GetUserContextAsync(clubId); + if (context.Result is not null) return context.Result; + if (!Allows(context.Roles!, ForumPostPermission, clubId)) + return StatusCode(403, new { message = "\u5f53\u524d\u7528\u6237\u6ca1\u6709\u53d1\u5e03\u8ba8\u8bba\u5185\u5bb9\u7684\u6743\u9650\u3002" }); + + if (image == null) + return BadRequest(new { message = "\u6587\u4ef6\u4e0d\u5b58\u5728" }); + + var result = await _imageUploadService.UploadAsync( + clubId, + image, + HttpContext.RequestAborted); + + if (!result.Success) + { + return result.FailureKind switch + { + UploadFailureKind.InvalidFile => + BadRequest(new { message = result.ErrorMessage ?? "\u6587\u4ef6\u4e0d\u7b26\u5408\u8981\u6c42" }), + UploadFailureKind.TooLarge => + StatusCode(413, new { message = result.ErrorMessage ?? "\u6587\u4ef6\u8fc7\u5927" }), + UploadFailureKind.Storage => + StatusCode(500, new { message = "\u4e0a\u4f20\u5931\u8d25" }), + _ => StatusCode(500, new { message = "\u4e0a\u4f20\u5931\u8d25" }) + }; + } + + var response = new ApiForumImageUploadResponse + { + ImageUrl = result.ImageUrl, + FileName = result.FileName, + UploadedAt = DateTime.UtcNow + }; + + return Ok(response); + } + + [HttpDelete("delete-image")] + public async Task DeleteImage(int clubId, [FromQuery] string storageKey) + { + var context = await GetUserContextAsync(clubId); + if (context.Result is not null) return context.Result; + + if (string.IsNullOrWhiteSpace(storageKey)) + return BadRequest(new { message = "storageKey 参数不能为空" }); + + var success = await _imageUploadService.DeleteAsync(storageKey, HttpContext.RequestAborted); + return success ? Ok() : StatusCode(500, new { message = "删除失败" }); + } + private async Task GetUserContextAsync(int clubId) { var userId = User.GetUserId(); @@ -206,6 +265,21 @@ private async Task GetUserContextAsync(int clubId) private static bool Allows(IReadOnlyList roles, string permission, int clubId) => AuthService.RolesAllow(roles, permission, clubId); private static bool IsPublished(ForumPost post) => string.Equals(post.PostStatus, Published, StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(post.PostStatus); + + private static List BuildNestedReplies(List allPosts, int? parentPostId, bool includeHidden) + { + var directReplies = allPosts.Where(r => r.ParentPostId == parentPostId) + .OrderBy(r => r.CreatedAt).ThenBy(r => r.PostId).ToList(); + var result = new List(); + foreach (var reply in directReplies) + { + if (!includeHidden && !IsPublished(reply)) continue; + var nestedReplies = BuildNestedReplies(allPosts, reply.PostId, includeHidden); + result.Add(ToApiPost(reply, nestedReplies)); + } + return result; + } + private static ApiForumPost ToApiPost(ForumPost post, List replies) => new() { Id = post.PostId, ClubId = post.ClubId, UserId = post.UserId, UserName = post.User is null ? null : (string.IsNullOrWhiteSpace(post.User.RealName) ? post.User.Username : post.User.RealName), ParentPostId = post.ParentPostId, Title = post.Title, Content = post.Content, IsTop = post.IsTop != 0, PostStatus = IsPublished(post) ? ApiForumPost.PostStatusEnum.PublishedEnum : ApiForumPost.PostStatusEnum.HiddenEnum, CreatedAt = LearningWorkflow.AsUtc(post.CreatedAt), UpdatedAt = LearningWorkflow.AsUtc(post.UpdatedAt), Replies = replies }; private sealed record UserContext(IActionResult? Result, User? User, IReadOnlyList? Roles); } diff --git a/backend/Models/ForumImageUploadResponse.cs b/backend/Models/ForumImageUploadResponse.cs new file mode 100644 index 00000000..906cd587 --- /dev/null +++ b/backend/Models/ForumImageUploadResponse.cs @@ -0,0 +1,65 @@ +/* + * ClubHub API + * + * ClubHub 高校社团运营与协同管理平台 API。 **API-first 开发流程**:修改本文件 → push → CI 自动生成前后端代码 → git pull 拉取生成代码 → 在 Controller / Services / Vue 组件中手写业务逻辑。 + * + * The version of the OpenAPI document: 0.1.0 + * + * Generated by: https://openapi-generator.tech + */ + +using System; +using System.Linq; +using System.Text; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json; + +namespace Org.OpenAPITools.Models +{ + /// + /// 论坛图片上传响应 + /// + [DataContract] + public partial class ForumImageUploadResponse + { + /// + /// 上传后的图片 OSS URL,可直接在 Markdown 中使用。 + /// + /// 上传后的图片 OSS URL,可直接在 Markdown 中使用。 + /* https://clubhub-oss.aliyuncs.com/forum/2026/08/19/abc123.png */ + [Required] + [DataMember(Name="imageUrl", EmitDefaultValue=false)] + public string ImageUrl { get; set; } + + /// + /// 图片文件名。 + /// + /// 图片文件名。 + /* abc123.png */ + [Required] + [DataMember(Name="fileName", EmitDefaultValue=false)] + public string FileName { get; set; } + + /// + /// 存储对象键,用于后续删除操作。 + /// + /// 存储对象键,用于后续删除操作。 + /* clubs/1/forum/2026/08/19/abc123def456.png */ + [Required] + [DataMember(Name="storageKey", EmitDefaultValue=false)] + public string StorageKey { get; set; } + + /// + /// 上传时间。 + /// + /// 上传时间。 + /* 2026-08-19T10:30Z */ + [Required] + [DataMember(Name="uploadedAt", EmitDefaultValue=true)] + public DateTime UploadedAt { get; set; } + + } +} diff --git a/backend/Program.cs b/backend/Program.cs index 2c91abfe..b5f3a4ce 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -62,6 +62,7 @@ builder.Configuration.GetSection(LearningPreviewOptions.SectionName)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/backend/Services/ForumImageUploadService.cs b/backend/Services/ForumImageUploadService.cs new file mode 100644 index 00000000..9cb1fc42 --- /dev/null +++ b/backend/Services/ForumImageUploadService.cs @@ -0,0 +1,210 @@ +using System.Collections.Frozen; +using Microsoft.Extensions.Options; +using OSS = AlibabaCloud.OSS.V2; + +namespace ClubHub.Api.Services; + +public enum UploadFailureKind +{ + InvalidFile, + TooLarge, + Storage +} + +public sealed record UploadResult(bool Success, string? ImageUrl, string? FileName, string? StorageKey, UploadFailureKind? FailureKind, string? ErrorMessage); + +public sealed class ForumImageUploadService : IDisposable +{ + private static readonly FrozenSet AllowedMimeTypes = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + "image/jpeg", + "image/png", + "image/gif", + "image/webp" + ); + + private static readonly FrozenSet AllowedExtensions = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp" + ); + + private const long MaxFileSizeBytes = 5 * 1024 * 1024; // 5 MB + private const string LegacyReferenceScheme = "oss"; + private readonly OssStorageOptions _options; + private readonly OSS.Client? _client; + private readonly Exception? _configurationError; + private readonly ILogger _logger; + + public ForumImageUploadService(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + if (!IsConfigured()) return; + + try + { + var credentialClient = new Aliyun.Credentials.Client( + new Aliyun.Credentials.Models.Config + { + Type = "ecs_ram_role", + RoleName = _options.RoleName.Trim() + }); + var credentialsProvider = new OSS.Credentials.CredentialsProviderFunc(() => + { + var credential = credentialClient.GetCredential(); + return new OSS.Credentials.Credentials( + credential.AccessKeyId, + credential.AccessKeySecret, + credential.SecurityToken); + }); + + var configuration = OSS.Configuration.LoadDefault(); + configuration.Region = _options.Region.Trim(); + configuration.Endpoint = NormalizeEndpoint(_options.Endpoint); + configuration.CredentialsProvider = credentialsProvider; + configuration.ConnectTimeout = TimeSpan.FromSeconds(10); + configuration.ReadWriteTimeout = TimeSpan.FromMinutes(5); + _client = new OSS.Client(configuration); + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception exception) + { + _configurationError = exception; + } + } + + /// + /// 验证图片文件的合法性(MIME 类型、大小等) + /// + public (bool IsValid, UploadFailureKind? FailureKind, string? ErrorMessage) ValidateImage(IFormFile file) + { + if (file == null) + return (false, UploadFailureKind.InvalidFile, "文件不存在"); + + if (file.Length == 0) + return (false, UploadFailureKind.InvalidFile, "文件大小为 0"); + + if (file.Length > MaxFileSizeBytes) + return (false, UploadFailureKind.TooLarge, $"文件过大,最大允许 {MaxFileSizeBytes / (1024 * 1024)} MB"); + + var mimeType = file.ContentType?.ToLowerInvariant() ?? string.Empty; + if (!AllowedMimeTypes.Contains(mimeType)) + return (false, UploadFailureKind.InvalidFile, "不支持的文件类型,仅支持 jpg、png、gif、webp"); + + var fileName = file.FileName?.ToLowerInvariant() ?? string.Empty; + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + if (!AllowedExtensions.Contains(extension)) + return (false, UploadFailureKind.InvalidFile, "文件扩展名不合法"); + + return (true, null, null); + } + + /// + /// 上传图片到 OSS + /// + public async Task UploadAsync( + int clubId, + IFormFile file, + CancellationToken cancellationToken) + { + var (isValid, failureKind, errorMessage) = ValidateImage(file); + if (!isValid) + return new(false, null, null, null, failureKind, errorMessage); + + if (_client == null) + return new(false, null, null, null, UploadFailureKind.Storage, "OSS 服务未正确配置"); + + var fileName = file.FileName ?? "image.jpg"; + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + var objectName = $"clubs/{clubId}/forum/{DateTime.UtcNow:yyyy/MM/dd}/{Guid.NewGuid():N}{extension}"; + + try + { + using var stream = file.OpenReadStream(); + await _client.PutObjectAsync( + new OSS.Models.PutObjectRequest + { + Bucket = _options.Bucket, + Key = objectName, + Body = stream, + ContentType = file.ContentType ?? "image/jpeg", + ContentDisposition = $"inline; filename*=UTF-8''{Uri.EscapeDataString(fileName)}" + }, + cancellationToken: cancellationToken); + + var imageUrl = BuildImageUrl(objectName); + return new(true, imageUrl, Path.GetFileName(objectName), objectName, null, null); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogError(exception, "OSS upload failed for club {ClubId} file {FileName}", clubId, fileName); + return new(false, null, null, null, UploadFailureKind.Storage, "上传失败"); + } + } + + /// + /// 删除已上传的图片 + /// + public async Task DeleteAsync(string storageKey, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(storageKey) || _client == null) + return false; + + try + { + await _client.DeleteObjectAsync( + new OSS.Models.DeleteObjectRequest + { + Bucket = _options.Bucket, + Key = storageKey + }, + cancellationToken: cancellationToken); + return true; + } + catch (Exception exception) + { + _logger.LogError(exception, "OSS delete failed for key {StorageKey}", storageKey); + return false; + } + } + + public void Dispose() => _client?.Dispose(); + + private OSS.Client? GetClient() => _client; + + private bool IsConfigured() => + !string.IsNullOrWhiteSpace(_options.Region) && + !string.IsNullOrWhiteSpace(_options.Endpoint) && + !string.IsNullOrWhiteSpace(_options.Bucket) && + !string.IsNullOrWhiteSpace(_options.RoleName); + + private string BuildImageUrl(string objectName) + { + var normalizedEndpoint = NormalizeEndpoint(_options.Endpoint); + var endpointWithoutScheme = normalizedEndpoint["https://".Length..]; + var bucket = _options.Bucket.Trim(); + return $"https://{bucket}.{endpointWithoutScheme}/{objectName}"; + } + + private static string NormalizeEndpoint(string endpoint) + { + var normalized = endpoint.Trim().TrimEnd('/'); + if (normalized.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Forum image OSS endpoint must use HTTPS or omit the scheme."); + } + + return normalized.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ? normalized + : $"https://{normalized}"; + } +} diff --git a/frontend/package.json b/frontend/package.json index aca14404..625f846e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,11 +22,16 @@ "vue-router": "^4.6.4" }, "devDependencies": { + "@types/dompurify": "^3.2.0", + "@types/marked": "^6.0.0", "@types/node": "^24.13.2", "@vitejs/plugin-vue": "^6.0.7", - "@vue/tsconfig": "^0.9.1", "@vitest/coverage-v8": "^4.1.10", + "@vue/tsconfig": "^0.9.1", + "dompurify": "^3.4.13", "jsdom": "^26.1.0", + "markdown-it": "^15.0.0", + "marked": "^18.0.10", "prettier": "3.9.4", "typescript": "~6.0.2", "vite": "^8.1.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 19992cfa..b72684aa 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -12,7 +12,7 @@ importers: version: 2.3.2(vue@3.5.39(typescript@6.0.3)) axios: specifier: ^1.18.1 - version: 1.18.1 + version: 1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) element-plus: specifier: ^2.14.2 version: 2.14.2(vue@3.5.39(typescript@6.0.3)) @@ -23,6 +23,12 @@ importers: specifier: ^4.6.4 version: 4.6.4(vue@3.5.39(typescript@6.0.3)) devDependencies: + "@types/dompurify": + specifier: ^3.2.0 + version: 3.2.0 + "@types/marked": + specifier: ^6.0.0 + version: 6.0.0 "@types/node": specifier: ^24.13.2 version: 24.13.2 @@ -35,9 +41,18 @@ importers: "@vue/tsconfig": specifier: ^0.9.1 version: 0.9.1(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)) + dompurify: + specifier: ^3.4.13 + version: 3.4.13 jsdom: specifier: ^26.1.0 - version: 26.1.0 + version: 26.1.0(supports-color@7.2.0) + markdown-it: + specifier: ^15.0.0 + version: 15.0.0 + marked: + specifier: ^18.0.10 + version: 18.0.10 prettier: specifier: 3.9.4 version: 3.9.4 @@ -49,7 +64,7 @@ importers: version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)) + version: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)) vue-tsc: specifier: ^3.3.5 version: 3.3.6(typescript@6.0.3) @@ -635,6 +650,13 @@ packages: integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, } + "@types/dompurify@3.2.0": + resolution: + { + integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==, + } + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + "@types/estree@1.0.9": resolution: { @@ -653,12 +675,25 @@ packages: integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==, } + "@types/marked@6.0.0": + resolution: + { + integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==, + } + deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed. + "@types/node@24.13.2": resolution: { integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==, } + "@types/trusted-types@2.0.7": + resolution: + { + integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, + } + "@types/web-bluetooth@0.0.21": resolution: { @@ -754,6 +789,11 @@ packages: { integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==, } + peerDependencies: + typescript: "*" + peerDependenciesMeta: + typescript: + optional: true "@vue/compiler-core@3.5.39": resolution: @@ -879,6 +919,12 @@ packages: integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==, } + argparse@3.0.0: + resolution: + { + integrity: sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==, + } + assertion-error@2.0.1: resolution: { @@ -995,6 +1041,12 @@ packages: } engines: { node: ">=8" } + dompurify@3.4.13: + resolution: + { + integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==, + } + dunder-proto@1.0.1: resolution: { @@ -1024,6 +1076,13 @@ packages: } engines: { node: ">=0.12" } + entities@8.0.0: + resolution: + { + integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==, + } + engines: { node: ">=20.19.0" } + es-define-property@1.0.1: resolution: { @@ -1375,6 +1434,12 @@ packages: } engines: { node: ">= 12.0.0" } + linkify-it@6.1.0: + resolution: + { + integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==, + } + lodash-es@4.18.1: resolution: { @@ -1422,6 +1487,21 @@ packages: } engines: { node: ">=10" } + markdown-it@15.0.0: + resolution: + { + integrity: sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==, + } + hasBin: true + + marked@18.0.10: + resolution: + { + integrity: sha512-FJeH4bRpYoXiggcgriCGItKCSv3xkngJc4QCZ/rkQCogU3VYaLxYJoZl8Nw/b4+x7iij/pd+09mZ6A1dXzpL0A==, + } + engines: { node: ">= 20" } + hasBin: true + math-intrinsics@1.1.0: resolution: { @@ -1429,6 +1509,12 @@ packages: } engines: { node: ">= 0.4" } + mdurl@2.1.0: + resolution: + { + integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==, + } + memoize-one@6.0.0: resolution: { @@ -1541,6 +1627,13 @@ packages: } engines: { node: ">=10" } + punycode.js@2.3.1: + resolution: + { + integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==, + } + engines: { node: ">=6" } + punycode@2.3.1: resolution: { @@ -1689,6 +1782,12 @@ packages: engines: { node: ">=14.17" } hasBin: true + uc.micro@3.0.0: + resolution: + { + integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==, + } + undici-types@7.18.2: resolution: { @@ -2137,6 +2236,10 @@ snapshots: "@types/deep-eql@4.0.2": {} + "@types/dompurify@3.2.0": + dependencies: + dompurify: 3.4.13 + "@types/estree@1.0.9": {} "@types/lodash-es@4.17.12": @@ -2145,10 +2248,17 @@ snapshots: "@types/lodash@4.17.24": {} + "@types/marked@6.0.0": + dependencies: + marked: 18.0.10 + "@types/node@24.13.2": dependencies: undici-types: 7.18.2 + "@types/trusted-types@2.0.7": + optional: true + "@types/web-bluetooth@0.0.21": {} "@vitejs/plugin-vue@6.0.7(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1))(vue@3.5.39(typescript@6.0.3))": @@ -2169,7 +2279,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)) + vitest: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)) "@vitest/expect@4.1.10": dependencies: @@ -2218,11 +2328,13 @@ snapshots: "@volar/source-map@2.4.28": {} - "@volar/typescript@2.4.28": + "@volar/typescript@2.4.28(typescript@6.0.3)": dependencies: "@volar/language-core": 2.4.28 path-browserify: 1.0.1 vscode-uri: 3.1.0 + optionalDependencies: + typescript: 6.0.3 "@vue/compiler-core@3.5.39": dependencies: @@ -2308,9 +2420,9 @@ snapshots: dependencies: vue: 3.5.39(typescript@6.0.3) - agent-base@6.0.2: + agent-base@6.0.2(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -2318,6 +2430,8 @@ snapshots: alien-signals@3.2.1: {} + argparse@3.0.0: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -2330,11 +2444,11 @@ snapshots: asynckit@0.4.0: {} - axios@1.18.1: + axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) form-data: 4.0.6 - https-proxy-agent: 5.0.1 + https-proxy-agent: 5.0.1(supports-color@7.2.0) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -2367,9 +2481,11 @@ snapshots: dayjs@1.11.21: {} - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decimal.js@10.6.0: {} @@ -2377,6 +2493,10 @@ snapshots: detect-libc@2.1.2: {} + dompurify@3.4.13: + optionalDependencies: + "@types/trusted-types": 2.0.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2406,6 +2526,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2465,7 +2587,9 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): + optionalDependencies: + debug: 4.4.3(supports-color@7.2.0) form-data@4.0.6: dependencies: @@ -2518,24 +2642,24 @@ snapshots: html-escaper@2.0.2: {} - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@7.2.0): dependencies: - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -2560,14 +2684,14 @@ snapshots: js-tokens@10.0.0: {} - jsdom@26.1.0: + jsdom@26.1.0(supports-color@7.2.0): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.24 parse5: 7.3.0 @@ -2636,6 +2760,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linkify-it@6.1.0: + dependencies: + uc.micro: 3.0.0 + lodash-es@4.18.1: {} lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): @@ -2662,8 +2790,21 @@ snapshots: dependencies: semver: 7.8.5 + markdown-it@15.0.0: + dependencies: + argparse: 3.0.0 + entities: 8.0.0 + linkify-it: 6.1.0 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 3.0.0 + + marked@18.0.10: {} + math-intrinsics@1.1.0: {} + mdurl@2.1.0: {} + memoize-one@6.0.0: {} mime-db@1.52.0: {} @@ -2706,6 +2847,8 @@ snapshots: proxy-from-env@2.1.0: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} rolldown@1.1.4: @@ -2783,6 +2926,8 @@ snapshots: typescript@6.0.3: {} + uc.micro@3.0.0: {} + undici-types@7.18.2: {} vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1): @@ -2797,7 +2942,7 @@ snapshots: esbuild: 0.28.1 fsevents: 2.3.3 - vitest@4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)): + vitest@4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)): dependencies: "@vitest/expect": 4.1.10 "@vitest/mocker": 4.1.10(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)) @@ -2822,7 +2967,7 @@ snapshots: optionalDependencies: "@types/node": 24.13.2 "@vitest/coverage-v8": 4.1.10(vitest@4.1.10) - jsdom: 26.1.0 + jsdom: 26.1.0(supports-color@7.2.0) transitivePeerDependencies: - msw @@ -2837,7 +2982,7 @@ snapshots: vue-tsc@3.3.6(typescript@6.0.3): dependencies: - "@volar/typescript": 2.4.28 + "@volar/typescript": 2.4.28(typescript@6.0.3) "@vue/language-core": 3.3.6 typescript: 6.0.3 diff --git a/frontend/src/api/apis/DefaultApi.ts b/frontend/src/api/apis/DefaultApi.ts index dc631384..0af024fc 100644 --- a/frontend/src/api/apis/DefaultApi.ts +++ b/frontend/src/api/apis/DefaultApi.ts @@ -267,6 +267,11 @@ import { DependencyHealthReportFromJSON, DependencyHealthReportToJSON, } from "../models/DependencyHealthReport"; +import { + type ForumImageUploadResponse, + ForumImageUploadResponseFromJSON, + ForumImageUploadResponseToJSON, +} from "../models/ForumImageUploadResponse"; import { type ForumPost, ForumPostFromJSON, ForumPostToJSON } from "../models/ForumPost"; import { type GenerateClubEvaluationsRequest, @@ -962,6 +967,17 @@ export interface DeleteClubForumPostRequest { postId: number; } +export interface DeleteForumImageRequest { + /** + * + */ + clubId: number; + /** + * 存储对象键,来自上传响应 + */ + storageKey: string; +} + export interface DeleteLearningResourceRequest { /** * @@ -2461,6 +2477,17 @@ export interface UploadClubAwardRuleDocumentFileRequest { file: Blob; } +export interface UploadForumImageRequest { + /** + * + */ + clubId: number; + /** + * 图片文件,支持 jpg、png、gif、webp,文件大小 ≤ 5MB + */ + image: Blob; +} + export interface UploadLearningResourceRequest { /** * 文件所属社团。 @@ -5273,7 +5300,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * 删除话题(包括其所有回复)或删除回复。话题发布者或讨论区管理员可执行删除操作。删除操作会记录至审计日志。 + * 删除话题或回复。删除话题时级联删除其所有后代回复(包括嵌套回复)。删除回复时仅删除该回复及其所有子后代。话题发布者或讨论区管理员可执行删除操作。所有删除操作均记录至审计日志。 * 删除社团讨论区话题或回复 */ async deleteClubForumPostRaw( @@ -5287,7 +5314,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * 删除话题(包括其所有回复)或删除回复。话题发布者或讨论区管理员可执行删除操作。删除操作会记录至审计日志。 + * 删除话题或回复。删除话题时级联删除其所有后代回复(包括嵌套回复)。删除回复时仅删除该回复及其所有子后代。话题发布者或讨论区管理员可执行删除操作。所有删除操作均记录至审计日志。 * 删除社团讨论区话题或回复 */ async deleteClubForumPost( @@ -5297,6 +5324,77 @@ export class DefaultApi extends runtime.BaseAPI { await this.deleteClubForumPostRaw(requestParameters, initOverrides); } + /** + * Creates request options for deleteForumImage without sending the request + */ + async deleteForumImageRequestOpts( + requestParameters: DeleteForumImageRequest, + ): Promise { + if (requestParameters["clubId"] == null) { + throw new runtime.RequiredError( + "clubId", + 'Required parameter "clubId" was null or undefined when calling deleteForumImage().', + ); + } + + if (requestParameters["storageKey"] == null) { + throw new runtime.RequiredError( + "storageKey", + 'Required parameter "storageKey" was null or undefined when calling deleteForumImage().', + ); + } + + const queryParameters: any = {}; + + if (requestParameters["storageKey"] != null) { + queryParameters["storageKey"] = requestParameters["storageKey"]; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("bearerAuth", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/api/v1/clubs/{clubId}/forum-posts/delete-image`; + urlPath = urlPath.replace("{clubId}", encodeURIComponent(String(requestParameters["clubId"]))); + + return { + path: urlPath, + method: "DELETE", + headers: headerParameters, + query: queryParameters, + }; + } + + /** + * 删除已上传的论坛图片 + */ + async deleteForumImageRaw( + requestParameters: DeleteForumImageRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.deleteForumImageRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.VoidApiResponse(response); + } + + /** + * 删除已上传的论坛图片 + */ + async deleteForumImage( + requestParameters: DeleteForumImageRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + await this.deleteForumImageRaw(requestParameters, initOverrides); + } + /** * Creates request options for deleteLearningResource without sending the request */ @@ -13366,6 +13464,94 @@ export class DefaultApi extends runtime.BaseAPI { return await response.value(); } + /** + * Creates request options for uploadForumImage without sending the request + */ + async uploadForumImageRequestOpts( + requestParameters: UploadForumImageRequest, + ): Promise { + if (requestParameters["clubId"] == null) { + throw new runtime.RequiredError( + "clubId", + 'Required parameter "clubId" was null or undefined when calling uploadForumImage().', + ); + } + + if (requestParameters["image"] == null) { + throw new runtime.RequiredError( + "image", + 'Required parameter "image" was null or undefined when calling uploadForumImage().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("bearerAuth", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + const consumes: runtime.Consume[] = [{ contentType: "multipart/form-data" }]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + // use FormData to transmit files using content-type "multipart/form-data" + useForm = canConsumeForm; + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + if (requestParameters["image"] != null) { + formParams.append("image", requestParameters["image"] as any); + } + + let urlPath = `/api/v1/clubs/{clubId}/forum-posts/upload-image`; + urlPath = urlPath.replace("{clubId}", encodeURIComponent(String(requestParameters["clubId"]))); + + return { + path: urlPath, + method: "POST", + headers: headerParameters, + query: queryParameters, + body: formParams, + }; + } + + /** + * 上传论坛图片到 OSS + */ + async uploadForumImageRaw( + requestParameters: UploadForumImageRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.uploadForumImageRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => + ForumImageUploadResponseFromJSON(jsonValue), + ); + } + + /** + * 上传论坛图片到 OSS + */ + async uploadForumImage( + requestParameters: UploadForumImageRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + const response = await this.uploadForumImageRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Creates request options for uploadLearningResource without sending the request */ diff --git a/frontend/src/api/models/ForumImageUploadResponse.ts b/frontend/src/api/models/ForumImageUploadResponse.ts new file mode 100644 index 00000000..91c486e6 --- /dev/null +++ b/frontend/src/api/models/ForumImageUploadResponse.ts @@ -0,0 +1,92 @@ +// @ts-nocheck +/* tslint:disable */ +/* eslint-disable */ +/** + * ClubHub API + * ClubHub 高校社团运营与协同管理平台 API。 **API-first 开发流程**:修改本文件 → push → CI 自动生成前后端代码 → git pull 拉取生成代码 → 在 Controller / Services / Vue 组件中手写业务逻辑。 + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues, parseDate, parseDateTime, serializeDate, serializeDateTime } from "../runtime"; +/** + * 论坛图片上传响应 + * @export + * @interface ForumImageUploadResponse + */ +export interface ForumImageUploadResponse { + /** + * 上传后的图片 OSS URL,可直接在 Markdown 中使用。 + */ + imageUrl: string; + /** + * 图片文件名。 + */ + fileName: string; + /** + * 存储对象键,用于后续删除操作。 + */ + storageKey: string; + /** + * 上传时间。 + */ + uploadedAt: Date; +} + +/** + * Check if a given object implements the ForumImageUploadResponse interface. + */ +export function instanceOfForumImageUploadResponse( + value: object, +): value is ForumImageUploadResponse { + if (!("imageUrl" in value) || value["imageUrl"] === undefined) return false; + if (!("fileName" in value) || value["fileName"] === undefined) return false; + if (!("storageKey" in value) || value["storageKey"] === undefined) return false; + if (!("uploadedAt" in value) || value["uploadedAt"] === undefined) return false; + return true; +} + +export function ForumImageUploadResponseFromJSON(json: any): ForumImageUploadResponse { + return ForumImageUploadResponseFromJSONTyped(json, false); +} + +export function ForumImageUploadResponseFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): ForumImageUploadResponse { + if (json == null) { + return json; + } + return { + imageUrl: json["imageUrl"], + fileName: json["fileName"], + storageKey: json["storageKey"], + uploadedAt: json["uploadedAt"] == null ? json["uploadedAt"] : parseDateTime(json["uploadedAt"]), + }; +} + +export function ForumImageUploadResponseToJSON(json: any): ForumImageUploadResponse { + return ForumImageUploadResponseToJSONTyped(json, false); +} + +export function ForumImageUploadResponseToJSONTyped( + value?: ForumImageUploadResponse | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + imageUrl: value["imageUrl"], + fileName: value["fileName"], + storageKey: value["storageKey"], + uploadedAt: + value["uploadedAt"] == null ? value["uploadedAt"] : serializeDateTime(value["uploadedAt"]), + }; +} diff --git a/frontend/src/api/models/index.ts b/frontend/src/api/models/index.ts index a2000b3a..af4f182d 100644 --- a/frontend/src/api/models/index.ts +++ b/frontend/src/api/models/index.ts @@ -66,6 +66,7 @@ export * from "./DependencyHealthCheck"; export * from "./DependencyHealthReport"; export * from "./DissolveClubRequest"; export * from "./ExitClubMemberRequest"; +export * from "./ForumImageUploadResponse"; export * from "./ForumPost"; export * from "./GenerateClubEvaluationsRequest"; export * from "./GenerateClubEvaluationsResult"; diff --git a/frontend/src/components/MarkdownEditor.vue b/frontend/src/components/MarkdownEditor.vue new file mode 100644 index 00000000..2a5cc141 --- /dev/null +++ b/frontend/src/components/MarkdownEditor.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/components/MarkdownRenderer.vue b/frontend/src/components/MarkdownRenderer.vue new file mode 100644 index 00000000..c43e3711 --- /dev/null +++ b/frontend/src/components/MarkdownRenderer.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/frontend/src/components/ReplyItem.vue b/frontend/src/components/ReplyItem.vue new file mode 100644 index 00000000..a58fcd5b --- /dev/null +++ b/frontend/src/components/ReplyItem.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/frontend/src/navigation.ts b/frontend/src/navigation.ts index da593336..772b4e09 100644 --- a/frontend/src/navigation.ts +++ b/frontend/src/navigation.ts @@ -78,7 +78,7 @@ export function buildNavigationGroups(permissions: string[]): NavigationGroup[] items: [ { label: "活动", path: "/activities", icon: "activity" }, { label: "通知", path: "/notices", icon: "notice" }, - { label: "讨论区", path: "/forum", icon: "forum" }, + { label: "讨论", path: "/forum", icon: "forum" }, { label: "项目", path: "/projects", icon: "project" }, ], }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ae17e6c6..b510c882 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -120,7 +120,7 @@ const router = createRouter({ { path: "/forum", component: ForumCenter, - meta: { title: "讨论区", eyebrow: "Community Forum" }, + meta: { title: "社团讨论区", eyebrow: "Community Forum" }, }, ], }); diff --git a/frontend/src/views/ForumCenter.vue b/frontend/src/views/ForumCenter.vue index 5cf2677b..092093fb 100644 --- a/frontend/src/views/ForumCenter.vue +++ b/frontend/src/views/ForumCenter.vue @@ -7,6 +7,9 @@ import { ForumPostFromJSON } from "../api/models"; import { onSessionChange, readAuth } from "../authSession"; import { formatBeijingDateTime } from "../beijingTime"; import { requestJson } from "../composables/useApiRequest"; +import MarkdownEditor from "../components/MarkdownEditor.vue"; +import MarkdownRenderer from "../components/MarkdownRenderer.vue"; +import ReplyItem from "../components/ReplyItem.vue"; const clubs = ref([]); const topics = ref([]); @@ -15,6 +18,7 @@ const loading = ref(false); const loadError = ref(null); const showHidden = ref(false); const replyingTo = ref(null); +const replyingToParentId = ref(null); const saving = ref(false); const auth = ref(readAuth()); const currentMemberClubIds = ref(new Set()); @@ -89,7 +93,7 @@ async function loadPosts() { loadError.value = null; try { const query = includeHidden ? "?includeHidden=true" : ""; - const data = await requestJson(`/api/v1/clubs/${clubId}/forum-posts${query}`); + const data = await requestJson(`/api/v1/clubs/${clubId}/forum-posts${query}`); const posts = data.map(ForumPostFromJSON); if (requestVersion === postsRequestVersion) { topics.value = posts; @@ -119,6 +123,7 @@ async function createPost(parentPostId?: number) { topicForm.content = ""; replyForm.content = ""; replyingTo.value = null; + replyingToParentId.value = null; ElMessage.success(parentPostId ? "回复成功" : "话题发布成功"); await loadPosts(); } catch (error) { @@ -209,7 +214,7 @@ onUnmounted(() => stopSessionListener?.());

社团讨论区

-

来聊点有意思的。一起建设社团的精神家园!

+
来聊点有意思的。一起建设社团的精神家园!
@@ -238,16 +243,20 @@ onUnmounted(() => stopSessionListener?.()); - + + + 发布话题 @@ -278,7 +287,7 @@ onUnmounted(() => stopSessionListener?.()); >发布人:{{ topic.userName || "匿名用户" }} · {{ formatTime(topic.createdAt) }} -

{{ topic.content }}

+
stopSessionListener?.()); >删除
-
- 发布人:{{ reply.userName || "匿名用户" }} · {{ formatTime(reply.createdAt) }} -

{{ reply.content }}

-
- {{ reply.postStatus === "hidden" ? "恢复显示" : "隐藏" }} - 删除 - 删除 -
+
+
+
+
+ {{ replyingTo.title || `回复自:${replyingTo.userName || "匿名用户"}` }} +
+
+ +
+
+ + @@ -397,7 +404,15 @@ onUnmounted(() => stopSessionListener?.()); } .heading { justify-content: space-between; - margin-bottom: 16px; +} +.subtitle, +.workspace-head p, +.muted { + color: var(--el-text-color-secondary); +} +.subtitle { + margin-top: 6px; + font-size: 14px; } .club-select { width: 240px; @@ -424,8 +439,7 @@ onUnmounted(() => stopSessionListener?.()); background: color-mix(in srgb, var(--club-surface-solid) 92%, var(--club-primary-soft)); box-shadow: var(--club-shadow-sm); } -.topic.hidden, -.reply.hidden { +.topic.hidden { background: var(--el-fill-color-lighter); } .topic header { @@ -460,6 +474,9 @@ onUnmounted(() => stopSessionListener?.()); overflow-wrap: anywhere; line-height: 1.65; } +.topic-reply { + margin-top: 12px; +} .reply { margin-top: 12px; padding: 12px; @@ -468,10 +485,20 @@ onUnmounted(() => stopSessionListener?.()); border-radius: 0 var(--club-radius-sm) var(--club-radius-sm) 0; background: color-mix(in srgb, var(--club-primary-soft) 30%, var(--club-surface-solid)); } -.reply-actions { - display: flex; - gap: 8px; - margin-top: 8px; +.reply-context { + padding: 12px; + background: var(--el-fill-color-lighter); + border-radius: 4px; + margin-bottom: 12px; +} +.context-title { + font-weight: 600; + margin-bottom: 8px; + color: var(--el-text-color); +} +.context-content { + font-size: 13px; + color: var(--el-text-color-secondary); } small { color: var(--el-text-color-secondary);