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.

if (!phone || phone.length < 7) return phone || '-';


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.

export const maskIdNumber = (id: string): string => {
  if (!id || id.length < 8) return id || '-';
  return id.slice(0, 3) + '***********' + id.slice(-4);
};


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.


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.

    setItem: (name, value) => {
      try {
        localStorage.setItem(name, value);
        adapter.clearLegacy();
      } catch {
        // 持久化失败不应影响应用运行，同时保留旧 key 以便下次重试
      }
    },

    setItem: (name, value) => {
      try {
        localStorage.setItem(name, value);
      } catch {
        // 持久化失败不应影响应用运行
      }
      try {
        adapter.clearLegacy();
      } catch {
        // 清理失败不影响应用运行
      }
    },


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.

  let token: string | null = null;
  let user: UserInfo | null = null;
  try {
    token = localStorage.getItem(LEGACY_TOKEN_KEY);
    const rawUser = localStorage.getItem(LEGACY_USER_KEY);
    if (rawUser !== null) {
      try {
        const parsed: unknown = JSON.parse(rawUser);
        user = isRecord(parsed) ? (parsed as UserInfo) : null;
      } catch {
        user = null;
      }
    }
  } catch {
    // 读取失败不应影响应用运行
  }
  return { token, user };

  const token = localStorage.getItem(LEGACY_TOKEN_KEY);
  const rawUser = localStorage.getItem(LEGACY_USER_KEY);


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.

routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs.filter(isDockTab) : [],

routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs : [],


Inconsistent validation in `migrateAuthState`: the envelope branch verifies `state.user` with `isRecord(...)` before casting, but the partial-state branch assigns `existing.user` directly without any shape check. Since `existing` is typed via `as Partial<UserPersistedState>`, a garbage value (string/number/array) from storage would be trusted as a `UserInfo` and could crash consumers that read `user.id`/`user.username`. Mirror the envelope branch's `isRecord` check.

    token: existing.token ?? null,
    user: isRecord(existing.user) ? (existing.user as UserInfo) : null,

    token: existing.token ?? null,
    user: existing.user ?? null,


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.

  if (token === null && user === null) return null;
  return JSON.stringify({ state: { token, user }, version: 1 });


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.

export function migrateAuthState(persisted: unknown, _version: number): UserPersistedState {
  if (isRecord(persisted) && isRecord(persisted.state)) {


Type-safety issue: `schema: z.ZodType<unknown>` erases the schema's output type, and the unconstrained generic `T` combined with the `as T` cast means callers can pass a schema/T pair that don't match (e.g. `validateResponse<number>(z.string(), data)`) without any compile error, risking runtime type mismatches. Tie the generic to the schema's output type and drop the unsafe cast: `schema: z.ZodType<T>` and `return result.data;` (result.data will then already be typed as `T`).

export function validateResponse<T>(schema: z.ZodType<T>, data: unknown): T {

export function validateResponse<T>(schema: z.ZodType<unknown>, data: unknown): T {


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.

    const first = result.error.issues[0];
    const path = first?.path?.join('.');


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`).

  render(): React.ReactNode {
    if (this.state.hasError) {


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.

              </Route>
              <Route path="*" element={<Navigate to="/" replace />} />
            </Routes>

              </Route>
            </Routes>


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.

return token ? <>{children}</> : <Navigate to="/login" replace />;

return token ? <>{children}</> : <Navigate to="/login" />;


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.

                <Route
                  path="wallets"
                  element={
                    <PermissionRoute permission="wallet:view">
                      <WalletsPage />
                    </PermissionRoute>
                  }
                />


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.

    status: attachmentUploadStatus[attachment.status],
    // or: status: toUploadStatus(attachment.status),

    status:
      attachment.status === 'ready'
        ? 'done'
        : attachment.status === 'failed'
          ? 'error'
          : 'uploading',


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.

  for (const item of all.slice(index + 1)) {
    if (typeof item.message.replyToMessageId === 'number') {
      return item.message.replyToMessageId;
    }
  }


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.

    const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime() || 0;
    const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime() || 0;

    const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();
    const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime();


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.

status: running ? 'loading' : success ? 'success' : 'error',


`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.

async function openSourceUrl(item: { url?: string }): Promise<void> {


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.

  const response = await fetch(attachment.url, {
    headers: token ? { Authorization: `Bearer ${token}` } : undefined,
  });


`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 ?? []);`

const forms = deriveForms(message).length > 0 ? deriveForms(message) : (message.forms ?? []);


`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.

window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);


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.

  const option = useMemo<EChartsOption>(
    () => (chart && chart.rows?.length ? buildOption(chart) : {}),
    [chart],
  );
  const [instance, setInstance] = useState<EChartsType | null>(null);
  if (!chart) return null;
  // 空数据集：渲染明确占位，而不是一张空白图
  if (!chart.rows || chart.rows.length === 0) {

  const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
  const [instance, setInstance] = useState<EChartsType | null>(null);
  if (!chart) return null;
  // 空数据集：渲染明确占位，而不是一张空白图
  if (!chart.rows || chart.rows.length === 0) {


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.

          <Typography.Text strong>{chart.title}</Typography.Text>
          <Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>


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).

        <div
          style={{
            height: 120,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
          }}
        >


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.

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    let cancelled = false;

    void (async () => {
      try {
        const mermaid = (await import('mermaid')).default;
        mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
        const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);
        if (cancelled) return;
        const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
        if (doc.querySelector('parsererror')) throw new Error('Invalid SVG output');
        container.replaceChildren(doc.documentElement);
        setError(null);
      } catch (e) {
        if (!cancelled) {
          setError(e instanceof Error ? e.message : '图表渲染失败');
        }
      }
    })();

    return () => {
      cancelled = true;
    };
  }, [children]);


`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.

const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);


The DOMParser result is inserted without checking for a `parsererror` element. If `mermaid.render` ever returns malformed/non-SVG markup, `doc.documentElement` will be a `<parsererror>` node and the user will see raw parser error text instead of a graceful fallback. Check `doc.querySelector('parsererror')` and throw/report it instead.

          const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
          container.replaceChildren(doc.documentElement);


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.

        <Form
          key={`${form.id}:${JSON.stringify(form.fields)}`}
          layout="vertical"
          size="small"
          initialValues={initialValues}

        <Form
          layout="vertical"
          size="small"
          initialValues={initialValues}


This effect depends on the entire `form` object and re-pushes the full command batch (`updateDataModel` + `updateComponents`) every time it runs. `pushCommands` only dedupes `createSurface`, so if the parent recreates the `form` object on each render, duplicate commands accumulate unboundedly in the surface command list and are replayed by XCard. Consider memoizing `form` in the parent, depending on stable keys (`form.id` / `form.fields`), or skipping the push when the serialized payload is unchanged.

  // Avoid depending on the whole `form` object identity: if the parent rebuilds it
  // each render, every render pushes duplicate commands. Use stable keys instead.
  }, [disabled, error, form?.id, form?.fields, pushCommands, sid, submitted, submitting]);

}, [disabled, error, form, pushCommands, sid, submitted, submitting]);


This is a nested/chained ternary expression (textarea → number → select → date → default), which is disallowed by the review rules. Extract a `renderField(field)` helper function so each branch is a plain `if/else` (or switch), improving readability.

              {field.type === 'textarea' ? (
                <Input.TextArea rows={3} placeholder={field.placeholder} />
              ) : field.type === 'number' ? (
                <InputNumber
                  className="ai-chat-dynamic-form__number"
                  placeholder={field.placeholder}
                />
              ) : field.type === 'select' ? (


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.

    const values =
      payload.context?.values && typeof payload.context.values === 'object'
        ? (payload.context.values as Record<string, unknown>)
        : null;
    if (!values) return;
    void handleSubmit(values);

    const values =
      payload.context?.values && typeof payload.context.values === 'object'
        ? (payload.context.values as Record<string, unknown>)
        : {};
    void handleSubmit(values);


`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.

const { submitting, submitted, error, run } = useSubmissionState();


`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()`.

    if (this.state.hasError) {
      if (this.props.fallback) return this.props.fallback;
      return (


`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).

console.error('[AppErrorBoundary] 渲染异常:', error, info.componentStack);


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.

  const activeTypeRef = useRef<AiReviewSectionType | undefined>(undefined);

  useEffect(() => {
    activeTypeRef.current = activeType;
  }, [activeType]);

  const activeTypeRef = useRef<AiReviewSectionType | undefined>(activeType);
  activeTypeRef.current = activeType;


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.

    const stepStatus: 'finish' | 'error' | 'process' | 'wait' = (() => {
      if (status === 'submitted') return 'finish';
      if (status === 'failed') return 'error';
      if (status === 'importing' || type === activeType) return 'process';
      return 'wait';
    })();

    const stepStatus: 'finish' | 'error' | 'process' | 'wait' =
      status === 'submitted'
        ? 'finish'
        : status === 'failed'
          ? 'error'
          : status === 'importing' || type === activeType
            ? 'process'
            : 'wait';


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.

                    {status === 'failed'
                      ? '重试导入本步'
                      : status === 'submitted'
                        ? '已导入'
                        : status === 'skipped'
                          ? '已跳过'
                          : expired
                            ? '已失效'
                            : '确认导入本步'}


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.

  const submittingRef = useRef(false);
  const handleSubmit = async (reviewId: string) => {
    if (submittingRef.current) return;
    submittingRef.current = true;
    setSubmitting(true);
    setError(null);
    try {
      await onSubmit(reviewId);
    } catch (reason) {
      setError(errorMessage(reason));
    } finally {
      submittingRef.current = false;
      setSubmitting(false);
    }
  };

  const handleSubmit = async (reviewId: string) => {
    if (submitting) return;
    setSubmitting(true);


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.

  useEffect(() => {
    setLocalReview(review);
    setError(null);


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.

<Flex vertical gap={2} style={{ minWidth: 160 }}>


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.

    <span
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        width: size,
        height: size,
        borderRadius: 8,
        background: BRAND_COLOR,
        color: '#fff',
        flexShrink: 0,
      }}
    >


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.

<ReadOutlined style={{ fontSize: size * 0.55 }} />


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.

  useEffect(() => {
    activeConversationKeyRef.current = activeConversationKey;
  }, [activeConversationKey]);


`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.

  const refreshConversations = useCallback(async () => {
    let items: ConversationData[];
    try {
      items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);
    } catch {
      message.error('刷新会话列表失败');
      return;
    }
    setConversations(items);

  const refreshConversations = useCallback(async () => {
    const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);
    setConversations(items);


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.

    let nextKey = items[0]?.key ?? '';
    if (current && items.some((item) => item.key === current)) nextKey = current;
    else if (current === '') nextKey = '';
    setActiveConversationKey(nextKey);

    setActiveConversationKey(
      current && items.some((item) => item.key === current) ? current : (items[0]?.key ?? ''),
    );


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.

        if (selected.length === conversations.length) {
          try {
            await aiChatApi.deleteAllConversations();
          } catch {
            message.error('删除会话失败，请重试');
            return;
          }
          for (const abort of requestAbortRef.current.values()) abort();
          requestAbortRef.current.clear();
          providersRef.current.clear();
          setConversationStatus({});
          setConversations([]);
          switchConversation('');
        } else {

        if (selected.length === conversations.length) {
          for (const abort of requestAbortRef.current.values()) abort();
          requestAbortRef.current.clear();
          providersRef.current.clear();
          setConversationStatus({});
          await aiChatApi.deleteAllConversations();
          setConversations([]);
          switchConversation('');
        } else {


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`.

              <span
                className="ai-chat-conversation-check"
                onClick={(event) => event.stopPropagation()}
              >
                <Checkbox
                  checked={selectedKeys.includes(item.key)}
                  onChange={() => toggleConversationSelection(item.key)}
                  aria-label={`选择 ${item.title}`}
                />
              </span>

              <Checkbox
                checked={selectedKeys.includes(item.key)}
                className="ai-chat-conversation-check"
                aria-label={`选择 ${item.title}`}
              />


`activeId` is typed `number | null`, so per project rules use strict equality instead of `!=`.

} else if (activeId !== null && !remaining.some((item) => item.id === activeId)) {

} else if (activeId != null && !remaining.some((item) => item.id === activeId)) {


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.

const roles = useUserStore((state) => state.user?.roles) ?? [];

const roles = useUserStore((state) => state.user?.roles ?? []);


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.

return <Spin size="large" className="default-route-spin" />;

return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;


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.

export const LiteCodeHighlighter = memo(function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) {

export function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) {


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.

const language = lang && SUPPORTED_LANGUAGES.has(lang) ? lang : undefined;


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.

customStyle={{ margin: '12px 0', borderRadius: 8, fontSize: 13 }}


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.

style={{ position: 'fixed', right: 24, bottom: 48, zIndex: 1000 }}


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).

useEventListener('scroll', updateVisible, undefined, useMemo(() => ({ passive: true }), []));

useEventListener('scroll', updateVisible, undefined, { passive: true });


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.

                <Attachments
                  customRequest={onCustomUpload}
                  onRemove={onRemoveAttachment}

                <Attachments
                  items={[]}
                  customRequest={onCustomUpload}
                  onRemove={onRemoveAttachment}


The `accept` / `multiple` / `customRequest` / `onRemove` configuration is duplicated verbatim across the two `Attachments` instances (header and footer). Extract a shared constant (e.g. `attachmentAccept`) and/or a shared props object so future changes to accepted file types or upload behavior are made in one place.

                  accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
                  multiple


Static inline style is used for a fixed font size; per the project rules inline `style` should be reserved for dynamic styles. Move `fontSize: 12` into a CSS class (e.g. `ai-chat-sidebar__empty-hint`).

<Typography.Text type="secondary" style={{ fontSize: 12 }}>


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`.

      // 草稿态：先创建 session，再发送第一条消息
      void (async () => {
        try {
          const created = toConversationData(await aiChatApi.createConversation());


`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).

    (params: AiChatInput) => {
      if (!activeId || !provider) return;
      requestAbortRef.current.set(activeId, () => provider.request.abort());


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.

    const file = options.file as File;
    if (attachmentsRef.current.length >= 5) {


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.

            info.message.role === 'user' ? (
              editingMessageId === info.id ? undefined : (


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).

  requestingRef.current = isRequesting;
  abortRef.current = abort;
  attachmentsRef.current = attachments;
  messagesRef.current = messages;


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.

provider.onExternalReview = (messageId, review) => {


`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).


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.

      } else if (typeof messageId === 'number') {
        setMessage(messageId, (info) => {
          const reviews = info.message.reviews ?? [];
          const exists = reviews.some((item) => item.id === updated.id);


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.

  useEffect(() => {
    const chart = containerRef.current ? echarts.getInstanceByDom(containerRef.current) : undefined;
    chart?.setOption(option, true);
  }, [option]);


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.

  const optionRef = useRef(option);
  optionRef.current = option;
  const onReadyRef = useRef(onReady);
  onReadyRef.current = onReady;


`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.

    const chart = echarts.init(containerRef.current);
    chart.setOption(optionRef.current);
    onReadyRef.current?.(chart);


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.

<Card
      size="small"
      className="next-step-hint"
    >

<Card
      size="small"
      className="next-step-hint"
      style={{ marginBottom: 16, borderColor: '#b7d4ff', background: '#f0f7ff' }}
      styles={{ body: { padding: '10px 16px' } }}
    >


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.

const [dismissed, setDismissed] = useState(false);
  if (dismissed) return null;


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.

<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>


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.


The connector SVG is absolutely positioned with `top: 0` inside the non-scrolling flex container, while the left/right row panels scroll internally (`overflowY: 'auto'`). After the user scrolls either panel, the row centers move up by `scrollTop` but the SVG lines stay fixed, so the match lines no longer align with the correct rows — this breaks the core visual of the component. Track the current `scrollTop` (e.g., in local state updated in the `onScroll` handlers for both panels) and apply `transform: translateY(-${scrollTop}px)` to the `<svg>` so the lines follow the rows.

        <svg
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            transform: `translateY(-${scrollTop}px)`,


Nested ternary expression — the review rules prohibit nested ternaries. Extract the row background into a small helper (or a lookup) for readability, e.g. `const getRowBackground = (isMatched, i) => isMatched ? '#f6ffed' : (i % 2 === 0 ? '#fafafa' : '#fff');`

background: isMatched ? '#f6ffed' : i % 2 === 0 ? '#fafafa' : '#fff',


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.

<div style={{ display: 'flex', position: 'relative' }}>


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).

  useEffect(() => {
    if (!open || !initialRunId) return;
    void loadRun(initialRunId);
  }, [open, initialRunId, loadRun]);


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.

      const result = await commitImportStep(run.id, activeStepKey, decisions);
      setReceipt(result);
      const refreshed = await getImportRun(run.id);


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.

          status:
            step.status === 'committed'
              ? ('finish' as const)
              : step.stepKey === activeStepKey
                ? ('process' as const)
                : ('wait' as const),


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.

          <Upload.Dragger
            accept=".xlsx,.csv"
            maxCount={1}
            showUploadList={false}
            disabled={uploading}
            customRequest={handleUpload}


`hasPermission` returns `false` in two distinct situations: while the permission list is still loading (`permissionStatus !== 'ready'`) and when the permission is genuinely absent. Because the check happens directly in render, every button rendered by this component is hidden during the initial permission fetch and then pops in once loading completes, causing layout jumps; worse, if the permission request fails and `status` never becomes `'ready'`, all permission-gated buttons are permanently hidden with no user feedback. Consider exposing the loading state (e.g., `permissionsReady` from `usePermission`) and rendering a disabled placeholder or skeleton while loading, and/or optionally supporting a disabled fallback instead of always unmounting the button via `return null`.

  const { hasPermission } = usePermission();
  if (!hasPermission(permission)) return null;


`res.success` is never checked here, unlike `handlePreview` below which throws on `!res.success`. If the API returns `success: false` (e.g., invalid credentials), the flow silently advances to the rule step with `response.data` possibly undefined — `response.data.fields` then throws a TypeError that is swallowed by the catch without any user-facing message. Add a success check before using `response.data`.

      const response = await api.post<{
        success: boolean;
        data: { name: string; fields: JinshujuFormField[] };
      }>('/sync/jinshuju/fields', values);
      if (!response.success) throw new Error('获取表单字段失败');
      setFormFields(response.data.fields);

      const response = await api.post<{
        success: boolean;
        data: { name: string; fields: JinshujuFormField[] };
      }>('/sync/jinshuju/fields', values);
      setFormFields(response.data.fields);


When the apply API returns `success: false`, the `if (res.success)` block is skipped, nothing is thrown, and the catch never runs — so the modal stays permanently stuck on the 'applying' loading screen with no error message and no way to return to the match step (footer is null). This is inconsistent with `handlePreview` and `handleConnectionNext` error handling. Also, `res.log` may be undefined, so `res.log.message` can throw a TypeError that leaves the user without feedback. Handle the failure branch explicitly.

      if (res.success) {
        message.success(res.log?.message || `处理 ${res.log?.recordsCount ?? 0} 条记录`);
        onApplied();
        reset();
      } else {
        throw new Error(res.log?.message || '同步失败');
      }

      if (res.success) {
        message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);
        onApplied();
        reset();
      }


Nested ternary expression violates the project rule that nested ternaries are not allowed. Extract the step-to-index mapping into a lookup or if/else for readability.

  const stepIndexMap: Record<typeof step, number> = { connection: 0, rule: 1, match: 2, applying: 2 };
  const currentStep = stepIndexMap[step];

const currentStep = step === 'connection' ? 0 : step === 'rule' ? 1 : 2;


The footer rendering also uses nested ternaries (step === 'connection' ? ... : step === 'rule' ? ... : step === 'match' ? ... : null). Refactor into a lookup or if/else to comply with the no-nested-ternary rule and improve readability.

      footer={
        step === 'connection'
          ? [


The queryFn swallows all load failures (`catch { return []; }`) with no user feedback. A failed rules fetch is indistinguishable from "no rules exist", so the UI may misleadingly show '当前表单还没有保存的规则' when the request actually failed. Consider surfacing a lightweight error message (or at least distinguishing the failure state) instead of silently returning an empty list.

        return res.success ? validateResponse<MatchRule[]>(jinshujuRulesSchema, res.data) : [];
      } catch {
        return [];
      }


Static inline styles are used in the compact branch (`padding: '32px 16px', textAlign: 'center'`, `fontSize: 12`, `marginTop: 12`). Per the project's review rules, inline `style` attributes should be avoided except for truly dynamic styles — consider extracting these into CSS classes or theme tokens for consistency and maintainability.

<div style={{ padding: '32px 16px', textAlign: 'center' }}>


Copy-paste bug: this is the error handler for `fetchNotifications` (fetching the notification list), but both the console log and the user-facing message say "全部已读失败" (mark-all-read failed). This misleads users/debugging when the fetch itself fails. Should be "获取通知失败，请重试".

    } catch (error) {
      console.error('获取通知失败', error);
      message.error('获取通知失败，请重试');
    }

    } catch (error) {
      console.error('全部已读失败', error);
      message.error('全部已读失败，请重试');
    }


The auth token is passed in the SSE URL query string (`?token=...`). Query-string parameters are commonly recorded in server/proxy access logs and browser history/devtools, increasing the risk of token leakage. Since REST calls already authenticate via the Authorization header (the api interceptor adds it), consider using a short-lived, single-use stream token, or switch to a mechanism that doesn't expose the token in the URL.

const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`);


`handleMarkAll` swallows the failure silently (`/* ignore */`). Marking all notifications read is a user-triggered destructive action; if the request fails the UI stays optimistic-looking (list already updated locally) with no feedback, and the unread count will be corrected by the next poll/SSE only. Add a user-friendly error message in the catch block (e.g., `message.error('全部已读失败，请重试')`) to be consistent with the rest of the component.

  const handleMarkAll = async () => {
    try {
      await api.put('/notifications/read-all');
      setUnreadCount(0);
      setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
    } catch {
      message.error('全部已读失败，请重试');
    }
  };


`openRef.current = open;` writes to a ref during render, which violates React's rules ("Do not write or read ref.current during rendering"). With concurrent rendering/StrictMode the ref can be left stale if the render is discarded, so the SSE `onmessage` handler may make the wrong decision about refreshing the list. Prefer updating the ref in an effect.

  const openRef = useRef(open);
  useEffect(() => {
    openRef.current = open;
  }, [open]);

  const openRef = useRef(open);
  openRef.current = open;


findRoleAwareLandingPath internally calls buildMenu/collectMenuPaths, which constructs the whole filtered menu tree. It is recomputed on every render of the unauthorized branch. Since this is a derived value from roles/permissions, memoize it with useMemo to avoid repeated menu construction on re-renders.

  const firstPath = useMemo(
    () => findRoleAwareLandingPath(roles, permissions),
    [roles, permissions],
  );

const firstPath = findRoleAwareLandingPath(roles, permissions);


When the user has no permissions at all, findRoleAwareLandingPath returns null, so the 403 Result renders without any button - the user is left stranded on the 'no access' page with no way to navigate away or back to login. Consider always providing a fallback action (e.g., back to home or logout) even when firstPath is null.

          firstPath ? (
            <Button type="primary" onClick={() => navigate(firstPath, { replace: true })}>
              前往可访问页面
            </Button>
          ) : undefined


Static inline styles should be avoided per the project rules (inline style is only allowed for dynamic styles). Move the loading spinner centering to a CSS class instead of hardcoding it inline.

return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;


The scroll reset runs in `useEffect`, which executes after the browser has painted the new route. On navigation the user can briefly see the new page at the old scroll position (flicker), and on initial mount it also runs an unnecessary `window.scrollTo`. Since this effect only touches layout state synchronously, prefer `useLayoutEffect` so the scroll happens before paint (the standard pattern for ScrollToTop components). Alternatively/additionally, setting `history.scrollRestoration = 'manual'` once avoids conflicts with browser scroll restoration on back/forward navigation.

  useLayoutEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);


Hardcoded fallback mappings use arbitrary Jinshuju field keys (`field_1`, `field_2`) that are unlikely to exist in a real form's `fields` list. When creating a new rule, the Select will display the raw key value (no matching option) and an invalid mapping can be saved. Better to default to an empty object (or derive defaults from the first available form fields).

  const [mappings, setMappings] = useState<Record<string, string>>(
    rule?.mappings ?? {},
  );

  const [mappings, setMappings] = useState<Record<string, string>>(
    rule?.mappings ?? { name: 'field_1', phone: 'field_2' },
  );


`saving` local state duplicates the pending state already exposed by `useApiMutation` (a wrapper around `useMutation`, which provides `isPending`). The extra state also forces a try/catch/finally dance that the mutation's built-in error handling already covers. Simplify by using `saveMutation.isPending` as the Button loading prop.

const [saving, setSaving] = useState(false);


Many static inline `style` objects are used throughout this component. Per project conventions (avoid inline styles except for dynamic ones), these should be extracted into CSS classes or a stylesheet for better maintainability.


The `options` prop re-declares its own structural type and narrows it compared to `EditableCellOption` exported by `EditableCell` (`label: React.ReactNode` and `value: string | number | boolean`). This silently restricts valid use cases (e.g., boolean-valued select options or labels rendered as ReactNode) and lets the type drift from the source of truth. Reuse the underlying prop type instead, e.g. `options?: React.ComponentProps<typeof EditableCell>['options']`.

options?: Array<{ value: string | number; label: string }>;


The `async`/`await` wrapper around `onSave` adds no behavior beyond the raw callback (both propagate rejections to `EditableCell`'s try/catch). Simplify to a direct delegation for readability.

onSave={(next) => onSave(record, field, next)}

    onSave={async (next) => {
      await onSave(record, field, next);
    }}


The default display fallback `String(value ?? '-')` renders non-primitive values poorly: arrays (e.g., for `multi-select`/`tags`/`date-range` editors) are joined as "a,b,c" and objects render as "[object Object]" in the UI. Since `value` is typed as `unknown`, consider a safer fallback that formats arrays/objects, or require callers to pass `children` for such editors.


Pressing Enter while using editors whose value commits through their own internal Enter handling (`date`/`date-range`/`select`/`multi-select`/`tags`) will also hit this keydown handler and call `save()` with the `draft` captured in the current render. Those controls only update `draft` via their own async `onChange` (e.g., rc-picker commits a typed date, rc-select selects a highlighted option, tags mode adds a tag), so `draft` here is still the pre-interaction stale value. Depending on event ordering, `event.preventDefault()` may also suppress the control's commit — the user's typed date / selected option / typed tag is either silently discarded or the previous value gets saved over the new one (and two concurrent `onSave` calls can race). Restrict the Enter shortcut to editors whose draft is already in sync with the input (text/textarea/number/money).

    if (
      event.key === 'Enter' &&
      (editor === 'text' || editor === 'textarea' || editor === 'number' || editor === 'money')
    ) {
      event.preventDefault();
      await save();
      return;
    }

    if (event.key === 'Enter' && editor !== 'textarea') {
      event.preventDefault();
      await save();
      return;
    }


`crypto.randomUUID()` is only available in secure contexts (https/localhost) and relatively modern browsers (e.g., Safari < 15.4 throws). This is invoked during render inside `useRef`, so on an intranet http deployment or older engine the whole component crashes with a TypeError. Add a fallback ID generator.

  const idRef = useRef(
    (() => {
      if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
        return crypto.randomUUID();
      }
      return `editable-cell-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
    })(),
  );

const idRef = useRef(crypto.randomUUID());


`normalizeEditableValue(formatValue ? formatValue(value) : value, editor)` is duplicated four times (initial `draft` state, `original` memo, `cancel`, `beginEdit`). If the display-value normalization ever changes for one editor type, the copies can diverge and cause the unchanged-value equality check (and thus unnecessary saves) to behave inconsistently. Extract a single helper and reuse it in all four places.

function normalizeDisplayValue<Value>(
  value: Value,
  editor: EditableCellEditor,
  formatValue?: (value: Value) => unknown,
) {
  return normalizeEditableValue(formatValue ? formatValue(value) : value, editor);
}

  const cancel = useCallback(() => {
    setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));


`value: draft as never` casts away all editor-specific types. Besides losing type safety, it has a runtime edge case: with editor `'text'` and a numeric `value`, `original` stays a number while any user input becomes a string, so `editableValuesEqual(serialized, original)` (fast-deep-equal) always returns false and an unmodified cell triggers a redundant `onSave`. Consider modeling `draft` as a discriminated union per editor instead of `as never`.

  const commonProps = {
    autoFocus: true,
    value: draft as never,


Opening user-uploaded attachment bytes via `URL.createObjectURL` + `window.open(url, '_blank')` is a stored-XSS vector: a blob URL inherits the creator page's origin, so if the attachment is an HTML/SVG file containing scripts, those scripts run in the admin's origin/session. Prefer forcing a download instead — e.g. build an `<a>` with a `download` attribute and click it, set the blob type to `application/octet-stream`, or serve the file through a dedicated endpoint returning `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`. Also note `window.open` may return null when blocked by popup blockers, and revoking the URL after a fixed 60s can break a preview tab that stays open longer.

                const url = URL.createObjectURL(blob);
                window.open(url, '_blank');
                setTimeout(() => URL.revokeObjectURL(url), 60_000);


The shared axios instance (`api/index.ts`) has a hardcoded 10s timeout, which also applies to this attachment download. Larger files may abort mid-download. Pass a larger per-request timeout (e.g. `timeout: 60000`) or stream the download instead.

                const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
                  responseType: 'blob',
                });


Endpoint URL paths are hardcoded and duplicated inline (`/archive/attachments/${attachmentId}`, `/archive/attachments/${id}/permanent`, `/archive/${studentId}/attachments`). Per the project's hardcoding rule, centralize these paths in the api layer (e.g. an `api/archive.ts`) so endpoints have a single source of truth.

async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),


Hardcoded business URL '/dashboard' violates the no-hardcoded-URL-path rule and creates an inconsistency: if the current active tab is already '/dashboard', `onNavigate` is a no-op, so the useEffect never re-runs and the dock stays permanently empty (while from any other route the dashboard tab is re-created). Derive the fallback destination from the menu items / route config (e.g. the first menu key) and guard with `if (location.pathname !== target) onNavigate(target)`, or explicitly re-insert the home tab.

  const closeAll = () => {
    setRouteDockTabs([]);
    onNavigate('/dashboard');
  };


Business route patterns (/students/:id/profile, /classes/:id) are hardcoded in this component purely for label lookup. This duplicates routing knowledge and will silently produce wrong labels if routes are renamed. Recommend centralizing the path → label mapping in a route config or menu metadata instead of maintaining regex fallbacks here.

  if (/^\/students\/\d+\/profile$/.test(pathname)) return '学生档案';
  if (/^\/classes\/\d+$/.test(pathname)) return '班级详情';


`activeKey` is just `location.pathname` (line 77), so the dependency array lists the same value twice. Keeping both is confusing and risks drift if the derivation changes; keep only `location.pathname` (and note that `menuItems` can be a large rebuilt array — a stable reference from the parent is relied upon here).

}, [activeKey, location.pathname, menuItems, setRouteDockTabs]);


Mutating refs (cacheRef/orderRef) directly during render is a render-time side effect and breaks React render purity. With React 18 StrictMode and concurrent rendering, the render function can run multiple times before commit and ref mutations are not rolled back. The `has()` guard makes it idempotent in the common case, but this pattern is fragile (e.g., an aborted render still leaves the entry cached). Move the cache bookkeeping into `useLayoutEffect` (backed by state that triggers re-render) or use the documented "adjust state during render" pattern so the render phase stays pure.

  if (outlet && !cacheRef.current.has(pageKey)) {
    cacheRef.current.set(pageKey, outlet);
    orderRef.current.push(pageKey);


The LRU eviction logic has two problems: (1) re-visiting an already cached page never refreshes its recency, so a page that was just viewed can still be treated as the oldest; (2) when the eviction target happens to be the currently active page (pageKey was inserted long ago and never re-touched), `oldest !== pageKey` skips the deletion but `shift()` has already removed the key from orderRef — cacheRef and orderRef become desynced and that page is never evicted/tracked again (cache grows permanently by one entry per occurrence). Fix by refreshing recency on access, which also guarantees the active page can never be the oldest so the deletion can be unconditional.

  if (cacheRef.current.has(pageKey)) {
    // 重新访问时刷新 LRU 顺序，避免活跃页被误判为最旧
    const idx = orderRef.current.indexOf(pageKey);
    if (idx > -1) {
      orderRef.current.splice(idx, 1);
      orderRef.current.push(pageKey);
    }
  } else if (outlet) {
    cacheRef.current.set(pageKey, outlet);
    orderRef.current.push(pageKey);
    if (orderRef.current.length > MAX_CACHED_PAGES) {
      const oldest = orderRef.current.shift();
      if (oldest) cacheRef.current.delete(oldest);
    }
  }

    if (orderRef.current.length > MAX_CACHED_PAGES) {
      const oldest = orderRef.current.shift();
      if (oldest && oldest !== pageKey) cacheRef.current.delete(oldest);
    }


Hidden cached pages are not truly frozen: they stay mounted as children of RouteKeeper and re-render on every navigation (useLocation triggers a RouteKeeper re-render, and the map re-renders all cached subtrees). Because they read the router-level LocationContext, `useLocation()`/`useSearchParams()` inside a hidden page return the *current* route's location rather than the location it was cached with, so effects that depend on location may fire while the page is hidden and up to MAX_CACHED_PAGES subtrees re-render on each navigation. Consider memoizing each cached page boundary and/or providing a location context captured at cache time so hidden pages don't react to unrelated navigations.

      {Array.from(cacheRef.current.entries()).map(([key, node]) => (
        <div
          key={key}
          className="route-keeper-page"
          style={{ display: key === pageKey ? undefined : 'none' }}
        >


Keying only by pathname means URLs sharing a pathname but differing in query params (e.g. search/filter pages `/list?keyword=a` and `/list?keyword=b`) share a single frozen cache entry: the cached node keeps the props/element captured on the first visit, so loader data or param-derived props never update when the same pathname is visited with different params — only direct router-context reads (useSearchParams) see the new URL. This is a deliberate design (documented in the comment), but pages relying on param/loader-derived content can show stale data; either include the relevant search params in the cache key or ensure such pages read state from the router context.

  // 仅以 pathname 作为缓存键：页面内部通过 URL 参数同步状态时不会
  // 产生第二个实例，切回时也不会因此重挂载。
  const pageKey = location.pathname;


Clearing an editable field via inline cell editing does not actually persist. `EditableCell.saveValue` serializes a cleared value to `undefined` for date/number/select editors (see `serializeEditableValue` in EditableCell/index.tsx), and this `saveCell` builds the payload `{ [field]: value }`. `JSON.stringify` silently drops `undefined` properties, so the request body contains no `examDate`/`classAvg`/`rank` key and the server cannot clear the field — the value reappears after refetch. Send `null` (or explicitly handle `undefined`) so the backend can clear nullable fields.

api.put(`/archive/exam-scores/${id}`, { [field]: value ?? null }),

api.put(`/archive/exam-scores/${id}`, { [field]: value }),


Display inconsistency with `null` values: the `score` cell uses `v ?? '-'` (which renders '-' for `null`), but `classAvg` and `rank` use `v !== undefined ? v : '-'`, which renders a blank cell when the API returns `null` (React renders `null` as nothing). Use `v ?? '-'` here too for a consistent fallback display.


API endpoint paths (`/archive/${studentId}/exam-scores`, `/archive/exam-scores/${id}`, `/archive/exam-scores/${id}/permanent`) are hardcoded business strings, which the review rules prohibit. Note this pattern is repeated across the sibling tabs (EnrollmentsTab/LearningTab/AttachmentsTab) and the parent page; consider centralizing these paths in a shared archive API module/constants so path changes are applied consistently.


Using document.write() to inject the report HTML is explicitly prohibited by the security checklist (page reflow + XSS surface). The HTML returned by `/archive/:id/report-html` embeds student data; if the server-side template does not escape fields (name, notes, etc.), arbitrary script can execute in the new window context. Also, if `window.open` returns null (popup blocked), the user gets no feedback. Prefer opening a Blob URL: `const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const w = window.open(url, '_blank');` (and revoke the URL later / notify when the popup is blocked).

      const blob = new Blob([html], { type: 'text/html' });
      const url = URL.createObjectURL(blob);
      const w = window.open(url, '_blank');
      if (!w) {
        message.error('预览报告被浏览器拦截，请允许弹出窗口');
      }

      const w = window.open('', '_blank');
      if (w) {
        w.document.write(html);
        w.document.close();
      }


This is a nested ternary (`canChooseOrganization ? ... : student.organization?.name ? ... : ...`), which is prohibited by the review rules and is hard to read/maintain. Flatten it by pre-computing the organization tag before the return statement, e.g. `const orgTag = student.organization?.name ? <Tag color="purple">{student.organization.name}</Tag> : '-';`, then use a single conditional.

        {canChooseOrganization ? (
          <EditableCell
            value={student.organizationId}
            editor="select"
            options={organizations.map((item) => ({ value: item.id, label: item.name }))}
            permission="student:edit"
            onSave={(next) => saveStudent('organizationId', next)}
          >
            {orgTag}
          </EditableCell>
        ) : (
          orgTag
        )}

        {canChooseOrganization ? (
          <EditableCell
            value={student.organizationId}
            editor="select"
            options={organizations.map((item) => ({ value: item.id, label: item.name }))}
            permission="student:edit"
            onSave={(next) => saveStudent('organizationId', next)}
          >
            {student.organization?.name ? (
              <Tag color="purple">{student.organization.name}</Tag>
            ) : (
              '-'
            )}
          </EditableCell>
        ) : student.organization?.name ? (
          <Tag color="purple">{student.organization.name}</Tag>
        ) : (
          '-'
        )}


`onRefresh` is declared in the props type and passed by the parent (`onRefresh={fetchData}`), but it is never destructured or referenced anywhere inside `InlineArchiveSummary` — dead prop. Remove it from the type, the destructuring-free usage, and the call site (or wire it up if refreshing was intended after edits).

  onRefresh: () => void;
  onViewSensitive: (fieldLabel: string, value: string) => void;


All four statistics cards (入学测试总分 / 阶段最高分 / 阶段提升分 / 出勤率) are hardcoded to display '-' and are never derived from `aggregateData` (e.g. examScores/attendances). This is an incomplete feature rendered as permanent placeholders. Either compute the real values from the aggregate data or remove the row until implemented.

        {[
          { title: '入学测试总分' },
          { title: '阶段最高分' },
          { title: '阶段提升分' },
          { title: '出勤率' },
        ].map((item) => (
          <Col span={6} key={item.title}>
            <Card size="small">
              <Statistic title={item.title} value="-" />
            </Card>
          </Col>
        ))}


The '报告版本' tab always renders `<Empty description="暂无报告版本" />` regardless of any data — a hardcoded placeholder for an unimplemented feature. If report versions exist on the server, this is misleading; otherwise consider removing the tab until it is implemented.

      {
        key: 'reports',
        label: '报告版本',
        children: <Empty description="暂无报告版本" />,
      },


The `<style>` tag injects a global `.archived-row` CSS rule on every render of this component (including when it is only used to render child tables). If the component mounts multiple times (e.g. inside a drawer and on a page), duplicate style tags are inserted and the global class name can collide with unrelated styles. Prefer defining this rule once in a global stylesheet/CSS module instead of inline per-render injection.

<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>


Nested ternary is prohibited by the review rules. The tag rendering here is a ternary nested inside another ternary, and the `Alert` `type` prop just below has the same pattern (`success ? (modelAvailable ? 'success' : 'warning') : 'error'`). Extract the logic into a helper/const (e.g. compute `status` and `type` before the return) to keep it flat and readable.

          {(() => {
            if (testResult.success && testResult.modelAvailable) {
              return <Tag icon={<CheckCircleOutlined />} color="success">成功</Tag>;
            }
            if (testResult.success) {
              return <Tag icon={<WarningOutlined />} color="warning">模型未找到</Tag>;
            }
            return <Tag icon={<CloseCircleOutlined />} color="error">失败</Tag>;
          })()}

          {testResult.success ? (
            testResult.modelAvailable ? (
              <Tag icon={<CheckCircleOutlined />} color="success">


Nested ternary is prohibited by the review rules. The key-preview rendering nests a ternary inside another ternary. Prefer extracting the tag into a variable (e.g. `const keyTag = config?.hasApiKey ? ... : hasFormKey ? ... : ...;`) before the return, or use an if-chain.

              {config?.hasApiKey ? (
                <Tag color="green">{config.maskedApiKey || '••••'}</Tag>
              ) : hasFormKey ? (


`formValues.apiKey && formValues.apiKey !== '••••'` evaluates to a string (or `''`), not a boolean, so `hasFormKey` has a string type despite being used as a condition and semantically being a boolean flag. Coerce with `!!` for a proper boolean type.

const hasFormKey = !!formValues.apiKey && formValues.apiKey !== '••••';

const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••';


The condition `!canWrite || (isFixedProvider && canWrite)` is redundant: whenever `canWrite` is false the first term already disables the input, and when `canWrite` is true the second term reduces to `isFixedProvider`. It can be simplified to `!canWrite || isFixedProvider`, which makes the intent (baseUrl is locked whenever the provider is fixed) clearer.

disabled={!canWrite || (isFixedProvider && canWrite)}


Many static inline styles are used throughout this file (e.g. `style={{ marginBottom: 12 }}`, `style={{ marginLeft: 8, fontSize: 12, color: '#999' }}`, `style={{ marginBottom: 8 }}`, `style={{ marginTop: 8 }}`, `style={{ marginBottom: 16 }}`). Per the review rules, inline styles should be avoided except for dynamic values; these repeated static styles should be moved into the CSS module (index.module.css).

<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>


The return type is `any[]`, which disables type checking for the menu items passed to antd's `Menu` (a typo in a key/label would silently pass through). Use `MenuProps['items']` from antd instead (and type the item via `NonNullable<MenuProps['items']>[number]`), or at least a properly typed local interface, so the map result stays type-safe.

const transformToMenuItems = useCallback((items: AppMenuItem[]): MenuProps['items'] => {

const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {


The profile endpoint path `/auth/profile` is hardcoded inline in the layout. Per the review rules, business URL paths should not be hardcoded at call sites — centralize it in the api layer (e.g., a `api.auth.getProfile()` wrapper or an endpoint constant) so path changes and API shaping stay in one place.


On any persistent profile-fetch failure (e.g., expired/invalid token returning 401, or a server outage), this catch block retries `verifyPermissions` every 5 seconds forever. The tab keeps hitting `/auth/profile` indefinitely in the background (also re-triggered on every `visibilitychange`/`online`), and the user gets no error feedback. Stop retrying on authentication errors (401/403), cap the number of retries, and surface a user-visible error (or redirect to login) when the profile can no longer be verified.

        .catch(() => {
          verificationInFlight = false;
          if (cancelled || !useUserStore.getState().token) return;
          retryTimer = window.setTimeout(verifyPermissions, 5_000);
        });


antd's `Drawer` `size` prop only accepts the preset values (`'default'` / `'large'`); a numeric `240` is not a valid `size` and won't apply the intended 240px width (the drawer falls back to the default width). If the goal is a 240px-wide drawer, use the `width` prop (`width={240}`) instead of `size`.


This layout relies heavily on static inline `style` props (Header, Content, Sider, logo block, etc.). Per the review rules, inline styles should be reserved for dynamic values; static layout styling should live in CSS classes (e.g., `.app-header`, `.app-content`) so the JSX stays readable and the styles are consistent/reusable.

        <Header
          className="app-header"
          style={{


recordDate / recordType / content are marked `required` in the add-modal validation, but these inline editors do not pass the `required` prop to EditableArchiveCell. As a result a user can clear e.g. `content` inline (the text editor trims it to `''` and the empty string is persisted via PUT), and clearing the date picker produces `{ recordDate: undefined }`, which JSON-serializes to an empty body while the cell still reports success. Pass `required` to these three editable cells (matching ExamScoresTab's `subject` cell and the modal rules) to prevent required fields from being emptied inline.

        <EditableArchiveCell value={v} field={LEARNING_FIELDS.recordDate} record={r} editor="date" onSave={saveCell}>
          {v}
        </EditableArchiveCell>


This manual label lookup duplicates the `getOptionLabel` helper already exported from `./shared`. Use `getOptionLabel(RECORD_TYPE_OPTIONS, v)` to keep the label resolution logic in one place and consistent with the rest of the module.

{RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}


API endpoints are hardcoded inline as template strings here and in the PUT/DELETE mutations below. Per the review rules, business URL paths should not be hardcoded — consider extracting archive endpoints into a shared constants module (or a small API wrapper) so paths stay consistent and are easy to test/maintain.


Static inline style; per the review rules inline styles should be avoided except for dynamic styles. Move `marginBottom: 16` to a CSS class (same applies to `style={{ width: '100%' }}` on the DatePicker).


`setSaving(true)` is only reached after `await form.validateFields()`. If the OK button is clicked twice in quick succession, both invocations can pass before React commits the `confirmLoading` state, causing two concurrent POST requests and duplicate enrollment records. Move the `saving` guard/set to the very start of the handler (the `finally` block already resets it).

    if (saving) return;
    try {
      setSaving(true);
      const values = await form.validateFields();

    try {
      const values = await form.validateFields();
      setSaving(true);


No cross-field validation between 开始日期 and 结束日期: a record can be created with `endDate` earlier than `startDate`, or with only one of the two dates filled in (the server DTO marks both optional and performs no range check either). This can corrupt the enrollment timeline displayed elsewhere. Add a validator on `endDate` (using `dependencies` on `startDate`) to reject `endDate < startDate`, or enforce both-or-neither.

          <Form.Item name="startDate" label="开始日期">
            <DatePicker style={{ width: '100%' }} />
          </Form.Item>


Static inline style — per project conventions (inline styles only for dynamic values), this margin should be a CSS class/utility instead.


'late' is a valid record status in this module (attendance-workspace.ts counts `status === 'present' || status === 'late'` as checked-in, and EMPTY_SUMMARY includes `late`), but STATUS_META has no 'late' entry. A 'late' record rendered by AttendanceStatusTag falls into the fallback branch and displays the raw English string 'late' with className `is-absent` and short '?', visually treating a late student as absent. Add a `late` entry to STATUS_META (e.g. label '迟到', className 'is-late' which already exists in attendance.css).

export const STATUS_META: Record<
  string,
  { label: string; color: string; className: string; short: string }
> = {


'late' records are excluded from the attendance rate calculation and from pickPrimaryStatus priority. Since attendance-workspace.ts treats `status === 'late'` as checked-in, a student with only 'late' records will be shown with primaryStatus 'absent' and rate 0%, misrepresenting them as fully absent. Count late as checked-in (consistent with the workspace logic) and add 'late' to the priority list in pickPrimaryStatus.

const checked = item.records.filter(
    (record) => record.status === 'present' || record.status === 'late',
  ).length;

const checked = item.records.filter((record) => record.status === 'present').length;


displayAttendanceStatus silently converts 'pending' to 'absent'. As a result, STATUS_META.pending ('待确认') is never reachable through AttendanceStatusTag, and in pickPrimaryStatus a student with only 'pending' records is treated as 'absent' (since pending is normalized to absent before the priority lookup). If 'pending → absent' is an intentional admin-side policy, document it with a comment; otherwise keep pending distinct so unconfirmed attendance is not shown as 缺勤.

export function displayAttendanceStatus(status?: string | null): string {
  return status === 'pending' || !status ? 'absent' : status;
}


The 'afternoon' period is labeled '晚课' (evening class) with startTime 14:00–17:00, which conflicts with both its periodKey 'afternoon' and the later 'evening_study' period labeled '晚自习' (18:30–21:00). This looks like a typo — the label should likely be '下午课' (afternoon class) to match the time slot.

{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },


The `columns` prop is typed as `any[]`, which discards all type safety for the Table columns (and the checklist requires avoiding `any` unless a comment justifies it). Use antd's `TableColumnsType<AttendanceRecordItem>` instead so column definitions remain type-checked against the record shape.


This compares against a display label (`'出勤'`) to decide the rendered text. Status display labels are business strings that can easily change, silently breaking the rendering. Compare against a stable status key (e.g., the record's status / STATUS_META key) or add an explicit display-name map instead of relying on label equality.

{meta.label === '出勤' ? '正常' : meta.label}


Static inline style block — per the checklist inline styles should be avoided except for dynamic values. Move this margin/alignment layout into a CSS class.

            style={{
              marginBottom: 8,
              display: 'flex',
              alignItems: 'center',
              gap: 8,
              flexWrap: 'wrap',
            }}


`rowSelection.onChange` provides `Key[]` (i.e. `string | number`), and blindly casting to `number[]` is an unsafe assumption. Although `rowKey="id"` currently makes keys numeric, a non-numeric key would silently produce broken selection state. Filter/validate the keys or keep the generic `Key[]` type in the handler signature.

onChange: (keys) => onSelectRecords(keys as number[]),


If `refreshConfig()` (i.e. `refetchConfig()`) rejects — e.g. a transient network error on `/ai/config` right after a successful test — this catch block overwrites the already-recorded successful `testResult` with a failure card saying "测试请求失败", even though the connection test itself succeeded. Additionally, `skipNextSyncRef` is set in `refreshConfig` before the fetch, so a failed refetch leaves the flag stuck and the next genuine config sync will be silently skipped. Separate the refresh from the test try/catch, e.g. `await refreshConfig().catch(() => {})`, so a refresh failure doesn't corrupt the test result.

      const res = await api.post<TestResult>('/ai/config/test', body);
      setTestResult(res);
      // 刷新失败不应覆盖已成功的测试结果
      await refreshConfig().catch(() => {});
    } catch (err: unknown) {

      const res = await api.post<TestResult>('/ai/config/test', body);
      setTestResult(res);
      await refreshConfig();
    } catch (err: unknown) {


This query runs unconditionally even for users without `ai:config:read`, because the `!canRead` early return happens after all hooks. Sensitive config (provider/baseUrl/key state) is fetched from `/ai/config` before the permission gate is reached, and the request is also wasted for unauthorized users. Hoist the `usePermission`/`canRead` computation above the query and gate it with `enabled: canRead`.

  } = useQuery<AiConfigData | null>({
    queryKey: ['ai', 'config'],
    enabled: canRead,


`enabled: true` is hardcoded on every save. There is no `enabled` control anywhere on this page, so if the AI feature was deliberately disabled (e.g. via DB or another admin surface), simply saving any change on this page silently re-enables it. Preserve the existing state (`enabled: config?.enabled ?? true`) or add an explicit enable/disable control so a disabled config isn't accidentally turned back on.

        defaultModel: defaultModel || undefined,
        enabled: config?.enabled ?? true,

        defaultModel: defaultModel || undefined,
        enabled: true,


This `try` also wraps `form.validateFields(...)`. When validation fails (e.g. missing baseUrl for OPENAI_COMPATIBLE), the rejection is reported as "获取模型列表失败" — a misleading error toast that blames the model fetch. Run `validateFields` before entering the try/catch (or distinguish validation failures) so users see the actual field errors instead of a fake fetch error.

    setFetchingModels(true);
    try {
      // validateFields 失败会进入 catch，被误报为“获取模型列表失败”
      await form.validateFields(['provider', 'baseUrl']);

    setFetchingModels(true);
    try {
      await form.validateFields(['provider', 'baseUrl']);


The masked-key sentinel `'••••'` is hardcoded in three places in this file (`handleFetchModels`, `handleSave`, `handleTest`) and duplicated in `AiConfigSteps.tsx`. A typo in one spot would silently send the literal mask to the server as a real API key. Extract a shared constant (e.g. `MASKED_API_KEY = '••••'` in helpers) and reuse it.

if (formKey && formKey !== '••••') body.apiKey = formKey;


Nested ternary in the metric value calculation violates the project rule against nested ternaries and hurts readability: it mixes a string value (`${attendanceRate}%`) with numeric branches and handles three cases inline. Prefer an if/else chain (or a small helper) so each branch is explicit. Also guard `summary.absent`/`summary.pending` with `?? 0` to avoid a potential `NaN` if either field is ever undefined.

            let value: string | number;
            if (metric.key === 'all') {
              value = `${attendanceRate}%`;
            } else if (metric.key === 'absent') {
              value = (summary.absent ?? 0) + (summary.pending ?? 0);
            } else {
              value = summary[metric.key as keyof AttendanceSummary] ?? 0;
            }

            const value =
              metric.key === 'all'
                ? `${attendanceRate}%`
                : metric.key === 'absent'
                  ? summary.absent + summary.pending
                  : (summary[metric.key as keyof AttendanceSummary] ?? 0);


The async `onSave` here does not `await onSaveAdminRecordCell(...)` and does not propagate rejection. EditableCell's internal save flow (`saveValue`) relies on the onSave promise rejecting on failure so it can show an error and keep the editor open; because this wrapper resolves immediately, the editor closes and shows a success/undo state even when the underlying mutation fails (the parent's `saveAdminRecordCell` also swallows the error, so a failed save looks successful to the user). Await the callback and let the error propagate (the parent should rethrow on failure) so EditableCell's built-in error handling works.

          onSave={async (next) => {
            await onSaveAdminRecordCell(record, 'status', next);
          }}

          onSave={async (next) => {
            onSaveAdminRecordCell(record, 'status', next);
          }}


While a correction is in flight via the action-column Segmented (`correctingRecordId === record.id` disables only that Segmented), this status EditableCell for the same record is still enabled. A user can open it and submit a different status concurrently, producing two overlapping mutations on the same record that can race (optimistic patch vs. server result). Consider also disabling the cell while that record is being corrected, e.g. `disabled={!canEdit || correctingRecordId === record.id}`, destructuring `correctingRecordId` from the context.

          options={ADMIN_CORRECTION_OPTIONS.map((item) => ({
            value: String(item.value),
            label: item.label,
          }))}
          disabled={!canEdit || correctingRecordId === record.id}

          options={ADMIN_CORRECTION_OPTIONS.map((item) => ({
            value: String(item.value),
            label: item.label,
          }))}
          disabled={!canEdit}


Edit controls remain enabled after the lesson is settled: when `session.status === 'completed'` the Alert says the lesson has been settled, but the per-row 已打卡/未打卡 buttons (and the 全部已打卡/全部未打卡 batch buttons) are still clickable. The API rejects updates to settled records, so the user will hit an avoidable error (individual edits even cause an optimistic update + rollback). Disable/hide editing when `completed` — e.g. render only `<AttendanceStatus>` when `completed || !canEditAttendance` and disable the batch buttons while `completed`.

              if (!canEditAttendance || completed) return <AttendanceStatus status={value} />;
              const checkedIn = value === 'present' || value === 'late';

              if (!canEditAttendance) return <AttendanceStatus status={value} />;
              const checkedIn = value === 'present' || value === 'late';


`updateRecord` does not respect `cancelledRef`, contradicting the component comment that "in-flight requests no longer update state or show toasts after unmount". If an individual update resolves after the drawer is closed or the lesson switched/remounted, it can still call `setRecords` and show `message.error` on a stale instance. Additionally, rapid toggling of the same record can race, and the rollback (`setRecords(... previous)`) may restore a stale status. Guard after the await (e.g. `if (cancelledRef.current) return;`) before updating state/toast, and consider ignoring errors when cancelled.

await api.put(`/attendance-records/${record.id}`, { status });


Business status literals ('present'/'late'/'absent') and API endpoint paths ('/attendance-records/batch-status', '/attendance-records/${id}', '/attendance-lessons/schedules/.../pull') are hardcoded and repeated in several places (batch filter, button handlers, update calls). Extract them into shared constants/helpers so a backend enum change or a typo can be fixed in one place.

        status === 'present'
          ? record.status !== 'present' && record.status !== 'late'
          : record.status !== 'absent',


Nested ternary (a three-way chained conditional) violates the project rule that nested ternary expressions are not allowed and hurts readability. Refactor to explicit if/else branches.

  const subjectTeacherNames = (() => {
    if (currentSchedule) {
      return getTeacherDisplayName({
        name: currentSchedule.teacherName,
        username: currentSchedule.teacherUsername,
      });
    }
    if (classId) {
      return formatTeacherNames(
        overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),
      );
    }
    return '请选择班级';
  })();

  const subjectTeacherNames = currentSchedule
    ? getTeacherDisplayName({
        name: currentSchedule.teacherName,
        username: currentSchedule.teacherUsername,
      })
    : classId
      ? formatTeacherNames(
          overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),
        )
      : '请选择班级';


Nested ternary used for the status label violates the no-nested-ternary rule. Extract a status → label map instead for clarity and to avoid silently treating any unknown status as 「缺勤」.

    const statusLabelMap: Record<string, string> = {
      present: '正常',
      leave: '请假',
      absent: '缺勤',
    };
    const statusLabel = statusLabelMap[nextStatus] ?? '未知';

    const statusLabel =
      nextStatus === 'present' ? '正常' : nextStatus === 'leave' ? '请假' : '缺勤';


Per project async conventions, prefer async/await over Promise chains. Also consider checking the response content-type / error body so a non-OK export (e.g. 401/500 returning JSON) shows a more accurate error.

    try {
      const response = await fetch(`/api/attendance-records/export?${params.toString()}`, {
        headers: { Authorization: `Bearer ${token}` },
      });
      if (!response.ok) throw new Error('导出失败');
      const blob = await response.blob();
      saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`);
    } catch {
      message.error('导出失败');
    }

    fetch(`/api/attendance-records/export?${params.toString()}`, {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((response) => {
        if (!response.ok) throw new Error('导出失败');
        return response.blob();
      })
      .then((blob) => saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`))
      .catch(() => message.error('导出失败'));