Skip to content
Closed
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
69 changes: 68 additions & 1 deletion electron/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,81 @@ export function parseUserProfile(html: string, username: string): UserProfile {
const solvedMatch = text.match(/解决\s*(\d+)/);
const solvedTotal = solvedMatch ? Number.parseInt(solvedMatch[1], 10) : null;
const problems = parseSolvedProblems(html);
const rows = root.querySelectorAll('#statics tr').map((row) => row.querySelectorAll('td'));
const stats = rows
.filter((cells) => cells.length > 1 &&
!/^(解决|solved)/i.test(normaliseText(cells[0].textContent)) &&
cells[1].querySelector('a[href*="jresult="]'))
.map((cells) => ({
label: normaliseText(cells[0].textContent),
count: parseCount(cells[1].textContent),
result: Number(cells[1].querySelector('a[href*="jresult="]')?.getAttribute('href')?.match(/[?&]jresult=(\d+)/)?.[1]),
}))
.filter((entry) => Number.isFinite(entry.result) && entry.count !== null);
const resultStats = stats.map(({ label, count }) => ({ label, count: count ?? 0 }));
const submissionTotal = findCount(rows, /^(提交|提交次数|submissions?)$/i);
const acceptedTotal = findCount(rows, /^(正确|通过|accepted)$/i);
const solved = solvedTotal ?? problems.length;
const failures = stats
.filter((entry) => entry.result >= 5 && entry.result <= 10)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

electron/parsers.ts:392 里 11 对应的是 Compile Error。这里的 >= 5 && <= 10 会把编译错误排除在失败数之外,导致 calculateRating 里的成功率偏高(把 CE 当成了成功提交)。若意图是统计所有非 AC 的终态,区间应改为 <= 11,同时继续排除 0–3 的 pending 状态。

Fix this →

.reduce((total, entry) => total + (entry.count ?? 0), 0);
const activity = parseActivity(root);
return {
username,
nick,
solvedTotal: solvedTotal ?? problems.length,
solvedTotal: solved,
submissionTotal: submissionTotal ?? 0,
acceptedTotal: acceptedTotal ?? 0,
rating: calculateRating(solved, failures, activity),
resultStats,
activity,
problems,
profileUrl: `${XMOJ_ORIGIN}/userinfo.php?user=${encodeURIComponent(username)}`,
};
}
function parseCount(value: string): number | null {
const match = normaliseText(value).replace(/,/g, '').match(/\d+/);
return match ? Number.parseInt(match[0], 10) : null;
}
function findCount(rows: ReturnType<ReturnType<typeof parse>['querySelectorAll']>[], label: RegExp): number | null {
for (const cells of rows) {
if (cells.length > 1 && label.test(normaliseText(cells[0].textContent)))
return parseCount(cells[1].textContent);
}
return null;
}
function parseActivity(root: ReturnType<typeof parse>): UserProfile['activity'] {
const script = root.querySelectorAll('script').find((node) =>
/\$\.plot\s*\(/.test(node.textContent) && /["']#submission["']/.test(node.textContent));
if (!script)
return [];
const series = [new Map<number, number>(), new Map<number, number>()];
for (const match of script.textContent.matchAll(/\bd([12])\.push\(\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]\s*\)\s*;?/g)) {
const timestamp = Number(match[2]);
const count = Number(match[3]);
if (!Number.isSafeInteger(timestamp) || timestamp <= 0 || timestamp > 8640000000000000 || !Number.isSafeInteger(count))
continue;
series[Number(match[1]) - 1].set(timestamp, count);
}
const timestamps = new Set([...series[0].keys(), ...series[1].keys()]);
return [...timestamps].sort((a, b) => a - b).map((timestamp) => ({
timestamp,
submissions: series[0].get(timestamp) ?? 0,
accepted: series[1].get(timestamp) ?? 0,
}));
}
function calculateRating(
solved: number,
failures: number,
activity: UserProfile['activity'],
): number {
if (solved === 0)
return 0;
const solveProgress = Math.min(1, Math.log1p(solved) / Math.log1p(2000));
const successRate = (solved + 4) / (solved + failures + 10);
const activityProgress = Math.min(1, activity.length / 365);
return Math.round(3000 * (0.7 * solveProgress + 0.2 * successRate + 0.1 * activityProgress));
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
Expand Down
5 changes: 4 additions & 1 deletion electron/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export async function fetchSource(http: HttpClient, solutionId: number): Promise
const text = response.body;
if (/<html|<body|<!DOCTYPE/i.test(text))
return null;
return text;
const source = text.replace(/\r?\n[ \t]*not-cached[ \t]*\s*$/i, '');
if (/^not-cached$/i.test(source.trim()))
return null;
return source;
}
export function extensionForLanguage(language: string): string {
const value = language.toLowerCase();
Expand Down
14 changes: 13 additions & 1 deletion src/components/AppSidebar.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
<script setup lang="ts">import { computed, ref } from 'vue';
import { BookText, CalendarDays, ChartNoAxesColumn, ChartPie, ChevronsUpDown, Download, FolderDown, House, Info, Loader2, LogIn, LogOut, Mail, MessagesSquare, RefreshCw, Settings, User, } from '@lucide/vue';
import { BookText, CalendarDays, ChartNoAxesColumn, ChartPie, ChevronsUpDown, Download, FolderDown, House, Info, Loader2, LogIn, LogOut, Mail, MessagesSquare, Pin, RefreshCw, Settings, User, } from '@lucide/vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu';
import { useSession } from '@/stores/session';
import { useNavigation, type ViewId } from '@/stores/navigation';
import { usePanel } from '@/stores/panel';
import { useToasts } from '@/stores/toasts';
import { useSidebar } from '@/components/ui/sidebar/utils';
import { checkAppUpdate, showAboutDialog, showUpdateDialog } from '@/services/update';
const session = useSession();
const navigation = useNavigation();
const panel = usePanel();
const sidebar = useSidebar();
const mainItems = [
{ id: 'home' as ViewId, title: '首页', icon: House },
{ id: 'problemset' as ViewId, title: '题库', icon: BookText },
Expand Down Expand Up @@ -54,6 +56,9 @@ async function handleLogout(): Promise<void> {
function handleSignIn(): void {
navigation.navigate('problemset');
}
function togglePinnedSidebar(): void {
sidebar.setOpen(!sidebar.pinnedOpen.value);
}
</script>

<template>
Expand Down Expand Up @@ -122,6 +127,13 @@ function handleSignIn(): void {
</DropdownMenuLabel>
<DropdownMenuSeparator />

<DropdownMenuItem @select="togglePinnedSidebar">
<Pin />
{{ sidebar.pinnedOpen.value ? '取消侧栏常驻' : '侧栏常驻' }}
</DropdownMenuItem>

<DropdownMenuSeparator />

<!-- The less-used views live here rather than in the main list. -->
<DropdownMenuItem
v-for="item in footerItems"
Expand Down
2 changes: 1 addition & 1 deletion src/components/NotificationToasts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function dismiss(notice: CommunityNotification): void {
}
function open(notice: CommunityNotification): void {
if (notice.kind === 'bbs') {
navigation.navigate('discussion');
navigation.openDiscussion(notice.postId, notice.pageNumber);
}
else {
navigation.navigate('mail');
Expand Down
20 changes: 11 additions & 9 deletions src/components/ui/sidebar/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const props = withDefaults(defineProps<SidebarProps>(), {
variant: "sidebar",
collapsible: "offcanvas",
});
const { isMobile, state, openMobile, setOpenMobile, setOpen } = useSidebar();
const { isMobile, state, openMobile, pinnedOpen, setOpenMobile, setOpen } = useSidebar();
let closeTimer: number | undefined;
const pinned = ref(0);
const pointerInside = ref(false);
Expand Down Expand Up @@ -45,7 +45,7 @@ function onMouseEnter(event?: MouseEvent): void {
}
function onMouseLeave(event?: MouseEvent): void {
pointerInside.value = false;
if (pinned.value > 0)
if (pinned.value > 0 || pinnedOpen.value)
return;
if (isPointerOverSidebar(event ? { x: event.clientX, y: event.clientY } : undefined))
return;
Expand All @@ -54,11 +54,11 @@ function onMouseLeave(event?: MouseEvent): void {
function scheduleClose(delay = 160): void {
cancelClose();
closeTimer = window.setTimeout(() => {
if (pinned.value > 0)
if (pinned.value > 0 || pinnedOpen.value)
return;
if (isPointerOverSidebar(lastPointer) || pointerInside.value)
return;
setOpen(false);
setOpen(false, false);
}, delay);
}
function pin(): void {
Expand All @@ -81,13 +81,13 @@ function onDocumentPointerDown(event: PointerEvent): void {
const closest = (target as HTMLElement).closest?.('[data-slot="dropdown-menu-content"], [data-slot="tooltip-content"]');
if (closest)
return;
if (pinned.value > 0 && pointerInside.value)
if (pinnedOpen.value || (pinned.value > 0 && pointerInside.value))
return;
setOpen(false);
setOpen(false, false);
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape')
setOpen(false);
if (event.key === 'Escape' && !pinnedOpen.value)
setOpen(false, false);
}
const panelRef = ref<HTMLElement | null>(null);
let lastPointer: { x: number; y: number } | undefined;
Expand Down Expand Up @@ -120,6 +120,8 @@ onBeforeUnmount(() => {
document.removeEventListener('mousemove', trackPointer, true);
});
onMounted(() => {
if (pinnedOpen.value)
setOpen(true, false);
document.addEventListener('pointerdown', onDocumentPointerDown, true);
document.addEventListener('keydown', onKeydown);
document.addEventListener('mousemove', trackPointer, true);
Expand Down Expand Up @@ -191,7 +193,7 @@ onMounted(() => {
'bg-sidebar text-sidebar-foreground border-sidebar-border',
// Only the visible width animates; the panel's left edge and its inner
// layout stay put, so nothing inside it jumps during the transition.
'transition-[width] duration-300 ease-in-out',
'transition-[width] duration-150 ease-out',
side === 'left' ? 'left-0 border-r' : 'right-0 border-l',
state === 'collapsed'
? 'w-(--sidebar-width-icon)'
Expand Down
24 changes: 22 additions & 2 deletions src/components/ui/sidebar/SidebarProvider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,31 @@ const emits = defineEmits<{
}>();
const isMobile = useMediaQuery("(max-width: 768px)");
const openMobile = ref(false);
const SIDEBAR_OPEN_KEY = "xmoj.sidebar.open.v1";
function initialOpen(): boolean {
try {
const saved = localStorage.getItem(SIDEBAR_OPEN_KEY);
return saved === null ? props.defaultOpen : saved === "true";
}
catch {
return props.defaultOpen;
}
}
const open = useVModel(props, "open", emits, {
defaultValue: props.defaultOpen ?? false,
defaultValue: initialOpen(),
passive: (props.open === undefined) as false,
}) as Ref<boolean>;
function setOpen(value: boolean, _persist = true) {
const pinnedOpen = ref(initialOpen());
function setOpen(value: boolean, persist = true) {
open.value = value;
if (persist) {
pinnedOpen.value = value;
Comment on lines +37 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

setOpen 的默认 persist = true 会隐藏一个行为变化:toggleSidebar()(Ctrl+B 快捷键)调用的是 setOpen(!open.value),于是每按一次快捷键都会把 pinnedOpen 改成当前状态并写入 localStorage。结果是用快捷键“打开”侧栏会变成永久常驻,和悬停展开时显式传 false 的行为不一致(Sidebar.vue:43 等处)。

如果快捷键只应切换显隐,应传 false;如果确实想让 Ctrl+B 设置常驻,请确认这是预期产品行为,并考虑在代码或 UI 上说明。

try {
localStorage.setItem(SIDEBAR_OPEN_KEY, String(value));
}
catch {
}
}
}
function setOpenMobile(value: boolean) {
openMobile.value = value;
Expand All @@ -42,6 +61,7 @@ const state = computed(() => open.value ? "expanded" : "collapsed");
provideSidebarContext({
state,
open,
pinnedOpen,
setOpen,
isMobile,
openMobile,
Expand Down
1 change: 1 addition & 0 deletions src/components/ui/sidebar/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export const [useSidebar, provideSidebarContext] = createContext<{
state: ComputedRef<"expanded" | "collapsed">;
open: Ref<boolean>;
pinnedOpen: Ref<boolean>;
setOpen: (value: boolean, persist?: boolean) => void;
isMobile: Ref<boolean>;
openMobile: Ref<boolean>;
Expand Down
8 changes: 7 additions & 1 deletion src/pages/Discussion.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import AnnouncementBody from '@/components/AnnouncementBody.vue';
import UserChip from '@/components/UserChip.vue';
import { createPost, createReply, fetchBoards, fetchPost, fetchPosts } from '@/services/api';
import { useSession } from '@/stores/session';
import { useNavigation } from '@/stores/navigation';
import type { BbsBoard, BbsPost, BbsPostDetail } from '@/shared/community';
const session = useSession();
const navigation = useNavigation();
const boards = ref<BbsBoard[]>([]);
const selectedBoard = ref(-1);
const posts = ref<BbsPost[]>([]);
Expand Down Expand Up @@ -212,7 +214,11 @@ function formatTime(value?: number): string {
const replies = computed(() => thread.value?.Reply ?? []);
onMounted(async () => {
await loadBoards(true);
await loadPosts();
const target = navigation.consumeDiscussionTarget();
if (target)
await openThread(target.postId, target.page);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

这里只在 onMounted 时消费一次 discussionTarget,但 App.vue 是用 v-else-if="view === 'discussion'" 渲染 Discussion 的:当用户已经停留在讨论页(比如正在看另一个帖子)时,点击 BBS 通知只会把 currentView 重新赋值为 'discussion',组件不会重新挂载,onMounted 不会再执行,因此点击“看起来没反应”。

更糟的是 target 会留在 store 里,等用户先切到别的页面再切回讨论页时被消费,意外打开之前那条通知对应的帖子。

建议把 pending target 以响应式 ref 暴露出来,在 Discussion 内 watch 并在消费后清空;或者让 openDiscussion 通过已注册的 handler 直接调用 openThread,而不是依赖挂载时机。

Fix this →

else
await loadPosts();
});
</script>

Expand Down
Loading
Loading