Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions frontend/src/activityStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
66 changes: 66 additions & 0 deletions frontend/src/activityStatus.ts
Original file line number Diff line number Diff line change
@@ -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<ActivityDisplayStatus>([
"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";
}
8 changes: 7 additions & 1 deletion frontend/src/forumClubScope.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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", () => {
Expand All @@ -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");
});
});
12 changes: 10 additions & 2 deletions frontend/src/forumClubScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ import type { Club } from "./api/models";

const normalizeStatus = (value?: string | null) => value?.trim().toLowerCase() ?? "";

export function isActiveForumClub(club: Pick<Club, "status" | "auditStatus">): boolean {
export function isOperationalClub(club: Pick<Club, "status" | "auditStatus">): 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<Club, "status" | "auditStatus">): boolean {
return isOperationalClub(club);
}

export function filterForumClubs(clubs: Club[]): Club[] {
return clubs.filter(isActiveForumClub);
return filterOperationalClubs(clubs);
}
38 changes: 29 additions & 9 deletions frontend/src/views/ActivityList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -105,6 +106,8 @@ const participationsDialogVisible = ref(false);
const currentActivity = ref<Activity | null>(null);
const participations = ref<ActivityParticipation[]>([]);
const participationLoading = ref(false);
const currentTime = ref(Date.now());
let statusTimer: number | undefined;

const currentUserId = computed(() => auth.value?.user.id ?? null);
const currentUserDisplay = computed(() => {
Expand Down Expand Up @@ -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: "已驳回" },
];
Expand All @@ -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<string, string> = {
draft: "草稿",
pending_review: "待审核",
published: "报名中",
registration_closed: "报名已截止",
rejected: "已驳回",
ongoing: "进行中",
finished: "已结束",
Expand All @@ -216,6 +223,7 @@ const statusType: Record<string, string> = {
draft: "info",
pending_review: "warning",
published: "success",
registration_closed: "warning",
rejected: "danger",
ongoing: "",
finished: "info",
Expand All @@ -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()]);
Expand All @@ -241,6 +252,7 @@ onMounted(async () => {

onUnmounted(() => {
stopSessionListener?.();
if (statusTimer !== undefined) window.clearInterval(statusTimer);
});

function formatDateTimeForPicker(date: Date) {
Expand All @@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -777,8 +793,12 @@ async function openParticipations(activity: Activity) {
<el-table-column prop="location" label="地点" width="120" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.status" :type="statusType[row.status] || 'info'" size="small">
{{ statusLabel[row.status] || row.status }}
<el-tag
v-if="row.status"
:type="statusType[displayActivityStatus(row)] || 'info'"
size="small"
>
{{ statusLabel[displayActivityStatus(row)] || displayActivityStatus(row) }}
</el-tag>
</template>
</el-table-column>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/ForumCenter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -68,7 +68,7 @@ async function loadClubs() {
requestJson<UserSummary[]>("/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);
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/views/ProjectList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "" }),
Expand Down Expand Up @@ -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, "社团列表加载失败"));
Expand Down
Loading