From 99252d7a85f5d542cd518386f30004007018f05e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 13:46:39 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=81=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D1=82=D0=B0=20=D1=81=20arm-web@test=20(02523?= =?UTF-8?q?3f)=20(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенесён диапазон arm-web 100fbfb..025233f (2 коммитов, 19 файлов набора синхронизации) скриптом docs/scripts/sync-web.sh. После переноса web/ и arm-web@test идентичны по набору. Коммиты arm-web: - f868a92 Скан PDF с JBIG2 не рисовался: передать pdf.js wasmUrl и копировать wasm в сборку (#32) - a8a6da0 Форматирование перед PR: ESLint + Prettier и npm run format (#29) Co-Authored-By: sync-web.sh Co-Authored-By: Claude Opus 5.5 --- web/.prettierrc.json | 16 +++++++ web/README.md | 15 +++++- web/biome.json | 7 +-- web/eslint.config.js | 46 +++++++++++++++++++ web/package.json | 10 ++++ web/rsbuild.config.ts | 2 + web/src/components/GenericTable/tableTypes.ts | 2 +- .../GenericTable/useTableFilters.ts | 2 +- web/src/index.tsx | 3 +- web/src/utils/money.ts | 2 +- web/src/widgets/ConsoleTable/ConsoleTable.tsx | 8 +--- web/src/widgets/ConsoleTable/flatten.ts | 2 +- web/src/widgets/ConsoleTable/useGrouping.ts | 6 ++- web/src/widgets/ConsolidatedOsv/OsvTable.tsx | 7 ++- web/src/widgets/DocumentReview/PdfPages.tsx | 4 +- .../DocumentReview/pdfjsOptions.test.ts | 17 +++++++ .../widgets/DocumentReview/pdfjsOptions.ts | 14 ++++++ web/src/widgets/Sidebar/Sidebar.tsx | 8 +--- web/src/widgets/Sidebar/styles.module.css | 12 +++-- 19 files changed, 150 insertions(+), 33 deletions(-) create mode 100644 web/.prettierrc.json create mode 100644 web/eslint.config.js create mode 100644 web/src/widgets/DocumentReview/pdfjsOptions.test.ts create mode 100644 web/src/widgets/DocumentReview/pdfjsOptions.ts diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..0086d0f --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "printWidth": 140, + "singleQuote": true, + "jsxSingleQuote": true, + "bracketSameLine": true, + "trailingComma": "all", + "semi": true, + "arrowParens": "always", + "overrides": [ + { + "files": "*.css", + "options": { "singleQuote": false } + } + ] +} diff --git a/web/README.md b/web/README.md index fe7f5f4..e287afe 100644 --- a/web/README.md +++ b/web/README.md @@ -13,9 +13,22 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v `ARM_CORE_URL` (по умолчанию `http://localhost:8000`), `REACT_APP_API_URL` в dev не нужен. 4. Вход — через dev-форму (`POST /dev/token`), например `ka_go@armka.local`. +## Форматирование и линтеры + +**Перед PR — `npm run format`** (или `make format`): `eslint --fix` → `biome check --write` (импорты, безопасные +исправления) → `prettier --write`. Команда приводит `src/` к единому виду; повторный запуск ничего не меняет. + +- Prettier (`.prettierrc.json`) — единственный форматтер: табы, ширина 140, одинарные кавычки (в CSS — двойные), + `>` тега JSX на той же строке. Форматтер biome выключен, чтобы два инструмента не спорили о пробелах. +- ESLint (`eslint.config.js`) — правила, повторяющие `biome.json`; biome остаётся линтером в `make lint`/`make check`. +- `npm run lint` — `eslint` + `prettier --check` без исправлений: ошибки валят команду, предупреждения (`any`, + `console`, зависимости хуков) — нет. +- В редакторе: расширения Prettier и ESLint, «Format on Save» с Prettier — результат совпадёт с `npm run format`. + ## Проверка -`make check` — install, biome, `tsc --noEmit`, vitest, `rsbuild build`. Зелёным считается всё вместе. +`make check` — install, biome + ESLint + Prettier (`make lint`), `tsc --noEmit`, vitest, `rsbuild build`. Зелёным +считается всё вместе. ## Демо-сценарий diff --git a/web/biome.json b/web/biome.json index fb06a60..ed04ff3 100644 --- a/web/biome.json +++ b/web/biome.json @@ -10,9 +10,7 @@ "ignore": ["node_modules"] }, "formatter": { - "enabled": true, - "lineWidth": 140, - "formatWithErrors": true + "enabled": false }, "organizeImports": { "enabled": true @@ -81,9 +79,6 @@ } }, "css": { - "formatter": { - "enabled": true - }, "parser": { "cssModules": true } diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..ed3165f --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,46 @@ +// ESLint для разработчиков фронта. Правила повторяют biome.json (biome остаётся в make lint/check); +// форматирование — только Prettier, поэтому eslint-config-prettier подключается последним. +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['node_modules', 'dist'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { globals: globals.browser }, + plugins: { react, 'react-hooks': reactHooks }, + settings: { react: { version: 'detect' } }, + rules: { + // correctness + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + // nursery + 'no-dupe-else-if': 'error', + 'no-nested-ternary': 'error', + 'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }], + // style + 'default-case': 'error', + '@typescript-eslint/prefer-for-of': 'error', + 'react/jsx-fragments': ['error', 'syntax'], + '@typescript-eslint/array-type': ['error', { default: 'array' }], + 'operator-assignment': 'error', + // suspicious + 'no-console': 'warn', + 'no-empty': 'error', + '@typescript-eslint/no-empty-function': 'error', + '@typescript-eslint/no-explicit-any': 'warn', + 'require-await': 'error', + }, + }, + prettier, +); diff --git a/web/package.json b/web/package.json index dfb4f18..44f8405 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,8 @@ "scripts": { "build": "rsbuild build", "test": "vitest run", + "format": "eslint --fix src && biome check --write src && prettier --write src", + "lint": "eslint src && prettier --check src", "start": "rsbuild dev --open", "preview": "rsbuild preview" }, @@ -23,13 +25,21 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@eslint/js": "9.17.0", "@rsbuild/core": "1.6.7", "@rsbuild/plugin-react": "^1.1.0", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "daisyui": "^4.12.10", + "eslint": "9.17.0", + "eslint-config-prettier": "9.1.0", + "eslint-plugin-react": "7.37.3", + "eslint-plugin-react-hooks": "5.1.0", + "globals": "15.14.0", + "prettier": "3.3.3", "tailwindcss": "^3.4.13", "typescript": "^5.7.3", + "typescript-eslint": "8.18.2", "vitest": "3.1.4" }, "optionalDependencies": { diff --git a/web/rsbuild.config.ts b/web/rsbuild.config.ts index 29003b1..9bcc497 100644 --- a/web/rsbuild.config.ts +++ b/web/rsbuild.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ }, }, output: { + // wasm-декодеры pdf.js (JBIG2, JPEG2000, ICC): без них скан не рисуется (AURORA-1635). Путь = PDFJS_WASM_DIR + copy: [{ from: 'node_modules/pdfjs-dist/wasm', to: 'static/pdfjs/wasm' }], filename: { js: '[name]_script.[contenthash:8].js', css: '[name]_style.[contenthash:8].css', diff --git a/web/src/components/GenericTable/tableTypes.ts b/web/src/components/GenericTable/tableTypes.ts index 3b8c933..9155916 100644 --- a/web/src/components/GenericTable/tableTypes.ts +++ b/web/src/components/GenericTable/tableTypes.ts @@ -37,7 +37,7 @@ export interface TableConfig { columns: ColumnDef[]; searchableKeys?: Extract[]; } -export type ColumnFilters = Partial, Array>>; +export type ColumnFilters = Partial, (string | number)[]>>; export function isGroup(c: ColumnDef): c is GroupColumnDef { return (c as GroupColumnDef).children !== undefined; } diff --git a/web/src/components/GenericTable/useTableFilters.ts b/web/src/components/GenericTable/useTableFilters.ts index 6a47b93..5117e90 100644 --- a/web/src/components/GenericTable/useTableFilters.ts +++ b/web/src/components/GenericTable/useTableFilters.ts @@ -16,7 +16,7 @@ export function useTableFilters() { const hasActiveFilters = rawQuery.trim().length > 0 || sort !== null || - (Object.values(columnFilters) as Array | undefined>).some((v) => v && v.length > 0); + (Object.values(columnFilters) as ((string | number)[] | undefined)[]).some((v) => v && v.length > 0); return { rawQuery, diff --git a/web/src/index.tsx b/web/src/index.tsx index ef095bf..3697fda 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -40,8 +40,7 @@ createRoot(rootElement).render( headerBg: 'rgb(248, 250, 252)', }, }, - }} - > + }}> , // , diff --git a/web/src/utils/money.ts b/web/src/utils/money.ts index 6c42aaf..9d4150e 100644 --- a/web/src/utils/money.ts +++ b/web/src/utils/money.ts @@ -49,7 +49,7 @@ export function parseAmountInput(input: string): { ok: true; value: string | nul /** Точная сумма строк Decimal из API (копейками, без float) → «123.45»; пустые пропускаются, все пустые — null. * Сумма, которую не удалось разобрать (больше двух знаков, текст), делает итог null — итог не выдумываем. */ -export function sumAmounts(values: ReadonlyArray): string | null { +export function sumAmounts(values: readonly (string | null | undefined)[]): string | null { let cents = 0n; let any = false; for (const value of values) { diff --git a/web/src/widgets/ConsoleTable/ConsoleTable.tsx b/web/src/widgets/ConsoleTable/ConsoleTable.tsx index 6c8a720..ee14dd2 100644 --- a/web/src/widgets/ConsoleTable/ConsoleTable.tsx +++ b/web/src/widgets/ConsoleTable/ConsoleTable.tsx @@ -20,13 +20,7 @@ const ITEMS = [ {}, ] as const; -const RowActions = memo(function RowActions({ - row, - onAction, -}: { - row: ConsRow; - onAction: (key: ActionKey, _row: ConsRow) => void; -}) { +const RowActions = memo(function RowActions({ row, onAction }: { row: ConsRow; onAction: (key: ActionKey, _row: ConsRow) => void }) { return ( { - const rep_date = new Date(con.reporting_date) ?? null; + const rep_date = new Date(con.reporting_date); out.push({ id: v.id, group_id: con.consolidation_id, diff --git a/web/src/widgets/ConsoleTable/useGrouping.ts b/web/src/widgets/ConsoleTable/useGrouping.ts index 451655b..e2dc072 100644 --- a/web/src/widgets/ConsoleTable/useGrouping.ts +++ b/web/src/widgets/ConsoleTable/useGrouping.ts @@ -6,7 +6,11 @@ export function useGrouping(rows: ConsRow[]) { const toggle = (group_id: string) => setExpanded((prev) => { const next = new Set(prev); - next.has(group_id) ? next.delete(group_id) : next.add(group_id); + if (next.has(group_id)) { + next.delete(group_id); + } else { + next.add(group_id); + } return next; }); const visibleRows = useMemo(() => rows.filter((r) => r.isPrimary || expanded.has(r.group_id)), [rows, expanded]); diff --git a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx index 9df6948..1ee69cc 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -37,7 +37,12 @@ function DropdownShell({ onReset, close, children, -}: { onApply: () => void; onReset: () => void; close: () => void; children: React.ReactNode }) { +}: { + onApply: () => void; + onReset: () => void; + close: () => void; + children: React.ReactNode; +}) { return (
{children} diff --git a/web/src/widgets/DocumentReview/PdfPages.tsx b/web/src/widgets/DocumentReview/PdfPages.tsx index 12cb4e7..ba3ce11 100644 --- a/web/src/widgets/DocumentReview/PdfPages.tsx +++ b/web/src/widgets/DocumentReview/PdfPages.tsx @@ -1,6 +1,8 @@ import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { Config } from '../../conf/config'; import { pdfjsLib } from './pdfjs'; +import { pdfjsWasmUrl } from './pdfjsOptions'; export const MIN_ZOOM = 0.5; export const MAX_ZOOM = 3; @@ -22,7 +24,7 @@ export default function PdfPages({ data, zoom }: { data: ArrayBuffer; zoom: numb let cancelled = false; async function load() { // pdf.js забирает переданный буфер себе, поэтому отдаём копию (эффект может выполниться повторно) - pdf = await pdfjsLib.getDocument({ data: data.slice(0) }).promise; + pdf = await pdfjsLib.getDocument({ data: data.slice(0), wasmUrl: pdfjsWasmUrl(Config.APP_ADDRESS) }).promise; if (cancelled) { pdf.destroy(); return; diff --git a/web/src/widgets/DocumentReview/pdfjsOptions.test.ts b/web/src/widgets/DocumentReview/pdfjsOptions.test.ts new file mode 100644 index 0000000..e35f4ca --- /dev/null +++ b/web/src/widgets/DocumentReview/pdfjsOptions.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { PDFJS_WASM_DIR, pdfjsWasmUrl } from './pdfjsOptions'; + +describe('pdfjsWasmUrl', () => { + it('кладёт wasm под префикс приложения и заканчивается слэшем (pdf.js дописывает к нему имя файла)', () => { + expect(pdfjsWasmUrl('/aurora/apps/fastapi-dkb-armka')).toBe(`/aurora/apps/fastapi-dkb-armka/${PDFJS_WASM_DIR}/`); + }); + + it('не удваивает слэши, если префикс уже заканчивается слэшем', () => { + expect(pdfjsWasmUrl('/aurora/apps/fastapi-dkb-armka/')).toBe(`/aurora/apps/fastapi-dkb-armka/${PDFJS_WASM_DIR}/`); + }); + + it('при корневом префиксе даёт путь от корня', () => { + expect(pdfjsWasmUrl('/')).toBe(`/${PDFJS_WASM_DIR}/`); + expect(pdfjsWasmUrl('')).toBe(`/${PDFJS_WASM_DIR}/`); + }); +}); diff --git a/web/src/widgets/DocumentReview/pdfjsOptions.ts b/web/src/widgets/DocumentReview/pdfjsOptions.ts new file mode 100644 index 0000000..480b277 --- /dev/null +++ b/web/src/widgets/DocumentReview/pdfjsOptions.ts @@ -0,0 +1,14 @@ +// Опции pdf.js для getDocument. Чистый модуль (без импорта pdfjs-dist), чтобы его можно было тестировать. +// +// pdf.js 5.x декодирует сканы JBIG2 и JPEG2000 и цветовые профили ICC через wasm-файлы (jbig2.wasm, openjpeg.wasm, +// qcms_bg.wasm). Без опции wasmUrl он их не находит: «JBig2 failed to initialize», «Unable to decode image», страница +// остаётся пустой (AURORA-1635). Файлы копирует в сборку rsbuild.config.ts (output.copy) в каталог PDFJS_WASM_DIR. + +/** Каталог wasm-файлов pdf.js в собранном приложении (относительно корня приложения). Тот же путь — в rsbuild.config.ts. */ +export const PDFJS_WASM_DIR = 'static/pdfjs/wasm'; + +/** Адрес каталога wasm: с учётом префикса приложения (ROOT_PATH) и обязательным слэшем в конце — pdf.js дописывает к нему имя файла. */ +export function pdfjsWasmUrl(appAddress: string): string { + const base = appAddress.replace(/\/+$/, ''); + return `${base}/${PDFJS_WASM_DIR}/`; +} diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index a615a1e..a57a7e2 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -138,13 +138,7 @@ function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) { ); } -const Section = memo(function Section({ - isCollapsed, - group, -}: { - isCollapsed: boolean; - group: Group; -}) { +const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) { return !isCollapsed ? (
{group.title}
diff --git a/web/src/widgets/Sidebar/styles.module.css b/web/src/widgets/Sidebar/styles.module.css index 8278b7a..036dcd9 100644 --- a/web/src/widgets/Sidebar/styles.module.css +++ b/web/src/widgets/Sidebar/styles.module.css @@ -30,7 +30,9 @@ color: rgba(55, 65, 81, 0.88); text-decoration: none; background: transparent; - transition: background 0.2s, color 0.2s; + transition: + background 0.2s, + color 0.2s; } /* .linkActive:hover { @@ -72,7 +74,9 @@ justify-content: center; cursor: pointer; color: rgba(0, 0, 0, 0.75); - transition: background 0.2s, color 0.2s; + transition: + background 0.2s, + color 0.2s; font-size: 22px; } @@ -111,7 +115,9 @@ justify-content: center; cursor: pointer; color: rgba(0, 0, 0, 0.75); - transition: background 0.2s, color 0.2s; + transition: + background 0.2s, + color 0.2s; font-size: 12px; padding: 0; } -- 2.47.2