diff --git a/AGENTS.md b/AGENTS.md index a36dcc2..11b0b33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,59 @@ +# AGENTS.md + +See `CLAUDE.md` for the architecture and command reference. This file adds gotchas and non-obvious conventions not worth repeating there. + +The block at the bottom of this file, between the `intent-skills` markers, is generated by TanStack Intent — leave it alone and edit only above it. + +## It is not TanStack Start + +`@tanstack/react-start` is **not** a dependency. This is a plain Vite SPA: `index.html` → `src/main.tsx` → `createRoot` → `RouterProvider`, with TanStack **Router** (+ `@tanstack/router-plugin`) for routing only. There is no SSR, no server functions, no loaders running server-side — don't reach for `createServerFn`, `createMiddleware`, or `.server.ts` files. `getRouter()` in `src/router.tsx` is a plain `createTanStackRouter`. + +## Query behaviour is configured once, in `src/router.tsx` + +- `staleTime: 30_000` globally — don't re-declare it per query. +- The retry predicate **never retries 401 / 403 / 404 / 422**; everything else retries twice. If you add a query that must not retry, express it through the status code rather than a local `retry: false`. +- `defaultPreload: 'intent'` with `defaultPreloadStaleTime: 0`, so hovering a nav link refetches. Route `beforeLoad` guards run on preload too — keep them cheap and side-effect free (`requirePermission` only reads the store). + +## Mutations go through `handleApiError` + +`src/lib/errors.ts` is the single error path for mutations: a 422 with `errors` populates react-hook-form fields (when you pass `setError`), 403 becomes a fixed `无权限执行此操作` toast, anything else toasts the backend's localized `message`. Don't hand-roll `try/catch` + `toast` in components. + +## Auth-store subtleties + +- Persisted to `localStorage` under **`gig-admin-auth`** (zustand `persist`). Clearing that key is the way to force a logged-out state in the browser. +- `isAuthenticated()` deliberately returns **false** for a session that has a `user` but zero roles *and* zero permissions — a backend account with no admin grants is treated as not logged in, and `_authed.tsx` logs it out with a toast. +- `can()` (imperative, for guards/nav) and `useCan()` (reactive, for components) are separate on purpose. Using `can()` inside a component won't re-render when `/auth/me` refreshes permissions — `_authed.tsx` subscribes to `s.permissions` explicitly for exactly that reason. + +## The 401 refresh hits the *public* API prefix + +`tryRefresh()` in `src/api/client.ts` posts to `/api/v1/auth/refresh-token`, not `/api/admin/v1/...`, with `client_machine_name: 'admin-api-client'`. It is single-flight — concurrent 401s share one attempt — and each request retries at most once before the session is cleared. Login and refresh must pass `anonymous: true` or they would recurse. + +## Uploads + +Pass a `FormData` to `api.upload()` and do not set `Content-Type` anywhere. The client detects `FormData` and skips the header so the browser can supply the multipart boundary; setting it by hand makes the server see an empty upload — a 422 where you expected a 202. `fetch` exposes no upload progress, so upload UIs must be indeterminate. + +## Tests + +- **There is no vitest config file.** jsdom is opted into per file with a `// @vitest-environment jsdom` pragma on line 1 — omit it and the test runs in node and fails on DOM globals. +- The API tests stub `fetch` with `vi.stubGlobal` and reset `useAuthStore` in `beforeEach`; follow that rather than mocking the `api` module, since the envelope/refresh logic is the thing under test. + +## Generated files + +- `src/routeTree.gen.ts` — written by the router plugin on dev/build (or `pnpm generate-routes`). Never hand-edit; never resolve merge conflicts in it by hand, regenerate instead. +- `src/api/types.gen.ts` — `@ts-nocheck`, reference only. Scribe emits duplicate operationIds so it does not typecheck; authoritative shapes are hand-written in `src/api/types.ts`. Regenerating needs the sibling `../gig-platform` checkout to have run `ddev artisan api-docs:generate` first. + +## Theme + +Dark mode is a `dark` class on ``, set by an inline script in `index.html` before first paint (reads `localStorage.theme`, falls back to `prefers-color-scheme`) to avoid a flash. The toggle in `_authed.tsx` writes that same key. Any new theming must go through the class, not a React-held theme value — `next-themes` is installed but unused. + +## Chinese UI copy + +All user-facing strings are hardcoded 简体中文, matching the backend's localized `message` values. `i18next` / `react-i18next` are in `package.json` but nothing imports them — don't introduce `useTranslation` for one screen. + +## No linter + +There is no ESLint config and no lint script. `pnpm typecheck && pnpm test` is the whole gate, locally and in CI (`.github/workflows/ci.yml`, mirrored in `.gitea/workflows/ci.yml`). `tsconfig.json` runs `strict` plus `noUnusedLocals` / `noUnusedParameters`, so unused imports are type errors, not warnings. + # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e6d3cf5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Standalone admin dashboard for the **gig-platform** Laravel backend, consuming `/api/admin/v1/*`. React 19, TanStack Router (+ Query + Table), shadcn/ui on Radix, Tailwind v4, TypeScript, Vite. It is a **plain client-rendered SPA** — `index.html` → `main.tsx` → `RouterProvider`; `@tanstack/react-start` is not a dependency, so there is no SSR and no server functions. Package manager is **pnpm**. UI copy is hardcoded 简体中文 — `i18next`/`react-i18next` are in `package.json` but nothing imports them yet, so don't reach for translation hooks. + +The backend repo is expected as a sibling checkout at `../gig-platform` (see `pnpm gen:api`). + +## Commands + +| Command | Purpose | +| --- | --- | +| `pnpm dev` | dev server on :3000 | +| `pnpm build` / `pnpm preview` | production build / preview | +| `pnpm typecheck` | `tsc --noEmit` | +| `pnpm test` | vitest (all) | +| `pnpm test src/api/client.test.ts` | single test file | +| `pnpm generate-routes` | `tsr generate` — regenerate `routeTree.gen.ts` (the Vite plugin also does this on dev/build) | +| `pnpm gen:api` | regenerate `src/api/types.gen.ts` from `../gig-platform/storage/app/api-docs/admin/openapi.yaml` | +| `pnpm cf:deploy` | build + `wrangler deploy` | + +There is no lint script and no ESLint config — `typecheck` + `test` are the gate (and what CI runs, in `.github/workflows/ci.yml` and the mirrored `.gitea/workflows/ci.yml`). + +`pnpm gen:api` requires the backend to have generated its docs first: run `ddev artisan api-docs:generate` in `../gig-platform`. + +## Architecture + +### API layer (`src/api/`) + +`client.ts` is the only place that talks to the network. Everything else calls the thin `api.{get,post,put,patch,delete,upload,text}` helpers. + +- **Envelope**: the backend returns `{ success, data, message }`; paginated lists add `pagination` (`current_page`, `next_page`, `page_size`, `prev_page`, `total_count`, `total_pages`). Call sites type responses as `ApiResponse` / `PaginatedResponse` and read `.data`. +- **Errors**: non-2xx throws `ApiError(status, code, message, errors?)` built from the backend's `{ success: false, code, message, data.errors }` payload. Catch `ApiError`, don't inspect raw responses. +- **Auth**: Bearer token from the zustand store; on a 401 the client does a **single-flight** refresh (`POST /api/v1/auth/refresh-token` with `client_machine_name=admin-api-client` — note this is the *public* `v1` prefix, not `admin/v1`) and retries the request once, then clears the session. Login and refresh pass `anonymous: true` to opt out of this. +- **Headers**: `X-Language-Locale: zh-CN` on every request. A `FormData` body deliberately gets **no** `Content-Type` — the browser must set it so the multipart boundary survives; setting it by hand makes the server see an empty upload (422 instead of 202). `fetch` gives no upload progress, so upload UIs stay indeterminate. +- **Endpoint modules**: one file per backend domain in `src/api/modules/` (`studio.ts`, `exc.ts`, `categories.ts`, …), each with a `const BASE = '/api/admin/v1/{module}'` and exported `fetchX` / mutation functions. Add new endpoints there, not inline in components. +- **Types**: `src/api/types.ts` is hand-written and authoritative. `types.gen.ts` is `@ts-nocheck` reference-only — scribe emits duplicate operationIds so the generated file doesn't typecheck. + +### Auth & permissions (`src/auth/`) + +- `store.ts` — zustand + persist (localStorage) holding tokens, user, roles, permissions. `can(key)` / `useCan(key)` return true for anyone holding the key **or** the `super-admin` role. +- `permissions.ts` — the `PERM` catalog mirroring the backend's `Modules/{Module}/app/Permissions/{Module}Permissions.php`. Keys are `admin:{area}:{action}`; a few operator-only ones use the `auth:` prefix (role management, db-studio, robot tokens) precisely because the `admin` role is synced to `admin:%` only. **When the backend adds a permission, add it to `PERM` here** — routes and nav reference the constants, never raw strings. +- `requirePermission(PERM.X)` is the route `beforeLoad` guard. It redirects to the first accessible nav route only once a session is loaded; an empty store falls through and the API's 403 is the backstop. +- `GET /auth/me` refreshes `meta.roles` / `meta.permissions` on mount and window focus (`src/routes/_authed.tsx`); an account with neither is logged out with a toast. + +### Routing (`src/routes/`) + +File-based TanStack Router with `autoCodeSplitting`. `routeTree.gen.ts` is generated — never edit it. `_authed.tsx` is the authenticated shell (sidebar + header + ``); everything under `_authed/` is behind the session check. Public routes: `login.tsx`, `api-docs.tsx`. + +Adding a page means three coordinated edits: the route file (with its `requirePermission` guard), an entry in `NAV_GROUPS` in `src/components/layout/nav.ts` (with `permission`, so the sidebar self-filters), and the `PERM` key if it's new. + +### List pages + +`useListPage(key, fetcher, filters)` in `src/lib/list-page.ts` is the standard pattern: it owns `page` / `page_size` state, keys the query on the serialized filters, uses `keepPreviousData`, and snaps back to page 1 when filters change. Pair it with `` (`src/components/data-table/`) and ``. Follow this rather than hand-rolling `useQuery` for a table. + +### Layout of the rest + +- `src/components/ui/` — shadcn/ui primitives (`components.json` config); regenerate rather than hand-editing where possible +- `src/components/` — shared app-level pieces (`data-table/`, `page-header`, `status-pill`, `confirm-dialog`, `show-once-token-dialog` for one-time token reveals) +- `src/features/{domain}/` — domain-specific composed UI (detail sheets, edit sheets) pulled into route files +- Path aliases: both `@/*` and `#/*` map to `./src/*`; existing code consistently uses `@/` + +## Deployment + +Client-rendered Vite SPA served as static assets by a Cloudflare Worker (`wrangler.jsonc`, `not_found_handling: single-page-application`, so `dist/index.html` answers every client route). `cf:deploy` is just `vite build && wrangler deploy`. `VITE_API_BASE_URL` is **baked in at build time** — it is a CI variable, not a runtime setting. Pushes to `main` typecheck + test + build + deploy; `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` are repo secrets. + +## Notes + +- `AGENTS.md` holds gotchas and non-obvious conventions that complement this file; everything below its `intent-skills` markers is generated by TanStack Intent — edit only above them. +- Local backend defaults to `https://gig-platform.ddev.site` (`.env.example`). Seed a local admin from the backend repo with `ddev exec php artisan tinker storage/app/e2e-admin-seed.php` → `admin@gig.local / Admin@12345`. diff --git a/README.md b/README.md index d6265d0..d1af286 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,13 @@ Standalone admin dashboard for the gig-platform backend (`/api/admin/v1/*`). -React 19 · TanStack Start (SPA mode) · TanStack Query/Table · shadcn/ui · Tailwind v4 · TypeScript. +React 19 · TanStack Router · TanStack Query/Table · shadcn/ui · Tailwind v4 · TypeScript · Vite. + +It is a plain client-rendered SPA (`index.html` → `src/main.tsx` → `RouterProvider`). There is no +SSR and no server functions — `@tanstack/react-start` is not used. + +> Working on this repo with an AI agent? `CLAUDE.md` has the architecture and conventions, +> `AGENTS.md` the gotchas. ## Quick start @@ -21,6 +27,9 @@ ddev exec php artisan tinker storage/app/e2e-admin-seed.php # → admin@gig.local / Admin@12345 ``` +An account that authenticates but holds no admin roles or permissions is treated as +logged out — the app clears the session and returns you to the login screen. + ## Scripts | Command | Purpose | @@ -28,8 +37,14 @@ ddev exec php artisan tinker storage/app/e2e-admin-seed.php | `pnpm dev` | dev server on :3000 | | `pnpm build` / `pnpm preview` | production build / preview | | `pnpm typecheck` | `tsc --noEmit` | -| `pnpm test` | vitest (API client envelope/refresh/error tests) | +| `pnpm test` | vitest — API client (envelope / refresh / errors) and auth store | +| `pnpm generate-routes` | `tsr generate` — rewrite `src/routeTree.gen.ts` (dev/build do this automatically) | | `pnpm gen:api` | regenerate `src/api/types.gen.ts` from the backend's private admin OpenAPI spec (`../gig-platform/storage/app/api-docs/admin/openapi.yaml`; run `ddev artisan api-docs:generate` there first) | +| `pnpm cf:build` / `pnpm cf:deploy` | Cloudflare build / build + `wrangler deploy` | + +There is no ESLint config and no lint script: `pnpm typecheck && pnpm test` is the gate, and it +is what CI runs. `tsconfig.json` is `strict` with `noUnusedLocals`/`noUnusedParameters`, so an +unused import fails the typecheck. ## Uploads @@ -49,7 +64,7 @@ Cloudflare returns `dist/index.html` for every client route. `CLOUDFLARE_ACCOUNT_ID` in your env): ```bash -pnpm cf:deploy # vite build → copy shell to index.html → wrangler deploy +pnpm cf:deploy # vite build → wrangler deploy ``` **CI/CD** — every push & PR runs typecheck + test + build; pushes to `main` @@ -73,26 +88,44 @@ Configure these on the repo before the first deploy: enabled and an `act_runner` registered with the `ubuntu-latest` label (use an image such as `catthehacker/ubuntu:act-22.04`). +`VITE_API_BASE_URL` is read at build time, so pointing a deployment at a different +backend means rebuilding, not changing a runtime setting. + ## Architecture notes -- **Auth**: Bearer tokens in localStorage (zustand persist). `src/api/client.ts` - unwraps the `{success, data, message}` envelope, sends `X-Language-Locale`, - and does a single-flight refresh-then-retry on 401 +- **Auth**: Bearer tokens in localStorage under `gig-admin-auth` (zustand persist). + `src/api/client.ts` unwraps the `{success, data, message}` envelope, sends + `X-Language-Locale`, and does a single-flight refresh-then-retry on 401 (`POST /api/v1/auth/refresh-token`, `client_machine_name=admin-api-client`). - **Permissions**: `GET /auth/me` returns `meta.roles` / `meta.permissions`; - nav items and routes gate on them (`super-admin` passes everything). + nav items and routes gate on them (`super-admin` passes everything). The key + catalog lives in `src/auth/permissions.ts` and mirrors the backend's + `Modules/{Module}/app/Permissions/`. - **Lists**: `useListPage` + `` bind to the backend's `pagination {current_page, page_size, …}` shape (`page`/`page_size` params). +- **Errors**: `handleApiError` (`src/lib/errors.ts`) is the single mutation error + path — 422 field errors land on the form, everything else becomes a toast. - **Generated types** (`types.gen.ts`) are reference-only (`@ts-nocheck` — scribe emits duplicate operationIds); hand-written shapes live in `src/api/types.ts`. +- **UI copy** is hardcoded 简体中文. `i18next` and `next-themes` are installed but + unused; theming is a `dark` class set by an inline script in `index.html`. -## Features (milestone 1) +## Features -- 分类管理:分类树 CRUD、待审核队列(通过/驳回/合并到已有分类) -- 内容工作室:机器人作者(暂停/恢复)、系列、文章、子话题、分类简报、风格预设、 +Sidebar groups (each entry hidden unless the account holds its permission): + +- **分类管理** — 分类树 CRUD、待审核队列(通过/驳回/合并到已有分类) +- **内容工作室** — 机器人作者、文章系列、文章、子话题、分类简报、风格预设、 Agent 接入令牌(签发一次性展示 / 吊销) -- 用户与系统:SID 保留规则、幸运号码、查询/分配;语言设置;推送设备;机器人会话令牌 +- **专长交易** — 需求、派单、订单、服务商监控 +- **Dimzou 内容** — 文档监控、发布监控、翻译 +- **简历导入** — 批量导入任务 +- **内容审核** — 被举报评论、被举报事件 +- **用户与系统** — 用户列表、角色管理、用户角色、SID 管理(保留规则、幸运号码、 + 查询/分配)、语言设置、推送设备、机器人会话令牌 +- **开发工具** — API 文档、数据库管理台 -Next milestones: moderation reports (comment / file-x), dashboard stats -(needs backend endpoints). +`/` has no dashboard: it redirects to the first nav route the account can see, and +renders a "未分配权限" card only when there is none. Dashboard stats are still pending +backend endpoints.