diff --git a/web/src/App.tsx b/web/src/App.tsx index 1b3151e5..8447cc85 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { Navigate, createBrowserRouter, RouterProvider, useRouteError } from 'react-router-dom'; import { AppShell } from '@/app/AppShell'; import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot'; import { GlobalToastRoot } from '@/app/GlobalToastRoot'; +import { Icon } from '@/app/Icon'; import { KickedNoticeRoot } from '@/app/KickedNoticeRoot'; import { RequireAuth } from '@/app/RequireAuth'; import { SecurityChallengeModal } from '@/app/SecurityChallengeModal'; @@ -34,6 +35,30 @@ import { SystemMessagesView } from '@/features/system/SystemMessagesView'; const queryClient = new QueryClient(); +/** + * Without this, an uncaught render-time exception ANYWHERE under `` (any page, any one + * bad field in server data) falls through to react-router's own built-in default error boundary -- + * real, not hypothetical (confirmed live: `node_modules/react-router` DOES implicitly wrap the + * outermost matched route the way this codebase never configured one to). That default shows + * "Unexpected Application Error!" plus the raw error message/stack to the end user and replaces the + * WHOLE logged-in shell, sidebar included, with no way back except a full page reload. Attached to + * AppShell (not each leaf route) so it covers every authenticated page with one component, and low + * enough that `RequireAuth`/login/tenant-entry are never affected by a bug in, say, 收藏. + */ +function RouteErrorBoundary() { + const error = useRouteError(); + if (import.meta.env.DEV) console.error(error); + return ( +
+ +

页面出现异常,请刷新重试

+ +
+ ); +} + const router = createBrowserRouter([ { path: 'tenant', element: }, { path: 'login', element: }, @@ -45,6 +70,7 @@ const router = createBrowserRouter([ children: [ { element: , + errorElement: , children: [ { index: true, element: }, { diff --git a/web/src/features/favorites/FavoritesLayout.tsx b/web/src/features/favorites/FavoritesLayout.tsx index 4f484ed3..cfca288b 100644 --- a/web/src/features/favorites/FavoritesLayout.tsx +++ b/web/src/features/favorites/FavoritesLayout.tsx @@ -144,7 +144,19 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite // Only show a second line when it says something the title line doesn't already -- a // hostname alongside a real title/url is useful, but repeating `text` under itself when // that's ALSO what the title line fell back to would just be the same line twice. - const subtitle = item.snapshot.url ? new URL(item.snapshot.url).hostname : title === text ? '' : text; + // `new URL()` throws on anything that isn't a well-formed absolute URL -- `url` can come from + // another client's own Drafty implementation or the backend's own extraction logic (docs/ + // IM-前后端对接文档.md §7.6), neither of which this codebase controls the shape of. A single + // malformed value here must not crash the whole render (there's no error boundary around this + // list short of react-router's own default one, which would replace the entire logged-in UI). + let subtitle = title === text ? '' : text; + if (item.snapshot.url) { + try { + subtitle = new URL(item.snapshot.url).hostname; + } catch { + // fall through to the plain-text fallback computed above + } + } return (
{item.snapshot.thumbnail_file_id ? ( diff --git a/web/src/features/groups/GroupFilesModal.tsx b/web/src/features/groups/GroupFilesModal.tsx index 5d75d7c1..6b415868 100644 --- a/web/src/features/groups/GroupFilesModal.tsx +++ b/web/src/features/groups/GroupFilesModal.tsx @@ -219,7 +219,13 @@ export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupF isPdf={pdf} open={openMenuId === f.group_file_id} onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))} - onClose={() => setOpenMenuId(null)} + // Functional update, not a plain `setOpenMenuId(null)` -- this fires from + // useClickOutside's window-level listener, which runs AFTER another row's own + // onToggle already ran in the same click (React's synthetic onToggle always + // precedes a window listener in the bubble order). An unconditional null would + // clobber that other row's freshly-opened state; only clear if this row is + // still the one actually open. + onClose={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : cur))} onDownload={() => downloadFile(f)} onOpen={() => openFileInNewTab(f)} onDelete={async () => { diff --git a/web/src/features/groups/GroupMembersModal.tsx b/web/src/features/groups/GroupMembersModal.tsx index db356a3d..a552e52d 100644 --- a/web/src/features/groups/GroupMembersModal.tsx +++ b/web/src/features/groups/GroupMembersModal.tsx @@ -72,6 +72,11 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia const [inviting, setInviting] = useState(initialView === 'invite'); const [error, setError] = useState(''); const [outcomeByUser, setOutcomeByUser] = useState>({}); + // Per-row "移出中" tracking -- `removeMutation` is one shared mutation instance, so its own + // `.variables`/`.isPending` only ever reflects the MOST RECENT call. Removing member A then quickly + // also removing member B would make A's button-disabled/"移出中…" state resolve back to normal the + // moment B's click fires, even though A's own request may still be in flight. + const [removingIds, setRemovingIds] = useState>(new Set()); useEscapeKey(onClose); const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) }); @@ -201,16 +206,24 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia {canRemoveMember(myUserId, myRole, m) ? (