mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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.
This commit is contained in:
parent
2346782e44
commit
8be84e6e2c
2 changed files with 179 additions and 78 deletions
|
|
@ -1,8 +1,15 @@
|
||||||
# 3x-ui frontend
|
# 3x-ui frontend
|
||||||
|
|
||||||
React 19 + Ant Design 6 + TypeScript + Vite 8. Multi-page app — one HTML
|
React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles —
|
||||||
entry per panel route — built into `../web/dist/` and embedded into the
|
`index.html` (admin panel SPA, all `/panel/*` routes), `login.html`
|
||||||
Go binary via `embed.FS`.
|
(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
|
## Dev
|
||||||
|
|
||||||
|
|
@ -11,15 +18,44 @@ npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
|
Vite serves on `http://localhost:5173/`. API calls and `/panel/*`
|
||||||
proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
|
routes proxy to the Go panel at `http://localhost:2053/`, so start
|
||||||
first (`go run main.go`) and then Vite.
|
the Go panel first (`go run main.go`) and then Vite. The proxy
|
||||||
|
auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
|
||||||
The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
|
`/panel/xray` to the matching Vite-served HTML, so the sidebar's
|
||||||
`/panel/xray` to the matching Vite-served HTML in dev mode (see
|
|
||||||
`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
|
|
||||||
production-style links work without round-tripping through Go.
|
production-style links work without round-tripping through Go.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
npx eslint --config eslint.deprecated.config.js src
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Production build
|
## Production build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -27,57 +63,139 @@ npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
|
Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
|
||||||
`assets/`). The Go binary embeds this directory at compile time and
|
`assets/`). `manualChunks` splits AntD, icons, codemirror, and
|
||||||
`web/controller/dist.go` serves the per-page HTML.
|
react-query into separate vendor bundles to keep the per-page
|
||||||
|
initial JS small. The Go binary embeds this directory at compile
|
||||||
## Type check and lint
|
time and `web/controller/dist.go` serves the per-page HTML.
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run typecheck
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
├── *.html # Vite entry HTML, one per panel route
|
├── index.html, login.html, subpage.html # 3 Vite entries
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
├── eslint.config.js
|
├── 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
|
├── vite.config.js
|
||||||
|
├── scripts/
|
||||||
|
│ └── build-openapi.mjs # endpoints.ts → openapi.json
|
||||||
└── src/
|
└── src/
|
||||||
├── entries/ # Per-page bootstrap (createRoot + render)
|
├── entries/ # Per-page bootstrap (createRoot + render)
|
||||||
├── pages/ # One folder per route, each with the page
|
├── main.tsx # Shared root for the admin SPA (index.html)
|
||||||
│ ├── index/ # component + helpers + sub-components
|
├── routes.tsx # react-router routes mounted under /panel/
|
||||||
│ ├── login/
|
├── pages/ # One folder per route, page component + helpers
|
||||||
│ ├── inbounds/
|
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
|
||||||
│ ├── clients/
|
│ ├── settings/, api-docs/, sub/
|
||||||
│ ├── xray/
|
├── layouts/ # AdminLayout (sidebar + header + outlet)
|
||||||
│ ├── nodes/
|
|
||||||
│ ├── settings/
|
|
||||||
│ ├── api-docs/
|
|
||||||
│ └── sub/
|
|
||||||
├── components/ # Cross-page React components
|
├── components/ # Cross-page React components
|
||||||
├── hooks/ # Reusable hooks (useTheme, useWebSocket, …)
|
├── hooks/ # useClients, useTheme, useWebSocket, …
|
||||||
├── api/ # Axios setup, CSRF interceptor, WebSocket
|
├── api/ # Axios + CSRF interceptor, TanStack Query bridge,
|
||||||
├── i18n/ # react-i18next init (locales live in web/translation/)
|
│ # WebSocket client + queryClient.ts
|
||||||
├── models/ # Inbound, Outbound, Status, … domain classes
|
├── i18n/ # react-i18next init (locales in web/translation/)
|
||||||
├── styles/ # Shared CSS modules (page-cards, …)
|
├── lib/xray/ # Pure functions: link generation, defaults,
|
||||||
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
|
│ # 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
|
## Adding a new page
|
||||||
|
|
||||||
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.tsx`.
|
Most new routes go inside the admin SPA (`index.html`) via
|
||||||
2. Add `src/entries/<page>.tsx` that imports the page component and
|
`routes.tsx` — no new HTML or Vite entry needed.
|
||||||
mounts it with `createRoot(...).render(...)`.
|
|
||||||
3. Add the page component under `src/pages/<page>/`.
|
1. Add the page component under `src/pages/<page>/`.
|
||||||
4. Register the entry in `rollupOptions.input` in `vite.config.js`.
|
2. Register it in `src/routes.tsx` under the `/panel/...` tree.
|
||||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add
|
3. If you need a brand-new top-level bundle (login-style standalone
|
||||||
it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
|
page), add the HTML at `frontend/<page>.html`, an entry at
|
||||||
6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.
|
`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")`.
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,26 @@
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{ ignores: ['node_modules/**', '../web/dist/**', 'src/generated/**'] },
|
{ ignores: ['node_modules/**', '../web/dist/**', 'src/generated/**'] },
|
||||||
...tseslint.configs.recommendedTypeChecked.map((config) => ({
|
{
|
||||||
...config,
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tseslint.plugin,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
...config.languageOptions,
|
parser: tseslint.parser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
...config.languageOptions?.parserOptions,
|
|
||||||
projectService: true,
|
projectService: true,
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-deprecated': 'warn',
|
'@typescript-eslint/no-deprecated': 'warn',
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
},
|
||||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
linterOptions: {
|
||||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
reportUnusedDisableDirectives: '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',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue