Hardcoded hex colors (#1677ff, #91caff, #e6f4ff) are inconsistent with the rest of the admin app, which uses CSS design tokens (e.g. `var(--ink)`, `var(--line)`, `var(--blue)` in attendance.css). If the antd theme or dark mode is customized elsewhere, this component will visually diverge and won't respond to theme changes. Consider defining/using theme tokens (or `var(--ant-color-primary)`, `var(--ant-color-primary-bg)` etc.) instead of hardcoding colors.

.editable-cell--enabled:not(.editable-cell--editing):hover {
  border-color: #91caff;
  background: #e6f4ff;
}


The display container sets `min-width: 0; width: 100%` but never constrains its content. Long cell values (e.g. long text/tags) will force the table column to expand or overflow the cell instead of truncating, which can break table layout. Consider adding `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;` (or `word-break`) to the display span so content stays bounded while still showing the undo button.

.editable-cell-display {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.editable-cell-display {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  width: 100%;
}


The undo button only provides a hover indicator (`text-decoration: underline`) but no `:focus-visible` style, and the editable cell's hover affordance (`border-color`/`background`) has no keyboard-equivalent focus style either. Keyboard/touch users get no visible indication of interactivity. Add a `:focus-visible` rule (e.g. outline) for the undo button and the enabled cell.

.editable-cell-undo:hover {
  text-decoration: underline;
}


`!important` on `.editable-cell-dropdown` is a code smell and makes overrides hard to trace; it may also conflict if `popupMatchSelectWidth={false}` is used (dropdown width no longer tracks the trigger). Prefer a more specific selector or an antd token/`styles` prop to set the dropdown width without `!important`.

.editable-cell-dropdown {
  min-width: 140px !important;
}


Removing `outline` and `box-shadow` on `:focus` (including `:focus-visible`) completely removes the visible focus indicator for keyboard users, which is an accessibility violation (WCAG 2.4.7). If the goal is only to suppress the default browser outline in favor of a custom style, keep `:focus-visible` styles instead of killing them entirely — e.g. `outline: none` only on mouse-driven `:focus` but provide an equivalent visible focus ring for `:focus-visible`.

.ai-chat-composer .ant-sender-input:focus:not(:focus-visible) {
  outline: none;
  box-shadow: none;
}

.ai-chat-composer .ant-sender-input:focus,
.ai-chat-composer .ant-sender-input:focus-visible,
.ai-chat-composer .ant-sender-input:focus-within {
  outline: none;
  box-shadow: none;
}


`.ai-chat-hover-actions` is hidden with `opacity: 0` + `pointer-events: none`, but it stays in the DOM and remains in the keyboard tab order, so interactive elements inside (copy/edit buttons) can be focused while invisible — screen readers will also announce hidden controls. Also, on touch devices there is no hover, and `.ant-bubble` is not focusable, so the `:focus-within` fallback can never trigger, making these actions unreachable. Use `visibility: hidden` when hidden (which removes it from the a11y tree and tab order) and `visibility: visible` on hover/`focus-within`, and/or make the bubble itself focusable for touch/keyboard access.

.ai-chat-hover-actions {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 3px;
  background: rgba(255, 255, 255, 0.94);
  border: 1px solid #eceef2;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
  opacity: 0;
  transform: translateY(-3px);
  transition:
    opacity 0.15s ease,
    transform 0.15s ease;
  pointer-events: none;
}


Using `!important` to override the margin is fragile and makes later overrides harder; prefer a higher-specificity selector or an antd token/`styles` prop instead of `!important`.

.ai-chat-review-card__summary {
  margin: 4px 0 8px !important;
  font-size: 12px;
}


On mobile the sidebar becomes `position: absolute`, so `flex-basis` no longer affects its layout and only `width` matters — but `width` is not transitioned (only `flex-basis` is). The result is that opening/closing the drawer on small screens jumps instantly instead of animating, inconsistent with the desktop behavior. Add a `width` transition (e.g. `transition: flex-basis 180ms ease, width 180ms ease;`) so mobile matches.

  .ai-chat-sidebar {
    position: absolute;
    z-index: 2;
    inset: 0 auto 0 0;
    box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);
    transition: width 180ms ease;
  }

@media (max-width: 575px) {
  .ai-chat-sidebar {
    position: absolute;
    z-index: 2;
    inset: 0 auto 0 0;
    box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);
  }


The ellipsis truncation is bound to `span:last-child` inside the card title, which is fragile and structure-dependent. If a title is plain text (no span wrapper) the rule silently does nothing; if the last child happens to be a status tag/badge/icon rather than the text, the overflow/ellipsis is applied to the wrong element. Consider introducing a dedicated class (e.g. `.exam-card-title-text`) for the truncatable text and applying `min-width: 0` explicitly to it, instead of relying on `:last-child`.

.exam-card .ant-card-head-title span:last-child {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}


These rules target generic elements (`> div`, `span`) inside `.exam-progress`, so they may unintentionally restyle Ant Design Progress internals (the `.ant-progress` root div and the `.ant-progress-text` percentage span are both matched). If the row markup isn't exactly `<div><span>label</span><Progress/></div>`, the `display:flex / space-between` can distort the progress bar layout and the percentage text gets greyed. `margin-bottom: 10px` is also applied to the last row, adding trailing space. Scope these styles to an explicit row class (e.g. `.exam-progress-row` / `.exam-progress-row span`) so they only affect intended elements.

.exam-meta,
.exam-progress > div {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 10px;
}

.exam-meta span,
.exam-progress span {
  color: rgba(0, 0, 0, 0.55);
}


Accessibility: `#ff4d4f` on a white background has a contrast ratio of roughly 3.3:1, below the WCAG AA 4.5:1 threshold for normal-size text. For a destructive-action label this may be hard to read, especially at small font sizes. Consider a darker danger tone (e.g. `#cf1322`) for text, while keeping the brighter red for borders/backgrounds.

.exam-purge-action {
  color: #ff4d4f;
}


This later media block overrides the earlier mobile `.route-dock` rule (`height: 42px; padding: 5px 8px`) with `padding: 6px 8px` but never adjusts the height. With the global `box-sizing: border-box` (and the 1px `border-bottom`), the content area becomes 42 - 6 - 6 - 1 = 29px, while `.route-dock .ant-tabs` / `.ant-tabs-tab` remain 32px tall. Since `.route-dock` has `overflow: hidden`, the bottom ~3px of the tabs (active-state border-radius/shadow) gets clipped. Keep `padding: 5px 8px` or increase the height to 44px to match the 32px tabs.

  .route-dock {
    height: 44px;
    padding: 6px 8px;
  }

  .route-dock {
    padding: 6px 8px;
  }


This is the third `@media (max-width: 575px)` block in the file, and it silently overrides earlier rules with the same specificity: the earlier `.route-dock .ant-tabs-tab { min-width: 104px; }` and `.route-dock { height: 42px; padding: 5px 8px; }` become dead rules once this block wins the cascade. Editing the earlier block would have no visible effect, which is error-prone. Consolidate all `max-width: 575px` rules into a single media block (or one shared source of truth) so the effective values are unambiguous.

  .route-dock .ant-tabs-tab {
    min-width: 88px;
    max-width: 160px;
    padding: 0 8px 0 10px !important;
  }


This pseudo-element injects the text '表格可左右滑动查看' above every table on mobile, even tables that fit their container and never need horizontal scrolling. It also exposes the hint to screen readers on all tables (pseudo-element content is read aloud). Gate it behind an explicit modifier class (e.g. only when a `.table-overflow-hint` class is added) or only render it when the table actually overflows.

  .ant-table-wrapper::before {
    content: '表格可左右滑动查看';


`100dvh` is unsupported in older browsers (Chrome < 108, Safari < 15.4, Firefox < 101), so this `min-height` (and the same pattern in `.ant-modal .ant-modal-body { max-height: calc(100dvh - 180px) }`) is silently dropped on those engines, leaving the layout without the intended sizing. Add a `100vh` fallback line before the `dvh` declaration so older browsers still get a sensible value.

  .app-content {
    min-height: calc(100vh - 72px);
    min-height: calc(100dvh - 72px);
    margin: 8px !important;
    padding: 12px !important;
    border-radius: 10px !important;
  }

  .app-content {
    min-height: calc(100dvh - 72px);
    margin: 8px !important;
    padding: 12px !important;
    border-radius: 10px !important;
  }


`position: sticky` on the topbar will not work: the nearest scroll container for the sticky element is `.student-attendance-center`, because that element has `overflow: hidden` (any non-`visible` overflow creates a scroll container). Since that container never actually scrolls (its height grows with the content, min-height: calc(100vh - 64px)), the topbar will simply scroll away with the page instead of sticking to the top of the viewport. Fix by removing `overflow: hidden` from `.student-attendance-center` (use `overflow: clip` if clipping is only there to hide decoration overflow, since `clip` does not create a scroll container) or by moving the sticky element outside that ancestor.

.student-center-topbar {
  position: sticky;
  top: 0;
  z-index: 12;


`--student-line` (and the other `--student-*` custom properties) are only declared on `.student-attendance-center`. The period editor is used inside `.attendance-period-modal`, and Ant Design modals render through a portal into `document.body` by default, which breaks custom-property inheritance. If the modal is portaled, `var(--student-line)` is invalid at computed-value time and the whole `border` declaration is dropped, leaving the rows borderless. Note the neighboring rule `.attendance-correction-segment` already uses a fallback (`var(--student-line, #e1e8e5)`) — apply the same fallback here (and to the other `--student-*` references), or define these variables on a common ancestor that includes the modal.

.attendance-period-row {
  display: grid;
  grid-template-columns: 1.05fr 1fr 132px 132px 92px auto;
  gap: 10px;
  align-items: start;
  padding: 12px;
  border: 1px solid var(--student-line);


This file ends with a dangling documentation comment `/** 考勤元数据 */` (attendance metadata) but no corresponding schema was ever defined below it. This looks like unfinished work; either implement the schema it refers to or remove the comment to avoid implying a schema exists that doesn't.

/** 考勤元数据 */


`ganttRoomsSchema` is the only schema in this file that does not end with `.passthrough()`. All other schemas (classroomSchedule, dashboardStats, roomRanking, classAttendanceRanking, classroomOccupancies, classroomUtilStats) explicitly preserve unknown fields, indicating the API responses may carry extra properties. Without `.passthrough()`, any additional field returned by the API (e.g. a room `id` or extra occupancy metadata) is silently stripped when parsing, and the parsed value can diverge from the raw payload. Add `.passthrough()` to both the array item and the `occupancies` item objects for consistency and to avoid silent data loss.

export const ganttRoomsSchema = z.array(
  z
    .object({
      roomNumber: z.string(),
      occupancies: z.array(
        z
          .object({
            studentName: z.string(),
            studentId: z.union([z.string(), z.number()]).optional(),
            checkInDate: z.string(),
            checkOutDate: z.string().nullable(),
            billingStartDate: z.string().optional(),
            billingEndDate: z.string().nullable().optional(),
          })
          .passthrough(),
      ),
    })
    .passthrough(),
);


The `top` and `bottom` item schemas in `classAttendanceRankingSchema` are byte-for-byte identical. Extract the item schema into a shared constant (e.g. `const attendanceRankingItemSchema = z.object({...}).passthrough()`) and reuse it in both arrays to avoid duplicated definitions drifting apart.

    top: z.array(
      z
        .object({ className: z.string(), present: z.number(), total: z.number(), rate: z.number() })
        .passthrough(),
    ),


Hardcoded login URL detection with duplicated variants ('/auth/login' vs 'auth/login'). This is fragile: it breaks if the request URL includes query params, a trailing slash, or a deployment sub-path, and in those cases a failed login (401) would incorrectly trigger logout + redirect. Extract a normalized login-path constant (e.g. strip leading slash and query string) instead of hardcoding two string literals.

const isLoginRequest = err.config?.url === '/auth/login' || err.config?.url === 'auth/login';


Setting config.headers.Authorization without a null check can throw a TypeError if a caller passes a config whose headers is undefined/null. Add a defensive initialization before assigning.

      config.headers = config.headers ?? {};
      config.headers.Authorization = `Bearer ${token}`;


Hardcoded '/login' redirect via window.location.href causes a full page reload and breaks when the app is deployed under a sub-path (non-root base URL). Prefer router-based navigation and derive the login path from a shared constant or the app base URL (e.g. import.meta.env.BASE_URL) instead of hardcoding.


The envelope schema requires `data` to be a non-nullable `aiConfigSchema` and omits the optional `message` field, while the consuming page declares `ApiResponse<T> { success; data; message? }` (see pages/AiConfig/index.tsx). If the API ever returns an error envelope (`{ success: false, message: '...' }` without `data`) or a “no config saved yet” response (`{ success: true, data: null }`), `validateResponse` will throw a generic “接口字段 data 格式异常” and the page will treat it as a load failure instead of surfacing the server message/empty state. Consider making `data` nullable and declaring `message` as optional.

export const aiConfigEnvelopeSchema = z
  .object({
    success: z.boolean(),
    data: aiConfigSchema.nullable(),
    message: z.string().optional(),
  })
  .passthrough();

export const aiConfigEnvelopeSchema = z
  .object({ success: z.boolean(), data: aiConfigSchema })
  .passthrough();


This trailing `/** 导入任务 */` section header has no following schema — the file ends right after it, and no “import task” schema exists anywhere in the project (a codebase-wide search finds only this comment and unrelated `ImportWizard` UI code). This is a dangling/incomplete section marker that will confuse maintainers; either implement the intended import-task schema here or remove the comment.


This is the only schema in the file without `.passthrough()`. `validateResponse` (utils/validate.ts) calls `safeParse`, and Zod strips unknown keys by default — so any extra fields returned by `/expense-types` (e.g. `id`) are silently dropped, unlike every sibling schema here. Add `.passthrough()` for consistency and to avoid silent data loss.

export const expenseTypesSchema = z.array(
  z.object({ code: z.string(), name: z.string(), category: z.string() }).passthrough(),
);

export const expenseTypesSchema = z.array(
  z.object({ code: z.string(), name: z.string(), category: z.string() }),
);


Inconsistent nullability inside the weekly schedule item: `id` and `classId` are `z.number().nullable()` while `classroomId` is a required non-nullable `z.number()`. If an empty timetable cell is represented with a null `classroomId` (as the nullable `id`/`classId` suggest), validation will fail on every schedules-page fetch. Make `classroomId` nullable too, or confirm the API never returns null for it.

          id: z.number().nullable(),
          classId: z.number().nullable(),
          classroomId: z.number().nullable(),

          id: z.number().nullable(),
          classId: z.number().nullable(),
          classroomId: z.number(),


The `{ id: z.number(), name: z.string() }` option shape is duplicated at least eight times in this file (`organizationOptionSchema`, `classroomOptionSchema`, `classOptionSchema`, `examDetailSchema`, `billSchema.student`, `rentalSchema.classroom`/`lesseeOrganization`, `scheduleLookupsSchema.classes`, `studentFilterLookupsSchema.classes/teachers`). Extract a shared option schema and reuse it to keep definitions consistent and easier to maintain.

export const idNameOptionSchema = z
  .object({ id: z.number(), name: z.string() })
  .passthrough();

export const organizationOptionSchema = idNameOptionSchema;

export const organizationOptionSchema = z
  .object({ id: z.number(), name: z.string() })
  .passthrough();


Inconsistent response handling: unlike `getImportRun`, which validates the envelope via `validateResponse`/`importRunEnvelopeSchema`, this function (and `previewImportStep`/`commitImportStep`) returns `res.data` directly without checking `res.success`. If the backend reports a business failure as HTTP 200 with `{ success: false, message }`, these functions return whatever `data` holds (typically `undefined`) and drop the error message, so callers treat a failed create/preview/commit as success. Extract a shared helper that validates the envelope and throws when `success` is false (e.g., `unwrapEnvelope<T>(res: ApiEnvelope<T>): T`) and use it in all four functions for consistency.

  const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {
    onUploadProgress: (event) => {


The API base URL is hand-built here, hardcoding `http://localhost:${VITE_API_PORT || 3002}/api` in dev and duplicating the `baseURL: '/api'` already centralized in `api/index.ts`. The same literal is duplicated in `utils/download.ts` and the Classrooms page. If the Vite dev proxy target ever differs from `VITE_API_PORT`, or the app is accessed from a non-localhost host, the generated report/download link will silently point to the wrong server. Extract a shared `getApiBaseUrl()` helper (or reuse the axios instance's baseURL) and reuse it everywhere.

export function importErrorReportUrl(runId: string, stepKey?: string): string {
  const base = import.meta.env.PROD
    ? '/api'
    : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;


`permissionTree` and `permissionsTree` are two factories for the same resource but produce *different* keys (`['rbac','roles','permission-tree']` vs `['rbac','permissions','tree']`), so a query written against one would never be matched by an invalidation written against the other. Moreover, a codebase search shows the entire `queryKeys.rbac.*` namespace is currently unused (the Roles/Permissions pages don't reference these keys), i.e. dead code. Consolidate into a single, consistently-named key (and align the actual pages with it) to avoid a future cache-miss trap.

    permissionTree: () => ['rbac', 'roles', 'permission-tree'] as const,
    permissionsTree: () => ['rbac', 'permissions', 'tree'] as const,


`organizations.list()` produces exactly the same key as `organizations.all` (`['organizations']`), which contradicts the file's own documented convention (root key `all` for bulk invalidation vs scoped list keys). Consequences: invalidating `all` always also hits `list`/`options` and vice versa, and `setQueryData(organizations.list(), ...)` writes to the root key, so the list can never be invalidated/refreshed independently of the rest of the module — a cache-crosstalk hazard the header comment warns about. Give the list its own segment, e.g. `['organizations', 'list']`.

    all: ['organizations'] as const,
    list: () => ['organizations', 'list'] as const,

    all: ['organizations'] as const,
    list: () => ['organizations'] as const,


Same issue as `organizations.list`: `deposits.list()` returns `['deposits']`, identical to `deposits.all`, so list and root keys are indistinguishable and list invalidation/refetch always covers the whole module. This namespace is also currently unused, so it should be either removed or fixed to a distinct key.

  deposits: {
    all: ['deposits'] as const,
    list: () => ['deposits', 'list'] as const,

  deposits: {
    all: ['deposits'] as const,
    list: () => ['deposits'] as const,


`dateRange` (and similarly `period` in `dashboard.summary`, `asOf` in `rooms.visual`, `classroomIds` in `classSchedules.list`, etc.) is typed as `unknown`, which defeats type checking at every call site that builds these keys — a typo in the shape (e.g. `{start: x, end: y}` vs `{from, to}`) silently changes the cache key instead of failing to compile. Define a concrete `DateRange` type (e.g. `{ start: string; end: string }`) and reuse it across these factories.

schedule: (id: number, dateRange: { start: string; end: string }) => ['classes', 'schedule', id, dateRange] as const,

schedule: (id: number, dateRange: unknown) => ['classes', 'schedule', id, dateRange] as const,


The file ends with an orphaned comment `/** 教室排课总览 */` (classroom scheduling overview) but no corresponding schema is defined below it. Either the intended schema is missing from this file, or this is leftover dead comment. Implement the schema or remove the comment to avoid misleading future readers.

/** 教室排课总览 */


`.passthrough()` on both the response envelope and each array item, combined with `config: z.record(z.string(), z.unknown())`, silently accepts unknown keys and leaves all config values as untyped `unknown`. This largely defeats the purpose of response validation: a renamed/removed field (e.g. `verify` → `verified`) or a changed config shape would pass validation silently and only fail later when consumers cast the value (as seen in `IntegrationConfig/index.tsx`, which re-declares the shape as a generic and casts `config` to `DingTalkConfig`). Consider using `.strict()` for the known envelope/item shapes and a discriminated union on `type` with typed config, and export the inferred types (`z.infer<typeof ...>`) so consumers don't have to redeclare the contract.

        .passthrough(),
    ),
  })
  .passthrough();


This trailing `/** 考勤记录 */` (attendance record) comment is unrelated to import-run schemas and appears to be a leftover from copy-paste. It serves no purpose at the end of the file and should be removed.


Schema mismatch with the actual API response. The server's `/attendance-records/alerts` endpoint (`getAlerts` in attendance-report.service.ts) returns items shaped `{ studentId, studentName, className, type, count, lastDate }` (and the admin `AlertItem` type also declares `studentNo`), but this schema requires `id` and `message`, which the API never returns. Since these fields are required in Zod, `validateResponse` will throw for every alert item and the alerts query will always fail at runtime. Align the schema with the real response shape, e.g. include `studentId/studentName/studentNo/className/type/count/lastDate`.

.object({
    studentId: z.number(),
    studentName: z.string(),
    studentNo: z.string().nullable().optional(),
    className: z.string(),
    type: z.string(),
    count: z.number(),
    lastDate: z.string(),
  })

.object({ id: z.number(), type: z.string(), message: z.string() })


This comment ("学生档案聚合" / student profile aggregation) is misplaced: it sits before the attendance class-option/alerts/periods/schedule-option schemas, which have nothing to do with student profile aggregation. It looks like a leftover from a removed schema and is misleading — remove it or move it to the correct schema.

/** 学生档案聚合 */


Fail-open behavior: `getRequiredPermission` returns null for any pathname not registered in `PERMISSION_PAGES` (including subpaths not covered by the `matches` regexes, and paths with trailing slashes), and `canAccessPath` treats `required === null` as accessible. If this function is used by the route guard (not just menu rendering), any route that is forgotten to be registered — e.g. `/students/new`, `/students/123/edit`, or any future detail page — silently bypasses the permission check. Consider failing closed (`required === null` → return false) or making unknown paths require explicit registration, so new routes can't accidentally become public.

export function canAccessPath(pathname: string, permissions: readonly string[]): boolean {
  const required = getRequiredPermission(pathname);
  return required === null || permissions.includes(required);
}


Subpath coverage is narrow and inconsistent across entries: `/students` only matches `/students/{digits}/profile`, while `/classes` and `/exams` match `/{digits}`. Any other detail route (create/edit pages, non-numeric ids, trailing slashes) falls through to `getRequiredPermission` → null and is therefore allowed (see the fail-open behavior in `canAccessPath`). The three `matches` implementations are also duplicated; extract a shared helper (e.g. `baseOrDetail(path, regex)`) and make sure every real route under these prefixes is either matched or explicitly excluded.

  {
    path: '/students',
    permission: 'student:view',
    matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p),
  },


The accommodation domain inference doesn't cover all permissions that back menu entries in the accommodation section. `bill:view` (账单管理), `deposit:view` (押金管理), and a `room:view`-only grant (房间管理) never add the 'accommodation' domain, so a user granted only these permissions will never see the 住宿运营 section and those menus become unreachable. For consistency with the academic/classroom/system rules, add the missing triggers, e.g. `|| permissions.includes('bill:view') || permissions.includes('deposit:view')` (and decide whether `room:view` alone should count).

  if (
    (permissions.includes('room:view') &&
      (permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||
    permissions.includes('wallet:view') ||
    permissions.includes('bill:view') ||
    permissions.includes('deposit:view')
  ) {
    normalized.add('accommodation');
  }

  if (
    (permissions.includes('room:view') &&
      (permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||
    permissions.includes('wallet:view')
  ) {
    normalized.add('accommodation');
  }


This branch contradicts the comment above it ("业务域按能力累加" — domains accumulate by capability). If a user has an administrative domain (e.g. academic/system) AND holds `teacher-workspace:view`, the 'teacher' domain is deliberately withheld, so the 教学工作 section (今日教学) becomes inaccessible to them even though they have the permission. Either remove the `!hasAdministrativeDomain` guard so teacher capability accumulates like the other domains, or document why teaching menus are intentionally exclusive to pure-teacher users.

  if (permissions.includes('teacher-workspace:view')) {
    normalized.add('teacher');
  }

  const hasAdministrativeDomain = [...normalized].some((role) => role !== 'teacher');
  if (!hasAdministrativeDomain && permissions.includes('teacher-workspace:view')) {
    normalized.add('teacher');
  }


Mapping 财务 (finance) to the 'accommodation' domain grants finance users the entire 住宿运营 section, including 房间管理 (room:view), 入住管理 (occupancy:view) and 住宿总览 — menus unrelated to billing. If the finance role is only meant to handle 费用/账单/余额/押金, this grants over-broad menu access. Please confirm this is intentional; otherwise introduce a dedicated finance domain (or role) and restrict which entries it can see.


Spreading `...section` and then casting with `as AppMenuItem` bypasses type checking: at runtime the object still carries `roles` (set to undefined) and the un-stripped `children` type, hiding real type errors. Prefer constructing the object explicitly without the cast, e.g. `{ key, label, icon, children }` or a type that allows `roles?: undefined`.

sections.push({ key: section.key, label: section.label, icon: section.icon, children });

sections.push({ ...section, children, roles: undefined } as AppMenuItem);


`collectMenuPaths` never returns an item's own key when it has children, so any future menu item that is both navigable and a parent would be silently omitted from the path list (and from `findRoleAwareLandingPath`). This is only correct today because all section keys are non-route group keys; consider collecting the parent key as well (e.g. `item.children ? [item.key, ...collectMenuPaths(item.children)] : [item.key]`) or documenting that section keys are intentionally excluded.

return items.flatMap((item) => (item.children ? collectMenuPaths(item.children) : [item.key]));


historyForms/historyReviews cast metadata directly into AiFormSchema/AiReviewSchema without validating shape, while historyCharts/historyArtifacts validate each item is an object with a string `id`. Malformed metadata (e.g. missing `id`, or an array stored in a2uiForm, which the Array.isArray guard silently drops) will either pass through invalid schemas or be silently discarded. Apply the same guard used in historyCharts (object check + string `id` check, and handle array metadata) so the four helpers behave consistently and the render layer never receives an invalid schema.

  if (!a2uiForm || typeof a2uiForm !== 'object' || Array.isArray(a2uiForm)) return undefined;
  return [a2uiForm as AiFormSchema];


The four history* helpers duplicate the same extraction pattern (read a metadata key, guard against non-object/array, wrap in array, optionally filter invalid items). Consider extracting a small generic helper (e.g. metadataList<T>(record, key, validator)) to eliminate the duplication and keep validation rules consistent in one place.

function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {


A completed user message loaded from history is mapped to status 'local' (via `record.role === 'user' ? 'local' : 'success'`). In the X chat SDK, 'local' typically denotes an optimistic message that has not yet been confirmed by the server; a history record with status 'completed' is server-persisted, so marking it 'local' may cause the UI to treat it as a local echo (e.g. show a sending indicator or alter available actions like retry/edit). Verify how the message list consumes the status — mapping completed user messages to 'success' may be more accurate.

return record.role === 'user' ? 'local' : 'success';


`data` is declared non-nullable even though `success` may be false. All callers in api.ts do `(await api.get<AiApiResponse<T>>(...)).data` without checking `success`, so when the backend returns `{ success: false }` (error contract without payload) the runtime value is `undefined` while TypeScript believes it is always `T` — a silent undefined-access risk. Make `data: T | null` (or have the API layer throw on `success: false`).

export interface AiApiResponse<T> {
  success: boolean;
  data: T | null;
  message?: string;
}

export interface AiApiResponse<T> {
  success: boolean;
  data: T;
  message?: string;
}


`AiChatMessage.reasoningContent` is required and non-nullable, but the persisted record (`AiMessageRecord.reasoningContent: string | null` and the server entity `ai-message.entity.ts`) treats it as nullable, and the mapper (`message-mappers.ts`) has to do `record.reasoningContent || ''` to bridge them. For user-role messages reasoning content semantically doesn't exist, so every construction site is forced to fabricate `''`. Align the type as `string | null` to match the record/server contract and avoid the lossy `|| ''` conversion.

export interface AiChatMessage {
  id?: number | string;
  role: AiMessageRole;
  content: string;
  reasoningContent: string;


`AiToolRunStatus` contains both `'error'` and `'failed'`, which are semantically the same failure state. The codebase already has to translate between them (`message-mappers.ts` and `sseReducer.ts` both map tool status `'error'` → `'failed'`), and `AiMessageRecord.status`/`AiChatMessageStatus` use different sets (`failed` vs `error`). Keeping overlapping states in one union forces mapping code and risks unhandled cases; consolidate to a single failure value.

export type AiToolRunStatus =
  | 'running'
  | 'success'
  | 'error'
  | 'failed'
  | 'denied'
  | 'not_found';


Inconsistent return contract: unlike `deleteAllConversations`/`deleteMessage` (which unwrap `.data`), `deleteConversation` returns the raw response body and types it as `void`. Since the axios response interceptor in `src/api/index.ts` already unwraps `res.data`, this method actually resolves to the full body (e.g. `{ success: true, data: ... }`), so the declared `void` type is misleading and any future caller expecting the unwrapped payload (like sibling methods) would get the wrong shape. `deleteAttachment` below has the same problem. Suggest unwrapping `.data` for consistency: `async (id: number) => (await api.delete<AiApiResponse<{ deleted: boolean }>>(`${basePath}/${id}`)).data`.

deleteConversation: (id: number) => api.delete<void>(`${basePath}/${id}`),


The `/api` prefix is hardcoded here but is already defined as the axios `baseURL` in `src/api/index.ts`. If the API base URL is ever changed (e.g. to `/api/v2`), the SSE stream URL will silently diverge from all REST calls made via the `api` instance. Consider deriving the prefix from a shared constant (or the api instance's `baseURL`) instead of duplicating the literal string.

export function conversationStreamUrl(id: number): string {
  return `/api${basePath}/${id}/stream`;
}


`page: 1` and `limit: 100` are hardcoded business numbers embedded inline. Consider extracting them to named constants (e.g. `const MESSAGE_PAGE_SIZE = 100`) used both for the initial request and the subsequent page requests, so the page size stays consistent and is self-documenting.


`sectionType` silently defaults to `'students'` for any section whose `type` is undefined and whose key doesn't match a known type/prefix. Unknown or newly-added section keys will be silently lumped into the 'students' group, which then skews `groupSections`/`groupStatus`/`dependencyHint` results (e.g., unknown sections counted as student data and blocking 'transfers' dependency resolution). Prefer an explicit handling for unmatched keys (throw/log a warning or return null and let the caller decide) instead of a silent fallback.

  const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));
  if (prefix) return prefix;
  throw new Error(`Unknown AI review section type for key: ${section.key}`);

  const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));
  return prefix ?? 'students';


`groupStatus` ignores the 'skipped' status entirely: a group whose sections are all skipped falls through to `'partial'` (部分完成), which is misleading since there is nothing actionable left in that group. Consider returning a 'skipped'-aware result (or at least 'pending') when `items.every(...)` is 'skipped', so the UI doesn't show '部分完成' for a fully-skipped group.

  if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';
  if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';
  if (items.every((item) => sectionStatus(item) === 'skipped')) return 'pending';
  return 'partial';

  if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';
  if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';
  return 'partial';


The same four-way union check on `section.type` is duplicated for `section.key` below. Extract a small type guard (e.g., `const isSectionType = (v: unknown): v is AiReviewSectionType => v === 'students' || v === 'rooms' || v === 'transfers' || v === 'checkins';`) and reuse it in both branches to avoid the duplicated literals drifting apart when a new section type is added.

  const isSectionType = (value: unknown): value is AiReviewSectionType =>
    value === 'students' || value === 'rooms' || value === 'transfers' || value === 'checkins';
  if (isSectionType(section.type)) {
    return section.type;
  }
  if (isSectionType(section.key)) {
    return section.key;
  }

  if (
    section.type === 'students' ||
    section.type === 'rooms' ||
    section.type === 'transfers' ||
    section.type === 'checkins'
  ) {
    return section.type;
  }
  const key = section.key as AiReviewSectionType;
  if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {
    return key;
  }


In the update branch, `{ ...item, ...next }` overwrites existing fields even when `next` explicitly carries `undefined`. Since `next` always sets `argumentsSummary: undefined` for non-running events and `resultSummary: undefined` for running events, a run started with `tool.started` (which captured `argumentsSummary`) will have its arguments summary wiped to `undefined` when `tool.completed`/`tool.failed` arrives — the completed payload rarely re-sends the original arguments. Result: the run loses its argument summary in the UI. Only override fields that actually have a value, or preserve the previous values.

  const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
  if (index === -1) return [...toolRuns, next];
  return toolRuns.map((item, itemIndex) =>
    itemIndex === index
      ? {
          ...item,
          ...next,
          argumentsSummary: next.argumentsSummary ?? item.argumentsSummary,
          resultSummary: next.resultSummary ?? item.resultSummary,
        }
      : item,
  );

  const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
  if (index === -1) return [...toolRuns, next];
  return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));


`message.reasoningContent +=` / `message.content +=` will produce a literal "undefined"/"null" prefix when the field is missing on `originMessage` at runtime (e.g. an assistant message loaded from a persisted record where `reasoningContent` can be `null`). Because `{ ...originMessage }` only copies existing keys, the accumulator may not be initialized. Use a null-safe accumulator instead.

  } else if (event === 'reasoning.delta') {
    message.retrying = null;
    message.reasoningContent = (message.reasoningContent ?? '') + (payload.delta ?? payload.reasoningContent ?? '');
  } else if (event === 'content.delta') {
    message.retrying = null;
    message.content = (message.content ?? '') + (payload.delta ?? payload.content ?? '');

  } else if (event === 'reasoning.delta') {
    message.retrying = null;
    message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
  } else if (event === 'content.delta') {
    message.retrying = null;
    message.content += payload.delta ?? payload.content ?? '';


The fallback `toolCallId` is derived from `toolRuns.length`, which changes after every upsert. If the SSE stream omits `toolCallId` for the same logical run across events (the very case this fallback exists for), `tool.started` creates "tool-0" but the matching `tool.completed`/`tool.failed` generates "tool-1" and appends a duplicate run instead of updating the original — leaving a permanently "running" entry. Prefer a stable key (e.g. toolName+messageId, or require `toolCallId` from the server) for the fallback.

const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;


`message.metadata = nested.metadata ?? message.metadata` replaces the entire metadata object. Any state accumulated during streaming — e.g. `a2uiImportWizard` set by the `ui.import_wizard` event via `{ ...message.metadata, a2uiImportWizard }` — will be silently dropped if the `message.completed`/`message.created` snapshot from the server doesn't include it. Merge instead of replacing to avoid losing transient metadata.

  message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
  message.metadata = { ...(message.metadata ?? {}), ...(nested.metadata ?? {}) };

  message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
  message.metadata = nested.metadata ?? message.metadata;


Nested ternary (prohibited by project review rules). `incoming ? [incoming] : []` is nested inside the false branch of `Array.isArray(incoming) ? ... : ...`, which hurts readability. Prefer explicit branches.

  let items: T[];
  if (Array.isArray(incoming)) {
    items = incoming;
  } else if (incoming) {
    items = [incoming];
  } else {
    items = [];
  }

const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];


`mergeArtifactIntoMessage` mutates the passed-in `message` in place (`message.uiArtifacts = ...`) and then returns the same reference. In `useAiChatMessageActions.tsx` it is invoked inside a React state updater (`setMessage(messageId, (info) => ({ message: mergeArtifactIntoMessage(info.message, artifact) }))`), which means: 1) the current state object is mutated directly, violating React immutability rules; 2) since the same `message` reference is returned, any component memoized on the message object reference will not re-render and the newly arrived artifact won't be displayed. Make the function immutable so it returns a new message object.

  return {
    ...message,
    uiArtifacts: mergeById<AiArtifactSchema>(message.uiArtifacts, artifact),
  };

  message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
  return message;


Unsound type predicate: `payload is AiFormSchema` (and the analogous predicates for AiReviewSchema/AiChartSchema) only checks truthiness and `typeof === 'object'`, yet asserts a full typed schema. Malformed payloads from the server (e.g., missing `fields`/`sections`/`rows`) will pass this guard and later crash rendering at runtime with no compile-time protection. At minimum, verify a distinguishing required field, or validate/normalize the payload before trusting the cast.

    .filter((payload): payload is AiFormSchema =>
      Boolean(payload) && typeof payload === 'object' && Array.isArray(payload.fields),
    );

.filter((payload): payload is AiFormSchema => Boolean(payload) && typeof payload === 'object');


The `suggestedStudent` shape in `JinshujuEntryRow` is structurally identical to `StudentOption` (id/name/phone/studentNo). Consider reusing `StudentOption` here to avoid duplicating the type definition — e.g. `suggestedStudent: StudentOption | null;` — so future field changes only need to be made in one place.

export interface JinshujuEntryRow {
  serialNumber: number;
  name: string;
  phone: string | null;
  suggestedStudent: {
    id: number;
    name: string;
    phone: string | null;
    studentNo: string | null;
  } | null;
}


The status union `'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired'` is duplicated verbatim in `ImportReceipt.runStatus`, while `ImportStepDetail.status` repeats a similar-but-different union (`'pending' ... 'skipped'`). This duplication risks silent drift between the types (e.g. steps support `'skipped'` while run/receipt statuses do not, and only run statuses have `'expired'`). Suggest extracting named union types (e.g. `type ImportRunStatus = ...; type ImportStepStatus = ...`) and referencing them from all interfaces so status sets stay consistent.

  status: 'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired';
  currentStepKey: ImportStepKey | null;


The login redirect path `/login` and the `login_expired_hint` sessionStorage key are hardcoded business constants that are coupled with the login page (per the code comment). If the route changes, this silently breaks the expired-session hint flow. Suggest extracting them into a shared constant (e.g., a routes/constants module) used by both this file and the login page.

    // 提示由登录页读取展示：直接弹 toast 会被跳转销毁
    sessionStorage.setItem('login_expired_hint', '1');
    useUserStore.getState().logout();
    usePermissionStore.getState().clearPermissions();
    window.location.href = '/login';


`crypto.randomUUID()` is undefined (and would throw a TypeError) in non-secure contexts (HTTP on non-localhost hosts) and in older browsers. Since `transformParams` runs before every request, a thrown error here would break the whole send flow whenever the caller doesn't supply `clientRequestId`. Add a safe fallback, e.g. `requestParams.clientRequestId || globalThis.crypto?.randomUUID?.() || \`${Date.now()}-${Math.random().toString(36).slice(2)}\``.

clientRequestId: requestParams.clientRequestId || crypto.randomUUID(),


Endpoint rewriting relies on fragile regexes with different anchors for different flows: regenerate/edit strip a trailing `/stream`, while form/review require the URL to match `/conversations/\d+/stream`. If the base stream URL format ever changes (e.g., adds a prefix, query params, or non-numeric ids), `String.replace` silently no-ops and the request is POSTed to the wrong endpoint with a rewritten body, producing hard-to-trace failures. Consider deriving the base path once (e.g., strip `/stream` via a shared helper) and building each sub-path (`/messages/{id}/regenerate/stream`, `/forms/{formId}/submit/stream`, ...) from it, so all four branches stay consistent.

requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.regenerateMessageId}/regenerate/stream`;


This block mutates refs (commandsRef / idRef) during the render phase to detect a surfaceId change. This is a render-phase side effect: it violates React's rules and is unsafe under StrictMode double-rendering/concurrent rendering (an aborted render can leave the refs mutated while the committed output still belongs to the old surface). It also leaves the returned `commands` state out of sync: the refs are cleared but the `commands` state array is not reset, so consumers keep receiving the previous surface's commands until the next `pushCommands` call. Move the reset into an effect (and clear the state in sync), e.g.:

```ts
const surfaceKey = surfaceId;
useEffect(() => {
  if (idRef.current !== surfaceKey) {
    commandsRef.current = [];
    idRef.current = surfaceKey;
    setCommands([]);
  }
}, [surfaceKey]);
```

Alternatively, remount the consumer component per surface with `key={surfaceId}`.

  const surfaceKey = surfaceId;
  if (idRef.current !== surfaceKey) {
    // 组件复用到新 surface 时，清空历史命令重新初始化
    commandsRef.current = [];
    idRef.current = surfaceKey;
  }


`run` does not reset `submitted` to false when a new submission starts. If the same hook instance is reused for a second submission (e.g., resubmit/edit after a successful submit) without an explicit `reset()` call, `submitted` stays `true` while the new request is in-flight, so consumers can observe both `submitting === true` and `submitted === true` simultaneously, producing inconsistent UI state. Reset it at the start of `run`.

    submittingRef.current = true;
    setSubmitting(true);
    setSubmitted(false);
    setError(null);

    submittingRef.current = true;
    setSubmitting(true);
    setError(null);


Example order contradicts the priority documented in this file. welcomeDescription resolves multi-domain users as 教师 > 住宿运营 > 教务 > 教室运营 > 系统/超管, but workflowPromptExamples pushes examples in the order academic → accommodation → classroom → teacher → system/super. Since getRoleDomains accumulates multiple domains for a single user (e.g., academic + accommodation + system via permissions), the suggested prompts will be shown in a different order than the welcome message priority implies. Recommend aligning the push order with the documented priority (teacher → accommodation → academic → classroom → system/super).

  if (domains.has('academic')) {
    examples.push(
      { label: '帮我从 Excel 导入学生并完成分班', description: '教学闭环' },


The domain keys 'teacher'/'accommodation'/'academic'/'classroom'/'system'/'super' are hardcoded here and duplicated in auth/menu-policy.ts (ROLE_ALIASES and getRoleDomains). These two files are tightly coupled through string literals: if a domain identifier is ever renamed, these `.has()` checks fail silently and users silently get the fallback welcome message / empty examples instead of a compile error. Consider exporting shared domain constants from menu-policy and importing them here.

export function welcomeDescription(roles: readonly string[], permissions: readonly string[]): string {
  const domains = getRoleDomains(roles, permissions);


formatFileSize stops at MB, so files ≥1GB (e.g., videos, scan archives) render as unwieldy numbers like "2048.0 MB" instead of "2.0 GB". Consider adding a GB branch (and optionally TB) for large attachments.


Color values are inconsistent across the status maps: preset Ant Design Tag colors (green/orange/red/blue/default) are mixed with a raw hex value (#999). Ant Design renders preset colors with theme-derived backgrounds while non-preset values are treated as custom colors, producing visually inconsistent tags. Use a single convention (all preset names or all explicit colors).

withdrawn: { text: '放弃', color: '#999' },


Edge case: `hasAllPermissions()` invoked with zero arguments returns `permissionsReady` (i.e., `true` whenever permissions are ready) due to the vacuous truth of `codes.every(...)` on an empty array. This silently bypasses any permission check and could grant access accidentally if a caller forgets to pass codes. Consider returning `false` when `codes.length === 0` (fail-closed), e.g. `permissionsReady && codes.length > 0 && codes.every(...)`. Note `hasAnyPermission()` with zero args already behaves fail-closed (returns `false`), so this is inconsistent as well.

  const hasAllPermissions = useCallback(
    (...codes: string[]): boolean =>
      permissionsReady && codes.length > 0 && codes.every((code) => permissions.includes(code)),
    [permissions, permissionsReady],
  );

  const hasAllPermissions = useCallback(
    (...codes: string[]): boolean =>
      permissionsReady && codes.every((code) => permissions.includes(code)),
    [permissions, permissionsReady],
  );


Writing to a ref during render (`keyRef.current = queryKey`) is a render-time side effect. React explicitly discourages reading/writing `ref.current` during render (except lazy init): in concurrent rendering the render may be discarded/replayed, so the ref can end up inconsistent with the committed render, and StrictMode double-renders can make this visible. Since the goal here is just to keep the latest queryKey, update it in a `useEffect` declared before the refetch effect (effects run in declaration order, so the key will be fresh when the refetch effect runs).

  const keyRef = useRef(queryKey);
  useEffect(() => {
    keyRef.current = queryKey;
  }, [queryKey]);

  const keyRef = useRef(queryKey);
  keyRef.current = queryKey;


On initial mount of the currently-active page, `visible` is already `true`, so this effect fires immediately and refetches the query — duplicating the initial fetch that `useQuery` already performed on mount. The hook's stated purpose is to refresh data when the page is *switched back to* (hidden → visible), not on first load. Consider tracking the previous visibility so the refetch only triggers on a hidden → visible transition.

  const prevVisibleRef = useRef(visible);
  useEffect(() => {
    const becameVisible = visible && !prevVisibleRef.current;
    prevVisibleRef.current = visible;
    if (becameVisible && keyRef.current) {
      void queryClient.refetchQueries({ queryKey: keyRef.current });
    }
  }, [visible, queryClient]);

  useEffect(() => {
    if (visible && keyRef.current) {
      void queryClient.refetchQueries({ queryKey: keyRef.current });
    }
  }, [visible, queryClient]);


The effect re-registers the window keydown listener whenever the `onSubmit` identity changes. If the caller passes an inline arrow function (very common at call sites), the listener is removed and re-added on every render while `active` is true, which is needless churn. Consider storing the latest callback in a ref so the listener is only (re)bound when `active` changes.

  const onSubmitRef = useRef(onSubmit);
  useEffect(() => {
    onSubmitRef.current = onSubmit;
  });

  useEffect(() => {
    if (!active) return;
    const handleKeyDown = (event: KeyboardEvent) => {
      if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
        event.preventDefault();
        onSubmitRef.current();
      }
    };
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [active]);


In Chinese/IME input contexts, a keydown event fired while composing text (e.g. confirming an IME candidate with Enter) will still match this condition and can trigger an unintended submit. Guard against IME composition with `event.isComposing` (or `event.keyCode === 229`) before invoking `onSubmit`.

      if (event.isComposing) return;
      if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {


`pristineRef` starts as `null`. If `snapshot()` has not been called yet (e.g., a caller forgets to invoke it, or `isDirty()`/`confirmClose()` is triggered before snapshot), `equal(form.getFieldsValue(), null)` always returns `false`, so `isDirty()` incorrectly reports `true` and a spurious confirmation dialog appears even for an untouched form. Make the contract defensive, e.g., treat a null baseline as not dirty, or initialize the ref with `form.getFieldsValue()` at mount.

const pristineRef = useRef<unknown>(form.getFieldsValue());

const pristineRef = useRef<unknown>(null);


`form.getFieldsValue()` only returns values for fields that are currently registered/mounted. If a field is conditionally rendered and unmounts between `snapshot()` and the dirty check (or re-mounts with a reset value), the compared value sets will differ and produce false-positive/false-negative dirty results. Consider using `form.getFieldsValue(true)` in both `snapshot()` and `isDirty()` so the comparison is stable regardless of whether fields are currently mounted.


`downloading` state and `busyRef` are two sources of truth for the same busy flag, and they must be manually kept in sync (assigned in three places). If they ever drift (e.g., an early-return path or a future edit), the button's `loading` state will no longer match the guard behavior. Consider consolidating to a single source of truth — e.g., keep `busyRef` for the synchronous guard and update both via a small helper, or use `useReducer`/`useSyncExternalStore` so the render state is derived from one flag.

  const [downloading, setDownloading] = useState(false);
  const busyRef = useRef(false);


`message.error(...)` can itself throw: `app-message`'s `requireMessageApi()` throws if the Ant Design message API has not been initialized yet. Because this call sits inside the `catch` block of an async function, that throw replaces the original download error, the promise returned by `run` rejects, and the failure propagates to the caller as an unhandled rejection with no user feedback. Consider wrapping the toast calls in a defensive `try/catch`, or ensure `bindMessageApi` is guaranteed to run before this hook is used.

message.error(
          options?.errorMsg ?? (error instanceof Error ? error.message : '下载失败，请重试'),
        );


If the owning component unmounts while `downloadBlob` is still in flight, the `finally` block will call `setDownloading(false)` on an unmounted component, and the in-flight `fetch` cannot be aborted. Consider tracking a mounted flag (or using an `AbortController` passed through to `downloadBlob`) so the async work can be cancelled and state updates skipped after unmount.

      } finally {
        busyRef.current = false;
        setDownloading(false);
      }


Type-safety gap: `schema` is typed as `z.ZodType<unknown>` with no link to `T`, so this wrapper (whose stated purpose is type safety) doesn't actually guarantee the validated shape matches `T`. `validateResponse<T>` simply casts `result.data as T` (see utils/validate.ts), so a caller can pass a schema whose output type differs from `T` and it compiles fine — the mismatch only surfaces at runtime. Consider constraining the schema to the generic, e.g. `schema: z.ZodType<T, z.ZodTypeDef, unknown>` (and update `validateResponse`'s signature accordingly), so mismatched schema/type pairs fail at compile time.

schema: z.ZodType<T, z.ZodTypeDef, unknown>;


The wrapped `queryFn` is recreated as a new closure on every render and it drops the QueryFunctionContext (specifically the AbortSignal). Consequences: (1) the new function identity invalidates any option memoization every render (minor cost; React Query won't refetch solely on identity change), and (2) callers have no way to cancel in-flight requests on unmount/refetch since the signal is never forwarded. If request cancellation matters, widen `queryFn` to accept the context and forward it: `queryFn: (ctx) => ... queryFn(ctx)` and pass `signal` down to the underlying API call.

queryFn: async () => validateResponse<T>(schema, await queryFn()),


`status` is typed as a plain `string`, which allows invalid values to compile silently. Since attendance statuses come from a fixed domain set (e.g. 'present' | 'absent' | 'late' | 'leave'), consider defining a string-literal union type (e.g. `export type LessonAttendanceStatus = 'present' | 'absent' | 'late' | 'leave'`) and using it here and for `session` (e.g. 'morning' | 'afternoon'). This gives compile-time safety against typos at all usage sites. If the set is not fixed, an explicit comment explaining why plain `string` is used would help.


The error generic of useMutation is hard-coded to `Error`, but `mutationFn: (vars) => Promise<TData>` gives no guarantee that the rejection value is an `Error` (APIs commonly reject with strings or plain objects). As a result, `error` passed to `onError`/`onSettled` is typed `Error` even though the runtime value may not have a `.message`, and caller code such as `error.message` becomes type-unsound. Suggest exposing a `TError = Error` generic parameter (like `TContext`) and forwarding it to useMutation, or accepting `unknown` in the callback types.

export function useApiMutation<TData = unknown, TVars = void, TContext = unknown, TError = Error>(
  ...
  return useMutation<TData, TError, TVars, TContext>({

return useMutation<TData, Error, TVars, TContext>({


`invalidateQueries` returns a promise that is discarded with `void` and not awaited before `options.onSuccess` runs. Two issues: (1) in the typical "success → close modal" flow, the refetch is started but the callback proceeds immediately, so the list can show stale data or the refetch can be cancelled on unmount, leaving the UI unrefreshed; (2) if a refetch rejects, the discarded promise produces an unhandled rejection. Consider making onSuccess async and awaiting all invalidations (React Query awaits the onSuccess promise before onSettled), or at minimum attach a `.catch`/`Promise.allSettled` to the invalidations.

    onSuccess: async (data, vars, context) => {
      const keys = options.invalidate ?? [];
      await Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: key })));
      options.onSuccess?.(data, vars, context);
    },

      for (const key of options.invalidate ?? []) {
        void queryClient.invalidateQueries({ queryKey: key });
      }


When the list overflows again after a previous clamp, the tab that was moved to the front by `[active, ...keptRest]` is treated as the "oldest" and evicted next, even though it may have been opened most recently. Example: at the 20-tab limit, opening X produces [X, B, C, ..., T]; opening Y next evicts X (kept at index 0) while keeping the much older B. This contradicts the stated "淘汰最旧" (evict oldest) intent, so eviction is not actually by recency once overflow occurs repeatedly. Consider evicting in place without reordering (e.g., drop the first non-active tab while preserving original order), or track recency separately.

  // 保持原顺序，仅剔除最旧的非当前页签，避免 active-first 重排破坏后续淘汰次序
  const dropIndex = list.findIndex((tab) => tab.key !== activeKey);
  return dropIndex >= 0 ? list.filter((_, i) => i !== dropIndex) : list.slice(-MAX_DOCK_TABS);

return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS);


This defensive branch is effectively dead/unreachable from `upsertDockTab` (the active tab is always present in the list — either newly appended or already existing), and when it is reached, `keptRest` always has length `MAX_DOCK_TABS - 1`, so `slice(-MAX_DOCK_TABS)` is a no-op and the result is always one short of the limit. Simplify to avoid misleading readers.

return active ? [active, ...keptRest] : keptRest;

  const keptRest = rest.slice(rest.length - (MAX_DOCK_TABS - 1));
  return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS);


`list as DockTab[]` discards the `readonly` modifier and returns the store's own array reference typed as mutable. The shared-reference behavior is deliberate (test asserts `toBe(tabs)`), but any caller that mutates the returned array would corrupt the persisted store state and any other component holding the same reference in place. Consider documenting the no-mutation contract, or returning a copy if mutation safety is preferred over the re-render optimization.

if (list.length <= MAX_DOCK_TABS) return list as DockTab[];


The destroy-on-revoked-permission behavior never actually runs: this effect has an empty dependency array, so it only executes once on mount (when `modalRef.current` is still null and the condition is a no-op) and once on unmount. Mutating `canLogRef.current` during render does not re-trigger the effect, so if `canLog` changes from true to false while a confirm dialog is open, the modal is never destroyed — contradicting the docstring (“when false any already-open confirm modal is destroyed”). Make the effect depend on `canLog` so the check re-runs when the permission changes.

  useEffect(() => {
    if (!canLog && modalRef.current) {
      modalRef.current.destroy();
      modalRef.current = null;
    }
    return () => {
      modalRef.current?.destroy();
      modalRef.current = null;
    };
  }, [canLog]);

  useEffect(() => {
    if (!canLogRef.current && modalRef.current) {
      modalRef.current.destroy();
      modalRef.current = null;
    }
    return () => {
      modalRef.current?.destroy();
      modalRef.current = null;
    };
  }, []);


Opening a new confirm overwrites `modalRef.current` without destroying an already-open dialog. If the user triggers the action twice (e.g., for two students), two confirm modals stack on top of each other, and the earlier modal's `afterClose` clears the ref while the newer modal is still open, so a later revoke/unmount won't destroy it. Destroy the existing dialog (if any) before creating a new one.

      modalRef.current?.destroy();
      modalRef.current = modal.confirm({


The label assignment is a 5-level nested ternary chain, which violates the project rule that nested ternary expressions are not allowed. This is hard to read and maintain. Refactor into if/else branches or a lookup table.

  let label: string;
  if (machine) {
    label = '考勤机打卡';
  } else if (source === 'USER') {
    label = '手机打卡';
  } else if (source.includes('BEACON') || source.includes('BLE')) {
    label = '蓝牙打卡';
  } else if (source.includes('WIFI')) {
    label = 'Wi-Fi 打卡';
  } else if (source.includes('APPROVE')) {
    label = '审批补卡';
  } else if (source) {
    label = `其他打卡（${record.punchSource}）`;
  } else {
    label = '打卡来源未知';
  }

  const label = machine
    ? '考勤机打卡'
    : source === 'USER'
      ? '手机打卡'
      : source.includes('BEACON') || source.includes('BLE')
        ? '蓝牙打卡'
        : source.includes('WIFI')
          ? 'Wi-Fi 打卡'
          : source.includes('APPROVE')
            ? '审批补卡'
            : source
              ? `其他打卡（${record.punchSource}）`
              : '打卡来源未知';


Phase is computed purely from the time-of-day, so any schedule that crosses midnight (endTime < startTime, e.g. 22:00–02:00) is misclassified: it will return 'ended' after startTime and 'upcoming' again before endTime. Additionally, seconds are ignored, so the phase stays 'ongoing' for up to one minute past the actual end time. Consider comparing against actual Date values (including the schedule date) or explicitly handling the endTime < startTime wrap-around.

export function getSchedulePhase(
  startTime: string,
  endTime: string,
  now = new Date(),
): SchedulePhase {
  const current = now.getHours() * 60 + now.getMinutes();
  if (current < toMinuteOfDay(startTime)) return 'upcoming';
  if (current <= toMinuteOfDay(endTime)) return 'ongoing';
  return 'ended';
}


The `in` operator also matches inherited properties from Object.prototype (e.g. 'toString', 'constructor', 'hasOwnProperty'). If a record ever carries one of these status strings, the subsequent `summary[status] += 1` would read an inherited function/accessor and corrupt the summary (e.g. assigning a string/NaN to it), and `__proto__` could even mutate the object's prototype. Use `Object.hasOwn(summary, record.status)` or an explicit whitelist of allowed status keys instead.

    if (Object.hasOwn(summary, record.status) && record.status !== 'total') {
      summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;
    }

    if (record.status in summary && record.status !== 'total') {
      summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;
    }


Role super-admin detection relies on Chinese display names (`'超级管理员'`, `'超管'`) in addition to the canonical code. Display names are not a stable identity — the codebase itself has a server test (`jwt.strategy.spec.ts`) asserting the role must be recognized via the canonical `code` even when the display name changes. If a super admin's display name is renamed, this predicate silently returns false and the user would be incorrectly included as a teacher candidate (a permission/authorization bypass). Prefer matching only the stable `role.code === 'super_admin'`, and extract the magic strings into shared role constants instead of hardcoding them here.

role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管';


The `'staff'` student-status value is a hardcoded business string. Per the review rules, business-related hardcoded strings should be extracted into shared constants/enums so the allowed status is defined in one place and stays consistent across the admin and server code that also reference these values.

if (user.studentStatus && user.studentStatus !== 'staff') return false;


`Number(value || 0)` returns `NaN` when the value is a non-numeric string (e.g. an unexpected API value like `"abc"`), and `NaN.toFixed(2)` renders `"NaN"`, so the printed bill would show `¥NaN`. Guard the conversion with `Number.isFinite` and fall back to `0.00`.

const money = (value: unknown) => {
  const n = Number(value ?? 0);
  return `¥${Number.isFinite(n) ? n.toFixed(2) : '0.00'}`;
};

const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;


Same NaN risk as `money()`: if `days`/`totalRoomDays`/`studentAmount` come through as non-numeric strings, these cells will render literal `NaN` on the printed bill. Also, `Number(item.studentAmount || 0).toFixed(2)` duplicates the formatting logic in `money()` — reuse it for consistency.

${Number.isFinite(Number(item.days)) ? Number(item.days) : 0}
          ${Number.isFinite(Number(item.totalRoomDays)) ? Number(item.totalRoomDays) : 0}
          ${money(item.studentAmount)}

${Number(item.days || 0)}
          ${Number(item.totalRoomDays || 0)}
          ${Number(item.studentAmount || 0).toFixed(2)}


Business-specific strings such as the institution name "恭学教育基地" and the footer "学生管理系统" are hardcoded directly in the template. Per the review rules, business-related hardcoded strings should be avoided — consider passing the institution/system name in via `BillPrintData` or a config so the template is reusable across tenants/systems.


Hardcoded business value `'short'` as the default stayType. Per the review rules, business-related hardcoded strings should be avoided — extract this to a named constant (e.g., DEFAULT_STAY_TYPE) or let the form/backend decide. Silently defaulting every check-in to 'short' can also hide a missing selection in the UI.


Hardcoded deposit default of `500` is a business number that should be a named constant (e.g., DEFAULT_DEPOSIT_AMOUNT) or configuration, so it isn't duplicated/silently drifted across the codebase.

depositAmount: collectDeposit ? values.depositAmount ?? 500 : undefined,


`values.lockerId || undefined` converts a legitimate `0` to `undefined` because `||` treats falsy values as missing. Since lockerId is a number, use nullish coalescing or pass the value through directly.


Same issue as the check-in payload: `values.newLockerId || undefined` drops a valid `0`. Use nullish coalescing or pass the value directly.

newLockerId: values.newLockerId || undefined,


dayjs is a value (a function) with a merged namespace, so default-importing it via `import type` just to reach the `Dayjs` type is fragile and non-idiomatic (it also fails under strict TS configs such as `verbatimModuleSyntax` in some versions). Prefer importing the named type directly: `import type { Dayjs } from 'dayjs';` and then declare `examDate: Dayjs`.


The exported array is inferred as `{ value: string; label: string }[]` and is mutable, so consumers could push/overwrite entries and the literal values get widened to `string`, losing any compile-time link to `ExamItem['examType']` or `ExamFormValues['examType']`. Add `as const` (and optionally make it `readonly`) so the option values stay as literal types and the array can't be mutated globally.

export const EXAM_TYPE_OPTIONS = [
  { value: '月考', label: '月考' },
  { value: '周测', label: '周测' },
  { value: '期中考试', label: '期中考试' },
  { value: '期末考试', label: '期末考试' },
  { value: '模拟考试', label: '模拟考试' },
  { value: '入学测试', label: '入学测试' },
] as const;


The tooltip formatter interpolates the student name (`p.data.name`) directly into an HTML string. ECharts injects custom tooltip formatter output into the tooltip DOM via innerHTML, so a user-controlled student name containing HTML (e.g. `<img onerror=...>`) can execute — an XSS risk. Escape the name before embedding (e.g. use ECharts' `encodeHTML` helper), or otherwise sanitize it.

    tooltip: {
      formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
        `${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,
    },


Same XSS concern: the classroom name (`p.name`) is interpolated into the tooltip HTML string without escaping. If classroom names are user-editable, an HTML/script payload could be executed in the tooltip. Escape the name before concatenating.

      formatter: (p: {
        name: string;
        data: { scheduleDays: number; rentalCount: number; occupancy: number };
      }) =>
        `${p.name}<br/>排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`,


When a check-in date is later than the computed end (e.g. a future booking beyond `periodEnd`/`today`, or malformed dates), `end[0] - start[0]` is negative and `Math.max(..., 2)` draws a misleading 2px bar anchored at the start coordinate; if `api.coord` cannot resolve a date it can even return NaN. Validate the dates/coords (e.g. `isNaN` checks) and skip or clamp the bar for invalid ranges instead of rendering a fake 2px bar.

          const rectShape = {
            x: start[0],
            y: start[1] - height / 2,
            width: Math.max(end[0] - start[0], 2),
            height,
          };


`buildExpensePieOption` duplicates nearly the entire structure of `buildAttendanceRingOption` (same pie type, radius, center, legend, COLORS). Consider extracting a shared `buildRingPieOption(data, colors)` helper and have both functions delegate to it to avoid drift between the two charts.

export function buildExpensePieOption(
  rows: ExpenseByTypeRow[],
  expenseTypeMap: Record<string, string>,
): EChartsOption {


Chart colors are hardcoded literals (`#007AFF`, `#34C759`, `#FF9500`, `#1677ff`, rgba overlays) even though a shared `COLORS` palette is already imported in this module. Using the theme constant keeps chart colors consistent and makes future rebranding a one-line change; prefer `COLORS`/theme tokens over ad-hoc hex values.

itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },


Nested ternary expression (dirty ? formValues : config ? ... : {}) violates the project rule that prohibits nested ternaries and hurts readability. Extract the branch into a local variable using if/else, e.g.:

```ts
const { dirty, formValues } = get();
let nextFormValues: Partial<DingTalkConfigFormValues> = {};
if (!dirty) {
  nextFormValues = config ? { ...config, appSecret: undefined } : {};
} else {
  nextFormValues = formValues;
}
```

formValues: dirty ? formValues : config ? { ...config, appSecret: undefined } : {},


The `{ ...config, appSecret: undefined }` snapshot-building logic is duplicated in `cacheServerSnapshot` and `commitConfig`. Extract it into a small helper (e.g. `configToFormValues = (config: DingTalkSavedConfig) => ({ ...config, appSecret: undefined })`) to keep the two actions in sync and avoid future divergence.

formValues: { ...config, appSecret: undefined },


`import.meta.env.DEV` is evaluated at module load time when the store is created. If this module is ever imported in a non-Vite environment (e.g., a Node/Jest test runner, SSR, or build tooling where `import.meta`/`import.meta.env` is undefined), this line throws a TypeError and breaks the entire store before any state logic can run. Guard the access, e.g. `enabled: typeof import.meta !== 'undefined' && import.meta.env?.DEV === true` or use a try/catch wrapper, to make the store safely importable in any environment.

{ name: 'integration-config-store', enabled: import.meta.env.DEV },


`hasFailure` does not consider `failedItems > 0`, but the warning/error messages below count on `failedItems`. If a caller reports `failedItems > 0` without setting `failedBatchCount` or `errors` (e.g., item-level failures not attached to a failed batch), the result is misclassified as a full `success` even though items failed. Also, when `errors` is non-empty but `failedItems === 0`, the error message would read "0 条全部写入失败". Make the failure detection consistent with the message fields, e.g. include `failedItems > 0` in `hasFailure` and guard the message when `failedItems === 0`.

const hasFailure = result.failedBatchCount > 0 || result.failedItems > 0 || result.errors.length > 0;

const hasFailure = result.failedBatchCount > 0 || result.errors.length > 0;


`scheduleCount` and `skippedNoMapping` are declared in `SyncResultInput` but never read by `classifySyncResult` (or any other code in this module). If they are not needed by callers either, they are dead fields and should be removed; if they are part of the shared input contract, consider documenting that they are unused by the classifier.

export interface SyncResultInput {
  scheduleCount: number;
  syncedItems: number;
  skippedNoMapping: number;


This is a nested ternary expression, which violates the project rule that nested ternaries are not allowed (it significantly hurts readability). Prefer extracting the logic into a lookup map or a small if/else helper, e.g. `batchAction: ({ active: 'checkout', all: 'archive', archived: 'restore' } as const)[view]`.

batchAction: ({ active: 'checkout', all: 'archive', archived: 'restore' } as const)[view],

batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore',


The `ViewPolicy` interface documents that `purgeBatch` (batch permanent delete) is mutually exclusive with batch restore ("与批量恢复互斥"), but `archiveViewPolicy` for the `archived` view sets both `batchAction: 'restore'` and `purgeBatch: true`. This means both actions would be active at the same time, contradicting the documented design. Fix the policy so the two are mutually exclusive (e.g. only expose purge when batchAction is not 'restore'), or correct the documentation.

export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({
  batchAction: view === 'archived' ? 'restore' : 'archive',
  readonly: view === 'archived',
  purgeBatch: view === 'archived',
});


`expenseStatusForView` is an identity function that returns its argument unchanged — it performs no transformation. Either the mapping to expense status was never implemented (dead/incomplete code) or this helper serves no purpose. If it is meant to map a view to an expense status, implement the actual mapping; otherwise remove it to avoid misleading dead code.

export const expenseStatusForView = (view: ArchiveView) => view;


For the `all` view this function returns `status: 'active'`, which would filter out archived records — contradicting the semantics of an 'all' view (should include both active and archived records). Verify the API semantics; the 'all' view most likely should not pass a restrictive status filter, e.g. `status: view === 'archived' ? 'archived' : undefined` with `active` handled separately.

export const occupancyParamsForView = (view: OccupancyView) => ({
  active: view === 'active' ? 'true' : undefined,
  status: view === 'archived' ? 'archived' : 'active',
});


`==`/`!=` are prohibited by the project rules; strict equality must be used. Since `classId` is an optional parameter that can be `undefined` or `null`, replace the loose nullish check with an explicit strict check: `if (classId === undefined || classId === null) return schedules;`


The persisted subset duplicates field declarations from AppState (`sidebarCollapsed`, `routeDockTabs`). If AppState is changed, this subset can silently drift out of sync, and the redundant boolean/DockTab types are repeated. Consider deriving it with `Pick<AppState, 'sidebarCollapsed' | 'routeDockTabs'>` to keep a single source of truth.

export type AppPersistedState = Pick<AppState, 'sidebarCollapsed' | 'routeDockTabs'>;

export interface AppPersistedState {
  sidebarCollapsed: boolean;
  routeDockTabs: DockTab[];
}


`editing` is typed as `any`, which hides a real hazard: the truthiness check `editing ? api.put(...) : api.post(...)` means an empty object `{}` (or a stale object without `id`) would still be truthy and produce a PUT to `/rooms/undefined`. Type it explicitly, e.g. `editing: { id: number } | null | undefined`, so the create/update branch is guarded by the type system and callers can't pass malformed objects.

export function useRoomMutations(editing: { id: number } | null | undefined) {

export function useRoomMutations(editing: any) {


`record` is typed as `any`, and `record.id` is dereferenced without any null/type check, so a malformed record silently yields `/rooms/undefined`. Type it as `{ id: number }` (plus any fields used by the caller) to make the cell-edit contract explicit.

async ({ record, field, value }: { record: { id: number }; field: string; value: unknown }) =>

async ({ record, field, value }: { record: any; field: string; value: unknown }) =>


`useLockerMutations` is a near-verbatim copy of `useBedMutations` (cell save, save/create, delete, batch — identical shapes except for the URL segment `beds`/`lockers`). This duplication is a maintenance hazard: any change to one (e.g., new endpoint, different invalidate keys) is easy to miss in the other. Consider extracting a shared factory, e.g. `useSubItemMutations(resource: 'beds' | 'lockers')` returning `saveCellMutation/saveMutation/deleteMutation/batchMutation`, then having both hooks delegate to it.


Both start/end times are anchored to the same fixed date `2000-01-01`. For schedules that span midnight (e.g. 23:00–00:30), `timeRange[1]` will be earlier than `timeRange[0]`, so any end-after-start comparison in the form fails and `buildSchedulePayload` sends a bare `00:30` endTime with no next-day semantics. Consider anchoring the end time to the following day when `endTime < startTime` (or storing times as time-of-day strings and normalizing in the payload builder).

timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],


Business magic values are hardcoded: the anchor date `2000-01-01` used when reconstructing times and the default `30` for `attendanceAdvanceMinutes`. These should be extracted into named constants so the default advance-minutes policy and the time anchor are defined once and documented (also making the timeRange rebuild logic easier to fix/reuse).

attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30,


`invalidateOccupancies` is recreated on every render with a loose type `Array<readonly unknown[]>` that doesn't match the `QueryKey[]` contract used by `useApiMutation` (type safety is only rescued by structural compatibility). Since the value is static, hoist it to module scope as a typed constant (e.g. `const INVALIDATE_OCCUPANCIES: QueryKey[] = [['occupancies']];`) and reference it from all mutations. This avoids per-render array allocation (the same object is passed into 10 `useApiMutation` calls each render) and provides a single source of truth for the invalidation key.

const INVALIDATE_OCCUPANCIES: QueryKey[] = [['occupancies']];

export function useOccupancyMutations() {

export function useOccupancyMutations() {
  const invalidateOccupancies: Array<readonly unknown[]> = [['occupancies']];


`importMutation` appends a raw `params` string to the URL. If `params` is empty the request goes to `/occupancies/import?` (stray `?`), and if it contains special characters (spaces, `#`, `&`, non-ASCII) they are not URL-encoded, which can silently break the import request or send wrong values. Prefer accepting a `Record<string, unknown>` object and serializing it with `URLSearchParams` (skipping the empty case) so encoding is handled consistently.

    async ({ formData, params }: { formData: FormData; params: Record<string, unknown> }) => {
      const query = new URLSearchParams(params as Record<string, string>).toString();
      return api.post(`/occupancies/import${query ? `?${query}` : ''}`, formData);
    },

    async ({ formData, params }: { formData: FormData; params: string }) =>
      api.post(`/occupancies/import?${params}`, formData),


The store state's permissions array is treated as immutable (the implementation in permissionStore.ts always replaces it via `[...new Set(permissions)]` on write), but the type declares a mutable `string[]`. Declaring `readonly string[]` would make the immutability contract explicit and prevent consumers from accidentally mutating shared state, which can lead to missed re-renders in Zustand. The same applies to `PermissionPersistedState.permissions`.

  /** 已校验通过的权限码 */
  permissions: readonly string[];

  /** 已校验通过的权限码 */
  permissions: string[];


Rehydration of persisted state is only validated when the stored version differs from the current version (via migrateAppUiState). In the common case (version matches), zustand's default merge shallow-merges the raw persisted `routeDockTabs`/`sidebarCollapsed` directly into the store without any structural validation (no isDockTab-style filtering). Since RouteDock consumes `tab.key`/`tab.label` from `routeDockTabs`, a stale or hand-edited `gongxue-app-ui` value (e.g., non-object entries, missing keys) would flow into the store and break tab rendering, while the legacy path would have been sanitized. Consider adding a `merge` option that re-runs the same normalization as `migrateAppUiState` (filtering invalid tabs and coercing `sidebarCollapsed` to a boolean) so the same-version path is as defensive as the migration path.

        version: 2,
        migrate: migrateAppUiState,


`UserPersistedState` is structurally identical to `UserState` (`{ token; user }`). Keeping two separate declarations that must be manually kept in sync invites drift whenever `UserState` changes. Consider deriving the persisted type instead, e.g. `export type UserPersistedState = Pick<UserState, 'token' | 'user'>;` (or `UserState` itself if it stays the persisted subset).

export interface UserPersistedState {
  token: string | null;
  user: UserInfo | null;
}


`StoreStatus` is imported and re-exported here, but nothing in the codebase consumes `StoreStatus` from this module (it is only used via `../types` and `store/index.ts`), so this re-export is dead code. Also, the attached comment talks about permissions being managed by the permission store, which is unrelated to `StoreStatus` — either remove the import/re-export or move the comment next to the `permissions` field in `UserInfo` to avoid confusion.

/** 兼容：用户状态中的权限由 permission Store 统一管理 */
export type { StoreStatus };


When `state.user` is null this returns `{}`, which silently discards the patch and still triggers a full `set` — notifying listeners and causing the `persist` middleware to run even though nothing changed. Callers who call `updateUser` before a session exists will have their updates silently lost, and the redundant state update is wasted work. Consider guarding outside the set (e.g., using `get()` and bailing out) or logging a warning when user is null, so the no-op is explicit.

  updateUser: (patch) => {
    set(
      (state) => {
        if (!state.user) return {};
        return { user: { ...state.user, ...patch } };
      },
      false,
      'user/updateUser',
    );
  },

  updateUser: (patch) => {
    set(
      (state) => (state.user ? { user: { ...state.user, ...patch } } : {}),
      false,
      'user/updateUser',
    );
  },


Non-serializable data in store state: `activeCell` holds a `save` closure (a function). While zustand itself tolerates this, the value is dropped/unreadable in Redux DevTools state snapshots, and any future use of the `persist` middleware or serialization-based testing would silently lose it. Consider storing the session id + serializable metadata in the store and keeping the live `save` closure in a module-level map/ref keyed by id (retrievable via a selector), which also keeps the command-based design.

  /** 当前处于编辑态的单元格（全局唯一） */
  activeCell: EditableCellSession | null;


`setActiveCell` unconditionally replaces the current session without invoking the previous session's `save()`. The “save the previously active cell before switching” invariant is therefore not enforced by this coordination hub — it relies on every caller doing it manually (today only `EditableCell/beginEdit` does, and it aborts on `saved === false`). Any future caller that sets `setActiveCell` directly will silently lose the previous cell's pending edits. Consider centralizing this: e.g., an async action that saves the old active cell and only replaces it on success, so the invariant cannot be bypassed.

setActiveCell: (session) => set({ activeCell: session }, false, 'editableCell/setActiveCell'),


When the store has no token (e.g., not logged in, or a public endpoint like '/rooms/template'), this sends `Authorization: Bearer undefined`, which can cause the server to reject requests that would otherwise succeed. Build the header conditionally so it's only sent when a token exists.

  const token = useUserStore.getState().token;
  const headers = token ? { Authorization: `Bearer ${token}` } : {};
  const res = await fetch(`${baseURL}${endpoint}`, { headers });

  const token = useUserStore.getState().token;
  const res = await fetch(`${baseURL}${endpoint}`, {
    headers: { Authorization: `Bearer ${token}` },
  });


`fetch` rejects with a network-level error (DNS failure, connection refused, timeout, etc.) rather than returning a non-ok response. That error is not caught here, so callers get a raw TypeError instead of a user-friendly message, and any `catch` in the caller may not distinguish network failures from HTTP failures. Wrap the request in try/catch and throw a friendly message.

  let res: Response;
  try {
    res = await fetch(`${baseURL}${endpoint}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
  } catch {
    throw new Error('网络错误，下载失败，请稍后重试');
  }

  if (!res.ok) {

  const res = await fetch(`${baseURL}${endpoint}`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (!res.ok) {


The dev API base URL is hardcoded to `http://localhost:...`. This makes it impossible to point the admin app at a non-local API without code changes, and breaks if the app is served from a different origin. Prefer a configurable env var (e.g., `VITE_API_BASE_URL`) with the localhost value as a fallback, or reuse the same API base used elsewhere in the app.

  const baseURL =
    import.meta.env.VITE_API_BASE_URL ||
    (import.meta.env.PROD
      ? '/api'
      : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`);

  const baseURL = import.meta.env.PROD
    ? '/api'
    : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;


Persisted settings are rehydrated with zustand's default shallow merge and no `migrate`, so structurally valid but malformed stored data is merged as-is. If localStorage contains e.g. `{"state":{"aiChat":null},"version":1}` (from a manual edit or a future bug), `state.aiChat` becomes `null` and `toggleAiChatDeepThinking` will throw `Cannot read properties of null (reading 'deepThinking')`. All other persisted stores in this project (auth/permission/app-ui, see store/middleware/persist.ts) validate/migrate their persisted state; this store should follow the same pattern. Add a defensive `merge` (or a `migrate` for future schema changes) that validates and defaults `aiChat`.

        partialize: (state): SettingsState => ({ aiChat: state.aiChat }),
        version: 1,
        merge: (persisted, current) => {
          const p = persisted as Partial<SettingsState> | undefined;
          return {
            ...current,
            aiChat: {
              deepThinking: p?.aiChat?.deepThinking ?? current.aiChat.deepThinking,
            },
          };
        },


`requireMessageApi()` throws when `bindMessageApi` hasn't run yet (e.g., AppMessageBridge not yet mounted, or unmounted on a route that doesn't render it). Since this `message` facade is called from error handlers too (e.g., `useApiMutation`'s default `onError` calls `message.error`), the throw propagates into those paths and masks the original error (or breaks unrelated logic). Consider falling back to antd's static `message` API or degrading to a no-op with a dev warning instead of throwing.

if (!messageApi) {
    throw new Error('Ant Design message API has not been initialized');
  }


The module-level `messageApi` singleton is only ever set, never reset. If the `AppMessageBridge` that bound it unmounts (route change, conditional render, tests), all subsequent `message.*` calls still target the detached instance and messages will silently fail to display. Add a corresponding `unbindMessageApi()`/cleanup (called from the bridge's effect cleanup) or track the binding lifecycle to avoid stale references.

export function bindMessageApi(api: MessageInstance): void {
  messageApi = api;
}


`crypto.randomUUID` is only available in secure contexts (HTTPS/localhost) and in relatively modern browsers (Chrome 92+, Firefox 95+, Safari 15.4+). In a non-secure context it is `undefined`, so this utility would throw at runtime and break every API call that relies on an operationId. Consider adding a fallback using `crypto.getRandomValues` (or a small UUID v4 implementation) to make the utility robust.

export const newOperationId = () => {
  if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
  const bytes = crypto.getRandomValues(new Uint8Array(16));
  bytes[6] = (bytes[6] & 0x0f) | 0x40;
  bytes[8] = (bytes[8] & 0x3f) | 0x80;
  return [...bytes].map((b, i) =>
    [4, 6, 8, 10].includes(i) ? `-${b.toString(16).padStart(2, '0')}` : b.toString(16).padStart(2, '0')
  ).join('');
};

export const newOperationId = () => crypto.randomUUID();


Test isolation gap: the stores persist under `gongxue-auth`, `gongxue-app-ui`, `gongxue-settings` (and `permissions`) via zustand persist (see store/middleware/persist.ts). Here only the legacy `token`/`user` keys and `permissions` are removed — the actual persist keys are never cleared. If any test fails/crashes before the store-reset lines run, stale persisted state (e.g. auth session, AI settings, UI state) leaks into subsequent tests in the same browser session. Prefer removing the real storage keys (e.g. import and use `AUTH_STORAGE_NAME`, `APP_UI_STORAGE_NAME`, `SETTINGS_STORAGE_NAME`, `PERMISSION_STORAGE_NAME`) so cleanup does not depend on store actions executing successfully.

  localStorage.removeItem('token');
  localStorage.removeItem('user');
  localStorage.removeItem('permissions');


Shallow setState replaces the entire nested `aiChat` object rather than merging it, so any future fields added to `AiChatSettings` (e.g. model, temperature) would be silently dropped and persisted to `gongxue-settings`. Use the store's existing action `useSettingsStore.getState().setAiChatDeepThinking(false)`, which already merges with a spread, to keep the reset robust.

useSettingsStore.setState({ aiChat: { deepThinking: false } });


Hardcoded URL and inconsistent comment: `BASE` is hardcoded to `localhost:3002` while the comment above claims the dev server proxies `/api → localhost:3003`. Use an environment variable (e.g. `import.meta.env.VITE_API_BASE_URL`) and fix the comment so the setup matches the actual dev server, otherwise integration tests silently hit the wrong target when the port changes.


The auth token is persisted to localStorage via the persist middleware. localStorage is readable by any script executed on the page, so a single XSS can exfiltrate the credential (unlike an httpOnly cookie, which is not accessible to JS). If the backend supports it, prefer an httpOnly secure cookie for the token, or at minimum document this as an accepted risk and ensure all rendered user input is escaped. Note the migrate/fallback logic also relies on reading/writing raw localStorage.


`get` and `api` are destructured and forwarded to `createUserActions(set, get, api)`, but `createUserActions` only consumes `set` (its StateCreator signature declares only `set`). These parameters are effectively dead here; simplify to `(set) => ({ ...createUserActions(set) })` to avoid implying the actions depend on `get`/`api`.


Backends commonly return validation errors as an array (e.g. `{ message: ['field is required'] }`), use a different key (`error`/`msg`), or return the response `data` as a plain string. In all these cases this branch discards the server-provided detail and falls back to the generic axios `error.message` (e.g. 'Request failed with status code 400'), so the user sees no actionable reason for the failure. Consider narrowing `data`'s runtime type: use `data` directly when it's a string, and join/stringify array or object `message` values before falling back to `error.message`.

    const data = error.response?.data;
    if (typeof data === 'string' && data) message = data;
    else if (data && typeof data === 'object') {
      const msg = (data as { message?: unknown }).message;
      if (typeof msg === 'string' && msg) message = msg;
      else if (Array.isArray(msg) && msg.length) message = msg.join('; ');
      else if (msg && typeof msg === 'object') message = JSON.stringify(msg);
    }
    if (!message && error.message) message = error.message;

    const data = error.response?.data as { message?: unknown } | undefined;
    if (typeof data?.message === 'string' && data.message) message = data.message;
    else if (error.message) message = error.message;


The `.replace(/ {2,}/g, ' ')` collapses every run of multiple spaces, which can corrupt error text that intentionally contains indentation or aligned formatting (e.g. SQL statements, stack traces, key-value output). If preserving such formatting matters, consider replacing only newline runs (and normalizing tabs) and leaving repeated spaces intact.

const singleLine = trimmed.replace(/[\n\r]+/g, ' ').replace(/ {2,}/g, ' ');


`replaceAll(roleType, label)` performs a raw substring replacement with no token boundary, so any snake_case key embedded inside a larger token (e.g. `subject_teacher_id`, `head_teacher_notes`, or a URL path containing `/head_teacher/`) will be partially rewritten and corrupt the text. Since `_` is a word character, `\b` word-boundary regexes won't help either. Prefer a single-pass alternation replacement so only exact key tokens are substituted:

  const rolePattern = new RegExp(
    Object.keys(teacherRoleLabels)
      .sort((a, b) => b.length - a.length)
      .join('|'),
    'g',
  );
  return (text ?? '').replace(rolePattern, (match) => teacherRoleLabels[match]);

  return Object.entries(teacherRoleLabels).reduce(
    (result, [roleType, label]) => result.replaceAll(roleType, label),
    text,
  );


`formatNotificationText` dereferences `text` with no null guard. Callers pass `item.title` / `item.content` directly (e.g. `formatNotificationText(item.title)`), so if a notification record is missing one of these fields this throws a TypeError and breaks the whole notification list render. Add a defensive default value.

export function formatNotificationText(text = ''): string {

export function formatNotificationText(text: string): string {


Fail-open window during background refresh: when `status === 'ready'`, `beginPermissionVerification` early-returns, leaving `status` at 'ready' and the old permissions active with no dedicated 'refreshing/verifying' sub-state. If the background verification fails (e.g., 401/network) and the caller does not invoke `clearPermissions` (or the failure path is missed), previously revoked permissions remain marked 'ready' and continue to take effect — which contradicts the fail-closed intent stated in the header comment. Consider introducing a distinct 'refreshing' status (or an in-flight flag) so the store itself can distinguish verified-from stale, and/or documenting a hard requirement that every refresh failure must call `clearPermissions`.

        beginPermissionVerification: () => {
          // 后台刷新时保留已就绪的权限，避免界面闪加载
          if (get().status === 'ready') return;


Stale-response race in `writePermissions`: it unconditionally sets `status: 'ready'` with whatever payload arrives, without correlating the response to the current verification cycle. If an older in-flight verification resolves after a newer one (or after a logout that called `clearPermissions`), the stale response re-marks the store as 'ready' with outdated permissions — e.g., a slow background-refresh response landing after logout resurrects the old permissions. Consider tracking a generation/request counter (incremented in `beginPermissionVerification`/`clearPermissions`) and ignoring `writePermissions` results whose token is older than the current one.

        writePermissions: (permissions) => {
          set(
            { permissions: [...new Set(permissions)], status: 'ready' },