{"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 `
label
`, 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 { 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(res: ApiEnvelope): T`) and use it in all four functions for consistency.","suggestion_code":null,"existing_code":" const res = await api.post>('/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`) 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(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>(...)).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 {\n success: boolean;\n data: T | null;\n message?: string;\n}","existing_code":"export interface AiApiResponse {\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>(`${basePath}/${id}`)).data`.","suggestion_code":null,"existing_code":" deleteConversation: (id: number) => api.delete(`${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(message.uiArtifacts, artifact),\n };","existing_code":" message.uiArtifacts = mergeById(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(form.getFieldsValue());","existing_code":"const pristineRef = useRef(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` with no link to `T`, so this wrapper (whose stated purpose is type safety) doesn't actually guarantee the validated shape matches `T`. `validateResponse` 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` (and update `validateResponse`'s signature accordingly), so mismatched schema/type pairs fail at compile time.","suggestion_code":" schema: z.ZodType;","existing_code":" schema: z.ZodType;"} {"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(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` 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(\n ...\n return useMutation({","existing_code":"return useMutation({"} {"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:00–02:00) is misclassified: it will return 'ended' after startTime and 'upcoming' again before endTime. Additionally, seconds are ignored, so the phase stays 'ongoing' for up to one minute past the actual end time. Consider comparing against actual Date values (including the schedule date) or explicitly handling the endTime < startTime wrap-around.","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] += 1;\n }","existing_code":" if (record.status in summary && record.status !== 'total') {\n summary[record.status as Exclude] += 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":" ${Number.isFinite(Number(item.days)) ? Number(item.days) : 0}\n ${Number.isFinite(Number(item.totalRoomDays)) ? Number(item.totalRoomDays) : 0}\n ${money(item.studentAmount)}","existing_code":" ${Number(item.days || 0)}\n ${Number(item.totalRoomDays || 0)}\n ${Number(item.studentAmount || 0).toFixed(2)}"} {"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":"

恭学教育基地水电费账单

"} {"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. ``) 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}
入住: ${p.data.value[1]}
退宿: ${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}
排课: ${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,\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 = {};\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` to keep a single source of truth.","suggestion_code":"export type AppPersistedState = Pick;","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:00–00:30), `timeRange[1]` will be earlier than `timeRange[0]`, so any end-after-start comparison in the form fails and `buildSchedulePayload` sends a bare `00:30` endTime with no next-day semantics. Consider anchoring the end time to the following day when `endTime < startTime` (or storing times as time-of-day strings and normalizing in the payload builder).","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` 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 = [['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` object and serializing it with `URLSearchParams` (skipping the empty case) so encoding is handled consistently.","suggestion_code":" async ({ formData, params }: { formData: FormData; params: Record }) => {\n const query = new URLSearchParams(params as Record).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;` (or `UserState` itself if it stays the persisted subset).","suggestion_code":"export type UserPersistedState = Pick;","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 | 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`, 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` 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(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` and `return result.data;` (result.data will then already be typed as `T`).","suggestion_code":"export function validateResponse(schema: z.ZodType, data: unknown): T {","existing_code":"export function validateResponse(schema: z.ZodType, 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":" \n } />\n ","existing_code":" \n "} {"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} : ;","existing_code":" return token ? <>{children} : ;"} {"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":" \n \n \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 {"} {"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(\n () => (chart && chart.rows?.length ? buildOption(chart) : {}),\n [chart],\n );\n const [instance, setInstance] = useState(null);\n if (!chart) return null;\n // 空数据集:渲染明确占位,而不是一张空白图\n if (!chart.rows || chart.rows.length === 0) {","existing_code":" const option = useMemo(() => (chart ? buildOption(chart) : {}), [chart]);\n const [instance, setInstance] = useState(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":"
\n {chart.title}\n {CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}\n
"} {"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":" "} {"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 `` 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":" \n ) : field.type === 'number' ? (\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)\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)\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(undefined);\n\n useEffect(() => {\n activeTypeRef.current = activeType;\n }, [activeType]);","existing_code":" const activeTypeRef = useRef(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":" "} {"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":" "} {"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":" "} {"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":" event.stopPropagation()}\n >\n toggleConversationSelection(item.key)}\n aria-label={`选择 ${item.title}`}\n />\n ","existing_code":" "} {"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 ;","existing_code":"return ;"} {"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":" ","existing_code":" "} {"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":"","existing_code":""} {"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":"
"} {"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 `` so the lines follow the rows.","suggestion_code":" 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":"
"} {"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":"