Bläddra i källkod

docs(frontend): refresh README + simplify deprecated-scan config

README rewrite reflects the post-Zod-migration state:
- 3 Vite entries (index/login/subpage), not "one per panel route"
- New folders: schemas/, lib/xray/, generated/, test/, layouts/
- Scripts table covers test/gen:api/gen:zod alongside the existing
  dev/build/lint/typecheck
- New sections on the Zod schema tree, the three validation layers,
  the unified Form.useForm + antdRule pattern, and the golden
  fixture testing setup
- "Adding a new page" updated to reflect that most additions are
  just react-router entries in routes.tsx, not new Vite bundles
- Explicit note that `@deprecated` in the prose is a JSDoc tag, not
  a shell command — comes with the exact one-line npx invocation

eslint.deprecated.config.js trimmed: dropping the
recommendedTypeChecked spread + the ~28 rule overrides that came
with it. The config now wires the @typescript-eslint and
react-hooks plugins manually and enables exactly one rule
(`@typescript-eslint/no-deprecated`). 45 lines → 30, same output:
zero false-positives, zero noise, zero deprecations on the current
tree.
MHSanaei 21 timmar sedan
förälder
incheckning
8be84e6e2c
2 ändrade filer med 177 tillägg och 76 borttagningar
  1. 167 49
      frontend/README.md
  2. 10 27
      frontend/eslint.deprecated.config.js

+ 167 - 49
frontend/README.md

@@ -1,8 +1,15 @@
 # 3x-ui frontend
 
-React 19 + Ant Design 6 + TypeScript + Vite 8. Multi-page app — one HTML
-entry per panel route — built into `../web/dist/` and embedded into the
-Go binary via `embed.FS`.
+React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles —
+`index.html` (admin panel SPA, all `/panel/*` routes), `login.html`
+(login + 2FA), and `subpage.html` (public subscription viewer). All
+three are built into `../web/dist/` and embedded into the Go binary
+via `embed.FS`.
+
+State is split between local `useState`, TanStack Query for server
+state, and `useTheme` / `useWebSocket` contexts. Form validation,
+API parsing, and the xray config model all run through a single
+shared Zod schema tree (see [Schemas](#schemas)).
 
 ## Dev
 
@@ -11,73 +18,184 @@ npm install
 npm run dev
 ```
 
-Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
-proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
-first (`go run main.go`) and then Vite.
-
-The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
-`/panel/xray` to the matching Vite-served HTML in dev mode (see
-`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
+Vite serves on `http://localhost:5173/`. API calls and `/panel/*`
+routes proxy to the Go panel at `http://localhost:2053/`, so start
+the Go panel first (`go run main.go`) and then Vite. The proxy
+auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
+`/panel/xray` to the matching Vite-served HTML, so the sidebar's
 production-style links work without round-tripping through Go.
 
-## Production build
+## Scripts
+
+| Command | What |
+|---|---|
+| `npm run dev` | Vite dev server with API + WS proxy to Go |
+| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../web/dist/` |
+| `npm run preview` | Serve the built bundle locally |
+| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
+| `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
+| `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
+| `npm run test:watch` | Vitest watch mode |
+| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
+| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
+
+CI runs `typecheck`, `lint`, `test`, and `build` on every PR
+(see `../.github/workflows/ci.yml`).
+
+### One-off: scan for deprecated APIs
+
+Run this command to sweep the codebase for usages of APIs marked
+with the JSDoc `@deprecated` tag (AntD prop renames, Zod renames,
+removed Web APIs, etc.):
 
 ```sh
-npm run build
+npx eslint --config eslint.deprecated.config.js src
 ```
 
-Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
-`assets/`). The Go binary embeds this directory at compile time and
-`web/controller/dist.go` serves the per-page HTML.
+It's a type-aware ESLint run against `eslint.deprecated.config.js`
+and is not wired into `npm run lint` because typed linting triples
+the wall-clock time.
 
-## Type check and lint
+## Production build
 
 ```sh
-npm run typecheck
-npm run lint
+npm run build
 ```
 
-`tsc --noEmit` against `tsconfig.json` (strict mode, `jsx: "react-jsx"`,
-`@/*` → `src/*` alias). ESLint 10 with `eslint.config.js` (flat config)
-— `@eslint/js` recommended plus `typescript-eslint` and
-`eslint-plugin-react-hooks` rules.
+Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
+`assets/`). `manualChunks` splits AntD, icons, codemirror, and
+react-query into separate vendor bundles to keep the per-page
+initial JS small. The Go binary embeds this directory at compile
+time and `web/controller/dist.go` serves the per-page HTML.
 
 ## Layout
 
 ```
 frontend/
-├── *.html                 # Vite entry HTML, one per panel route
+├── index.html, login.html, subpage.html  # 3 Vite entries
 ├── tsconfig.json
 ├── eslint.config.js
+├── eslint.deprecated.config.js           # On-demand type-aware lint config that flags
+│                                         #   usages of APIs marked with JSDoc @deprecated
+├── vitest.config.ts
 ├── vite.config.js
+├── scripts/
+│   └── build-openapi.mjs                 # endpoints.ts → openapi.json
 └── src/
-    ├── entries/           # Per-page bootstrap (createRoot + render)
-    ├── pages/             # One folder per route, each with the page
-    │   ├── index/         # component + helpers + sub-components
-    │   ├── login/
-    │   ├── inbounds/
-    │   ├── clients/
-    │   ├── xray/
-    │   ├── nodes/
-    │   ├── settings/
-    │   ├── api-docs/
-    │   └── sub/
-    ├── components/        # Cross-page React components
-    ├── hooks/             # Reusable hooks (useTheme, useWebSocket, …)
-    ├── api/               # Axios setup, CSRF interceptor, WebSocket
-    ├── i18n/              # react-i18next init (locales live in web/translation/)
-    ├── models/            # Inbound, Outbound, Status, … domain classes
-    ├── styles/            # Shared CSS modules (page-cards, …)
-    └── utils/             # HttpUtil, ObjectUtil, LanguageManager, …
+    ├── entries/         # Per-page bootstrap (createRoot + render)
+    ├── main.tsx         # Shared root for the admin SPA (index.html)
+    ├── routes.tsx       # react-router routes mounted under /panel/
+    ├── pages/           # One folder per route, page component + helpers
+    │   ├── index/, login/, inbounds/, clients/, xray/, nodes/,
+    │   ├── settings/, api-docs/, sub/
+    ├── layouts/         # AdminLayout (sidebar + header + outlet)
+    ├── components/      # Cross-page React components
+    ├── hooks/           # useClients, useTheme, useWebSocket, …
+    ├── api/             # Axios + CSRF interceptor, TanStack Query bridge,
+    │                    #   WebSocket client + queryClient.ts
+    ├── i18n/            # react-i18next init (locales in web/translation/)
+    ├── lib/xray/        # Pure functions: link generation, defaults,
+    │                    #   form ⇄ wire adapters, protocol capabilities
+    ├── schemas/         # Zod source-of-truth (see "Schemas" below)
+    ├── generated/       # Code-generated zod + ts types from Go
+    │                    #   (DO NOT hand-edit — regenerated by gen:zod)
+    ├── models/          # Thin legacy types still in transit
+    │                    #   (DBInbound, Status, AllSetting, reality-targets)
+    ├── styles/          # Shared CSS modules
+    ├── test/            # Vitest specs + golden fixtures
+    │   ├── *.test.ts
+    │   ├── __snapshots__/
+    │   └── golden/fixtures/  # Per-(protocol × network × security) JSON
+    └── utils/           # HttpUtil, ClipboardManager, SizeFormatter, …
+```
+
+## Schemas
+
+`src/schemas/` is the single source of truth for the xray
+configuration model. Every API response is parsed through it,
+every form field is validated against it, and TypeScript types
+are inferred via `z.infer<typeof X>` — never hand-written.
+
+```
+schemas/
+├── primitives/      # Atomic reusable schemas (port, protocol, sniffing, …)
+├── api/             # Backend response shapes (e.g. SlimInboundSchema)
+├── forms/           # User-facing form shapes (narrower than api/)
+├── protocols/
+│   ├── inbound/     # Per-protocol settings (vmess, vless, trojan, …)
+│   ├── outbound/
+│   ├── stream/      # Network transports (tcp, ws, grpc, xhttp, kcp, …)
+│   └── security/    # TLS, Reality, none
+├── client.ts, dns.ts, routing.ts, setting.ts, status.ts, xray.ts
+└── _envelope.ts     # Generic `Msg<T>` envelope wrapper
 ```
 
+Patterns:
+
+- **Discriminated unions** for polymorphic data — inbound `settings`
+  is `z.discriminatedUnion('protocol', […])`, same for stream and
+  security.
+- **Three validation layers**, non-overlapping:
+  - API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
+    Query `queryFn` — warn-only in prod, throws in dev
+  - Form input: `antdRule(schema.shape.field)` on every `<Form.Item>` —
+    blocks submit + per-field inline error
+  - Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
+    because a malformed payload here is always a developer bug
+- **No `.loose()` or `[key: string]: any`** in production schemas.
+  `@typescript-eslint/no-explicit-any: error` is enforced.
+
+## Form pattern (Pattern A)
+
+All non-trivial modals use this single pattern:
+
+```tsx
+const [form] = Form.useForm<InboundFormValues>();
+
+const onFinish = async () => {
+  const values = await form.validateFields();
+  await createInbound.mutateAsync(values);
+};
+
+<Form form={form} onFinish={onFinish}>
+  <Form.Item
+    name="port"
+    label="Port"
+    rules={[antdRule(InboundFormSchema.shape.port, t)]}
+  >
+    <InputNumber min={1} max={65535} />
+  </Form.Item>
+</Form>
+```
+
+No `safeParse`-on-submit handlers, no `useRef<any>` for form
+references, no inline `z.string().min(1)` in rules. Conditional
+fields use `<Form.Item dependencies={...} shouldUpdate>` with the
+nested protocol schema.
+
+## Testing
+
+Vitest runs everything under `src/test/`. Schemas have **golden
+fixture suites** — one JSON per `(protocol × network × security)`
+combination round-tripped through `schema.parse` → link generator
+→ snapshot. Regenerate snapshots after intentional changes:
+
+```sh
+npx vitest run -u
+```
+
+Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
+via `import.meta.glob`.
+
 ## Adding a new page
 
-1. Add `frontend/<page>.html` referencing `/src/entries/<page>.tsx`.
-2. Add `src/entries/<page>.tsx` that imports the page component and
-   mounts it with `createRoot(...).render(...)`.
-3. Add the page component under `src/pages/<page>/`.
-4. Register the entry in `rollupOptions.input` in `vite.config.js`.
-5. If the page is reachable from the sidebar at `/panel/<route>`, add
-   it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
-6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.
+Most new routes go inside the admin SPA (`index.html`) via
+`routes.tsx` — no new HTML or Vite entry needed.
+
+1. Add the page component under `src/pages/<page>/`.
+2. Register it in `src/routes.tsx` under the `/panel/...` tree.
+3. If you need a brand-new top-level bundle (login-style standalone
+   page), add the HTML at `frontend/<page>.html`, an entry at
+   `src/entries/<page>.tsx`, and register it in `rollupOptions.input`
+   in `vite.config.js`. Then add the Go controller call to
+   `serveDistPage(c, "<page>.html")`.

+ 10 - 27
frontend/eslint.deprecated.config.js

@@ -1,43 +1,26 @@
 import tseslint from 'typescript-eslint';
+import reactHooks from 'eslint-plugin-react-hooks';
 
 export default [
   { ignores: ['node_modules/**', '../web/dist/**', 'src/generated/**'] },
-  ...tseslint.configs.recommendedTypeChecked.map((config) => ({
-    ...config,
+  {
     files: ['**/*.{ts,tsx}'],
+    plugins: {
+      '@typescript-eslint': tseslint.plugin,
+      'react-hooks': reactHooks,
+    },
     languageOptions: {
-      ...config.languageOptions,
+      parser: tseslint.parser,
       parserOptions: {
-        ...config.languageOptions?.parserOptions,
         projectService: true,
         tsconfigRootDir: import.meta.dirname,
       },
     },
-  })),
-  {
-    files: ['**/*.{ts,tsx}'],
     rules: {
       '@typescript-eslint/no-deprecated': 'warn',
-      '@typescript-eslint/no-explicit-any': 'off',
-      '@typescript-eslint/no-unsafe-assignment': 'off',
-      '@typescript-eslint/no-unsafe-member-access': 'off',
-      '@typescript-eslint/no-unsafe-call': 'off',
-      '@typescript-eslint/no-unsafe-return': 'off',
-      '@typescript-eslint/no-unsafe-argument': 'off',
-      '@typescript-eslint/no-misused-promises': 'off',
-      '@typescript-eslint/no-floating-promises': 'off',
-      '@typescript-eslint/restrict-template-expressions': 'off',
-      '@typescript-eslint/no-unused-vars': 'off',
-      '@typescript-eslint/no-base-to-string': 'off',
-      '@typescript-eslint/no-redundant-type-constituents': 'off',
-      '@typescript-eslint/unbound-method': 'off',
-      '@typescript-eslint/require-await': 'off',
-      '@typescript-eslint/await-thenable': 'off',
-      '@typescript-eslint/no-empty-function': 'off',
-      '@typescript-eslint/prefer-promise-reject-errors': 'off',
-      '@typescript-eslint/only-throw-error': 'off',
-      '@typescript-eslint/no-unnecessary-type-assertion': 'off',
-      'react-hooks/exhaustive-deps': 'off',
+    },
+    linterOptions: {
+      reportUnusedDisableDirectives: 'off',
     },
   },
 ];