Files
gongxue-base/ocr-reports/admin-scan-comments.jsonl
wangziqi 1289aea7ce docs(ocr): 全库 OCR 审查与修复记录
由 OCR(open-codereview.ai,deepseek-v4-flash)审查产出:
- 审查汇总.md / high-severity-comments.md / all-comments.jsonl / 修复记录.md 等

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00

440 lines
344 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{"path":"apps/admin/src/components/EditableCell/style.css","start_line":40,"end_line":43,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":".editable-cell--enabled:not(.editable-cell--editing):hover {\n border-color: #91caff;\n background: #e6f4ff;\n}"}
{"path":"apps/admin/src/components/EditableCell/style.css","start_line":8,"end_line":14,"category":"bug","severity":"medium","content":"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.","suggestion_code":".editable-cell-display {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}","existing_code":".editable-cell-display {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n width: 100%;\n}"}
{"path":"apps/admin/src/components/EditableCell/style.css","start_line":27,"end_line":29,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":".editable-cell-undo:hover {\n text-decoration: underline;\n}"}
{"path":"apps/admin/src/components/EditableCell/style.css","start_line":64,"end_line":66,"category":"maintainability","severity":"low","content":"`!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`.","suggestion_code":null,"existing_code":".editable-cell-dropdown {\n min-width: 140px !important;\n}"}
{"path":"apps/admin/src/components/AiChat/style.css","start_line":348,"end_line":353,"category":"bug","severity":"medium","content":"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`.","suggestion_code":".ai-chat-composer .ant-sender-input:focus:not(:focus-visible) {\n outline: none;\n box-shadow: none;\n}","existing_code":".ai-chat-composer .ant-sender-input:focus,\n.ai-chat-composer .ant-sender-input:focus-visible,\n.ai-chat-composer .ant-sender-input:focus-within {\n outline: none;\n box-shadow: none;\n}"}
{"path":"apps/admin/src/components/AiChat/style.css","start_line":247,"end_line":262,"category":"bug","severity":"medium","content":"`.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.","suggestion_code":null,"existing_code":".ai-chat-hover-actions {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n padding: 3px;\n background: rgba(255, 255, 255, 0.94);\n border: 1px solid #eceef2;\n border-radius: 8px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);\n opacity: 0;\n transform: translateY(-3px);\n transition:\n opacity 0.15s ease,\n transform 0.15s ease;\n pointer-events: none;\n}"}
{"path":"apps/admin/src/components/AiChat/style.css","start_line":441,"end_line":444,"category":"maintainability","severity":"low","content":"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`.","suggestion_code":null,"existing_code":".ai-chat-review-card__summary {\n margin: 4px 0 8px !important;\n font-size: 12px;\n}"}
{"path":"apps/admin/src/components/AiChat/style.css","start_line":372,"end_line":378,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" .ai-chat-sidebar {\n position: absolute;\n z-index: 2;\n inset: 0 auto 0 0;\n box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);\n transition: width 180ms ease;\n }","existing_code":"@media (max-width: 575px) {\n .ai-chat-sidebar {\n position: absolute;\n z-index: 2;\n inset: 0 auto 0 0;\n box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);\n }"}
{"path":"apps/admin/src/pages/Exams/style.css","start_line":41,"end_line":45,"category":"maintainability","severity":"medium","content":"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`.","suggestion_code":null,"existing_code":".exam-card .ant-card-head-title span:last-child {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}"}
{"path":"apps/admin/src/pages/Exams/style.css","start_line":47,"end_line":59,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":".exam-meta,\n.exam-progress > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n margin-bottom: 10px;\n}\n\n.exam-meta span,\n.exam-progress span {\n color: rgba(0, 0, 0, 0.55);\n}"}
{"path":"apps/admin/src/pages/Exams/style.css","start_line":83,"end_line":85,"category":"other","severity":"low","content":"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.","suggestion_code":null,"existing_code":".exam-purge-action {\n color: #ff4d4f;\n}"}
{"path":"apps/admin/src/index.css","start_line":557,"end_line":559,"category":"bug","severity":"medium","content":"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.","suggestion_code":" .route-dock {\n height: 44px;\n padding: 6px 8px;\n }","existing_code":" .route-dock {\n padding: 6px 8px;\n }"}
{"path":"apps/admin/src/index.css","start_line":561,"end_line":565,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" .route-dock .ant-tabs-tab {\n min-width: 88px;\n max-width: 160px;\n padding: 0 8px 0 10px !important;\n }"}
{"path":"apps/admin/src/index.css","start_line":477,"end_line":478,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" .ant-table-wrapper::before {\n content: '表格可左右滑动查看';"}
{"path":"apps/admin/src/index.css","start_line":280,"end_line":285,"category":"bug","severity":"low","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.","suggestion_code":" .app-content {\n min-height: calc(100vh - 72px);\n min-height: calc(100dvh - 72px);\n margin: 8px !important;\n padding: 12px !important;\n border-radius: 10px !important;\n }","existing_code":" .app-content {\n min-height: calc(100dvh - 72px);\n margin: 8px !important;\n padding: 12px !important;\n border-radius: 10px !important;\n }"}
{"path":"apps/admin/src/pages/Attendance/attendance.css","start_line":744,"end_line":747,"category":"bug","severity":"medium","content":"`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.","suggestion_code":null,"existing_code":".student-center-topbar {\n position: sticky;\n top: 0;\n z-index: 12;"}
{"path":"apps/admin/src/pages/Attendance/attendance.css","start_line":845,"end_line":851,"category":"bug","severity":"low","content":"`--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.","suggestion_code":null,"existing_code":".attendance-period-row {\n display: grid;\n grid-template-columns: 1.05fr 1fr 132px 132px 92px auto;\n gap: 10px;\n align-items: start;\n padding: 12px;\n border: 1px solid var(--student-line);"}
{"path":"apps/admin/src/api/schemas/dashboard.ts","start_line":91,"end_line":91,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"/** 考勤元数据 */"}
{"path":"apps/admin/src/api/schemas/dashboard.ts","start_line":60,"end_line":63,"category":"maintainability","severity":"medium","content":"`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.","suggestion_code":"export const ganttRoomsSchema = z.array(\n z\n .object({\n roomNumber: z.string(),\n occupancies: z.array(\n z\n .object({\n studentName: z.string(),\n studentId: z.union([z.string(), z.number()]).optional(),\n checkInDate: z.string(),\n checkOutDate: z.string().nullable(),\n billingStartDate: z.string().optional(),\n billingEndDate: z.string().nullable().optional(),\n })\n .passthrough(),\n ),\n })\n .passthrough(),\n);","existing_code":"export const ganttRoomsSchema = z.array(\n z\n .object({\n roomNumber: z.string(),"}
{"path":"apps/admin/src/api/schemas/dashboard.ts","start_line":47,"end_line":51,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" top: z.array(\n z\n .object({ className: z.string(), present: z.number(), total: z.number(), rate: z.number() })\n .passthrough(),\n ),"}
{"path":"apps/admin/src/api/index.ts","start_line":21,"end_line":21,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const isLoginRequest = err.config?.url === '/auth/login' || err.config?.url === 'auth/login';"}
{"path":"apps/admin/src/api/index.ts","start_line":13,"end_line":13,"category":"bug","severity":"low","content":"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.","suggestion_code":" config.headers = config.headers ?? {};\n config.headers.Authorization = `Bearer ${token}`;","existing_code":" config.headers.Authorization = `Bearer ${token}`;"}
{"path":"apps/admin/src/api/index.ts","start_line":26,"end_line":26,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" window.location.href = '/login';"}
{"path":"apps/admin/src/api/schemas/ai.ts","start_line":25,"end_line":27,"category":"bug","severity":"medium","content":"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.","suggestion_code":"export const aiConfigEnvelopeSchema = z\n .object({\n success: z.boolean(),\n data: aiConfigSchema.nullable(),\n message: z.string().optional(),\n })\n .passthrough();","existing_code":"export const aiConfigEnvelopeSchema = z\n .object({ success: z.boolean(), data: aiConfigSchema })\n .passthrough();"}
{"path":"apps/admin/src/api/schemas/ai.ts","start_line":29,"end_line":29,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"/** 导入任务 */"}
{"path":"apps/admin/src/api/schemas/core.ts","start_line":243,"end_line":245,"category":"bug","severity":"medium","content":"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.","suggestion_code":"export const expenseTypesSchema = z.array(\n z.object({ code: z.string(), name: z.string(), category: z.string() }).passthrough(),\n);","existing_code":"export const expenseTypesSchema = z.array(\n z.object({ code: z.string(), name: z.string(), category: z.string() }),\n);"}
{"path":"apps/admin/src/api/schemas/core.ts","start_line":275,"end_line":277,"category":"bug","severity":"medium","content":"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.","suggestion_code":" id: z.number().nullable(),\n classId: z.number().nullable(),\n classroomId: z.number().nullable(),","existing_code":" id: z.number().nullable(),\n classId: z.number().nullable(),\n classroomId: z.number(),"}
{"path":"apps/admin/src/api/schemas/core.ts","start_line":41,"end_line":43,"category":"maintainability","severity":"low","content":"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.","suggestion_code":"export const idNameOptionSchema = z\n .object({ id: z.number(), name: z.string() })\n .passthrough();\n\nexport const organizationOptionSchema = idNameOptionSchema;","existing_code":"export const organizationOptionSchema = z\n .object({ id: z.number(), name: z.string() })\n .passthrough();"}
{"path":"apps/admin/src/api/imports.ts","start_line":36,"end_line":37,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {\n onUploadProgress: (event) => {"}
{"path":"apps/admin/src/api/imports.ts","start_line":76,"end_line":79,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"export function importErrorReportUrl(runId: string, stepKey?: string): string {\n const base = import.meta.env.PROD\n ? '/api'\n : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;"}
{"path":"apps/admin/src/api/queryKeys.ts","start_line":64,"end_line":65,"category":"maintainability","severity":"medium","content":"`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.","suggestion_code":null,"existing_code":" permissionTree: () => ['rbac', 'roles', 'permission-tree'] as const,\n permissionsTree: () => ['rbac', 'permissions', 'tree'] as const,"}
{"path":"apps/admin/src/api/queryKeys.ts","start_line":52,"end_line":53,"category":"bug","severity":"medium","content":"`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']`.","suggestion_code":" all: ['organizations'] as const,\n list: () => ['organizations', 'list'] as const,","existing_code":" all: ['organizations'] as const,\n list: () => ['organizations'] as const,"}
{"path":"apps/admin/src/api/queryKeys.ts","start_line":88,"end_line":90,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" deposits: {\n all: ['deposits'] as const,\n list: () => ['deposits', 'list'] as const,","existing_code":" deposits: {\n all: ['deposits'] as const,\n list: () => ['deposits'] as const,"}
{"path":"apps/admin/src/api/queryKeys.ts","start_line":31,"end_line":31,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":" schedule: (id: number, dateRange: { start: string; end: string }) => ['classes', 'schedule', id, dateRange] as const,","existing_code":" schedule: (id: number, dateRange: unknown) => ['classes', 'schedule', id, dateRange] as const,"}
{"path":"apps/admin/src/api/schemas/integration.ts","start_line":23,"end_line":23,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"/** 教室排课总览 */"}
{"path":"apps/admin/src/api/schemas/integration.ts","start_line":13,"end_line":16,"category":"maintainability","severity":"medium","content":"`.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.","suggestion_code":null,"existing_code":" .passthrough(),\n ),\n })\n .passthrough();"}
{"path":"apps/admin/src/api/schemas/import-run.ts","start_line":71,"end_line":71,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"/** 考勤记录 */"}
{"path":"apps/admin/src/api/schemas/attendance.ts","start_line":53,"end_line":53,"category":"bug","severity":"high","content":"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`.","suggestion_code":".object({\n studentId: z.number(),\n studentName: z.string(),\n studentNo: z.string().nullable().optional(),\n className: z.string(),\n type: z.string(),\n count: z.number(),\n lastDate: z.string(),\n })","existing_code":".object({ id: z.number(), type: z.string(), message: z.string() })"}
{"path":"apps/admin/src/api/schemas/attendance.ts","start_line":44,"end_line":44,"category":"documentation","severity":"low","content":"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.","suggestion_code":null,"existing_code":"/** 学生档案聚合 */"}
{"path":"apps/admin/src/auth/permission-navigation.ts","start_line":60,"end_line":63,"category":"security","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export function canAccessPath(pathname: string, permissions: readonly string[]): boolean {\n const required = getRequiredPermission(pathname);\n return required === null || permissions.includes(required);\n}"}
{"path":"apps/admin/src/auth/permission-navigation.ts","start_line":17,"end_line":21,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" {\n path: '/students',\n permission: 'student:view',\n matches: (p) => p === '/students' || /^\\/students\\/\\d+\\/profile$/.test(p),\n },"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":157,"end_line":163,"category":"bug","severity":"medium","content":"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).","suggestion_code":" if (\n (permissions.includes('room:view') &&\n (permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||\n permissions.includes('wallet:view') ||\n permissions.includes('bill:view') ||\n permissions.includes('deposit:view')\n ) {\n normalized.add('accommodation');\n }","existing_code":" if (\n (permissions.includes('room:view') &&\n (permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||\n permissions.includes('wallet:view')\n ) {\n normalized.add('accommodation');\n }"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":170,"end_line":173,"category":"bug","severity":"medium","content":"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.","suggestion_code":" if (permissions.includes('teacher-workspace:view')) {\n normalized.add('teacher');\n }","existing_code":" const hasAdministrativeDomain = [...normalized].some((role) => role !== 'teacher');\n if (!hasAdministrativeDomain && permissions.includes('teacher-workspace:view')) {\n normalized.add('teacher');\n }"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":27,"end_line":27,"category":"security","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" 财务: 'accommodation',"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":213,"end_line":213,"category":"maintainability","severity":"low","content":"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`.","suggestion_code":" sections.push({ key: section.key, label: section.label, icon: section.icon, children });","existing_code":" sections.push({ ...section, children, roles: undefined } as AppMenuItem);"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":224,"end_line":224,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" return items.flatMap((item) => (item.children ? collectMenuPaths(item.children) : [item.key]));"}
{"path":"apps/admin/src/components/AiChat/message-mappers.ts","start_line":31,"end_line":32,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" if (!a2uiForm || typeof a2uiForm !== 'object' || Array.isArray(a2uiForm)) return undefined;\n return [a2uiForm as AiFormSchema];"}
{"path":"apps/admin/src/components/AiChat/message-mappers.ts","start_line":43,"end_line":43,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {"}
{"path":"apps/admin/src/components/AiChat/message-mappers.ts","start_line":18,"end_line":18,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" return record.role === 'user' ? 'local' : 'success';"}
{"path":"apps/admin/src/components/AiChat/types.ts","start_line":233,"end_line":237,"category":"bug","severity":"medium","content":"`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`).","suggestion_code":"export interface AiApiResponse<T> {\n success: boolean;\n data: T | null;\n message?: string;\n}","existing_code":"export interface AiApiResponse<T> {\n success: boolean;\n data: T;\n message?: string;\n}"}
{"path":"apps/admin/src/components/AiChat/types.ts","start_line":167,"end_line":171,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":" reasoningContent: string | null;","existing_code":"export interface AiChatMessage {\n id?: number | string;\n role: AiMessageRole;\n content: string;\n reasoningContent: string;"}
{"path":"apps/admin/src/components/AiChat/types.ts","start_line":138,"end_line":144,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":"export type AiToolRunStatus =\n | 'running'\n | 'success'\n | 'error'\n | 'failed'\n | 'denied'\n | 'not_found';"}
{"path":"apps/admin/src/components/AiChat/api.ts","start_line":24,"end_line":24,"category":"maintainability","severity":"medium","content":"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`.","suggestion_code":null,"existing_code":" deleteConversation: (id: number) => api.delete<void>(`${basePath}/${id}`),"}
{"path":"apps/admin/src/components/AiChat/api.ts","start_line":89,"end_line":91,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"export function conversationStreamUrl(id: number): string {\n return `/api${basePath}/${id}/stream`;\n}"}
{"path":"apps/admin/src/components/AiChat/api.ts","start_line":71,"end_line":71,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" params: { page: 1, limit: 100 },"}
{"path":"apps/admin/src/components/AiChat/reviewSection.ts","start_line":32,"end_line":33,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));\n if (prefix) return prefix;\n throw new Error(`Unknown AI review section type for key: ${section.key}`);","existing_code":" const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));\n return prefix ?? 'students';"}
{"path":"apps/admin/src/components/AiChat/reviewSection.ts","start_line":94,"end_line":96,"category":"bug","severity":"low","content":"`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.","suggestion_code":" if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';\n if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';\n if (items.every((item) => sectionStatus(item) === 'skipped')) return 'pending';\n return 'partial';","existing_code":" if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';\n if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';\n return 'partial';"}
{"path":"apps/admin/src/components/AiChat/reviewSection.ts","start_line":20,"end_line":31,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" const isSectionType = (value: unknown): value is AiReviewSectionType =>\n value === 'students' || value === 'rooms' || value === 'transfers' || value === 'checkins';\n if (isSectionType(section.type)) {\n return section.type;\n }\n if (isSectionType(section.key)) {\n return section.key;\n }","existing_code":" if (\n section.type === 'students' ||\n section.type === 'rooms' ||\n section.type === 'transfers' ||\n section.type === 'checkins'\n ) {\n return section.type;\n }\n const key = section.key as AiReviewSectionType;\n if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {\n return key;\n }"}
{"path":"apps/admin/src/components/AiChat/sseReducer.ts","start_line":92,"end_line":94,"category":"bug","severity":"medium","content":"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.","suggestion_code":" const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);\n if (index === -1) return [...toolRuns, next];\n return toolRuns.map((item, itemIndex) =>\n itemIndex === index\n ? {\n ...item,\n ...next,\n argumentsSummary: next.argumentsSummary ?? item.argumentsSummary,\n resultSummary: next.resultSummary ?? item.resultSummary,\n }\n : item,\n );","existing_code":" const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);\n if (index === -1) return [...toolRuns, next];\n return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));"}
{"path":"apps/admin/src/components/AiChat/sseReducer.ts","start_line":152,"end_line":157,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" } else if (event === 'reasoning.delta') {\n message.retrying = null;\n message.reasoningContent = (message.reasoningContent ?? '') + (payload.delta ?? payload.reasoningContent ?? '');\n } else if (event === 'content.delta') {\n message.retrying = null;\n message.content = (message.content ?? '') + (payload.delta ?? payload.content ?? '');","existing_code":" } else if (event === 'reasoning.delta') {\n message.retrying = null;\n message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';\n } else if (event === 'content.delta') {\n message.retrying = null;\n message.content += payload.delta ?? payload.content ?? '';"}
{"path":"apps/admin/src/components/AiChat/sseReducer.ts","start_line":81,"end_line":81,"category":"bug","severity":"medium","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.","suggestion_code":null,"existing_code":" const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;"}
{"path":"apps/admin/src/components/AiChat/sseReducer.ts","start_line":133,"end_line":134,"category":"bug","severity":"low","content":"`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.","suggestion_code":" message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;\n message.metadata = { ...(message.metadata ?? {}), ...(nested.metadata ?? {}) };","existing_code":" message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;\n message.metadata = nested.metadata ?? message.metadata;"}
{"path":"apps/admin/src/components/AiChat/uiArtifacts.ts","start_line":13,"end_line":13,"category":"style","severity":"low","content":"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.","suggestion_code":" let items: T[];\n if (Array.isArray(incoming)) {\n items = incoming;\n } else if (incoming) {\n items = [incoming];\n } else {\n items = [];\n }","existing_code":" const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];"}
{"path":"apps/admin/src/components/AiChat/uiArtifacts.ts","start_line":39,"end_line":40,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" return {\n ...message,\n uiArtifacts: mergeById<AiArtifactSchema>(message.uiArtifacts, artifact),\n };","existing_code":" message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);\n return message;"}
{"path":"apps/admin/src/components/AiChat/uiArtifacts.ts","start_line":51,"end_line":51,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" .filter((payload): payload is AiFormSchema =>\n Boolean(payload) && typeof payload === 'object' && Array.isArray(payload.fields),\n );","existing_code":" .filter((payload): payload is AiFormSchema => Boolean(payload) && typeof payload === 'object');"}
{"path":"apps/admin/src/components/JinshujuMatchModal.types.ts","start_line":1,"end_line":11,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"export interface JinshujuEntryRow {\n serialNumber: number;\n name: string;\n phone: string | null;\n suggestedStudent: {\n id: number;\n name: string;\n phone: string | null;\n studentNo: string | null;\n } | null;\n}"}
{"path":"apps/admin/src/components/ImportWizard/types.ts","start_line":34,"end_line":35,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" status: 'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired';\n currentStepKey: ImportStepKey | null;"}
{"path":"apps/admin/src/components/AiChat/provider.ts","start_line":83,"end_line":87,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" // 提示由登录页读取展示:直接弹 toast 会被跳转销毁\n sessionStorage.setItem('login_expired_hint', '1');\n useUserStore.getState().logout();\n usePermissionStore.getState().clearPermissions();\n window.location.href = '/login';"}
{"path":"apps/admin/src/components/AiChat/provider.ts","start_line":130,"end_line":130,"category":"bug","severity":"low","content":"`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)}\\``.","suggestion_code":null,"existing_code":" clientRequestId: requestParams.clientRequestId || crypto.randomUUID(),"}
{"path":"apps/admin/src/components/AiChat/provider.ts","start_line":28,"end_line":28,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" requestInput = `${String(input).replace(/\\/stream$/, '')}/messages/${body.regenerateMessageId}/regenerate/stream`;"}
{"path":"apps/admin/src/components/AiChat/useSubmissionState.ts","start_line":77,"end_line":82,"category":"bug","severity":"high","content":"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.:\n\n```ts\nconst surfaceKey = surfaceId;\nuseEffect(() => {\n if (idRef.current !== surfaceKey) {\n commandsRef.current = [];\n idRef.current = surfaceKey;\n setCommands([]);\n }\n}, [surfaceKey]);\n```\n\nAlternatively, remount the consumer component per surface with `key={surfaceId}`.","suggestion_code":null,"existing_code":" const surfaceKey = surfaceId;\n if (idRef.current !== surfaceKey) {\n // 组件复用到新 surface 时,清空历史命令重新初始化\n commandsRef.current = [];\n idRef.current = surfaceKey;\n }"}
{"path":"apps/admin/src/components/AiChat/useSubmissionState.ts","start_line":20,"end_line":22,"category":"bug","severity":"medium","content":"`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`.","suggestion_code":" submittingRef.current = true;\n setSubmitting(true);\n setSubmitted(false);\n setError(null);","existing_code":" submittingRef.current = true;\n setSubmitting(true);\n setError(null);"}
{"path":"apps/admin/src/components/AiChat/welcomeCopy.ts","start_line":39,"end_line":41,"category":"maintainability","severity":"medium","content":"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).","suggestion_code":null,"existing_code":" if (domains.has('academic')) {\n examples.push(\n { label: '帮我从 Excel 导入学生并完成分班', description: '教学闭环' },"}
{"path":"apps/admin/src/components/AiChat/welcomeCopy.ts","start_line":7,"end_line":8,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"export function welcomeDescription(roles: readonly string[], permissions: readonly string[]): string {\n const domains = getRoleDomains(roles, permissions);"}
{"path":"apps/admin/src/components/StudentProfileContent/shared.ts","start_line":194,"end_line":194,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;","existing_code":" return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;"}
{"path":"apps/admin/src/components/StudentProfileContent/shared.ts","start_line":116,"end_line":116,"category":"maintainability","severity":"low","content":"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).","suggestion_code":null,"existing_code":" withdrawn: { text: '放弃', color: '#999' },"}
{"path":"apps/admin/src/hooks/usePermission.ts","start_line":17,"end_line":21,"category":"bug","severity":"low","content":"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.","suggestion_code":" const hasAllPermissions = useCallback(\n (...codes: string[]): boolean =>\n permissionsReady && codes.length > 0 && codes.every((code) => permissions.includes(code)),\n [permissions, permissionsReady],\n );","existing_code":" const hasAllPermissions = useCallback(\n (...codes: string[]): boolean =>\n permissionsReady && codes.every((code) => permissions.includes(code)),\n [permissions, permissionsReady],\n );"}
{"path":"apps/admin/src/hooks/usePageVisible.ts","start_line":29,"end_line":30,"category":"bug","severity":"medium","content":"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).","suggestion_code":" const keyRef = useRef(queryKey);\n useEffect(() => {\n keyRef.current = queryKey;\n }, [queryKey]);","existing_code":" const keyRef = useRef(queryKey);\n keyRef.current = queryKey;"}
{"path":"apps/admin/src/hooks/usePageVisible.ts","start_line":32,"end_line":36,"category":"performance","severity":"low","content":"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.","suggestion_code":" const prevVisibleRef = useRef(visible);\n useEffect(() => {\n const becameVisible = visible && !prevVisibleRef.current;\n prevVisibleRef.current = visible;\n if (becameVisible && keyRef.current) {\n void queryClient.refetchQueries({ queryKey: keyRef.current });\n }\n }, [visible, queryClient]);","existing_code":" useEffect(() => {\n if (visible && keyRef.current) {\n void queryClient.refetchQueries({ queryKey: keyRef.current });\n }\n }, [visible, queryClient]);"}
{"path":"apps/admin/src/hooks/useSubmitShortcut.ts","start_line":18,"end_line":18,"category":"performance","severity":"low","content":"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.","suggestion_code":" const onSubmitRef = useRef(onSubmit);\n useEffect(() => {\n onSubmitRef.current = onSubmit;\n });\n\n useEffect(() => {\n if (!active) return;\n const handleKeyDown = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault();\n onSubmitRef.current();\n }\n };\n window.addEventListener('keydown', handleKeyDown);\n return () => window.removeEventListener('keydown', handleKeyDown);\n }, [active]);","existing_code":" }, [active, onSubmit]);"}
{"path":"apps/admin/src/hooks/useSubmitShortcut.ts","start_line":11,"end_line":11,"category":"bug","severity":"low","content":"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`.","suggestion_code":" if (event.isComposing) return;\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {","existing_code":" if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {"}
{"path":"apps/admin/src/hooks/useDirtyGuard.ts","start_line":19,"end_line":19,"category":"bug","severity":"medium","content":"`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.","suggestion_code":"const pristineRef = useRef<unknown>(form.getFieldsValue());","existing_code":"const pristineRef = useRef<unknown>(null);"}
{"path":"apps/admin/src/hooks/useDirtyGuard.ts","start_line":28,"end_line":28,"category":"bug","severity":"low","content":"`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.","suggestion_code":"return !equal(form.getFieldsValue(true), pristineRef.current);","existing_code":"return !equal(form.getFieldsValue(), pristineRef.current);"}
{"path":"apps/admin/src/hooks/useDownload.ts","start_line":20,"end_line":21,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" const [downloading, setDownloading] = useState(false);\n const busyRef = useRef(false);"}
{"path":"apps/admin/src/hooks/useDownload.ts","start_line":32,"end_line":34,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":"message.error(\n options?.errorMsg ?? (error instanceof Error ? error.message : '下载失败,请重试'),\n );"}
{"path":"apps/admin/src/hooks/useDownload.ts","start_line":35,"end_line":38,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" } finally {\n busyRef.current = false;\n setDownloading(false);\n }"}
{"path":"apps/admin/src/hooks/useApiQuery.ts","start_line":10,"end_line":10,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":" schema: z.ZodType<T, z.ZodTypeDef, unknown>;","existing_code":" schema: z.ZodType<unknown>;"}
{"path":"apps/admin/src/hooks/useApiQuery.ts","start_line":28,"end_line":28,"category":"performance","severity":"low","content":"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.","suggestion_code":null,"existing_code":" queryFn: async () => validateResponse<T>(schema, await queryFn()),"}
{"path":"apps/admin/src/pages/Attendance/types.ts","start_line":15,"end_line":16,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" session: string;\n status: string;"}
{"path":"apps/admin/src/hooks/useApiMutation.ts","start_line":40,"end_line":40,"category":"bug","severity":"medium","content":"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.","suggestion_code":"export function useApiMutation<TData = unknown, TVars = void, TContext = unknown, TError = Error>(\n ...\n return useMutation<TData, TError, TVars, TContext>({","existing_code":"return useMutation<TData, Error, TVars, TContext>({"}
{"path":"apps/admin/src/hooks/useApiMutation.ts","start_line":49,"end_line":51,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" onSuccess: async (data, vars, context) => {\n const keys = options.invalidate ?? [];\n await Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: key })));\n options.onSuccess?.(data, vars, context);\n },","existing_code":" for (const key of options.invalidate ?? []) {\n void queryClient.invalidateQueries({ queryKey: key });\n }"}
{"path":"apps/admin/src/components/RouteDock/dockTabs.ts","start_line":13,"end_line":13,"category":"bug","severity":"medium","content":"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.","suggestion_code":" // 保持原顺序,仅剔除最旧的非当前页签,避免 active-first 重排破坏后续淘汰次序\n const dropIndex = list.findIndex((tab) => tab.key !== activeKey);\n return dropIndex >= 0 ? list.filter((_, i) => i !== dropIndex) : list.slice(-MAX_DOCK_TABS);","existing_code":" return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS);"}
{"path":"apps/admin/src/components/RouteDock/dockTabs.ts","start_line":12,"end_line":13,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" return active ? [active, ...keptRest] : keptRest;","existing_code":" const keptRest = rest.slice(rest.length - (MAX_DOCK_TABS - 1));\n return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS);"}
{"path":"apps/admin/src/components/RouteDock/dockTabs.ts","start_line":8,"end_line":8,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" if (list.length <= MAX_DOCK_TABS) return list as DockTab[];"}
{"path":"apps/admin/src/hooks/useViewSensitive.ts","start_line":21,"end_line":30,"category":"bug","severity":"medium","content":"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.","suggestion_code":" useEffect(() => {\n if (!canLog && modalRef.current) {\n modalRef.current.destroy();\n modalRef.current = null;\n }\n return () => {\n modalRef.current?.destroy();\n modalRef.current = null;\n };\n }, [canLog]);","existing_code":" useEffect(() => {\n if (!canLogRef.current && modalRef.current) {\n modalRef.current.destroy();\n modalRef.current = null;\n }\n return () => {\n modalRef.current?.destroy();\n modalRef.current = null;\n };\n }, []);"}
{"path":"apps/admin/src/hooks/useViewSensitive.ts","start_line":35,"end_line":35,"category":"bug","severity":"low","content":"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.","suggestion_code":" modalRef.current?.destroy();\n modalRef.current = modal.confirm({","existing_code":" modalRef.current = modal.confirm({"}
{"path":"apps/admin/src/pages/Attendance/attendance-workspace.ts","start_line":130,"end_line":142,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":" let label: string;\n if (machine) {\n label = '考勤机打卡';\n } else if (source === 'USER') {\n label = '手机打卡';\n } else if (source.includes('BEACON') || source.includes('BLE')) {\n label = '蓝牙打卡';\n } else if (source.includes('WIFI')) {\n label = 'Wi-Fi 打卡';\n } else if (source.includes('APPROVE')) {\n label = '审批补卡';\n } else if (source) {\n label = `其他打卡(${record.punchSource}`;\n } else {\n label = '打卡来源未知';\n }","existing_code":" const label = machine\n ? '考勤机打卡'\n : source === 'USER'\n ? '手机打卡'\n : source.includes('BEACON') || source.includes('BLE')\n ? '蓝牙打卡'\n : source.includes('WIFI')\n ? 'Wi-Fi 打卡'\n : source.includes('APPROVE')\n ? '审批补卡'\n : source\n ? `其他打卡(${record.punchSource}`\n : '打卡来源未知';"}
{"path":"apps/admin/src/pages/Attendance/attendance-workspace.ts","start_line":22,"end_line":31,"category":"bug","severity":"medium","content":"Phase is computed purely from the time-of-day, so any schedule that crosses midnight (endTime < startTime, e.g. 22:0002: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.","suggestion_code":null,"existing_code":"export function getSchedulePhase(\n startTime: string,\n endTime: string,\n now = new Date(),\n): SchedulePhase {\n const current = now.getHours() * 60 + now.getMinutes();\n if (current < toMinuteOfDay(startTime)) return 'upcoming';\n if (current <= toMinuteOfDay(endTime)) return 'ongoing';\n return 'ended';\n}"}
{"path":"apps/admin/src/pages/Attendance/attendance-workspace.ts","start_line":56,"end_line":58,"category":"bug","severity":"low","content":"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.","suggestion_code":" if (Object.hasOwn(summary, record.status) && record.status !== 'total') {\n summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;\n }","existing_code":" if (record.status in summary && record.status !== 'total') {\n summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;\n }"}
{"path":"apps/admin/src/pages/Classes/teacher-candidate.ts","start_line":17,"end_line":17,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":" role.code === 'super_admin';","existing_code":" role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管';"}
{"path":"apps/admin/src/pages/Classes/teacher-candidate.ts","start_line":21,"end_line":21,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" if (user.studentStatus && user.studentStatus !== 'staff') return false;"}
{"path":"apps/admin/src/pages/Bills/bill-print.ts","start_line":46,"end_line":46,"category":"bug","severity":"medium","content":"`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`.","suggestion_code":"const money = (value: unknown) => {\n const n = Number(value ?? 0);\n return `¥${Number.isFinite(n) ? n.toFixed(2) : '0.00'}`;\n};","existing_code":"const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;"}
{"path":"apps/admin/src/pages/Bills/bill-print.ts","start_line":55,"end_line":57,"category":"bug","severity":"medium","content":"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.","suggestion_code":" <td>${Number.isFinite(Number(item.days)) ? Number(item.days) : 0}</td>\n <td>${Number.isFinite(Number(item.totalRoomDays)) ? Number(item.totalRoomDays) : 0}</td>\n <td>${money(item.studentAmount)}</td>","existing_code":" <td>${Number(item.days || 0)}</td>\n <td>${Number(item.totalRoomDays || 0)}</td>\n <td>${Number(item.studentAmount || 0).toFixed(2)}</td>"}
{"path":"apps/admin/src/pages/Bills/bill-print.ts","start_line":96,"end_line":96,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" <h1>恭学教育基地水电费账单</h1>"}
{"path":"apps/admin/src/pages/Occupancies/occupancy-form.ts","start_line":23,"end_line":23,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"stayType: values.stayType || 'short',"}
{"path":"apps/admin/src/pages/Occupancies/occupancy-form.ts","start_line":25,"end_line":25,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"depositAmount: collectDeposit ? values.depositAmount ?? 500 : undefined,"}
{"path":"apps/admin/src/pages/Occupancies/occupancy-form.ts","start_line":28,"end_line":28,"category":"bug","severity":"low","content":"`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.","suggestion_code":"lockerId: values.lockerId,","existing_code":"lockerId: values.lockerId || undefined,"}
{"path":"apps/admin/src/pages/Occupancies/occupancy-form.ts","start_line":45,"end_line":45,"category":"bug","severity":"low","content":"Same issue as the check-in payload: `values.newLockerId || undefined` drops a valid `0`. Use nullish coalescing or pass the value directly.","suggestion_code":"newLockerId: values.newLockerId,","existing_code":"newLockerId: values.newLockerId || undefined,"}
{"path":"apps/admin/src/pages/Exams/types.ts","start_line":1,"end_line":1,"category":"maintainability","severity":"low","content":"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`.","suggestion_code":"import type { Dayjs } from 'dayjs';","existing_code":"import type dayjs from 'dayjs';"}
{"path":"apps/admin/src/pages/Exams/types.ts","start_line":30,"end_line":31,"category":"maintainability","severity":"low","content":"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.","suggestion_code":"export const EXAM_TYPE_OPTIONS = [\n { value: '月考', label: '月考' },\n { value: '周测', label: '周测' },\n { value: '期中考试', label: '期中考试' },\n { value: '期末考试', label: '期末考试' },\n { value: '模拟考试', label: '模拟考试' },\n { value: '入学测试', label: '入学测试' },\n] as const;","existing_code":"export const EXAM_TYPE_OPTIONS = [\n { value: '月考', label: '月考' },"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":203,"end_line":206,"category":"security","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" tooltip: {\n formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>\n `${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,\n },"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":160,"end_line":164,"category":"security","severity":"low","content":"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.","suggestion_code":null,"existing_code":" formatter: (p: {\n name: string;\n data: { scheduleDays: number; rentalCount: number; occupancy: number };\n }) =>\n `${p.name}<br/>排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`,"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":232,"end_line":237,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" const rectShape = {\n x: start[0],\n y: start[1] - height / 2,\n width: Math.max(end[0] - start[0], 2),\n height,\n };"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":133,"end_line":136,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":"export function buildExpensePieOption(\n rows: ExpenseByTypeRow[],\n expenseTypeMap: Record<string, string>,\n): EChartsOption {"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":51,"end_line":51,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },"}
{"path":"apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts","start_line":64,"end_line":64,"category":"maintainability","severity":"medium","content":"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.:\n\n```ts\nconst { dirty, formValues } = get();\nlet nextFormValues: Partial<DingTalkConfigFormValues> = {};\nif (!dirty) {\n nextFormValues = config ? { ...config, appSecret: undefined } : {};\n} else {\n nextFormValues = formValues;\n}\n```","suggestion_code":null,"existing_code":" formValues: dirty ? formValues : config ? { ...config, appSecret: undefined } : {},"}
{"path":"apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts","start_line":75,"end_line":75,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" formValues: { ...config, appSecret: undefined },"}
{"path":"apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts","start_line":84,"end_line":84,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" { name: 'integration-config-store', enabled: import.meta.env.DEV },"}
{"path":"apps/admin/src/pages/Schedules/sync-result.ts","start_line":28,"end_line":28,"category":"bug","severity":"medium","content":"`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`.","suggestion_code":"const hasFailure = result.failedBatchCount > 0 || result.failedItems > 0 || result.errors.length > 0;","existing_code":"const hasFailure = result.failedBatchCount > 0 || result.errors.length > 0;"}
{"path":"apps/admin/src/pages/Schedules/sync-result.ts","start_line":3,"end_line":6,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":"export interface SyncResultInput {\n scheduleCount: number;\n syncedItems: number;\n skippedNoMapping: number;"}
{"path":"apps/admin/src/pages/archive-view.ts","start_line":29,"end_line":29,"category":"maintainability","severity":"low","content":"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]`.","suggestion_code":"batchAction: ({ active: 'checkout', all: 'archive', archived: 'restore' } as const)[view],","existing_code":"batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore',"}
{"path":"apps/admin/src/pages/archive-view.ts","start_line":22,"end_line":26,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({\n batchAction: view === 'archived' ? 'restore' : 'archive',\n readonly: view === 'archived',\n purgeBatch: view === 'archived',\n});"}
{"path":"apps/admin/src/pages/archive-view.ts","start_line":20,"end_line":20,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":"export const expenseStatusForView = (view: ArchiveView) => view;"}
{"path":"apps/admin/src/pages/archive-view.ts","start_line":37,"end_line":40,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export const occupancyParamsForView = (view: OccupancyView) => ({\n active: view === 'active' ? 'true' : undefined,\n status: view === 'archived' ? 'archived' : 'active',\n});"}
{"path":"apps/admin/src/pages/Schedules/schedule-visibility.ts","start_line":17,"end_line":17,"category":"style","severity":"low","content":"`==`/`!=` 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;`","suggestion_code":" if (classId === undefined || classId === null) return schedules;","existing_code":" if (classId == null) return schedules;"}
{"path":"apps/admin/src/store/app/appTypes.ts","start_line":36,"end_line":39,"category":"maintainability","severity":"low","content":"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.","suggestion_code":"export type AppPersistedState = Pick<AppState, 'sidebarCollapsed' | 'routeDockTabs'>;","existing_code":"export interface AppPersistedState {\n sidebarCollapsed: boolean;\n routeDockTabs: DockTab[];\n}"}
{"path":"apps/admin/src/pages/Rooms/useRoomMutations.ts","start_line":106,"end_line":106,"category":"maintainability","severity":"medium","content":"`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.","suggestion_code":"export function useRoomMutations(editing: { id: number } | null | undefined) {","existing_code":"export function useRoomMutations(editing: any) {"}
{"path":"apps/admin/src/pages/Rooms/useRoomMutations.ts","start_line":113,"end_line":113,"category":"maintainability","severity":"medium","content":"`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.","suggestion_code":"async ({ record, field, value }: { record: { id: number }; field: string; value: unknown }) =>","existing_code":"async ({ record, field, value }: { record: any; field: string; value: unknown }) =>"}
{"path":"apps/admin/src/pages/Rooms/useRoomMutations.ts","start_line":53,"end_line":53,"category":"maintainability","severity":"medium","content":"`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.","suggestion_code":null,"existing_code":"export function useLockerMutations() {"}
{"path":"apps/admin/src/pages/Schedules/schedule-form.ts","start_line":38,"end_line":38,"category":"bug","severity":"medium","content":"Both start/end times are anchored to the same fixed date `2000-01-01`. For schedules that span midnight (e.g. 23:0000: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).","suggestion_code":null,"existing_code":"timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],"}
{"path":"apps/admin/src/pages/Schedules/schedule-form.ts","start_line":37,"end_line":37,"category":"maintainability","severity":"low","content":"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).","suggestion_code":null,"existing_code":"attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30,"}
{"path":"apps/admin/src/pages/Occupancies/useOccupancyMutations.ts","start_line":4,"end_line":5,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":"const INVALIDATE_OCCUPANCIES: QueryKey[] = [['occupancies']];\n\nexport function useOccupancyMutations() {","existing_code":"export function useOccupancyMutations() {\n const invalidateOccupancies: Array<readonly unknown[]> = [['occupancies']];"}
{"path":"apps/admin/src/pages/Occupancies/useOccupancyMutations.ts","start_line":46,"end_line":47,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" async ({ formData, params }: { formData: FormData; params: Record<string, unknown> }) => {\n const query = new URLSearchParams(params as Record<string, string>).toString();\n return api.post(`/occupancies/import${query ? `?${query}` : ''}`, formData);\n },","existing_code":" async ({ formData, params }: { formData: FormData; params: string }) =>\n api.post(`/occupancies/import?${params}`, formData),"}
{"path":"apps/admin/src/store/permission/permissionTypes.ts","start_line":6,"end_line":7,"category":"maintainability","severity":"low","content":"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`.","suggestion_code":" /** 已校验通过的权限码 */\n permissions: readonly string[];","existing_code":" /** 已校验通过的权限码 */\n permissions: string[];"}
{"path":"apps/admin/src/store/app/appStore.ts","start_line":62,"end_line":63,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" version: 2,\n migrate: migrateAppUiState,"}
{"path":"apps/admin/src/store/user/userTypes.ts","start_line":32,"end_line":35,"category":"maintainability","severity":"low","content":"`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).","suggestion_code":"export type UserPersistedState = Pick<UserState, 'token' | 'user'>;","existing_code":"export interface UserPersistedState {\n token: string | null;\n user: UserInfo | null;\n}"}
{"path":"apps/admin/src/store/user/userTypes.ts","start_line":37,"end_line":38,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":"/** 兼容:用户状态中的权限由 permission Store 统一管理 */\nexport type { StoreStatus };"}
{"path":"apps/admin/src/store/user/userActions.ts","start_line":23,"end_line":29,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" updateUser: (patch) => {\n set(\n (state) => {\n if (!state.user) return {};\n return { user: { ...state.user, ...patch } };\n },\n false,\n 'user/updateUser',\n );\n },","existing_code":" updateUser: (patch) => {\n set(\n (state) => (state.user ? { user: { ...state.user, ...patch } } : {}),\n false,\n 'user/updateUser',\n );\n },"}
{"path":"apps/admin/src/store/editableCell/editableCellStore.ts","start_line":17,"end_line":18,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" /** 当前处于编辑态的单元格(全局唯一) */\n activeCell: EditableCellSession | null;"}
{"path":"apps/admin/src/store/editableCell/editableCellStore.ts","start_line":35,"end_line":35,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" setActiveCell: (session) => set({ activeCell: session }, false, 'editableCell/setActiveCell'),"}
{"path":"apps/admin/src/utils/download.ts","start_line":15,"end_line":18,"category":"bug","severity":"medium","content":"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.","suggestion_code":" const token = useUserStore.getState().token;\n const headers = token ? { Authorization: `Bearer ${token}` } : {};\n const res = await fetch(`${baseURL}${endpoint}`, { headers });","existing_code":" const token = useUserStore.getState().token;\n const res = await fetch(`${baseURL}${endpoint}`, {\n headers: { Authorization: `Bearer ${token}` },\n });"}
{"path":"apps/admin/src/utils/download.ts","start_line":16,"end_line":20,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" let res: Response;\n try {\n res = await fetch(`${baseURL}${endpoint}`, {\n headers: { Authorization: `Bearer ${token}` },\n });\n } catch {\n throw new Error('网络错误,下载失败,请稍后重试');\n }\n\n if (!res.ok) {","existing_code":" const res = await fetch(`${baseURL}${endpoint}`, {\n headers: { Authorization: `Bearer ${token}` },\n });\n\n if (!res.ok) {"}
{"path":"apps/admin/src/utils/download.ts","start_line":11,"end_line":13,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" const baseURL =\n import.meta.env.VITE_API_BASE_URL ||\n (import.meta.env.PROD\n ? '/api'\n : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`);","existing_code":" const baseURL = import.meta.env.PROD\n ? '/api'\n : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;"}
{"path":"apps/admin/src/store/settings/settingsStore.ts","start_line":38,"end_line":39,"category":"bug","severity":"low","content":"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`.","suggestion_code":" partialize: (state): SettingsState => ({ aiChat: state.aiChat }),\n version: 1,\n merge: (persisted, current) => {\n const p = persisted as Partial<SettingsState> | undefined;\n return {\n ...current,\n aiChat: {\n deepThinking: p?.aiChat?.deepThinking ?? current.aiChat.deepThinking,\n },\n };\n },","existing_code":" partialize: (state): SettingsState => ({ aiChat: state.aiChat }),\n version: 1,"}
{"path":"apps/admin/src/ui/app-message.ts","start_line":10,"end_line":12,"category":"bug","severity":"medium","content":"`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.","suggestion_code":null,"existing_code":"if (!messageApi) {\n throw new Error('Ant Design message API has not been initialized');\n }"}
{"path":"apps/admin/src/ui/app-message.ts","start_line":5,"end_line":7,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export function bindMessageApi(api: MessageInstance): void {\n messageApi = api;\n}"}
{"path":"apps/admin/src/utils/operation-id.ts","start_line":1,"end_line":1,"category":"bug","severity":"low","content":"`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.","suggestion_code":"export const newOperationId = () => {\n if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();\n const bytes = crypto.getRandomValues(new Uint8Array(16));\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n return [...bytes].map((b, i) =>\n [4, 6, 8, 10].includes(i) ? `-${b.toString(16).padStart(2, '0')}` : b.toString(16).padStart(2, '0')\n ).join('');\n};","existing_code":"export const newOperationId = () => crypto.randomUUID();"}
{"path":"apps/admin/src/test/setup.ts","start_line":22,"end_line":24,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" localStorage.removeItem('token');\n localStorage.removeItem('user');\n localStorage.removeItem('permissions');"}
{"path":"apps/admin/src/test/setup.ts","start_line":35,"end_line":35,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" useSettingsStore.setState({ aiChat: { deepThinking: false } });"}
{"path":"apps/admin/src/test/setup.ts","start_line":12,"end_line":12,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"const BASE = 'http://localhost:3002';"}
{"path":"apps/admin/src/store/user/userStore.ts","start_line":22,"end_line":22,"category":"security","severity":"low","content":"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.","suggestion_code":null,"existing_code":"storage: authPersistStorage,"}
{"path":"apps/admin/src/store/user/userStore.ts","start_line":15,"end_line":15,"category":"maintainability","severity":"low","content":"`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`.","suggestion_code":"(set) => ({","existing_code":"(set, get, api) => ({"}
{"path":"apps/admin/src/utils/error.ts","start_line":11,"end_line":13,"category":"bug","severity":"medium","content":"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`.","suggestion_code":" const data = error.response?.data;\n if (typeof data === 'string' && data) message = data;\n else if (data && typeof data === 'object') {\n const msg = (data as { message?: unknown }).message;\n if (typeof msg === 'string' && msg) message = msg;\n else if (Array.isArray(msg) && msg.length) message = msg.join('; ');\n else if (msg && typeof msg === 'object') message = JSON.stringify(msg);\n }\n if (!message && error.message) message = error.message;","existing_code":" const data = error.response?.data as { message?: unknown } | undefined;\n if (typeof data?.message === 'string' && data.message) message = data.message;\n else if (error.message) message = error.message;"}
{"path":"apps/admin/src/utils/error.ts","start_line":22,"end_line":22,"category":"other","severity":"low","content":"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.","suggestion_code":null,"existing_code":" const singleLine = trimmed.replace(/[\\n\\r]+/g, ' ').replace(/ {2,}/g, ' ');"}
{"path":"apps/admin/src/utils/notification-display.ts","start_line":21,"end_line":24,"category":"bug","severity":"medium","content":"`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:","suggestion_code":" const rolePattern = new RegExp(\n Object.keys(teacherRoleLabels)\n .sort((a, b) => b.length - a.length)\n .join('|'),\n 'g',\n );\n return (text ?? '').replace(rolePattern, (match) => teacherRoleLabels[match]);","existing_code":" return Object.entries(teacherRoleLabels).reduce(\n (result, [roleType, label]) => result.replaceAll(roleType, label),\n text,\n );"}
{"path":"apps/admin/src/utils/notification-display.ts","start_line":20,"end_line":20,"category":"bug","severity":"medium","content":"`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.","suggestion_code":"export function formatNotificationText(text = ''): string {","existing_code":"export function formatNotificationText(text: string): string {"}
{"path":"apps/admin/src/store/permission/permissionStore.ts","start_line":24,"end_line":26,"category":"security","severity":"low","content":"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`.","suggestion_code":null,"existing_code":" beginPermissionVerification: () => {\n // 后台刷新时保留已就绪的权限,避免界面闪加载\n if (get().status === 'ready') return;"}
{"path":"apps/admin/src/store/permission/permissionStore.ts","start_line":29,"end_line":31,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" writePermissions: (permissions) => {\n set(\n { permissions: [...new Set(permissions)], status: 'ready' },"}
{"path":"apps/admin/src/utils/sensitive.ts","start_line":2,"end_line":2,"category":"security","severity":"medium","content":"Privacy/correctness issue: when the input is shorter than the threshold, the raw unmasked value is returned (or the value is only trivially masked). For a 7-char phone, `slice(0,3)` + `slice(-4)` reveals all 7 digits with asterisks inserted in the middle, and a 6-digit value is returned in full — defeating the purpose of masking sensitive PII. Consider returning a fully masked fallback (e.g., `'****'` or `'-'`) for values below the threshold and requiring a length that guarantees a meaningful hidden portion.","suggestion_code":null,"existing_code":"if (!phone || phone.length < 7) return phone || '-';"}
{"path":"apps/admin/src/utils/sensitive.ts","start_line":6,"end_line":9,"category":"maintainability","severity":"low","content":"This function duplicates `maskPhone` almost exactly (same guard + slice pattern, only threshold and mask length differ). Consider extracting a generic helper, e.g. `maskValue(value: string, minLen: number, head: number, tail: number, mask: string)`, and having both exports delegate to it to avoid divergent behavior in the future.","suggestion_code":null,"existing_code":"export const maskIdNumber = (id: string): string => {\n if (!id || id.length < 8) return id || '-';\n return id.slice(0, 3) + '***********' + id.slice(-4);\n};"}
{"path":"apps/admin/src/utils/sensitive.ts","start_line":8,"end_line":8,"category":"bug","severity":"medium","content":"The mask uses a fixed 11 asterisks regardless of the actual input length. For IDs shorter than 18 digits, the masked output becomes longer than the original (e.g., an 8-char ID becomes 18 chars) and the hidden portion is not proportional. Also, for an 8-char ID, only 1 character is actually hidden (first 3 + last 4 = 7 of 8), so the ID is effectively exposed. Prefer building the mask from the actual hidden length (e.g., repeat `'*'` by `id.length - head - tail`) to keep the output length consistent and hide a meaningful portion.","suggestion_code":null,"existing_code":"return id.slice(0, 3) + '***********' + id.slice(-4);"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":73,"end_line":84,"category":"bug","severity":"medium","content":"Data-loss risk: `adapter.clearLegacy()` runs unconditionally even when `localStorage.setItem` throws (e.g., QuotaExceededError / storage disabled). The old key is the only copy of the session (token/user or dock tabs) before migration, so a failed write during the migrate write-back permanently destroys the legacy data — on the next page load the user is logged out with no way to recover. `removeItem` has the same problem. Only clear the legacy key after the new-key operation has succeeded.","suggestion_code":" setItem: (name, value) => {\n try {\n localStorage.setItem(name, value);\n adapter.clearLegacy();\n } catch {\n // 持久化失败不应影响应用运行,同时保留旧 key 以便下次重试\n }\n },","existing_code":" setItem: (name, value) => {\n try {\n localStorage.setItem(name, value);\n } catch {\n // 持久化失败不应影响应用运行\n }\n try {\n adapter.clearLegacy();\n } catch {\n // 清理失败不影响应用运行\n }\n },"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":46,"end_line":47,"category":"bug","severity":"low","content":"Inconsistent robustness: the write paths (`setItem`/`removeItem`) are wrapped in try/catch, but the read paths (`localStorage.getItem` in `readLegacyAuth`, `legacyDockValue`, `legacyFallbackStorage.getItem` and `permissionStorage.getItem`) are not. In restricted environments (storage disabled, privacy mode) `localStorage.getItem` can throw SecurityError, which would propagate out of the storage's `getItem` and break store initialization — the opposite of the stated intent that persistence failures should not affect the app. Guard the reads as well.","suggestion_code":" let token: string | null = null;\n let user: UserInfo | null = null;\n try {\n token = localStorage.getItem(LEGACY_TOKEN_KEY);\n const rawUser = localStorage.getItem(LEGACY_USER_KEY);\n if (rawUser !== null) {\n try {\n const parsed: unknown = JSON.parse(rawUser);\n user = isRecord(parsed) ? (parsed as UserInfo) : null;\n } catch {\n user = null;\n }\n }\n } catch {\n // 读取失败不应影响应用运行\n }\n return { token, user };","existing_code":" const token = localStorage.getItem(LEGACY_TOKEN_KEY);\n const rawUser = localStorage.getItem(LEGACY_USER_KEY);"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":228,"end_line":228,"category":"bug","severity":"low","content":"Inconsistent validation between the two branches of `migrateAppUiState`: the v1-envelope path filters `routeDockTabs` with `isDockTab`, but the partial-state path returns the array as-is. A corrupted/older partial state could hydrate invalid tabs (no leading '/', missing label, or containing '?') into the store and break the dock UI. Apply the same `isDockTab` filter here.","suggestion_code":" routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs.filter(isDockTab) : [],","existing_code":" routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs : [],"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":190,"end_line":191,"category":"bug","severity":"low","content":"Inconsistent validation in `migrateAuthState`: the envelope branch verifies `state.user` with `isRecord(...)` before casting, but the partial-state branch assigns `existing.user` directly without any shape check. Since `existing` is typed via `as Partial<UserPersistedState>`, a garbage value (string/number/array) from storage would be trusted as a `UserInfo` and could crash consumers that read `user.id`/`user.username`. Mirror the envelope branch's `isRecord` check.","suggestion_code":" token: existing.token ?? null,\n user: isRecord(existing.user) ? (existing.user as UserInfo) : null,","existing_code":" token: existing.token ?? null,\n user: existing.user ?? null,"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":103,"end_line":104,"category":"security","severity":"low","content":"Security consideration: the auth token is persisted to `localStorage` (via `authPersistStorage`). Any XSS in the app can trivially read it, which fully compromises the session. If feasible for this app, prefer storing the token in an httpOnly cookie (or short-lived memory + refresh flow); at minimum keep this risk in mind and ensure no `dangerouslySetInnerHTML`/`eval`-style sinks exist anywhere in the admin app.","suggestion_code":null,"existing_code":" if (token === null && user === null) return null;\n return JSON.stringify({ state: { token, user }, version: 1 });"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":180,"end_line":181,"category":"maintainability","severity":"low","content":"Duplicated logic: the three migrate functions (`migrateAuthState`, `migratePermissionState`, `migrateAppUiState`) all re-implement the same \"envelope vs. partial state\" branching (`isRecord(persisted) && isRecord(persisted.state)` then `Array.isArray(...)` fallback), and the storage adapters share the same try/catch-laden set/remove pattern. Consider extracting small helpers (e.g., `unwrapEnvelope(persisted)` / a `safeStorage` wrapper) to keep future migration additions consistent.","suggestion_code":null,"existing_code":"export function migrateAuthState(persisted: unknown, _version: number): UserPersistedState {\n if (isRecord(persisted) && isRecord(persisted.state)) {"}
{"path":"apps/admin/src/utils/validate.ts","start_line":7,"end_line":7,"category":"maintainability","severity":"medium","content":"Type-safety issue: `schema: z.ZodType<unknown>` erases the schema's output type, and the unconstrained generic `T` combined with the `as T` cast means callers can pass a schema/T pair that don't match (e.g. `validateResponse<number>(z.string(), data)`) without any compile error, risking runtime type mismatches. Tie the generic to the schema's output type and drop the unsafe cast: `schema: z.ZodType<T>` and `return result.data;` (result.data will then already be typed as `T`).","suggestion_code":"export function validateResponse<T>(schema: z.ZodType<T>, data: unknown): T {","existing_code":"export function validateResponse<T>(schema: z.ZodType<unknown>, data: unknown): T {"}
{"path":"apps/admin/src/utils/validate.ts","start_line":10,"end_line":11,"category":"maintainability","severity":"low","content":"Only the first issue's path is surfaced in the thrown error message. When several fields fail validation at once, users only see one field, which can be misleading for debugging. Since all issues are already available in `result.error.issues`, consider aggregating the paths (e.g. `issues.map(i => i.path.join('.')).join(', ')`) so the message reports all malformed fields.","suggestion_code":null,"existing_code":" const first = result.error.issues[0];\n const path = first?.path?.join('.');"}
{"path":"apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx","start_line":32,"end_line":33,"category":"maintainability","severity":"low","content":"Error boundary state is never reset: once an artifact throws, `hasError` stays true for the lifetime of this component instance. The fallback description tells users to \"refresh and retry\", but the component offers no retry/reset path. Call sites currently pass `key={id}` (see AiMessageContent.tsx), which remounts the boundary when the artifact id changes, but if the same artifact id re-renders successfully later (e.g. streaming/regeneration updates), the stale error placeholder persists and permanently masks the recovered content. Consider exposing a reset mechanism (e.g. resetting `hasError` when `children` changes via `getDerivedStateFromProps`/`componentDidUpdate`, or documenting that callers MUST use a stable per-artifact `key`).","suggestion_code":null,"existing_code":" render(): React.ReactNode {\n if (this.state.hasError) {"}
{"path":"apps/admin/src/App.tsx","start_line":345,"end_line":346,"category":"bug","severity":"medium","content":"There is no catch-all route. Any unknown/typo'd path (e.g. /studnets) matches no route under the \"/\" layout, so react-router renders nothing and the user gets a blank page with no feedback. Add a `path=\"*\"` route (redirect to \"/\" or show a 404 Result) so unknown URLs are handled gracefully.","suggestion_code":" </Route>\n <Route path=\"*\" element={<Navigate to=\"/\" replace />} />\n </Routes>","existing_code":" </Route>\n </Routes>"}
{"path":"apps/admin/src/App.tsx","start_line":48,"end_line":48,"category":"bug","severity":"low","content":"When unauthenticated, the redirect to /login is performed without `replace`, which pollutes the history stack: pressing the browser Back button after login returns to the previously protected URL and immediately bounces to /login again. Use `replace` so the protected route is not kept in history.","suggestion_code":" return token ? <>{children}</> : <Navigate to=\"/login\" replace />;","existing_code":" return token ? <>{children}</> : <Navigate to=\"/login\" />;"}
{"path":"apps/admin/src/App.tsx","start_line":162,"end_line":169,"category":"maintainability","severity":"low","content":"Every route's permission string is hardcoded inline here (~30 occurrences), duplicating the single-source-of-truth tables in auth/menu-policy.ts and auth/permission-navigation.ts. This duplication has already drifted: the /wallets route exists here (wallet:view) and in the menu policy, but is missing from PERMISSION_PAGES in permission-navigation.ts, so canAccessPath('/wallets') returns true for all logged-in users regardless of the wallet:view gate. Consider deriving the route table from the central permission config to keep the two in sync.","suggestion_code":null,"existing_code":" <Route\n path=\"wallets\"\n element={\n <PermissionRoute permission=\"wallet:view\">\n <WalletsPage />\n </PermissionRoute>\n }\n />"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx","start_line":42,"end_line":47,"category":"style","severity":"low","content":"This is a nested ternary chain, which is explicitly prohibited by the project's review rules. Extract it into a small lookup map or helper function to improve readability and reduce the risk of future branch mistakes.","suggestion_code":" status: attachmentUploadStatus[attachment.status],\n // or: status: toUploadStatus(attachment.status),","existing_code":" status:\n attachment.status === 'ready'\n ? 'done'\n : attachment.status === 'failed'\n ? 'error'\n : 'uploading',"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx","start_line":76,"end_line":80,"category":"bug","severity":"medium","content":"The loop returns the first numeric replyToMessageId found after the user message without verifying it actually refers to THIS message. If the user sends two pending messages before the assistant replies (message B followed by message A), the first assistant reply's replyToMessageId belongs to message B, so resolving message A would return message B's server id — later deleting/editing the wrong message. Consider validating the mapping (e.g., only accept the reply whose replyToMessageId matches this message's own assigned id, or bail out when another pending user message precedes the reply) and add a comment documenting the assumption.","suggestion_code":null,"existing_code":" for (const item of all.slice(index + 1)) {\n if (typeof item.message.replyToMessageId === 'number') {\n return item.message.replyToMessageId;\n }\n }"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx","start_line":27,"end_line":28,"category":"bug","severity":"low","content":"If a conversation's lastMessageAt/updatedAt contains an unparsable value, new Date(...).getTime() returns NaN and the comparator produces NaN, leaving the sort order unstable/implementation-defined. Guard against invalid dates (e.g., fall back to 0) so the sort remains deterministic.","suggestion_code":" const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime() || 0;\n const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime() || 0;","existing_code":" const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();\n const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime();"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":138,"end_line":138,"category":"style","severity":"low","content":"Nested ternaries are prohibited by the review rules. The `status`, `content`, and `icon` fields here each chain nested `?:` expressions, which hurts readability and is error-prone (e.g., `running ? ... : success ? ... : ...`). Extract a small helper (e.g., a function that maps `tool.status` to `{ status, icon, content }`) or use explicit if/else branches.","suggestion_code":null,"existing_code":" status: running ? 'loading' : success ? 'success' : 'error',"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":96,"end_line":96,"category":"maintainability","severity":"low","content":"`openAttachment` and `openSourceUrl` are near-duplicate implementations (fetch with auth header → blob → open in new tab → delayed revoke). Extract a shared helper such as `openProtectedUrl(url, errorMessage)` to avoid future drift between the two code paths.","suggestion_code":null,"existing_code":"async function openSourceUrl(item: { url?: string }): Promise<void> {"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":87,"end_line":89,"category":"security","severity":"medium","content":"The user's bearer token is attached to `fetch(url)` without verifying the URL is same-origin. `attachment.url` and `a2uiSources[].url` come from server/AI-generated data; if any source URL is external, the token would be sent to a third party (credential leak). The same issue exists in `openSourceUrl`. Validate the origin (e.g., `new URL(url, window.location.origin).origin === window.location.origin`) before adding the `Authorization` header.","suggestion_code":null,"existing_code":" const response = await fetch(attachment.url, {\n headers: token ? { Authorization: `Bearer ${token}` } : undefined,\n });"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":230,"end_line":230,"category":"performance","severity":"low","content":"`deriveForms(message)` is invoked twice per render (same for `deriveReviews`/`deriveCharts` below), and the whole expression is re-evaluated on every render including each streaming SSE chunk. Call the derive function once into a local variable before choosing the fallback, e.g. `const derived = deriveForms(message); const forms = derived.length > 0 ? derived : (message.forms ?? []);`","suggestion_code":null,"existing_code":" const forms = deriveForms(message).length > 0 ? deriveForms(message) : (message.forms ?? []);"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":93,"end_line":93,"category":"bug","severity":"low","content":"`window.open` can return `null` when the popup is blocked by the browser; in that case the click silently does nothing even though the code explicitly aims to avoid \"clicked but no response\". Also, revoking the object URL after a fixed 60s can abort the download of large attachments if the new tab is still loading. Consider checking the `window.open` result (fall back to `message.warning` or a direct download) and revoking after the tab loads rather than a fixed timeout.","suggestion_code":null,"existing_code":" window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);"}
{"path":"apps/admin/src/components/AiChat/DynamicChart.tsx","start_line":204,"end_line":208,"category":"bug","severity":"high","content":"The empty/undefined data guard runs *after* `useMemo` has already invoked `buildOption(chart)`. Every builder (`buildNameValueRows`, `buildCategoryOption`, etc.) immediately calls `chart.rows.map(...)` and `chart.columns[0]?.key`, so if `rows`/`columns` is missing at runtime this throws before the \"暂无数据\" placeholder is reached. The chart payload comes from AI message metadata that is only validated as `typeof payload === 'object'` (see `deriveCharts` in uiArtifacts.ts), so a missing `rows` array is entirely possible and the `!chart.rows` guard itself acknowledges it — the guard is just ineffective because of ordering. Fix: make the option build null-safe instead of moving the guard above the hook (that would violate the Rules of Hooks), e.g. only build the option when rows exist, and guard `columns` in the builders.","suggestion_code":" const option = useMemo<EChartsOption>(\n () => (chart && chart.rows?.length ? buildOption(chart) : {}),\n [chart],\n );\n const [instance, setInstance] = useState<EChartsType | null>(null);\n if (!chart) return null;\n // 空数据集:渲染明确占位,而不是一张空白图\n if (!chart.rows || chart.rows.length === 0) {","existing_code":" const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);\n const [instance, setInstance] = useState<EChartsType | null>(null);\n if (!chart) return null;\n // 空数据集:渲染明确占位,而不是一张空白图\n if (!chart.rows || chart.rows.length === 0) {"}
{"path":"apps/admin/src/components/AiChat/DynamicChart.tsx","start_line":211,"end_line":214,"category":"maintainability","severity":"low","content":"The chart-card header JSX (title + type tag) is duplicated verbatim in the empty-state branch and the normal branch. Extract it into a small local render (e.g. `renderHeader()`) or a sub-component to avoid the two branches drifting apart when the header changes.","suggestion_code":null,"existing_code":" <div className=\"ai-chat-chart-card__header\">\n <Typography.Text strong>{chart.title}</Typography.Text>\n <Tag color=\"blue\">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>\n </div>"}
{"path":"apps/admin/src/components/AiChat/DynamicChart.tsx","start_line":215,"end_line":222,"category":"style","severity":"low","content":"Static inline styles are used for the empty-state placeholder block (height/flex/align). Per the review rules, inline `style` should be reserved for dynamic values; move these fixed layout styles into the CSS module/class (e.g. a `ai-chat-chart-card__empty` class).","suggestion_code":null,"existing_code":" <div\n style={{\n height: 120,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n }}\n >"}
{"path":"apps/admin/src/components/AiChat/LiteMermaid.tsx","start_line":21,"end_line":23,"category":"bug","severity":"medium","content":"Race condition: the effect has no cancellation/cleanup for the in-flight async render. If `children` changes while a previous `mermaid.render` is still pending (common with streaming AI responses or quick message switches — `mermaid` is dynamically imported and slow on first load), the older render can complete after the newer one and overwrite `container` with stale SVG, or a stale failure can set `error` for the new content. `isMounted()` only guards against unmount, not against stale async results. Fix: add a per-effect `cancelled` flag reset in the effect cleanup and check it before touching DOM/state.","suggestion_code":" useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n let cancelled = false;\n\n void (async () => {\n try {\n const mermaid = (await import('mermaid')).default;\n mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });\n const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);\n if (cancelled) return;\n const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');\n if (doc.querySelector('parsererror')) throw new Error('Invalid SVG output');\n container.replaceChildren(doc.documentElement);\n setError(null);\n } catch (e) {\n if (!cancelled) {\n setError(e instanceof Error ? e.message : '图表渲染失败');\n }\n }\n })();\n\n return () => {\n cancelled = true;\n };\n }, [children]);","existing_code":" void (async () => {\n try {\n const mermaid = (await import('mermaid')).default;"}
{"path":"apps/admin/src/components/AiChat/LiteMermaid.tsx","start_line":25,"end_line":25,"category":"bug","severity":"low","content":"`crypto.randomUUID()` is only available in secure contexts (HTTPS/localhost) and modern browsers; on HTTP or older environments it throws `TypeError: crypto.randomUUID is not a function`, which is caught and surfaced as a render failure even though the diagram itself is valid. Consider a fallback (e.g., `crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)`) or a module-level counter for the render id.","suggestion_code":null,"existing_code":" const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);"}
{"path":"apps/admin/src/components/AiChat/LiteMermaid.tsx","start_line":27,"end_line":28,"category":"bug","severity":"low","content":"The DOMParser result is inserted without checking for a `parsererror` element. If `mermaid.render` ever returns malformed/non-SVG markup, `doc.documentElement` will be a `<parsererror>` node and the user will see raw parser error text instead of a graceful fallback. Check `doc.querySelector('parsererror')` and throw/report it instead.","suggestion_code":null,"existing_code":" const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');\n container.replaceChildren(doc.documentElement);"}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":107,"end_line":110,"category":"bug","severity":"medium","content":"antd `Form` only applies `initialValues` on mount. Because the surface id is derived from `form.id`, a schema update (new fields/defaults) or a regenerated form reusing the same `id` will leave the mounted `Form` with stale user-entered values — the memoized `initialValues` change is silently ignored. Key the `Form` by a stable schema revision (e.g. `form.id`, or include a schema hash) so it remounts when the underlying schema changes.","suggestion_code":" <Form\n key={`${form.id}:${JSON.stringify(form.fields)}`}\n layout=\"vertical\"\n size=\"small\"\n initialValues={initialValues}","existing_code":" <Form\n layout=\"vertical\"\n size=\"small\"\n initialValues={initialValues}"}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":217,"end_line":217,"category":"performance","severity":"medium","content":"This effect depends on the entire `form` object and re-pushes the full command batch (`updateDataModel` + `updateComponents`) every time it runs. `pushCommands` only dedupes `createSurface`, so if the parent recreates the `form` object on each render, duplicate commands accumulate unboundedly in the surface command list and are replayed by XCard. Consider memoizing `form` in the parent, depending on stable keys (`form.id` / `form.fields`), or skipping the push when the serialized payload is unchanged.","suggestion_code":" // Avoid depending on the whole `form` object identity: if the parent rebuilds it\n // each render, every render pushes duplicate commands. Use stable keys instead.\n }, [disabled, error, form?.id, form?.fields, pushCommands, sid, submitted, submitting]);","existing_code":" }, [disabled, error, form, pushCommands, sid, submitted, submitting]);"}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":131,"end_line":138,"category":"style","severity":"low","content":"This is a nested/chained ternary expression (textarea → number → select → date → default), which is disallowed by the review rules. Extract a `renderField(field)` helper function so each branch is a plain `if/else` (or switch), improving readability.","suggestion_code":null,"existing_code":" {field.type === 'textarea' ? (\n <Input.TextArea rows={3} placeholder={field.placeholder} />\n ) : field.type === 'number' ? (\n <InputNumber\n className=\"ai-chat-dynamic-form__number\"\n placeholder={field.placeholder}\n />\n ) : field.type === 'select' ? ("}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":227,"end_line":231,"category":"bug","severity":"low","content":"When `form:submit` arrives without a valid `values` context, the handler silently submits an empty object `{}` to `onSubmit`. A malformed payload should be ignored (with a warning) rather than triggering a submission that may pass server-side validation with missing required fields.","suggestion_code":" const values =\n payload.context?.values && typeof payload.context.values === 'object'\n ? (payload.context.values as Record<string, unknown>)\n : null;\n if (!values) return;\n void handleSubmit(values);","existing_code":" const values =\n payload.context?.values && typeof payload.context.values === 'object'\n ? (payload.context.values as Record<string, unknown>)\n : {};\n void handleSubmit(values);"}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":184,"end_line":184,"category":"bug","severity":"low","content":"`submitting`/`submitted`/`error` are local to this component instance and never reset when `form.id` changes. If the parent reuses the same `DynamicForm` instance for a new form id (e.g. reconciliation without a `key`), the new form would immediately render the previous submission state (spinner/success alert) instead of a fresh form. Consider resetting state when `form.id` changes (e.g. via `useEffect` on `form.id` calling `reset`) or ensuring the parent keys each instance by form id.","suggestion_code":null,"existing_code":" const { submitting, submitted, error, run } = useSubmissionState();"}
{"path":"apps/admin/src/components/AppErrorBoundary.tsx","start_line":33,"end_line":35,"category":"bug","severity":"medium","content":"`hasError` is set once and never reset: after a single render error the fallback is shown permanently, even after the error condition disappears. This matters here because RouteKeeper wraps each cached page with this boundary — a transient error would permanently replace that page's content (and the global wrapper in main.tsx would permanently block the app) until a full page reload. Suggest resetting state when `children` changes (e.g. in `componentDidUpdate`, compare `this.props.children !== prevProps.children`) or exposing a retry action that calls `setState({ hasError: false })` instead of forcing `window.location.reload()`.","suggestion_code":null,"existing_code":" if (this.state.hasError) {\n if (this.props.fallback) return this.props.fallback;\n return ("}
{"path":"apps/admin/src/components/AppErrorBoundary.tsx","start_line":29,"end_line":29,"category":"maintainability","severity":"low","content":"`componentDidCatch` only logs to the console, so render failures are invisible to any error monitoring in production. Consider reporting `error` and `info.componentStack` to an error tracking service here (note also that error boundaries cannot catch errors thrown in event handlers, async callbacks, or SSR, so those paths need separate handling).","suggestion_code":null,"existing_code":" console.error('[AppErrorBoundary] 渲染异常:', error, info.componentStack);"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":430,"end_line":431,"category":"bug","severity":"medium","content":"Writing to a ref during render is discouraged by React (unsafe with concurrent rendering/StrictMode double render — the committed render can be discarded while the ref is left with a value from an abandoned render). Move the assignment into an effect declared before the `[review]` sync effect so `activeTypeRef` is guaranteed to hold the committed value when read.","suggestion_code":" const activeTypeRef = useRef<AiReviewSectionType | undefined>(undefined);\n\n useEffect(() => {\n activeTypeRef.current = activeType;\n }, [activeType]);","existing_code":" const activeTypeRef = useRef<AiReviewSectionType | undefined>(activeType);\n activeTypeRef.current = activeType;"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":129,"end_line":136,"category":"maintainability","severity":"low","content":"Nested ternary expressions are prohibited by the review rules. The `stepStatus` chain (four levels) should be replaced with a lookup table or an extracted helper function for readability.","suggestion_code":" const stepStatus: 'finish' | 'error' | 'process' | 'wait' = (() => {\n if (status === 'submitted') return 'finish';\n if (status === 'failed') return 'error';\n if (status === 'importing' || type === activeType) return 'process';\n return 'wait';\n })();","existing_code":" const stepStatus: 'finish' | 'error' | 'process' | 'wait' =\n status === 'submitted'\n ? 'finish'\n : status === 'failed'\n ? 'error'\n : status === 'importing' || type === activeType\n ? 'process'\n : 'wait';"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":302,"end_line":310,"category":"maintainability","severity":"low","content":"Nested ternary expressions are prohibited by the review rules. This 5-level chain (failed/submitted/skipped/expired/default) should be extracted into a helper function or a lookup map for clarity.","suggestion_code":null,"existing_code":" {status === 'failed'\n ? '重试导入本步'\n : status === 'submitted'\n ? '已导入'\n : status === 'skipped'\n ? '已跳过'\n : expired\n ? '已失效'\n : '确认导入本步'}"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":508,"end_line":510,"category":"bug","severity":"medium","content":"Duplicate-submission guards rely on component state (`if (submitting) return`), which is stale within the same event tick — two rapid invocations can both pass the guard and fire the import twice, and `handleConfirmStep`/`handleConfirmGroup`/`handleSubmit` do not guard against each other either. The sibling `useSubmissionState` already solves this with a `submittingRef`; apply the same ref-based guard here (reset in `finally`) for robustness.","suggestion_code":" const submittingRef = useRef(false);\n const handleSubmit = async (reviewId: string) => {\n if (submittingRef.current) return;\n submittingRef.current = true;\n setSubmitting(true);\n setError(null);\n try {\n await onSubmit(reviewId);\n } catch (reason) {\n setError(errorMessage(reason));\n } finally {\n submittingRef.current = false;\n setSubmitting(false);\n }\n };","existing_code":" const handleSubmit = async (reviewId: string) => {\n if (submitting) return;\n setSubmitting(true);"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":437,"end_line":438,"category":"bug","severity":"low","content":"When the `review` prop is replaced with a new review, `error` is not reset, so an error from the previous review's failed action leaks into the new card (both in the card data model and the footer Alert). Clear `error` here alongside the other state resets.","suggestion_code":" useEffect(() => {\n setLocalReview(review);\n setError(null);","existing_code":" useEffect(() => {\n setLocalReview(review);"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":279,"end_line":279,"category":"style","severity":"low","content":"Static inline style — per the review rules inline styles should only be used for dynamic values. `minWidth: 160` is constant; move it to a CSS class.","suggestion_code":null,"existing_code":" <Flex vertical gap={2} style={{ minWidth: 160 }}>"}
{"path":"apps/admin/src/components/BrandLogo.tsx","start_line":8,"end_line":20,"category":"maintainability","severity":"low","content":"Most of these style declarations are static (display, alignItems, justifyContent, borderRadius, background, color, flexShrink) and are mixed with the dynamic size-dependent values. Per the project's rule of avoiding inline `style` except for dynamic styles, the static properties should be extracted into a CSS class, keeping only `width`, `height`, and `fontSize` inline.","suggestion_code":null,"existing_code":" <span\n style={{\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n width: size,\n height: size,\n borderRadius: 8,\n background: BRAND_COLOR,\n color: '#fff',\n flexShrink: 0,\n }}\n >"}
{"path":"apps/admin/src/components/BrandLogo.tsx","start_line":21,"end_line":21,"category":"other","severity":"low","content":"The brand logo container provides no accessible name. Since the element is a purely decorative icon driven by `size` and color, consider adding `role=\"img\"` with an `aria-label` (e.g. the brand name) or `aria-hidden=\"true\"` if it is purely decorative, so assistive technologies interpret it correctly.","suggestion_code":null,"existing_code":" <ReadOutlined style={{ fontSize: size * 0.55 }} />"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":97,"end_line":97,"category":"maintainability","severity":"medium","content":"Writing to a ref during render is a render side effect, which is discouraged by React (under concurrent rendering the write may be based on a discarded render, and it also makes the component non-pure). Since this ref is only consumed inside async callbacks (`refreshConversations`), move the sync into a `useEffect` so it always reflects the committed value.","suggestion_code":" useEffect(() => {\n activeConversationKeyRef.current = activeConversationKey;\n }, [activeConversationKey]);","existing_code":" activeConversationKeyRef.current = activeConversationKey;"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":99,"end_line":101,"category":"bug","severity":"medium","content":"`refreshConversations` has no try/catch. It is invoked via `void refreshConversations()` from the provider completion callback (and from `useAiChatMessageActions` on message deletion), so a failed `listConversations()` produces an unhandled promise rejection with no user feedback. Wrap the call and surface a friendly error.","suggestion_code":" const refreshConversations = useCallback(async () => {\n let items: ConversationData[];\n try {\n items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);\n } catch {\n message.error('刷新会话列表失败');\n return;\n }\n setConversations(items);","existing_code":" const refreshConversations = useCallback(async () => {\n const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);\n setConversations(items);"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":103,"end_line":105,"category":"bug","severity":"medium","content":"When the current key is the draft state `''`, this fallback forcibly switches the active conversation to `items[0]`. Because background conversation completions also call `refreshConversations`, a user composing a new draft while another conversation finishes will be yanked out of the draft, and the composed text would then be sent to that other conversation. Preserve the `''` draft state explicitly.","suggestion_code":" let nextKey = items[0]?.key ?? '';\n if (current && items.some((item) => item.key === current)) nextKey = current;\n else if (current === '') nextKey = '';\n setActiveConversationKey(nextKey);","existing_code":" setActiveConversationKey(\n current && items.some((item) => item.key === current) ? current : (items[0]?.key ?? ''),\n );"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":349,"end_line":357,"category":"bug","severity":"medium","content":"In the delete-all branch, abort handles / providers / status are cleared before `deleteAllConversations()` resolves. If the API call fails, the confirm's `onOk` rejects (modal stays open) but the local runtime state has already been wiped while the conversations remain in the list — inconsistent state, and no error message is shown. Await the API call first and handle the failure (e.g. keep selection and show an error) before clearing local state.","suggestion_code":" if (selected.length === conversations.length) {\n try {\n await aiChatApi.deleteAllConversations();\n } catch {\n message.error('删除会话失败,请重试');\n return;\n }\n for (const abort of requestAbortRef.current.values()) abort();\n requestAbortRef.current.clear();\n providersRef.current.clear();\n setConversationStatus({});\n setConversations([]);\n switchConversation('');\n } else {","existing_code":" if (selected.length === conversations.length) {\n for (const abort of requestAbortRef.current.values()) abort();\n requestAbortRef.current.clear();\n providersRef.current.clear();\n setConversationStatus({});\n await aiChatApi.deleteAllConversations();\n setConversations([]);\n switchConversation('');\n } else {"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":459,"end_line":463,"category":"maintainability","severity":"low","content":"The Checkbox is controlled (`checked`) but has no `onChange`, making it effectively read-only and triggering a React controlled-input warning. Selection toggling currently relies solely on the row click. Wire the checkbox up and stop click propagation so clicking the checkbox toggles reliably without double-firing the row's `onActiveChange`.","suggestion_code":" <span\n className=\"ai-chat-conversation-check\"\n onClick={(event) => event.stopPropagation()}\n >\n <Checkbox\n checked={selectedKeys.includes(item.key)}\n onChange={() => toggleConversationSelection(item.key)}\n aria-label={`选择 ${item.title}`}\n />\n </span>","existing_code":" <Checkbox\n checked={selectedKeys.includes(item.key)}\n className=\"ai-chat-conversation-check\"\n aria-label={`选择 ${item.title}`}\n />"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":377,"end_line":377,"category":"style","severity":"low","content":"`activeId` is typed `number | null`, so per project rules use strict equality instead of `!=`.","suggestion_code":" } else if (activeId !== null && !remaining.some((item) => item.id === activeId)) {","existing_code":" } else if (activeId != null && !remaining.some((item) => item.id === activeId)) {"}
{"path":"apps/admin/src/components/DefaultRoute.tsx","start_line":10,"end_line":10,"category":"bug","severity":"medium","content":"The selector `(state) => state.user?.roles ?? []` returns a fresh `[]` literal every time it is evaluated whenever `state.user` is null (e.g. logged-out / not-yet-loaded session). Since `useUserStore` is a zustand store, its underlying `useSyncExternalStore` `getSnapshot` is compared with `Object.is`; a new array reference on each call violates the stable-snapshot contract and can trigger the \"getSnapshot should be cached\" warning / an infinite re-render loop, and at minimum causes needless re-renders on every store update. Select the raw value and apply the fallback outside the selector instead.","suggestion_code":"const roles = useUserStore((state) => state.user?.roles) ?? [];","existing_code":"const roles = useUserStore((state) => state.user?.roles ?? []);"}
{"path":"apps/admin/src/components/DefaultRoute.tsx","start_line":12,"end_line":12,"category":"maintainability","severity":"low","content":"Static (non-dynamic) inline styles are discouraged by the review rules. Move this fixed layout style into a CSS class so it can be reused and theme-managed.","suggestion_code":"return <Spin size=\"large\" className=\"default-route-spin\" />;","existing_code":"return <Spin size=\"large\" style={{ display: 'block', margin: '80px auto' }} />;"}
{"path":"apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx","start_line":38,"end_line":38,"category":"performance","severity":"medium","content":"Syntax highlighting with refractor/prism is CPU-intensive, and in an AI chat the parent list re-renders frequently (e.g., streaming updates). Without memoization, every parent render re-runs the tokenization for all code blocks even when `lang`/`children` are unchanged. Since both props are immutable strings here, wrapping the component with React.memo is a cheap, effective optimization.","suggestion_code":"export const LiteCodeHighlighter = memo(function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) {","existing_code":"export function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) {"}
{"path":"apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx","start_line":39,"end_line":39,"category":"other","severity":"low","content":"Unsupported languages silently fall back to plain, unhighlighted text. AI responses commonly use python/go/java/html/xml/yaml/markdown as well as alias names like `ts`/`js`/`sh`, all of which will render without any syntax coloring here. Note also that the tsx definition internally auto-registers `jsx`, yet `jsx` is missing from SUPPORTED_LANGUAGES, so JSX-only blocks won't highlight either. Consider adding the most common AI-response languages (including `jsx`) or explicitly documenting the fallback behavior.","suggestion_code":null,"existing_code":"const language = lang && SUPPORTED_LANGUAGES.has(lang) ? lang : undefined;"}
{"path":"apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx","start_line":44,"end_line":44,"category":"style","severity":"low","content":"The `customStyle` here is a static style object. Per the project style rules, static styling should live in CSS (via a className prop) rather than inline styles; reserve inline styles for dynamic values.","suggestion_code":null,"existing_code":"customStyle={{ margin: '12px 0', borderRadius: 8, fontSize: 13 }}"}
{"path":"apps/admin/src/components/BackTop.tsx","start_line":35,"end_line":35,"category":"style","severity":"low","content":"Static inline styles are prohibited by the review rules (inline styles should only be used for dynamic values). These values never change, so extract them into a CSS class (e.g. `.back-top-btn { position: fixed; right: 24px; bottom: 48px; z-index: 1000; }`) for better maintainability and to avoid recreating a new style object on every render.","suggestion_code":" className=\"back-top-btn\"","existing_code":" style={{ position: 'fixed', right: 24, bottom: 48, zIndex: 1000 }}"}
{"path":"apps/admin/src/components/BackTop.tsx","start_line":18,"end_line":18,"category":"performance","severity":"low","content":"Minor performance issue: the inline `{ passive: true }` object literal is recreated on every render, and usehooks-ts' `useEventListener` includes `options` in its internal effect dependencies, so the scroll listener is removed and re-attached on each render of this component. Memoize the options (e.g. `useMemo(() => ({ passive: true }), [])`). Additionally, `setVisible` is invoked on every scroll event; consider rAF-throttling the handler since scroll events can fire at high frequency (React bails out on unchanged values, but the handler still runs every time).","suggestion_code":" useEventListener('scroll', updateVisible, undefined, useMemo(() => ({ passive: true }), []));","existing_code":" useEventListener('scroll', updateVisible, undefined, { passive: true });"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx","start_line":210,"end_line":213,"category":"bug","severity":"medium","content":"The footer `Attachments` is always controlled with `items={[]}` and no `onChange`. In `@ant-design/x`, once `items` is provided the component is fully controlled (`useControlledState([], items)`), so files selected here never appear in this instance's file list and upload progress is never shown. The header instance is only rendered when `uploadItems.length > 0`, i.e. only after the upload has already completed (parent state is updated in `customUpload`'s success path). As a result, during the upload the user gets no visual feedback at all (no chip, no progress), and the selected file cannot be removed from the footer. Consider dropping `items={[]}` (let the footer manage its own list) and syncing via `onChange`, or otherwise surfacing the uploading state.","suggestion_code":" <Attachments\n customRequest={onCustomUpload}\n onRemove={onRemoveAttachment}","existing_code":" <Attachments\n items={[]}\n customRequest={onCustomUpload}\n onRemove={onRemoveAttachment}"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx","start_line":201,"end_line":202,"category":"maintainability","severity":"low","content":"The `accept` / `multiple` / `customRequest` / `onRemove` configuration is duplicated verbatim across the two `Attachments` instances (header and footer). Extract a shared constant (e.g. `attachmentAccept`) and/or a shared props object so future changes to accepted file types or upload behavior are made in one place.","suggestion_code":null,"existing_code":" accept=\"image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx\"\n multiple"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx","start_line":70,"end_line":70,"category":"style","severity":"low","content":"Static inline style is used for a fixed font size; per the project rules inline `style` should be reserved for dynamic styles. Move `fontSize: 12` into a CSS class (e.g. `ai-chat-sidebar__empty-hint`).","suggestion_code":" <Typography.Text type=\"secondary\" className=\"ai-chat-sidebar__empty-hint\">","existing_code":" <Typography.Text type=\"secondary\" style={{ fontSize: 12 }}>"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":195,"end_line":198,"category":"bug","severity":"medium","content":"Draft-mode send has no re-entrancy guard. `createConversation()` is async and `isRequesting` is still `false` during that window, so a fast double-click / double-Enter on the send button will run the draft branch twice: two sessions are created server-side, two `queueRequest`s are queued, and the extra session is left orphaned/empty (only one key gets activated). Guard the branch, e.g. reject while `pendingDraftConversationIdRef.current` is non-null (or use a dedicated `submittingDraftRef`), before calling `createConversation`.","suggestion_code":null,"existing_code":" // 草稿态:先创建 session再发送第一条消息\n void (async () => {\n try {\n const created = toConversationData(await aiChatApi.createConversation());"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":131,"end_line":133,"category":"bug","severity":"medium","content":"`requestWithStatus` silently returns when `!activeId || !provider`, but by the time it's called from `submit()` the attachments have already been cleared from both state and `attachmentsRef` and `setInput('')` was called, and from `confirmEditMessage()` the following messages have already been removed locally. If the guard fires (e.g. provider not yet ready), the user's typed message and attachments are lost with no feedback. Either hoist the guard before consuming state in `submit`, or restore the input/attachments and show an error message on this path (mirror the `catch` in the draft branch).","suggestion_code":null,"existing_code":" (params: AiChatInput) => {\n if (!activeId || !provider) return;\n requestAbortRef.current.set(activeId, () => provider.request.abort());"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":437,"end_line":438,"category":"bug","severity":"medium","content":"The 5-attachment limit check is unreliable: `attachmentsRef.current` is only refreshed on re-render (`attachmentsRef.current = attachments`), but `customUpload` only calls `setAttachments(...)` and never updates the ref synchronously. When several files are selected at once, antd Upload invokes `customRequest` per file before a re-render happens, so all of them pass the `>= 5` check and the limit is bypassed. Track the count synchronously (e.g. push to `attachmentsRef.current` on success, decrement on `removeAttachment`/error) or validate against the actual Upload file list. Also consider extracting the magic number `5` into a named constant.","suggestion_code":null,"existing_code":" const file = options.file as File;\n if (attachmentsRef.current.length >= 5) {"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":494,"end_line":495,"category":"style","severity":"low","content":"Nested ternary expressions are not allowed per the review rules. The `extra` expression here nests a second ternary (`editingMessageId === info.id ? undefined : ...`) inside the `role === 'user' ? ... : ...` branch. Extract the hover-action rendering into a small helper function (e.g. `renderUserActions(info)` / `renderAssistantActions(info)`) to flatten the logic.","suggestion_code":null,"existing_code":" info.message.role === 'user' ? (\n editingMessageId === info.id ? undefined : ("}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":121,"end_line":124,"category":"maintainability","severity":"low","content":"These four refs are mutated during the render phase (`requestingRef.current = isRequesting`, etc.). In React 18 concurrent rendering a render pass can be started and then discarded, leaving the refs inconsistent with the committed state, and it violates the \"no side effects in render\" rule. Prefer syncing them inside a `useEffect` (they are only consumed by event handlers/callbacks, so effect timing is fine), or update them at the exact mutation sites (e.g. inside `setAttachments` callbacks and `useXChat` callbacks).","suggestion_code":null,"existing_code":" requestingRef.current = isRequesting;\n abortRef.current = abort;\n attachmentsRef.current = attachments;\n messagesRef.current = messages;"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":104,"end_line":104,"category":"maintainability","severity":"low","content":"The effect assigns `provider.onExternalReview`/`onExternalArtifact` without a cleanup function. When this hook unmounts (or the provider instance changes), the old provider keeps calling closures that reference the stale hook's `setMessage`, which can trigger state updates on an unmounted component and leak handlers. Return a cleanup that clears the handlers (e.g. set them to `undefined`) when the effect re-runs/unmounts.","suggestion_code":null,"existing_code":" provider.onExternalReview = (messageId, review) => {"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":244,"end_line":244,"category":"bug","severity":"low","content":"`navigator.clipboard.writeText` has no error handling: it rejects when the document is not in a secure context or the permission is denied, producing an unhandled promise rejection and silently failing the copy action. Wrap it in try/catch and surface a user-friendly fallback (e.g. `message.error('复制失败')` or a textarea-based fallback).","suggestion_code":null,"existing_code":" void navigator.clipboard.writeText(message.content);"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":417,"end_line":420,"category":"maintainability","severity":"low","content":"The \"merge the returned review into `message.reviews` (replace by id or append)\" logic is duplicated verbatim between `confirmReviewStep` and `confirmReviewGroup` (and again in the `onExternalReview` effect). Extract a single `applyReview(messageId, review)` helper and reuse it in all three places to avoid drift.","suggestion_code":null,"existing_code":" } else if (typeof messageId === 'number') {\n setMessage(messageId, (info) => {\n const reviews = info.message.reviews ?? [];\n const exists = reviews.some((item) => item.id === updated.id);"}
{"path":"apps/admin/src/components/ECharts.tsx","start_line":71,"end_line":74,"category":"bug","severity":"medium","content":"This update effect also runs on the initial mount, immediately after the init effect already called `chart.setOption(optionRef.current)`. So on every mount `setOption` is invoked twice with the same data — and the second call uses `notMerge: true`, which fully rebuilds the chart and discards the first initialization (including anything the `onReady` callback may have done). Additionally, using `notMerge: true` on every subsequent option change wipes interactive state (dataZoom ranges, legend toggles) and restarts animations. Recommend removing the setOption from the init effect (or skipping the first run here) and using the default merge mode (`chart?.setOption(option)`) for normal updates.","suggestion_code":null,"existing_code":" useEffect(() => {\n const chart = containerRef.current ? echarts.getInstanceByDom(containerRef.current) : undefined;\n chart?.setOption(option, true);\n }, [option]);"}
{"path":"apps/admin/src/components/ECharts.tsx","start_line":53,"end_line":56,"category":"maintainability","severity":"low","content":"Writing to refs during render (`optionRef.current = option`, `onReadyRef.current = onReady`) is discouraged by React's rules: in concurrent rendering the render pass can be discarded/replayed, so the ref may not match the committed props, and the mount effect reading `optionRef.current` could consume a stale value. Since the init effect only needs the initial values, just initialize with `useRef(option)`/`useRef(onReady)` and, if the latest value is needed, update the refs inside a `useEffect` instead of during render.","suggestion_code":null,"existing_code":" const optionRef = useRef(option);\n optionRef.current = option;\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;"}
{"path":"apps/admin/src/components/ECharts.tsx","start_line":60,"end_line":62,"category":"bug","severity":"low","content":"`onReady` is invoked with the chart instance created here, but under React 18 StrictMode (dev) effects run mount → cleanup → mount: the first instance is disposed and the callback is invoked again with a brand-new instance. Consumers that captured the first instance (e.g., stored it in state for later export) will hold a disposed chart, and calling `setOption`/`resize` on it logs errors. Consider invoking `onReady` in a way that tolerates remounts, or document that the callback may be called multiple times with a replaced instance.","suggestion_code":null,"existing_code":" const chart = echarts.init(containerRef.current);\n chart.setOption(optionRef.current);\n onReadyRef.current?.(chart);"}
{"path":"apps/admin/src/components/NextStepHint.tsx","start_line":33,"end_line":38,"category":"maintainability","severity":"low","content":"Static (non-dynamic) inline styles are used here: the Card's `style`, the body padding via `styles`, and the icon `color` are all fixed values. Per the review rules, inline `style` attributes should be avoided except for dynamic styles. These colors (`#b7d4ff`, `#f0f7ff`, `#1677ff`) also hardcode theme values that should come from design tokens. Suggest moving them into the `.next-step-hint` CSS class (e.g. via `:global` or a CSS module) so they can be themed and cached consistently.","suggestion_code":"<Card\n size=\"small\"\n className=\"next-step-hint\"\n >","existing_code":"<Card\n size=\"small\"\n className=\"next-step-hint\"\n style={{ marginBottom: 16, borderColor: '#b7d4ff', background: '#f0f7ff' }}\n styles={{ body: { padding: '10px 16px' } }}\n >"}
{"path":"apps/admin/src/components/NextStepHint.tsx","start_line":29,"end_line":30,"category":"bug","severity":"low","content":"The `dismissed` state is fully internal and persists for the lifetime of the mounted component. Because React reconciles components by position/type, if a parent reuses this component slot for a different next-step (e.g. `title` changes from \"下一步:分班\" to \"下一步:缴费\") without remounting, the hint will stay hidden even though the business state has changed and the hint should be shown again. Consider supporting a controlled `dismissed`/`onClose` pattern (or resetting dismissal when the `title` changes) so parents can re-show the hint after it becomes relevant again.","suggestion_code":null,"existing_code":"const [dismissed, setDismissed] = useState(false);\n if (dismissed) return null;"}
{"path":"apps/admin/src/components/MatchSelector.tsx","start_line":27,"end_line":27,"category":"style","severity":"low","content":"The identical static inline style object `{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }` is duplicated in all three action branches (match / create / default). Per the review rules, static (non-dynamic) inline styles should be avoided, and this is also duplicate code. Extract the shared layout into a module-level constant (e.g. `const rowStyle = {...}`) or a CSS class, and reuse it in the three branches.","suggestion_code":null,"existing_code":"<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>"}
{"path":"apps/admin/src/components/MatchSelector.tsx","start_line":93,"end_line":93,"category":"maintainability","severity":"low","content":"Passing `value={undefined}` explicitly to the Select is redundant: this branch is unmounted as soon as a selection is made (the parent switches `action` to 'match'), so there is nothing to reset, and the explicit prop makes the controlled/uncontrolled behavior ambiguous. Omit the `value` prop (or use `defaultValue`) so it is a clean uncontrolled input.","suggestion_code":null,"existing_code":" value={undefined}"}
{"path":"apps/admin/src/components/MatchStep.tsx","start_line":74,"end_line":78,"category":"bug","severity":"medium","content":"The connector SVG is absolutely positioned with `top: 0` inside the non-scrolling flex container, while the left/right row panels scroll internally (`overflowY: 'auto'`). After the user scrolls either panel, the row centers move up by `scrollTop` but the SVG lines stay fixed, so the match lines no longer align with the correct rows — this breaks the core visual of the component. Track the current `scrollTop` (e.g., in local state updated in the `onScroll` handlers for both panels) and apply `transform: translateY(-${scrollTop}px)` to the `<svg>` so the lines follow the rows.","suggestion_code":" <svg\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n transform: `translateY(-${scrollTop}px)`,","existing_code":" <svg\n style={{\n position: 'absolute',\n top: 0,\n left: 0,"}
{"path":"apps/admin/src/components/MatchStep.tsx","start_line":105,"end_line":105,"category":"maintainability","severity":"low","content":"Nested ternary expression — the review rules prohibit nested ternaries. Extract the row background into a small helper (or a lookup) for readability, e.g. `const getRowBackground = (isMatched, i) => isMatched ? '#f6ffed' : (i % 2 === 0 ? '#fafafa' : '#fff');`","suggestion_code":" background: getRowBackground(isMatched, i),","existing_code":" background: isMatched ? '#f6ffed' : i % 2 === 0 ? '#fafafa' : '#fff',"}
{"path":"apps/admin/src/components/MatchStep.tsx","start_line":73,"end_line":73,"category":"maintainability","severity":"low","content":"Extensive static inline `style` attributes throughout the component (row styles, flex container, etc.) violate the 'avoid inline styles except for dynamic styles' rule and make the markup hard to maintain. Consider extracting the static style objects into module-level constants or a CSS/antd `styles` property so only genuinely dynamic values (matched background, border color, etc.) remain inline.","suggestion_code":null,"existing_code":" <div style={{ display: 'flex', position: 'relative' }}>"}
{"path":"apps/admin/src/components/ImportWizard/ImportWizardModal.tsx","start_line":165,"end_line":168,"category":"bug","severity":"medium","content":"Stale-state bug: the Modal is never destroyed (`destroyOnHidden={false}`), so `run`, `receipt`, `previewByStep`, etc. persist across open/close. When the modal is reopened with `runId === null` (manual mode, per the prop contract “为空时向导从上传文件开始”) after a previous run was loaded, this effect returns early and the wizard renders the old run's data instead of the upload screen. Reset all wizard state when `open` becomes true without a runId (or clear it on close/onCancel).","suggestion_code":null,"existing_code":" useEffect(() => {\n if (!open || !initialRunId) return;\n void loadRun(initialRunId);\n }, [open, initialRunId, loadRun]);"}
{"path":"apps/admin/src/components/ImportWizard/ImportWizardModal.tsx","start_line":255,"end_line":257,"category":"bug","severity":"medium","content":"Misleading error handling: `commitImportStep` and the follow-up `getImportRun` refresh are in the same try/catch. If the commit succeeds but the refresh fails, the catch reports “提交失败” even though the data was actually committed, leaving the UI inconsistent (receipt set but run state not refreshed). Move the `getImportRun` refresh out of the commit's try block (or give it its own error handling) so a refresh failure doesn't mask a successful commit.","suggestion_code":null,"existing_code":" const result = await commitImportStep(run.id, activeStepKey, decisions);\n setReceipt(result);\n const refreshed = await getImportRun(run.id);"}
{"path":"apps/admin/src/components/ImportWizard/ImportWizardModal.tsx","start_line":383,"end_line":388,"category":"maintainability","severity":"low","content":"Nested ternary violates the project rule (“Nested ternary expressions are not allowed”). Extract the step status mapping into a small helper (e.g., `stepStatus(step, activeStepKey)`) to keep it linear and readable.","suggestion_code":null,"existing_code":" status:\n step.status === 'committed'\n ? ('finish' as const)\n : step.stepKey === activeStepKey\n ? ('process' as const)\n : ('wait' as const),"}
{"path":"apps/admin/src/components/ImportWizard/ImportWizardModal.tsx","start_line":411,"end_line":416,"category":"bug","severity":"low","content":"The hint text promises “单文件不超过 10MB”, but no actual size/type validation is performed in `customRequest` (the `accept` attribute is also not enforced on drag-and-drop). Oversized or wrong-format files are sent to the server and fail later with a generic error. Add a `beforeUpload` check (size ≤ 10MB and extension .xlsx/.csv) with a user-friendly rejection message.","suggestion_code":null,"existing_code":" <Upload.Dragger\n accept=\".xlsx,.csv\"\n maxCount={1}\n showUploadList={false}\n disabled={uploading}\n customRequest={handleUpload}"}
{"path":"apps/admin/src/components/PermissionButton.tsx","start_line":15,"end_line":16,"category":"bug","severity":"medium","content":"`hasPermission` returns `false` in two distinct situations: while the permission list is still loading (`permissionStatus !== 'ready'`) and when the permission is genuinely absent. Because the check happens directly in render, every button rendered by this component is hidden during the initial permission fetch and then pops in once loading completes, causing layout jumps; worse, if the permission request fails and `status` never becomes `'ready'`, all permission-gated buttons are permanently hidden with no user feedback. Consider exposing the loading state (e.g., `permissionsReady` from `usePermission`) and rendering a disabled placeholder or skeleton while loading, and/or optionally supporting a disabled fallback instead of always unmounting the button via `return null`.","suggestion_code":null,"existing_code":" const { hasPermission } = usePermission();\n if (!hasPermission(permission)) return null;"}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":82,"end_line":86,"category":"bug","severity":"medium","content":"`res.success` is never checked here, unlike `handlePreview` below which throws on `!res.success`. If the API returns `success: false` (e.g., invalid credentials), the flow silently advances to the rule step with `response.data` possibly undefined — `response.data.fields` then throws a TypeError that is swallowed by the catch without any user-facing message. Add a success check before using `response.data`.","suggestion_code":" const response = await api.post<{\n success: boolean;\n data: { name: string; fields: JinshujuFormField[] };\n }>('/sync/jinshuju/fields', values);\n if (!response.success) throw new Error('获取表单字段失败');\n setFormFields(response.data.fields);","existing_code":" const response = await api.post<{\n success: boolean;\n data: { name: string; fields: JinshujuFormField[] };\n }>('/sync/jinshuju/fields', values);\n setFormFields(response.data.fields);"}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":145,"end_line":149,"category":"bug","severity":"high","content":"When the apply API returns `success: false`, the `if (res.success)` block is skipped, nothing is thrown, and the catch never runs — so the modal stays permanently stuck on the 'applying' loading screen with no error message and no way to return to the match step (footer is null). This is inconsistent with `handlePreview` and `handleConnectionNext` error handling. Also, `res.log` may be undefined, so `res.log.message` can throw a TypeError that leaves the user without feedback. Handle the failure branch explicitly.","suggestion_code":" if (res.success) {\n message.success(res.log?.message || `处理 ${res.log?.recordsCount ?? 0} 条记录`);\n onApplied();\n reset();\n } else {\n throw new Error(res.log?.message || '同步失败');\n }","existing_code":" if (res.success) {\n message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);\n onApplied();\n reset();\n }"}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":341,"end_line":341,"category":"style","severity":"low","content":"Nested ternary expression violates the project rule that nested ternaries are not allowed. Extract the step-to-index mapping into a lookup or if/else for readability.","suggestion_code":" const stepIndexMap: Record<typeof step, number> = { connection: 0, rule: 1, match: 2, applying: 2 };\n const currentStep = stepIndexMap[step];","existing_code":" const currentStep = step === 'connection' ? 0 : step === 'rule' ? 1 : 2;"}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":351,"end_line":353,"category":"style","severity":"low","content":"The footer rendering also uses nested ternaries (step === 'connection' ? ... : step === 'rule' ? ... : step === 'match' ? ... : null). Refactor into a lookup or if/else to comply with the no-nested-ternary rule and improve readability.","suggestion_code":null,"existing_code":" footer={\n step === 'connection'\n ? ["}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":65,"end_line":68,"category":"maintainability","severity":"low","content":"The queryFn swallows all load failures (`catch { return []; }`) with no user feedback. A failed rules fetch is indistinguishable from \"no rules exist\", so the UI may misleadingly show '当前表单还没有保存的规则' when the request actually failed. Consider surfacing a lightweight error message (or at least distinguishing the failure state) instead of silently returning an empty list.","suggestion_code":null,"existing_code":" return res.success ? validateResponse<MatchRule[]>(jinshujuRulesSchema, res.data) : [];\n } catch {\n return [];\n }"}
{"path":"apps/admin/src/components/QueryState/QueryErrorState.tsx","start_line":27,"end_line":27,"category":"maintainability","severity":"low","content":"Static inline styles are used in the compact branch (`padding: '32px 16px', textAlign: 'center'`, `fontSize: 12`, `marginTop: 12`). Per the project's review rules, inline `style` attributes should be avoided except for truly dynamic styles — consider extracting these into CSS classes or theme tokens for consistency and maintainability.","suggestion_code":null,"existing_code":"<div style={{ padding: '32px 16px', textAlign: 'center' }}>"}
{"path":"apps/admin/src/components/NotificationBell.tsx","start_line":37,"end_line":40,"category":"bug","severity":"medium","content":"Copy-paste bug: this is the error handler for `fetchNotifications` (fetching the notification list), but both the console log and the user-facing message say \"全部已读失败\" (mark-all-read failed). This misleads users/debugging when the fetch itself fails. Should be \"获取通知失败,请重试\".","suggestion_code":" } catch (error) {\n console.error('获取通知失败', error);\n message.error('获取通知失败,请重试');\n }","existing_code":" } catch (error) {\n console.error('全部已读失败', error);\n message.error('全部已读失败,请重试');\n }"}
{"path":"apps/admin/src/components/NotificationBell.tsx","start_line":62,"end_line":62,"category":"security","severity":"medium","content":"The auth token is passed in the SSE URL query string (`?token=...`). Query-string parameters are commonly recorded in server/proxy access logs and browser history/devtools, increasing the risk of token leakage. Since REST calls already authenticate via the Authorization header (the api interceptor adds it), consider using a short-lived, single-use stream token, or switch to a mechanism that doesn't expose the token in the URL.","suggestion_code":null,"existing_code":" const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`);"}
{"path":"apps/admin/src/components/NotificationBell.tsx","start_line":101,"end_line":103,"category":"bug","severity":"medium","content":"`handleMarkAll` swallows the failure silently (`/* ignore */`). Marking all notifications read is a user-triggered destructive action; if the request fails the UI stays optimistic-looking (list already updated locally) with no feedback, and the unread count will be corrected by the next poll/SSE only. Add a user-friendly error message in the catch block (e.g., `message.error('全部已读失败,请重试')`) to be consistent with the rest of the component.","suggestion_code":" const handleMarkAll = async () => {\n try {\n await api.put('/notifications/read-all');\n setUnreadCount(0);\n setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));\n } catch {\n message.error('全部已读失败,请重试');\n }\n };","existing_code":" const handleMarkAll = async () => {\n try {\n await api.put('/notifications/read-all');"}
{"path":"apps/admin/src/components/NotificationBell.tsx","start_line":51,"end_line":52,"category":"maintainability","severity":"low","content":"`openRef.current = open;` writes to a ref during render, which violates React's rules (\"Do not write or read ref.current during rendering\"). With concurrent rendering/StrictMode the ref can be left stale if the render is discarded, so the SSE `onmessage` handler may make the wrong decision about refreshing the list. Prefer updating the ref in an effect.","suggestion_code":" const openRef = useRef(open);\n useEffect(() => {\n openRef.current = open;\n }, [open]);","existing_code":" const openRef = useRef(open);\n openRef.current = open;"}
{"path":"apps/admin/src/components/PermissionRoute.tsx","start_line":21,"end_line":21,"category":"performance","severity":"low","content":"findRoleAwareLandingPath internally calls buildMenu/collectMenuPaths, which constructs the whole filtered menu tree. It is recomputed on every render of the unauthorized branch. Since this is a derived value from roles/permissions, memoize it with useMemo to avoid repeated menu construction on re-renders.","suggestion_code":" const firstPath = useMemo(\n () => findRoleAwareLandingPath(roles, permissions),\n [roles, permissions],\n );","existing_code":" const firstPath = findRoleAwareLandingPath(roles, permissions);"}
{"path":"apps/admin/src/components/PermissionRoute.tsx","start_line":28,"end_line":32,"category":"bug","severity":"low","content":"When the user has no permissions at all, findRoleAwareLandingPath returns null, so the 403 Result renders without any button - the user is left stranded on the 'no access' page with no way to navigate away or back to login. Consider always providing a fallback action (e.g., back to home or logout) even when firstPath is null.","suggestion_code":null,"existing_code":" firstPath ? (\n <Button type=\"primary\" onClick={() => navigate(firstPath, { replace: true })}>\n 前往可访问页面\n </Button>\n ) : undefined"}
{"path":"apps/admin/src/components/PermissionRoute.tsx","start_line":18,"end_line":18,"category":"style","severity":"low","content":"Static inline styles should be avoided per the project rules (inline style is only allowed for dynamic styles). Move the loading spinner centering to a CSS class instead of hardcoding it inline.","suggestion_code":null,"existing_code":" return <Spin size=\"large\" style={{ display: 'block', margin: '80px auto' }} />;"}
{"path":"apps/admin/src/components/ScrollToTop.tsx","start_line":10,"end_line":12,"category":"performance","severity":"low","content":"The scroll reset runs in `useEffect`, which executes after the browser has painted the new route. On navigation the user can briefly see the new page at the old scroll position (flicker), and on initial mount it also runs an unnecessary `window.scrollTo`. Since this effect only touches layout state synchronously, prefer `useLayoutEffect` so the scroll happens before paint (the standard pattern for ScrollToTop components). Alternatively/additionally, setting `history.scrollRestoration = 'manual'` once avoids conflicts with browser scroll restoration on back/forward navigation.","suggestion_code":" useLayoutEffect(() => {\n window.scrollTo(0, 0);\n }, [pathname]);","existing_code":" useEffect(() => {\n window.scrollTo(0, 0);\n }, [pathname]);"}
{"path":"apps/admin/src/components/RuleEditor.tsx","start_line":30,"end_line":32,"category":"bug","severity":"medium","content":"Hardcoded fallback mappings use arbitrary Jinshuju field keys (`field_1`, `field_2`) that are unlikely to exist in a real form's `fields` list. When creating a new rule, the Select will display the raw key value (no matching option) and an invalid mapping can be saved. Better to default to an empty object (or derive defaults from the first available form fields).","suggestion_code":" const [mappings, setMappings] = useState<Record<string, string>>(\n rule?.mappings ?? {},\n );","existing_code":" const [mappings, setMappings] = useState<Record<string, string>>(\n rule?.mappings ?? { name: 'field_1', phone: 'field_2' },\n );"}
{"path":"apps/admin/src/components/RuleEditor.tsx","start_line":33,"end_line":33,"category":"maintainability","severity":"low","content":"`saving` local state duplicates the pending state already exposed by `useApiMutation` (a wrapper around `useMutation`, which provides `isPending`). The extra state also forces a try/catch/finally dance that the mutation's built-in error handling already covers. Simplify by using `saveMutation.isPending` as the Button loading prop.","suggestion_code":null,"existing_code":" const [saving, setSaving] = useState(false);"}
{"path":"apps/admin/src/components/RuleEditor.tsx","start_line":65,"end_line":65,"category":"style","severity":"low","content":"Many static inline `style` objects are used throughout this component. Per project conventions (avoid inline styles except for dynamic ones), these should be extracted into CSS classes or a stylesheet for better maintainability.","suggestion_code":null,"existing_code":" <div style={{ padding: '12px 0' }}>"}
{"path":"apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx","start_line":27,"end_line":27,"category":"maintainability","severity":"medium","content":"The `options` prop re-declares its own structural type and narrows it compared to `EditableCellOption` exported by `EditableCell` (`label: React.ReactNode` and `value: string | number | boolean`). This silently restricts valid use cases (e.g., boolean-valued select options or labels rendered as ReactNode) and lets the type drift from the source of truth. Reuse the underlying prop type instead, e.g. `options?: React.ComponentProps<typeof EditableCell>['options']`.","suggestion_code":" options?: React.ComponentProps<typeof EditableCell>['options'];","existing_code":" options?: Array<{ value: string | number; label: string }>;"}
{"path":"apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx","start_line":39,"end_line":41,"category":"maintainability","severity":"low","content":"The `async`/`await` wrapper around `onSave` adds no behavior beyond the raw callback (both propagate rejections to `EditableCell`'s try/catch). Simplify to a direct delegation for readability.","suggestion_code":" onSave={(next) => onSave(record, field, next)}","existing_code":" onSave={async (next) => {\n await onSave(record, field, next);\n }}"}
{"path":"apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx","start_line":43,"end_line":43,"category":"bug","severity":"low","content":"The default display fallback `String(value ?? '-')` renders non-primitive values poorly: arrays (e.g., for `multi-select`/`tags`/`date-range` editors) are joined as \"a,b,c\" and objects render as \"[object Object]\" in the UI. Since `value` is typed as `unknown`, consider a safer fallback that formats arrays/objects, or require callers to pass `children` for such editors.","suggestion_code":null,"existing_code":" {children ?? String(value ?? '-')}"}
{"path":"apps/admin/src/components/EditableCell/index.tsx","start_line":265,"end_line":269,"category":"bug","severity":"high","content":"Pressing Enter while using editors whose value commits through their own internal Enter handling (`date`/`date-range`/`select`/`multi-select`/`tags`) will also hit this keydown handler and call `save()` with the `draft` captured in the current render. Those controls only update `draft` via their own async `onChange` (e.g., rc-picker commits a typed date, rc-select selects a highlighted option, tags mode adds a tag), so `draft` here is still the pre-interaction stale value. Depending on event ordering, `event.preventDefault()` may also suppress the control's commit — the user's typed date / selected option / typed tag is either silently discarded or the previous value gets saved over the new one (and two concurrent `onSave` calls can race). Restrict the Enter shortcut to editors whose draft is already in sync with the input (text/textarea/number/money).","suggestion_code":" if (\n event.key === 'Enter' &&\n (editor === 'text' || editor === 'textarea' || editor === 'number' || editor === 'money')\n ) {\n event.preventDefault();\n await save();\n return;\n }","existing_code":" if (event.key === 'Enter' && editor !== 'textarea') {\n event.preventDefault();\n await save();\n return;\n }"}
{"path":"apps/admin/src/components/EditableCell/index.tsx","start_line":95,"end_line":95,"category":"bug","severity":"medium","content":"`crypto.randomUUID()` is only available in secure contexts (https/localhost) and relatively modern browsers (e.g., Safari < 15.4 throws). This is invoked during render inside `useRef`, so on an intranet http deployment or older engine the whole component crashes with a TypeError. Add a fallback ID generator.","suggestion_code":" const idRef = useRef(\n (() => {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n return `editable-cell-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;\n })(),\n );","existing_code":" const idRef = useRef(crypto.randomUUID());"}
{"path":"apps/admin/src/components/EditableCell/index.tsx","start_line":119,"end_line":120,"category":"maintainability","severity":"low","content":"`normalizeEditableValue(formatValue ? formatValue(value) : value, editor)` is duplicated four times (initial `draft` state, `original` memo, `cancel`, `beginEdit`). If the display-value normalization ever changes for one editor type, the copies can diverge and cause the unchanged-value equality check (and thus unnecessary saves) to behave inconsistently. Extract a single helper and reuse it in all four places.","suggestion_code":"function normalizeDisplayValue<Value>(\n value: Value,\n editor: EditableCellEditor,\n formatValue?: (value: Value) => unknown,\n) {\n return normalizeEditableValue(formatValue ? formatValue(value) : value, editor);\n}","existing_code":" const cancel = useCallback(() => {\n setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));"}
{"path":"apps/admin/src/components/EditableCell/index.tsx","start_line":273,"end_line":275,"category":"maintainability","severity":"low","content":"`value: draft as never` casts away all editor-specific types. Besides losing type safety, it has a runtime edge case: with editor `'text'` and a numeric `value`, `original` stays a number while any user input becomes a string, so `editableValuesEqual(serialized, original)` (fast-deep-equal) always returns false and an unmodified cell triggers a redundant `onSave`. Consider modeling `draft` as a discriminated union per editor instead of `as never`.","suggestion_code":null,"existing_code":" const commonProps = {\n autoFocus: true,\n value: draft as never,"}
{"path":"apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx","start_line":83,"end_line":85,"category":"security","severity":"high","content":"Opening user-uploaded attachment bytes via `URL.createObjectURL` + `window.open(url, '_blank')` is a stored-XSS vector: a blob URL inherits the creator page's origin, so if the attachment is an HTML/SVG file containing scripts, those scripts run in the admin's origin/session. Prefer forcing a download instead — e.g. build an `<a>` with a `download` attribute and click it, set the blob type to `application/octet-stream`, or serve the file through a dedicated endpoint returning `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`. Also note `window.open` may return null when blocked by popup blockers, and revoking the URL after a fixed 60s can break a preview tab that stays open longer.","suggestion_code":null,"existing_code":" const url = URL.createObjectURL(blob);\n window.open(url, '_blank');\n setTimeout(() => URL.revokeObjectURL(url), 60_000);"}
{"path":"apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx","start_line":80,"end_line":82,"category":"bug","severity":"low","content":"The shared axios instance (`api/index.ts`) has a hardcoded 10s timeout, which also applies to this attachment download. Larger files may abort mid-download. Pass a larger per-request timeout (e.g. `timeout: 60000`) or stream the download instead.","suggestion_code":null,"existing_code":" const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {\n responseType: 'blob',\n });"}
{"path":"apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx","start_line":23,"end_line":23,"category":"maintainability","severity":"low","content":"Endpoint URL paths are hardcoded and duplicated inline (`/archive/attachments/${attachmentId}`, `/archive/attachments/${id}/permanent`, `/archive/${studentId}/attachments`). Per the project's hardcoding rule, centralize these paths in the api layer (e.g. an `api/archive.ts`) so endpoints have a single source of truth.","suggestion_code":null,"existing_code":" async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),"}
{"path":"apps/admin/src/components/RouteDock/index.tsx","start_line":126,"end_line":129,"category":"maintainability","severity":"medium","content":"Hardcoded business URL '/dashboard' violates the no-hardcoded-URL-path rule and creates an inconsistency: if the current active tab is already '/dashboard', `onNavigate` is a no-op, so the useEffect never re-runs and the dock stays permanently empty (while from any other route the dashboard tab is re-created). Derive the fallback destination from the menu items / route config (e.g. the first menu key) and guard with `if (location.pathname !== target) onNavigate(target)`, or explicitly re-insert the home tab.","suggestion_code":null,"existing_code":" const closeAll = () => {\n setRouteDockTabs([]);\n onNavigate('/dashboard');\n };"}
{"path":"apps/admin/src/components/RouteDock/index.tsx","start_line":49,"end_line":50,"category":"maintainability","severity":"medium","content":"Business route patterns (/students/:id/profile, /classes/:id) are hardcoded in this component purely for label lookup. This duplicates routing knowledge and will silently produce wrong labels if routes are renamed. Recommend centralizing the path → label mapping in a route config or menu metadata instead of maintaining regex fallbacks here.","suggestion_code":null,"existing_code":" if (/^\\/students\\/\\d+\\/profile$/.test(pathname)) return '学生档案';\n if (/^\\/classes\\/\\d+$/.test(pathname)) return '班级详情';"}
{"path":"apps/admin/src/components/RouteDock/index.tsx","start_line":87,"end_line":87,"category":"maintainability","severity":"low","content":"`activeKey` is just `location.pathname` (line 77), so the dependency array lists the same value twice. Keeping both is confusing and risks drift if the derivation changes; keep only `location.pathname` (and note that `menuItems` can be a large rebuilt array — a stable reference from the parent is relied upon here).","suggestion_code":null,"existing_code":" }, [activeKey, location.pathname, menuItems, setRouteDockTabs]);"}
{"path":"apps/admin/src/components/RouteKeeper.tsx","start_line":24,"end_line":26,"category":"bug","severity":"medium","content":"Mutating refs (cacheRef/orderRef) directly during render is a render-time side effect and breaks React render purity. With React 18 StrictMode and concurrent rendering, the render function can run multiple times before commit and ref mutations are not rolled back. The `has()` guard makes it idempotent in the common case, but this pattern is fragile (e.g., an aborted render still leaves the entry cached). Move the cache bookkeeping into `useLayoutEffect` (backed by state that triggers re-render) or use the documented \"adjust state during render\" pattern so the render phase stays pure.","suggestion_code":null,"existing_code":" if (outlet && !cacheRef.current.has(pageKey)) {\n cacheRef.current.set(pageKey, outlet);\n orderRef.current.push(pageKey);"}
{"path":"apps/admin/src/components/RouteKeeper.tsx","start_line":27,"end_line":30,"category":"bug","severity":"medium","content":"The LRU eviction logic has two problems: (1) re-visiting an already cached page never refreshes its recency, so a page that was just viewed can still be treated as the oldest; (2) when the eviction target happens to be the currently active page (pageKey was inserted long ago and never re-touched), `oldest !== pageKey` skips the deletion but `shift()` has already removed the key from orderRef — cacheRef and orderRef become desynced and that page is never evicted/tracked again (cache grows permanently by one entry per occurrence). Fix by refreshing recency on access, which also guarantees the active page can never be the oldest so the deletion can be unconditional.","suggestion_code":" if (cacheRef.current.has(pageKey)) {\n // 重新访问时刷新 LRU 顺序,避免活跃页被误判为最旧\n const idx = orderRef.current.indexOf(pageKey);\n if (idx > -1) {\n orderRef.current.splice(idx, 1);\n orderRef.current.push(pageKey);\n }\n } else if (outlet) {\n cacheRef.current.set(pageKey, outlet);\n orderRef.current.push(pageKey);\n if (orderRef.current.length > MAX_CACHED_PAGES) {\n const oldest = orderRef.current.shift();\n if (oldest) cacheRef.current.delete(oldest);\n }\n }","existing_code":" if (orderRef.current.length > MAX_CACHED_PAGES) {\n const oldest = orderRef.current.shift();\n if (oldest && oldest !== pageKey) cacheRef.current.delete(oldest);\n }"}
{"path":"apps/admin/src/components/RouteKeeper.tsx","start_line":35,"end_line":40,"category":"bug","severity":"medium","content":"Hidden cached pages are not truly frozen: they stay mounted as children of RouteKeeper and re-render on every navigation (useLocation triggers a RouteKeeper re-render, and the map re-renders all cached subtrees). Because they read the router-level LocationContext, `useLocation()`/`useSearchParams()` inside a hidden page return the *current* route's location rather than the location it was cached with, so effects that depend on location may fire while the page is hidden and up to MAX_CACHED_PAGES subtrees re-render on each navigation. Consider memoizing each cached page boundary and/or providing a location context captured at cache time so hidden pages don't react to unrelated navigations.","suggestion_code":null,"existing_code":" {Array.from(cacheRef.current.entries()).map(([key, node]) => (\n <div\n key={key}\n className=\"route-keeper-page\"\n style={{ display: key === pageKey ? undefined : 'none' }}\n >"}
{"path":"apps/admin/src/components/RouteKeeper.tsx","start_line":20,"end_line":22,"category":"maintainability","severity":"low","content":"Keying only by pathname means URLs sharing a pathname but differing in query params (e.g. search/filter pages `/list?keyword=a` and `/list?keyword=b`) share a single frozen cache entry: the cached node keeps the props/element captured on the first visit, so loader data or param-derived props never update when the same pathname is visited with different params — only direct router-context reads (useSearchParams) see the new URL. This is a deliberate design (documented in the comment), but pages relying on param/loader-derived content can show stale data; either include the relevant search params in the cache key or ensure such pages read state from the router context.","suggestion_code":null,"existing_code":" // 仅以 pathname 作为缓存键:页面内部通过 URL 参数同步状态时不会\n // 产生第二个实例,切回时也不会因此重挂载。\n const pageKey = location.pathname;"}
{"path":"apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx","start_line":42,"end_line":42,"category":"bug","severity":"medium","content":"Clearing an editable field via inline cell editing does not actually persist. `EditableCell.saveValue` serializes a cleared value to `undefined` for date/number/select editors (see `serializeEditableValue` in EditableCell/index.tsx), and this `saveCell` builds the payload `{ [field]: value }`. `JSON.stringify` silently drops `undefined` properties, so the request body contains no `examDate`/`classAvg`/`rank` key and the server cannot clear the field — the value reappears after refetch. Send `null` (or explicitly handle `undefined`) so the backend can clear nullable fields.","suggestion_code":" api.put(`/archive/exam-scores/${id}`, { [field]: value ?? null }),","existing_code":" api.put(`/archive/exam-scores/${id}`, { [field]: value }),"}
{"path":"apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx","start_line":137,"end_line":137,"category":"bug","severity":"low","content":"Display inconsistency with `null` values: the `score` cell uses `v ?? '-'` (which renders '-' for `null`), but `classAvg` and `rank` use `v !== undefined ? v : '-'`, which renders a blank cell when the API returns `null` (React renders `null` as nothing). Use `v ?? '-'` here too for a consistent fallback display.","suggestion_code":" {v ?? '-'}","existing_code":" {v !== undefined ? v : '-'}"}
{"path":"apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx","start_line":37,"end_line":37,"category":"maintainability","severity":"low","content":"API endpoint paths (`/archive/${studentId}/exam-scores`, `/archive/exam-scores/${id}`, `/archive/exam-scores/${id}/permanent`) are hardcoded business strings, which the review rules prohibit. Note this pattern is repeated across the sibling tabs (EnrollmentsTab/LearningTab/AttachmentsTab) and the parent page; consider centralizing these paths in a shared archive API module/constants so path changes are applied consistently.","suggestion_code":null,"existing_code":" api.post(`/archive/${studentId}/exam-scores`, payload),"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":515,"end_line":519,"category":"security","severity":"medium","content":"Using document.write() to inject the report HTML is explicitly prohibited by the security checklist (page reflow + XSS surface). The HTML returned by `/archive/:id/report-html` embeds student data; if the server-side template does not escape fields (name, notes, etc.), arbitrary script can execute in the new window context. Also, if `window.open` returns null (popup blocked), the user gets no feedback. Prefer opening a Blob URL: `const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const w = window.open(url, '_blank');` (and revoke the URL later / notify when the popup is blocked).","suggestion_code":" const blob = new Blob([html], { type: 'text/html' });\n const url = URL.createObjectURL(blob);\n const w = window.open(url, '_blank');\n if (!w) {\n message.error('预览报告被浏览器拦截,请允许弹出窗口');\n }","existing_code":" const w = window.open('', '_blank');\n if (w) {\n w.document.write(html);\n w.document.close();\n }"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":290,"end_line":308,"category":"maintainability","severity":"low","content":"This is a nested ternary (`canChooseOrganization ? ... : student.organization?.name ? ... : ...`), which is prohibited by the review rules and is hard to read/maintain. Flatten it by pre-computing the organization tag before the return statement, e.g. `const orgTag = student.organization?.name ? <Tag color=\"purple\">{student.organization.name}</Tag> : '-';`, then use a single conditional.","suggestion_code":" {canChooseOrganization ? (\n <EditableCell\n value={student.organizationId}\n editor=\"select\"\n options={organizations.map((item) => ({ value: item.id, label: item.name }))}\n permission=\"student:edit\"\n onSave={(next) => saveStudent('organizationId', next)}\n >\n {orgTag}\n </EditableCell>\n ) : (\n orgTag\n )}","existing_code":" {canChooseOrganization ? (\n <EditableCell\n value={student.organizationId}\n editor=\"select\"\n options={organizations.map((item) => ({ value: item.id, label: item.name }))}\n permission=\"student:edit\"\n onSave={(next) => saveStudent('organizationId', next)}\n >\n {student.organization?.name ? (\n <Tag color=\"purple\">{student.organization.name}</Tag>\n ) : (\n '-'\n )}\n </EditableCell>\n ) : student.organization?.name ? (\n <Tag color=\"purple\">{student.organization.name}</Tag>\n ) : (\n '-'\n )}"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":125,"end_line":126,"category":"maintainability","severity":"low","content":"`onRefresh` is declared in the props type and passed by the parent (`onRefresh={fetchData}`), but it is never destructured or referenced anywhere inside `InlineArchiveSummary` — dead prop. Remove it from the type, the destructuring-free usage, and the call site (or wire it up if refreshing was intended after edits).","suggestion_code":null,"existing_code":" onRefresh: () => void;\n onViewSensitive: (fieldLabel: string, value: string) => void;"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":620,"end_line":631,"category":"maintainability","severity":"low","content":"All four statistics cards (入学测试总分 / 阶段最高分 / 阶段提升分 / 出勤率) are hardcoded to display '-' and are never derived from `aggregateData` (e.g. examScores/attendances). This is an incomplete feature rendered as permanent placeholders. Either compute the real values from the aggregate data or remove the row until implemented.","suggestion_code":null,"existing_code":" {[\n { title: '入学测试总分' },\n { title: '阶段最高分' },\n { title: '阶段提升分' },\n { title: '出勤率' },\n ].map((item) => (\n <Col span={6} key={item.title}>\n <Card size=\"small\">\n <Statistic title={item.title} value=\"-\" />\n </Card>\n </Col>\n ))}"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":566,"end_line":570,"category":"maintainability","severity":"low","content":"The '报告版本' tab always renders `<Empty description=\"暂无报告版本\" />` regardless of any data — a hardcoded placeholder for an unimplemented feature. If report versions exist on the server, this is misleading; otherwise consider removing the tab until it is implemented.","suggestion_code":null,"existing_code":" {\n key: 'reports',\n label: '报告版本',\n children: <Empty description=\"暂无报告版本\" />,\n },"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":598,"end_line":598,"category":"maintainability","severity":"low","content":"The `<style>` tag injects a global `.archived-row` CSS rule on every render of this component (including when it is only used to render child tables). If the component mounts multiple times (e.g. inside a drawer and on a page), duplicate style tags are inserted and the global class name can collide with unrelated styles. Prefer defining this rule once in a global stylesheet/CSS module instead of inline per-render injection.","suggestion_code":null,"existing_code":" <style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>"}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":290,"end_line":292,"category":"maintainability","severity":"medium","content":"Nested ternary is prohibited by the review rules. The tag rendering here is a ternary nested inside another ternary, and the `Alert` `type` prop just below has the same pattern (`success ? (modelAvailable ? 'success' : 'warning') : 'error'`). Extract the logic into a helper/const (e.g. compute `status` and `type` before the return) to keep it flat and readable.","suggestion_code":" {(() => {\n if (testResult.success && testResult.modelAvailable) {\n return <Tag icon={<CheckCircleOutlined />} color=\"success\">成功</Tag>;\n }\n if (testResult.success) {\n return <Tag icon={<WarningOutlined />} color=\"warning\">模型未找到</Tag>;\n }\n return <Tag icon={<CloseCircleOutlined />} color=\"error\">失败</Tag>;\n })()}","existing_code":" {testResult.success ? (\n testResult.modelAvailable ? (\n <Tag icon={<CheckCircleOutlined />} color=\"success\">"}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":371,"end_line":373,"category":"maintainability","severity":"medium","content":"Nested ternary is prohibited by the review rules. The key-preview rendering nests a ternary inside another ternary. Prefer extracting the tag into a variable (e.g. `const keyTag = config?.hasApiKey ? ... : hasFormKey ? ... : ...;`) before the return, or use an if-chain.","suggestion_code":null,"existing_code":" {config?.hasApiKey ? (\n <Tag color=\"green\">{config.maskedApiKey || '••••'}</Tag>\n ) : hasFormKey ? ("}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":353,"end_line":353,"category":"maintainability","severity":"low","content":"`formValues.apiKey && formValues.apiKey !== '••••'` evaluates to a string (or `''`), not a boolean, so `hasFormKey` has a string type despite being used as a condition and semantically being a boolean flag. Coerce with `!!` for a proper boolean type.","suggestion_code":" const hasFormKey = !!formValues.apiKey && formValues.apiKey !== '••••';","existing_code":" const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••';"}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":112,"end_line":112,"category":"maintainability","severity":"low","content":"The condition `!canWrite || (isFixedProvider && canWrite)` is redundant: whenever `canWrite` is false the first term already disables the input, and when `canWrite` is true the second term reduces to `isFixedProvider`. It can be simplified to `!canWrite || isFixedProvider`, which makes the intent (baseUrl is locked whenever the provider is fixed) clearer.","suggestion_code":" disabled={!canWrite || isFixedProvider}","existing_code":" disabled={!canWrite || (isFixedProvider && canWrite)}"}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":156,"end_line":156,"category":"maintainability","severity":"low","content":"Many static inline styles are used throughout this file (e.g. `style={{ marginBottom: 12 }}`, `style={{ marginLeft: 8, fontSize: 12, color: '#999' }}`, `style={{ marginBottom: 8 }}`, `style={{ marginTop: 8 }}`, `style={{ marginBottom: 16 }}`). Per the review rules, inline styles should be avoided except for dynamic values; these repeated static styles should be moved into the CSS module (index.module.css).","suggestion_code":null,"existing_code":" <Descriptions column={1} size=\"small\" style={{ marginBottom: 12 }}>"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":258,"end_line":258,"category":"maintainability","severity":"medium","content":"The return type is `any[]`, which disables type checking for the menu items passed to antd's `Menu` (a typo in a key/label would silently pass through). Use `MenuProps['items']` from antd instead (and type the item via `NonNullable<MenuProps['items']>[number]`), or at least a properly typed local interface, so the map result stays type-safe.","suggestion_code":" const transformToMenuItems = useCallback((items: AppMenuItem[]): MenuProps['items'] => {","existing_code":" const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":114,"end_line":114,"category":"maintainability","severity":"low","content":"The profile endpoint path `/auth/profile` is hardcoded inline in the layout. Per the review rules, business URL paths should not be hardcoded at call sites — centralize it in the api layer (e.g., a `api.auth.getProfile()` wrapper or an endpoint constant) so path changes and API shaping stay in one place.","suggestion_code":null,"existing_code":" '/auth/profile',"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":122,"end_line":126,"category":"performance","severity":"medium","content":"On any persistent profile-fetch failure (e.g., expired/invalid token returning 401, or a server outage), this catch block retries `verifyPermissions` every 5 seconds forever. The tab keeps hitting `/auth/profile` indefinitely in the background (also re-triggered on every `visibilitychange`/`online`), and the user gets no error feedback. Stop retrying on authentication errors (401/403), cap the number of retries, and surface a user-visible error (or redirect to login) when the profile can no longer be verified.","suggestion_code":null,"existing_code":" .catch(() => {\n verificationInFlight = false;\n if (cancelled || !useUserStore.getState().token) return;\n retryTimer = window.setTimeout(verifyPermissions, 5_000);\n });"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":325,"end_line":325,"category":"bug","severity":"low","content":"antd's `Drawer` `size` prop only accepts the preset values (`'default'` / `'large'`); a numeric `240` is not a valid `size` and won't apply the intended 240px width (the drawer falls back to the default width). If the goal is a 240px-wide drawer, use the `width` prop (`width={240}`) instead of `size`.","suggestion_code":" width={240}","existing_code":" size={240}"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":339,"end_line":341,"category":"maintainability","severity":"low","content":"This layout relies heavily on static inline `style` props (Header, Content, Sider, logo block, etc.). Per the review rules, inline styles should be reserved for dynamic values; static layout styling should live in CSS classes (e.g., `.app-header`, `.app-content`) so the JSX stays readable and the styles are consistent/reusable.","suggestion_code":null,"existing_code":" <Header\n className=\"app-header\"\n style={{"}
{"path":"apps/admin/src/components/StudentProfileContent/LearningTab.tsx","start_line":98,"end_line":100,"category":"bug","severity":"medium","content":"recordDate / recordType / content are marked `required` in the add-modal validation, but these inline editors do not pass the `required` prop to EditableArchiveCell. As a result a user can clear e.g. `content` inline (the text editor trims it to `''` and the empty string is persisted via PUT), and clearing the date picker produces `{ recordDate: undefined }`, which JSON-serializes to an empty body while the cell still reports success. Pass `required` to these three editable cells (matching ExamScoresTab's `subject` cell and the modal rules) to prevent required fields from being emptied inline.","suggestion_code":null,"existing_code":" <EditableArchiveCell value={v} field={LEARNING_FIELDS.recordDate} record={r} editor=\"date\" onSave={saveCell}>\n {v}\n </EditableArchiveCell>"}
{"path":"apps/admin/src/components/StudentProfileContent/LearningTab.tsx","start_line":108,"end_line":108,"category":"maintainability","severity":"low","content":"This manual label lookup duplicates the `getOptionLabel` helper already exported from `./shared`. Use `getOptionLabel(RECORD_TYPE_OPTIONS, v)` to keep the label resolution logic in one place and consistent with the rest of the module.","suggestion_code":null,"existing_code":" {RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}"}
{"path":"apps/admin/src/components/StudentProfileContent/LearningTab.tsx","start_line":35,"end_line":35,"category":"maintainability","severity":"low","content":"API endpoints are hardcoded inline as template strings here and in the PUT/DELETE mutations below. Per the review rules, business URL paths should not be hardcoded — consider extracting archive endpoints into a shared constants module (or a small API wrapper) so paths stay consistent and are easy to test/maintain.","suggestion_code":null,"existing_code":" api.post(`/archive/${studentId}/learning-records`, payload),"}
{"path":"apps/admin/src/components/StudentProfileContent/LearningTab.tsx","start_line":161,"end_line":161,"category":"style","severity":"low","content":"Static inline style; per the review rules inline styles should be avoided except for dynamic styles. Move `marginBottom: 16` to a CSS class (same applies to `style={{ width: '100%' }}` on the DatePicker).","suggestion_code":null,"existing_code":" style={{ marginBottom: 16 }}"}
{"path":"apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx","start_line":52,"end_line":54,"category":"bug","severity":"medium","content":"`setSaving(true)` is only reached after `await form.validateFields()`. If the OK button is clicked twice in quick succession, both invocations can pass before React commits the `confirmLoading` state, causing two concurrent POST requests and duplicate enrollment records. Move the `saving` guard/set to the very start of the handler (the `finally` block already resets it).","suggestion_code":" if (saving) return;\n try {\n setSaving(true);\n const values = await form.validateFields();","existing_code":" try {\n const values = await form.validateFields();\n setSaving(true);"}
{"path":"apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx","start_line":250,"end_line":252,"category":"bug","severity":"medium","content":"No cross-field validation between 开始日期 and 结束日期: a record can be created with `endDate` earlier than `startDate`, or with only one of the two dates filled in (the server DTO marks both optional and performs no range check either). This can corrupt the enrollment timeline displayed elsewhere. Add a validator on `endDate` (using `dependencies` on `startDate`) to reject `endDate < startDate`, or enforce both-or-neither.","suggestion_code":null,"existing_code":" <Form.Item name=\"startDate\" label=\"开始日期\">\n <DatePicker style={{ width: '100%' }} />\n </Form.Item>"}
{"path":"apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx","start_line":204,"end_line":204,"category":"style","severity":"low","content":"Static inline style — per project conventions (inline styles only for dynamic values), this margin should be a CSS class/utility instead.","suggestion_code":null,"existing_code":" style={{ marginBottom: 16 }}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx","start_line":14,"end_line":17,"category":"bug","severity":"medium","content":"'late' is a valid record status in this module (attendance-workspace.ts counts `status === 'present' || status === 'late'` as checked-in, and EMPTY_SUMMARY includes `late`), but STATUS_META has no 'late' entry. A 'late' record rendered by AttendanceStatusTag falls into the fallback branch and displays the raw English string 'late' with className `is-absent` and short '?', visually treating a late student as absent. Add a `late` entry to STATUS_META (e.g. label '迟到', className 'is-late' which already exists in attendance.css).","suggestion_code":null,"existing_code":"export const STATUS_META: Record<\n string,\n { label: string; color: string; className: string; short: string }\n> = {"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx","start_line":192,"end_line":192,"category":"bug","severity":"high","content":"'late' records are excluded from the attendance rate calculation and from pickPrimaryStatus priority. Since attendance-workspace.ts treats `status === 'late'` as checked-in, a student with only 'late' records will be shown with primaryStatus 'absent' and rate 0%, misrepresenting them as fully absent. Count late as checked-in (consistent with the workspace logic) and add 'late' to the priority list in pickPrimaryStatus.","suggestion_code":"const checked = item.records.filter(\n (record) => record.status === 'present' || record.status === 'late',\n ).length;","existing_code":"const checked = item.records.filter((record) => record.status === 'present').length;"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx","start_line":97,"end_line":99,"category":"bug","severity":"medium","content":"displayAttendanceStatus silently converts 'pending' to 'absent'. As a result, STATUS_META.pending ('待确认') is never reachable through AttendanceStatusTag, and in pickPrimaryStatus a student with only 'pending' records is treated as 'absent' (since pending is normalized to absent before the priority lookup). If 'pending → absent' is an intentional admin-side policy, document it with a comment; otherwise keep pending distinct so unconfirmed attendance is not shown as 缺勤.","suggestion_code":null,"existing_code":"export function displayAttendanceStatus(status?: string | null): string {\n return status === 'pending' || !status ? 'absent' : status;\n}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx","start_line":10,"end_line":10,"category":"bug","severity":"low","content":"The 'afternoon' period is labeled '晚课' (evening class) with startTime 14:0017:00, which conflicts with both its periodKey 'afternoon' and the later 'evening_study' period labeled '晚自习' (18:3021:00). This looks like a typo — the label should likely be '下午课' (afternoon class) to match the time slot.","suggestion_code":null,"existing_code":" { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx","start_line":25,"end_line":25,"category":"maintainability","severity":"medium","content":"The `columns` prop is typed as `any[]`, which discards all type safety for the Table columns (and the checklist requires avoiding `any` unless a comment justifies it). Use antd's `TableColumnsType<AttendanceRecordItem>` instead so column definitions remain type-checked against the record shape.","suggestion_code":" columns: TableColumnsType<AttendanceRecordItem>;","existing_code":" columns: any[];"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx","start_line":134,"end_line":134,"category":"maintainability","severity":"medium","content":"This compares against a display label (`'出勤'`) to decide the rendered text. Status display labels are business strings that can easily change, silently breaking the rendering. Compare against a stable status key (e.g., the record's status / STATUS_META key) or add an explicit display-name map instead of relying on label equality.","suggestion_code":null,"existing_code":" {meta.label === '出勤' ? '正常' : meta.label}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx","start_line":149,"end_line":155,"category":"style","severity":"low","content":"Static inline style block — per the checklist inline styles should be avoided except for dynamic values. Move this margin/alignment layout into a CSS class.","suggestion_code":null,"existing_code":" style={{\n marginBottom: 8,\n display: 'flex',\n alignItems: 'center',\n gap: 8,\n flexWrap: 'wrap',\n }}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx","start_line":190,"end_line":190,"category":"maintainability","severity":"low","content":"`rowSelection.onChange` provides `Key[]` (i.e. `string | number`), and blindly casting to `number[]` is an unsafe assumption. Although `rowKey=\"id\"` currently makes keys numeric, a non-numeric key would silently produce broken selection state. Filter/validate the keys or keep the generic `Key[]` type in the handler signature.","suggestion_code":null,"existing_code":" onChange: (keys) => onSelectRecords(keys as number[]),"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":264,"end_line":267,"category":"bug","severity":"medium","content":"If `refreshConfig()` (i.e. `refetchConfig()`) rejects — e.g. a transient network error on `/ai/config` right after a successful test — this catch block overwrites the already-recorded successful `testResult` with a failure card saying \"测试请求失败\", even though the connection test itself succeeded. Additionally, `skipNextSyncRef` is set in `refreshConfig` before the fetch, so a failed refetch leaves the flag stuck and the next genuine config sync will be silently skipped. Separate the refresh from the test try/catch, e.g. `await refreshConfig().catch(() => {})`, so a refresh failure doesn't corrupt the test result.","suggestion_code":" const res = await api.post<TestResult>('/ai/config/test', body);\n setTestResult(res);\n // 刷新失败不应覆盖已成功的测试结果\n await refreshConfig().catch(() => {});\n } catch (err: unknown) {","existing_code":" const res = await api.post<TestResult>('/ai/config/test', body);\n setTestResult(res);\n await refreshConfig();\n } catch (err: unknown) {"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":82,"end_line":83,"category":"security","severity":"medium","content":"This query runs unconditionally even for users without `ai:config:read`, because the `!canRead` early return happens after all hooks. Sensitive config (provider/baseUrl/key state) is fetched from `/ai/config` before the permission gate is reached, and the request is also wasted for unauthorized users. Hoist the `usePermission`/`canRead` computation above the query and gate it with `enabled: canRead`.","suggestion_code":" } = useQuery<AiConfigData | null>({\n queryKey: ['ai', 'config'],\n enabled: canRead,","existing_code":" } = useQuery<AiConfigData | null>({\n queryKey: ['ai', 'config'],"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":222,"end_line":223,"category":"bug","severity":"medium","content":"`enabled: true` is hardcoded on every save. There is no `enabled` control anywhere on this page, so if the AI feature was deliberately disabled (e.g. via DB or another admin surface), simply saving any change on this page silently re-enables it. Preserve the existing state (`enabled: config?.enabled ?? true`) or add an explicit enable/disable control so a disabled config isn't accidentally turned back on.","suggestion_code":" defaultModel: defaultModel || undefined,\n enabled: config?.enabled ?? true,","existing_code":" defaultModel: defaultModel || undefined,\n enabled: true,"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":169,"end_line":171,"category":"bug","severity":"low","content":"This `try` also wraps `form.validateFields(...)`. When validation fails (e.g. missing baseUrl for OPENAI_COMPATIBLE), the rejection is reported as \"获取模型列表失败\" — a misleading error toast that blames the model fetch. Run `validateFields` before entering the try/catch (or distinguish validation failures) so users see the actual field errors instead of a fake fetch error.","suggestion_code":" setFetchingModels(true);\n try {\n // validateFields 失败会进入 catch被误报为“获取模型列表失败”\n await form.validateFields(['provider', 'baseUrl']);","existing_code":" setFetchingModels(true);\n try {\n await form.validateFields(['provider', 'baseUrl']);"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":176,"end_line":176,"category":"maintainability","severity":"low","content":"The masked-key sentinel `'••••'` is hardcoded in three places in this file (`handleFetchModels`, `handleSave`, `handleTest`) and duplicated in `AiConfigSteps.tsx`. A typo in one spot would silently send the literal mask to the server as a real API key. Extract a shared constant (e.g. `MASKED_API_KEY = '••••'` in helpers) and reuse it.","suggestion_code":null,"existing_code":" if (formKey && formKey !== '••••') body.apiKey = formKey;"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx","start_line":230,"end_line":235,"category":"maintainability","severity":"medium","content":"Nested ternary in the metric value calculation violates the project rule against nested ternaries and hurts readability: it mixes a string value (`${attendanceRate}%`) with numeric branches and handles three cases inline. Prefer an if/else chain (or a small helper) so each branch is explicit. Also guard `summary.absent`/`summary.pending` with `?? 0` to avoid a potential `NaN` if either field is ever undefined.","suggestion_code":" let value: string | number;\n if (metric.key === 'all') {\n value = `${attendanceRate}%`;\n } else if (metric.key === 'absent') {\n value = (summary.absent ?? 0) + (summary.pending ?? 0);\n } else {\n value = summary[metric.key as keyof AttendanceSummary] ?? 0;\n }","existing_code":" const value =\n metric.key === 'all'\n ? `${attendanceRate}%`\n : metric.key === 'absent'\n ? summary.absent + summary.pending\n : (summary[metric.key as keyof AttendanceSummary] ?? 0);"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx","start_line":64,"end_line":66,"category":"bug","severity":"medium","content":"The async `onSave` here does not `await onSaveAdminRecordCell(...)` and does not propagate rejection. EditableCell's internal save flow (`saveValue`) relies on the onSave promise rejecting on failure so it can show an error and keep the editor open; because this wrapper resolves immediately, the editor closes and shows a success/undo state even when the underlying mutation fails (the parent's `saveAdminRecordCell` also swallows the error, so a failed save looks successful to the user). Await the callback and let the error propagate (the parent should rethrow on failure) so EditableCell's built-in error handling works.","suggestion_code":" onSave={async (next) => {\n await onSaveAdminRecordCell(record, 'status', next);\n }}","existing_code":" onSave={async (next) => {\n onSaveAdminRecordCell(record, 'status', next);\n }}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx","start_line":59,"end_line":63,"category":"bug","severity":"low","content":"While a correction is in flight via the action-column Segmented (`correctingRecordId === record.id` disables only that Segmented), this status EditableCell for the same record is still enabled. A user can open it and submit a different status concurrently, producing two overlapping mutations on the same record that can race (optimistic patch vs. server result). Consider also disabling the cell while that record is being corrected, e.g. `disabled={!canEdit || correctingRecordId === record.id}`, destructuring `correctingRecordId` from the context.","suggestion_code":" options={ADMIN_CORRECTION_OPTIONS.map((item) => ({\n value: String(item.value),\n label: item.label,\n }))}\n disabled={!canEdit || correctingRecordId === record.id}","existing_code":" options={ADMIN_CORRECTION_OPTIONS.map((item) => ({\n value: String(item.value),\n label: item.label,\n }))}\n disabled={!canEdit}"}
{"path":"apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx","start_line":310,"end_line":311,"category":"bug","severity":"medium","content":"Edit controls remain enabled after the lesson is settled: when `session.status === 'completed'` the Alert says the lesson has been settled, but the per-row 已打卡/未打卡 buttons (and the 全部已打卡/全部未打卡 batch buttons) are still clickable. The API rejects updates to settled records, so the user will hit an avoidable error (individual edits even cause an optimistic update + rollback). Disable/hide editing when `completed` — e.g. render only `<AttendanceStatus>` when `completed || !canEditAttendance` and disable the batch buttons while `completed`.","suggestion_code":" if (!canEditAttendance || completed) return <AttendanceStatus status={value} />;\n const checkedIn = value === 'present' || value === 'late';","existing_code":" if (!canEditAttendance) return <AttendanceStatus status={value} />;\n const checkedIn = value === 'present' || value === 'late';"}
{"path":"apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx","start_line":191,"end_line":191,"category":"bug","severity":"low","content":"`updateRecord` does not respect `cancelledRef`, contradicting the component comment that \"in-flight requests no longer update state or show toasts after unmount\". If an individual update resolves after the drawer is closed or the lesson switched/remounted, it can still call `setRecords` and show `message.error` on a stale instance. Additionally, rapid toggling of the same record can race, and the rollback (`setRecords(... previous)`) may restore a stale status. Guard after the await (e.g. `if (cancelledRef.current) return;`) before updating state/toast, and consider ignoring errors when cancelled.","suggestion_code":null,"existing_code":" await api.put(`/attendance-records/${record.id}`, { status });"}
{"path":"apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx","start_line":101,"end_line":103,"category":"maintainability","severity":"low","content":"Business status literals ('present'/'late'/'absent') and API endpoint paths ('/attendance-records/batch-status', '/attendance-records/${id}', '/attendance-lessons/schedules/.../pull') are hardcoded and repeated in several places (batch filter, button handlers, update calls). Extract them into shared constants/helpers so a backend enum change or a typo can be fixed in one place.","suggestion_code":null,"existing_code":" status === 'present'\n ? record.status !== 'present' && record.status !== 'late'\n : record.status !== 'absent',"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":487,"end_line":496,"category":"maintainability","severity":"medium","content":"Nested ternary (a three-way chained conditional) violates the project rule that nested ternary expressions are not allowed and hurts readability. Refactor to explicit if/else branches.","suggestion_code":" const subjectTeacherNames = (() => {\n if (currentSchedule) {\n return getTeacherDisplayName({\n name: currentSchedule.teacherName,\n username: currentSchedule.teacherUsername,\n });\n }\n if (classId) {\n return formatTeacherNames(\n overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),\n );\n }\n return '请选择班级';\n })();","existing_code":" const subjectTeacherNames = currentSchedule\n ? getTeacherDisplayName({\n name: currentSchedule.teacherName,\n username: currentSchedule.teacherUsername,\n })\n : classId\n ? formatTeacherNames(\n overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),\n )\n : '请选择班级';"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":409,"end_line":410,"category":"maintainability","severity":"medium","content":"Nested ternary used for the status label violates the no-nested-ternary rule. Extract a status → label map instead for clarity and to avoid silently treating any unknown status as 「缺勤」.","suggestion_code":" const statusLabelMap: Record<string, string> = {\n present: '正常',\n leave: '请假',\n absent: '缺勤',\n };\n const statusLabel = statusLabelMap[nextStatus] ?? '未知';","existing_code":" const statusLabel =\n nextStatus === 'present' ? '正常' : nextStatus === 'leave' ? '请假' : '缺勤';"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":346,"end_line":354,"category":"maintainability","severity":"low","content":"Per project async conventions, prefer async/await over Promise chains. Also consider checking the response content-type / error body so a non-OK export (e.g. 401/500 returning JSON) shows a more accurate error.","suggestion_code":" try {\n const response = await fetch(`/api/attendance-records/export?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n });\n if (!response.ok) throw new Error('导出失败');\n const blob = await response.blob();\n saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`);\n } catch {\n message.error('导出失败');\n }","existing_code":" fetch(`/api/attendance-records/export?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n .then((response) => {\n if (!response.ok) throw new Error('导出失败');\n return response.blob();\n })\n .then((blob) => saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`))\n .catch(() => message.error('导出失败'));"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":501,"end_line":508,"category":"performance","severity":"low","content":"`buildAttendanceAdminColumns` is called on every render, producing a new `columns` array identity each time and forcing the table to re-render/re-measure even when nothing relevant changed. Memoize the columns (and make the `saveAdminRecordCell`/`updateAdminRecordStatus` handlers stable with useCallback) so the memo is effective.","suggestion_code":" const columns = useMemo(\n () =>\n buildAttendanceAdminColumns({\n isMobile,\n canEdit,\n sessionMap,\n correctingRecordId,\n onSaveAdminRecordCell: saveAdminRecordCell,\n onUpdateAdminRecordStatus: updateAdminRecordStatus,\n }),\n [isMobile, canEdit, sessionMap, correctingRecordId, saveAdminRecordCell, updateAdminRecordStatus],\n );","existing_code":" const columns = buildAttendanceAdminColumns({\n isMobile,\n canEdit,\n sessionMap,\n correctingRecordId,\n onSaveAdminRecordCell: saveAdminRecordCell,\n onUpdateAdminRecordStatus: updateAdminRecordStatus,\n });"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":584,"end_line":587,"category":"bug","severity":"low","content":"When the user changes `pageSize`, the current `page` is preserved unchanged. If the smaller page size yields fewer pages than the current page, the query can land on an empty/out-of-range page with no data. Reset `page` to 1 when `pageSize` changes.","suggestion_code":" onPageChange={(nextPage, nextPageSize) => {\n if (nextPageSize !== pageSize) {\n setPage(1);\n } else {\n setPage(nextPage);\n }\n setPageSize(nextPageSize);\n }}","existing_code":" onPageChange={(nextPage, nextPageSize) => {\n setPage(nextPage);\n setPageSize(nextPageSize);\n }}"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":323,"end_line":323,"category":"performance","severity":"low","content":"`refreshDingTalkMutation` is configured with `invalidate: [['attendance','records'], ['attendance','sync-status']]`, which already refetches the active records/sync-status queries on success. The explicit `loadRecords()` / `loadSyncStatus()` here therefore issues duplicate network requests. Keep only one mechanism (prefer the invalidate config) to avoid double fetching.","suggestion_code":" // no manual refetch needed: the mutation's invalidate already refetches records & sync-status","existing_code":" await Promise.all([loadRecords(), loadSyncStatus()]);"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx","start_line":157,"end_line":158,"category":"performance","severity":"low","content":"`sortAttendanceRecords(student.records)` is invoked twice in the same render (here in the rates section and again in the timeline section). The sort is recomputed on every render, including after each status correction (when `correctingRecordId` changes). Compute it once before the return (e.g. `const sortedRecords = sortAttendanceRecords(student.records);`) and reuse it in both `.map()` calls to avoid duplicate work.","suggestion_code":" <section className=\"student-detail-rates\">\n {sortedRecords.map((record) => {","existing_code":" <section className=\"student-detail-rates\">\n {sortAttendanceRecords(student.records).map((record) => {"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx","start_line":137,"end_line":137,"category":"bug","severity":"low","content":"A string width ('100%') is passed to Drawer's `size` prop, which elsewhere in this project is used with numeric widths (e.g. `size={680}`, `size={960}`). antd's `size` is meant for preset values / numeric width; passing an arbitrary string may be ignored or fall back to the default width (378px), so on mobile the drawer may not actually be full-screen. Use the `width` prop (which accepts `string | number`) for the percentage case: `width={isMobile ? '100%' : 520}`.","suggestion_code":" width={isMobile ? '100%' : 520}","existing_code":" size={isMobile ? '100%' : 520}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx","start_line":174,"end_line":174,"category":"bug","severity":"low","content":"Here `displayAttendanceStatus` maps 'pending' records to 'absent', so in this detail/correction drawer a pending (待确认) record is shown as 缺勤 and the correction `Segmented` preselects 缺勤 as if it were already the current value. This is misleading in a correction UI: the parent's `updateAdminRecordStatus` only treats a selection as a no-op when the raw status is not 'pending', so clicking the already-highlighted 缺勤 option will silently commit a pending record to absent. Consider displaying the raw `record.status` (and showing a pending state) in this drawer so admins can distinguish 待确认 records before correcting them.","suggestion_code":null,"existing_code":" <AttendanceStatusTag status={displayAttendanceStatus(record.status)} />"}
{"path":"apps/admin/src/pages/Attendance/index.tsx","start_line":9,"end_line":12,"category":"bug","severity":"high","content":"`useMemo(readCurrentRoles, [])` reads the store only once at first render via `getState()` and never subscribes to updates. `userStore.user` is initialized as `null` and populated only after login/persist rehydration, so if this page mounts before the user data is available (or after role changes/logout), `roles` stays `[]` and `experience` is permanently stale — e.g. an academic admin (`教务管理员`) without an explicit `attendance:edit` permission would be wrongly routed to the teacher workspace. Use a zustand selector instead so the component subscribes and recomputes when roles change:\n\n```tsx\nconst roles = useUserStore((state) =>\n Array.isArray(state.user?.roles) ? (state.user!.roles as string[]) : [],\n);\n```","suggestion_code":"const roles = useUserStore((state) =>\n Array.isArray(state.user?.roles) ? (state.user!.roles as string[]) : [],\n);","existing_code":"function readCurrentRoles(): string[] {\n const roles = useUserStore.getState().user?.roles;\n return Array.isArray(roles) ? roles : [];\n}"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":72,"end_line":72,"category":"maintainability","severity":"medium","content":"`EditableRentalCell` is declared inside the `RentalTable` component and used as a JSX element (`<EditableRentalCell .../>`), so React treats it as a component type whose identity changes on every render. Any re-render of `RentalTable` (e.g., each `setContractPercent` progress update during an upload) unmounts/remounts the whole cell subtree, which can interrupt an in-progress inline edit and drop input focus, and defeats memoization. Either invoke it as a plain render function (`EditableRentalCell({...})`) or move it outside the component, passing `onSaveCell`/`hasPermission` via props.","suggestion_code":null,"existing_code":" const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":266,"end_line":268,"category":"bug","severity":"medium","content":"On upload failure only `onError` is invoked. Because the `Upload` uses `showUploadList={false}`, antd will not render any error UI, so the user receives no feedback when a contract upload fails. Add a user-friendly `message.error(...)` in the catch block.","suggestion_code":null,"existing_code":" } catch (e) {\n onError?.(e as Error);\n } finally {"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":32,"end_line":33,"category":"maintainability","severity":"low","content":"Pervasive `any` usage (props `data`/`classrooms`/`organizations`, all render params) without justification comments. This hides field-name mistakes (see the `record.status` vs `record.effectiveStatus` mixing in the action column). Define typed interfaces such as `Rental`, `Classroom`, `Organization` and use them in the props and render callbacks.","suggestion_code":null,"existing_code":"export interface RentalTableProps {\n data: any[];"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":189,"end_line":189,"category":"bug","severity":"low","content":"If `startDate`/`endDate` is missing or an invalid date string, `dayjs(...).diff(...)` returns `NaN` and the cell renders \"NaN天\". Guard against invalid/missing dates and fall back to '-' before computing the duration.","suggestion_code":null,"existing_code":" const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":260,"end_line":261,"category":"bug","severity":"low","content":"`uploadingContractId` and `contractPercent` are single shared states for the whole table. If a second upload starts before the first finishes (or two rows are uploaded concurrently), the second call overwrites the first, so the first row's loading/progress display is wrong and its state is cleared prematurely. Track progress per contract id (e.g., a `Record<number, number>` map) or prevent starting another upload while one is in progress.","suggestion_code":null,"existing_code":" setUploadingContractId(r.id);\n setContractPercent(0);"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":327,"end_line":327,"category":"bug","severity":"low","content":"All other row actions are gated on `record.effectiveStatus`, but the delete button checks `record.status === 'cancelled'`. If the persisted `status` field isn't updated to 'cancelled' when a rental is cancelled (only `effectiveStatus` changes), this button never appears; if the two fields can diverge, the visibility logic is inconsistent. Use the same field used by the sibling conditions for consistency.","suggestion_code":null,"existing_code":" {record.status === 'cancelled' && canPurgeRental ? ("}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":65,"end_line":68,"category":"bug","severity":"medium","content":"The detail query does not guard against an undefined `id`, while the sibling schedule/attendance queries do (`if (!id) return ...`). If the route param is missing, this sends a request to `/classes/undefined`. Add `enabled: Boolean(id)` to the query options or an `if (!id)` guard in the queryFn for consistency and robustness.","suggestion_code":null,"existing_code":" queryFn: async () => {\n const res = (await api.get(`/classes/${id}`)) as ClassDetail;\n return { detail: res, students: res.students || [], teachers: res.teachers || [] };\n },"}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":93,"end_line":95,"category":"performance","severity":"low","content":"The schedule and attendance-summary queries are executed immediately on page mount (and on every date-range change) even when the user never opens those tabs, producing wasted network requests. Consider lazy-loading them only when the corresponding tab is active (e.g. track the active tab key and use `enabled`, or fetch inside the tab component).","suggestion_code":null,"existing_code":" queryKey: ['classes', 'schedule', id, scheduleDateRange],\n queryFn: async () => {\n if (!id) return [];"}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":96,"end_line":99,"category":"maintainability","severity":"low","content":"The date-range-to-query-params conversion is duplicated in the schedule and attendance-summary queryFns. Extract a small helper (e.g. `buildDateRangeParams(start, end)`) to avoid duplication and keep both queries in sync.","suggestion_code":null,"existing_code":" const params: Record<string, string> = {};\n if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');\n if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');\n return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];"}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":125,"end_line":126,"category":"bug","severity":"low","content":"`editForm.validateFields()` rejects on validation errors, which are caught by the same catch block that reports '更新失败' via `message.error`. This surfaces a validation failure as a server/update failure to the user. Separate the form validation from the API call (e.g. return early on validation failure or use distinct catch blocks).","suggestion_code":null,"existing_code":" const values = await editForm.validateFields();\n await api.put(`/classes/${id}`, {"}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":230,"end_line":232,"category":"style","severity":"low","content":"Static inline style `style={{ padding: 24 }}` should be moved to a CSS class / CSS module per the project's inline-style guideline (inline styles should only be used for dynamic values).","suggestion_code":null,"existing_code":" <div style={{ padding: 24 }}>\n <Skeleton active paragraph={{ rows: 8 }} />\n </div>"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":257,"end_line":259,"category":"security","severity":"medium","content":"`document.write()` is used to render the print window content, which the review rules prohibit (page reflow + security risk). Prefer navigating the opened window to a Blob URL or assigning `printWindow.document.body.innerHTML` instead of calling `document.write()`.","suggestion_code":" try {\n const bill = await api.get<BillPrintData>(`/bills/${billId}`);\n const blob = new Blob([buildBillPrintHtml(bill)], { type: 'text/html;charset=utf-8' });\n const url = URL.createObjectURL(blob);\n printWindow.location.href = url;\n window.setTimeout(() => URL.revokeObjectURL(url), 60_000);\n } catch (error: any) {","existing_code":" printWindow.document.write(\n '<p style=\"font-family:sans-serif;padding:24px\">正在加载账单...</p>',\n );"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":279,"end_line":285,"category":"bug","severity":"medium","content":"These renderers call `v.toFixed(2)` without a null/undefined guard, while the adjacent `paidAmount`/`outstandingAmount`/`walletBalance` columns use `(value ?? 0)`. If the API omits these fields, the table cell throws `Cannot read properties of undefined (reading 'toFixed')` and breaks rendering. The same unguarded pattern exists in the detail modal for `roomTotalAmount` and `studentAmount`. Use `Number(v || 0).toFixed(2)` consistently.","suggestion_code":" render: (v: number) => `¥${Number(v || 0).toFixed(2)}`,","existing_code":" {\n title: '分摊费用',\n dataIndex: 'sharedAmount',\n width: 120,\n align: 'right' as const,\n render: (v: number) => `¥${v.toFixed(2)}`,\n },"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":61,"end_line":61,"category":"maintainability","severity":"medium","content":"Heavy use of `any` (the `detailModal` state, `res` in `handleGenerate`/`showDetail`, `(b: any)` in `filteredBills`, and `(_: any, r: any)` in column renders) defeats type safety. Since `billsSchema` already exists, define a `Bill` interface (or infer from the schema) and type the state, query result, and render callbacks accordingly, so null/optional fields are enforced by the compiler instead of relying on optional chaining at runtime.","suggestion_code":" const [detailModal, setDetailModal] = useState<Bill | null>(null);","existing_code":" const [detailModal, setDetailModal] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":47,"end_line":54,"category":"bug","severity":"low","content":"The expense-type filter offers `rent` (租金), but `typeMap` has no `rent` entry. Bills whose detail items use `expenseType: 'rent'` will render the raw English value `rent` in the detail table instead of `租金`. Add `rent: '租金',` to keep the map consistent with the filter options.","suggestion_code":"const typeMap: Record<string, string> = {\n water: '水费',\n electricity: '电费',\n cleaning: '保洁费',\n rent: '租金',\n damage: '损坏赔偿',\n penalty: '罚款',\n other: '其他',\n};","existing_code":"const typeMap: Record<string, string> = {\n water: '水费',\n electricity: '电费',\n cleaning: '保洁费',\n damage: '损坏赔偿',\n penalty: '罚款',\n other: '其他',\n};"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":152,"end_line":156,"category":"bug","severity":"low","content":"Potential stale-response race: if the user opens the detail modal for bill A (still loading) and then opens bill B, the earlier A response can arrive last and overwrite the modal with the wrong bill. Guard with a request sequence (e.g., a `useRef` counter or an AbortController) so only the latest request's result is applied.","suggestion_code":" const showDetail = useCallback(async (id: number) => {\n const seq = ++detailSeq.current;\n setDetailLoading(true);\n try {\n const res = await api.get(`/bills/${id}`);\n if (seq === detailSeq.current) setDetailModal(res);","existing_code":" const showDetail = useCallback(async (id: number) => {\n setDetailLoading(true);\n try {\n const res = await api.get(`/bills/${id}`);\n setDetailModal(res);"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":304,"end_line":306,"category":"style","severity":"low","content":"Static inline styles (e.g. fixed color values in the table/detail renders) are used in several places; the review rules ask to avoid inline `style` except for dynamic values. Move static colors/font styles into CSS classes (or the component stylesheet) for consistency and maintainability.","suggestion_code":null,"existing_code":" render: (value: number) => (\n <span style={{ color: '#389e0d' }}>¥{(value ?? 0).toFixed(2)}</span>\n ),"}
{"path":"apps/admin/src/pages/Attendance/teacher.tsx","start_line":130,"end_line":131,"category":"bug","severity":"low","content":"During the initial load (isPending) and while the spinner is active, `schedules` is still empty so the '今天还没有课程' empty card is rendered behind the Spin mask. A user can briefly see a misleading no-courses message before data arrives. Guard the empty branch with `!loading` so the empty state only appears after the fetch settles and truly returns no schedules.","suggestion_code":" ) : !loading && schedules.length === 0 ? (\n <Card className=\"attendance-empty-card\">","existing_code":" ) : schedules.length === 0 ? (\n <Card className=\"attendance-empty-card\">"}
{"path":"apps/admin/src/pages/Attendance/teacher.tsx","start_line":66,"end_line":68,"category":"bug","severity":"medium","content":"`now` is captured once per render and there is no timer or refetch interval, so the phase logic (`startedCount`, `nextSchedule`, and each `LessonCard`'s status) goes stale while the page stays open. A course whose start time passes while the teacher is viewing the page will never transition from '待上课/等待上课' to '进行中/查看当前考勤' until the user manually clicks the refresh button. Consider adding `refetchInterval` to the query (e.g. every 60s) or a minute-level ticking state so phase transitions and KPIs self-update.","suggestion_code":null,"existing_code":" const now = new Date();\n const schedules = workspace?.todaySchedules ?? [];\n const startedCount = schedules.filter((item) =>"}
{"path":"apps/admin/src/pages/Attendance/teacher.tsx","start_line":71,"end_line":73,"category":"bug","severity":"low","content":"`nextSchedule` (and the timeline order) relies on the backend returning `todaySchedules` already sorted by time. If the response is not chronologically ordered, '下一节' may point to a later class while an earlier upcoming/ongoing one exists, and the timeline sequence would be out of order. Sort by `startTime` before picking the next schedule to make the KPI robust regardless of API ordering.","suggestion_code":" const nextSchedule = [...schedules]\n .sort((a, b) => a.startTime.localeCompare(b.startTime))\n .find((item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended');","existing_code":" const nextSchedule = schedules.find(\n (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',\n );"}
{"path":"apps/admin/src/pages/Classes/ClassDetailTabs.tsx","start_line":314,"end_line":314,"category":"maintainability","severity":"medium","content":"The roster export re-implements download logic already provided by ../../utils/download.ts (downloadBlob) and bypasses the centralized axios api wrapper used everywhere else in this codebase. It hardcodes the /api prefix and the endpoint path, manually reads the token without the token guard that the axios interceptor applies (a missing token would send an invalid Authorization header), and misses the 401 auto-logout handling. Consider using downloadBlob('/classes/<id>/roster/export', filename) and keeping the try/catch for the user-facing message.","suggestion_code":null,"existing_code":"if (!res.ok) throw new Error('导出失败');"}
{"path":"apps/admin/src/pages/Classes/ClassDetailTabs.tsx","start_line":240,"end_line":241,"category":"bug","severity":"low","content":"id is declared optional (id?: string) but is interpolated into the export URL and the fallback filename. When undefined (e.g., missing route param), the request goes to /api/classes/undefined/roster/export and the file is named 班级花名册-undefined.xlsx without a clear error. Make id a required prop or guard the export with an early return/error message when id is falsy.","suggestion_code":null,"existing_code":"export const ClassStudentsTab: React.FC<{\n id?: string;"}
{"path":"apps/admin/src/pages/Classes/ClassDetailTabs.tsx","start_line":517,"end_line":521,"category":"maintainability","severity":"low","content":"The DatePicker.RangePicker block and the table pagination config (defaultPageSize: 20, showSizeChanger, pageSizeOptions) are duplicated between ClassScheduleTab and ClassAttendanceTab. Consider extracting a shared date-range picker component or a common pagination config constant to avoid drift.","suggestion_code":null,"existing_code":"<DatePicker.RangePicker\n value={scheduleDateRange}\n onChange={(dates) => onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}\n placeholder={['开始日期', '结束日期']}\n />"}
{"path":"apps/admin/src/pages/Classes/ClassDetailTabs.tsx","start_line":299,"end_line":299,"category":"style","severity":"low","content":"Static layout spacing is applied via inline style attributes (marginBottom/marginRight). Per the project's inline-style rule, prefer class names/CSS for static spacing and reserve inline styles for dynamic values.","suggestion_code":null,"existing_code":"style={{ marginBottom: 16, marginRight: 8 }}"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":119,"end_line":121,"category":"bug","severity":"medium","content":"`openEdit` does not call `form.resetFields()` before `setFieldsValue`, unlike `openCreate`. Since the Modal keeps the Form mounted across open/close (no `destroyOnHidden`), validation errors/touched state from a previous failed create/edit attempt will still be displayed when reopening the modal in edit mode. Call `form.resetFields()` (or `form.clearValidate()`) before setting the values.","suggestion_code":" const openEdit = (record: AttendanceDeviceRow) => {\n setEditing(record);\n form.resetFields();\n form.setFieldsValue({","existing_code":" const openEdit = (record: AttendanceDeviceRow) => {\n setEditing(record);\n form.setFieldsValue({"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":133,"end_line":135,"category":"bug","severity":"low","content":"`form.validateFields()` is awaited outside the try/catch. When validation fails, the rejected promise propagates out of `handleSave` (the `onOk` handler) as an unhandled rejection instead of being handled here. Wrap validation in its own try/catch and return early on failure (antd Form already shows the inline field errors).","suggestion_code":" const handleSave = async () => {\n let values;\n try {\n values = await form.validateFields();\n } catch {\n return; // 校验失败,错误已由 Form 内联展示\n }\n setSaving(true);","existing_code":" const handleSave = async () => {\n const values = await form.validateFields();\n setSaving(true);"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":383,"end_line":388,"category":"maintainability","severity":"low","content":"Duplicate business logic: the status option array `[{value:'active'...},{value:'disabled'...}]` is defined twice (modal Select and table column), and classroom-option formatting is computed in two places with inconsistent label formats (`名称(楼栋)` in `classroomOptions` vs `楼栋 · 名称` in the 绑定教室 column). Extract shared constants/helpers (e.g., `STATUS_OPTIONS` and a `formatClassroomOption` function) so the two views can't drift.","suggestion_code":null,"existing_code":" <Select\n options={[\n { value: 'active', label: '启用' },\n { value: 'disabled', label: '停用' },\n ]}\n />"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":194,"end_line":194,"category":"style","severity":"low","content":"Static inline `style` attributes are used in several places (flex container div, `fontFamily: 'monospace'` span, two `color: '#999'` placeholders). Per the review rules these should be moved to a stylesheet/className unless they are dynamic styles.","suggestion_code":null,"existing_code":" <span style={{ fontFamily: 'monospace' }}>{value}</span>"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":73,"end_line":73,"category":"performance","severity":"low","content":"`loading = isLoading || isFetching` makes the entire table show a loading overlay on every background refetch (e.g., after each inline cell save or delete), causing visible flicker while the user is interacting with rows. Consider using only `isLoading` for the spinner (background refetches keep showing the existing rows) or a less intrusive loading indicator.","suggestion_code":null,"existing_code":" const loading = isLoading || isFetching;"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":223,"end_line":225,"category":"bug","severity":"medium","content":"Response status is not checked before saving the blob. If the server returns an error (e.g. 401/500, or a JSON error body), `res.blob()` still resolves and the error body is saved as a corrupt `教室导入模板.xlsx`, and no failure message is shown. Check `res.ok` (or `res.status`) before converting to a blob.","suggestion_code":" fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })\n .then((res) => {\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n return res.blob();\n })\n .then((blob) => saveAs(blob, '教室导入模板.xlsx'))","existing_code":" fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })\n .then((res) => res.blob())\n .then((blob) => saveAs(blob, '教室导入模板.xlsx'))"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":479,"end_line":483,"category":"bug","severity":"medium","content":"Two issues here: (1) Same as the template download — `r.ok` is never checked, so error responses (401/500) get saved as a bogus `教室使用报表.xlsx` instead of showing an error; (2) In dev mode this uses `/api` (relative to the Vite origin) while `handleDownloadTemplate` uses `http://localhost:${VITE_API_PORT || 3002}/api`. Unless a dev proxy is configured for `/api`, the export silently fails in dev. The base URL logic is duplicated between the two download flows and should be extracted into one helper that also validates the response.","suggestion_code":" fetch(`${baseURL}/classrooms/export`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n .then((r) => {\n if (!r.ok) throw new Error(`HTTP ${r.status}`);\n return r.blob();\n })\n .then((b) => saveAs(b, '教室使用报表.xlsx'))","existing_code":" fetch(`${baseURL}/classrooms/export`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n .then((r) => r.blob())\n .then((b) => saveAs(b, '教室使用报表.xlsx'))"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":66,"end_line":66,"category":"maintainability","severity":"low","content":"`any` is used extensively (`editing`, `useQuery<any[]>`, `saveCellMutation` params, column render types). This defeats type checking — e.g. `editing.id`, `record.effectiveStatus`, `r.status === 'archived'` are all untyped. Define a `Classroom` interface (status/effectiveStatus/currentUsage are already partially modeled in `CurrentUsage`/`statusMap`) and replace `any` with it, or add a comment justifying each `any`.","suggestion_code":" const [editing, setEditing] = useState<Classroom | null>(null);","existing_code":" const [editing, setEditing] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":497,"end_line":498,"category":"bug","severity":"low","content":"`res.message` may be undefined if the backend returns a successful response without a message, which would show a toast of \"undefined\". Guard with a fallback message.","suggestion_code":" const res: any = await importMutation.mutateAsync(formData);\n message.success(res?.message ?? '导入成功');","existing_code":" const res: any = await importMutation.mutateAsync(formData);\n message.success(res.message);"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":418,"end_line":418,"category":"performance","severity":"low","content":"`useDirtyGuard` returns a fresh object literal `{ confirmClose, snapshot, isDirty }` on every render (the callbacks inside are stable, but the object identity is not). Because `formGuard` is in the `useMemo` dependency array, the `columns` memo is invalidated on every render, defeating the memo and re-creating all `EditableCell` render closures on each render. Destructure the stable callbacks (e.g. `const { confirmClose, snapshot } = useDirtyGuard(form)`) and depend on those instead.","suggestion_code":" [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form, confirmClose, snapshot],","existing_code":" [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form, formGuard],"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":73,"end_line":77,"category":"bug","severity":"medium","content":"Occupancy rate math is inconsistent: `total` counts every classroom (`classrooms.length * days`) but `rented` only sums classrooms present in `summary`. The per-row fallback `data.summary[c.id] || { rentedDays: 0, ... }` shows `summary` may legitimately omit classrooms without bookings, in which case `rented`/`rate` here are understated while the per-row numbers are not. Iterate over `data.classrooms` and accumulate `data.summary[c.id]?.rentedDays ?? 0` so numerator and denominator are consistent.","suggestion_code":null,"existing_code":" let rented = 0;\n const total = data.classrooms.length * data.days;\n for (const cid of Object.keys(data.summary)) {\n rented += data.summary[+cid].rentedDays;\n }"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":168,"end_line":168,"category":"style","severity":"low","content":"Nested ternary is prohibited by the review rules. Extract a helper (e.g. `function rateColor(rate: number) { if (rate > 70) return '#cf1322'; if (rate > 40) return '#fa8c16'; return '#3f8600'; }`) and call it here.","suggestion_code":null,"existing_code":" color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":284,"end_line":289,"category":"style","severity":"low","content":"Nested ternary is prohibited by the review rules. Use the same extracted color helper as the overall statistic (or a small `if/else` function) instead of nesting ternaries inline.","suggestion_code":null,"existing_code":" color:\n sum.occupancyRate > 0.7\n ? '#cf1322'\n : sum.occupancyRate > 0.4\n ? '#fa8c16'\n : '#3f8600',"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":322,"end_line":328,"category":"style","severity":"low","content":"Nested ternary is prohibited by the review rules. Compute the icon beforehand, e.g. `let icon = null; if (isInternal) icon = <ReadOutlined style={{ fontSize: 12 }} />; else if (cell.hasContract) icon = <FileTextOutlined style={{ fontSize: 12 }} />;` and render `{icon}`.","suggestion_code":null,"existing_code":" {isInternal ? (\n <ReadOutlined style={{ fontSize: 12 }} />\n ) : cell.hasContract ? (\n <FileTextOutlined style={{ fontSize: 12 }} />\n ) : (\n ''\n )}"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":33,"end_line":33,"category":"maintainability","severity":"medium","content":"Excessive `any` usage bypasses type safety: `classrooms: any[]`, `organizations: any[]`, `matrix: Record<number, Record<number, any>>`, plus `useState<any>(null)`, `const res: any`, and `new Map<string, any[]>()` elsewhere in this file. Define proper interfaces for the schedule cell/classroom/organization data (or derive types from the zod schema via z.infer) so missing fields / wrong shapes are caught by the compiler.","suggestion_code":null,"existing_code":" matrix: Record<number, Record<number, any>>;"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":114,"end_line":114,"category":"performance","severity":"medium","content":"`loading = isLoading || isFetching` drives the Table's `loading` prop, so the full-table spinner appears on every background refetch — including right after each inline cell save (saveCellMutation invalidates ['classes'] → refetch) and on page visibility refetch via useVisibleRefetch. This overlays the table during the inline-edit/undo flow and can interfere with user interaction. Consider using only `isLoading` for the initial spinner, and optionally a lighter indicator (e.g., a small loading icon) for background refetches.","suggestion_code":" const loading = isLoading;","existing_code":" const loading = isLoading || isFetching;"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":213,"end_line":215,"category":"maintainability","severity":"low","content":"This catch block catches errors from `form.validateFields()` as well as from `saveMutation.mutateAsync()`, but the comment claims all errors are handled by useApiMutation — that is only true for the mutation. A validation rejection here is swallowed silently (users only see inline field errors). This becomes a real problem if any cross-field validator is added (e.g., endDate >= startDate), where the failure would be silently dropped. Recommend separating the two failure paths, e.g. `catch (e) { if (!(e as any)?.errorFields) throw e; }` or explicitly returning early when validation fails.","suggestion_code":null,"existing_code":" } catch {\n // 错误提示由 useApiMutation 统一处理\n } finally {"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":489,"end_line":494,"category":"bug","severity":"medium","content":"No validation ensures `endDate` is on/after `startDate`; a user can pick an inverted date range and submit it. The API may accept it without server-side checks, storing invalid data. Add a cross-field rule (e.g., a validator on endDate comparing with startDate) or disable endDate dates before startDate in the DatePicker.","suggestion_code":null,"existing_code":" <Form.Item name=\"startDate\" label=\"开班日期\">\n <DatePicker />\n </Form.Item>\n <Form.Item name=\"endDate\" label=\"结课日期\">\n <DatePicker />\n </Form.Item>"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":496,"end_line":496,"category":"bug","severity":"low","content":"The inline '学员' cell editor allows `min={0}` (and displays `maxStudents || '-'`), while the modal form enforces `InputNumber min={1}`. A class whose maxStudents was saved as 0 via the inline editor cannot be opened in the edit modal without triggering a validation error on submit. Align the two constraints to avoid inconsistent data acceptance.","suggestion_code":" <InputNumber min={0} />","existing_code":" <InputNumber min={1} />"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":390,"end_line":394,"category":"style","severity":"low","content":"Multiple static inline styles are used for the toolbar (marginBottom, fixed widths, margins). Per the project rule of avoiding inline styles except for dynamic values, these should be moved into CSS classes — the toolbar already carries a `responsive-toolbar` class that could host the spacing, and the selects could use a shared class instead of duplicated `width: 120`.","suggestion_code":null,"existing_code":" <Space\n style={{ marginBottom: 16 }}\n wrap\n className=\"responsive-toolbar responsive-toolbar--single\"\n >"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":58,"end_line":61,"category":"bug","severity":"medium","content":"The installment status editor below offers an 'overdue' (逾期) option, but `installmentStatusMap` only defines 'pending' and 'paid'. When a record is set to 'overdue', the Tag in the detail table renders the raw English string 'overdue' with no color. Add an overdue entry to keep the display consistent.","suggestion_code":"export const installmentStatusMap: Record<string, { text: string; color: string }> = {\n pending: { text: '待缴', color: 'orange' },\n paid: { text: '已缴', color: 'green' },\n overdue: { text: '已逾期', color: 'red' },\n};","existing_code":"export const installmentStatusMap: Record<string, { text: string; color: string }> = {\n pending: { text: '待缴', color: 'orange' },\n paid: { text: '已缴', color: 'green' },\n};"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":412,"end_line":412,"category":"style","severity":"low","content":"Uses non-strict `!=`; the project rules require strict equality. Since `installmentModal` is typed `number | null`, `!== null` is safe and equivalent.","suggestion_code":"open={installmentModal !== null}","existing_code":"open={installmentModal != null}"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":144,"end_line":144,"category":"bug","severity":"medium","content":"The OK button is disabled when no eligible student is selected (`okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }}`), but this keyboard shortcut still calls `onBatchCreate` on Enter (e.g., while typing in the amount/notes fields), bypassing that guard. Gate the shortcut on the selection as well.","suggestion_code":"useSubmitShortcut(batchModal && !saving && effectiveSelectedEligibleIds.length > 0, onBatchCreate);","existing_code":"useSubmitShortcut(batchModal && !saving, onBatchCreate);"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":334,"end_line":337,"category":"maintainability","severity":"low","content":"Uses `item: any` in the installment table render functions (paidDate and status columns). Per project rules `any` should be avoided; type the row with a proper installment type, e.g. `NonNullable<DepositRecord['installments']>[number]` (also apply to the status column's `item: any` below).","suggestion_code":null,"existing_code":" render: (value: string, item: any) => (\n <EditableCell\n value={value}\n editor=\"date\""}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":203,"end_line":203,"category":"maintainability","severity":"low","content":"Casting `eligibleColumns` to `never` masks all type checking and is fragile. Type the `eligibleColumns` prop properly (e.g. `ColumnsType<EligibleStudent>`) instead of forcing a `never` cast at the usage site.","suggestion_code":null,"existing_code":"columns={eligibleColumns as never}"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":258,"end_line":258,"category":"style","severity":"low","content":"Many static styles are inlined across this file (widths, margins, paddings, background, flex layouts). Per the review rules, static styles should be extracted to CSS/classNames; inline `style` should be reserved for dynamic values.","suggestion_code":null,"existing_code":"<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>"}
{"path":"apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx","start_line":44,"end_line":48,"category":"maintainability","severity":"medium","content":"The three Card blocks (缺勤 / 待处理账单 / 待退押金) are structurally identical: same header row (icon + arrow), same count/label/status layout, same onClick→go(path) navigation, and the same static inline styles repeated three times. Consider extracting a data-driven shared card, e.g. a config array `[{ key, path, icon, count, label, statusText, tone }]` rendered by a single inner render function (per the 'Inner Components / renderItem' rule). This would remove ~120 duplicated lines and also let the repeated static inline styles (flex container, marginTop, color values) be consolidated into the constants file where the project already centralizes card styles (Dashboard.types.ts).","suggestion_code":null,"existing_code":" <Card\n style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}\n styles={{ body: { padding: 16 } }}\n onClick={() => go('/attendance')}\n >"}
{"path":"apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx","start_line":47,"end_line":47,"category":"other","severity":"low","content":"Clicking an antd `Card` uses a plain div-level onClick, which is not keyboard-accessible: keyboard users cannot activate it (no Tab focus / Enter / Space handling) and screen readers get no role or hint that it is interactive navigation. Consider using `role=\"button\"`/`tabIndex`/`onKeyDown` (Enter/Space) or wrapping the card content in a `<Link>` for proper semantics.","suggestion_code":null,"existing_code":"onClick={() => go('/attendance')}"}
{"path":"apps/admin/src/pages/Deposits/DepositTable.tsx","start_line":12,"end_line":12,"category":"maintainability","severity":"medium","content":"The `data` prop is typed as `any[]`, and render callbacks also use `any` (`r: any`, `record: any`), bypassing the type safety the component already has (`DepositRecord` is imported from './DepositModals'). If `any` is truly necessary, add a comment explaining why; otherwise type as `DepositRecord[]` and type the render parameters accordingly.","suggestion_code":null,"existing_code":"data: any[];"}
{"path":"apps/admin/src/pages/Deposits/DepositTable.tsx","start_line":142,"end_line":142,"category":"bug","severity":"medium","content":"`rowKey=\"id\"` assumes every row has a numeric `id`, but the render logic explicitly guards `typeof record.id === 'number'` (`hasDeposit`), implying `data` may contain rows without an id (e.g. draft/unpaid records). Such rows get `undefined` as key, which triggers React duplicate-key warnings and unstable row identity. Use a `rowKey` function with a fallback, e.g. `rowKey={(r) => r.id ?? `${r.student?.id}-${r.roomNumber}`}`.","suggestion_code":null,"existing_code":"rowKey=\"id\""}
{"path":"apps/admin/src/pages/Deposits/DepositTable.tsx","start_line":97,"end_line":99,"category":"maintainability","severity":"low","content":"Empty `catch {}` silently swallows failures from `onArchive`/`onPurge`. This only works if the caller always passes a `useApiMutation` wrapper that surfaces errors. If a plain async function is passed, the user gets no feedback while the action fails. Consider logging the error or showing a fallback error message to avoid silent failures.","suggestion_code":null,"existing_code":" } catch {\n // 错误提示由 useApiMutation 统一处理\n }"}
{"path":"apps/admin/src/pages/ClassroomRentals/index.tsx","start_line":245,"end_line":245,"category":"bug","severity":"medium","content":"`form.validateFields()` rejects when validation fails, and this happens *before* the try/catch, so the `handleSave` promise rejects. When save is triggered via the keyboard shortcut (`useSubmitShortcut(modalOpen && !saving, () => handleSave())`) this becomes an unhandled promise rejection (and depending on the antd version, the Modal `onOk` path may also log the rejection). Wrap the validation call in try/catch so validation failures are handled gracefully and don't produce unhandled rejections.","suggestion_code":" let values;\n try {\n values = await form.validateFields();\n } catch {\n // 校验错误已由表单内联提示,无需额外处理\n return;\n }","existing_code":" const values = await form.validateFields();"}
{"path":"apps/admin/src/pages/ClassroomRentals/index.tsx","start_line":42,"end_line":42,"category":"maintainability","severity":"medium","content":"`any` is used in several places (`useState<any>(null)`, `useQuery<any[]>`, `record: any`, `meta` typed as `{ classrooms: any[]; organizations: any[] }`, `params: any`), which defeats type safety and lets runtime shape mismatches (e.g. field renames in the API response) go undetected. Define concrete types such as `RentalRecord`, `Classroom`, `Organization` and type the query results, component props and state accordingly.","suggestion_code":" const [editing, setEditing] = useState<RentalRecord | null>(null);","existing_code":" const [editing, setEditing] = useState<any>(null);"}
{"path":"apps/admin/src/pages/ClassroomRentals/index.tsx","start_line":246,"end_line":246,"category":"bug","severity":"low","content":"The client-side guard (`rangeIncludesUnavailableDate` / `disabledDate`) only covers months already fetched by `loadUnavailableDates` — initially only the current+next month (or startDate+1 in `openEdit`) plus months visited while navigating the picker panels. If a multi-month range is chosen by quick-jumping panels (year view) or an edited rental spans several months, conflicting dates in the unloaded middle months are neither disabled nor caught client-side; the user only learns about them from the server-side conflict error. Consider loading unavailable dates for every month of the selected range (e.g. after `dateRange` is chosen) to keep the UI guard accurate.","suggestion_code":null,"existing_code":" if (rangeIncludesUnavailableDate(values.dateRange)) {"}
{"path":"apps/admin/src/pages/ClassroomRentals/index.tsx","start_line":368,"end_line":370,"category":"style","severity":"low","content":"Static layout styles are inline (container div `style`, `width: 180/110`, etc.). Per the project convention of avoiding inline styles except for dynamic ones, these could be moved to CSS modules/classes for maintainability.","suggestion_code":null,"existing_code":" <div\n style={{\n marginBottom: 16,"}
{"path":"apps/admin/src/pages/Exams/ExamFormModal.tsx","start_line":55,"end_line":55,"category":"style","severity":"low","content":"Static inline style is used for the DatePicker width. Per the project's review rules, inline `style` attributes should be avoided except for dynamic styles. Since this width is constant, prefer a CSS class (e.g., a shared `.full-width` / `.date-picker-full-width` rule) instead of an inline style.","suggestion_code":"<DatePicker className=\"date-picker-full-width\" />","existing_code":"<DatePicker style={{ width: '100%' }} />"}
{"path":"apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx","start_line":32,"end_line":32,"category":"bug","severity":"medium","content":"Layout shift / scroll jump: the placeholder minHeight (340/440/490) is smaller than the actual rendered content height (antd Card header ~56px + body padding ~48px + chart 300/400/450px ≈ 404/504/554px). When the section scrolls into view, the placeholder swaps to the real chart and the page grows by ~60px, pushing content below down and causing a visible jump. Align the placeholder minHeight with the real content height (e.g. heatmap: mobile 404 / desktop 504; gantt: mobile 404 / desktop 554). Additionally, the '加载中…' label is misleading: nothing is loading at that point (the data may already be fetched; the chart is simply not rendered yet), so a neutral placeholder text would be more accurate.","suggestion_code":null,"existing_code":" <Card title={title} style={{ minHeight }}>"}
{"path":"apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx","start_line":54,"end_line":54,"category":"performance","severity":"low","content":"The full ECharts option is rebuilt on every render of the card (e.g. on each parent re-render, refetch, or isFetching toggle), even though `data`/`periodEnd` rarely change. Memoize the option with `useMemo` keyed on `data` (and `periodEnd`) to avoid repeated heavy computation. Same applies to `buildGanttOption` in `GanttCard`.","suggestion_code":null,"existing_code":" option={buildClassroomHeatmapOption(data)}"}
{"path":"apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx","start_line":52,"end_line":52,"category":"bug","severity":"low","content":"`data.some(...)` / `data.length > 0` dereference `data` without a guard. The current callers always pass arrays (defaulting to `[]` after validation), but if any future caller passes `undefined` the whole dashboard section crashes. Use `data?.some(...)` and `(data?.length ?? 0) > 0` for defensive null-safety, per the null-check convention.","suggestion_code":null,"existing_code":" {data.some((r) => Number(r.occupancy) > 0) ? ("}
{"path":"apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx","start_line":58,"end_line":60,"category":"maintainability","severity":"low","content":"The loading placeholder ('加载中…') and the two empty-state blocks ('暂无教室占用数据' / '暂无入住数据') duplicate the same inline-styled markup (`textAlign/padding/color`) across both cards. Extract a small shared `EmptyState`/`Placeholder` component (or at least a shared style constant) to remove duplication and the repeated static inline styles.","suggestion_code":null,"existing_code":" <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>\n 暂无教室占用数据\n </div>"}
{"path":"apps/admin/src/pages/Deposits/index.tsx","start_line":208,"end_line":211,"category":"bug","severity":"medium","content":"搜索框占位符承诺「搜索学生姓名/学号」,但默认列表分支只匹配 `d.student?.name`,没有匹配 `studentNo`;而房型筛选分支同时匹配 `studentName` 和 `studentNo`,两处行为不一致。结果是:未选择房型时按学号搜索已有押金记录搜不到。建议补上学号匹配。","suggestion_code":" if (searchText) {\n const s = searchText.toLowerCase();\n const nameMatched = d.student?.name?.toLowerCase().includes(s);\n const noMatched = d.student?.studentNo?.toLowerCase().includes(s);\n if (!nameMatched && !noMatched) return false;\n }","existing_code":" if (searchText) {\n const s = searchText.toLowerCase();\n if (!d.student?.name?.toLowerCase().includes(s)) return false;\n }"}
{"path":"apps/admin/src/pages/Deposits/index.tsx","start_line":270,"end_line":271,"category":"bug","severity":"medium","content":"`effectiveSelectedEligibleIds` 在 `selectionTouched === false` 时取当前 `eligibleStudents` 全部学生。切换房型(`handleBatchRoomTypeChange`)后会清空选中并异步刷新候选列表,但刷新完成前 `eligibleStudents` 仍是上一房型的过期数据;批量弹窗的确认按钮仅按 `effectiveSelectedEligibleIds.length === 0` 禁用,未考虑 `eligibleLoading`。用户切房型后立即点「确认批量收取」,会把上一房型的学生按新房型金额批量收费,属于数据一致性问题。建议提交时等待候选列表加载完成,或把 `eligibleLoading` 加入提交禁用条件。","suggestion_code":" if (eligibleLoading) {\n message.warning('候选学生列表加载中,请稍候');\n return;\n }\n await batchCreateMutation.mutateAsync({\n studentIds: effectiveSelectedEligibleIds,","existing_code":" await batchCreateMutation.mutateAsync({\n studentIds: effectiveSelectedEligibleIds,"}
{"path":"apps/admin/src/pages/Deposits/index.tsx","start_line":219,"end_line":219,"category":"maintainability","severity":"low","content":"`'四人间'` 与 `amount: 500``openCreateDeposit` 中)等业务默认值在页面内硬编码,与 `DepositModals` 导出的 `roomTypeOptions`/`suggestedDepositByRoomType` 重复。房型或建议金额调整时两处容易不一致,建议复用常量(例如 `suggestedDepositByRoomType['四人间']`),集中定义默认房型常量。","suggestion_code":null,"existing_code":" const openBatchModal = (roomType = filterRoomType || '四人间') => {"}
{"path":"apps/admin/src/pages/Deposits/index.tsx","start_line":349,"end_line":352,"category":"other","severity":"low","content":"`saveInstallmentCell` 成功后再用原生 `api.get` 重拉详情,未经过 `depositsSchema` 校验就 `setDetailModal`,且该重拉被外层 catch 吞掉(与 useApiMutation 的错误提示逻辑无关),失败时详情弹窗静默停留在旧数据。建议复用查询缓存(如 `queryClient.invalidateQueries` 后从缓存读取),或至少校验响应并单独处理重拉失败。","suggestion_code":null,"existing_code":" if (detailModal) {\n const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);\n setDetailModal(refreshed);\n }"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":84,"end_line":84,"category":"performance","severity":"medium","content":"Changing the date range updates `queryKey: ['dashboard', period]`, and because no `placeholderData` is configured, the new query starts with no cached data: `isLoading` becomes true and the entire page collapses to the full-page Skeleton before re-rendering every chart. This produces a visible full-page flash on every date-picker change and defeats the careful per-module/partial-failure loading design (the header `refreshLoading` spin never covers query-key switches). Consider `placeholderData: keepPreviousData` so the previous dashboard stays visible while the new period loads.","suggestion_code":" queryKey: ['dashboard', period],\n placeholderData: keepPreviousData,","existing_code":" queryKey: ['dashboard', period],"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":109,"end_line":112,"category":"bug","severity":"low","content":"`partialFailures` only counts rejected HTTP requests; modules whose responses fail schema validation inside `safeValidate` are silently downgraded to fallback values (only logged with `console.error`). As a result the '有 N 项数据加载失败' warning under-reports real failures and users see empty/zero data for those modules with no explanation. Consider counting validation failures into `partialFailures` or surfacing them separately.","suggestion_code":null,"existing_code":" const partialFailures = rejected.length;\n if (partialFailures > 0) {\n console.error('部分看板数据加载失败', rejected);\n }"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":179,"end_line":181,"category":"bug","severity":"low","content":"If the `/expense-types` request fails, this query silently returns `{}` and the expense pie chart falls back to rendering raw type codes with no user-facing error indication (and this module is excluded from the dashboard's partial-failure accounting), so users can't tell why names are missing. Consider folding it into the main `Promise.allSettled`/`partialFailures` flow or showing a fallback label.","suggestion_code":null,"existing_code":" } catch {\n return {};\n }"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":53,"end_line":54,"category":"bug","severity":"low","content":"`Grid.useBreakpoint()` returns an empty object `{}` on the very first render, so `!screens.sm` evaluates to `true` and the page briefly renders with the mobile layout / mobile chart heights before switching to desktop — causing a layout flash on mount. Guard the undefined value, e.g. `const isMobile = !(screens.sm ?? false);`.","suggestion_code":" const screens = Grid.useBreakpoint();\n const isMobile = !(screens.sm ?? false);","existing_code":" const screens = Grid.useBreakpoint();\n const isMobile = !screens.sm;"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":470,"end_line":477,"category":"maintainability","severity":"low","content":"This '暂无…数据' empty-state block (the same `textAlign: 'center', padding: 40, color: '#999'` div) is duplicated 6 times across the chart cards. Extract a small shared component (e.g. `<ChartEmpty text=\"暂无考勤数据\" />`) to eliminate the duplication and keep copy consistent.","suggestion_code":null,"existing_code":" {(stats?.attendanceTrend || []).length > 0 ? (\n <ReactECharts\n option={buildAttendanceLineOption(stats?.attendanceTrend ?? [])}\n style={{ width: '100%', height: isMobile ? 250 : 300 }}\n />\n ) : (\n <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无考勤数据</div>\n )}"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":231,"end_line":240,"category":"style","severity":"low","content":"This header container mixes static inline styles (`marginBottom`, `gap`, `display`, `justifyContent`) with breakpoint-dependent ones. The project already extracts shared static styles into constants (`SECTION_ROW_STYLE`, `MARGIN_BOTTOM_16_STYLE`); move the static parts into a constant and keep only the dynamic (`isMobile`-dependent) values inline.","suggestion_code":null,"existing_code":" <div\n style={{\n marginBottom: 16,\n display: 'flex',\n justifyContent: 'space-between',\n alignItems: isMobile ? 'flex-start' : 'center',\n flexDirection: isMobile ? 'column' : 'row',\n gap: 12,\n }}\n >"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":27,"end_line":31,"category":"bug","severity":"medium","content":"Unstable dependency in useEffect: `useDirtyGuard` returns a new object literal on every render, so `roomExpenseGuard` changes identity on each render. Any re-render of this component while the modal is open (e.g. parent state changes like `saving` toggling, list refresh, another modal) re-runs this effect and calls `snapshot()` again, resetting the \"unmodified\" baseline to the *currently edited* values. This silently defeats the dirty-guard, so users can lose unsaved edits without a confirmation prompt. Fix: depend on the stable function instead of the wrapping object, e.g. `const { confirmClose, snapshot } = useDirtyGuard(form)` and use `[open, snapshot]` (snapshot is useCallback-stable as long as `form` is stable). Same issue in UtilityModal and PersonalExpenseModal below.","suggestion_code":" const { confirmClose: roomExpenseConfirmClose, snapshot: roomExpenseSnapshot } = useDirtyGuard(form);\n // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准\n useEffect(() => {\n if (open) roomExpenseSnapshot();\n }, [open, roomExpenseSnapshot]);","existing_code":" const roomExpenseGuard = useDirtyGuard(form);\n // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准\n useEffect(() => {\n if (open) roomExpenseGuard.snapshot();\n }, [open, roomExpenseGuard]);"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":83,"end_line":87,"category":"bug","severity":"medium","content":"Same unstable-dependency issue as in RoomExpenseModal: `utilityGuard` is a newly created object on each render, so the effect re-runs (and re-snapshots, resetting the pristine baseline) on any re-render while the modal is open, weakening the dirty-check. Use the stable `snapshot` function in the dependency array instead.","suggestion_code":" const { confirmClose: utilityConfirmClose, snapshot: utilitySnapshot } = useDirtyGuard(form);\n // 打开弹窗时记录当前表单值为「未修改」基准\n useEffect(() => {\n if (open) utilitySnapshot();\n }, [open, utilitySnapshot]);","existing_code":" const utilityGuard = useDirtyGuard(form);\n // 打开弹窗时记录当前表单值为「未修改」基准\n useEffect(() => {\n if (open) utilityGuard.snapshot();\n }, [open, utilityGuard]);"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":143,"end_line":147,"category":"bug","severity":"medium","content":"Same unstable-dependency issue as in the other two modals: `personalExpenseGuard` is a fresh object every render, so the effect can re-run and re-snapshot (resetting the pristine baseline to edited values) on any re-render while the modal is open. Use the stable `snapshot` function in the dependency array instead.","suggestion_code":" const { confirmClose: personalExpenseConfirmClose, snapshot: personalExpenseSnapshot } = useDirtyGuard(form);\n // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准\n useEffect(() => {\n if (open) personalExpenseSnapshot();\n }, [open, personalExpenseSnapshot]);","existing_code":" const personalExpenseGuard = useDirtyGuard(form);\n // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准\n useEffect(() => {\n if (open) personalExpenseGuard.snapshot();\n }, [open, personalExpenseGuard]);"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":21,"end_line":22,"category":"maintainability","severity":"medium","content":"Props are typed as `any[]` and the map callbacks use `(r: any)`, `(student: any)`, `(s: any)` without a justifying comment, which the project rules prohibit. This also hides potential null/undefined issues (e.g. `r.id`/`student.id` access) and removes all type safety at the component boundary. Define minimal interfaces, e.g. `{ id: number | string; roomNumber?: string; building?: string; name?: string; studentNo?: string }` for Room/Student, and apply them to all three modals.","suggestion_code":" rooms: Array<{ id: number | string; roomNumber: string; building?: string }>;\n typeOptions: Array<{ value: string; label: string }>;","existing_code":" rooms: any[];\n typeOptions: Array<{ value: string; label: string }>;"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":1,"end_line":1,"category":"maintainability","severity":"low","content":"Possible typo: `aislop-ignore-file` is likely meant to be `eslint-ignore-file`. If this is intended as a lint suppression directive, the misspelling makes it ineffective and the duplicated-block warning will still be reported. Correct the directive name (or rename it consistently if it is a custom tool).","suggestion_code":"// eslint-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化","existing_code":"// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化"}
{"path":"apps/admin/src/pages/Expenses/index.tsx","start_line":355,"end_line":357,"category":"bug","severity":"medium","content":"`utilityForm.validateFields()` is called outside the try/catch block, unlike `handleRoomExpense`/`handlePersonalExpense` which put it inside try. If form validation fails, the promise rejection is unhandled (unhandled promise rejection / console error), and `setSaving(true)` never runs while the modal stays open. Move `validateFields()` inside the try block and swallow validation errors there, consistent with the other two handlers.","suggestion_code":" const handleStudentUtility = async () => {\n setSaving(true);\n try {\n const values = await utilityForm.validateFields();","existing_code":" const handleStudentUtility = async () => {\n const values = await utilityForm.validateFields();\n setSaving(true);"}
{"path":"apps/admin/src/pages/Expenses/index.tsx","start_line":367,"end_line":370,"category":"bug","severity":"medium","content":"No null check on the API response before accessing `result.bill`. If the backend response doesn't include a `bill` field (or `result` is empty), `bill.paidAmount` throws a TypeError, which is silently swallowed by the catch block — the operation actually succeeded but the user gets no success feedback and the modal stays open. Guard with optional chaining / a default, e.g. `const bill = result?.bill;` and only build the amount message when `bill` exists.","suggestion_code":" const bill = result?.bill ?? {};\n message.success(\n `账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`,\n );","existing_code":" const bill = result.bill;\n message.success(\n `账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`,\n );"}
{"path":"apps/admin/src/pages/Expenses/index.tsx","start_line":280,"end_line":281,"category":"performance","severity":"medium","content":"Batch purge fires N parallel per-item permanent-delete requests (one per selected id), each of which invalidates the `['expenses']` query on success — causing N refetches — and the whole operation is non-atomic: if one request fails mid-way, some records are already permanently deleted while the catch block only shows a generic error. The codebase already has `batch-delete`/`batch-restore` endpoints; add a `batch-purge` endpoint (or at least aggregate per-item results) so this is a single atomic request with a single invalidation. Same issue in `handleBatchPurgePersonal`.","suggestion_code":null,"existing_code":" await Promise.all(selectedRoomKeys.map((id) => mutations.purgeRoom.mutateAsync(id)));\n message.success('批量永久删除成功');"}
{"path":"apps/admin/src/pages/Expenses/index.tsx","start_line":31,"end_line":32,"category":"maintainability","severity":"low","content":"Widespread use of `any` (editingRoom/editingPersonal state, query result arrays `rooms/personal/students/roomsList`, `record` params in `saveRoomCell`/`savePersonalCell`, mutation payloads) with no justification comments, per the project rule that `any` should be avoided or explained. Define proper types (e.g. an `ExpenseRecord`/`RoomExpense` interface from the schemas already imported) to catch field typos like `bill.paidAmount` at compile time.","suggestion_code":null,"existing_code":" const [editingRoom, setEditingRoom] = useState<any>(null);\n const [editingPersonal, setEditingPersonal] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":110,"end_line":119,"category":"performance","severity":"high","content":"`EditableExpenseCell` is declared inside the `ExpenseTablePanel` render body, so every parent render creates a brand-new component type. React then treats all editable cells as different component types and unmounts/remounts them on each render (e.g. when `loading`, `selectedKeys`, `data`, or filters change), which resets EditableCell's internal editing state (losing focus / unsaved input) and wastes reconciliation work across the whole table. Hoist this component to module scope and pass `permission`/`disabled` as props, or render cells via a plain function instead of JSX components.","suggestion_code":null,"existing_code":" const EditableExpenseCell = ({\n value,\n editor,\n min,\n max,\n required,\n options,\n onSave,\n children,\n }: {"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":232,"end_line":234,"category":"bug","severity":"medium","content":"`amount` comes from `data: any[]` (API data). If a record has `amount` as `null`/`undefined` or serialized as a string (common for money fields), `v.toFixed(2)` throws a TypeError and crashes the whole table render. Guard the value, e.g. `{`¥${Number(v ?? 0).toFixed(2)}`}` (same pattern in the personal-expense column).","suggestion_code":null,"existing_code":" >\n {`¥${v.toFixed(2)}`}\n </EditableExpenseCell>"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":271,"end_line":272,"category":"bug","severity":"low","content":"`dayjs(null)` does not throw — it returns the current date/time — so a missing `createdAt` would silently display today's timestamp. Make the formatting null-safe, e.g. `v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'`.","suggestion_code":null,"existing_code":" dataIndex: 'createdAt',\n render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":46,"end_line":49,"category":"maintainability","severity":"low","content":"The props and handlers use `any` extensively (`data`, `rooms`, `students`, `record`, `res`, etc.), which defeats type checking and directly enabled the null/format crashes above. Define typed interfaces for the expense record / room / student / import response (or make the component generic) instead of `any[]` / `any` without justification.","suggestion_code":null,"existing_code":" data: any[];\n loading: boolean;\n selectedKeys: number[];\n onSelect: (keys: number[]) => void;"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":197,"end_line":197,"category":"performance","severity":"low","content":"`rooms.map(...)` / `students.map(...)` are rebuilt for every cell on every render (O(rows × options)). Compute the select options once (e.g. `const roomOptions = useMemo(...)` ) and reuse the same array so cell re-renders stay cheap.","suggestion_code":null,"existing_code":" options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":152,"end_line":154,"category":"bug","severity":"low","content":"Single-row permanent delete calls `onPurge(record.id)` immediately without any confirmation, while the batch \"永久删除\" path wraps the same irreversible action in a `Popconfirm` with a warning. Add a `Popconfirm` here as well to prevent accidental permanent data loss.","suggestion_code":null,"existing_code":" <Button size=\"small\" danger type=\"link\" onClick={() => onPurge(record.id)}>\n 删除\n </Button>"}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":415,"end_line":422,"category":"maintainability","severity":"low","content":"The Card `actions` array mixes a keyed `<span>` with an unkeyed `<>` fragment. React will emit a \"Each child in a list should have a unique key prop\" warning because the fragment is an array element without a key, and the `key` props on the inner `Popconfirm`s don't apply to the outer array. Use a keyed `<Fragment key=\"...\">` (or restructure into a render helper) for the archived branch.","suggestion_code":null,"existing_code":"actions={[\n <span key=\"detail\" onClick={() => navigate(`/exams/${exam.id}`)}>\n 查看成绩\n </span>,\n exam.status === 'archived' ? (\n <>\n <Popconfirm\n key=\"restore\""}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":428,"end_line":430,"category":"style","severity":"low","content":"Nested ternary expressions are not allowed per the review rules: the `canPurgeExam ? ... : null` branch is nested inside the outer `exam.status === 'archived' ? ... : ...` ternary. Extract the archived-branch rendering into a local variable or a render method (e.g., `renderArchivedActions(exam)`) to keep the JSX flat and readable.","suggestion_code":null,"existing_code":" {canPurgeExam ? (\n <Popconfirm\n key=\"purge\""}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":71,"end_line":81,"category":"maintainability","severity":"medium","content":"Calling `message.error` inside the classes `queryFn` is problematic: React Query retries failed queries (default 3 times) and `useVisibleRefetch(['exams'])` re-runs this query on every page re-visit, so a failing backend produces repeated toast spam. It also swallows the error by returning `[]`, so the page never surfaces the failure (no retry UI for the class filter). Prefer letting the query reject and handling the error at the query level (e.g., `useQuery` `onError` or a `QueryErrorState` for the filter), or at minimum gate the toast.","suggestion_code":null,"existing_code":" try {\n return (\n validateResponse<ClassOption[]>(\n classOptionsSchema,\n await api.get<ClassOption[]>('/classes'),\n ) ?? []\n );\n } catch (error: unknown) {\n message.error(getErrorMessage(error, '加载班级失败'));\n return [];\n }"}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":206,"end_line":209,"category":"maintainability","severity":"low","content":"`batchPurge` and `batchChangeArchiveStatus` duplicate the same skeleton (empty/busy guard → `setBatchLoading(true)` → mutate → toast → clear selection → `finally` reset). Extract a shared helper such as `runBatchOperation(operation, successMessage)` to avoid the duplicated try/catch/finally and keep behavior consistent.","suggestion_code":null,"existing_code":" const batchPurge = async () => {\n if (selectedExamIds.length === 0 || batchLoading) return;\n setBatchLoading(true);\n try {"}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":172,"end_line":176,"category":"bug","severity":"low","content":"The empty `catch` in `submit` silently swallows *all* errors, including genuine runtime/programming errors (e.g., if `values.examDate` is cleared by the user, `values.examDate.format(...)` throws and the user gets no feedback — the modal just stays open). Only the form-validation rejection should be ignored; re-throw or surface anything else so it isn't hidden.","suggestion_code":null,"existing_code":" } catch {\n // 校验错误静默,接口错误由 useApiMutation 统一提示\n } finally {\n setSaving(false);\n }"}
{"path":"apps/admin/src/pages/Login/index.tsx","start_line":30,"end_line":30,"category":"maintainability","severity":"medium","content":"The `values` parameter is typed as `any`, which bypasses compile-time checking of the form fields. Define a typed interface (e.g. `{ username: string; password: string }`) so field names are validated and typos are caught at build time.","suggestion_code":"async (values: { username: string; password: string }) => {","existing_code":"async (values: any) => {"}
{"path":"apps/admin/src/pages/Login/index.tsx","start_line":34,"end_line":34,"category":"maintainability","severity":"medium","content":"`res` is typed as `any` even though `api.post` is generic (`post<T>`, see `apps/admin/src/api/index.ts`). Type the login response (the `UserInfo` interface already exists in `store/user/userTypes`) so `res.access_token`/`res.user` access is statically checked instead of silently becoming `undefined` at runtime when the backend shape changes.","suggestion_code":"const res = await api.post<{ access_token: string; user: UserInfo }>('/auth/login', values);","existing_code":"const res: any = await api.post('/auth/login', values);"}
{"path":"apps/admin/src/pages/Login/index.tsx","start_line":34,"end_line":34,"category":"maintainability","severity":"low","content":"The business URL path `/auth/login` is hardcoded here and duplicated as a string literal in the interceptor of `apps/admin/src/api/index.ts` (used to detect 401 on the login request). A rename would need to be applied in two places. Extract a shared constant (e.g. `API_ENDPOINTS.login`) and reference it in both files.","suggestion_code":null,"existing_code":"const res: any = await api.post('/auth/login', values);"}
{"path":"apps/admin/src/pages/Login/index.tsx","start_line":53,"end_line":55,"category":"style","severity":"low","content":"These are static styles applied via inline `style` attributes (the wrapper div, Card, Title, Button, etc.), which the review rules disallow except for dynamic styles. Move them into a CSS module or styled component for maintainability.","suggestion_code":null,"existing_code":" style={{\n minHeight: '100vh',\n display: 'flex',"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":255,"end_line":259,"category":"bug","severity":"medium","content":"`setImporting(true)` is missing at the start of `handleCreateClass`, so the create-class Modal's `confirmLoading={importing}` never shows a loading state, the drawer buttons stay enabled during creation (allowing double submission), and the `finally { setImporting(false) }` is effectively a no-op. Add `setImporting(true)` before `validateFields()`.","suggestion_code":" const handleCreateClass = async () => {\n setImporting(true);\n try {\n const values = await classForm.validateFields();\n const users = extractCheckedUsers();\n await api.post('/classes', { ...values, users });","existing_code":" const handleCreateClass = async () => {\n try {\n const values = await classForm.validateFields();\n const users = extractCheckedUsers();\n await api.post('/classes', { ...values, users });"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":275,"end_line":277,"category":"bug","severity":"medium","content":"`response.data` is used without verifying `response.success` or null-guarding. If the backend returns `success: false` with a 200 (a pattern the other API calls in this file check for), `attendanceGroups` becomes `undefined`, and the confirmation Modal's `attendanceGroups.length` (in the Alert title and the OK button `disabled`) will throw a TypeError.","suggestion_code":" const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');\n if (!response.success) {\n message.error('获取钉钉考勤组失败');\n return;\n }\n setAttendanceGroups(response.data ?? []);\n setDeleteGroupsOpen(true);","existing_code":" const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');\n setAttendanceGroups(response.data);\n setDeleteGroupsOpen(true);"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":288,"end_line":291,"category":"bug","severity":"medium","content":"`response.data.failed` / `response.data.deleted` are accessed without checking `response.success` or guarding `response.data`. If the backend returns `success: false` or an empty payload, this throws inside the try block instead of showing a user-friendly error, and the modal is never closed.","suggestion_code":" const response = await api.post<DeleteAttendanceGroupsResponse>(\n '/sync/dingtalk/attendance-groups/delete-all',\n );\n if (!response.success || !response.data) {\n message.error('清空钉钉考勤组失败');\n return;\n }\n setDeleteGroupsOpen(false);","existing_code":" const response = await api.post<DeleteAttendanceGroupsResponse>(\n '/sync/dingtalk/attendance-groups/delete-all',\n );\n setDeleteGroupsOpen(false);"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":145,"end_line":148,"category":"maintainability","severity":"low","content":"The empty `catch` silently swallows class-list loading errors. If `/classes` fails, the drawer shows an empty class list and the user cannot distinguish \"no classes exist\" from \"load failed\". Log the error and surface a user-friendly message.","suggestion_code":" } catch (e: unknown) {\n console.error('获取班级列表失败', e);\n message.error('获取班级列表失败');\n }\n };","existing_code":" } catch {\n /* ignore */\n }\n };"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":217,"end_line":221,"category":"performance","severity":"low","content":"`checkedKeys.includes(...)` inside the nested tree walk is O(totalUsers × checkedKeys) and runs on every import. For large org trees (thousands of users) this is wasteful. Build a `Set` once before the walk and use `has()` instead.","suggestion_code":" const checkedSet = new Set(checkedKeys.map(String));\n for (const node of nodes) {\n for (const u of node.users ?? []) {\n if (checkedSet.has(`user-${u.userid}`)) {\n result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });\n }\n }","existing_code":" for (const u of node.users ?? []) {\n if (checkedKeys.includes(`user-${u.userid}`)) {\n result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });\n }\n }"}
{"path":"apps/admin/src/pages/Exams/detail.tsx","start_line":59,"end_line":63,"category":"bug","severity":"low","content":"`id` from `useParams()` is typed `string | undefined` but is used without a null check in both the detail query (`/exams/${id}`) and `saveScoreMutation` (`/exams/${id}/scores/${rowId}`). If this page is ever rendered without a valid route param (e.g. malformed URL or direct render in tests), the request will be sent to `/exams/undefined`. Consider adding `enabled: !!id` to the query and an early empty/redirect state when `id` is missing.","suggestion_code":" const { data: detail, isLoading, isFetching, isError, refetch } = useQuery<ExamDetail | null>({\n queryKey: ['exams', 'detail', id],\n enabled: !!id,\n queryFn: async () =>\n validateResponse<ExamDetail>(examDetailSchema, await api.get<ExamDetail>(`/exams/${id}`)),\n });","existing_code":" const { data: detail, isLoading, isFetching, isError, refetch } = useQuery<ExamDetail | null>({\n queryKey: ['exams', 'detail', id],\n queryFn: async () =>\n validateResponse<ExamDetail>(examDetailSchema, await api.get<ExamDetail>(`/exams/${id}`)),\n });"}
{"path":"apps/admin/src/pages/Exams/detail.tsx","start_line":111,"end_line":111,"category":"security","severity":"medium","content":"The score editing cell is gated with `permission=\"exam:view\"`, but the route `exams/:id` is already wrapped in `<PermissionRoute permission=\"exam:view\">`, so every user who can reach this page satisfies the check — meaning any viewer can silently edit scores (the gate is a no-op). If score entry is meant to require a distinct write permission (e.g. `exam:score:update`), this should use that permission; otherwise the redundant check is misleading and should be removed/commented.","suggestion_code":null,"existing_code":" permission=\"exam:view\""}
{"path":"apps/admin/src/pages/IntegrationConfig/index.tsx","start_line":126,"end_line":130,"category":"bug","severity":"medium","content":"When the test endpoint returns HTTP 200 with `success: false` (a normal failure response, which the code itself accounts for when setting `verified: res.success`), the failure message is still shown via `message.success`. This displays a success toast for a failed connection and can mislead users. Branch on `res.success` and use `message.error` for failures.","suggestion_code":" if (res.success) {\n message.success(res.message);\n } else {\n message.error(res.message);\n }","existing_code":" queryClient.setQueryData(['integration', 'config'], (prev) => ({\n ...(prev ?? { config: initialCache.config, verified: initialCache.verified }),\n verified: res.success,\n }));\n message.success(res.message);"}
{"path":"apps/admin/src/pages/IntegrationConfig/index.tsx","start_line":71,"end_line":74,"category":"bug","severity":"medium","content":"The queryFn catch swallows ALL errors and falls back to `initialCache` values captured at mount. If a background refetch (e.g., on window focus, or the refetch triggered by `saveMutation`'s invalidate) fails after a config was already loaded, this replaces the previously fetched server config with stale mount-time data — the UI can regress to an old config right after a successful save, with no user feedback. Only fall back to cache on first load (or rethrow so react-query keeps the previous data / retries).","suggestion_code":null,"existing_code":" } catch {\n // not configured\n return { config: initialCache.config, verified: initialCache.verified };\n }"}
{"path":"apps/admin/src/pages/IntegrationConfig/index.tsx","start_line":42,"end_line":45,"category":"maintainability","severity":"low","content":"`dirty` is destructured from the store but never used anywhere in the component (dead code). Also, `useMemo(..., [])` here is only a mount-time snapshot of the zustand store; it is an anti-pattern for capturing initial state and triggers exhaustive-deps lint warnings. A simpler alternative is reading the store once via `useRef`/`useState` initializer, or just calling `useIntegrationConfigStore.getState()` directly at the top of the component (it is already stable at mount).","suggestion_code":null,"existing_code":" const initialCache = useMemo(() => {\n const { loaded, config, verified, formValues, dirty } = useIntegrationConfigStore.getState();\n return { loaded, config, verified, formValues, dirty };\n }, []);"}
{"path":"apps/admin/src/pages/IntegrationConfig/index.tsx","start_line":187,"end_line":187,"category":"bug","severity":"low","content":"The `required` rule passes for whitespace-only input (e.g. `' '`), and `buildDingTalkConfigPayload` only trims without re-checking, so an empty `corpId`/`agentId` string can be submitted to `/integration/config`. Add a `whitespace: true` rule (or validate the trimmed value) for `corpId` and `agentId` so blank/space-only values are rejected before hitting the API.","suggestion_code":null,"existing_code":" rules={[{ required: true, message: '请输入 CorpId' }]}"}