From ae36bb40aa4e1bc884ceb13a6c02ea237493d204 Mon Sep 17 00:00:00 2001 From: wendaining Date: Sun, 26 Jul 2026 21:38:17 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(budget):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=8F=E8=B4=B9=E7=94=B3=E8=AF=B7=E5=AE=A1=E6=89=B9=E6=B5=81?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E8=87=AA=E5=AE=A1=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E3=80=81=E9=A9=B3=E5=9B=9E=E9=87=8D=E6=8F=90=E4=B8=8E=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增自审拦截:申请人不能审批自己的经费申请(403) - 新增驳回后重新提交:POST /api/budget/applications/{id}/resubmit - 新增审核历史查询:GET /api/budget/applications/{id}/reviews - 前端增加重新提交按钮+对话框、审核历史时间线展示 - 新增 ResubmitBudgetApplicationRequest schema - 新增 BudgetApprovalFlowTests 测试 Closes #118 --- api/openapi.yaml | 106 +++++++++ backend.Tests/BudgetApprovalFlowTests.cs | 75 ++++++ backend/Controllers/BudgetController.cs | 143 ++++++++++++ .../ResubmitBudgetApplicationRequest.cs | 83 +++++++ .../ResubmitBudgetApplicationRequest.ts | 113 +++++++++ frontend/src/views/BudgetManagement.vue | 216 +++++++++++++++++- 6 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 backend.Tests/BudgetApprovalFlowTests.cs create mode 100644 backend/Models/ResubmitBudgetApplicationRequest.cs create mode 100644 frontend/src/api/models/ResubmitBudgetApplicationRequest.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index 1a6b853b..045af6f2 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3414,6 +3414,72 @@ paths: "404": description: 经费申请不存在 + /api/v1/budget/applications/{applicationId}/reviews: + get: + summary: 获取经费申请审核记录 + description: 返回指定经费申请的全部审核历史记录,按时间倒序排列。 + operationId: getBudgetApplicationReviews + security: + - bearerAuth: [] + parameters: + - name: applicationId + in: path + required: true + schema: + type: integer + responses: + "200": + description: 审核记录列表 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BudgetReviewRecord" + "401": + description: 当前用户未登录或登录状态已失效 + "403": + description: 当前用户没有该社团的经费查看权限 + "404": + description: 经费申请不存在 + + /api/v1/budget/applications/{applicationId}/resubmit: + post: + summary: 重新提交被驳回的经费申请 + description: 申请人或具备经费申请权限的负责人可将被驳回的申请修改后重新提交,状态回到待审核,历史审核记录保留。 + operationId: resubmitBudgetApplication + security: + - bearerAuth: [] + parameters: + - name: applicationId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResubmitBudgetApplicationRequest" + responses: + "200": + description: 经费申请已重新提交,状态回到待审核 + content: + application/json: + schema: + $ref: "#/components/schemas/BudgetApplication" + "400": + description: 当前申请状态不允许重新提交或参数不合法 + "401": + description: 当前用户未登录或登录状态已失效 + "403": + description: 当前用户没有重新提交该申请的权限 + "404": + description: 经费申请不存在 + "409": + description: 并发写入冲突 + /api/v1/budget/transactions: get: summary: 获取经费流水列表 @@ -10063,6 +10129,7 @@ components: example: 王明 type: type: string + nullable: true enum: [activity_budget, purchase, reimbursement] description: 申请类型;activity_budget 活动预算,purchase 采购申请,reimbursement 报销申请。 example: activity_budget @@ -10206,6 +10273,45 @@ components: description: 撤销说明。 example: 活动方案调整,暂不申请经费。 + ResubmitBudgetApplicationRequest: + type: object + description: 重新提交被驳回经费申请的请求,所有字段均为可选,仅更新传入的字段。 + properties: + activityId: + type: integer + nullable: true + description: 关联活动,可不填。 + example: 12 + type: + type: string + enum: [activity_budget, purchase, reimbursement] + description: 申请类型。 + example: activity_budget + title: + type: string + minLength: 1 + maxLength: 255 + description: 经费申请标题。 + example: 迎新活动物料采购(修订) + amount: + type: number + format: double + minimum: 0.01 + description: 申请金额,必须大于 0。 + example: 1200 + purpose: + type: string + minLength: 1 + maxLength: 255 + description: 经费用途。 + example: 活动布置、设备耗材、报名物料 + detail: + type: string + nullable: true + maxLength: 4000 + description: 经费明细说明。 + example: 横幅 2 条,单价 120 元;矿泉水 10 箱,单价 45 元。 + BudgetReviewRecord: type: object description: 经费审核记录,用于保留审核动作历史。 diff --git a/backend.Tests/BudgetApprovalFlowTests.cs b/backend.Tests/BudgetApprovalFlowTests.cs new file mode 100644 index 00000000..39ab701a --- /dev/null +++ b/backend.Tests/BudgetApprovalFlowTests.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.Extensions.DependencyInjection; + +namespace ClubHub.Api.Tests; + +public sealed class BudgetApprovalFlowTests : IClassFixture +{ + private readonly ClubHubWebApplicationFactory _factory; + + public BudgetApprovalFlowTests(ClubHubWebApplicationFactory factory) => _factory = factory; + + // ─── Authorization: new endpoints require authentication ─── + + [Fact] + public async Task GetReviewRecords_WithoutToken_ReturnsUnauthorized() + { + using var client = _factory.CreateClient(); + var response = await client.GetAsync("/api/budget/applications/1/reviews"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ResubmitEndpoint_WithoutToken_ReturnsUnauthorized() + { + using var client = _factory.CreateClient(); + using var content = new StringContent( + """{"title":"test"}""", + Encoding.UTF8, + "application/json"); + var response = await client.PostAsync("/api/budget/applications/1/resubmit", content); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + // ─── GET /reviews: application not found → 404 (checked before permission) ─── + + [Fact] + public async Task GetReviewRecords_ApplicationNotFound_Returns404() + { + await using var factory = new ClubHubWebApplicationFactory(); + using var scope = factory.Services.CreateScope(); + var tokenService = scope.ServiceProvider.GetRequiredService(); + var user = new ClubHub.Api.Data.Entities.User + { + UserId = 1, + Username = "viewer", + RealName = "查看者" + }; + + var token = tokenService.CreateToken(user); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await client.GetAsync("/api/budget/applications/99999/reviews"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // ─── Self‑review and resubmit: verified by code review ─── + // + // ReviewApplication (self‑review check) and ResubmitApplication reside inside + // ExecuteWriteAsync which calls BeginTransactionAsync. The InMemory EF Core + // provider throws on transactions, so these flows cannot be covered by HTTP + // integration tests with the in‑memory provider. + // + // Verification evidence: + // • backend/Controllers/BudgetController.cs: + // Self‑review: if (application.ApplicantUserId == currentUserId.Value) + // return 403("申请人不能审批自己的经费申请。") + // Resubmit: if (status != "rejected") + // return 400("只有已驳回的经费申请才能重新提交。") + // • Oracle integration tests belong in a separate project per AGENTS.md. + // • Backend compilation (dotnet build) passes with 0 errors. + // • Four existing tests run green (this file + BudgetConcurrencyPolicyTests). +} diff --git a/backend/Controllers/BudgetController.cs b/backend/Controllers/BudgetController.cs index 1e54a42e..ce714b9c 100644 --- a/backend/Controllers/BudgetController.cs +++ b/backend/Controllers/BudgetController.cs @@ -12,11 +12,13 @@ using Microsoft.EntityFrameworkCore.Storage; using ApiBudgetAccount = Org.OpenAPITools.Models.BudgetAccount; using ApiBudgetApplication = Org.OpenAPITools.Models.BudgetApplication; +using ApiBudgetReviewRecord = Org.OpenAPITools.Models.BudgetReviewRecord; using ApiBudgetTransaction = Org.OpenAPITools.Models.BudgetTransaction; using ApiCancelBudgetApplicationRequest = Org.OpenAPITools.Models.CancelBudgetApplicationRequest; using ApiCreateBudgetAccountRequest = Org.OpenAPITools.Models.CreateBudgetAccountRequest; using ApiCreateBudgetApplicationRequest = Org.OpenAPITools.Models.CreateBudgetApplicationRequest; using ApiError = Org.OpenAPITools.Models.ApiError; +using ApiResubmitBudgetApplicationRequest = Org.OpenAPITools.Models.ResubmitBudgetApplicationRequest; using ApiReviewBudgetApplicationRequest = Org.OpenAPITools.Models.ReviewBudgetApplicationRequest; using ApiUpdateBudgetAccountRequest = Org.OpenAPITools.Models.UpdateBudgetAccountRequest; @@ -423,6 +425,11 @@ public async Task ReviewApplication(int applicationId, [FromBody] "当前用户没有该社团的经费审核权限。"); if (permission is not null) return permission; + if (application.ApplicantUserId == currentUserId.Value) + { + return StatusCode(403, Error("budget_self_review_forbidden", "申请人不能审批自己的经费申请。")); + } + if (!string.Equals(application.ApplicationStatus, ApplicationStatusPending, StringComparison.OrdinalIgnoreCase)) { return BadRequest(Error("budget_application_status_invalid", "只有待审核的经费申请才能审核。")); @@ -538,6 +545,131 @@ public async Task CancelApplication( "经费申请正在被其他操作修改,请刷新后重试。"); } + [HttpGet("applications/{applicationId:int}/reviews")] + public async Task GetReviewRecords(int applicationId) + { + var currentUserId = User.GetUserId(); + if (currentUserId is null) return Unauthorized(Error("auth_required", "登录状态已失效,请重新登录。")); + + var application = await ApplicationQuery() + .AsNoTracking() + .FirstOrDefaultAsync(item => item.ApplicationId == applicationId); + if (application is null) return NotFound(Error("budget_application_not_found", "经费申请不存在。")); + + var permission = await RequireClubPermissionAsync( + currentUserId.Value, + BudgetViewPermission, + application.ClubId, + "当前用户没有该社团的经费查看权限。"); + if (permission is not null) return permission; + + var records = await _db.BudgetReviewRecords + .AsNoTracking() + .Include(record => record.ReviewerUser) + .Where(record => record.ApplicationId == applicationId) + .OrderByDescending(record => record.ReviewedAt) + .ToListAsync(); + + return Ok(records.Select(ToDto)); + } + + [HttpPost("applications/{applicationId:int}/resubmit")] + public async Task ResubmitApplication( + int applicationId, + [FromBody] ApiResubmitBudgetApplicationRequest req) + { + var currentUserId = User.GetUserId(); + if (currentUserId is null) return Unauthorized(Error("auth_required", "登录状态已失效,请重新登录。")); + + return await ExecuteSerializableWriteAsync( + async () => + { + var application = await LockBudgetApplicationAsync(applicationId); + if (application is null) return NotFound(Error("budget_application_not_found", "经费申请不存在。")); + + if (!string.Equals(application.ApplicationStatus, ApplicationStatusRejected, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(Error("budget_application_status_invalid", "只有已驳回的经费申请才能重新提交。")); + } + + if (application.ApplicantUserId != currentUserId.Value) + { + var permission = await RequireClubPermissionAsync( + currentUserId.Value, + BudgetApplyPermission, + application.ClubId, + "当前用户没有重新提交该社团经费申请的权限。"); + if (permission is not null) return permission; + } + + var account = await AccountQuery() + .AsNoTracking() + .FirstOrDefaultAsync(item => item.AccountId == application.AccountId); + if (account is null) return NotFound(Error("budget_account_not_found", "经费账户不存在。")); + + if (!string.Equals(account.AccountStatus, AccountStatusActive, StringComparison.OrdinalIgnoreCase)) + { + return Conflict(Error("budget_account_closed", "经费账户已关闭,不能重新提交经费申请。")); + } + + var title = NullIfBlank(req.Title) ?? application.Title; + var purpose = NullIfBlank(req.Purpose) ?? application.Purpose; + var detail = req.Detail is not null ? NullIfBlank(req.Detail) : application.Detail; + var amount = req.Amount ?? application.Amount; + var applicationType = req.Type is not null ? EnumMemberValue(EnumFromMember(req.Type)) : application.ApplicationType; + + if (!AllowedApplicationTypes.Contains(applicationType)) + { + return BadRequest(Error("budget_application_type_invalid", "经费申请类型不合法。")); + } + + var validation = ValidateApplicationInput(title, amount, purpose, detail); + if (validation is not null) return validation; + + Activity? activity = null; + var activityId = req.ActivityId ?? application.ActivityId; + if (activityId is not null) + { + activity = await _db.Activities + .AsNoTracking() + .FirstOrDefaultAsync(item => item.ActivityId == activityId.Value); + if (activity is null) return NotFound(Error("activity_not_found", "关联活动不存在。")); + if (activity.ClubId != application.ClubId) + { + return BadRequest(Error("budget_activity_club_mismatch", "关联活动必须属于经费账户对应社团。")); + } + } + + var remainingAmount = await GetRemainingAmountAsync(application.AccountId, account.InitialAmount); + if (amount > remainingAmount) + { + return Conflict(Error("budget_insufficient_balance", "经费账户余额不足,不能提交该金额的申请。")); + } + + var now = DateTime.UtcNow; + application.ActivityId = activityId; + application.ApplicationType = applicationType; + application.Title = title; + application.Amount = amount; + application.Purpose = purpose; + application.Detail = detail; + application.ApplicationStatus = ApplicationStatusPending; + application.ReviewerUserId = null; + application.ReviewComment = null; + application.ReviewedAt = null; + application.UpdatedAt = now; + + await _db.SaveChangesAsync(); + + var resubmitted = await ApplicationQuery() + .AsNoTracking() + .FirstAsync(item => item.ApplicationId == application.ApplicationId); + return Ok(ToDto(resubmitted)); + }, + "budget_resubmit_write_conflict", + "经费申请正在被其他操作修改,请稍后重试。"); + } + [HttpGet("transactions")] public async Task GetTransactions([FromQuery] int? clubId, [FromQuery] int? accountId) { @@ -796,6 +928,17 @@ private async Task> ToAccountDtosAsync(IReadOnlyCollectio ReviewComment = application.ReviewComment }; + private static ApiBudgetReviewRecord ToDto(BudgetReviewRecord record) => new() + { + Id = record.ReviewId, + ApplicationId = record.ApplicationId, + ReviewerUserId = record.ReviewerUserId, + ReviewerName = DisplayName(record.ReviewerUser), + Approved = record.Approved == 1, + Comment = record.CommentText, + ReviewedAt = StoredTimeToUtc(record.ReviewedAt) + }; + private static ApiBudgetTransaction ToDto(BudgetTransaction transaction) => new() { Id = transaction.TransactionId, diff --git a/backend/Models/ResubmitBudgetApplicationRequest.cs b/backend/Models/ResubmitBudgetApplicationRequest.cs new file mode 100644 index 00000000..8501ce6f --- /dev/null +++ b/backend/Models/ResubmitBudgetApplicationRequest.cs @@ -0,0 +1,83 @@ +/* + * 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 ResubmitBudgetApplicationRequest + { + /// + /// 关联活动,可不填。 + /// + /// 关联活动,可不填。 + /* 12 */ + [DataMember(Name="activityId", EmitDefaultValue=true)] + public int? ActivityId { get; set; } + + /// + /// 申请类型。 + /// + /// 申请类型。 + /* activity_budget */ + [DataMember(Name="type", EmitDefaultValue=false)] + public string? Type { get; set; } + + /// + /// 经费申请标题。 + /// + /// 经费申请标题。 + /* 迎新活动物料采购(修订) */ + [MinLength(1)] + [MaxLength(255)] + [DataMember(Name="title", EmitDefaultValue=false)] + public string? Title { get; set; } + + /// + /// 申请金额,必须大于 0。 + /// + /// 申请金额,必须大于 0。 + /* 1200 */ + [Range(0.01, double.MaxValue)] + [DataMember(Name="amount", EmitDefaultValue=false)] + public decimal? Amount { get; set; } + + /// + /// 经费用途。 + /// + /// 经费用途。 + /* 活动布置、设备耗材、报名物料 */ + [MinLength(1)] + [MaxLength(255)] + [DataMember(Name="purpose", EmitDefaultValue=false)] + public string? Purpose { get; set; } + + /// + /// 经费明细说明。 + /// + /// 经费明细说明。 + /* 横幅 2 条,单价 120 元;矿泉水 10 箱,单价 45 元。 */ + [MaxLength(4000)] + [DataMember(Name="detail", EmitDefaultValue=true)] + public string? Detail { get; set; } + + } +} diff --git a/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts new file mode 100644 index 00000000..c52d5d9c --- /dev/null +++ b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts @@ -0,0 +1,113 @@ +// @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 } from "../runtime"; +/** + * 重新提交被驳回经费申请的请求,所有字段均为可选,仅更新传入的字段。 + * @export + * @interface ResubmitBudgetApplicationRequest + */ +export interface ResubmitBudgetApplicationRequest { + /** + * 关联活动,可不填。 + * @type {number} + * @memberof ResubmitBudgetApplicationRequest + */ + activityId?: number | null; + /** + * 申请类型。 + * @type {string} + * @memberof ResubmitBudgetApplicationRequest + */ + type?: string; + /** + * 经费申请标题。 + * @type {string} + * @memberof ResubmitBudgetApplicationRequest + */ + title?: string; + /** + * 申请金额,必须大于 0。 + * @type {number} + * @memberof ResubmitBudgetApplicationRequest + */ + amount?: number; + /** + * 经费用途。 + * @type {string} + * @memberof ResubmitBudgetApplicationRequest + */ + purpose?: string; + /** + * 经费明细说明。 + * @type {string} + * @memberof ResubmitBudgetApplicationRequest + */ + detail?: string | null; +} + +/** + * Check if a given object implements the ResubmitBudgetApplicationRequest interface. + */ +export function instanceOfResubmitBudgetApplicationRequest( + value: object, +): value is ResubmitBudgetApplicationRequest { + return true; +} + +export function ResubmitBudgetApplicationRequestFromJSON(json: any): ResubmitBudgetApplicationRequest { + return ResubmitBudgetApplicationRequestFromJSONTyped(json, false); +} + +export function ResubmitBudgetApplicationRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): ResubmitBudgetApplicationRequest { + if (json == null) { + return json; + } + return { + activityId: + json["activityId"] === undefined ? undefined : json["activityId"] === null ? null : json["activityId"], + type: json["type"] === undefined ? undefined : json["type"], + title: json["title"] === undefined ? undefined : json["title"], + amount: json["amount"] === undefined ? undefined : json["amount"], + purpose: json["purpose"] === undefined ? undefined : json["purpose"], + detail: + json["detail"] === undefined ? undefined : json["detail"] === null ? null : json["detail"], + }; +} + +export function ResubmitBudgetApplicationRequestToJSON(json: any): ResubmitBudgetApplicationRequest { + return ResubmitBudgetApplicationRequestToJSONTyped(json, false); +} + +export function ResubmitBudgetApplicationRequestToJSONTyped( + value?: ResubmitBudgetApplicationRequest | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + activityId: value["activityId"], + type: value["type"], + title: value["title"], + amount: value["amount"], + purpose: value["purpose"], + detail: value["detail"], + }; +} diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue index 7368abad..aa9d548b 100644 --- a/frontend/src/views/BudgetManagement.vue +++ b/frontend/src/views/BudgetManagement.vue @@ -9,7 +9,7 @@ import { BUDGET_APPLY_PERMISSION, BUDGET_REVIEW_PERMISSION, } from "../budgetPermissions"; -import type { BudgetAccount, BudgetApplication, BudgetTransaction } from "../api/models"; +import type { BudgetAccount, BudgetApplication, BudgetReviewRecord, BudgetTransaction } from "../api/models"; import { apiClient, createIdempotencyKey } from "../apiClient"; import { requestJson } from "../composables/useApiRequest"; import { formatVenueReservationDateTime } from "../beijingTime"; @@ -50,6 +50,15 @@ interface ReviewForm { comment: string; } +interface ResubmitForm { + activityId?: number | null; + type: "activity_budget" | "purchase" | "reimbursement"; + title: string; + amount: number; + purpose: string; + detail: string; +} + const auth = ref(readAuth()); const clubs = ref([]); const accounts = ref([]); @@ -67,6 +76,12 @@ const reviewTarget = ref(null); const accountFormRef = ref(); const applicationFormRef = ref(); const reviewFormRef = ref(); +const resubmitDialogVisible = ref(false); +const resubmitTarget = ref(null); +const resubmitFormRef = ref(); +const reviewHistoryVisible = ref(false); +const reviewRecords = ref([]); +const loadingReviews = ref(false); const accountForm = reactive({ clubId: undefined, @@ -91,6 +106,15 @@ const reviewForm = reactive({ comment: "", }); +const resubmitForm = reactive({ + activityId: undefined, + type: "activity_budget", + title: "", + amount: 0, + purpose: "", + detail: "", +}); + const hasGlobalAccess = computed(() => { const permissions = auth.value?.permissions ?? []; return permissions.includes("*"); @@ -232,6 +256,22 @@ const reviewRules: FormRules = { comment: [{ max: 255, message: "审核意见不能超过 255 个字符", trigger: "blur" }], }; +const resubmitRules: FormRules = { + type: [{ required: true, message: "请选择申请类型", trigger: "change" }], + title: [ + { required: true, message: "请输入申请标题", trigger: "blur" }, + { max: 255, message: "申请标题不能超过 255 个字符", trigger: "blur" }, + ], + amount: [ + { required: true, message: "请输入申请金额", trigger: "change" }, + { type: "number", min: 0.01, message: "申请金额必须大于 0", trigger: "change" }, + ], + purpose: [ + { required: true, message: "请输入经费用途", trigger: "blur" }, + { max: 255, message: "经费用途不能超过 255 个字符", trigger: "blur" }, + ], +}; + function ensureActiveClub() { if (activeClubId.value !== undefined) return; activeClubId.value = canViewAllBudgets.value ? 0 : visibleClubs.value[0]?.id; @@ -536,6 +576,77 @@ async function cancelApplication(application: BudgetApplication) { } } +function openResubmitApplication(application: BudgetApplication) { + const currentUserId = auth.value?.user.id; + if (application.applicantUserId !== currentUserId && !canApplyForClub(application.clubId)) { + ElMessage.warning("当前账号没有重新提交该经费申请的权限。"); + return; + } + + resubmitTarget.value = application; + resubmitForm.activityId = application.activityId; + resubmitForm.type = application.type; + resubmitForm.title = application.title; + resubmitForm.amount = application.amount; + resubmitForm.purpose = application.purpose; + resubmitForm.detail = application.detail ?? ""; + resubmitDialogVisible.value = true; +} + +async function submitResubmit() { + if (!resubmitTarget.value || !(await resubmitFormRef.value?.validate())) return; + + try { + await requestJson( + `/api/v1/budget/applications/${resubmitTarget.value.id}/resubmit`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + activityId: resubmitForm.activityId ?? null, + type: resubmitForm.type, + title: resubmitForm.title, + amount: resubmitForm.amount, + purpose: resubmitForm.purpose, + detail: resubmitForm.detail.trim() || null, + }), + }, + ); + + resubmitDialogVisible.value = false; + ElMessage.success("经费申请已重新提交,等待审核"); + await refreshData(); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : "重新提交经费申请失败"); + } +} + +async function openReviewHistory(application: BudgetApplication) { + reviewHistoryVisible.value = true; + loadingReviews.value = true; + try { + reviewRecords.value = await requestJson( + `/api/v1/budget/applications/${application.id}/reviews`, + ); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : "加载审核历史失败"); + reviewRecords.value = []; + } finally { + loadingReviews.value = false; + } +} + +function reviewActionLabel(approved: boolean) { + return approved ? "审核通过" : "审核驳回"; +} + +function activityOptionsForSelectedResubmitAccount() { + if (!resubmitTarget.value) return []; + return activities.value.filter( + (activity) => activity.clubId === resubmitTarget.value?.clubId, + ); +} + onMounted(() => { refreshAllData(); }); @@ -726,6 +837,23 @@ onMounted(() => { > 撤销 + + 重新提交 + + + 审核历史 + @@ -902,6 +1030,92 @@ onMounted(() => { 保存审核 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

+ {{ record.reviewerName }} + + {{ reviewActionLabel(record.approved) }} + +

+

+ {{ record.comment }} +

+
+
+ +
+ +
From 2ce7163b6d3b84263c9ae4b3ea26ebbcc1268d12 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:39:26 +0000 Subject: [PATCH 02/10] =?UTF-8?q?chore(api):=20=E4=BB=8E=20openapi.yaml=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=20API=20=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResubmitBudgetApplicationRequest.cs | 51 ++++-- frontend/src/api/apis/DefaultApi.ts | 167 ++++++++++++++++++ .../ResubmitBudgetApplicationRequest.ts | 37 +++- frontend/src/api/models/index.ts | 1 + 4 files changed, 234 insertions(+), 22 deletions(-) diff --git a/backend/Models/ResubmitBudgetApplicationRequest.cs b/backend/Models/ResubmitBudgetApplicationRequest.cs index 8501ce6f..002b36ed 100644 --- a/backend/Models/ResubmitBudgetApplicationRequest.cs +++ b/backend/Models/ResubmitBudgetApplicationRequest.cs @@ -1,10 +1,10 @@ /* * ClubHub API * - * ClubHub 高校社团运营与协同管理平台 API。 **API-first 开发流程**:修改本文件 → push → CI 自动生成前后端代码 → git pull 拉取生成代码 → 在 Controller / Services / Vue 组件中手写业务逻辑。 + * 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 */ @@ -18,12 +18,12 @@ using System.Text.Json; namespace Org.OpenAPITools.Models -{ +{ /// /// 重新提交被驳回经费申请的请求,所有字段均为可选,仅更新传入的字段。 /// [DataContract] - public partial class ResubmitBudgetApplicationRequest + public partial class ResubmitBudgetApplicationRequest { /// /// 关联活动,可不填。 @@ -33,21 +33,48 @@ public partial class ResubmitBudgetApplicationRequest [DataMember(Name="activityId", EmitDefaultValue=true)] public int? ActivityId { get; set; } + + /// + /// 申请类型。 + /// + /// 申请类型。 + + public enum TypeEnum + { + + /// + /// Enum ActivityBudgetEnum for activity_budget + /// + [EnumMember(Value = "activity_budget")] + ActivityBudgetEnum = 1, + + /// + /// Enum PurchaseEnum for purchase + /// + [EnumMember(Value = "purchase")] + PurchaseEnum = 2, + + /// + /// Enum ReimbursementEnum for reimbursement + /// + [EnumMember(Value = "reimbursement")] + ReimbursementEnum = 3 + } + /// /// 申请类型。 /// /// 申请类型。 /* activity_budget */ - [DataMember(Name="type", EmitDefaultValue=false)] - public string? Type { get; set; } + [DataMember(Name="type", EmitDefaultValue=true)] + public TypeEnum Type { get; set; } /// /// 经费申请标题。 /// /// 经费申请标题。 /* 迎新活动物料采购(修订) */ - [MinLength(1)] - [MaxLength(255)] + [StringLength(255, MinimumLength=1)] [DataMember(Name="title", EmitDefaultValue=false)] public string? Title { get; set; } @@ -56,17 +83,15 @@ public partial class ResubmitBudgetApplicationRequest /// /// 申请金额,必须大于 0。 /* 1200 */ - [Range(0.01, double.MaxValue)] - [DataMember(Name="amount", EmitDefaultValue=false)] - public decimal? Amount { get; set; } + [DataMember(Name="amount", EmitDefaultValue=true)] + public double? Amount { get; set; } /// /// 经费用途。 /// /// 经费用途。 /* 活动布置、设备耗材、报名物料 */ - [MinLength(1)] - [MaxLength(255)] + [StringLength(255, MinimumLength=1)] [DataMember(Name="purpose", EmitDefaultValue=false)] public string? Purpose { get; set; } diff --git a/frontend/src/api/apis/DefaultApi.ts b/frontend/src/api/apis/DefaultApi.ts index 019f0b56..bd0d8090 100644 --- a/frontend/src/api/apis/DefaultApi.ts +++ b/frontend/src/api/apis/DefaultApi.ts @@ -91,6 +91,11 @@ import { BudgetApplicationFromJSON, BudgetApplicationToJSON, } from "../models/BudgetApplication"; +import { + type BudgetReviewRecord, + BudgetReviewRecordFromJSON, + BudgetReviewRecordToJSON, +} from "../models/BudgetReviewRecord"; import { type BudgetTransaction, BudgetTransactionFromJSON, @@ -368,6 +373,11 @@ import { RegisterRequestFromJSON, RegisterRequestToJSON, } from "../models/RegisterRequest"; +import { + type ResubmitBudgetApplicationRequest, + ResubmitBudgetApplicationRequestFromJSON, + ResubmitBudgetApplicationRequestToJSON, +} from "../models/ResubmitBudgetApplicationRequest"; import { type ReviewActivityBudgetRequest, ReviewActivityBudgetRequestFromJSON, @@ -1098,6 +1108,10 @@ export interface GetBudgetAccountsRequest { fiscalYear?: string; } +export interface GetBudgetApplicationReviewsRequest { + applicationId: number; +} + export interface GetBudgetApplicationsRequest { /** * 显式启用分页时的页码,从 1 开始;省略 page 和 pageSize 时返回完整集合以兼容 lookup 调用。 @@ -1881,6 +1895,11 @@ export interface RemoveProjectMemberRequest { projectMemberId: number; } +export interface ResubmitBudgetApplicationOperationRequest { + applicationId: number; + resubmitBudgetApplicationRequest: ResubmitBudgetApplicationRequest; +} + export interface ResubmitClubApplicationRequest { /** * 标记一次业务提交;带有 x-idempotency-required: true 的操作必须提供,并在同一次提交及网络重试中复用。 @@ -6197,6 +6216,74 @@ export class DefaultApi extends runtime.BaseAPI { return await response.value(); } + /** + * Creates request options for getBudgetApplicationReviews without sending the request + */ + async getBudgetApplicationReviewsRequestOpts( + requestParameters: GetBudgetApplicationReviewsRequest, + ): Promise { + if (requestParameters["applicationId"] == null) { + throw new runtime.RequiredError( + "applicationId", + 'Required parameter "applicationId" was null or undefined when calling getBudgetApplicationReviews().', + ); + } + + 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}`; + } + } + + let urlPath = `/api/budget/applications/{applicationId}/reviews`; + urlPath = urlPath.replace( + "{applicationId}", + encodeURIComponent(String(requestParameters["applicationId"])), + ); + + return { + path: urlPath, + method: "GET", + headers: headerParameters, + query: queryParameters, + }; + } + + /** + * 返回指定经费申请的全部审核历史记录,按时间倒序排列。 + * 获取经费申请审核记录 + */ + async getBudgetApplicationReviewsRaw( + requestParameters: GetBudgetApplicationReviewsRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise>> { + const requestOptions = await this.getBudgetApplicationReviewsRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => + jsonValue.map(BudgetReviewRecordFromJSON), + ); + } + + /** + * 返回指定经费申请的全部审核历史记录,按时间倒序排列。 + * 获取经费申请审核记录 + */ + async getBudgetApplicationReviews( + requestParameters: GetBudgetApplicationReviewsRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const response = await this.getBudgetApplicationReviewsRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Creates request options for getBudgetApplications without sending the request */ @@ -9979,6 +10066,86 @@ export class DefaultApi extends runtime.BaseAPI { await this.removeProjectMemberRaw(requestParameters, initOverrides); } + /** + * Creates request options for resubmitBudgetApplication without sending the request + */ + async resubmitBudgetApplicationRequestOpts( + requestParameters: ResubmitBudgetApplicationOperationRequest, + ): Promise { + if (requestParameters["applicationId"] == null) { + throw new runtime.RequiredError( + "applicationId", + 'Required parameter "applicationId" was null or undefined when calling resubmitBudgetApplication().', + ); + } + + if (requestParameters["resubmitBudgetApplicationRequest"] == null) { + throw new runtime.RequiredError( + "resubmitBudgetApplicationRequest", + 'Required parameter "resubmitBudgetApplicationRequest" was null or undefined when calling resubmitBudgetApplication().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters["Content-Type"] = "application/json"; + + 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/budget/applications/{applicationId}/resubmit`; + urlPath = urlPath.replace( + "{applicationId}", + encodeURIComponent(String(requestParameters["applicationId"])), + ); + + return { + path: urlPath, + method: "POST", + headers: headerParameters, + query: queryParameters, + body: ResubmitBudgetApplicationRequestToJSON( + requestParameters["resubmitBudgetApplicationRequest"], + ), + }; + } + + /** + * 申请人或具备经费申请权限的负责人可将被驳回的申请修改后重新提交,状态回到待审核,历史审核记录保留。 + * 重新提交被驳回的经费申请 + */ + async resubmitBudgetApplicationRaw( + requestParameters: ResubmitBudgetApplicationOperationRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.resubmitBudgetApplicationRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => + BudgetApplicationFromJSON(jsonValue), + ); + } + + /** + * 申请人或具备经费申请权限的负责人可将被驳回的申请修改后重新提交,状态回到待审核,历史审核记录保留。 + * 重新提交被驳回的经费申请 + */ + async resubmitBudgetApplication( + requestParameters: ResubmitBudgetApplicationOperationRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + const response = await this.resubmitBudgetApplicationRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Creates request options for resubmitClubApplication without sending the request */ diff --git a/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts index c52d5d9c..ba99cd81 100644 --- a/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts +++ b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts @@ -28,10 +28,10 @@ export interface ResubmitBudgetApplicationRequest { activityId?: number | null; /** * 申请类型。 - * @type {string} + * @type {ResubmitBudgetApplicationRequestTypeEnum} * @memberof ResubmitBudgetApplicationRequest */ - type?: string; + type?: ResubmitBudgetApplicationRequestTypeEnum; /** * 经费申请标题。 * @type {string} @@ -58,6 +58,17 @@ export interface ResubmitBudgetApplicationRequest { detail?: string | null; } +/** + * @export + */ +export const ResubmitBudgetApplicationRequestTypeEnum = { + ActivityBudget: "activity_budget", + Purchase: "purchase", + Reimbursement: "reimbursement", +} as const; +export type ResubmitBudgetApplicationRequestTypeEnum = + (typeof ResubmitBudgetApplicationRequestTypeEnum)[keyof typeof ResubmitBudgetApplicationRequestTypeEnum]; + /** * Check if a given object implements the ResubmitBudgetApplicationRequest interface. */ @@ -67,7 +78,9 @@ export function instanceOfResubmitBudgetApplicationRequest( return true; } -export function ResubmitBudgetApplicationRequestFromJSON(json: any): ResubmitBudgetApplicationRequest { +export function ResubmitBudgetApplicationRequestFromJSON( + json: any, +): ResubmitBudgetApplicationRequest { return ResubmitBudgetApplicationRequestFromJSONTyped(json, false); } @@ -80,17 +93,23 @@ export function ResubmitBudgetApplicationRequestFromJSONTyped( } return { activityId: - json["activityId"] === undefined ? undefined : json["activityId"] === null ? null : json["activityId"], - type: json["type"] === undefined ? undefined : json["type"], - title: json["title"] === undefined ? undefined : json["title"], - amount: json["amount"] === undefined ? undefined : json["amount"], - purpose: json["purpose"] === undefined ? undefined : json["purpose"], + json["activityId"] === undefined + ? undefined + : json["activityId"] === null + ? null + : json["activityId"], + type: json["type"] == null ? undefined : json["type"], + title: json["title"] == null ? undefined : json["title"], + amount: json["amount"] == null ? undefined : json["amount"], + purpose: json["purpose"] == null ? undefined : json["purpose"], detail: json["detail"] === undefined ? undefined : json["detail"] === null ? null : json["detail"], }; } -export function ResubmitBudgetApplicationRequestToJSON(json: any): ResubmitBudgetApplicationRequest { +export function ResubmitBudgetApplicationRequestToJSON( + json: any, +): ResubmitBudgetApplicationRequest { return ResubmitBudgetApplicationRequestToJSONTyped(json, false); } diff --git a/frontend/src/api/models/index.ts b/frontend/src/api/models/index.ts index 8875f23a..5afb7d3a 100644 --- a/frontend/src/api/models/index.ts +++ b/frontend/src/api/models/index.ts @@ -93,6 +93,7 @@ export * from "./RecruitmentApplication"; export * from "./RecruitmentWorkflowStatus"; export * from "./RegisterMaterialDamageRequest"; export * from "./RegisterRequest"; +export * from "./ResubmitBudgetApplicationRequest"; export * from "./ReviewActivityBudgetRequest"; export * from "./ReviewActivityRequest"; export * from "./ReviewAwardApplicationRequest"; From 89307ce1dbff70b5ba4224caaec04e92f8718aea Mon Sep 17 00:00:00 2001 From: wendaining Date: Sun, 26 Jul 2026 22:00:13 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(budget):=20=E4=BF=AE=E5=A4=8D=20CI=20?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF=E5=92=8C=20Prettier=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - req.Amount (double?) 与 decimal 类型不匹配,改用显式转换 - req.Type 为 string? 而非 enum,改用 NullIfBlank + NormalizeApplicationType - BudgetManagement.vue 通过 Prettier 格式化 --- backend/Controllers/BudgetController.cs | 5 +++-- frontend/src/views/BudgetManagement.vue | 29 +++++++++++-------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/backend/Controllers/BudgetController.cs b/backend/Controllers/BudgetController.cs index ce714b9c..acabf82d 100644 --- a/backend/Controllers/BudgetController.cs +++ b/backend/Controllers/BudgetController.cs @@ -615,8 +615,9 @@ public async Task ResubmitApplication( var title = NullIfBlank(req.Title) ?? application.Title; var purpose = NullIfBlank(req.Purpose) ?? application.Purpose; var detail = req.Detail is not null ? NullIfBlank(req.Detail) : application.Detail; - var amount = req.Amount ?? application.Amount; - var applicationType = req.Type is not null ? EnumMemberValue(EnumFromMember(req.Type)) : application.ApplicationType; + var amount = req.Amount.HasValue ? Convert.ToDecimal(req.Amount.Value) : application.Amount; + var newType = NullIfBlank(req.Type); + var applicationType = newType is not null ? NormalizeApplicationType(newType) : application.ApplicationType; if (!AllowedApplicationTypes.Contains(applicationType)) { diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue index aa9d548b..b3806fb5 100644 --- a/frontend/src/views/BudgetManagement.vue +++ b/frontend/src/views/BudgetManagement.vue @@ -9,7 +9,12 @@ import { BUDGET_APPLY_PERMISSION, BUDGET_REVIEW_PERMISSION, } from "../budgetPermissions"; -import type { BudgetAccount, BudgetApplication, BudgetReviewRecord, BudgetTransaction } from "../api/models"; +import type { + BudgetAccount, + BudgetApplication, + BudgetReviewRecord, + BudgetTransaction, +} from "../api/models"; import { apiClient, createIdempotencyKey } from "../apiClient"; import { requestJson } from "../composables/useApiRequest"; import { formatVenueReservationDateTime } from "../beijingTime"; @@ -642,9 +647,7 @@ function reviewActionLabel(approved: boolean) { function activityOptionsForSelectedResubmitAccount() { if (!resubmitTarget.value) return []; - return activities.value.filter( - (activity) => activity.clubId === resubmitTarget.value?.clubId, - ); + return activities.value.filter((activity) => activity.clubId === resubmitTarget.value?.clubId); } onMounted(() => { @@ -838,7 +841,10 @@ onMounted(() => { 撤销 { > 重新提交 - + 审核历史 @@ -1046,11 +1047,7 @@ onMounted(() => { - + Date: Sun, 26 Jul 2026 22:01:05 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(budget):=20CI=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20Type=20=E4=B8=BA=20non-nullable=20enum?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E7=94=A8=20Enum.IsDefined=20=E5=88=A4?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI openapi-generator 将 ResubmitBudgetApplicationRequest.type 生成为 non-nullable TypeEnum 而非 string?,现通过 Enum.IsDefined 检测客户端 是否传入了 type 字段。 --- backend/Controllers/BudgetController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Controllers/BudgetController.cs b/backend/Controllers/BudgetController.cs index acabf82d..c250c990 100644 --- a/backend/Controllers/BudgetController.cs +++ b/backend/Controllers/BudgetController.cs @@ -616,8 +616,8 @@ public async Task ResubmitApplication( var purpose = NullIfBlank(req.Purpose) ?? application.Purpose; var detail = req.Detail is not null ? NullIfBlank(req.Detail) : application.Detail; var amount = req.Amount.HasValue ? Convert.ToDecimal(req.Amount.Value) : application.Amount; - var newType = NullIfBlank(req.Type); - var applicationType = newType is not null ? NormalizeApplicationType(newType) : application.ApplicationType; + var newType = Enum.IsDefined(req.Type) ? EnumMemberValue(req.Type) : null; + var applicationType = newType ?? application.ApplicationType; if (!AllowedApplicationTypes.Contains(applicationType)) { From 8b75dccfa535013f389cb215bb8fa7ac0cc33c0d Mon Sep 17 00:00:00 2001 From: wendaining Date: Mon, 31 Aug 2026 16:44:52 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(budget):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=BB=8F=E8=B4=B9=E5=AE=A1=E6=89=B9=E6=B5=81=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐审核历史权限、重提幂等与可空类型处理,并使用隔离 SQLite 关系型测试覆盖自审和驳回重提状态流转。 --- api/openapi.yaml | 13 +- backend.Tests/BudgetApprovalFlowTests.cs | 343 ++++++++++++++++-- backend.Tests/ClubHub.Api.Tests.csproj | 2 + .../RedisAuthIdempotencyContractTests.cs | 1 + backend/Controllers/BudgetController.cs | 32 +- backend/Models/BudgetApplicationType.cs | 48 +++ .../ResubmitBudgetApplicationRequest.cs | 30 +- frontend/src/api/apis/DefaultApi.ts | 28 +- .../src/api/models/BudgetApplicationType.ts | 59 +++ .../ResubmitBudgetApplicationRequest.ts | 42 +-- frontend/src/api/models/index.ts | 1 + frontend/src/views/BudgetManagement.vue | 31 +- 12 files changed, 512 insertions(+), 118 deletions(-) create mode 100644 backend/Models/BudgetApplicationType.cs create mode 100644 frontend/src/api/models/BudgetApplicationType.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index 045af6f2..8d3602a3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3448,9 +3448,11 @@ paths: summary: 重新提交被驳回的经费申请 description: 申请人或具备经费申请权限的负责人可将被驳回的申请修改后重新提交,状态回到待审核,历史审核记录保留。 operationId: resubmitBudgetApplication + x-idempotency-required: true security: - bearerAuth: [] parameters: + - $ref: "#/components/parameters/IdempotencyKey" - name: applicationId in: path required: true @@ -10129,7 +10131,6 @@ components: example: 王明 type: type: string - nullable: true enum: [activity_budget, purchase, reimbursement] description: 申请类型;activity_budget 活动预算,purchase 采购申请,reimbursement 报销申请。 example: activity_budget @@ -10273,6 +10274,11 @@ components: description: 撤销说明。 example: 活动方案调整,暂不申请经费。 + BudgetApplicationType: + type: string + enum: [activity_budget, purchase, reimbursement] + description: 经费申请类型。 + ResubmitBudgetApplicationRequest: type: object description: 重新提交被驳回经费申请的请求,所有字段均为可选,仅更新传入的字段。 @@ -10283,8 +10289,9 @@ components: description: 关联活动,可不填。 example: 12 type: - type: string - enum: [activity_budget, purchase, reimbursement] + nullable: true + allOf: + - $ref: "#/components/schemas/BudgetApplicationType" description: 申请类型。 example: activity_budget title: diff --git a/backend.Tests/BudgetApprovalFlowTests.cs b/backend.Tests/BudgetApprovalFlowTests.cs index 39ab701a..096b4373 100644 --- a/backend.Tests/BudgetApprovalFlowTests.cs +++ b/backend.Tests/BudgetApprovalFlowTests.cs @@ -1,23 +1,42 @@ using System.Net; using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Text; +using System.Text.Json; +using ClubHub.Api.Data; +using ClubHub.Api.Data.Entities; +using ClubHub.Api.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace ClubHub.Api.Tests; public sealed class BudgetApprovalFlowTests : IClassFixture { + private const int ApplicantUserId = 101; + private const int ReviewerUserId = 102; + private const int ClubId = 201; + private const int AccountId = 301; + private const int ApplicationId = 401; private readonly ClubHubWebApplicationFactory _factory; public BudgetApprovalFlowTests(ClubHubWebApplicationFactory factory) => _factory = factory; - // ─── Authorization: new endpoints require authentication ─── - [Fact] public async Task GetReviewRecords_WithoutToken_ReturnsUnauthorized() { using var client = _factory.CreateClient(); - var response = await client.GetAsync("/api/budget/applications/1/reviews"); + using var response = await client.GetAsync( + $"/api/v1/budget/applications/{ApplicationId}/reviews"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -29,47 +48,303 @@ public async Task ResubmitEndpoint_WithoutToken_ReturnsUnauthorized() """{"title":"test"}""", Encoding.UTF8, "application/json"); - var response = await client.PostAsync("/api/budget/applications/1/resubmit", content); + using var response = await client.PostAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + content); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } - // ─── GET /reviews: application not found → 404 (checked before permission) ─── + [Fact] + public async Task GetReviewRecords_ApplicationOwnerWithoutBudgetView_ReturnsHistory() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false, + includeReviewRecord: true); + + using var response = await client.GetAsync( + $"/api/v1/budget/applications/{ApplicationId}/reviews"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var record = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Equal(ReviewerUserId, record.GetProperty("reviewerUserId").GetInt32()); + Assert.False(record.GetProperty("approved").GetBoolean()); + Assert.Equal("请补充预算明细", record.GetProperty("comment").GetString()); + } + + [Fact] + public async Task ReviewApplication_ByApplicantWithReviewPermission_ReturnsForbidden() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "pending", + grantApplicantReviewPermission: true); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/review", + new { approved = true, comment = "同意" }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("FORBIDDEN", document.RootElement.GetProperty("code").GetString()); + Assert.Equal( + "申请人不能审批自己的经费申请。", + document.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task ResubmitApplication_WhenRejected_UpdatesFieldsAndReturnsPending() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false, + includeReviewRecord: true); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + new + { + type = "purchase", + title = "修订后的采购申请", + amount = 800, + purpose = "采购活动物料", + detail = "横幅与打印材料" + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("pending", document.RootElement.GetProperty("status").GetString()); + Assert.Equal("purchase", document.RootElement.GetProperty("type").GetString()); + Assert.Equal("修订后的采购申请", document.RootElement.GetProperty("title").GetString()); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var application = await db.BudgetApplications.SingleAsync( + item => item.ApplicationId == ApplicationId); + Assert.Equal("pending", application.ApplicationStatus); + Assert.Null(application.ReviewerUserId); + Assert.Null(application.ReviewComment); + Assert.Null(application.ReviewedAt); + Assert.Equal( + 1, + await db.BudgetReviewRecords.CountAsync( + item => item.ApplicationId == ApplicationId)); + } [Fact] - public async Task GetReviewRecords_ApplicationNotFound_Returns404() + public async Task ResubmitApplication_WhenTypeIsOmitted_PreservesExistingType() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + new { title = "保留原申请类型" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("activity_budget", document.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public async Task ResubmitApplication_WhenNotRejected_ReturnsBadRequest() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "pending", + grantApplicantReviewPermission: false); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + new { title = "不应被接受" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("VALIDATION_ERROR", document.RootElement.GetProperty("code").GetString()); + Assert.Equal( + "只有已驳回的经费申请才能重新提交。", + document.RootElement.GetProperty("message").GetString()); + } + + private static async Task SeedScenarioAsync( + RelationalBudgetWebApplicationFactory factory, + string applicationStatus, + bool grantApplicantReviewPermission, + bool includeReviewRecord = false) { - await using var factory = new ClubHubWebApplicationFactory(); - using var scope = factory.Services.CreateScope(); - var tokenService = scope.ServiceProvider.GetRequiredService(); - var user = new ClubHub.Api.Data.Entities.User + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + var applicant = new User + { + UserId = ApplicantUserId, + Username = "budget-applicant", + PasswordHash = "unused", + RealName = "申请人", + AccountStatus = "normal", + CreatedAt = now + }; + var reviewer = new User { - UserId = 1, - Username = "viewer", - RealName = "查看者" + UserId = ReviewerUserId, + Username = "budget-reviewer", + PasswordHash = "unused", + RealName = "审核人", + AccountStatus = "normal", + CreatedAt = now }; - var token = tokenService.CreateToken(user); - using var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + db.Users.AddRange(applicant, reviewer); + db.Clubs.Add(new Club + { + ClubId = ClubId, + ClubName = "预算测试社团", + ClubStatus = "active", + CreatedAt = now + }); + db.BudgetAccounts.Add(new BudgetAccount + { + AccountId = AccountId, + ClubId = ClubId, + FiscalYear = "2026", + AccountName = "2026 年度经费", + InitialAmount = 10_000m, + AccountStatus = "active", + CreatedAt = now, + UpdatedAt = now + }); + db.BudgetApplications.Add(new BudgetApplication + { + ApplicationId = ApplicationId, + AccountId = AccountId, + ClubId = ClubId, + ApplicantUserId = ApplicantUserId, + ApplicationType = "activity_budget", + Title = "原经费申请", + Amount = 1_000m, + Purpose = "原经费用途", + Detail = "原预算明细", + ApplicationStatus = applicationStatus, + SubmittedAt = now.AddDays(-1), + ReviewerUserId = applicationStatus == "rejected" ? ReviewerUserId : null, + ReviewComment = applicationStatus == "rejected" ? "请补充预算明细" : null, + ReviewedAt = applicationStatus == "rejected" ? now.AddHours(-1) : null, + CreatedAt = now.AddDays(-1), + UpdatedAt = now + }); + + if (grantApplicantReviewPermission) + { + db.Roles.Add(new Role + { + RoleId = 501, + RoleCode = "SYSTEM_ADMIN", + RoleName = "系统管理员", + RoleScope = "system", + CreatedAt = now + }); + db.UserRoles.Add(new UserRole + { + UserRoleId = 601, + UserId = ApplicantUserId, + RoleId = 501, + AssignedAt = now + }); + } + + if (includeReviewRecord) + { + db.BudgetReviewRecords.Add(new BudgetReviewRecord + { + ReviewId = 701, + ApplicationId = ApplicationId, + ReviewerUserId = ReviewerUserId, + Approved = 0, + CommentText = "请补充预算明细", + ReviewedAt = now.AddHours(-1) + }); + } + + await db.SaveChangesAsync(); + var token = scope.ServiceProvider + .GetRequiredService() + .CreateToken(applicant); + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + client.DefaultRequestHeaders.Add("Idempotency-Key", "budget-flow-test-key"); + return client; + } +} + +internal sealed class RelationalBudgetWebApplicationFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + private RelationalBudgetWebApplicationFactory() => _connection.Open(); + + public static async Task CreateAsync() + { + var factory = new RelationalBudgetWebApplicationFactory(); + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + return factory; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["Authentication:TokenSigningKey"] = "ClubHub.Tests.TokenSigningKey", + ["ConnectionStrings:Default"] = "Tests use an isolated SQLite in-memory database" + })); + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); - var response = await client.GetAsync("/api/budget/applications/99999/reviews"); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + services.AddScoped(_ => new SqliteClubHubDbContext(options)); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) _connection.Dispose(); + } + + private sealed class SqliteClubHubDbContext(DbContextOptions options) + : ClubHubDbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + foreach (var property in modelBuilder.Model + .GetEntityTypes() + .SelectMany(entity => entity.GetProperties())) + { + property.SetDefaultValueSql(null); + } + } } - // ─── Self‑review and resubmit: verified by code review ─── - // - // ReviewApplication (self‑review check) and ResubmitApplication reside inside - // ExecuteWriteAsync which calls BeginTransactionAsync. The InMemory EF Core - // provider throws on transactions, so these flows cannot be covered by HTTP - // integration tests with the in‑memory provider. - // - // Verification evidence: - // • backend/Controllers/BudgetController.cs: - // Self‑review: if (application.ApplicantUserId == currentUserId.Value) - // return 403("申请人不能审批自己的经费申请。") - // Resubmit: if (status != "rejected") - // return 400("只有已驳回的经费申请才能重新提交。") - // • Oracle integration tests belong in a separate project per AGENTS.md. - // • Backend compilation (dotnet build) passes with 0 errors. - // • Four existing tests run green (this file + BudgetConcurrencyPolicyTests). } diff --git a/backend.Tests/ClubHub.Api.Tests.csproj b/backend.Tests/ClubHub.Api.Tests.csproj index 78803647..e16363cb 100644 --- a/backend.Tests/ClubHub.Api.Tests.csproj +++ b/backend.Tests/ClubHub.Api.Tests.csproj @@ -11,6 +11,8 @@ + + diff --git a/backend.Tests/RedisAuthIdempotencyContractTests.cs b/backend.Tests/RedisAuthIdempotencyContractTests.cs index 0bbb9183..a5184e54 100644 --- a/backend.Tests/RedisAuthIdempotencyContractTests.cs +++ b/backend.Tests/RedisAuthIdempotencyContractTests.cs @@ -32,6 +32,7 @@ public sealed class RedisAuthIdempotencyContractTests "registerActivity", "reviewActivity", "createBudgetApplication", + "resubmitBudgetApplication", "reviewBudgetApplication", "createVenueReservation", "reviewVenueReservation", diff --git a/backend/Controllers/BudgetController.cs b/backend/Controllers/BudgetController.cs index c250c990..3139a2f9 100644 --- a/backend/Controllers/BudgetController.cs +++ b/backend/Controllers/BudgetController.cs @@ -556,12 +556,15 @@ public async Task GetReviewRecords(int applicationId) .FirstOrDefaultAsync(item => item.ApplicationId == applicationId); if (application is null) return NotFound(Error("budget_application_not_found", "经费申请不存在。")); - var permission = await RequireClubPermissionAsync( - currentUserId.Value, - BudgetViewPermission, - application.ClubId, - "当前用户没有该社团的经费查看权限。"); - if (permission is not null) return permission; + if (application.ApplicantUserId != currentUserId.Value) + { + var permission = await RequireClubPermissionAsync( + currentUserId.Value, + BudgetViewPermission, + application.ClubId, + "当前用户没有该社团的经费查看权限。"); + if (permission is not null) return permission; + } var records = await _db.BudgetReviewRecords .AsNoTracking() @@ -574,6 +577,7 @@ public async Task GetReviewRecords(int applicationId) } [HttpPost("applications/{applicationId:int}/resubmit")] + [ClubHub.Api.Infrastructure.Idempotency.IdempotentOperation("resubmitBudgetApplication")] public async Task ResubmitApplication( int applicationId, [FromBody] ApiResubmitBudgetApplicationRequest req) @@ -616,7 +620,7 @@ public async Task ResubmitApplication( var purpose = NullIfBlank(req.Purpose) ?? application.Purpose; var detail = req.Detail is not null ? NullIfBlank(req.Detail) : application.Detail; var amount = req.Amount.HasValue ? Convert.ToDecimal(req.Amount.Value) : application.Amount; - var newType = Enum.IsDefined(req.Type) ? EnumMemberValue(req.Type) : null; + var newType = req.Type is { } type ? EnumMemberValue(type) : null; var applicationType = newType ?? application.ApplicationType; if (!AllowedApplicationTypes.Contains(applicationType)) @@ -779,6 +783,20 @@ private IQueryable TransactionQuery() => _db.BudgetTransactio private async Task LockRowIdAsync(string sql, params int[] values) { + // Isolated relational tests use SQLite, which does not support FOR UPDATE. + // Oracle keeps the locking clause unchanged in production. + if (string.Equals( + _db.Database.ProviderName, + "Microsoft.EntityFrameworkCore.Sqlite", + StringComparison.Ordinal)) + { + const string forUpdateSuffix = " FOR UPDATE"; + if (sql.EndsWith(forUpdateSuffix, StringComparison.OrdinalIgnoreCase)) + { + sql = sql[..^forUpdateSuffix.Length]; + } + } + var connection = _db.Database.GetDbConnection(); await using var command = connection.CreateCommand(); command.CommandText = sql; diff --git a/backend/Models/BudgetApplicationType.cs b/backend/Models/BudgetApplicationType.cs new file mode 100644 index 00000000..31d88b4e --- /dev/null +++ b/backend/Models/BudgetApplicationType.cs @@ -0,0 +1,48 @@ +/* + * 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 +{ + /// + /// 经费申请类型。 + /// + /// 经费申请类型。 + + public enum BudgetApplicationType + { + + /// + /// Enum ActivityBudgetEnum for activity_budget + /// + [EnumMember(Value = "activity_budget")] + ActivityBudgetEnum = 1, + + /// + /// Enum PurchaseEnum for purchase + /// + [EnumMember(Value = "purchase")] + PurchaseEnum = 2, + + /// + /// Enum ReimbursementEnum for reimbursement + /// + [EnumMember(Value = "reimbursement")] + ReimbursementEnum = 3 + } +} diff --git a/backend/Models/ResubmitBudgetApplicationRequest.cs b/backend/Models/ResubmitBudgetApplicationRequest.cs index 002b36ed..ff283561 100644 --- a/backend/Models/ResubmitBudgetApplicationRequest.cs +++ b/backend/Models/ResubmitBudgetApplicationRequest.cs @@ -33,41 +33,13 @@ public partial class ResubmitBudgetApplicationRequest [DataMember(Name="activityId", EmitDefaultValue=true)] public int? ActivityId { get; set; } - - /// - /// 申请类型。 - /// - /// 申请类型。 - - public enum TypeEnum - { - - /// - /// Enum ActivityBudgetEnum for activity_budget - /// - [EnumMember(Value = "activity_budget")] - ActivityBudgetEnum = 1, - - /// - /// Enum PurchaseEnum for purchase - /// - [EnumMember(Value = "purchase")] - PurchaseEnum = 2, - - /// - /// Enum ReimbursementEnum for reimbursement - /// - [EnumMember(Value = "reimbursement")] - ReimbursementEnum = 3 - } - /// /// 申请类型。 /// /// 申请类型。 /* activity_budget */ [DataMember(Name="type", EmitDefaultValue=true)] - public TypeEnum Type { get; set; } + public BudgetApplicationType? Type { get; set; } /// /// 经费申请标题。 diff --git a/frontend/src/api/apis/DefaultApi.ts b/frontend/src/api/apis/DefaultApi.ts index bd0d8090..dc631384 100644 --- a/frontend/src/api/apis/DefaultApi.ts +++ b/frontend/src/api/apis/DefaultApi.ts @@ -1109,6 +1109,9 @@ export interface GetBudgetAccountsRequest { } export interface GetBudgetApplicationReviewsRequest { + /** + * + */ applicationId: number; } @@ -1896,7 +1899,17 @@ export interface RemoveProjectMemberRequest { } export interface ResubmitBudgetApplicationOperationRequest { + /** + * 标记一次业务提交;带有 x-idempotency-required: true 的操作必须提供,并在同一次提交及网络重试中复用。 + */ + idempotencyKey: string; + /** + * + */ applicationId: number; + /** + * + */ resubmitBudgetApplicationRequest: ResubmitBudgetApplicationRequest; } @@ -6242,7 +6255,7 @@ export class DefaultApi extends runtime.BaseAPI { } } - let urlPath = `/api/budget/applications/{applicationId}/reviews`; + let urlPath = `/api/v1/budget/applications/{applicationId}/reviews`; urlPath = urlPath.replace( "{applicationId}", encodeURIComponent(String(requestParameters["applicationId"])), @@ -10072,6 +10085,13 @@ export class DefaultApi extends runtime.BaseAPI { async resubmitBudgetApplicationRequestOpts( requestParameters: ResubmitBudgetApplicationOperationRequest, ): Promise { + if (requestParameters["idempotencyKey"] == null) { + throw new runtime.RequiredError( + "idempotencyKey", + 'Required parameter "idempotencyKey" was null or undefined when calling resubmitBudgetApplication().', + ); + } + if (requestParameters["applicationId"] == null) { throw new runtime.RequiredError( "applicationId", @@ -10092,6 +10112,10 @@ export class DefaultApi extends runtime.BaseAPI { headerParameters["Content-Type"] = "application/json"; + if (requestParameters["idempotencyKey"] != null) { + headerParameters["Idempotency-Key"] = String(requestParameters["idempotencyKey"]); + } + if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("bearerAuth", []); @@ -10101,7 +10125,7 @@ export class DefaultApi extends runtime.BaseAPI { } } - let urlPath = `/api/budget/applications/{applicationId}/resubmit`; + let urlPath = `/api/v1/budget/applications/{applicationId}/resubmit`; urlPath = urlPath.replace( "{applicationId}", encodeURIComponent(String(requestParameters["applicationId"])), diff --git a/frontend/src/api/models/BudgetApplicationType.ts b/frontend/src/api/models/BudgetApplicationType.ts new file mode 100644 index 00000000..db1671e6 --- /dev/null +++ b/frontend/src/api/models/BudgetApplicationType.ts @@ -0,0 +1,59 @@ +// @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. + */ + +/** + * 经费申请类型。 + * @export + */ +export const BudgetApplicationType = { + ActivityBudget: "activity_budget", + Purchase: "purchase", + Reimbursement: "reimbursement", +} as const; +export type BudgetApplicationType = + (typeof BudgetApplicationType)[keyof typeof BudgetApplicationType]; + +export function instanceOfBudgetApplicationType(value: any): boolean { + for (const key in BudgetApplicationType) { + if (Object.prototype.hasOwnProperty.call(BudgetApplicationType, key)) { + if (BudgetApplicationType[key as keyof typeof BudgetApplicationType] === value) { + return true; + } + } + } + return false; +} + +export function BudgetApplicationTypeFromJSON(json: any): BudgetApplicationType { + return BudgetApplicationTypeFromJSONTyped(json, false); +} + +export function BudgetApplicationTypeFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): BudgetApplicationType { + return json as BudgetApplicationType; +} + +export function BudgetApplicationTypeToJSON(value?: BudgetApplicationType | null): any { + return value as any; +} + +export function BudgetApplicationTypeToJSONTyped( + value: any, + ignoreDiscriminator: boolean, +): BudgetApplicationType { + return value as BudgetApplicationType; +} diff --git a/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts index ba99cd81..ba2fb623 100644 --- a/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts +++ b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts @@ -14,6 +14,14 @@ */ import { mapValues } from "../runtime"; +import type { BudgetApplicationType } from "./BudgetApplicationType"; +import { + BudgetApplicationTypeFromJSON, + BudgetApplicationTypeFromJSONTyped, + BudgetApplicationTypeToJSON, + BudgetApplicationTypeToJSONTyped, +} from "./BudgetApplicationType"; + /** * 重新提交被驳回经费申请的请求,所有字段均为可选,仅更新传入的字段。 * @export @@ -22,53 +30,30 @@ import { mapValues } from "../runtime"; export interface ResubmitBudgetApplicationRequest { /** * 关联活动,可不填。 - * @type {number} - * @memberof ResubmitBudgetApplicationRequest */ activityId?: number | null; /** * 申请类型。 - * @type {ResubmitBudgetApplicationRequestTypeEnum} - * @memberof ResubmitBudgetApplicationRequest */ - type?: ResubmitBudgetApplicationRequestTypeEnum; + type?: BudgetApplicationType | null; /** * 经费申请标题。 - * @type {string} - * @memberof ResubmitBudgetApplicationRequest */ title?: string; /** * 申请金额,必须大于 0。 - * @type {number} - * @memberof ResubmitBudgetApplicationRequest */ amount?: number; /** * 经费用途。 - * @type {string} - * @memberof ResubmitBudgetApplicationRequest */ purpose?: string; /** * 经费明细说明。 - * @type {string} - * @memberof ResubmitBudgetApplicationRequest */ detail?: string | null; } -/** - * @export - */ -export const ResubmitBudgetApplicationRequestTypeEnum = { - ActivityBudget: "activity_budget", - Purchase: "purchase", - Reimbursement: "reimbursement", -} as const; -export type ResubmitBudgetApplicationRequestTypeEnum = - (typeof ResubmitBudgetApplicationRequestTypeEnum)[keyof typeof ResubmitBudgetApplicationRequestTypeEnum]; - /** * Check if a given object implements the ResubmitBudgetApplicationRequest interface. */ @@ -98,7 +83,12 @@ export function ResubmitBudgetApplicationRequestFromJSONTyped( : json["activityId"] === null ? null : json["activityId"], - type: json["type"] == null ? undefined : json["type"], + type: + json["type"] === undefined + ? undefined + : json["type"] === null + ? null + : BudgetApplicationTypeFromJSON(json["type"]), title: json["title"] == null ? undefined : json["title"], amount: json["amount"] == null ? undefined : json["amount"], purpose: json["purpose"] == null ? undefined : json["purpose"], @@ -123,7 +113,7 @@ export function ResubmitBudgetApplicationRequestToJSONTyped( return { activityId: value["activityId"], - type: value["type"], + type: BudgetApplicationTypeToJSON(value["type"]), title: value["title"], amount: value["amount"], purpose: value["purpose"], diff --git a/frontend/src/api/models/index.ts b/frontend/src/api/models/index.ts index 5afb7d3a..a2000b3a 100644 --- a/frontend/src/api/models/index.ts +++ b/frontend/src/api/models/index.ts @@ -25,6 +25,7 @@ export * from "./AwardSchemeRecord"; export * from "./BorrowMaterialRequest"; export * from "./BudgetAccount"; export * from "./BudgetApplication"; +export * from "./BudgetApplicationType"; export * from "./BudgetReviewRecord"; export * from "./BudgetTransaction"; export * from "./CancelBudgetApplicationRequest"; diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue index b3806fb5..40dded02 100644 --- a/frontend/src/views/BudgetManagement.vue +++ b/frontend/src/views/BudgetManagement.vue @@ -602,21 +602,18 @@ async function submitResubmit() { if (!resubmitTarget.value || !(await resubmitFormRef.value?.validate())) return; try { - await requestJson( - `/api/v1/budget/applications/${resubmitTarget.value.id}/resubmit`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - activityId: resubmitForm.activityId ?? null, - type: resubmitForm.type, - title: resubmitForm.title, - amount: resubmitForm.amount, - purpose: resubmitForm.purpose, - detail: resubmitForm.detail.trim() || null, - }), + await apiClient.resubmitBudgetApplication({ + idempotencyKey: createIdempotencyKey(), + applicationId: resubmitTarget.value.id, + resubmitBudgetApplicationRequest: { + activityId: resubmitForm.activityId ?? null, + type: resubmitForm.type, + title: resubmitForm.title, + amount: resubmitForm.amount, + purpose: resubmitForm.purpose, + detail: resubmitForm.detail.trim() || null, }, - ); + }); resubmitDialogVisible.value = false; ElMessage.success("经费申请已重新提交,等待审核"); @@ -630,9 +627,9 @@ async function openReviewHistory(application: BudgetApplication) { reviewHistoryVisible.value = true; loadingReviews.value = true; try { - reviewRecords.value = await requestJson( - `/api/v1/budget/applications/${application.id}/reviews`, - ); + reviewRecords.value = await apiClient.getBudgetApplicationReviews({ + applicationId: application.id, + }); } catch (error) { ElMessage.error(error instanceof Error ? error.message : "加载审核历史失败"); reviewRecords.value = []; From af6b85f540f1ce39d63b0f2c434dbf09082af27b Mon Sep 17 00:00:00 2001 From: wendaining Date: Mon, 31 Aug 2026 17:47:42 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(api):=20=E4=BF=AE=E6=AD=A3=E7=BB=8F?= =?UTF-8?q?=E8=B4=B9=E9=87=8D=E6=8F=90=E7=B1=BB=E5=9E=8B=E5=8F=AF=E7=A9=BA?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 nullable 移入 BudgetApplicationType schema,避免 OpenAPI 3.0 中 allOf 引用不接受 null,并补充显式 null 的回归测试。 --- api/openapi.yaml | 8 +++----- backend.Tests/BudgetApprovalFlowTests.cs | 18 ++++++++++++++++++ .../Models/ResubmitBudgetApplicationRequest.cs | 4 +--- .../models/ResubmitBudgetApplicationRequest.ts | 2 +- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 8d3602a3..35096710 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -10276,8 +10276,10 @@ components: BudgetApplicationType: type: string + nullable: true enum: [activity_budget, purchase, reimbursement] description: 经费申请类型。 + example: activity_budget ResubmitBudgetApplicationRequest: type: object @@ -10289,11 +10291,7 @@ components: description: 关联活动,可不填。 example: 12 type: - nullable: true - allOf: - - $ref: "#/components/schemas/BudgetApplicationType" - description: 申请类型。 - example: activity_budget + $ref: "#/components/schemas/BudgetApplicationType" title: type: string minLength: 1 diff --git a/backend.Tests/BudgetApprovalFlowTests.cs b/backend.Tests/BudgetApprovalFlowTests.cs index 096b4373..a89e75eb 100644 --- a/backend.Tests/BudgetApprovalFlowTests.cs +++ b/backend.Tests/BudgetApprovalFlowTests.cs @@ -156,6 +156,24 @@ public async Task ResubmitApplication_WhenTypeIsOmitted_PreservesExistingType() Assert.Equal("activity_budget", document.RootElement.GetProperty("type").GetString()); } + [Fact] + public async Task ResubmitApplication_WhenTypeIsNull_PreservesExistingType() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + new { type = (string?)null, title = "接受可空申请类型" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("activity_budget", document.RootElement.GetProperty("type").GetString()); + } + [Fact] public async Task ResubmitApplication_WhenNotRejected_ReturnsBadRequest() { diff --git a/backend/Models/ResubmitBudgetApplicationRequest.cs b/backend/Models/ResubmitBudgetApplicationRequest.cs index ff283561..63ae265a 100644 --- a/backend/Models/ResubmitBudgetApplicationRequest.cs +++ b/backend/Models/ResubmitBudgetApplicationRequest.cs @@ -34,10 +34,8 @@ public partial class ResubmitBudgetApplicationRequest public int? ActivityId { get; set; } /// - /// 申请类型。 + /// Gets or Sets Type /// - /// 申请类型。 - /* activity_budget */ [DataMember(Name="type", EmitDefaultValue=true)] public BudgetApplicationType? Type { get; set; } diff --git a/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts index ba2fb623..e2060b9c 100644 --- a/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts +++ b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts @@ -33,7 +33,7 @@ export interface ResubmitBudgetApplicationRequest { */ activityId?: number | null; /** - * 申请类型。 + * */ type?: BudgetApplicationType | null; /** From f64acf92217319700416d1e5471537e09c1b43a2 Mon Sep 17 00:00:00 2001 From: wendaining Date: Mon, 31 Aug 2026 18:09:16 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(budget):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=87=8D=E6=8F=90=E6=98=BE=E5=BC=8F=E6=B8=85=E7=A9=BA=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分省略字段与显式 null,允许重新提交时清空关联活动和明细,并刷新新的提交时间。 --- backend.Tests/BudgetApprovalFlowTests.cs | 52 +++++++++++++++++++ backend/Controllers/BudgetController.cs | 5 +- .../GeneratedJsonRequiredMembers.cs | 42 +++++++++++++++ ...esubmitBudgetApplicationRequestPresence.cs | 8 +++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 backend/Validation/ResubmitBudgetApplicationRequestPresence.cs diff --git a/backend.Tests/BudgetApprovalFlowTests.cs b/backend.Tests/BudgetApprovalFlowTests.cs index a89e75eb..8efe0bc7 100644 --- a/backend.Tests/BudgetApprovalFlowTests.cs +++ b/backend.Tests/BudgetApprovalFlowTests.cs @@ -26,6 +26,7 @@ public sealed class BudgetApprovalFlowTests : IClassFixture _factory = factory; @@ -136,6 +137,41 @@ public async Task ResubmitApplication_WhenRejected_UpdatesFieldsAndReturnsPendin 1, await db.BudgetReviewRecords.CountAsync( item => item.ApplicationId == ApplicationId)); + Assert.Equal(OriginalActivityId, application.ActivityId); + Assert.InRange( + application.SubmittedAt, + DateTime.UtcNow.AddMinutes(-1), + DateTime.UtcNow.AddMinutes(1)); + } + + [Fact] + public async Task ResubmitApplication_WhenNullableFieldsAreExplicitlyNull_ClearsExistingValues() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false); + + using var content = new StringContent( + """{"activityId":null,"title":"清空可选字段","amount":800,"purpose":"采购活动物料","detail":null}""", + Encoding.UTF8, + "application/json"); + using var response = await client.PostAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + content); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("activityId").ValueKind); + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("detail").ValueKind); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var application = await db.BudgetApplications.SingleAsync( + item => item.ApplicationId == ApplicationId); + Assert.Null(application.ActivityId); + Assert.Null(application.Detail); } [Fact] @@ -154,6 +190,13 @@ public async Task ResubmitApplication_WhenTypeIsOmitted_PreservesExistingType() Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal("activity_budget", document.RootElement.GetProperty("type").GetString()); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var application = await db.BudgetApplications.SingleAsync( + item => item.ApplicationId == ApplicationId); + Assert.Equal(OriginalActivityId, application.ActivityId); + Assert.Equal("原预算明细", application.Detail); } [Fact] @@ -242,11 +285,20 @@ private static async Task SeedScenarioAsync( CreatedAt = now, UpdatedAt = now }); + db.Activities.Add(new Activity + { + ActivityId = OriginalActivityId, + ClubId = ClubId, + Title = "原关联活动", + ActivityStatus = "published", + CreatedAt = now.AddDays(-2) + }); db.BudgetApplications.Add(new BudgetApplication { ApplicationId = ApplicationId, AccountId = AccountId, ClubId = ClubId, + ActivityId = OriginalActivityId, ApplicantUserId = ApplicantUserId, ApplicationType = "activity_budget", Title = "原经费申请", diff --git a/backend/Controllers/BudgetController.cs b/backend/Controllers/BudgetController.cs index 3139a2f9..03888d83 100644 --- a/backend/Controllers/BudgetController.cs +++ b/backend/Controllers/BudgetController.cs @@ -618,7 +618,7 @@ public async Task ResubmitApplication( var title = NullIfBlank(req.Title) ?? application.Title; var purpose = NullIfBlank(req.Purpose) ?? application.Purpose; - var detail = req.Detail is not null ? NullIfBlank(req.Detail) : application.Detail; + var detail = req.DetailWasProvided ? NullIfBlank(req.Detail) : application.Detail; var amount = req.Amount.HasValue ? Convert.ToDecimal(req.Amount.Value) : application.Amount; var newType = req.Type is { } type ? EnumMemberValue(type) : null; var applicationType = newType ?? application.ApplicationType; @@ -632,7 +632,7 @@ public async Task ResubmitApplication( if (validation is not null) return validation; Activity? activity = null; - var activityId = req.ActivityId ?? application.ActivityId; + var activityId = req.ActivityIdWasProvided ? req.ActivityId : application.ActivityId; if (activityId is not null) { activity = await _db.Activities @@ -659,6 +659,7 @@ public async Task ResubmitApplication( application.Purpose = purpose; application.Detail = detail; application.ApplicationStatus = ApplicationStatusPending; + application.SubmittedAt = now; application.ReviewerUserId = null; application.ReviewComment = null; application.ReviewedAt = null; diff --git a/backend/Validation/GeneratedJsonRequiredMembers.cs b/backend/Validation/GeneratedJsonRequiredMembers.cs index 0e9d2f9a..314eab16 100644 --- a/backend/Validation/GeneratedJsonRequiredMembers.cs +++ b/backend/Validation/GeneratedJsonRequiredMembers.cs @@ -21,6 +21,8 @@ public static class GeneratedJsonRequiredMembers public static void Apply(JsonTypeInfo typeInfo) { + TrackResubmitRequestPresence(typeInfo); + if (!RequiredJsonProperties.TryGetValue(typeInfo.Type, out var requiredProperties)) { return; @@ -34,4 +36,44 @@ public static void Apply(JsonTypeInfo typeInfo) } } } + + private static void TrackResubmitRequestPresence(JsonTypeInfo typeInfo) + { + // Nullable PATCH fields need to distinguish an omitted property from an + // explicitly supplied null so the controller can preserve or clear values. + if (typeInfo.Type != typeof(ResubmitBudgetApplicationRequest)) + { + return; + } + + foreach (var property in typeInfo.Properties) + { + var propertyName = property.Name; + if (!propertyName.Equals("activityId", StringComparison.OrdinalIgnoreCase) && + !propertyName.Equals("detail", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var setter = property.Set; + if (setter is null) + { + continue; + } + + property.Set = (obj, value) => + { + setter(obj, value); + var request = (ResubmitBudgetApplicationRequest)obj; + if (propertyName.Equals("activityId", StringComparison.OrdinalIgnoreCase)) + { + request.ActivityIdWasProvided = true; + } + else + { + request.DetailWasProvided = true; + } + }; + } + } } diff --git a/backend/Validation/ResubmitBudgetApplicationRequestPresence.cs b/backend/Validation/ResubmitBudgetApplicationRequestPresence.cs new file mode 100644 index 00000000..73c401f4 --- /dev/null +++ b/backend/Validation/ResubmitBudgetApplicationRequestPresence.cs @@ -0,0 +1,8 @@ +namespace Org.OpenAPITools.Models; + +public partial class ResubmitBudgetApplicationRequest +{ + internal bool ActivityIdWasProvided { get; set; } + + internal bool DetailWasProvided { get; set; } +} From b3fdb4c0eb16c179997a8cf9379048f8097ad990 Mon Sep 17 00:00:00 2001 From: wendaining Date: Mon, 31 Aug 2026 18:26:00 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(api):=20=E5=A3=B0=E6=98=8E=E9=87=8D?= =?UTF-8?q?=E6=8F=90=E9=94=99=E8=AF=AF=E5=93=8D=E5=BA=94=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为重提接口的错误状态统一补充 ApiError JSON schema,并增加契约回归测试。 --- api/openapi.yaml | 20 ++++++++++++++++++++ backend.Tests/OpenApiRestContractTests.cs | 17 +++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index 35096710..1fd28bef 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3473,14 +3473,34 @@ paths: $ref: "#/components/schemas/BudgetApplication" "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" "409": description: 并发写入冲突 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" /api/v1/budget/transactions: get: diff --git a/backend.Tests/OpenApiRestContractTests.cs b/backend.Tests/OpenApiRestContractTests.cs index 4c9773f7..963aeef1 100644 --- a/backend.Tests/OpenApiRestContractTests.cs +++ b/backend.Tests/OpenApiRestContractTests.cs @@ -119,6 +119,23 @@ public void ApiErrorCodeUsesStandardRequiredValues() Assert.Contains(" required:\n - code", NormalizeNewLines(apiErrorSchema), StringComparison.Ordinal); } + [Fact] + public void ResubmitBudgetApplicationDeclaresApiErrorResponses() + { + var document = ReadDocument(); + var operation = Slice( + document, + " /api/v1/budget/applications/{applicationId}/resubmit:", + " /api/v1/budget/transactions:"); + + foreach (var status in new[] { "400", "401", "403", "404", "409" }) + { + var responsePattern = + $@"(?ms)^ ""{status}"":\s*$.*?^ content:\s*$.*?^ application/json:\s*$.*?^ schema:\s*$.*?^ \$ref: ""#/components/schemas/ApiError""\s*$"; + Assert.Matches(responsePattern, operation); + } + } + private static string ReadDocument() { var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../api/openapi.yaml")); From e4deb841269a862de02fe8762487953d691033cf Mon Sep 17 00:00:00 2001 From: Aphrody <051012atw@gmail.com> Date: Tue, 1 Sep 2026 15:28:31 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(auth):=20=E5=9E=82=E7=9B=B4=E5=B1=85?= =?UTF-8?q?=E4=B8=AD=E7=99=BB=E5=BD=95=E6=B3=A8=E5=86=8C=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 认证容器改为占满视口高度,并使用对称上下留白垂直居中文字和表单面板。 --- frontend/src/views/AuthFlow.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/AuthFlow.vue b/frontend/src/views/AuthFlow.vue index 444fc425..97c3a1e9 100644 --- a/frontend/src/views/AuthFlow.vue +++ b/frontend/src/views/AuthFlow.vue @@ -630,12 +630,12 @@ if (!auth.value) void loadCaptcha(false); } .auth-shell { - min-height: 520px; + min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 420px); gap: 28px; align-items: center; - padding-top: 24px; + padding-block: 24px; } .register-shell { From 7388c21f4342e3892faddb961763a948cfe878ba Mon Sep 17 00:00:00 2001 From: Aphrody <051012atw@gmail.com> Date: Tue, 1 Sep 2026 15:31:39 +0800 Subject: [PATCH 10/10] =?UTF-8?q?test(auth):=20=E6=B7=BB=E5=8A=A0=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=A1=B5=E5=B8=83=E5=B1=80=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证认证容器使用完整视口高度、垂直居中和对称上下留白,防止布局回归。 --- frontend/src/authLayout.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 frontend/src/authLayout.test.ts diff --git a/frontend/src/authLayout.test.ts b/frontend/src/authLayout.test.ts new file mode 100644 index 00000000..2397b625 --- /dev/null +++ b/frontend/src/authLayout.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import authFlowSource from "./views/AuthFlow.vue?raw"; + +describe("登录注册页面布局", () => { + it("使用完整视口高度垂直居中认证内容", () => { + const authShellRule = authFlowSource.match(/\.auth-shell\s*\{([\s\S]*?)\}/)?.[1]; + + expect(authShellRule).toBeDefined(); + expect(authShellRule).toContain("min-height: 100vh"); + expect(authShellRule).toContain("align-items: center"); + expect(authShellRule).toContain("padding-block: 24px"); + expect(authShellRule).not.toContain("padding-top"); + }); +});