diff --git a/frontend/src/activityStatus.test.ts b/frontend/src/activityStatus.test.ts new file mode 100644 index 00000000..8badf906 --- /dev/null +++ b/frontend/src/activityStatus.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { beijingStoredDateTimeTimestamp } from "./beijingTime"; +import { resolveActivityDisplayStatus } from "./activityStatus"; + +const at = (value: string) => beijingStoredDateTimeTimestamp(value); + +describe("activity display status", () => { + it("marks a published activity as registration closed after its deadline", () => { + expect( + resolveActivityDisplayStatus( + { + status: "published", + startTime: "2026-09-12T10:00:00", + endTime: "2026-09-12T12:00:00", + registrationDeadline: "2026-09-11T23:59:00", + }, + at("2026-09-12T08:00:00"), + ), + ).toBe("registration_closed"); + }); + + it("marks a published activity as ongoing after it starts", () => { + expect( + resolveActivityDisplayStatus( + { + status: "published", + startTime: "2026-09-12T10:00:00", + endTime: "2026-09-12T12:00:00", + registrationDeadline: "2026-09-12T09:00:00", + }, + at("2026-09-12T10:30:00"), + ), + ).toBe("ongoing"); + }); + + it("marks a stale published row as finished after the event ends", () => { + expect( + resolveActivityDisplayStatus( + { + status: "published", + startTime: "2026-09-12T10:00:00", + endTime: "2026-09-12T12:00:00", + registrationDeadline: "2026-09-12T09:00:00", + }, + at("2026-09-12T12:00:01"), + ), + ).toBe("finished"); + }); + + it("preserves workflow statuses that have no time based override", () => { + expect( + resolveActivityDisplayStatus({ status: "pending_review" }, at("2026-09-12T10:00:00")), + ).toBe("pending_review"); + expect(resolveActivityDisplayStatus({ status: "ongoing" }, at("2026-09-12T10:00:00"))).toBe( + "ongoing", + ); + }); +}); diff --git a/frontend/src/activityStatus.ts b/frontend/src/activityStatus.ts new file mode 100644 index 00000000..631be263 --- /dev/null +++ b/frontend/src/activityStatus.ts @@ -0,0 +1,66 @@ +import { beijingStoredDateTimeTimestamp } from "./beijingTime"; + +export type ActivityStatusInput = { + status?: string | null; + startTime?: string | Date | null; + endTime?: string | Date | null; + registrationDeadline?: string | Date | null; +}; + +export type ActivityDisplayStatus = + | "draft" + | "pending_review" + | "published" + | "registration_closed" + | "ongoing" + | "finished" + | "rejected" + | "cancelled" + | "unknown"; + +const workflowStatuses = new Set([ + "draft", + "pending_review", + "published", + "registration_closed", + "ongoing", + "finished", + "rejected", + "cancelled", +]); + +function timestamp(value?: string | Date | null) { + if (value instanceof Date) return value.getTime(); + return beijingStoredDateTimeTimestamp(value); +} + +function isFiniteTimestamp(value: number): value is number { + return Number.isFinite(value); +} + +/** Resolve stale `published` rows into the status users can act on today. */ +export function resolveActivityDisplayStatus( + activity: ActivityStatusInput, + now = Date.now(), +): ActivityDisplayStatus { + const status = (activity.status ?? "").trim().toLowerCase(); + const start = timestamp(activity.startTime); + const end = timestamp(activity.endTime); + const deadline = timestamp(activity.registrationDeadline); + + if (status === "published") { + if (isFiniteTimestamp(start) && now >= start && (!isFiniteTimestamp(end) || now < end)) { + return "ongoing"; + } + if (isFiniteTimestamp(end) && now >= end) return "finished"; + if (isFiniteTimestamp(deadline) && now >= deadline) return "registration_closed"; + return "published"; + } + + if (status === "ongoing" && isFiniteTimestamp(end) && now >= end) return "finished"; + if (workflowStatuses.has(status as ActivityDisplayStatus)) { + return status as ActivityDisplayStatus; + } + + return "unknown"; +} diff --git a/frontend/src/forumClubScope.test.ts b/frontend/src/forumClubScope.test.ts index f7155ff2..f6e1e610 100644 --- a/frontend/src/forumClubScope.test.ts +++ b/frontend/src/forumClubScope.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import type { Club } from "./api/models"; -import { filterForumClubs, isActiveForumClub } from "./forumClubScope"; +import { filterForumClubs, filterOperationalClubs, isActiveForumClub } from "./forumClubScope"; import forumCenterSource from "./views/ForumCenter.vue?raw"; +import projectListSource from "./views/ProjectList.vue?raw"; const club = (id: number, status: string | null, auditStatus: string | null): Club => ({ id, @@ -24,6 +25,7 @@ describe("forum club scope", () => { ]; expect(filterForumClubs(clubs).map((item) => item.id)).toEqual([1]); + expect(filterOperationalClubs(clubs).map((item) => item.id)).toEqual([1]); }); it("normalizes status casing and surrounding whitespace", () => { @@ -41,4 +43,8 @@ describe("forum club scope", () => { expect(forumCenterSource).toContain("topics.value = []"); expect(forumCenterSource).toContain("loadError.value = null"); }); + + it("wires the same operational-club filter into project selectors", () => { + expect(projectListSource).toContain("filterOperationalClubs"); + }); }); diff --git a/frontend/src/forumClubScope.ts b/frontend/src/forumClubScope.ts index 0e9722a1..4dcdb3b7 100644 --- a/frontend/src/forumClubScope.ts +++ b/frontend/src/forumClubScope.ts @@ -2,12 +2,20 @@ import type { Club } from "./api/models"; const normalizeStatus = (value?: string | null) => value?.trim().toLowerCase() ?? ""; -export function isActiveForumClub(club: Pick): boolean { +export function isOperationalClub(club: Pick): boolean { return ( normalizeStatus(club.status) === "active" && normalizeStatus(club.auditStatus) === "approved" ); } +export function filterOperationalClubs(clubs: Club[]): Club[] { + return clubs.filter(isOperationalClub); +} + +export function isActiveForumClub(club: Pick): boolean { + return isOperationalClub(club); +} + export function filterForumClubs(clubs: Club[]): Club[] { - return clubs.filter(isActiveForumClub); + return filterOperationalClubs(clubs); } diff --git a/frontend/src/views/ActivityList.vue b/frontend/src/views/ActivityList.vue index d684f05c..26f62dec 100644 --- a/frontend/src/views/ActivityList.vue +++ b/frontend/src/views/ActivityList.vue @@ -12,6 +12,7 @@ import { import { requestJson } from "../composables/useApiRequest"; import { activityRegistrationButtonText } from "../defenseBusinessRules"; import { MATERIAL_ACCESS_PERMISSIONS } from "../materialPermissions"; +import { resolveActivityDisplayStatus } from "../activityStatus"; interface Activity { id: number; @@ -105,6 +106,8 @@ const participationsDialogVisible = ref(false); const currentActivity = ref(null); const participations = ref([]); const participationLoading = ref(false); +const currentTime = ref(Date.now()); +let statusTimer: number | undefined; const currentUserId = computed(() => auth.value?.user.id ?? null); const currentUserDisplay = computed(() => { @@ -191,6 +194,7 @@ const statusFilterOptions = [ { value: "all", label: "全部状态" }, { value: "pending_review", label: "待审核" }, { value: "published", label: "报名中" }, + { value: "registration_closed", label: "报名已截止" }, { value: "ongoing", label: "进行中" }, { value: "rejected", label: "已驳回" }, ]; @@ -199,13 +203,16 @@ const filteredActivities = computed(() => { if (statusFilter.value === "all") { return activities.value; } - return activities.value.filter((activity) => activity.status === statusFilter.value); + return activities.value.filter( + (activity) => displayActivityStatus(activity) === statusFilter.value, + ); }); const statusLabel: Record = { draft: "草稿", pending_review: "待审核", published: "报名中", + registration_closed: "报名已截止", rejected: "已驳回", ongoing: "进行中", finished: "已结束", @@ -216,6 +223,7 @@ const statusType: Record = { draft: "info", pending_review: "warning", published: "success", + registration_closed: "warning", rejected: "danger", ongoing: "", finished: "info", @@ -232,6 +240,9 @@ const CHECKIN_WINDOW_MINUTES = 5; const SIGN_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; onMounted(async () => { + statusTimer = window.setInterval(() => { + currentTime.value = Date.now(); + }, 30_000); stopSessionListener = onSessionChange(() => { auth.value = readAuth(); void Promise.all([loadActivities(), loadClubOptions()]); @@ -241,6 +252,7 @@ onMounted(async () => { onUnmounted(() => { stopSessionListener?.(); + if (statusTimer !== undefined) window.clearInterval(statusTimer); }); function formatDateTimeForPicker(date: Date) { @@ -259,6 +271,10 @@ function formatActivityTimeRange(activity: Activity) { return `${formatTime(activity.startTime)} ~ ${formatTime(activity.endTime)}`; } +function displayActivityStatus(activity: Activity) { + return resolveActivityDisplayStatus(activity, currentTime.value); +} + function buildDefaultCreateTimes() { const start = new Date(); start.setDate(start.getDate() + 1); @@ -441,21 +457,21 @@ async function loadClubOptions() { } function canRegister(activity: Activity) { - const deadlinePassed = - activity.registrationDeadline != null && - beijingStoredDateTimeTimestamp(activity.registrationDeadline) < Date.now(); - return ( Boolean(currentUserId.value) && - activity.status === "published" && + displayActivityStatus(activity) === "published" && hasScopedPermission("activity:checkin", activity.clubId) && !activity.isRegistered && - !deadlinePassed && (activity.maxParticipants == null || activity.currentParticipants < activity.maxParticipants) ); } function registerButtonText(activity: Activity) { + const status = displayActivityStatus(activity); + if (status === "registration_closed") return "报名已截止"; + if (status === "ongoing") return "进行中"; + if (status === "finished") return "活动已结束"; + return activityRegistrationButtonText({ isRegistered: activity.isRegistered, hasMemberPermission: hasScopedPermission("activity:checkin", activity.clubId), @@ -777,8 +793,12 @@ async function openParticipations(activity: Activity) { diff --git a/frontend/src/views/ForumCenter.vue b/frontend/src/views/ForumCenter.vue index efe53140..e04be9ca 100644 --- a/frontend/src/views/ForumCenter.vue +++ b/frontend/src/views/ForumCenter.vue @@ -7,7 +7,7 @@ import { ForumPostFromJSON } from "../api/models"; import { onSessionChange, readAuth } from "../authSession"; import { formatBeijingDateTime } from "../beijingTime"; import { requestJson } from "../composables/useApiRequest"; -import { filterForumClubs } from "../forumClubScope"; +import { filterOperationalClubs } from "../forumClubScope"; import MarkdownEditor from "../components/MarkdownEditor.vue"; import MarkdownRenderer from "../components/MarkdownRenderer.vue"; import ReplyItem from "../components/ReplyItem.vue"; @@ -68,7 +68,7 @@ async function loadClubs() { requestJson("/api/v1/users"), ]); if (clubResult.status === "rejected") throw clubResult.reason; - const availableClubs = filterForumClubs(clubResult.value); + const availableClubs = filterOperationalClubs(clubResult.value); clubs.value = availableClubs; const users = userResult.status === "fulfilled" ? userResult.value : []; const currentUser = users.find((user) => user.id === auth.value?.user.id); diff --git a/frontend/src/views/ProjectList.vue b/frontend/src/views/ProjectList.vue index 6f20adf4..eb57333b 100644 --- a/frontend/src/views/ProjectList.vue +++ b/frontend/src/views/ProjectList.vue @@ -15,6 +15,7 @@ import { } from "../api"; import { apiClient as api, createIdempotencyKey } from "../apiClient"; import { onSessionChange, readAuth, type AuthRole } from "../authSession"; +import { filterOperationalClubs } from "../forumClubScope"; const publicApi = new DefaultApi( new Configuration({ basePath: import.meta.env.VITE_API_BASE_URL ?? "" }), @@ -349,7 +350,13 @@ async function loadClubs() { const requestVersion = sessionVersion; try { const nextClubs = await publicApi.getClubs(); - if (requestVersion === sessionVersion) clubs.value = nextClubs; + if (requestVersion !== sessionVersion) return; + + const availableClubs = filterOperationalClubs(nextClubs); + clubs.value = availableClubs; + if (filters.clubId && !availableClubs.some((club) => club.id === filters.clubId)) { + filters.clubId = undefined; + } } catch (error) { if (requestVersion !== sessionVersion) return; ElMessage.error(toErrorMessage(error, "社团列表加载失败"));