diff --git a/docker/admin.yml b/docker/admin.yml index 1f7ab820af5..8b21a1d3ffd 100644 --- a/docker/admin.yml +++ b/docker/admin.yml @@ -4,7 +4,7 @@ services: # 后台应用 tailchat-admin: build: - context: ../ + context: . image: tailchat restart: unless-stopped env_file: docker-compose.env @@ -14,7 +14,26 @@ services: labels: - "traefik.enable=true" - "traefik.http.routers.admin.rule=PathPrefix(`/admin`)" + - "traefik.http.routers.admin.priority=50" - "traefik.http.services.admin.loadbalancer.server.port=3000" networks: - internal command: pnpm start:admin + + tailchat-admin-next: + build: + context: . + image: tailchat + restart: unless-stopped + env_file: docker-compose.env + depends_on: + - mongo + - redis + labels: + - "traefik.enable=true" + - "traefik.http.routers.admin-next.rule=PathPrefix(`/admin-next`)" + - "traefik.http.routers.admin-next.priority=100" + - "traefik.http.services.admin-next.loadbalancer.server.port=3100" + networks: + - internal + command: pnpm start:admin-next diff --git a/docs/superpowers/plans/2026-08-21-tailchat-admin-next.md b/docs/superpowers/plans/2026-08-21-tailchat-admin-next.md new file mode 100644 index 00000000000..e8f54fb38ea --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-tailchat-admin-next.md @@ -0,0 +1,283 @@ +# Tailchat Admin Next Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a complete bilingual, independently runnable replacement for the legacy Tailchat Admin under `server/admin-next` without Tushan. + +**Architecture:** Copy the legacy Express/Moleculer admin backend into a sibling package with isolated paths, port, and JWT platform. Build the client with React, native History and fetch APIs, Arco Design themed through local CSS tokens, Recharts, a shared resource-table/form layer, and the existing ByteMD editor. + +**Tech Stack:** Node.js 18, pnpm 8.15.8, TypeScript 4.9, React 18, Vite 4, Express 4, Arco Design 2.51, Recharts 2.7, native Node test runner, ByteMD. + +**Spec:** `docs/superpowers/specs/2026-08-21-tailchat-admin-next-design.md` + +## Global Constraints + +- Keep `server/admin` unchanged and runnable throughout this work. +- Use `/admin-next/`, `/admin-next/api`, `ADMIN_NEXT_PORT` defaulting to `3100`, JWT platform `admin-next`, and storage key `tailchat:admin-next:auth`. +- Copy the complete legacy backend; do not share runtime backend modules with `server/admin`. +- Do not add Tushan or React Router; keep Arco behind shared controls and Recharts behind chart wrappers. +- Preserve Chinese and English for every user-visible string. +- Use only `tailchat-logo.png` from the Open Design project. +- Do not change root admin scripts, Docker, release packaging, CI routing, deployment, or production traffic. +- Do not commit, push, or create a pull request; the user has not requested delivery actions. + +## File map + +- `pnpm-workspace.yaml`: add the independent workspace package. +- `server/admin-next/package.json`, `tsconfig*.json`, `vite.config.ts`, `nodemon.json`, `index.html`: package runtime and build configuration. +- `server/admin-next/public/tailchat-logo.svg`: approved PNG bytes embedded in a text SVG container. +- `server/admin-next/src/server/**`: complete copied legacy backend with independent path, port, and token platform. +- `server/admin-next/src/client/core.ts`: tested route, auth, query, nested-value, CSV, and chart helpers. +- `server/admin-next/src/client/core.test.ts`: native Node behavior tests. +- `server/admin-next/src/client/auth.tsx`: login state and protected-session context. +- `server/admin-next/src/client/api.ts`: authenticated fetch and resource operations. +- `server/admin-next/src/client/i18n.tsx`: bilingual dictionary, lookup, persistence, and context. +- `server/admin-next/src/client/icons.tsx`: reusable monoline SVG icons. +- `server/admin-next/src/client/components.tsx`: shell, controls, tables, forms, charts, modal, toast, and state views. +- `server/admin-next/src/client/resources.ts`: resource schemas and field behavior. +- `server/admin-next/src/client/pages/Overview.tsx`: dashboard and analytics. +- `server/admin-next/src/client/pages/Resources.tsx`: generic CRUD pages and user/group/file custom actions. +- `server/admin-next/src/client/pages/Infrastructure.tsx`: network, Socket.IO, and cache. +- `server/admin-next/src/client/pages/System.tsx`: notification editor and system settings. +- `server/admin-next/src/client/App.tsx`, `main.tsx`, `styles.css`, `vite-env.d.ts`: application entry, route composition, and approved responsive visual system. + +--- + +### Task 1: Establish the independent package and copied backend + +**Files:** +- Create: `server/admin-next/package.json` +- Create: `server/admin-next/tsconfig.json` +- Create: `server/admin-next/tsconfig.server.json` +- Create: `server/admin-next/vite.config.ts` +- Create: `server/admin-next/nodemon.json` +- Create: `server/admin-next/index.html` +- Create: `server/admin-next/src/server/**` +- Create: `server/admin-next/public/tailchat-logo.svg` +- Modify: `pnpm-workspace.yaml` + +**Interfaces:** +- Consumes: existing `server/admin/src/server/**`, server models, discover plugin model, and `tailchat-server-sdk`. +- Produces: a server at `http://localhost:${ADMIN_NEXT_PORT:-3100}/admin-next/` with API prefix `/admin-next/api`. + +- [ ] **Step 1: Add package configuration and workspace ownership** + +Use the legacy dependency versions but remove `tushan`, `axios`, and `@loadable/component`. Add scripts `dev`, `start`, `test`, `check:type`, `build:client`, `build:server`, and `build`; point production start to `dist/admin-next/src/server/index.js`; set Vite base to `/admin-next/`. + +- [ ] **Step 2: Copy the complete backend and isolate runtime constants** + +Copy every file below `server/admin/src/server`. Change the Express mount to `/admin-next/api`, port lookup to `ADMIN_NEXT_PORT || 3100`, browser URL to `/admin-next/`, and both JWT sign/verify platform values to `admin-next`. Do not change resource behavior. + +- [ ] **Step 3: Verify the copied server compiles** + +Run: `pnpm --dir server/admin-next build:server` +Expected: exit 0 and output under `server/admin-next/dist/admin-next/src/server`. + +### Task 2: Build and test the client core + +**Files:** +- Create: `server/admin-next/src/client/core.test.ts` +- Create: `server/admin-next/src/client/core.ts` + +**Interfaces:** +- Consumes: browser pathname, stored auth JSON, resource list options, arbitrary record values, and chart dimensions. +- Produces: `normalizeRoute(pathname): RouteId`, `readAuth(raw, now): AuthSession | null`, `buildResourceQuery(options): string`, `getValue(record, path): unknown`, `toCSV(rows, columns): string`, and `linePoints(values, width, height): string`. + +- [ ] **Step 1: Write failing native Node tests** + +Create tests using `node:test` and `node:assert/strict` that assert: + +```ts +assert.equal(normalizeRoute('/admin-next/users/'), 'users'); +assert.equal(normalizeRoute('/admin-next/not-real'), 'dashboard'); +assert.equal(readAuth(JSON.stringify({ token: 't', username: 'a', expiredAt: 100 }), 99)?.token, 't'); +assert.equal(readAuth(JSON.stringify({ token: 't', username: 'a', expiredAt: 100 }), 100), null); +assert.equal(buildResourceQuery({ page: 2, perPage: 20, sort: 'createdAt', order: 'DESC', search: 'moon' }), '_sort=createdAt&_order=DESC&_start=20&_end=40&q=moon'); +assert.equal(toCSV([{ name: 'a,b', note: 'say "hi"' }], [{ key: 'name', label: 'Name' }, { key: 'note', label: 'Note' }]), 'Name,Note\r\n"a,b","say ""hi"""'); +``` + +- [ ] **Step 2: Run the tests and confirm RED** + +Run: `pnpm --dir server/admin-next test` +Expected: FAIL because `./core` does not exist. + +- [ ] **Step 3: Implement the minimum core helpers** + +Use `URLSearchParams`, `JSON.parse`, dot-path reduction, RFC 4180-compatible field quoting, and direct SVG point scaling. Keep route IDs in one readonly set shared by `normalizeRoute` and the application route table. + +- [ ] **Step 4: Run the tests and confirm GREEN** + +Run: `pnpm --dir server/admin-next test` +Expected: all tests pass with no warnings. + +### Task 3: Implement authentication, bilingual shell, and navigation + +**Files:** +- Create: `server/admin-next/src/client/auth.tsx` +- Create: `server/admin-next/src/client/api.ts` +- Create: `server/admin-next/src/client/i18n.tsx` +- Create: `server/admin-next/src/client/icons.tsx` +- Create: `server/admin-next/src/client/components.tsx` +- Create: `server/admin-next/src/client/App.tsx` +- Create: `server/admin-next/src/client/main.tsx` +- Create: `server/admin-next/src/client/styles.css` +- Create: `server/admin-next/src/client/vite-env.d.ts` + +**Interfaces:** +- Consumes: core helpers from Task 2 and API prefix `/admin-next/api`. +- Produces: `useAuth()`, `api(path, init)`, `useI18n()`, `navigate(route)`, `AppShell`, `Modal`, `ToastProvider`, form controls, data-state components, and authenticated route rendering. + +- [ ] **Step 1: Add a failing dictionary and request-contract test** + +Extend `core.test.ts` to require every route ID to have non-empty Chinese and English labels and to require `requestHeaders('token', false)` to include `Authorization: Bearer token` without forcing a multipart content type. + +- [ ] **Step 2: Run the focused test and confirm RED** + +Run: `pnpm --dir server/admin-next test` +Expected: FAIL because the dictionary and request-header helpers are absent. + +- [ ] **Step 3: Implement login and the approved application shell** + +Implement credential login, local expiry enforcement, centralized 401 logout, persisted language selection, native History navigation, 260px desktop sidebar, 62px top bar, mobile drawer, scrim, language switch, account logout, command palette, visible keyboard focus, Escape handling, and focus restoration. Copy only the approved logo into `public`. + +- [ ] **Step 4: Complete the request and translation helpers** + +Return parsed JSON for JSON responses, plain text for non-JSON failures, preserve `FormData` boundaries, expose list totals, and include every navigation, action, field, validation, empty, loading, and error string in both languages. + +- [ ] **Step 5: Run tests and client type checking** + +Run: `pnpm --dir server/admin-next test && pnpm --dir server/admin-next check:type` +Expected: exit 0. + +### Task 4: Implement real overview and analytics pages + +**Files:** +- Modify: `server/admin-next/src/client/core.test.ts` +- Modify: `server/admin-next/src/client/core.ts` +- Modify: `server/admin-next/src/client/components.tsx` +- Create: `server/admin-next/src/client/pages/Overview.tsx` +- Modify: `server/admin-next/src/client/App.tsx` + +**Interfaces:** +- Consumes: `api`, list totals, `linePoints`, visual primitives, and translations. +- Produces: `DashboardPage`, `AnalyticsPage`, `LineChart`, and `BarChart` backed only by current API data. + +- [ ] **Step 1: Add failing geometry edge-case tests** + +Assert that `linePoints([], 100, 40)` returns an empty string, a one-point series is centered, and a flat series remains finite without `NaN` or `Infinity`. + +- [ ] **Step 2: Run tests and confirm RED** + +Run: `pnpm --dir server/admin-next test` +Expected: FAIL on the unimplemented geometry behavior. + +- [ ] **Step 3: Implement minimal responsive SVG charts and pages** + +Load the four real totals, both 14-day summaries, and all four analytics endpoints. Render skeleton, empty, error, and loaded states. Use SVG `polyline`, gradients, axes, bars, and accessible text; do not synthesize deltas or sample points. + +- [ ] **Step 4: Run tests and production client build** + +Run: `pnpm --dir server/admin-next test && pnpm --dir server/admin-next build:client` +Expected: exit 0 with no missing route or asset errors. + +### Task 5: Implement generic resource CRUD and custom admin actions + +**Files:** +- Modify: `server/admin-next/src/client/core.test.ts` +- Modify: `server/admin-next/src/client/core.ts` +- Create: `server/admin-next/src/client/resources.ts` +- Create: `server/admin-next/src/client/pages/Resources.tsx` +- Modify: `server/admin-next/src/client/App.tsx` + +**Interfaces:** +- Consumes: JSON-server list protocol, generic API mutations, resource schemas, modal, table, form controls, and toast. +- Produces: users, groups, login logs, messages, files, mail, and discover pages with exact capability flags and custom user/group/file actions. + +- [ ] **Step 1: Add failing record and export tests** + +Assert `getValue({ members: ['a'], metaData: { 'content-type': 'x' } }, 'members.length') === 1`, preserve literal keys such as `metaData.content-type`, and verify CSV converts booleans, arrays, objects, null, commas, quotes, and line breaks deterministically. + +- [ ] **Step 2: Run tests and confirm RED** + +Run: `pnpm --dir server/admin-next test` +Expected: FAIL on the new nested-value and export cases. + +- [ ] **Step 3: Implement the resource schema and shared list UI** + +Define exact legacy fields and create/edit visibility. Implement search, usage and chat-only filters, sort, page size, pagination, refresh, row selection, detail, create, edit, confirmed delete, confirmed batch delete, and filtered all-page CSV export. Keep table minimum widths and horizontal scrolling. + +- [ ] **Step 4: Implement real custom actions** + +Users: reset password with the legacy hash, ban, and unban. Groups: create through the existing endpoint and add a selected user through `group.addMember`. Files: fetch total storage and preserve the `meta=onlyChat` query. Disable actions while pending and reload only after success. + +- [ ] **Step 5: Run behavior, type, and client build checks** + +Run: `pnpm --dir server/admin-next test && pnpm --dir server/admin-next check:type && pnpm --dir server/admin-next build:client` +Expected: exit 0. + +### Task 6: Implement infrastructure, notification, and system settings + +**Files:** +- Modify: `server/admin-next/src/client/core.test.ts` +- Modify: `server/admin-next/src/client/core.ts` +- Create: `server/admin-next/src/client/pages/Infrastructure.tsx` +- Create: `server/admin-next/src/client/pages/System.tsx` +- Modify: `server/admin-next/src/client/App.tsx` + +**Interfaces:** +- Consumes: network, cache, callAction, config, upload, and notify endpoints plus ByteMD. +- Produces: network registry and ping UI, Socket.IO instructions, confirmed cache cleaning, validated Markdown notifications, and editable system settings. + +- [ ] **Step 1: Add failing validation tests** + +Add `validateNotification(scope, users, title, content)` tests that reject empty title/content and specified scope without users, and accept a complete all-user or selected-user payload. + +- [ ] **Step 2: Run tests and confirm RED** + +Run: `pnpm --dir server/admin-next test` +Expected: FAIL because notification validation is absent. + +- [ ] **Step 3: Implement infrastructure pages** + +Render actual network nodes, registry lists, and ping latency/status; derive the Socket.IO URL from `window.location`; open the existing `/socketio/admin/` destination; and confirm both cache targets before posting to `/cache/clean`. + +- [ ] **Step 4: Implement notification and system pages** + +Use ByteMD for Markdown content, searchable real user selection for specified recipients, and server-confirmed counts. Read all client policy values; patch server name on explicit save; upload or remove server entry image; and save an enabled or disabled announcement with text and optional link. + +- [ ] **Step 5: Run the complete package build** + +Run: `pnpm --dir server/admin-next test && pnpm --dir server/admin-next build` +Expected: client and server builds both exit 0. + +### Task 7: Verify coexistence and responsive visual behavior + +**Files:** +- Modify only files already in scope when a verification failure identifies a defect. + +**Interfaces:** +- Consumes: completed `server/admin-next` package and unchanged `server/admin` package. +- Produces: reproducible build, behavior, and visual acceptance evidence. + +- [ ] **Step 1: Build both admin packages** + +Run: `pnpm --dir server/admin-next test && pnpm --dir server/admin-next build && pnpm build:admin` +Expected: all commands exit 0. + +- [ ] **Step 2: Run the application when services are available** + +Run: `pnpm --dir server/admin-next dev` +Expected: the new UI is reachable at `http://localhost:3100/admin-next/`; the legacy admin can still use port 3000. If MongoDB or the transporter is unavailable, record the exact blocker and continue with static preview checks. + +- [ ] **Step 3: Inspect desktop, tablet, and mobile states** + +Check 1440x900, 1024x768, and 390x844. Verify login, authenticated shell where available, sidebar drawer and scrim, table scrolling, unsqueezed cards, command palette keyboard behavior, destructive confirmation, focus visibility, and Chinese/English switching. + +- [ ] **Step 4: Run final repository checks** + +Run: `git diff --check && git status --short --branch --untracked-files=all` +Expected: no whitespace errors; only the design, plan, workspace entry, and `server/admin-next` files are changed. + +- [ ] **Step 5: Report the handoff** + +Report changed files, exact successful commands, any environment-dependent skipped checks, visual findings, and the separate run and preview commands. State explicitly that no cutover, commit, push, or deployment occurred. diff --git a/docs/superpowers/specs/2026-08-21-tailchat-admin-next-design.md b/docs/superpowers/specs/2026-08-21-tailchat-admin-next-design.md new file mode 100644 index 00000000000..16d9f8f1d0f --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-tailchat-admin-next-design.md @@ -0,0 +1,191 @@ +# Tailchat Admin Next Design + +Date: 2026-08-21 +Status: Approved + +## Objective + +Build a complete replacement for the existing `server/admin` application in +`server/admin-next`. The replacement must run beside the legacy application, +cover its real administrative capabilities, remove Tushan, preserve Chinese +and English, and reproduce the approved Open Design project as a runnable React +application. + +The legacy application remains unchanged and remains the production entry +until `admin-next` has been accepted separately. + +## Source of truth + +Behavior comes from the current `server/admin` client and server. Visual design +comes from version 5 of Open Design project +`f69073fe-c560-449c-a18d-4b668aaf9e00`, especially +`tailchat-admin.html` and `tailchat-logo.png`. + +The implementation must preserve the design's two explicit corrections: + +- the mobile sidebar becomes an accessible drawer instead of disappearing; +- cards and tables retain minimum widths and scroll instead of being squeezed. + +## Coexistence boundary + +`server/admin-next` is a separate Vite, React, Express, and TypeScript package. +It uses these independent runtime boundaries: + +- browser base path: `/admin-next/`; +- API prefix: `/admin-next/api`; +- development and production port: `ADMIN_NEXT_PORT`, default `3100`; +- browser authentication key: `tailchat:admin-next:auth`; +- JWT platform: `admin-next`. + +It reuses the deployment's existing `ADMIN_USER`, `ADMIN_PASS`, `SECRET`, +`MONGO_URL`, and `TRANSPORTER` settings. The complete existing admin backend is +copied into the new package so acceptance work cannot change the legacy admin +at runtime. The copied backend changes only the path, port, platform identity, +package output path, and branding required for independent operation. + +The pnpm workspace gains `server/admin-next`. Root admin scripts, Docker, +release packaging, CI path filters, and production routing are deliberately not +switched in this phase. + +## Frontend architecture + +The client uses React 18, the browser History API, native `fetch`, CSS, Arco +Design, and Recharts. It does not use Tushan or React Router. Arco is consumed +through the existing common-control wrappers and inherits the approved dark +palette from local CSS tokens. The already-installed ByteMD packages remain +for the system notification editor. Existing small runtime libraries may be +retained where they materially reduce code, such as `dayjs` and `filesize`. + +The application is divided into four practical layers: + +1. authentication and API helpers; +2. application shell, navigation, bilingual strings, and common controls; +3. reusable resource table and record form behavior; +4. overview, infrastructure, notification, and system-settings pages. + +Routes are represented below `/admin-next/`. Direct loads, browser back and +forward, command-palette navigation, and sidebar navigation resolve through the +same route table. + +## Authentication and request flow + +The login form posts the configured credentials to +`/admin-next/api/login`. A successful response stores username, token, and +expiry locally. Expired state is rejected before a protected request. Every +protected request sends `Authorization: Bearer `. Any HTTP 401 clears +the stored state and returns to login. + +The API helper supports JSON, `FormData`, error-text extraction, list totals +from `X-Total-Count`, and JSON-server-compatible list parameters. Resource +lists use `_sort`, `_order`, `_start`, `_end`, `q`, field filters, and the +existing file `meta=onlyChat` behavior. Mutations refresh confirmed server +state; there are no optimistic destructive writes. + +Destructive actions require confirmation, disable controls while submitting, +surface server failures, and only show success after the API resolves. CSV +export follows the active filters and loads every result page through the +existing list API before producing the file. + +## Functional parity + +The new UI exposes these real capabilities: + +| Area | Capability | +| --- | --- | +| Login | Admin credential login, two-hour JWT, expiry handling, logout | +| Dashboard | Real totals for users, groups, files, messages; 14-day user and message summaries; project links | +| Analytics | Seven-day active groups and users; largest groups; top file-storage users | +| Users | Search, pagination, sorting, detail, create, edit, delete, refresh, filtered CSV export, reset password, ban, unban | +| Groups | Search, pagination, sorting, detail, create, edit, delete, refresh, filtered CSV export, add member | +| Login logs | Search, pagination, sorting, detail, refresh, filtered CSV export | +| Messages | Search, pagination, sorting, detail, edit, delete, batch delete, refresh, filtered CSV export | +| Files | Search, usage filter, chat-only filter, total storage, sorting, detail, delete, batch delete, refresh | +| Mail | Pagination, sorting, detail, refresh | +| Discover | Pagination, sorting, detail, create, delete, refresh | +| Network | Real node, service, action, and event registries plus ping results | +| Socket.IO | Current server URL, connection instructions, and link to the real Socket.IO admin UI | +| Cache | Clear client-config cache or all cache with confirmation and server response | +| Notification | Send Markdown inbox notifications to all permanent users or selected users | +| System | Read client policy values; edit server name, entry image, and announcement | + +Reset password preserves the legacy behavior and hash for the documented +temporary password `123456789`. Final authorization and all mutations remain +server-side. + +## Visual system + +The UI faithfully translates the approved design rather than creating another +generic dashboard: + +- background `#0b0e14`, panel `#12151d`, raised surfaces `#171b24`, + `#1b202b`, and `#222836`; +- primary blue `rgb(24, 144, 255)`, success `#3ba55d`, warning `#faa61a`, + and danger `#ff4d4f`; +- 12px panel radii, 8px control radii, subtle borders, soft radial background + light, and blue active-navigation rail; +- Inter for body text, Space Grotesk for display text, JetBrains Mono for + technical values, with system fallbacks; +- the supplied local Tailchat cat logo is the only project image copied into + the runtime. + +Desktop uses a fixed 260px sidebar and a 62px translucent top bar. Content is +centered up to 1240px. At 1024px, KPI cards use two columns and chart grids use +one column. At 940px, the sidebar becomes a focus-managed drawer with a scrim +and hamburger control. At 560px, KPI cards use one column. Tables have explicit +minimum widths and horizontal scrolling at every breakpoint. + +The top bar contains command search, language selection, and the account menu. +The prototype's nonfunctional notification bell and sample metrics are not +implemented. The command palette opens with Command-K or Control-K, supports +keyboard selection, and navigates only to real pages. + +Reusable controls are limited to the repeated needs of this application: +application shell, sidebar, top bar, page header, statistic card, data table, +filters, form controls, modal or drawer, status badge, line and bar charts, +toast, loading state, empty state, and error state. + +Transitions last 130-260ms. `prefers-reduced-motion` disables nonessential +motion. Keyboard focus is visible; dialogs close on Escape, trap focus, and +restore focus; the drawer closes after navigation. + +## Internationalization + +Every user-visible core string has Chinese and English forms. The initial +language follows a stored choice, then browser language, then English. The +top-bar switch persists the choice without reloading. Backend field names and +technical identifiers remain unchanged. + +## Error, empty, and loading behavior + +Each page distinguishes loading, empty data, API error, and loaded data. Page +requests that become stale are ignored or aborted. Forms preserve entered +values after server failures. Tables keep their previous page visible while a +refresh is in progress. Toasts report completed actions and actionable errors, +not speculative success. + +## Verification and acceptance + +Acceptance requires: + +- a native Node test covering route normalization, authentication expiry, + resource query construction, CSV escaping, and other extracted behavior; +- successful client type checking and Vite production build; +- successful copied-server TypeScript build; +- successful legacy `pnpm build:admin`, proving coexistence did not break the + old package; +- login and authenticated-page runtime checks when MongoDB and the Moleculer + transporter are available; +- visual checks at desktop, tablet, and mobile widths, including drawer, + minimum card widths, table scrolling, command palette, login, and both + languages; +- `git diff --check` and a final full untracked-file status review. + +Environment-dependent checks must be reported as blocked rather than claimed +as passing. + +## Non-goals + +This phase does not delete or alter `server/admin`, change production routing, +replace root admin commands, update Docker images, deploy, commit, push, or cut +traffic over to the new UI. Those steps belong to a later acceptance and +cutover change. diff --git a/package.json b/package.json index e1e669954de..9404d9c240c 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "dev:admin": "cd server/admin && pnpm dev", "start:service": "cd server && pnpm start:service", "start:admin": "cd server/admin && pnpm start", - "build": "concurrently npm:build:web npm:build:server npm:build:admin && cp -r client/web/dist/* server/dist/public", + "start:admin-next": "cd server/admin-next && pnpm start", + "build": "concurrently npm:build:web npm:build:server npm:build:admin npm:build:admin-next && cp -r client/web/dist/* server/dist/public", "build:web": "cd client/web && pnpm build", "build:server": "cd server && pnpm build && echo \"Install server side plugin:\" && pnpm run plugin:install com.msgbyte.tasks com.msgbyte.linkmeta com.msgbyte.github com.msgbyte.simplenotify com.msgbyte.topic com.msgbyte.agora com.msgbyte.wxpusher com.msgbyte.welcome com.msgbyte.iam com.msgbyte.discover com.msgbyte.livekit && mkdir -p ./dist/public && cp -r ./public/plugins ./dist/public && cp ./public/registry-be.json ./dist/public", "build:admin": "cd server/admin && pnpm build", + "build:admin-next": "cd server/admin-next && pnpm build", "check:type": "concurrently npm:check:type:client npm:check:type:server", "check:type:client": "cd client/web && tsc --noEmit", "check:type:server": "cd server && tsc --noEmit", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e366ae02e9..4fc5f04ae00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1702,6 +1702,100 @@ importers: specifier: ^4.2.0 version: 4.2.0(@types/node@18.11.9) + server/admin-next: + dependencies: + '@arco-design/web-react': + specifier: 2.51.0 + version: 2.51.0(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0) + '@bytemd/plugin-gfm': + specifier: ^1.21.0 + version: 1.21.0(bytemd@1.21.0) + '@bytemd/react': + specifier: ^1.21.0 + version: 1.21.0(react@18.2.0) + '@fastify/busboy': + specifier: ^1.1.0 + version: 1.1.0 + bytemd: + specifier: ^1.21.0 + version: 1.21.0 + compression: + specifier: ^1.7.4 + version: 1.7.4 + dayjs: + specifier: ^1.11.7 + version: 1.11.7 + express: + specifier: ^4.18.2 + version: 4.18.2 + filesize: + specifier: ^8.0.7 + version: 8.0.7 + jsonwebtoken: + specifier: ^8.5.1 + version: 8.5.1 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + md5: + specifier: ^2.3.0 + version: 2.3.0 + morgan: + specifier: ^1.10.0 + version: 1.10.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + recharts: + specifier: 2.7.2 + version: 2.7.2(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + tailchat-server-sdk: + specifier: workspace:^ + version: link:../packages/sdk + vite-express: + specifier: 0.8.0 + version: 0.8.0(patch_hash=u6touqej4dt3zxnslnszarl7vq)(express@4.18.2)(vite@4.2.0) + devDependencies: + '@types/compression': + specifier: ^1.7.2 + version: 1.7.2 + '@types/express': + specifier: ^4.17.15 + version: 4.17.17 + '@types/md5': + specifier: ^2.3.2 + version: 2.3.2 + '@types/morgan': + specifier: ^1.9.4 + version: 1.9.4 + '@types/react': + specifier: 18.0.20 + version: 18.0.20 + '@types/react-dom': + specifier: ^18.0.11 + version: 18.0.11 + '@vitejs/plugin-react': + specifier: ^3.1.0 + version: 3.1.0(vite@4.2.0) + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + nodemon: + specifier: ^2.0.22 + version: 2.0.22 + ts-node: + specifier: 10.9.1 + version: 10.9.1(@types/node@18.11.9)(typescript@4.9.4) + typescript: + specifier: 4.9.4 + version: 4.9.4 + vite: + specifier: ^4.2.0 + version: 4.2.0(@types/node@18.11.9) + server/packages/openapi-generator: dependencies: '@apidevtools/swagger-parser': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 07fed42c217..dbd98f892a9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: - 'client/packages/**' - 'server' - 'server/admin' + - 'server/admin-next' - 'server/packages/**' - 'server/plugins/**' - 'server/test/demo/**' diff --git a/server/admin-next/index.html b/server/admin-next/index.html new file mode 100644 index 00000000000..b3dbc12cc7b --- /dev/null +++ b/server/admin-next/index.html @@ -0,0 +1,13 @@ + + + + + + + Tailchat Admin Next + + +
+ + + diff --git a/server/admin-next/nodemon.json b/server/admin-next/nodemon.json new file mode 100644 index 00000000000..4fecdee34fa --- /dev/null +++ b/server/admin-next/nodemon.json @@ -0,0 +1,7 @@ +{ + "verbose": true, + "watch": ["./src/server"], + "ext": "ts", + "delay": 1000, + "exec": "ts-node ./src/server/index.ts" +} diff --git a/server/admin-next/package.json b/server/admin-next/package.json new file mode 100644 index 00000000000..afe93bc52e8 --- /dev/null +++ b/server/admin-next/package.json @@ -0,0 +1,50 @@ +{ + "name": "tailchat-admin-next", + "private": true, + "version": "0.0.0", + "author": "moonrailgun", + "scripts": { + "dev": "nodemon", + "start": "cross-env NODE_ENV=production node dist/admin-next/src/server/index.js", + "test": "node --test -r ts-node/register src/client/*.test.ts src/client/*.test.tsx", + "check:type": "tsc --noEmit", + "build": "pnpm build:client && pnpm build:server", + "build:client": "vite build", + "build:server": "tsc -p tsconfig.server.json", + "preview": "vite preview" + }, + "dependencies": { + "@arco-design/web-react": "2.51.0", + "@bytemd/plugin-gfm": "^1.21.0", + "@bytemd/react": "^1.21.0", + "@fastify/busboy": "^1.1.0", + "bytemd": "^1.21.0", + "compression": "^1.7.4", + "dayjs": "^1.11.7", + "express": "^4.18.2", + "filesize": "^8.0.7", + "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.21", + "md5": "^2.3.0", + "morgan": "^1.10.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "2.7.2", + "tailchat-server-sdk": "workspace:^", + "vite-express": "0.8.0" + }, + "devDependencies": { + "@types/compression": "^1.7.2", + "@types/express": "^4.17.15", + "@types/md5": "^2.3.2", + "@types/morgan": "^1.9.4", + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@vitejs/plugin-react": "^3.1.0", + "cross-env": "^7.0.3", + "nodemon": "^2.0.22", + "ts-node": "^10.9.1", + "typescript": "^4.9.3", + "vite": "^4.2.0" + } +} diff --git a/server/admin-next/public/tailchat-logo.svg b/server/admin-next/public/tailchat-logo.svg new file mode 100644 index 00000000000..22eef0f8f94 --- /dev/null +++ b/server/admin-next/public/tailchat-logo.svg @@ -0,0 +1 @@ + diff --git a/server/admin-next/src/client/App.tsx b/server/admin-next/src/client/App.tsx new file mode 100644 index 00000000000..10d6b161fcd --- /dev/null +++ b/server/admin-next/src/client/App.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { Alert, Input } from '@arco-design/web-react'; +import { useAuth } from './auth'; +import { AppShell, Button } from './components'; +import { normalizeRoute, type RouteId } from './core'; +import { Icon } from './icons'; +import { useI18n } from './i18n'; +import { DashboardPage, specialPages } from './pages'; +import { ResourcePage } from './resources'; + +const resourceRoutes = new Set([ + 'users', + 'login-logs', + 'messages', + 'groups', + 'files', + 'mail', + 'discover', +]); + +export default function App() { + const { session, logout } = useAuth(); + const [route, setRoute] = useState(() => + normalizeRoute(window.location.pathname) + ); + useEffect(() => { + const sync = () => setRoute(normalizeRoute(window.location.pathname)); + window.addEventListener('popstate', sync); + return () => window.removeEventListener('popstate', sync); + }, []); + useEffect(() => { + document.title = `Tailchat Admin · ${route}`; + }, [route]); + if (!session) return ; + const navigate = (next: RouteId) => { + window.history.pushState({}, '', `/admin-next/${next}`); + setRoute(next); + }; + let page: React.ReactNode; + if (route === 'dashboard') + page = ; + else if (resourceRoutes.has(route)) + page = ; + else { + const Page = specialPages[route]; + page = Page ? : null; + } + return ( + + {page} + + ); +} + +function LoginPage() { + const { login } = useAuth(); + const { t, language, setLanguage } = useI18n(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setLoading(true); + setError(''); + try { + await login(username, password); + } catch { + setError(t('auth.failed')); + } finally { + setLoading(false); + } + }; + return ( +
+
+
+ +
+
+ Tailchat + {t('app.edition')} +
+
+ {t('app.console')} +

{t('auth.signIn')}

+

{t('auth.subtitle')}

+
+
+ + + {error && } + + +
{t('app.footer')}
+
+
+ ); +} diff --git a/server/admin-next/src/client/api.ts b/server/admin-next/src/client/api.ts new file mode 100644 index 00000000000..c3f488a9284 --- /dev/null +++ b/server/admin-next/src/client/api.ts @@ -0,0 +1,87 @@ +import { + buildResourceQuery, + readAuth, + requestHeaders, + type ResourceQuery, +} from './core'; + +export const API_BASE = '/admin-next/api'; +export const AUTH_STORAGE_KEY = 'tailchat:admin-next:auth'; +export const UNAUTHORIZED_EVENT = 'tailchat:admin-next:unauthorized'; + +export class ApiError extends Error { + constructor(message: string, public status: number) { + super(message); + } +} + +export async function api( + path: string, + init: RequestInit & { auth?: boolean } = {} +): Promise { + const session = readAuth(window.localStorage.getItem(AUTH_STORAGE_KEY)); + const needsAuth = init.auth !== false; + const form = init.body instanceof FormData; + const response = await fetch(`${API_BASE}${path}`, { + ...init, + headers: { + ...requestHeaders( + needsAuth ? session?.token || '' : '', + Boolean(init.body) && !form + ), + ...init.headers, + }, + }); + + if (response.status === 401 && needsAuth) { + window.localStorage.removeItem(AUTH_STORAGE_KEY); + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); + } + + if (!response.ok) { + const message = + (await response.text()) || `${response.status} ${response.statusText}`; + throw new ApiError(message, response.status); + } + + if (response.status === 204) return undefined as T; + const type = response.headers.get('content-type') || ''; + return ( + type.includes('application/json') ? response.json() : response.text() + ) as Promise; +} + +export async function listResource>( + resource: string, + options: ResourceQuery +): Promise<{ rows: T[]; total: number }> { + const response = await fetch( + `${API_BASE}/${resource}?${buildResourceQuery(options)}`, + { + headers: requestHeaders( + readAuth(window.localStorage.getItem(AUTH_STORAGE_KEY))?.token || '', + false + ), + } + ); + if (response.status === 401) { + window.localStorage.removeItem(AUTH_STORAGE_KEY); + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); + } + if (!response.ok) + throw new ApiError( + (await response.text()) || response.statusText, + response.status + ); + return { + rows: await response.json(), + total: Number(response.headers.get('X-Total-Count') || 0), + }; +} + +export function callAction(action: string, params: Record) { + return api('/callAction', { + method: 'POST', + body: JSON.stringify({ action, params }), + }); +} diff --git a/server/admin-next/src/client/auth.tsx b/server/admin-next/src/client/auth.tsx new file mode 100644 index 00000000000..82c6bb6bcec --- /dev/null +++ b/server/admin-next/src/client/auth.tsx @@ -0,0 +1,67 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { api, AUTH_STORAGE_KEY, UNAUTHORIZED_EVENT } from './api'; +import { readAuth, type AuthSession } from './core'; + +interface AuthValue { + session: AuthSession | null; + login: (username: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: React.PropsWithChildren) { + const [session, setSession] = useState(() => + readAuth(window.localStorage.getItem(AUTH_STORAGE_KEY)) + ); + + const logout = () => { + window.localStorage.removeItem(AUTH_STORAGE_KEY); + setSession(null); + }; + + useEffect(() => { + window.addEventListener(UNAUTHORIZED_EVENT, logout); + return () => window.removeEventListener(UNAUTHORIZED_EVENT, logout); + }, []); + + useEffect(() => { + if (!session) return; + const timer = window.setTimeout( + logout, + Math.max(0, session.expiredAt - Date.now()) + ); + return () => window.clearTimeout(timer); + }, [session]); + + const value = useMemo( + () => ({ + session, + async login(username, password) { + const next = await api('/login', { + method: 'POST', + auth: false, + body: JSON.stringify({ username, password }), + }); + window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(next)); + setSession(next); + }, + logout, + }), + [session] + ); + + return {children}; +} + +export function useAuth() { + const value = useContext(AuthContext); + if (!value) throw new Error('AuthProvider is missing'); + return value; +} diff --git a/server/admin-next/src/client/components.test.tsx b/server/admin-next/src/client/components.test.tsx new file mode 100644 index 00000000000..3752242a393 --- /dev/null +++ b/server/admin-next/src/client/components.test.tsx @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { BarChart, Button, Card, LineChart } from './components'; + +test('renders shared primitives with Arco Design', () => { + assert.match(renderToStaticMarkup(), /arco-btn/); + assert.match(renderToStaticMarkup(Content), /arco-card/); +}); + +test('renders line and bar charts with Recharts', () => { + const data = [ + { label: 'Monday', value: 3 }, + { label: 'Tuesday', value: 7 }, + ]; + + for (const Chart of [LineChart, BarChart]) { + assert.match( + renderToStaticMarkup(), + /recharts-responsive-container/ + ); + } +}); + +test('styles Arco controls without generic native form overrides', () => { + const styles = readFileSync(`${__dirname}/styles.css`, 'utf8'); + + assert.match(styles, /\.admin-table \.arco-table-th/); + assert.match(styles, /\.resource-pagination/); + assert.doesNotMatch(styles, /:where\(input:not/); +}); + +test('maps Arco dark theme variables to the existing admin palette', () => { + const styles = readFileSync(`${__dirname}/styles.css`, 'utf8'); + + assert.match(styles, /--color-bg-1:\s*var\(--panel\)/); + assert.match(styles, /--primary-6:\s*var\(--primary-rgb\)/); + assert.match(styles, /--border-radius-small:\s*var\(--control-radius\)/); +}); + +test('uses Arco controls for forms, tables, pagination, and confirmations', () => { + const app = readFileSync(`${__dirname}/App.tsx`, 'utf8'); + const pages = readFileSync(`${__dirname}/pages.tsx`, 'utf8'); + const resources = readFileSync(`${__dirname}/resources.tsx`, 'utf8'); + + assert.match(app, / { + const styles = readFileSync(`${__dirname}/styles.css`, 'utf8'); + const resources = readFileSync(`${__dirname}/resources.tsx`, 'utf8'); + + assert.match(styles, /\.sidebar \.nav \.arco-btn-text:not\(\.active\)/); + assert.match(resources, / & { + children?: React.ReactNode; + className?: string; + icon?: IconName; + type?: 'button' | 'submit' | 'reset'; + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; +}) { + return ( + : undefined} + status={variant === 'danger' ? 'danger' : undefined} + type={ + variant === 'primary' + ? 'primary' + : variant === 'ghost' + ? 'text' + : 'secondary' + } + {...props} + > + {children} + + ); +} + +export function Card({ + className = '', + children, +}: React.PropsWithChildren<{ className?: string }>) { + return ( + + {children} + + ); +} + +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description: string; + actions?: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+ {actions &&
{actions}
} +
+ ); +} + +export function LoadingState() { + const { t } = useI18n(); + return ( +
+ +
+ ); +} + +export function EmptyState({ message }: { message?: string }) { + const { t } = useI18n(); + return ( +
+ +
+ ); +} + +export function ErrorState({ + retry, + message, +}: { + retry?: () => void; + message?: string; +}) { + const { t } = useI18n(); + return ( +
+ {t('common.retry')}} + /> +
+ ); +} + +export function Modal({ + title, + children, + onClose, + footer, + wide = false, +}: React.PropsWithChildren<{ + title: string; + onClose: () => void; + footer?: React.ReactNode; + wide?: boolean; +}>) { + return ( + + {children} + + ); +} + +type ToastType = 'success' | 'error'; +const ToastContext = createContext<(message: string, type?: ToastType) => void>( + () => undefined +); + +export function ToastProvider({ children }: React.PropsWithChildren) { + const [messageApi, contextHolder] = Message.useMessage({ duration: 3500 }); + const notify = (message: string, type: ToastType = 'success') => { + messageApi[type]?.(message); + }; + return ( + + {children} + {contextHolder} + + ); +} + +export const useToast = () => useContext(ToastContext); + +const sections: { label: string; routes: { id: RouteId; icon: IconName }[] }[] = + [ + { + label: 'nav.overview', + routes: [ + { id: 'dashboard', icon: 'dashboard' }, + { id: 'analytics', icon: 'chart' }, + ], + }, + { + label: 'nav.content', + routes: [ + { id: 'users', icon: 'users' }, + { id: 'login-logs', icon: 'login' }, + { id: 'messages', icon: 'message' }, + { id: 'groups', icon: 'group' }, + { id: 'files', icon: 'file' }, + { id: 'mail', icon: 'mail' }, + ], + }, + { label: 'nav.plugins', routes: [{ id: 'discover', icon: 'discover' }] }, + { + label: 'nav.infrastructure', + routes: [ + { id: 'network', icon: 'network' }, + { id: 'socketio', icon: 'socket' }, + { id: 'cache', icon: 'database' }, + ], + }, + { + label: 'nav.system', + routes: [ + { id: 'system-notify', icon: 'notify' }, + { id: 'system', icon: 'settings' }, + ], + }, + ]; + +export function AppShell({ + route, + username, + navigate, + logout, + children, +}: React.PropsWithChildren<{ + route: RouteId; + username: string; + navigate: (route: RouteId) => void; + logout: () => void; +}>) { + const { t, language, setLanguage } = useI18n(); + const [drawer, setDrawer] = useState(false); + const [palette, setPalette] = useState(false); + const [query, setQuery] = useState(''); + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setPalette(true); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + useEffect(() => { + if (!palette) setQuery(''); + }, [palette]); + const results = useMemo( + () => + ROUTES.filter((id) => { + const haystack = `${t(`route.${id}`)} ${id}`.toLowerCase(); + return haystack.includes(query.toLowerCase()); + }), + [query, t] + ); + const go = (next: RouteId) => { + navigate(next); + setDrawer(false); + setPalette(false); + }; + return ( +
+ {drawer && ( + setDrawer(false)} + /> + )} + +
+
+ setDrawer(true)} + aria-label={t('shell.menu')} + icon={} + /> + setPalette(true)} + > + + {t('shell.command')} + ⌘K + +
+ setLanguage(language === 'zh' ? 'en' : 'zh')} + aria-label={t('shell.language')} + > + + {language === 'zh' ? 'EN' : '中文'} + + + {username.slice(0, 1).toUpperCase()} + + {username} + } + /> +
+
{children}
+
+ {palette && ( + setPalette(false)}> + } + value={query} + onChange={setQuery} + placeholder={t('shell.commandPlaceholder')} + /> +
+ {results.map((id) => ( + go(id)}> + {t(`route.${id}`)} + + + ))} + {!results.length && ( + + )} +
+
+ )} +
+ ); +} + +export function LineChart({ + data, +}: { + data: { label: string; value: number }[]; +}) { + if (!data.length) + return ( +
+ +
+ ); + return ( +
`${item.label}: ${item.value}`).join(', ')} + > + + + + + + + + + +
+ ); +} + +export function BarChart({ + data, + format = String, +}: { + data: { label: string; value: number }[]; + format?: (value: number) => string; +}) { + if (!data.length) + return ( +
+ +
+ ); + return ( +
`${item.label}: ${format(item.value)}`) + .join(', ')} + > + + + + label || '—'} + /> + format(Number(value))} + /> + + format(Number(value))} + /> + + + +
+ ); +} diff --git a/server/admin-next/src/client/core.test.ts b/server/admin-next/src/client/core.test.ts new file mode 100644 index 00000000000..b26d651ab09 --- /dev/null +++ b/server/admin-next/src/client/core.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildResourceQuery, + getValue, + normalizeRoute, + readAuth, + requestHeaders, + toCSV, + validateNotification, +} from './core'; +import { translations } from './i18n'; + +test('normalizes admin-next routes', () => { + assert.equal(normalizeRoute('/admin-next/users/'), 'users'); + assert.equal(normalizeRoute('/admin-next/not-real'), 'dashboard'); +}); + +test('accepts only unexpired auth sessions', () => { + const value = JSON.stringify({ token: 't', username: 'a', expiredAt: 100 }); + assert.equal(readAuth(value, 99)?.token, 't'); + assert.equal(readAuth(value, 100), null); + assert.equal(readAuth('bad json', 0), null); +}); + +test('builds the legacy resource query', () => { + assert.equal( + buildResourceQuery({ + page: 2, + perPage: 20, + sort: 'createdAt', + order: 'DESC', + search: 'moon', + }), + '_sort=createdAt&_order=DESC&_start=20&_end=40&q=moon' + ); +}); + +test('reads nested values and exports deterministic CSV', () => { + assert.equal( + getValue( + { members: ['a'], metaData: { 'content-type': 'x' } }, + 'members.length' + ), + 1 + ); + assert.equal( + getValue({ metaData: { 'content-type': 'x' } }, 'metaData.content-type'), + 'x' + ); + assert.equal( + toCSV( + [ + { + name: 'a,b', + note: 'say "hi"', + enabled: true, + tags: ['a', 'b'], + meta: { a: 1 }, + empty: null, + }, + ], + [ + { key: 'name', label: 'Name' }, + { key: 'note', label: 'Note' }, + { key: 'enabled', label: 'Enabled' }, + { key: 'tags', label: 'Tags' }, + { key: 'meta', label: 'Meta' }, + { key: 'empty', label: 'Empty' }, + ] + ), + 'Name,Note,Enabled,Tags,Meta,Empty\r\n"a,b","say ""hi""",true,"[""a"",""b""]","{""a"":1}",' + ); +}); + +test('creates authenticated headers without breaking multipart forms', () => { + assert.deepEqual(requestHeaders('token', false), { + Authorization: 'Bearer token', + }); + assert.deepEqual(requestHeaders('token', true), { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + }); +}); + +test('validates notification boundaries', () => { + assert.ok(validateNotification('all', [], '', 'body')); + assert.ok(validateNotification('all', [], 'title', '')); + assert.ok(validateNotification('specified', [], 'title', 'body')); + assert.equal(validateNotification('all', [], 'title', 'body'), null); + assert.equal( + validateNotification('specified', ['user'], 'title', 'body'), + null + ); +}); + +test('keeps every route bilingual', () => { + for (const route of [ + 'dashboard', + 'analytics', + 'users', + 'login-logs', + 'messages', + 'groups', + 'files', + 'mail', + 'discover', + 'network', + 'socketio', + 'cache', + 'system-notify', + 'system', + ]) { + assert.ok(translations.zh[`route.${route}`]); + assert.ok(translations.en[`route.${route}`]); + } +}); diff --git a/server/admin-next/src/client/core.ts b/server/admin-next/src/client/core.ts new file mode 100644 index 00000000000..d86db732acb --- /dev/null +++ b/server/admin-next/src/client/core.ts @@ -0,0 +1,130 @@ +export const ROUTES = [ + 'dashboard', + 'analytics', + 'users', + 'login-logs', + 'messages', + 'groups', + 'files', + 'mail', + 'discover', + 'network', + 'socketio', + 'cache', + 'system-notify', + 'system', +] as const; + +export type RouteId = (typeof ROUTES)[number]; + +export interface AuthSession { + token: string; + username: string; + expiredAt: number; +} + +export function normalizeRoute(pathname: string): RouteId { + const route = pathname.replace(/^\/admin-next\/?/, '').replace(/\/$/, ''); + return ROUTES.includes(route as RouteId) ? (route as RouteId) : 'dashboard'; +} + +export function readAuth( + raw: string | null, + now = Date.now() +): AuthSession | null { + try { + const value = JSON.parse(raw || '') as AuthSession; + return value?.token && value?.username && value.expiredAt > now + ? value + : null; + } catch { + return null; + } +} + +export interface ResourceQuery { + page: number; + perPage: number; + sort: string; + order: 'ASC' | 'DESC'; + search?: string; + filters?: Record; +} + +export function buildResourceQuery(options: ResourceQuery): string { + const start = (options.page - 1) * options.perPage; + const query = new URLSearchParams({ + _sort: options.sort, + _order: options.order, + _start: String(start), + _end: String(start + options.perPage), + }); + if (options.search) query.set('q', options.search); + Object.entries(options.filters || {}).forEach(([key, value]) => { + if (value !== undefined && value !== '') query.set(key, String(value)); + }); + return query.toString(); +} + +export function getValue(record: unknown, path: string): unknown { + return path.split('.').reduce((value, key) => { + if (value === null || value === undefined) return undefined; + return (value as Record)[key]; + }, record); +} + +function printable(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function csvField(value: unknown): string { + const text = printable(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +export function toCSV( + rows: Record[], + columns: { key: string; label: string }[] +): string { + return [ + columns.map((column) => csvField(column.label)).join(','), + ...rows.map((row) => + columns.map((column) => csvField(getValue(row, column.key))).join(',') + ), + ].join('\r\n'); +} + +export function requestHeaders( + token: string, + json: boolean +): Record { + return { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(json ? { 'Content-Type': 'application/json' } : {}), + }; +} + +export function validateNotification( + scope: 'all' | 'specified', + users: string[], + title: string, + content: string +): 'title' | 'content' | 'users' | null { + if (!title.trim()) return 'title'; + if (!content.trim()) return 'content'; + if (scope === 'specified' && users.length === 0) return 'users'; + return null; +} + +export function downloadCSV(filename: string, csv: string): void { + const url = URL.createObjectURL( + new Blob([`\ufeff${csv}`], { type: 'text/csv' }) + ); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/server/admin-next/src/client/i18n.tsx b/server/admin-next/src/client/i18n.tsx new file mode 100644 index 00000000000..1cb03b51a2f --- /dev/null +++ b/server/admin-next/src/client/i18n.tsx @@ -0,0 +1,393 @@ +import React, { createContext, useContext, useMemo, useState } from 'react'; + +export type Language = 'zh' | 'en'; + +const commonZh: Record = { + 'app.name': 'Tailchat 管理后台', + 'app.edition': 'Admin Next', + 'app.console': '管理控制台', + 'app.footer': '由 MsgByte 构建', + 'common.loading': '正在加载', + 'common.retry': '重试', + 'common.search': '搜索', + 'common.refresh': '刷新', + 'common.create': '新建', + 'common.edit': '编辑', + 'common.delete': '删除', + 'common.more': '更多', + 'common.details': '详情', + 'common.export': '导出 CSV', + 'common.actions': '操作', + 'common.save': '保存', + 'common.cancel': '取消', + 'common.confirm': '确认', + 'common.close': '关闭', + 'common.success': '操作成功', + 'common.failed': '操作失败', + 'common.empty': '暂无数据', + 'common.previous': '上一页', + 'common.next': '下一页', + 'common.page': '第 {{page}} 页', + 'common.total': '共 {{total}} 条', + 'common.selected': '已选择 {{count}} 条', + 'common.all': '全部', + 'common.yes': '是', + 'common.no': '否', + 'common.copy': '复制', + 'common.copied': '已复制', + 'common.open': '打开', + 'common.remove': '移除', + 'common.upload': '上传', + 'common.required': '此项为必填项', + 'common.invalidJson': '请输入有效的 JSON', + 'common.confirmDelete': '确定删除这条记录吗?此操作无法撤销。', + 'common.confirmBatchDelete': + '确定删除选中的 {{count}} 条记录吗?此操作无法撤销。', + 'common.loadError': '加载失败,请重试', + 'common.noSearchResults': '没有符合当前条件的数据', + 'auth.username': '管理员账号', + 'auth.password': '管理员密码', + 'auth.signIn': '登录管理后台', + 'auth.signingIn': '正在登录…', + 'auth.subtitle': '使用服务端配置的管理凭证继续', + 'auth.failed': '账号或密码错误', + 'auth.expired': '登录已过期,请重新登录', + 'auth.logout': '退出登录', + 'shell.menu': '打开导航', + 'shell.command': '快速跳转', + 'shell.commandHint': '按 ⌘K 快速跳转', + 'shell.commandPlaceholder': '搜索页面…', + 'shell.language': '语言', + 'nav.overview': '概览', + 'nav.content': '内容与用户', + 'nav.plugins': '插件', + 'nav.infrastructure': '基础设施', + 'nav.system': '系统', + 'route.dashboard': '仪表盘', + 'route.analytics': '数据分析', + 'route.users': '用户管理', + 'route.login-logs': '登录日志', + 'route.messages': '消息管理', + 'route.groups': '群组管理', + 'route.files': '文件管理', + 'route.mail': '邮件历史', + 'route.discover': '探索', + 'route.network': '微服务网络', + 'route.socketio': 'Socket.IO 长链接', + 'route.cache': '缓存管理', + 'route.system-notify': '系统通知', + 'route.system': '系统设置', + 'description.dashboard': '掌握用户、群组、消息和文件的实时概况', + 'description.analytics': '查看近期开启协作的用户与群组排行', + 'description.users': '管理账号资料、登录状态与访问权限', + 'description.login-logs': '审查用户登录 IP、设备与时间', + 'description.messages': '检索、审查和管理聊天消息', + 'description.groups': '管理群组、所有者、成员与权限数据', + 'description.files': '查看文件用量并清理存储对象', + 'description.mail': '检查系统邮件的发送结果', + 'description.discover': '维护公开探索列表与排序', + 'description.network': '查看 Moleculer 节点、服务、操作和事件', + 'description.socketio': '连接官方 Socket.IO Admin UI', + 'description.cache': '按范围清理服务端缓存', + 'description.system-notify': '向全部或指定用户发送 Markdown 收件箱通知', + 'description.system': '查看客户端策略并配置服务器品牌与公告', + 'dashboard.welcome': '欢迎回来,{{name}}', + 'dashboard.users': '用户', + 'dashboard.groups': '群组', + 'dashboard.files': '文件', + 'dashboard.messages': '消息', + 'dashboard.newUsers': '最近 14 天新增用户', + 'dashboard.messageCount': '最近 14 天消息数', + 'dashboard.realData': '数据来自当前 Tailchat 实例', + 'analytics.activeGroups': '前 5 名活跃群组', + 'analytics.activeUsers': '前 5 名活跃用户', + 'analytics.largeGroups': '最大的 5 个群组', + 'analytics.fileStorage': '文件存储用量最大的 5 名用户', + 'analytics.messages': '消息数', + 'analytics.members': '成员数', + 'analytics.storage': '存储用量', + 'resource.searchPlaceholder': '输入关键词搜索…', + 'resource.pageSize': '每页', + 'resource.usage': '使用场景', + 'resource.chatOnly': '仅显示聊天文件', + 'resource.storageTotal': '文件总大小', + 'resource.resetPassword': '重置密码', + 'resource.resetPasswordConfirm': + '密码将重置为 123456789,请提醒用户及时修改。', + 'resource.ban': '封禁用户', + 'resource.unban': '解除封禁', + 'resource.banConfirm': '封禁会断开当前连接并阻止之后登录,是否继续?', + 'resource.unbanConfirm': '解除封禁后用户可正常登录,是否继续?', + 'resource.addMember': '增加群组成员', + 'resource.selectUser': '搜索并选择用户', + 'resource.noUser': '请先选择用户', + 'resource.exporting': '正在导出所有筛选结果…', + 'resource.preview': '预览', + 'network.nodes': '节点列表', + 'network.local': '本机', + 'network.available': '可用', + 'network.unavailable': '不可用', + 'network.ping': '检测节点延迟', + 'network.pinging': '检测中…', + 'network.latency': '{{count}} 个节点响应', + 'network.services': '服务', + 'network.actions': '操作', + 'network.events': '事件', + 'socket.url': '服务器 URL', + 'socket.credentials': '账号密码与 Tailchat 管理后台凭证一致。', + 'socket.notice': + '在 Advanced options 中启用 websocket only 和 MessagePack parser。', + 'socket.open': '打开 Socket.IO Admin UI', + 'cache.warning': '生产环境请谨慎操作,清理缓存可能在短时间内增加数据库压力。', + 'cache.config': '清理客户端配置缓存', + 'cache.all': '清理全部缓存', + 'cache.confirmConfig': '确定清理客户端配置缓存吗?', + 'cache.confirmAll': '确定清理全部缓存吗?', + 'notify.title': '通知标题', + 'notify.content': 'Markdown 内容', + 'notify.scope': '通知范围', + 'notify.all': '所有正式用户', + 'notify.specified': '指定用户', + 'notify.allTip': '不包含临时用户;用户很多时发送可能需要一些时间。', + 'notify.send': '发送通知', + 'notify.sent': '发送成功,共 {{count}} 名用户', + 'notify.needTitle': '请输入通知标题', + 'notify.needContent': '请输入通知内容', + 'notify.needUsers': '请选择至少一名用户', + 'system.config': '客户端配置', + 'system.announcement': '公告', + 'system.uploadFileLimit': '上传文件限制(Byte)', + 'system.emailVerification': '强制邮箱验证', + 'system.allowGuestLogin': '允许访客登录', + 'system.allowUserRegister': '允许用户注册', + 'system.allowCreateGroup': '允许创建群组', + 'system.serverName': '服务器名称', + 'system.serverEntryImage': '服务器登录图', + 'system.saveName': '保存名称', + 'system.announcementEnable': '启用公告', + 'system.announcementText': '公告文本', + 'system.announcementLink': '公告链接(可选)', + 'system.saveAnnouncement': '保存公告', + 'system.uploading': '正在上传…', +}; + +const commonEn: Record = { + 'app.name': 'Tailchat Admin', + 'app.edition': 'Admin Next', + 'app.console': 'Management Console', + 'app.footer': 'Built by MsgByte', + 'common.loading': 'Loading', + 'common.retry': 'Retry', + 'common.search': 'Search', + 'common.refresh': 'Refresh', + 'common.create': 'Create', + 'common.edit': 'Edit', + 'common.delete': 'Delete', + 'common.more': 'More', + 'common.details': 'Details', + 'common.export': 'Export CSV', + 'common.actions': 'Actions', + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.confirm': 'Confirm', + 'common.close': 'Close', + 'common.success': 'Completed successfully', + 'common.failed': 'Operation failed', + 'common.empty': 'No data', + 'common.previous': 'Previous', + 'common.next': 'Next', + 'common.page': 'Page {{page}}', + 'common.total': '{{total}} total', + 'common.selected': '{{count}} selected', + 'common.all': 'All', + 'common.yes': 'Yes', + 'common.no': 'No', + 'common.copy': 'Copy', + 'common.copied': 'Copied', + 'common.open': 'Open', + 'common.remove': 'Remove', + 'common.upload': 'Upload', + 'common.required': 'This field is required', + 'common.invalidJson': 'Enter valid JSON', + 'common.confirmDelete': 'Delete this record? This cannot be undone.', + 'common.confirmBatchDelete': + 'Delete the selected {{count}} records? This cannot be undone.', + 'common.loadError': 'Could not load data. Try again.', + 'common.noSearchResults': 'No data matches these filters', + 'auth.username': 'Admin username', + 'auth.password': 'Admin password', + 'auth.signIn': 'Sign in to Admin', + 'auth.signingIn': 'Signing in…', + 'auth.subtitle': + 'Continue with the administrator credentials configured on the server', + 'auth.failed': 'Incorrect username or password', + 'auth.expired': 'Your session expired. Sign in again.', + 'auth.logout': 'Sign out', + 'shell.menu': 'Open navigation', + 'shell.command': 'Quick navigation', + 'shell.commandHint': 'Press ⌘K to jump', + 'shell.commandPlaceholder': 'Search pages…', + 'shell.language': 'Language', + 'nav.overview': 'Overview', + 'nav.content': 'Content & users', + 'nav.plugins': 'Plugins', + 'nav.infrastructure': 'Infrastructure', + 'nav.system': 'System', + 'route.dashboard': 'Dashboard', + 'route.analytics': 'Analytics', + 'route.users': 'Users', + 'route.login-logs': 'Login logs', + 'route.messages': 'Messages', + 'route.groups': 'Groups', + 'route.files': 'Files', + 'route.mail': 'Mail history', + 'route.discover': 'Discover', + 'route.network': 'Service network', + 'route.socketio': 'Socket.IO connection', + 'route.cache': 'Cache', + 'route.system-notify': 'System notification', + 'route.system': 'System settings', + 'description.dashboard': + 'Track users, groups, messages, and files at a glance', + 'description.analytics': + 'Review recent collaboration leaders and storage usage', + 'description.users': 'Manage profiles, login state, and account access', + 'description.login-logs': 'Audit login IP addresses, devices, and timestamps', + 'description.messages': 'Search, inspect, and manage chat messages', + 'description.groups': 'Manage groups, owners, members, and permission data', + 'description.files': 'Review file usage and clean up stored objects', + 'description.mail': 'Inspect delivery results for system email', + 'description.discover': 'Maintain the public discovery list and ordering', + 'description.network': + 'Inspect Moleculer nodes, services, actions, and events', + 'description.socketio': 'Connect the official Socket.IO Admin UI', + 'description.cache': 'Clear server caches by scope', + 'description.system-notify': + 'Send Markdown inbox notifications to all or selected users', + 'description.system': + 'Review client policy and configure branding and announcements', + 'dashboard.welcome': 'Welcome back, {{name}}', + 'dashboard.users': 'Users', + 'dashboard.groups': 'Groups', + 'dashboard.files': 'Files', + 'dashboard.messages': 'Messages', + 'dashboard.newUsers': 'New users · last 14 days', + 'dashboard.messageCount': 'Messages · last 14 days', + 'dashboard.realData': 'Data from this Tailchat instance', + 'analytics.activeGroups': 'Top 5 active groups', + 'analytics.activeUsers': 'Top 5 active users', + 'analytics.largeGroups': '5 largest groups', + 'analytics.fileStorage': 'Top 5 users by file storage', + 'analytics.messages': 'Messages', + 'analytics.members': 'Members', + 'analytics.storage': 'Storage', + 'resource.searchPlaceholder': 'Search by keyword…', + 'resource.pageSize': 'Per page', + 'resource.usage': 'Usage', + 'resource.chatOnly': 'Only show chat files', + 'resource.storageTotal': 'Total file size', + 'resource.resetPassword': 'Reset password', + 'resource.resetPasswordConfirm': + 'The password will become 123456789. Ask the user to change it promptly.', + 'resource.ban': 'Ban user', + 'resource.unban': 'Unban user', + 'resource.banConfirm': + 'This disconnects the user and blocks future sign-ins. Continue?', + 'resource.unbanConfirm': 'The user will be able to sign in again. Continue?', + 'resource.addMember': 'Add group member', + 'resource.selectUser': 'Search and select a user', + 'resource.noUser': 'Select a user first', + 'resource.exporting': 'Exporting all filtered results…', + 'resource.preview': 'Preview', + 'network.nodes': 'Nodes', + 'network.local': 'Local', + 'network.available': 'Available', + 'network.unavailable': 'Unavailable', + 'network.ping': 'Check node latency', + 'network.pinging': 'Checking…', + 'network.latency': '{{count}} nodes responded', + 'network.services': 'Services', + 'network.actions': 'Actions', + 'network.events': 'Events', + 'socket.url': 'Server URL', + 'socket.credentials': 'Use the same credentials as Tailchat Admin.', + 'socket.notice': + 'Enable websocket only and MessagePack parser under Advanced options.', + 'socket.open': 'Open Socket.IO Admin UI', + 'cache.warning': + 'Use caution in production. Clearing caches can briefly increase database load.', + 'cache.config': 'Clear client config cache', + 'cache.all': 'Clear all caches', + 'cache.confirmConfig': 'Clear the client config cache?', + 'cache.confirmAll': 'Clear every cache?', + 'notify.title': 'Notification title', + 'notify.content': 'Markdown content', + 'notify.scope': 'Audience', + 'notify.all': 'All registered users', + 'notify.specified': 'Selected users', + 'notify.allTip': + 'Temporary users are excluded. Large audiences may take some time.', + 'notify.send': 'Send notification', + 'notify.sent': 'Sent successfully to {{count}} users', + 'notify.needTitle': 'Enter a notification title', + 'notify.needContent': 'Enter notification content', + 'notify.needUsers': 'Select at least one user', + 'system.config': 'Client configuration', + 'system.announcement': 'Announcement', + 'system.uploadFileLimit': 'Upload file limit (Byte)', + 'system.emailVerification': 'Require email verification', + 'system.allowGuestLogin': 'Allow guest sign-in', + 'system.allowUserRegister': 'Allow user registration', + 'system.allowCreateGroup': 'Allow group creation', + 'system.serverName': 'Server name', + 'system.serverEntryImage': 'Server entry image', + 'system.saveName': 'Save name', + 'system.announcementEnable': 'Enable announcement', + 'system.announcementText': 'Announcement text', + 'system.announcementLink': 'Announcement link (optional)', + 'system.saveAnnouncement': 'Save announcement', + 'system.uploading': 'Uploading…', +}; + +export const translations: Record> = { + zh: commonZh, + en: commonEn, +}; + +const STORAGE_KEY = 'tailchat:admin-next:language'; +const I18nContext = createContext<{ + language: Language; + setLanguage: (language: Language) => void; + t: (key: string, values?: Record) => string; +} | null>(null); + +export function I18nProvider({ children }: React.PropsWithChildren) { + const [language, setLanguageState] = useState(() => + window.localStorage.getItem(STORAGE_KEY) === 'en' ? 'en' : 'zh' + ); + const value = useMemo( + () => ({ + language, + setLanguage(next: Language) { + window.localStorage.setItem(STORAGE_KEY, next); + setLanguageState(next); + document.documentElement.lang = next === 'zh' ? 'zh-CN' : 'en'; + }, + t(key: string, values: Record = {}) { + return (translations[language][key] || key).replace( + /{{(\w+)}}/g, + (_, name) => String(values[name] ?? '') + ); + }, + }), + [language] + ); + + return {children}; +} + +export function useI18n() { + const value = useContext(I18nContext); + if (!value) throw new Error('I18nProvider is missing'); + return value; +} diff --git a/server/admin-next/src/client/icons.tsx b/server/admin-next/src/client/icons.tsx new file mode 100644 index 00000000000..1039f1bcc8c --- /dev/null +++ b/server/admin-next/src/client/icons.tsx @@ -0,0 +1,223 @@ +import type React from 'react'; + +export type IconName = + | 'dashboard' + | 'chart' + | 'users' + | 'login' + | 'message' + | 'group' + | 'file' + | 'mail' + | 'discover' + | 'network' + | 'socket' + | 'database' + | 'notify' + | 'settings' + | 'search' + | 'menu' + | 'close' + | 'plus' + | 'refresh' + | 'download' + | 'edit' + | 'trash' + | 'eye' + | 'chevron' + | 'external' + | 'check' + | 'warning' + | 'logout' + | 'language' + | 'copy' + | 'server' + | 'more'; + +const paths: Record = { + dashboard: ( + <> + + + + + + ), + chart: ( + <> + + + ), + users: ( + <> + + + + + ), + login: ( + <> + + + ), + message: ( + + ), + group: ( + <> + + + + ), + file: ( + <> + + + + ), + mail: ( + <> + + + + ), + discover: ( + <> + + + + ), + network: ( + <> + + + + + + ), + socket: ( + <> + + + + ), + database: ( + <> + + + + ), + notify: ( + <> + + + ), + settings: ( + <> + + + + ), + search: ( + <> + + + + ), + menu: , + close: , + plus: , + refresh: ( + <> + + + + ), + download: ( + <> + + + ), + edit: ( + <> + + + + ), + trash: ( + <> + + + ), + eye: ( + <> + + + + ), + chevron: , + external: ( + <> + + + + ), + check: , + warning: ( + <> + + + + ), + logout: ( + <> + + + + ), + language: ( + <> + + + + ), + copy: ( + <> + + + + ), + server: ( + <> + + + + + ), + more: ( + <> + + + + + ), +}; + +export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { + return ( + + ); +} diff --git a/server/admin-next/src/client/main.tsx b/server/admin-next/src/client/main.tsx new file mode 100644 index 00000000000..91610c8e9cb --- /dev/null +++ b/server/admin-next/src/client/main.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import { AuthProvider } from './auth'; +import { ToastProvider } from './components'; +import { I18nProvider } from './i18n'; +import '@arco-design/web-react/dist/css/arco.css'; +import './styles.css'; + +document.body.setAttribute('arco-theme', 'dark'); + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + +); diff --git a/server/admin-next/src/client/pages.tsx b/server/admin-next/src/client/pages.tsx new file mode 100644 index 00000000000..5834b063685 --- /dev/null +++ b/server/admin-next/src/client/pages.tsx @@ -0,0 +1,921 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Input, + Popconfirm, + Radio, + Switch, + Table, + Tag, + Upload, + type TableColumnProps, +} from '@arco-design/web-react'; +import { Editor } from '@bytemd/react'; +import gfm from '@bytemd/plugin-gfm'; +import filesize from 'filesize'; +import 'bytemd/dist/index.css'; +import { api, listResource } from './api'; +import { + BarChart, + Button, + Card, + EmptyState, + ErrorState, + LineChart, + LoadingState, + PageHeader, + useToast, +} from './components'; +import { validateNotification, type RouteId } from './core'; +import { Icon } from './icons'; +import { useI18n } from './i18n'; +import { UserPicker } from './resources'; + +type Summary = { date: string; count: number }; + +export function DashboardPage({ username }: { username: string }) { + const { t } = useI18n(); + const [data, setData] = useState<{ + counts: number[]; + users: Summary[]; + messages: Summary[]; + } | null>(null); + const [error, setError] = useState(''); + const load = () => { + setError(''); + Promise.all([ + listResource('users', { page: 1, perPage: 1, sort: 'id', order: 'DESC' }), + listResource('groups', { + page: 1, + perPage: 1, + sort: 'id', + order: 'DESC', + }), + listResource('file', { page: 1, perPage: 1, sort: 'id', order: 'DESC' }), + listResource('messages', { + page: 1, + perPage: 1, + sort: 'id', + order: 'DESC', + }), + api<{ summary: Summary[] }>('/user/count/summary'), + api<{ summary: Summary[] }>('/message/count/summary'), + ]) + .then(([users, groups, files, messages, userSummary, messageSummary]) => + setData({ + counts: [users.total, groups.total, files.total, messages.total], + users: userSummary.summary, + messages: messageSummary.summary, + }) + ) + .catch((err) => setError(String(err))); + }; + useEffect(load, []); + const cards = ['users', 'groups', 'files', 'messages'] as const; + return ( + <> + + {t('common.refresh')} + + } + /> + {!data && !error ? ( + + ) : error ? ( + + ) : ( + data && ( + <> +
+ {cards.map((name, index) => ( + + + + +
+ {t(`dashboard.${name}`)} + + {new Intl.NumberFormat().format(data.counts[index])} + +
+
+ ))} +
+
+ +
+
+

{t('dashboard.newUsers')}

+ {t('dashboard.realData')} +
+ + {data.users.reduce((sum, item) => sum + item.count, 0)} + +
+ ({ + label: item.date.slice(5), + value: item.count, + }))} + /> +
+ +
+
+

{t('dashboard.messageCount')}

+ {t('dashboard.realData')} +
+ + {data.messages.reduce((sum, item) => sum + item.count, 0)} + +
+ ({ + label: item.date.slice(5), + value: item.count, + }))} + /> +
+
+ + ) + )} + + ); +} + +interface AnalyticsData { + activeGroups: { groupId: string; groupName: string; messageCount: number }[]; + activeUsers: { userId: string; userName: string; messageCount: number }[]; + largeGroups: { _id: string; name: string; memberCount: number }[]; + fileStorageUserTop: { + userId: string; + userName: string; + fileStorageTotal: number; + }[]; +} + +export function AnalyticsPage() { + const { t } = useI18n(); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const load = () => { + setError(''); + Promise.all([ + api>('/analytics/activeGroups'), + api>('/analytics/activeUsers'), + api>('/analytics/largeGroups'), + api>( + '/analytics/fileStorageUserTop' + ), + ]) + .then(([a, b, c, d]) => setData({ ...a, ...b, ...c, ...d })) + .catch((err) => setError(String(err))); + }; + useEffect(load, []); + return ( + <> + + {t('common.refresh')} + + } + /> + {!data && !error ? ( + + ) : error ? ( + + ) : ( + data && ( +
+ ({ + label: item.groupName || item.groupId, + value: item.messageCount, + }))} + /> + ({ + label: item.userName || item.userId, + value: item.messageCount, + }))} + /> + ({ + label: item.name || item._id, + value: item.memberCount, + }))} + /> + ({ + label: item.userName || item.userId, + value: item.fileStorageTotal, + }))} + format={(value) => filesize(value)} + /> +
+ ) + )} + + ); +} + +function MetricCard({ + title, + subtitle, + data, + format, +}: { + title: string; + subtitle: string; + data: { label: string; value: number }[]; + format?: (value: number) => string; +}) { + return ( + +
+
+

{title}

+ {subtitle} +
+ +
+ +
+ ); +} + +interface NetworkData { + nodes: Record[]; + services: string[]; + actions: string[]; + events: string[]; +} + +export function NetworkPage() { + const { t } = useI18n(); + const notify = useToast(); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [pinging, setPinging] = useState(false); + const [ping, setPing] = useState([]); + const load = () => { + setError(''); + api('/network/all') + .then(setData) + .catch((err) => setError(String(err))); + }; + useEffect(load, []); + const runPing = async () => { + setPinging(true); + try { + const result = await api('/network/ping'); + setPing(Array.isArray(result) ? result : []); + notify( + t('network.latency', { + count: Array.isArray(result) ? result.length : 0, + }) + ); + } catch (err) { + notify(String(err), 'error'); + } finally { + setPinging(false); + } + }; + const nodeColumns: TableColumnProps>[] = [ + { title: 'ID', dataIndex: 'id', width: 190, ellipsis: true }, + { + title: 'Host', + dataIndex: 'hostname', + width: 160, + render: (value) => String(value || '—'), + }, + { + title: 'IP', + dataIndex: 'ipList', + width: 210, + render: (value) => (Array.isArray(value) ? value.join(', ') : '—'), + }, + { + title: t('network.available'), + dataIndex: 'available', + width: 130, + render: (_, node) => ( + + {node.local + ? t('network.local') + : node.available + ? t('network.available') + : t('network.unavailable')} + + ), + }, + { + title: 'Client', + dataIndex: 'client', + width: 220, + render: (value) => {compact(value)}, + }, + { + title: 'CPU', + dataIndex: 'cpu', + width: 220, + render: (value) => {compact(value)}, + }, + ]; + return ( + <> + + + + + } + /> + {!data && !error ? ( + + ) : error ? ( + + ) : ( + data && ( + <> +
+ + {t('network.nodes')} + {data.nodes.length} + + + {t('network.services')} + {data.services.length} + + + {t('network.actions')} + {data.actions.length} + + + {t('network.events')} + {data.events.length} + +
+ +
+

{t('network.nodes')}

+
+ String(node.id)} + pagination={false} + scroll={{ x: 1130 }} + noDataElement={} + /> + + {!!ping.length && ( + +
+

{t('network.ping')}

+
+
{JSON.stringify(ping, null, 2)}
+
+ )} +
+ + + +
+ + ) + )} + + ); +} + +const compact = (value: unknown) => (value ? JSON.stringify(value) : '—'); +function StringList({ title, values }: { title: string; values: string[] }) { + return ( + +
+

{title}

+ {values.length} +
+
+ {values.map((value) => ( + {value} + ))} +
+
+ ); +} + +export function SocketPage() { + const { t } = useI18n(); + const notify = useToast(); + const socketUrl = `${ + window.location.protocol === 'https:' ? 'wss:' : 'ws:' + }//${window.location.host}`; + const copy = async () => { + await navigator.clipboard.writeText(socketUrl); + notify(t('common.copied')); + }; + return ( + <> + +
+ + + + +

{t('socket.url')}

+
+ {socketUrl} + +
+

{t('socket.credentials')}

+ + +
+
+ + ); +} + +export function CachePage() { + const { t } = useI18n(); + const notify = useToast(); + const [loading, setLoading] = useState(''); + const clean = async (target: 'config.client' | 'all') => { + setLoading(target); + try { + const result = await api<{ success: boolean; message?: string }>( + '/cache/clean', + { + method: 'POST', + body: JSON.stringify(target === 'all' ? {} : { target }), + } + ); + if (!result.success) + throw new Error(result.message || t('common.failed')); + notify(t('common.success')); + } catch (err) { + notify(String(err), 'error'); + } finally { + setLoading(''); + } + }; + return ( + <> + + +
+ + + + +

{t('cache.config')}

+ clean('config.client')} + > + + +
+ + + + +

{t('cache.all')}

+ clean('all')}> + + +
+
+ + ); +} + +export function NotifyPage() { + const { t } = useI18n(); + const notify = useToast(); + const [scope, setScope] = useState<'all' | 'specified'>('all'); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [users, setUsers] = useState[]>([]); + const [sending, setSending] = useState(false); + const plugins = useMemo(() => [gfm()], []); + const send = async (event: React.FormEvent) => { + event.preventDefault(); + const invalid = validateNotification( + scope, + users.map((user) => String(user.id)), + title, + content + ); + if (invalid) + return notify( + t(`notify.need${invalid[0].toUpperCase()}${invalid.slice(1)}`), + 'error' + ); + setSending(true); + try { + const result = await api<{ userIds: string[] }>('/users/system/notify', { + method: 'POST', + body: JSON.stringify({ + scope, + specifiedUser: users.map((user) => user.id), + title, + content, + }), + }); + notify(t('notify.sent', { count: result.userIds.length })); + setTitle(''); + setContent(''); + setUsers([]); + } catch (err) { + notify(String(err), 'error'); + } finally { + setSending(false); + } + }; + return ( + <> + + +
+ + {scope === 'all' ? ( + + ) : ( + + )} + + +
+ +
+ +
+ + ); +} + +interface ClientConfig { + uploadFileLimit?: number; + emailVerification?: boolean; + disableGuestLogin?: boolean; + disableUserRegister?: boolean; + disableCreateGroup?: boolean; + serverName?: string; + serverEntryImage?: string; + announcement?: false | { id?: number; text?: string; link?: string }; + [key: string]: unknown; +} + +export function SystemPage() { + const { t } = useI18n(); + const notify = useToast(); + const [config, setConfig] = useState(null); + const [error, setError] = useState(''); + const [name, setName] = useState(''); + const [announcement, setAnnouncement] = useState({ + enable: false, + text: '', + link: '', + }); + const [uploading, setUploading] = useState(false); + const load = () => { + setError(''); + api<{ config: ClientConfig }>('/config/client') + .then(({ config: next }) => { + setConfig(next); + setName(next.serverName || ''); + setAnnouncement( + next.announcement + ? { + enable: true, + text: next.announcement.text || '', + link: next.announcement.link || '', + } + : { enable: false, text: '', link: '' } + ); + }) + .catch((err) => setError(String(err))); + }; + useEffect(load, []); + const patch = async (key: string, value: unknown) => { + await api('/config/client', { + method: 'PATCH', + body: JSON.stringify({ key, value }), + }); + notify(t('common.success')); + load(); + }; + const upload = async (file: File) => { + setUploading(true); + try { + const body = new FormData(); + body.append('file', file); + body.append('usage', 'server'); + const result = await api<{ files: { url: string }[] }>('/file/upload', { + method: 'PUT', + body, + }); + const url = result.files[0]?.url; + if (!url) throw new Error(t('common.failed')); + await patch('serverEntryImage', url); + } catch (err) { + notify(String(err), 'error'); + } finally { + setUploading(false); + } + }; + if (!config && !error) return ; + if (error) return ; + return ( + <> + + {t('common.refresh')} + + } + /> +
+ +
+

{t('system.config')}

+
+
+ + + + + +
+ + +
+ +
+

{t('system.announcement')}

+
+ + + +
+ +
+
+
+ + ); +} + +function ConfigRow({ + label, + value, +}: { + label: string; + value: string | boolean; +}) { + return ( +
+
{label}
+
+ {typeof value === 'boolean' ? ( + } + > + {value ? 'ON' : 'OFF'} + + ) : ( + value + )} +
+
+ ); +} + +export const specialPages: Partial> = { + analytics: AnalyticsPage, + network: NetworkPage, + socketio: SocketPage, + cache: CachePage, + 'system-notify': NotifyPage, + system: SystemPage, +}; diff --git a/server/admin-next/src/client/resources.tsx b/server/admin-next/src/client/resources.tsx new file mode 100644 index 00000000000..3d31e70da07 --- /dev/null +++ b/server/admin-next/src/client/resources.tsx @@ -0,0 +1,1065 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Checkbox, + Dropdown, + Image, + Input, + Menu, + Pagination, + Popconfirm, + Switch, + Table, + Tag, + Tooltip, + type TableColumnProps, +} from '@arco-design/web-react'; +import filesize from 'filesize'; +import { api, callAction, listResource } from './api'; +import { downloadCSV, getValue, toCSV, type RouteId } from './core'; +import { + Button, + Card, + EmptyState, + ErrorState, + Modal, + PageHeader, + useToast, +} from './components'; +import { Icon } from './icons'; +import { useI18n, type Language } from './i18n'; + +type Label = { zh: string; en: string }; +type FieldType = + | 'text' + | 'email' + | 'boolean' + | 'date' + | 'json' + | 'number' + | 'image' + | 'filesize' + | 'textarea'; +type UserAction = 'delete' | 'reset' | 'ban' | 'unban'; + +interface Field { + key: string; + label: Label; + type?: FieldType; + sortable?: boolean; + editable?: boolean; + required?: boolean; + wide?: boolean; + defaultValue?: unknown; +} + +interface ResourceSchema { + route: RouteId; + resource: string; + fields: Field[]; + create?: boolean; + edit?: boolean; + remove?: boolean; + batchRemove?: boolean; + export?: boolean; + pageSizes?: number[]; +} + +const L = (zh: string, en: string): Label => ({ zh, en }); +const id = (sortable = false): Field => ({ + key: 'id', + label: L('ID', 'ID'), + sortable, +}); +const createdAt: Field = { + key: 'createdAt', + label: L('创建时间', 'Created at'), + type: 'date', + sortable: true, +}; + +const schemas: Record = { + users: { + route: 'users', + resource: 'users', + create: true, + edit: true, + remove: true, + export: true, + fields: [ + id(true), + { + key: 'email', + label: L('邮箱', 'Email'), + type: 'email', + editable: true, + required: true, + }, + { key: 'nickname', label: L('昵称', 'Nickname'), editable: true }, + { + key: 'discriminator', + label: L('识别码', 'Discriminator'), + editable: true, + required: true, + }, + { + key: 'temporary', + label: L('临时用户', 'Temporary'), + type: 'boolean', + editable: true, + }, + { + key: 'avatar', + label: L('头像', 'Avatar'), + type: 'image', + editable: true, + }, + { key: 'type', label: L('类型', 'Type') }, + { + key: 'emailVerified', + label: L('邮箱已验证', 'Email verified'), + type: 'boolean', + editable: true, + }, + { key: 'banned', label: L('已封禁', 'Banned'), type: 'boolean' }, + { key: 'lastLoginIp', label: L('最后登录 IP', 'Last login IP') }, + { + key: 'lastLoginAt', + label: L('最后登录时间', 'Last login at'), + type: 'date', + }, + { + key: 'lastLoginUserAgent', + label: L('最后登录设备', 'Last user agent'), + wide: true, + }, + { + key: 'settings', + label: L('设置', 'Settings'), + type: 'json', + editable: true, + wide: true, + }, + createdAt, + ], + }, + 'login-logs': { + route: 'login-logs', + resource: 'user_login_logs', + export: true, + fields: [ + id(true), + { key: 'userId', label: L('用户 ID', 'User ID') }, + { key: 'ip', label: L('IP 地址', 'IP address'), sortable: true }, + { key: 'userAgent', label: L('设备信息', 'User agent'), wide: true }, + createdAt, + ], + }, + messages: { + route: 'messages', + resource: 'messages', + edit: true, + remove: true, + batchRemove: true, + export: true, + fields: [ + id(true), + { + key: 'content', + label: L('内容', 'Content'), + type: 'textarea', + editable: true, + wide: true, + }, + { key: 'author', label: L('发送者', 'Author'), editable: true }, + { key: 'groupId', label: L('群组 ID', 'Group ID'), editable: true }, + { + key: 'converseId', + label: L('会话 ID', 'Conversation ID'), + editable: true, + }, + { + key: 'hasRecall', + label: L('已撤回', 'Recalled'), + type: 'boolean', + editable: true, + }, + { + key: 'reactions', + label: L('回应', 'Reactions'), + type: 'json', + editable: true, + wide: true, + }, + createdAt, + ], + }, + groups: { + route: 'groups', + resource: 'groups', + create: true, + edit: true, + remove: true, + export: true, + fields: [ + id(), + { key: 'name', label: L('名称', 'Name'), editable: true, required: true }, + { + key: 'owner', + label: L('所有者', 'Owner'), + editable: true, + required: true, + }, + { key: 'members.length', label: L('成员数', 'Members') }, + { key: 'panels.length', label: L('面板数', 'Panels') }, + { key: 'roles', label: L('角色', 'Roles'), type: 'json', wide: true }, + { + key: 'fallbackPermissions', + label: L('默认权限', 'Fallback permissions'), + type: 'json', + wide: true, + }, + createdAt, + ], + }, + files: { + route: 'files', + resource: 'file', + remove: true, + batchRemove: true, + export: true, + pageSizes: [20, 50, 100, 500, 2000], + fields: [ + { key: 'objectName', label: L('对象名称', 'Object name'), wide: true }, + { key: 'url', label: L('预览', 'Preview'), type: 'image' }, + { key: 'usage', label: L('用途', 'Usage') }, + { + key: 'size', + label: L('大小', 'Size'), + type: 'filesize', + sortable: true, + }, + { key: 'metaData.content-type', label: L('内容类型', 'Content type') }, + { key: 'etag', label: L('ETag', 'ETag'), wide: true }, + { key: 'userId', label: L('用户 ID', 'User ID') }, + createdAt, + ], + }, + mail: { + route: 'mail', + resource: 'mail', + export: true, + fields: [ + { key: 'to', label: L('收件人', 'Recipient') }, + { key: 'subject', label: L('主题', 'Subject'), wide: true }, + { key: 'host', label: L('主机', 'Host') }, + { key: 'port', label: L('端口', 'Port'), type: 'number' }, + { key: 'secure', label: L('安全连接', 'Secure'), type: 'boolean' }, + { key: 'is_success', label: L('发送成功', 'Succeeded'), type: 'boolean' }, + { + key: 'data', + label: L('响应数据', 'Response data'), + type: 'json', + wide: true, + }, + { key: 'error', label: L('错误', 'Error'), wide: true }, + createdAt, + ], + }, + discover: { + route: 'discover', + resource: 'p_discover', + create: true, + remove: true, + fields: [ + { + key: 'groupId', + label: L('群组 ID', 'Group ID'), + editable: true, + required: true, + }, + { + key: 'active', + label: L('启用', 'Active'), + type: 'boolean', + editable: true, + defaultValue: true, + }, + { + key: 'order', + label: L('排序', 'Order'), + type: 'number', + editable: true, + defaultValue: 0, + sortable: true, + }, + ], + }, +}; + +function formatValue( + value: unknown, + field: Field, + language: Language +): React.ReactNode { + if (field.type === 'boolean') + return ( + + {value + ? language === 'zh' + ? '是' + : 'Yes' + : language === 'zh' + ? '否' + : 'No'} + + ); + if (field.type === 'date' && value) + return new Intl.DateTimeFormat(language === 'zh' ? 'zh-CN' : 'en', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(String(value))); + if (field.type === 'filesize') return filesize(Number(value || 0)); + if (field.type === 'image' && value) + return ( + + ); + if (typeof value === 'object' && value !== null) return JSON.stringify(value); + return value === null || value === undefined || value === '' + ? '—' + : String(value); +} + +function initialData(schema: ResourceSchema, record?: Record) { + return Object.fromEntries( + schema.fields + .filter((field) => field.editable) + .map((field) => [ + field.key, + record + ? getValue(record, field.key) ?? '' + : field.defaultValue ?? (field.type === 'boolean' ? false : ''), + ]) + ); +} + +function setPath( + target: Record, + path: string, + value: unknown +) { + const keys = path.split('.'); + let cursor = target; + keys.forEach((key, index) => { + if (index === keys.length - 1) cursor[key] = value; + else cursor = cursor[key] = (cursor[key] as Record) || {}; + }); +} + +function ResourceForm({ + schema, + record, + onClose, + onSaved, +}: { + schema: ResourceSchema; + record?: Record; + onClose: () => void; + onSaved: () => void; +}) { + const { language, t } = useI18n(); + const notify = useToast(); + const [values, setValues] = useState>(() => + initialData(schema, record) + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const fields = schema.fields.filter((field) => field.editable); + const save = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + const missing = fields.find( + (field) => field.required && !String(values[field.key] ?? '').trim() + ); + if (missing) + return setError(`${missing.label[language]}: ${t('common.required')}`); + if (values.discriminator && !/^\d{4}$/.test(String(values.discriminator))) + return setError( + language === 'zh' + ? '识别码必须为 4 位数字' + : 'Discriminator must contain 4 digits' + ); + const payload: Record = {}; + try { + fields.forEach((field) => { + let value = values[field.key]; + if (field.type === 'json' && typeof value === 'string') + value = value.trim() ? JSON.parse(value) : {}; + if (field.type === 'number') value = Number(value); + setPath(payload, field.key, value); + }); + setSaving(true); + await api(`/${schema.resource}${record ? `/${record.id}` : ''}`, { + method: record ? 'PUT' : 'POST', + body: JSON.stringify(payload), + }); + notify(t('common.success')); + onSaved(); + onClose(); + } catch (err) { + setError( + err instanceof SyntaxError ? t('common.invalidJson') : String(err) + ); + } finally { + setSaving(false); + } + }; + return ( +
+
+ {fields.map((field) => ( + + ))} +
+ {error && } +
+ + +
+ + ); +} + +export function UserPicker({ + onSelect, +}: { + onSelect: (record: Record) => void; +}) { + const { t } = useI18n(); + const [query, setQuery] = useState(''); + const [rows, setRows] = useState[]>([]); + useEffect(() => { + const timer = window.setTimeout(() => { + if (!query.trim()) return setRows([]); + listResource('users', { + page: 1, + perPage: 8, + sort: 'id', + order: 'ASC', + search: query, + }) + .then((result) => setRows(result.rows)) + .catch(() => setRows([])); + }, 250); + return () => window.clearTimeout(timer); + }, [query]); + return ( +
+ } + value={query} + onChange={setQuery} + placeholder={t('resource.selectUser')} + allowClear + /> + {!!rows.length && ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+ ); +} + +function Detail({ + schema, + record, +}: { + schema: ResourceSchema; + record: Record; +}) { + const { language } = useI18n(); + return ( +
+ {schema.fields.map((field) => ( +
+
{field.label[language]}
+
{formatValue(getValue(record, field.key), field, language)}
+
+ ))} +
+ ); +} + +export function ResourcePage({ route }: { route: keyof typeof schemas }) { + const schema = schemas[route]; + const { t, language } = useI18n(); + const notify = useToast(); + const [rows, setRows] = useState[]>([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(schema.pageSizes?.[0] || 20); + const [sort, setSort] = useState( + schema.fields.find((field) => field.sortable)?.key || 'id' + ); + const [order, setOrder] = useState<'ASC' | 'DESC'>('DESC'); + const [draftSearch, setDraftSearch] = useState(''); + const [search, setSearch] = useState(''); + const [chatOnly, setChatOnly] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [selected, setSelected] = useState([]); + const [detail, setDetail] = useState | null>(null); + const [editing, setEditing] = useState< + Record | 'create' | null + >(null); + const [addingMember, setAddingMember] = useState | null>(null); + const [pendingUserAction, setPendingUserAction] = useState<{ + id: string; + action: UserAction; + } | null>(null); + const [storageTotal, setStorageTotal] = useState(null); + const [revision, setRevision] = useState(0); + const filters = + route === 'files' && chatOnly ? { meta: 'onlyChat' } : undefined; + const load = () => { + setLoading(true); + setError(''); + listResource(schema.resource, { + page, + perPage, + sort, + order, + search, + filters, + }) + .then((result) => { + setRows(result.rows); + setTotal(result.total); + setSelected([]); + }) + .catch((err) => setError(String(err))) + .finally(() => setLoading(false)); + }; + useEffect(load, [page, perPage, sort, order, search, chatOnly, revision]); + useEffect(() => { + if (route === 'files') + api<{ totalSize: number }>('/file/filesizeSum') + .then((result) => setStorageTotal(result.totalSize)) + .catch(() => setStorageTotal(null)); + }, [route, revision]); + const refresh = () => setRevision((value) => value + 1); + const remove = async (record: Record) => { + try { + await api(`/${schema.resource}/${record.id}`, { method: 'DELETE' }); + notify(t('common.success')); + refresh(); + } catch (err) { + notify(String(err), 'error'); + } + }; + const removeSelected = async () => { + try { + await Promise.all( + selected.map((recordId) => + api(`/${schema.resource}/${recordId}`, { method: 'DELETE' }) + ) + ); + notify(t('common.success')); + refresh(); + } catch (err) { + notify(String(err), 'error'); + } + }; + const exportAll = async () => { + notify(t('resource.exporting')); + try { + const all: Record[] = []; + const batch = Math.min(500, schema.pageSizes?.at(-1) || 500); + for (let current = 1; ; current += 1) { + const result = await listResource(schema.resource, { + page: current, + perPage: batch, + sort, + order, + search, + filters, + }); + all.push(...result.rows); + if (all.length >= result.total || result.rows.length === 0) break; + } + downloadCSV( + `${schema.resource}-${new Date().toISOString().slice(0, 10)}.csv`, + toCSV( + all, + schema.fields.map((field) => ({ + key: field.key, + label: field.label[language], + })) + ) + ); + } catch (err) { + notify(String(err), 'error'); + } + }; + const customUserAction = async ( + record: Record, + action: 'reset' | 'ban' | 'unban' + ) => { + try { + if (action === 'reset') + await api(`/users/${record.id}`, { + method: 'PUT', + body: JSON.stringify({ + password: + '$2a$10$eSebpg0CEvsbDC7j1NxB2epMUkYwKhfT8vGdPQYkfeXYMqM8HjnpW', + }), + }); + else + await api(`/user/${action}`, { + method: 'POST', + body: JSON.stringify({ userId: record.id }), + }); + notify(t('common.success')); + refresh(); + } catch (err) { + notify(String(err), 'error'); + } + }; + const columns: TableColumnProps>[] = [ + ...schema.fields.map( + (field): TableColumnProps> => ({ + title: field.label[language], + dataIndex: field.key, + width: field.wide ? 260 : field.type === 'image' ? 90 : 160, + ellipsis: field.type !== 'image', + sorter: field.sortable, + sortDirections: ['ascend', 'descend'], + sortOrder: + sort === field.key + ? order === 'ASC' + ? 'ascend' + : 'descend' + : undefined, + render: (_, record) => ( +
+ {formatValue(getValue(record, field.key), field, language)} +
+ ), + }) + ), + { + title: t('common.actions'), + key: 'actions', + fixed: 'right', + width: route === 'users' ? 120 : 150, + render: (_, record) => { + const banAction: UserAction = record.banned ? 'unban' : 'ban'; + const recordId = String(record.id); + const pendingAction = + pendingUserAction?.id === recordId ? pendingUserAction.action : null; + const confirmation = + pendingAction === 'delete' + ? t('common.confirmDelete') + : pendingAction + ? t( + pendingAction === 'reset' + ? 'resource.resetPasswordConfirm' + : `resource.${pendingAction}Confirm` + ) + : ''; + return ( +
+ +
+ ); + }, + }, + ]; + return ( + <> + + {schema.export && ( + + )} + + {schema.create && ( + + )} + + } + /> + {route === 'files' && storageTotal !== null && ( +
+ {t('resource.storageTotal')} + {filesize(storageTotal)} +
+ )} + +
+
{ + event.preventDefault(); + setPage(1); + setSearch(draftSearch); + }} + > + } + value={draftSearch} + onChange={setDraftSearch} + placeholder={t('resource.searchPlaceholder')} + allowClear + /> + + {route === 'files' && ( + { + setPage(1); + setChatOnly(checked); + }} + > + {t('resource.chatOnly')} + + )} + {!!selected.length && schema.batchRemove && ( + <> + + {t('common.selected', { count: selected.length })} + + + + + + )} +
+ {error ? ( + + ) : ( +
String(record.id)} + pagination={false} + noDataElement={ + + } + rowSelection={ + schema.batchRemove + ? { + selectedRowKeys: selected, + onChange: (keys) => setSelected(keys.map(String)), + } + : undefined + } + scroll={{ + x: schema.fields.reduce( + (width, field) => + width + + (field.wide ? 260 : field.type === 'image' ? 90 : 160), + route === 'users' ? 120 : 150 + ), + }} + onChange={(_, sorterInfo) => { + const current = Array.isArray(sorterInfo) + ? sorterInfo[0] + : sorterInfo; + if (!current?.field || !current.direction) return; + setPage(1); + setSort(String(current.field)); + setOrder(current.direction === 'ascend' ? 'ASC' : 'DESC'); + }} + /> + )} +
+ t('common.total', { total: value })} + onChange={(nextPage, nextSize) => { + if (nextSize !== perPage) { + setPage(1); + setPerPage(nextSize); + } else setPage(nextPage); + }} + /> +
+ + {detail && ( + setDetail(null)}> + + + )} + {editing && ( + setEditing(null)} + > + setEditing(null)} + onSaved={refresh} + /> + + )} + {addingMember && ( + setAddingMember(null)} + /> + )} + + ); +} + +function AddMemberModal({ + group, + onClose, +}: { + group: Record; + onClose: () => void; +}) { + const { t } = useI18n(); + const notify = useToast(); + const [user, setUser] = useState | null>(null); + const save = async () => { + if (!user) return notify(t('resource.noUser'), 'error'); + try { + await callAction('group.addMember', { + groupId: group.id, + userId: user.id, + }); + notify(t('common.success')); + onClose(); + } catch (err) { + notify(String(err), 'error'); + } + }; + return ( + + + + + } + > + + {user && ( +
+ {String(user.nickname || user.email || user.id)} + {String(user.email || user.id)} +
+ )} +
+ ); +} diff --git a/server/admin-next/src/client/styles.css b/server/admin-next/src/client/styles.css new file mode 100644 index 00000000000..8caf1c41d14 --- /dev/null +++ b/server/admin-next/src/client/styles.css @@ -0,0 +1,304 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + color: #e8edf5; + background: #0b0e14; + font-synthesis: none; + --bg: #0b0e14; + --panel: #12151d; + --panel-2: #171b24; + --surface: #1b202b; + --surface-2: #222836; + --border: #28303e; + --border-soft: #202633; + --text: #e8edf5; + --muted: #8c96a7; + --primary-rgb: 24, 144, 255; + --primary: rgb(var(--primary-rgb)); + --primary-soft: rgba(24, 144, 255, .14); + --success: #3ba55d; + --warning: #faa61a; + --danger: #ff4d4f; + --control-radius: 8px; + --shadow: 0 18px 60px rgba(0, 0, 0, .3); +} + +body[arco-theme='dark'] { + --color-bg-1: var(--panel); + --color-bg-2: var(--panel); + --color-bg-3: var(--panel-2); + --color-bg-4: var(--surface); + --color-bg-5: var(--surface-2); + --color-bg-popup: var(--surface-2); + --color-border: var(--border); + --color-border-1: var(--border-soft); + --color-border-2: var(--border); + --color-border-3: #30394a; + --color-border-4: #41506a; + --color-text-1: var(--text); + --color-text-2: #bcc5d3; + --color-text-3: var(--muted); + --color-text-4: #596476; + --primary-6: var(--primary-rgb); + --success-6: 59, 165, 93; + --warning-6: 250, 166, 26; + --danger-6: 255, 77, 79; + --border-radius-small: var(--control-radius); + --border-radius-medium: var(--control-radius); + --border-radius-large: 10px; + --color-mask-bg: rgba(3, 5, 8, .75); +} + +* { box-sizing: border-box; } +html, body, #root { min-height: 100%; margin: 0; } +body { background: var(--bg); color: var(--text); } +button, input, textarea, select { font: inherit; } +button { color: inherit; } +button, a, input, textarea, select { outline: none; } +button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { box-shadow: 0 0 0 3px rgba(24, 144, 255, .28); } +h1, h2, p { margin-top: 0; } +code, kbd, pre { font-family: "SFMono-Regular", Consolas, monospace; } +.icon { flex: 0 0 auto; } + +.button { min-height: 38px; border: 1px solid var(--border); border-radius: 8px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; background: var(--surface); cursor: pointer; font-weight: 600; font-size: 13px; transition: .18s ease; } +.button:hover:not(:disabled) { border-color: #41506a; background: var(--surface-2); transform: translateY(-1px); } +.button:disabled { opacity: .45; cursor: not-allowed; } +.button-primary { background: var(--primary); border-color: var(--primary); color: white; } +.button-primary:hover:not(:disabled) { background: #1684e8; border-color: #1684e8; } +.button-danger { color: #ff8b8c; background: rgba(255, 77, 79, .1); border-color: rgba(255, 77, 79, .28); } +.button-ghost { background: transparent; border-color: transparent; } +.icon-button { width: 38px; height: 38px; padding: 0; display: inline-grid; place-items: center; border: 0; border-radius: 8px; background: transparent; cursor: pointer; } +.icon-button:hover { background: var(--surface); } + +.app-shell { min-height: 100vh; } +.sidebar { position: fixed; inset: 0 auto 0 0; width: 260px; z-index: 30; display: flex; flex-direction: column; background: #0e1118; border-right: 1px solid var(--border-soft); } +.brand { height: 76px; padding: 0 22px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border-soft); } +.brand img { width: 38px; height: 38px; border-radius: 10px; } +.brand div { min-width: 0; display: flex; flex-direction: column; } +.brand strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 15px; letter-spacing: -.02em; } +.brand span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; margin-top: 3px; } +.nav { flex: 1; overflow: auto; padding: 14px 12px 24px; } +.nav-section + .nav-section { margin-top: 22px; } +.nav-label { display: block; color: #697488; padding: 0 10px 7px; font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.nav button { width: 100%; height: 40px; border: 0; border-radius: 7px; padding: 0 11px; background: transparent; display: flex; align-items: center; gap: 11px; cursor: pointer; text-align: left; font-size: 13px; } +.sidebar .nav .arco-btn-text:not(.active) { color: #9aa4b5; } +.sidebar .nav .arco-btn-text:not(.active):hover { color: var(--text); background: rgba(255,255,255,.035); } +.nav button.active { background: var(--primary-soft); color: #72b8ff; } +.sidebar-footer { padding: 14px 22px; border-top: 1px solid var(--border-soft); color: #596476; font-size: 11px; } +.workspace { min-height: 100vh; margin-left: 260px; } +.topbar { height: 62px; position: sticky; top: 0; z-index: 20; display: flex; align-items: center; padding: 0 28px; background: rgba(11, 14, 20, .88); border-bottom: 1px solid var(--border-soft); backdrop-filter: blur(14px); } +.command-trigger { height: 36px; min-width: 236px; padding: 0 10px; display: flex; align-items: center; gap: 9px; color: var(--muted); background: var(--panel); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; } +.command-trigger span { flex: 1; text-align: left; font-size: 12px; } +kbd { padding: 3px 6px; color: #738096; border: 1px solid #343d4d; border-radius: 5px; background: #171b24; font-size: 10px; } +.topbar-spacer { flex: 1; } +.topbar-control { border: 0; background: transparent; color: var(--muted); display: flex; align-items: center; gap: 7px; padding: 8px; border-radius: 7px; cursor: pointer; } +.topbar-control:hover { background: var(--surface); color: var(--text); } +.user-chip { width: 28px; height: 28px; margin-left: 14px; display: grid; place-items: center; border-radius: 7px; background: linear-gradient(145deg, #127de1, #27a7ff); color: white; font-weight: 800; font-size: 12px; } +.username { max-width: 140px; padding: 0 10px 0 8px; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } +.content { width: min(1540px, 100%); margin: 0 auto; padding: 30px 34px 60px; } +.mobile-only { display: none; } + +.page-header { min-height: 68px; margin-bottom: 24px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; } +.page-header h1 { margin-bottom: 7px; font-size: 26px; letter-spacing: -.035em; } +.page-header p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; } +.page-actions { display: flex; align-items: center; justify-content: flex-end; gap: 9px; flex-wrap: wrap; } +.card { background: var(--panel); border: 1px solid var(--border-soft); border-radius: 10px; box-shadow: 0 1px rgba(255, 255, 255, .015); } + +.kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; } +.kpi-card { padding: 20px; display: flex; align-items: center; gap: 16px; } +.kpi-icon { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 9px; color: #65b5ff; background: var(--primary-soft); } +.kpi-groups { color: #b493ff; background: rgba(140, 95, 255, .12); } +.kpi-files { color: #f7bb50; background: rgba(250, 166, 26, .12); } +.kpi-messages { color: #64cc7d; background: rgba(59, 165, 93, .12); } +.kpi-card div { display: flex; flex-direction: column; gap: 4px; } +.kpi-card span { color: var(--muted); font-size: 12px; } +.kpi-card strong { font-size: 25px; letter-spacing: -.04em; } +.chart-grid-layout, .analytics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.chart-card, .metric-card { padding: 20px; } +.chart-card header, .metric-card header { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 22px; } +.chart-card h2, .metric-card h2, .section-heading h2, .form-card h2, .socket-card h2, .cache-grid h2 { margin-bottom: 5px; font-size: 15px; } +.chart-card header span, .metric-card header span { color: var(--muted); font-size: 11px; } +.chart-card header > strong { font-size: 24px; } +.line-chart { height: 200px; } +.line-chart, .bar-chart { min-width: 0; } +.chart-empty { height: 200px; } +.chart-empty .state { min-height: 100%; } + +.table-toolbar { min-height: 64px; padding: 13px 15px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border-soft); } +.table-search { min-width: 260px; } +.search-input.arco-input-wrapper, .command-search.arco-input-wrapper { width: 100%; height: 38px; background: #0f131b; border-color: var(--border); border-radius: 8px; } +.search-input.arco-input-wrapper:hover, .command-search.arco-input-wrapper:hover { border-color: #41506a; } +.check-label { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; } +.selection-count { margin-left: auto; color: #76baff; font-size: 12px; } +.admin-table { overflow: hidden; } +.admin-table .arco-table-container { border: 0; border-radius: 0; } +.admin-table .arco-table-th { height: 44px; color: #758195; background: #10141c; border-color: var(--border-soft); font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } +.admin-table .arco-table-td { height: 55px; color: #c7cfdb; background: var(--panel); border-color: #1c222e; font-size: 12px; } +.admin-table .arco-table-cell { padding: 8px 13px; } +.admin-table .arco-table-tr:hover .arco-table-td { background: #151923; } +.admin-table .arco-table-col-fixed-right { box-shadow: -8px 0 14px rgba(8, 10, 14, .18); } +.admin-table .arco-table-th.arco-table-col-fixed-right { background: #10141c; } +.cell-content { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.table-image.arco-image { width: 56px; height: 56px; overflow: hidden; border-radius: 7px; background: var(--surface); } +.table-image .arco-image-img { width: 100%; height: 100%; object-fit: cover; } +.row-actions { display: flex; align-items: center; gap: 2px; } +.row-actions .button { width: 28px; min-width: 28px; height: 28px; min-height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #7f8b9f; } +.row-actions button:hover { color: #75baff; background: var(--primary-soft); } +.row-actions button.danger-action:hover { color: #ff7a7c; background: rgba(255,77,79,.1); } +.user-action-menu .arco-menu-item { display: grid; grid-template-columns: 24px 1fr; align-items: center; column-gap: 12px; } +.user-action-menu .arco-menu-item .icon { justify-self: center; } +.user-action-menu .danger-action { color: #ff8b8c; } +.resource-pagination { min-height: 58px; padding: 10px 15px; display: flex; align-items: center; justify-content: flex-end; overflow-x: auto; border-top: 1px solid var(--border-soft); } +.resource-pagination .arco-pagination { flex: 0 0 auto; flex-wrap: nowrap; } +.inline-stat { width: max-content; margin: -10px 0 14px auto; display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; } +.inline-stat strong { color: var(--text); } + +.state { min-height: 260px; display: flex; align-items: center; justify-content: center; gap: 10px; color: var(--muted); font-size: 13px; } +.state-empty { flex-direction: column; } +.state-error { width: min(680px, calc(100% - 32px)); } + +.arco-modal-mask { backdrop-filter: blur(5px); } +.admin-modal.arco-modal-wrapper { padding: 24px; } +.admin-modal.arco-modal-wrapper.arco-modal-wrapper-align-center .modal { width: min(520px, 100%); max-height: min(780px, calc(100vh - 48px)); overflow: hidden; display: inline-flex; flex-direction: column; background: var(--panel-2); border: 1px solid #30394a; border-radius: 12px; box-shadow: var(--shadow); } +.admin-modal.arco-modal-wrapper.arco-modal-wrapper-align-center .modal-wide { width: min(900px, 100%); } +.modal .arco-modal-header, .modal .arco-modal-footer { min-height: 58px; height: auto; padding: 12px 18px; display: flex; align-items: center; border-color: var(--border); } +.modal .arco-modal-title { text-align: left; font-size: 16px; font-weight: 600; } +.modal .arco-modal-footer { justify-content: flex-end; gap: 9px; } +.modal .arco-modal-footer > .arco-btn { margin-left: 0; } +.modal .arco-modal-content { padding: 18px; overflow: auto; } +.modal-inline-footer { margin-top: 20px; display: flex; justify-content: flex-end; gap: 9px; } +.detail-grid { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.detail-grid div { min-width: 0; padding: 13px; background: var(--panel); } +.detail-grid dt { margin-bottom: 7px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; } +.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #d4dae3; font-size: 12px; } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; } +.form-wide { grid-column: 1 / -1; } +.form-card { padding: 22px; } +.form-card form, .form-card > label, .form-grid label, .form-card form > label { display: flex; flex-direction: column; gap: 8px; } +.form-card form { gap: 18px; } +label > span { color: #a7b0bf; font-size: 12px; font-weight: 600; } +.arco-input-wrapper, .arco-textarea-wrapper, .arco-select-view { background-color: #0f131b; border-color: var(--border); } +.arco-input-wrapper:hover, .arco-textarea-wrapper:hover, .arco-select-view:hover { border-color: #41506a; } +.arco-textarea { resize: vertical; line-height: 1.55; } +.form-error { padding: 10px 12px; display: flex; align-items: center; gap: 7px; border-radius: 7px; background: rgba(255,77,79,.1); color: #ff8586; font-size: 12px; } +.user-picker { position: relative; } +.user-picker .search-input { width: 100%; } +.picker-results { position: absolute; left: 0; right: 0; top: calc(100% + 5px); z-index: 10; padding: 5px; background: #181d27; border: 1px solid #333d4f; border-radius: 8px; box-shadow: var(--shadow); } +.picker-results .picker-result { width: 100%; min-height: 38px; padding: 9px; border: 0; border-radius: 6px; background: transparent; color: var(--text); display: flex; justify-content: space-between; gap: 10px; text-align: left; } +.picker-results button:hover { background: var(--surface-2); } +.picker-results small, .selected-user small { color: var(--muted); } +.selected-user { margin-top: 12px; padding: 11px; display: flex; justify-content: space-between; border-radius: 7px; background: var(--primary-soft); color: #8fc8ff; font-size: 12px; } + +.analytics-grid { align-items: stretch; } +.metric-card { min-height: 340px; } +.metric-card header > .icon { color: #5e6d82; } +.network-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; } +.network-stats .card { padding: 17px; display: flex; align-items: baseline; justify-content: space-between; } +.network-stats span { color: var(--muted); font-size: 11px; } +.network-stats strong { font-size: 20px; } +.section-heading { min-height: 56px; padding: 15px 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-soft); } +.section-heading h2 { margin: 0; } +.section-heading span { color: var(--muted); font-size: 11px; } +.network-detail-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.tag-list { max-height: 340px; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 4px; } +.tag-list code { padding: 7px 9px; color: #aeb8c7; border-radius: 5px; background: #0f131b; font-size: 10px; overflow-wrap: anywhere; } +.json-view { max-height: 380px; margin: 0; padding: 18px; overflow: auto; color: #a9c7e4; background: #0d1117; font-size: 11px; line-height: 1.55; } +.center-card { min-height: 60vh; display: grid; place-items: center; } +.socket-card { width: min(680px, 100%); padding: 36px; text-align: center; } +.feature-icon { width: 52px; height: 52px; margin: 0 auto 18px; display: grid; place-items: center; border-radius: 11px; background: var(--primary-soft); color: #6bb7ff; } +.socket-card p { color: var(--muted); font-size: 12px; } +.copy-field { margin: 18px 0; padding: 6px 6px 6px 14px; display: flex; align-items: center; gap: 12px; background: #0e1219; border: 1px solid var(--border); border-radius: 8px; } +.copy-field code { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; text-align: left; color: #8bc9ff; } +.notice { padding: 12px 14px; display: flex; align-items: flex-start; gap: 10px; border: 1px solid rgba(24,144,255,.2); border-radius: 8px; background: rgba(24,144,255,.08); color: #a8c9e8; font-size: 12px; line-height: 1.5; } +.socket-card .notice { margin: 22px 0; text-align: left; } +.notice-warning { margin-bottom: 16px; color: #e3bd75; background: rgba(250,166,26,.08); border-color: rgba(250,166,26,.2); } +.cache-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.cache-grid .card { padding: 28px; text-align: center; } +.danger-card .feature-icon { color: #ff7e80; background: rgba(255,77,79,.1); } +.selected-users { display: flex; flex-wrap: wrap; gap: 7px; } +.markdown-editor { overflow: hidden; border: 1px solid var(--border); border-radius: 8px; } +.bytemd { height: 420px; color: var(--text); background: #0f131b; border: 0; } +.bytemd-toolbar, .bytemd-status { color: #9da8b9; background: var(--panel-2); border-color: var(--border); } +.CodeMirror, .bytemd-preview { color: var(--text); background: #0f131b; } +.form-actions { display: flex; justify-content: flex-end; } +.settings-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: 16px; align-items: start; } +.settings-grid .form-card { display: flex; flex-direction: column; gap: 18px; } +.settings-grid .section-heading { margin: -22px -22px 0; } +.config-list { margin: 0; border: 1px solid var(--border-soft); border-radius: 8px; overflow: hidden; } +.config-list div { padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border-soft); } +.config-list div:last-child { border-bottom: 0; } +.config-list dt { color: var(--muted); font-size: 11px; } +.config-list dd { margin: 0; font-size: 12px; } +.inline-form { display: flex; gap: 9px; } +.entry-image { padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: #0e1219; } +.entry-image img { width: 100%; max-height: 320px; margin-bottom: 10px; border-radius: 6px; object-fit: contain; } +.entry-upload, .entry-upload .arco-upload-trigger { width: 100%; } +.entry-upload .button { width: 100%; min-height: 90px; border-style: dashed; flex-direction: column; color: #7f8b9f; } +.switch-row { flex-direction: row !important; justify-content: space-between; align-items: center; } + +.command-search { width: 100%; min-width: 0; margin-bottom: 10px; } +.command-results { max-height: 390px; overflow: auto; } +.command-results > button { width: 100%; min-height: 44px; padding: 0 10px; display: flex; justify-content: space-between; align-items: center; border: 0; border-radius: 7px; background: transparent; color: #bfc7d3; cursor: pointer; } +.command-results > button:hover { background: var(--primary-soft); color: #80c2ff; } +.command-results .state { min-height: 160px; } + +.login-page { min-height: 100vh; position: relative; overflow: hidden; display: grid; place-items: center; padding: 28px; background: radial-gradient(circle at 50% 15%, #101d2e 0, var(--bg) 45%); } +.login-page::before { content: ""; position: absolute; inset: 0; opacity: .16; background-image: linear-gradient(rgba(255,255,255,.045) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.045) 1px, transparent 1px); background-size: 44px 44px; mask-image: linear-gradient(to bottom, black, transparent 75%); } +.login-glow { position: absolute; width: 360px; height: 360px; border-radius: 50%; filter: blur(110px); opacity: .16; } +.login-glow-one { left: -140px; top: -120px; background: var(--primary); } +.login-glow-two { right: -180px; bottom: -170px; background: #5865f2; } +.language-switch { position: absolute; z-index: 2; right: 24px; top: 22px; padding: 8px 10px; display: flex; align-items: center; gap: 7px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); cursor: pointer; } +.login-panel { position: relative; z-index: 1; width: min(430px, 100%); padding: 36px; border: 1px solid #2b3443; border-radius: 14px; background: rgba(18,21,29,.92); box-shadow: 0 30px 90px rgba(0,0,0,.45); backdrop-filter: blur(20px); } +.login-brand { display: flex; align-items: center; gap: 12px; } +.login-brand img { width: 42px; height: 42px; border-radius: 10px; } +.login-brand span { padding: 5px 8px; border: 1px solid rgba(24,144,255,.25); border-radius: 5px; background: var(--primary-soft); color: #76baff; font-size: 9px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.login-heading { margin: 35px 0 26px; } +.eyebrow { color: #5d9bd4; font-size: 10px; font-weight: 800; letter-spacing: .15em; text-transform: uppercase; } +.login-heading h1 { margin: 8px 0; font-size: 27px; letter-spacing: -.04em; } +.login-heading p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; } +.login-panel form { display: flex; flex-direction: column; gap: 17px; } +.login-panel form label { display: flex; flex-direction: column; gap: 8px; } +.login-panel form .button { width: 100%; height: 43px; margin-top: 4px; } +.login-panel footer { margin-top: 28px; color: #596476; text-align: center; font-size: 10px; } +.drawer-backdrop { display: none; } + +@media (max-width: 1120px) { + .kpi-grid { grid-template-columns: repeat(2, 1fr); } + .network-detail-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 940px) { + .sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: var(--shadow); } + .sidebar-open { transform: translateX(0); } + .drawer-backdrop { display: block; position: fixed; inset: 0; z-index: 25; border: 0; background: rgba(3,5,8,.7); } + .workspace { margin-left: 0; } + .mobile-only { display: inline-grid; margin-right: 10px; } + .content { padding: 26px 22px 50px; } + .settings-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 720px) { + .topbar { padding: 0 14px; } + .command-trigger { min-width: 38px; width: 38px; padding: 0; justify-content: center; } + .command-trigger span, .command-trigger kbd, .username { display: none; } + .content { padding: 22px 14px 42px; } + .page-header { flex-direction: column; } + .page-actions { width: 100%; justify-content: flex-start; } + .kpi-grid, .chart-grid-layout, .analytics-grid, .cache-grid, .network-stats { grid-template-columns: 1fr; } + .table-toolbar { align-items: stretch; flex-direction: column; } + .table-search { min-width: 0; width: 100%; } + .selection-count { margin-left: 0; } + .resource-pagination { justify-content: flex-start; } + .form-grid, .detail-grid { grid-template-columns: 1fr; } + .admin-modal.arco-modal-wrapper { padding: 10px; } + .admin-modal.arco-modal-wrapper.arco-modal-wrapper-align-center .modal { max-height: calc(100vh - 20px); } + .login-page { padding: 15px; } + .login-panel { padding: 28px 22px; } + .socket-card { padding: 24px 18px; } + .copy-field { align-items: stretch; flex-direction: column; padding: 10px; } + .inline-form { flex-direction: column; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/server/admin-next/src/server/broker.ts b/server/admin-next/src/server/broker.ts new file mode 100644 index 00000000000..12585c5a26d --- /dev/null +++ b/server/admin-next/src/server/broker.ts @@ -0,0 +1,28 @@ +import { TcBroker, SYSTEM_USERID } from 'tailchat-server-sdk'; +import brokerConfig from '../../../moleculer.config'; + +const transporter = process.env.TRANSPORTER; +export const broker = new TcBroker({ + ...brokerConfig, + metrics: false, + logger: false, + transporter, +}); + +broker.start().then(() => { + console.log('Connnected to Tailchat network, TRANSPORTER: ', transporter); +}); + +export function callBrokerAction( + actionName: string, + params: any, + opts?: Record +): Promise { + return broker.call(actionName, params, { + ...opts, + meta: { + ...opts?.meta, + userId: SYSTEM_USERID, + }, + }); +} diff --git a/server/admin-next/src/server/index.ts b/server/admin-next/src/server/index.ts new file mode 100644 index 00000000000..573b674ee06 --- /dev/null +++ b/server/admin-next/src/server/index.ts @@ -0,0 +1,63 @@ +import express from 'express'; +import ViteExpress from 'vite-express'; +import mongoose from 'mongoose'; +import compression from 'compression'; +import morgan from 'morgan'; +import path from 'path'; +import dotenv from 'dotenv'; +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); +import { apiRouter } from './router/api'; + +const app = express(); + +const port = Number(process.env.ADMIN_NEXT_PORT || 3100); + +if (!process.env.MONGO_URL) { + console.error('Require env: MONGO_URL'); + process.exit(1); +} + +// 链接数据库 +mongoose.connect(process.env.MONGO_URL, (error: any) => { + if (!error) { + return console.info('Datebase connected'); + } + console.error('Datebase connect error', error); +}); + +app.use(compression()); +app.use(express.json()); + +// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header +app.disable('x-powered-by'); + +// Remix fingerprints its assets so we can cache forever. +app.use( + '/build', + express.static('public/build', { immutable: true, maxAge: '1y' }) +); + +// Everything else (like favicon.ico) is cached for an hour. You may want to be +// more aggressive with this caching. +app.use(express.static('public', { maxAge: '1h' })); + +app.use(morgan('tiny')); + +app.use('/admin-next/api', apiRouter); + +app.use((err: any, req: any, res: any, next: any) => { + res.status(500); + res.json({ error: err.message }); +}); + +if (process.env.NODE_ENV === 'production') { + ViteExpress.config({ + mode: 'production', + }); +} + +ViteExpress.listen(app, port, () => { + console.log( + `Server is listening on port ${port}, visit with: http://localhost:${port}/admin-next/` + ); +}); diff --git a/server/admin-next/src/server/middleware/auth.ts b/server/admin-next/src/server/middleware/auth.ts new file mode 100644 index 00000000000..978697e60b7 --- /dev/null +++ b/server/admin-next/src/server/middleware/auth.ts @@ -0,0 +1,39 @@ +import type { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import md5 from 'md5'; + +export const adminAuth = { + username: process.env.ADMIN_USER, + password: process.env.ADMIN_PASS, +}; + +export const authSecret = + (process.env.SECRET || 'tailchat') + md5(JSON.stringify(adminAuth)); // 增加一个md5的盐值确保SECRET没有设置的情况下只修改了用户名密码也不会被人伪造token秘钥 + +export function auth() { + return (req: Request, res: Response, next: NextFunction) => { + try { + const authorization = req.headers.authorization; + if (!authorization) { + res.status(401).end('not found authorization in headers'); + return; + } + + const token = authorization.slice('Bearer '.length); + + const payload = jwt.verify(token, authSecret); + if (typeof payload === 'string') { + res.status(401).end('payload type error'); + return; + } + if (payload.platform !== 'admin-next') { + res.status(401).end('Payload invalid'); + return; + } + + next(); + } catch (err) { + res.status(401).end(String(err)); + } + }; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/README.md b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/README.md new file mode 100644 index 00000000000..6e6313fe34e --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/README.md @@ -0,0 +1,5 @@ +fork from https://github.com/NathanAdhitya/express-mongoose-ra-json-server + +modify: +- count logic in get `/` + diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/index.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/index.ts new file mode 100644 index 00000000000..c3e082b9be0 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/index.ts @@ -0,0 +1,259 @@ +import { RequestHandler, Router } from 'express'; +import type { LeanDocument } from 'mongoose'; +import statusMessages from './statusMessages'; +import type { ADPBaseModel, ADPBaseSchema } from './utils/baseModel.interface'; +import castFilter from './utils/castFilter'; +import convertId from './utils/convertId'; +import filterGetList from './utils/filterGetList'; +import { filterReadOnly } from './utils/filterReadOnly'; +import parseQuery from './utils/parseQuery'; +import virtualId from './utils/virtualId'; + +// Export certain helper functions for custom reuse. +export { default as virtualId } from './utils/virtualId'; +export { default as convertId } from './utils/convertId'; +export { default as castFilter } from './utils/castFilter'; +export { default as parseQuery } from './utils/parseQuery'; +export { default as filterGetList } from './utils/filterGetList'; +export { filterReadOnly } from './utils/filterReadOnly'; +export { default as statusMessages } from './statusMessages'; + +export interface raExpressMongooseCapabilities { + list?: boolean; + get?: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; +} + +export interface raExpressMongooseOptions { + /** Fields to search from ?q (used for autofill and search) */ + q?: string[]; + + /** Base name for ACLs (e.g. list operation does baseName.list) */ + aclName?: string; + + /** Fields to allow regex based search (non-exact search) */ + allowedRegexFields?: string[]; + + /** Read-only fields to filter out during create and update */ + readOnlyFields?: string[]; + + /** Function to transform inputs received in create and update */ + inputTransformer?: (input: Partial) => Promise>; + + /** Additional queries for list, e.g. deleted/hidden flag. */ + listQuery?: Record; + + /** Max rows from a get operation to prevent accidental server suicide (default 100) */ + maxRows?: number; + + /** Extra selects for mongoose queries (in the case that certain fields are hidden by default) */ + extraSelects?: string; + + /** Disable or enable certain parts. */ + capabilities?: raExpressMongooseCapabilities; + + /** Specify a custom express.js router */ + router?: Router; + + /** Specify an ACL middleware to check against permissions */ + ACLMiddleware?: (name: string) => RequestHandler; +} + +export function raExpressMongoose( + model: T, + options?: raExpressMongooseOptions +) { + const { + q, + allowedRegexFields = [], + readOnlyFields, + inputTransformer = (input: any) => input, + listQuery, + extraSelects, + maxRows = 100, + capabilities, + aclName, + router = Router(), + ACLMiddleware, + } = options ?? {}; + + const { + list: canList = true, + get: canGet = true, + create: canCreate = true, + update: canUpdate = true, + delete: canDelete = true, + } = capabilities ?? {}; + + /** getList, getMany, getManyReference */ + if (canList) + router.get( + '/', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.list`) + : (req, res, next) => next(), + async (req, res) => { + const filterQuery = { + ...listQuery, + ...parseQuery( + castFilter( + convertId(filterGetList(req.query)), + model, + allowedRegexFields + ), + model, + allowedRegexFields, + q + ), + }; + let query = model.find(filterQuery); + + if (req.query._sort && req.query._order) + query = query.sort({ + [typeof req.query._sort === 'string' + ? req.query._sort === 'id' + ? '_id' + : req.query._sort + : '_id']: req.query._order === 'ASC' ? 1 : -1, + }); + + if (req.query._start) + query = query.skip( + parseInt( + typeof req.query._start === 'string' ? req.query._start : '0' + ) + ); + + if (req.query._end) + query = query.limit( + Math.min( + parseInt( + typeof req.query._end === 'string' ? req.query._end : '0' + ) - + (req.query._start + ? parseInt( + typeof req.query._start === 'string' + ? req.query._start + : '0' + ) + : 0), + maxRows + ) + ); + else query = query.limit(maxRows); + + if (extraSelects) query = query.select(extraSelects); + + if (Object.keys(filterQuery).length === 0) { + res.set( + 'X-Total-Count', + (await model.estimatedDocumentCount()).toString() + ); + } else { + res.set( + 'X-Total-Count', + (await model.countDocuments(filterQuery)).toString() + ); + } + + return res.json( + virtualId((await query.lean()) as LeanDocument) + ); + } + ); + + /** getOne, getMany */ + if (canGet) + router.get( + '/:id', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.list`) + : (req, res, next) => next(), + async (req, res) => { + await model + .findById(req.params.id) + .select(extraSelects) + .lean() + .then((result) => res.json(virtualId(result))) + .catch((e) => { + return statusMessages.error(res, 400, e); + }); + } + ); + + /** create */ + if (canCreate) + router.post( + '/', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.create`) + : (req, res, next) => next(), + async (req, res) => { + // eslint-disable-next-line new-cap + const result = convertId( + await inputTransformer(filterReadOnly(req.body, readOnlyFields)) + ); + const newData = { + ...result, + }; + + const newEntry = new model(newData); + await newEntry + .save() + .then((result) => res.json(virtualId(result))) + .catch((e: any) => { + return statusMessages.error(res, 400, e, 'Bad request'); + }); + } + ); + + /** update */ + if (canUpdate) + router.put( + '/:id', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.edit`) + : (req, res, next) => next(), + async (req, res) => { + const updateData = { + ...(await convertId( + await inputTransformer(filterReadOnly(req.body, readOnlyFields)) + )), + }; + + await model + .findOneAndUpdate({ _id: req.params.id }, updateData, { + new: true, + runValidators: true, + }) + .lean() + .then((result) => res.json(virtualId(result))) + .catch((e) => { + return statusMessages.error(res, 400, e, 'Bad request'); + }); + } + ); + + /** + * delete + */ + if (canDelete) + router.delete( + '/:id', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.delete`) + : (req, res, next) => next(), + async (req, res) => { + await model + .findOneAndDelete({ _id: req.params.id }) + .then((result) => res.json(virtualId(result))) + .catch((e) => { + return statusMessages.error(res, 404, e, 'Element does not exist'); + }); + } + ); + + return router; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/statusMessages.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/statusMessages.ts new file mode 100644 index 00000000000..5bc2b00043d --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/statusMessages.ts @@ -0,0 +1,24 @@ +/** + * @file statusMessages + * @description handles status messages / error responses + */ + +import type { Response } from 'express'; + +/** + * Handles rejections other than errors. 400, 401, etc. + */ +function reject(res: Response, status: number, reason?: any) { + return res.status(status).json({ message: reason ?? 'Invalid request' }); +} + +/** + * Handles errors + */ +function error(res: Response, status: number, e: Error, message?: string) { + if (process.env.NODE_ENV !== 'production') { + return res.status(status).json({ message, error: e.message }); + } +} + +export default { reject, error }; diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/baseModel.interface.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/baseModel.interface.ts new file mode 100644 index 00000000000..1f6a382a076 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/baseModel.interface.ts @@ -0,0 +1,7 @@ +import type { Model, Document } from 'mongoose'; + +export interface ADPBaseSchema { + _id: string; +} + +export type ADPBaseModel = Model; diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/castFilter.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/castFilter.ts new file mode 100644 index 00000000000..0c1e1008d7c --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/castFilter.ts @@ -0,0 +1,35 @@ +import type { ADPBaseModel } from './baseModel.interface'; + +/** + * Turns all the params into their proper types, string into regexes. + * Only works with shallow objects. + * Mutates original object and returns mutated object. + */ +export default function castFilter( + obj: Record, + model: T, + allowedRegexes: string[] = [] +) { + const { path } = model.schema; + Object.keys(obj).forEach((key) => { + try { + obj[key] = path(key).cast(obj[key], null, null); + } catch (e) {} + + if (allowedRegexes.includes(key) && typeof obj[key] === 'string') { + obj[key] = new RegExp(escapeStringRegexp(obj[key])); + } + }); + + return obj; +} + +function escapeStringRegexp(string) { + if (typeof string !== 'string') { + throw new TypeError('Expected a string'); + } + + // Escape characters with special meaning either inside or outside character sets. + // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar. + return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/convertId.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/convertId.ts new file mode 100644 index 00000000000..ae5b78606ea --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/convertId.ts @@ -0,0 +1,14 @@ +/** Turns id into _id for search queries */ +export default function convertId>(obj: T) { + if (obj.id) { + const newObject = { + _id: obj.id, + ...obj, + }; + + delete newObject.id; + return newObject; + } else { + return obj; + } +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterGetList.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterGetList.ts new file mode 100644 index 00000000000..470c948240b --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterGetList.ts @@ -0,0 +1,17 @@ +export const filterGetListParams = [ + '_sort', + '_order', + '_start', + '_end', +] as const; + +/** Removes _sort, _order, _start, _end from a query. */ +export default function filterGetList>( + obj: T +) { + const filtered: any = {}; + Object.entries(obj).forEach(([index, value]) => { + if (!filterGetListParams.includes(index as any)) filtered[index] = value; + }); + return filtered as Omit; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterReadOnly.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterReadOnly.ts new file mode 100644 index 00000000000..57906468fb2 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterReadOnly.ts @@ -0,0 +1,13 @@ +/** Makes sure that it does not modify crucial and sacred parts mutates the original object. */ +export function filterReadOnly( + obj: T, + readOnlyFields?: string[] +) { + if (!readOnlyFields) return obj as T; + + readOnlyFields.forEach((v) => { + delete obj[v]; + }); + + return obj as Partial; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/parseQuery.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/parseQuery.ts new file mode 100644 index 00000000000..b589e4c643f --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/parseQuery.ts @@ -0,0 +1,38 @@ +import type { ADPBaseModel } from './baseModel.interface'; +import castFilter from './castFilter'; +import { isValidObjectId } from 'mongoose'; + +interface parseQueryParam { + q?: string; + $or?: any; +} + +/** + * Turns ?q into $or queries, deletes q + * @param {Object} results Original object with the q field + * @param {string[]} fields Fields to apply q to + */ +export default function parseQuery< + T extends parseQueryParam, + M extends ADPBaseModel +>( + result: T, + model: M, + allowedRegexes: string[], + fields?: string[] +): T & { $or?: any } { + if (!fields) return result; + if (result.q) { + if (!Array.isArray(result.$or)) result.$or = []; + fields.forEach((field) => { + if (field === '_id' && !isValidObjectId(result.q)) { + // Skip _id search in invalid objectid + return; + } + const newFilter = { [field]: result.q }; + result.$or.push(castFilter(newFilter, model, allowedRegexes)); + }); + delete result.q; + } + return result; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/virtualId.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/virtualId.ts new file mode 100644 index 00000000000..308a2b0160f --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/virtualId.ts @@ -0,0 +1,21 @@ +export default function virtualId( + arr: T[] +): Array; +export default function virtualId( + doc: T +): T & { id: string }; + +/** Virtual ID (_id to id) for react-admin */ +export default function virtualId(el: Array | T) { + if (Array.isArray(el)) { + return el.map((e) => { + return { + id: e._id, + ...e, + _id: undefined, + }; + }); + } + + return { id: el._id, ...el, _id: undefined }; +} diff --git a/server/admin-next/src/server/router/analytics.ts b/server/admin-next/src/server/router/analytics.ts new file mode 100644 index 00000000000..509aa939597 --- /dev/null +++ b/server/admin-next/src/server/router/analytics.ts @@ -0,0 +1,218 @@ +import { Router } from 'express'; +import { auth } from '../middleware/auth'; +import messageModel from '../../../../models/chat/message'; +import groupModel from '../../../../models/group/group'; +import fileModel from '../../../../models/file'; +import dayjs from 'dayjs'; +import { db } from 'tailchat-server-sdk'; + +const router = Router(); + +router.get('/activeGroups', auth(), async (req, res) => { + // 返回最近7天的最活跃的群组 + const day = 7; + const aggregateRes: { _id: string; count: number }[] = await messageModel + .aggregate([ + { + $match: { + createdAt: { + $gte: dayjs().subtract(day, 'd').startOf('d').toDate(), + $lt: dayjs().endOf('d').toDate(), + }, + }, + }, + { + $group: { + _id: '$groupId' as any, + count: { + $sum: 1, + }, + }, + }, + { + $sort: { + count: -1, + }, + }, + { + $limit: 5, + }, + { + $lookup: { + from: 'groups', + localField: '_id', + foreignField: '_id', + as: 'groupInfo', + }, + }, + { + $project: { + _id: 0, + groupId: '$_id', + messageCount: '$count', + groupName: { + $arrayElemAt: ['$groupInfo.name', 0], + }, + }, + }, + ]) + .exec(); + + const activeGroups = aggregateRes; + + res.json({ activeGroups }); +}); + +router.get('/activeUsers', auth(), async (req, res) => { + // 返回最近7天的最活跃的用户 + const day = 7; + const aggregateRes: { _id: string; count: number }[] = await messageModel + .aggregate([ + { + $match: { + author: { + $ne: new db.Types.ObjectId('000000000000000000000000'), + }, + createdAt: { + $gte: dayjs().subtract(day, 'd').startOf('d').toDate(), + $lt: dayjs().endOf('d').toDate(), + }, + }, + }, + { + $group: { + _id: '$author' as any, + count: { + $sum: 1, + }, + }, + }, + { + $sort: { + count: -1, + }, + }, + { + $limit: 5, + }, + { + $lookup: { + from: 'users', + localField: '_id', + foreignField: '_id', + as: 'userInfo', + }, + }, + { + $project: { + _id: 0, + userId: '$_id', + messageCount: '$count', + userName: { + $concat: [ + { + $arrayElemAt: ['$userInfo.nickname', 0], + }, + '#', + { + $arrayElemAt: ['$userInfo.discriminator', 0], + }, + ], + // $arrayElemAt: ['$userInfo.nickname', 0], + }, + }, + }, + ]) + .exec(); + + const activeUsers = aggregateRes; + + res.json({ activeUsers }); +}); + +router.get('/largeGroups', auth(), async (req, res) => { + // 返回最大的 5 个群组 + const limit = 5; + const aggregateRes: { _id: string; count: number }[] = await groupModel + .aggregate([ + { + $project: { + name: 1, + memberCount: { + $size: '$members', + }, + }, + }, + { + $sort: { + memberCount: -1, + }, + }, + { + $limit: limit, + }, + ]) + .exec(); + + const largeGroups = aggregateRes; + + res.json({ largeGroups }); +}); + +router.get('/fileStorageUserTop', auth(), async (req, res) => { + // 返回最大的 5 个群组 + const limit = 5; + const aggregateRes: { _id: string; count: number }[] = await fileModel + .aggregate([ + { + $group: { + _id: '$userId', + total: { + $sum: '$size', + }, + } as any, + }, + { + $sort: { + total: -1, + }, + }, + { + $limit: limit, + }, + { + $lookup: { + from: 'users', + localField: '_id', + foreignField: '_id', + as: 'userInfo', + }, + }, + { + $project: { + _id: 0, + userId: '$_id', + fileStorageTotal: '$total', + userName: { + $concat: [ + { + $arrayElemAt: ['$userInfo.nickname', 0], + }, + '#', + { + $arrayElemAt: ['$userInfo.discriminator', 0], + }, + ], + // $arrayElemAt: ['$userInfo.nickname', 0], + }, + }, + }, + ]) + .exec(); + + const fileStorageUserTop = aggregateRes; + + res.json({ fileStorageUserTop }); +}); + +export { router as analyticsRouter }; diff --git a/server/admin-next/src/server/router/api.ts b/server/admin-next/src/server/router/api.ts new file mode 100644 index 00000000000..63f3d6702bb --- /dev/null +++ b/server/admin-next/src/server/router/api.ts @@ -0,0 +1,390 @@ +import { Router } from 'express'; +import jwt from 'jsonwebtoken'; +import { broker, callBrokerAction } from '../broker'; +import { adminAuth, auth, authSecret } from '../middleware/auth'; +import { configRouter } from './config'; +import { networkRouter } from './network'; +import { fileRouter } from './file'; +import dayjs from 'dayjs'; +import userModel from '../../../../models/user/user'; +import userLoginLogModel from '../../../../models/user/userLoginLog'; +import messageModel from '../../../../models/chat/message'; +import fileModel from '../../../../models/file'; +import groupModel from '../../../../models/group/group'; +import { + raExpressMongoose, + virtualId, +} from '../middleware/express-mongoose-ra-json-server'; +import { cacheRouter } from './cache'; +import discoverModel from '../../../../plugins/com.msgbyte.discover/models/discover'; +import { analyticsRouter } from './analytics'; +import _ from 'lodash'; + +const router = Router(); + +router.post('/login', (req, res) => { + if (!adminAuth.username || !adminAuth.password) { + res.status(401).end('Server not set env: ADMIN_USER, ADMIN_PASS'); + return; + } + + const { username, password } = req.body; + + if (username === adminAuth.username && password === adminAuth.password) { + // 用户名和密码都正确,返回token + const token = jwt.sign( + { + username, + platform: 'admin-next', + }, + authSecret, + { + expiresIn: '2h', + } + ); + + res.status(200).json({ + username, + token: token, + expiredAt: new Date().valueOf() + 2 * 60 * 60 * 1000, + }); + } else { + res.status(401).end('username or password incorrect'); + } +}); + +router.use('/analytics', analyticsRouter); +router.use('/network', networkRouter); +router.use('/config', configRouter); +router.use('/file', fileRouter); +router.use('/cache', cacheRouter); + +router.post('/callAction', auth(), async (req, res) => { + const { action, params } = req.body; + const ret = await callBrokerAction(action, params); + + res.json(ret); +}); + +router.get('/user/count/summary', auth(), async (req, res) => { + // 返回最近14天的用户数统计 + const day = 14; + const aggregateRes: { count: number; date: string }[] = await userModel + .aggregate([ + { + $match: { + createdAt: { + $gte: dayjs().subtract(day, 'd').startOf('d').toDate(), + $lt: dayjs().endOf('d').toDate(), + }, + }, + }, + { + $group: { + _id: { + createdAt: { + $dateToString: { + format: '%Y-%m-%d', + date: '$createdAt', + }, + }, + } as any, + count: { + $sum: 1, + }, + }, + }, + { + $project: { + date: '$_id.createdAt', + count: '$count', + }, + }, + ]) + .exec(); + + const summary = Array.from({ length: day }) + .map((_, d) => { + const date = dayjs().subtract(d, 'd').format('YYYY-MM-DD'); + + return { + date, + count: aggregateRes.find((r) => r.date === date)?.count ?? 0, + }; + }) + .reverse(); + + res.json({ summary }); +}); +router.post('/user/ban', auth(), async (req, res) => { + const { userId } = req.body; + + const ret = await broker.call('user.banUser', { + userId, + }); + + res.json({ + ret, + }); +}); +router.post('/user/unban', auth(), async (req, res) => { + const { userId } = req.body; + + const ret = await broker.call('user.unbanUser', { + userId, + }); + + res.json({ + ret, + }); +}); +router.post('/users/system/notify', auth(), async (req, res) => { + const { scope, specifiedUser, title, content } = req.body; + + let userIds = []; + + if (scope === 'all') { + const users = await userModel.find( + { + // false 或 null(正式用户或者老的用户) + temporary: { + $ne: true, + }, + }, + { + _id: 1, + } + ); + + userIds = users.map((u) => u._id); + } else if (scope === 'specified') { + userIds = Array.isArray(specifiedUser) ? specifiedUser : [specifiedUser]; + } + + broker.call('chat.inbox.batchAppend', { + userIds, + type: 'markdown', + payload: { + title, + content, + }, + }); + + res.json({ userIds }); +}); +router.use( + '/users', + auth(), + raExpressMongoose(userModel, { + q: ['_id', 'nickname', 'email'], + allowedRegexFields: ['nickname'], + }) +); +router.use( + '/user_login_logs', + auth(), + raExpressMongoose(userLoginLogModel, { + q: ['ip', 'userAgent'], + allowedRegexFields: ['ip', 'userAgent'], + capabilities: { + create: false, + update: false, + delete: false, + }, + }) +); +router.delete('/messages/:id', auth(), async (req, res) => { + try { + const messageId = req.params.id; + await callBrokerAction('chat.message.deleteMessage', { + messageId, + }); + + res.json({ id: messageId }); + } catch (err) { + console.error(err); + res.status(500).json({ message: (err as any).message }); + } +}); + +router.get('/message/count/summary', auth(), async (req, res) => { + // 返回最近14天的消息数统计 + const day = 14; + const aggregateRes: { count: number; date: string }[] = await messageModel + .aggregate([ + { + $match: { + createdAt: { + $gte: dayjs().subtract(day, 'd').startOf('d').toDate(), + $lt: dayjs().endOf('d').toDate(), + }, + }, + }, + { + $group: { + _id: { + createdAt: { + $dateToString: { + format: '%Y-%m-%d', + date: '$createdAt', + }, + }, + } as any, + count: { + $sum: 1, + }, + }, + }, + { + $project: { + date: '$_id.createdAt', + count: '$count', + }, + }, + ]) + .exec(); + + const summary = Array.from({ length: day }) + .map((_, d) => { + const date = dayjs().subtract(d, 'd').format('YYYY-MM-DD'); + + return { + date, + count: aggregateRes.find((r) => r.date === date)?.count ?? 0, + }; + }) + .reverse(); + + res.json({ summary }); +}); +router.use( + '/messages', + auth(), + raExpressMongoose(messageModel, { + q: ['content'], + allowedRegexFields: ['content'], + }) +); + +router.post('/groups/', auth(), async (req, res) => { + // create group + const { name, owner } = req.body; + + const group = await groupModel.createGroup({ + name, + owner, + }); + + res.json({ + id: group._id, + }); +}); +router.use( + '/groups', + auth(), + raExpressMongoose(groupModel, { + q: ['_id', 'name'], + capabilities: { + create: false, + }, + }) +); + +router.delete('/file/:id', auth(), async (req, res) => { + try { + const fileId = req.params.id; + + const record = await fileModel.findById(fileId); + if (record) { + await callBrokerAction('file.delete', { + objectName: record.objectName, + }); + } + + res.json({ id: fileId }); + } catch (err) { + console.error(err); + res.status(500).json({ message: (err as any).message }); + } +}); +router.use( + '/file', + auth(), + async (req, res, next) => { + const onlyChatFile = req.query.meta === 'onlyChat'; + + if (!onlyChatFile) { + return next(); + } + + // only return chatted file rather than all file + const result = await fileModel + .aggregate() + .lookup({ + from: 'users', + localField: 'url', + foreignField: 'avatar', + as: 'avatarMatchedUser', + }) + .lookup({ + from: 'groups', + localField: 'url', + foreignField: 'avatar', + as: 'avatarMatchedGroup', + }) + .lookup({ + from: 'groups', + localField: 'url', + foreignField: 'config.groupBackgroundImage', + as: 'backgroundMatchedGroup', + }) + .match({ + 'avatarMatchedUser.0': { $exists: false }, + 'avatarMatchedGroup.0': { $exists: false }, + 'backgroundMatchedGroup.0': { $exists: false }, + }) + .project({ + avatarMatchedUser: 0, + avatarMatchedGroup: 0, + backgroundMatchedGroup: 0, + }) + .facet({ + metadata: [{ $count: 'total' }], + data: [ + { + $sort: { + [typeof req.query._sort === 'string' + ? req.query._sort === 'id' + ? '_id' + : req.query._sort + : '_id']: req.query._order === 'ASC' ? 1 : -1, + }, + }, + { $skip: Number(req.query._start) }, + { $limit: Number(req.query._end) - Number(req.query._start) }, + ], + }) + .exec(); + + const list = _.get(result, '0.data'); + const total = _.get(result, '0.metadata.0.total'); + + return res.set('X-Total-Count', total).json(virtualId(list)).end(); + }, + raExpressMongoose(fileModel, { + q: ['objectName'], + allowedRegexFields: ['objectName'], + capabilities: { + delete: false, + }, + maxRows: 2000, + }) +); +router.use( + '/mail', + auth(), + raExpressMongoose(require('../../../../models/user/mail').default) +); +router.use('/p_discover', auth(), raExpressMongoose(discoverModel)); + +export { router as apiRouter }; diff --git a/server/admin-next/src/server/router/cache.ts b/server/admin-next/src/server/router/cache.ts new file mode 100644 index 00000000000..24cd08356f8 --- /dev/null +++ b/server/admin-next/src/server/router/cache.ts @@ -0,0 +1,32 @@ +import { Router } from 'express'; +import { broker } from '../broker'; +import { auth } from '../middleware/auth'; + +const router = Router(); + +/** + * 清理所有缓存 + */ +router.post('/clean', auth(), async (req, res, next) => { + try { + if (!broker.cacher) { + res.json({ + success: false, + message: 'Not found cacher', + }); + return; + } + + const { target = undefined } = req.body; + + await broker.cacher.clean(target); + + res.json({ + success: true, + }); + } catch (err) { + next(err); + } +}); + +export { router as cacheRouter }; diff --git a/server/admin-next/src/server/router/config.ts b/server/admin-next/src/server/router/config.ts new file mode 100644 index 00000000000..134bc0a7a2f --- /dev/null +++ b/server/admin-next/src/server/router/config.ts @@ -0,0 +1,38 @@ +/** + * Network 相关接口 + */ + +import { Router } from 'express'; +import { broker } from '../broker'; +import { auth } from '../middleware/auth'; + +const router = Router(); + +router.get('/client', auth(), async (req, res, next) => { + try { + const config = await broker.call('config.client'); + + res.json({ + config, + }); + } catch (err) { + next(err); + } +}); + +router.patch('/client', auth(), async (req, res, next) => { + try { + await broker.call('config.setClientConfig', { + key: req.body.key, + value: req.body.value, + }); + + res.json({ + success: true, + }); + } catch (err) { + next(err); + } +}); + +export { router as configRouter }; diff --git a/server/admin-next/src/server/router/file.ts b/server/admin-next/src/server/router/file.ts new file mode 100644 index 00000000000..d432036ef1b --- /dev/null +++ b/server/admin-next/src/server/router/file.ts @@ -0,0 +1,82 @@ +/** + * Network 相关接口 + */ + +import { Router } from 'express'; +import { callBrokerAction } from '../broker'; +import { auth } from '../middleware/auth'; +import Busboy from '@fastify/busboy'; +import fileModel from '../../../../models/file'; + +const router = Router(); + +router.put('/upload', auth(), async (req, res) => { + const busboy = new Busboy({ headers: req.headers as any }); + + const promises: Promise[] = []; + busboy.on('file', (fieldname, file, filename, encoding, mimetype) => { + promises.push( + callBrokerAction('file.save', file, { + filename: filename, + }) + .then((data) => { + console.log(data); + return data; + }) + .catch((err) => { + file.resume(); // Drain file stream to continue processing form + busboy.emit('error', err); + return err; + }) + ); + }); + + busboy.on('finish', async () => { + /* istanbul ignore next */ + if (promises.length == 0) { + res.status(500).json('File missing in the request'); + return; + } + + try { + const files = await Promise.all(promises); + + res.json({ files }); + } catch (err) { + console.error(err); + res.status(500).json(String(err)); + } + }); + + busboy.on('error', (err) => { + console.error(err); + req.unpipe(busboy); + req.resume(); + res.status(500).json({ err }); + }); + + req.pipe(busboy); +}); + +router.get('/filesizeSum', auth(), async (req, res) => { + const ret = await fileModel.aggregate([ + { + $group: { + _id: '$objectName' as any, + size: { $first: '$size' }, + }, + }, + { + $group: { + _id: null, + totalSize: { $sum: '$size' }, + }, + }, + ]); + + const totalSize = ret[0].totalSize; + + res.json({ totalSize }); +}); + +export { router as fileRouter }; diff --git a/server/admin-next/src/server/router/network.ts b/server/admin-next/src/server/router/network.ts new file mode 100644 index 00000000000..1e56b3f9bf4 --- /dev/null +++ b/server/admin-next/src/server/router/network.ts @@ -0,0 +1,37 @@ +/** + * Network 相关接口 + */ + +import { Router } from 'express'; +import { broker } from '../broker'; +import { auth } from '../middleware/auth'; +import _ from 'lodash'; + +const router = Router(); + +router.get('/all', auth(), async (req, res) => { + res.json({ + nodes: Array.from(new Map(broker.registry.nodes.nodes).values()).map( + (item) => + _.pick(item, [ + 'id', + 'available', + 'local', + 'ipList', + 'hostname', + 'cpu', + 'client', + ]) + ), + events: broker.registry.events.events.map((item: any) => item.name), + services: broker.registry.services.services.map((item: any) => item.name), + actions: Array.from(new Map(broker.registry.actions.actions).keys()), + }); +}); + +router.get('/ping', auth(), async (req, res) => { + const pong = await broker.ping(); + res.json(pong); +}); + +export { router as networkRouter }; diff --git a/server/admin-next/tsconfig.json b/server/admin-next/tsconfig.json new file mode 100644 index 00000000000..293ba9620a4 --- /dev/null +++ b/server/admin-next/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "jsx": "react-jsx", + "forceConsistentCasingInFileNames": true, + "importsNotUsedAsValues": "error", + "module": "CommonJS", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/server/admin-next/tsconfig.server.json b/server/admin-next/tsconfig.server.json new file mode 100644 index 00000000000..7d15171ea42 --- /dev/null +++ b/server/admin-next/tsconfig.server.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/server/**/*.ts", "../models/**/*.ts"], + "exclude": ["node_modules/**/*", "dist"], + "compilerOptions": { + "rootDirs": ["./", "../"], + "outDir": "./dist", + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "importsNotUsedAsValues": "error", + "experimentalDecorators": true, + "noEmit": false + } +} diff --git a/server/admin-next/vite.config.ts b/server/admin-next/vite.config.ts new file mode 100644 index 00000000000..1112f92e21f --- /dev/null +++ b/server/admin-next/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + base: '/admin-next/', + plugins: [react()], + server: { + open: false, + hmr: { port: 24679 }, + }, + preview: { + open: false, + }, +}); diff --git a/server/package.json b/server/package.json index 004bf1b05be..b2a7c777710 100644 --- a/server/package.json +++ b/server/package.json @@ -6,11 +6,12 @@ "author": "moonrailgun ", "license": "Apache-2.0", "scripts": { - "dev": "concurrently --kill-others npm:dev:main npm:dev:sdk npm:dev:plugins npm:dev:admin", + "dev": "concurrently --kill-others npm:dev:main npm:dev:sdk npm:dev:plugins npm:dev:admin npm:dev:admin-next", "dev:main": "ts-node ./runner.ts", "dev:sdk": "cd packages/sdk && pnpm watch", "dev:plugins": "pnpm run --filter \"./plugins/*\" build:web:watch", "dev:admin": "cd admin && pnpm dev", + "dev:admin-next": "cd admin-next && pnpm dev", "debug": "node --inspect -r ts-node/register ./runner.ts", "build": "ts-node scripts/build.ts", "start:service": "cd dist && tailchat-runner --config moleculer.config.js",