Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ tests/live-login-report.txt
# Regenerate with: npm run test:userscript -- --url=<page> --dump=tests/rendered-<page>.html
tests/rendered-*.html

# Live page dumps and audit scratch captured from xmoj.tech during verification.
# Regenerable via tests/live-dump.ts / tests/live-dump2.ts; never commit.
tests/live-dump/
tests/tmp-audit-*
tests/xmoj-script-ref/

# Editor directories and files
.vscode/*
.idea
Expand Down
31 changes: 26 additions & 5 deletions electron/judge-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { XMOJ_ORIGIN, type ContestDetail, type ContestListPage, type ContestProblemSummary, type NavInfo, } from '../src/shared/types.ts';
import { XMOJ_ORIGIN, type ContestDetail, type ContestListPage, type ContestProblemSummary, type ContestSummary, type NavInfo, } from '../src/shared/types.ts';
import type { HttpClient } from './http.ts';
interface JsonResult<T> {
ok: boolean;
Expand Down Expand Up @@ -41,12 +41,25 @@ async function getJson<T>(http: HttpClient, path: string): Promise<JsonResult<T>
export function fetchNavInfo(http: HttpClient): Promise<JsonResult<NavInfo>> {
return getJson<NavInfo>(http, '/api/nav');
}
export function fetchContestList(http: HttpClient, page = 1): Promise<JsonResult<ContestListPage>> {
return getJson<ContestListPage>(http, `/api/contest/list?pageNum=${page}`);
export async function fetchContestList(http: HttpClient, page = 1): Promise<JsonResult<ContestListPage>> {
const result = await getJson<Omit<ContestListPage, 'list'> & {
list?: (ContestSummary & { createdBy?: string | null })[];
}>(http, `/api/contest/list?pageNum=${page}`);
if (!result.ok || !result.data) {
return { ok: false, status: result.status, data: null, error: result.error };
}
const list = (result.data.list ?? []).map((contest) => {
const { createdBy, ...rest } = contest;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

建议同步 bump CACHE_VERSION(非阻塞)。

本 PR 在 fetchContestList / fetchContestDetail 里把条目的 createdBy 归一化为 creator 并剥离 createdBy。这两类结果由 electron/main.ts 以 contest:list:* / contest:detail:* 写入 PageCache,而这两个前缀都在 electron/cache.ts 的 PERSISTED_PREFIXES 中,会持久化到 page-cache.json:升级用户的旧缓存条目只有 createdBy、没有 creator,与新版产出的 payload 形状不再一致。

实际影响有限:Contests.vue 的 load() / openContest() 都是 cacheOnly → fresh,同一次打开就会被新数据覆盖,不会崩。但按仓库「payload 形状变化要 bump CACHE_VERSION」的约定,建议把它从 3 提升到 4。顺带还能让旧 parser 写入的题库标题(problemset:v2:all)与倒计时顺序(home:snapshot)立即失效——否则 ExportAcCode.vue 只调用 fetchAllProblems(true)、没有 fresh 兜底,旧的「数字开头标题被截断」会继续留在导出文件名里。

return {
...rest,
creator: contest.creator ?? createdBy ?? null,
};
});
return { ok: true, status: result.status, data: { ...result.data, list } };
}
export async function fetchContestDetail(http: HttpClient, contestId: number): Promise<JsonResult<ContestDetail>> {
const result = await getJson<{
contest: ContestDetail['contest'];
contest: ContestDetail['contest'] & { createdBy?: string | null };
problems?: RawContestProblem[];
myRole?: string | null;
canExport?: boolean;
Expand All @@ -55,6 +68,11 @@ export async function fetchContestDetail(http: HttpClient, contestId: number): P
if (!result.ok || !result.data) {
return { ok: false, status: result.status, data: null, error: result.error };
}
const contest = result.data.contest;
if (!contest) {
return { ok: false, status: result.status, data: null, error: '比赛详情为空' };
}
const { createdBy, ...contestRest } = contest;
const problems: ContestProblemSummary[] = (result.data.problems ?? []).map((problem) => {
const rawId = problem.problemId;
const id = typeof rawId === 'string' ? Number.parseInt(rawId, 10) : typeof rawId === 'number' ? rawId : null;
Expand All @@ -69,7 +87,10 @@ export async function fetchContestDetail(http: HttpClient, contestId: number): P
ok: true,
status: result.status,
data: {
contest: result.data.contest,
contest: {
...contestRest,
creator: result.data.contest.creator ?? createdBy ?? null,
},
problems,
myRole: result.data.myRole ?? null,
canExport: Boolean(result.data.canExport),
Expand Down
12 changes: 12 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,18 @@ function registerIpc(): void {
const input = (payload ?? {}) as Record<string, unknown>;
return withCommunity((api, c) => api.sendMail(c, asString(input.toUser), asString(input.content)));
});
handle(IPC.mailReadMention, (payload) => {
const input = (payload ?? {}) as Record<string, unknown>;
const mentionId = Number(input.mentionId);
if (!Number.isSafeInteger(mentionId) || mentionId <= 0) throw new Error('无效的私信通知编号');
return withCommunity((api, c) => api.readMailMention(c, mentionId));
});
handle(IPC.bbsReadMention, (payload) => {
const input = (payload ?? {}) as Record<string, unknown>;
const mentionId = Number(input.mentionId);
if (!Number.isSafeInteger(mentionId) || mentionId <= 0) throw new Error('无效的讨论通知编号');
return withCommunity((api, c) => api.readBbsMention(c, mentionId));
});
const pollNotices = createNoticePoller((channel, credentials) => withCommunity(async (api, c) => {
if (credentials.sessionId !== c.sessionId || credentials.username !== c.username) return [];
const notices: CommunityNotification[] = [];
Expand Down
13 changes: 11 additions & 2 deletions electron/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export function parseCountdown(html: string, today = new Date()): HomeCountdown[
continue;
result.push({ event, date, days });
}
return result.sort((a, b) => a.days - b.days);
return result;
}
function scoreProblemTable(table: HTMLElement): number {
const rows = table.querySelectorAll('tbody tr').length > 0
Expand Down Expand Up @@ -328,7 +328,7 @@ export function parseProblemsetPage(html: string, requestedPage = 1): Problemset
title = normaliseText(cells[2].textContent);
if (!title)
title = `题目 ${id}`;
title = title.replace(/^\s*\d+\s*[-–—:.]?\s*/, '').trim() || `题目 ${id}`;
title = title.replace(/^\s*\d+\s*[-–—:.]\s*/, '').trim() || `题目 ${id}`;
const statusEl = row.querySelector('div.status, .status');
const statusClasses = statusEl?.classList ? Array.from(statusEl.classList.value) : [];
const solved = statusClasses.includes('status_y')
Expand Down Expand Up @@ -382,6 +382,7 @@ const JUDGE_RESULTS: Record<number, {
0: { label: 'Waiting', verdict: 'pending' },
1: { label: 'Pending', verdict: 'pending' },
2: { label: 'Compiling', verdict: 'pending' },
3: { label: 'Running', verdict: 'pending' },
4: { label: 'Accepted', verdict: 'accepted' },
5: { label: 'Presentation Error', verdict: 'presentation_error' },
6: { label: 'Wrong Answer', verdict: 'wrong_answer' },
Expand All @@ -392,6 +393,10 @@ const JUDGE_RESULTS: Record<number, {
11: { label: 'Compile Error', verdict: 'compile_error' },
};
export const JUDGE_PENDING_THRESHOLD = 4;
const JUDGER_NAMES = new Set(['saturn', 'hustoj', 'jupiter', 'mars', 'judge']);
function isJudgerName(value: string): boolean {
return JUDGER_NAMES.has(value.toLowerCase());
}
const VERDICT_LABEL_CODES: [
RegExp,
number
Expand Down Expand Up @@ -648,6 +653,10 @@ export function parseStatusPage(html: string): StatusPage {
continue;
if (matchLanguage(value))
continue;
if (isJudgerName(value))
continue;
if (value.startsWith('-'))
continue;
userRating = value;
break;
}
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,14 @@ const api = {
content: string;
captchaSecretKey?: string;
}) => invoke(IPC.bbsNewReply, payload),
readMention: (mentionId: number) => invoke(IPC.bbsReadMention, { mentionId }),
std: (problemId: number) => invoke(IPC.bbsStd, { problemId }),
},
mail: {
list: (cacheOnly = false) => invoke<CachedValue<unknown>>(IPC.mailList, { cacheOnly }).then((r) => r.value),
get: (otherUser: string, cacheOnly = false) => invoke<CachedValue<unknown>>(IPC.mailGet, { otherUser, cacheOnly }).then((r) => r.value),
send: (toUser: string, content: string) => invoke(IPC.mailSend, { toUser, content }),
readMention: (mentionId: number) => invoke(IPC.mailReadMention, { mentionId }),
},
notices: {
poll: () => invoke<CommunityNotification[]>(IPC.noticesPoll),
Expand Down
26 changes: 24 additions & 2 deletions electron/rank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@ import { parse } from 'node-html-parser';
import { XMOJ_ORIGIN, type ContestRank, type RankRow } from '../src/shared/types.ts';
import type { HttpClient } from './http.ts';
import { normaliseText } from './parsers.ts';
function isAcceptedRankColor(raw: string): boolean {
const value = raw.trim().toLowerCase();
let rgb: [number, number, number] | null = null;
const hex = value.match(/^#?([0-9a-f]{6})$/);
if (hex) {
rgb = [
Number.parseInt(hex[1].slice(0, 2), 16),
Number.parseInt(hex[1].slice(2, 4), 16),
Number.parseInt(hex[1].slice(4, 6), 16),
];
}
else {
const fn = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*[),]/);
if (fn)
rgb = [Number(fn[1]), Number(fn[2]), Number(fn[3])];
}
if (!rgb)
return false;
const [r, g, b] = rgb;
return (g === 255 && g > r && g > b) || (r === 170 && g === 170 && b === 255);
}
export function parseContestRank(html: string, contestId: number, oi: boolean): ContestRank {
const root = parse(html);
const table = root.querySelector('table');
Expand Down Expand Up @@ -36,7 +57,8 @@ export function parseContestRank(html: string, contestId: number, oi: boolean):
const cell = cells[5 + i];
const value = normaliseText(cell?.textContent ?? '');
const style = cell?.getAttribute('style') ?? '';
const accepted = /#(00ff00|53ff53)/i.test(style);
const color = style.match(/background(?:-color)?\s*:\s*([^;]+)/i)?.[1] ?? style;
const accepted = isAcceptedRankColor(color);
problemCells.push({ text: value, accepted });
}
rows.push({
Expand Down Expand Up @@ -105,7 +127,7 @@ async function fetchCorrectionRank(http: HttpClient, contestId: number): Promise
score: Number(row.total ?? 0),
cells: (row.cells ?? []).map((cell) => ({
text: String(cell.text ?? ''),
accepted: /^(00ff00|53ff53)$/i.test(String(cell.bgColor ?? '').replace(/^#/, '')),
accepted: isAcceptedRankColor(String(cell.bgColor ?? '')),
})),
};
});
Expand Down
5 changes: 4 additions & 1 deletion electron/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,12 @@ export async function fetchSource(http: HttpClient, solutionId: number): Promise
const response = await http.get(`${XMOJ_ORIGIN}/getsource.php?id=${solutionId}`);
if (response.status !== 200)
return null;
const text = response.body.replace(/^\s*<!--\s*not[- ]cached\s*-->\s*\r?\n?/i, '').replace(/\r?\n?<!--\s*not[- ]cached\s*-->\s*$/i, '');
let text = response.body.replace(/^\s*<!--\s*not[- ]cached\s*-->\s*\r?\n?/i, '').replace(/\r?\n?<!--\s*not[- ]cached\s*-->\s*$/i, '');
if (/<html|<body|<!DOCTYPE/i.test(text))
return null;
const banner = /\r?\n[ \t]*\/\*{6,}(?:(?!\*\/)[\s\S])*\*\/[ \t]*\r?\n?$/.exec(text) ?? /^[ \t]*\/\*{6,}(?:(?!\*\/)[\s\S])*\*\/[ \t]*\r?\n?$/.exec(text);
if (banner)
text = text.slice(0, banner.index).trimEnd();
return text;
}
export function extensionForLanguage(language: string): string {
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "node --experimental-strip-types tests/validate.ts",
"test:bugfix": "node --experimental-strip-types --loader ./tests/loader.mjs tests/bugfix-regressions.ts",
"test:parser": "node --experimental-strip-types --loader ./tests/loader.mjs tests/parser-deep.ts",
"test:mapping": "node --experimental-strip-types --loader ./tests/loader.mjs tests/api-mapping.ts",
"test:lib": "node --experimental-strip-types --loader ./tests/loader-lib.mjs tests/lib-coverage.ts",
"test:ipc": "node --experimental-strip-types --loader ./tests/loader.mjs tests/ipc-contract.ts",
"test:stores": "node --experimental-strip-types --loader ./tests/loader-stores.mjs tests/stores.ts",
"test:offline": "npm run test && npm run test:bugfix && npm run test:parser && npm run test:mapping && npm run test:lib && npm run test:ipc && npm run test:stores",
"test:poller": "node --experimental-strip-types tests/poller.ts",
"test:codecheck": "node --experimental-strip-types tests/codecheck.ts",
"test:diff": "node --experimental-strip-types tests/diff.ts",
Expand Down
2 changes: 1 addition & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ onMounted(() => {
<Status v-else-if="view === 'status'" />
<Discussion v-else-if="view === 'discussion'" />
<Mail v-else-if="view === 'mail'" />
<Submit v-else-if="view === 'submit'" :problem-id="navigation.activeProblemId.value" />
<Submit v-else-if="view === 'submit'" :problem-id="navigation.activeProblemId.value" :contest-id="navigation.activeContest.value?.contestId" :contest-pid="navigation.activeContest.value?.contestPid" />
<Compare v-else-if="view === 'compare'" />
<Contests v-else-if="view === 'contests'" />
<Monthly v-else-if="view === 'monthly'" />
Expand Down
7 changes: 6 additions & 1 deletion src/components/NotificationToasts.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue';
import { Bell, Mail, X } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { pollNotices } from '@/services/api';
import { pollNotices, markBbsMentionRead, markMailMentionRead } from '@/services/api';
import { createPoller } from '@/lib/poller';
import { useSession } from '@/stores/session';
import { useNavigation } from '@/stores/navigation';
import { useMailTarget } from '@/stores/mail-target';
import type { CommunityNotification } from '@/shared/types';
const POLL_INTERVAL_MS = 60000;
const session = useSession();
const navigation = useNavigation();
const mailTarget = useMailTarget();
const notices = ref<CommunityNotification[]>([]);
const seen = new Set<string>();
let seenUser = '';
Expand Down Expand Up @@ -43,9 +45,12 @@ function open(notice: CommunityNotification): void {
if (notice.kind === 'bbs') {
if (notice.postId) navigation.viewThread(notice.postId, notice.pageNumber || 1);
else navigation.navigate('discussion');
void markBbsMentionRead(notice.mentionId).catch(() => {});
}
else {
if (notice.mailUser) mailTarget.setTarget(notice.mailUser);
navigation.navigate('mail');
void markMailMentionRead(notice.mentionId).catch(() => {});
}
dismiss(notice);
}
Expand Down
56 changes: 40 additions & 16 deletions src/pages/Contests.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">import { computed, onMounted, ref } from 'vue';
import { useErrorToast } from '@/composables/useErrorToast';
import { ChevronLeft, ChevronRight, RefreshCw, Trophy, Play } from '@lucide/vue';
import { ChevronLeft, ChevronRight, RefreshCw, Trophy, Play, Send } from '@lucide/vue';
import ContestReplay from '@/components/ContestReplay.vue';
import { Button } from '@/components/ui/button';
import UserChip from '@/components/UserChip.vue';
Expand All @@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { fetchContestDetail, fetchContestList } from '@/services/api';
import { useNavigation } from '@/stores/navigation';
import { usePanel } from '@/stores/panel';
import type { ContestDetail, ContestSummary } from '@/shared/types';
import type { ContestDetail, ContestProblemSummary, ContestSummary } from '@/shared/types';
const navigation = useNavigation();
const panel = usePanel();
const contests = ref<ContestSummary[]>([]);
Expand Down Expand Up @@ -108,6 +108,18 @@ function openProblem(problemId: number | null): void {
if (problemId !== null)
navigation.viewProblem(problemId);
}
function submitProblem(problem: ContestProblemSummary): void {
const current = detail.value;
if (!current || problem.problemId === null)
return;
const index = current.problems.indexOf(problem);
if (index < 0)
return;
navigation.submitProblem(problem.problemId, {
contestId: current.contest.contestId,
contestPid: index,
});
}
function problemStatusClass(status: string | null | undefined): string {
const value = String(status ?? '').toUpperCase();
if (value === 'AC' || value === 'ACCEPTED') {
Expand Down Expand Up @@ -226,6 +238,7 @@ onMounted(() => {
<span>#{{ contest.contestId }}</span>
<span>{{ contest.startTime ?? '—' }}</span>
<span>→ {{ contest.endTime ?? '—' }}</span>
<span v-if="contest.creator">创建者 {{ contest.creator }}</span>
</span>
</span>
</button>
Expand Down Expand Up @@ -281,24 +294,35 @@ onMounted(() => {
</div>

<div v-else-if="detail.problems.length" class="divide-y rounded-xl border">
<button
<div
v-for="problem in detail.problems"
:key="problem.num"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
@click="openProblem(problem.problemId)"
class="flex w-full items-center gap-3 px-4 py-2.5 transition-colors hover:bg-accent/50"
>
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md border font-mono text-xs"
:class="problemStatusClass(problem.status)"
:title="problemStatusLabel(problem.status)"
<button class="flex min-w-0 flex-1 items-center gap-3 text-left" @click="openProblem(problem.problemId)">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md border font-mono text-xs"
:class="problemStatusClass(problem.status)"
:title="problemStatusLabel(problem.status)"
>
{{ problem.num }}
</span>
<span class="min-w-0 flex-1 truncate text-sm">{{ problem.title }}</span>
<span v-if="problem.problemId" class="shrink-0 font-mono text-xs text-muted-foreground">
#{{ problem.problemId }}
</span>
</button>
<Button
v-if="problem.problemId"
variant="outline"
size="sm"
class="shrink-0"
@click="submitProblem(problem)"
>
{{ problem.num }}
</span>
<span class="min-w-0 flex-1 truncate text-sm">{{ problem.title }}</span>
<span v-if="problem.problemId" class="shrink-0 font-mono text-xs text-muted-foreground">
#{{ problem.problemId }}
</span>
</button>
<Send />
提交
</Button>
</div>
</div>

<div
Expand Down
6 changes: 3 additions & 3 deletions src/pages/ExportAcCode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Download, FolderOpen, Loader2 } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { exportAcCode, fetchProblemset, fetchUserProfile } from '@/services/api';
import { exportAcCode, fetchAllProblems, fetchUserProfile } from '@/services/api';
import { useSession } from '@/stores/session';
import type { ExportResult, ProblemsetEntry } from '@/shared/types';
const LANGUAGE_FILTERS = ['C++', 'C', 'Pascal'];
Expand All @@ -29,9 +29,9 @@ async function load(): Promise<void> {
const username = session.state.user?.username;
if (!username) throw new Error('请先登录');
const profile = await fetchUserProfile(username);
const first = await fetchProblemset({ page: 1 }, true).catch(() => null);
const all = await fetchAllProblems(true).catch(() => null);
if (current !== token) return;
const titles = new Map(first?.value.problems.map(problem => [problem.id, problem.title]) ?? []);
const titles = new Map(all?.problems.map(problem => [problem.id, problem.title]) ?? []);
problems.value = profile.problems.map(problem => ({ id: problem.id, title: titles.get(problem.id) ?? `题目 ${problem.id}`, url: problem.url, solved: true, accepted: null, submissions: problem.attempts, acceptanceRate: null }));
}
catch (caught) {
Expand Down
Loading
Loading