SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,111 @@
|
||||
# Host appearance overlays
|
||||
|
||||
Set `SNOWLUMA_WEBUI_TARGET=maibot` on the **SnowLuma process** to use MaiBot's
|
||||
millennium appearance. The same release bundle supports standalone and embedded
|
||||
deployments. An unset or empty value keeps the existing SnowLuma experience;
|
||||
unknown values fail WebUI startup with an explicit diagnostic.
|
||||
|
||||
This first adapter implements **millennium only**, in light and dark modes.
|
||||
Other MaiBot styles are not implemented. There is no new appearance selector in
|
||||
SnowLuma. The host owns theme changes; existing theme controls are unavailable
|
||||
while managed, and the settings page explains which preferences are preserved.
|
||||
|
||||
## Runtime and ownership
|
||||
|
||||
1. Core reads the environment at startup. [Static document delivery](../core/src/webui/static-frontend.ts)
|
||||
injects a validated, public JSON bootstrap into `/`, `/index.html` and SPA
|
||||
fallback documents. Documents use `Cache-Control: no-store`. No secrets or
|
||||
arbitrary environment variables reach the browser.
|
||||
2. [Frontend bootstrap](src/appearance/runtime.ts) loads only the selected
|
||||
target's assets before mounting React. Standalone deployments load no target
|
||||
stylesheet, font or host listener. Vite development serves the same bootstrap
|
||||
when its process has the environment variable; production builds remain neutral.
|
||||
3. [ThemeContext](src/contexts/ThemeContext.tsx) keeps `savedAppearance` separate
|
||||
from effective `appearance`. Only saved preferences enter localStorage or
|
||||
`config/ui.json`. Target updates never invoke the persistence API.
|
||||
4. The target owns colors, fonts, radius, wallpaper, CSS variable overrides,
|
||||
custom CSS and custom cursor presentation. These saved values are preserved
|
||||
but suspended. UI scale, density, motion/accessibility, navigation layout and
|
||||
data preferences remain available. Safe-mode restrictions still apply when
|
||||
returning to native appearance.
|
||||
5. A single layout effect applies effective appearance. Changes do not reload
|
||||
the document, change React keys or restart sessions. All target rules are
|
||||
scoped to `html[data-webui-target]`, including portals under `body`.
|
||||
|
||||
## Host protocol, version 1
|
||||
|
||||
The contract lives in [common/webui-target.ts](../common/src/webui-target.ts).
|
||||
The deployment environment is an opt-in, **not a live transport**. Existing
|
||||
launchers need to implement the sender below; merely setting the environment
|
||||
variable cannot convey another process's current theme.
|
||||
|
||||
A same-document embedding bridge (for example Electron `executeJavaScript`)
|
||||
publishes the latest snapshot before dispatching the event:
|
||||
|
||||
```js
|
||||
const appearance = {
|
||||
version: 1,
|
||||
target: 'maibot',
|
||||
style: 'millennium',
|
||||
mode: 'dark', // 'light' | 'dark' | 'system'
|
||||
};
|
||||
window.__SNOWLUMA_HOST_APPEARANCE__ = appearance;
|
||||
window.dispatchEvent(new CustomEvent('snowluma:host-appearance', { detail: appearance }));
|
||||
```
|
||||
|
||||
The snapshot handles messages sent before React or the lazy asset loader is
|
||||
ready. Publish on each guest `dom-ready` and whenever the host's resolved
|
||||
brightness changes. A host that already follows MaiBot should forward its own
|
||||
resolved `light`/`dark` state; this avoids competing sources of truth. Validate
|
||||
the guest's expected origin before injecting, serialize values with
|
||||
`JSON.stringify`, and do not enable Node integration in the guest.
|
||||
|
||||
Send `{ version: 1, target: null }` using the same mechanism to release the
|
||||
override for the current document. The original appearance returns immediately.
|
||||
The opted-in document keeps its host subscription so the host can reapply the
|
||||
target later. Listeners are released on provider unmount. This does not change
|
||||
the startup environment or enable a target in a standalone deployment.
|
||||
|
||||
The current adapter starts with millennium and system brightness before a host
|
||||
snapshot arrives. The last valid snapshot remains active if the host stops
|
||||
sending. Snapshots are not persisted or shared across origins/tabs. A reload
|
||||
requires the host to publish again. Unsupported versions, targets, styles or
|
||||
modes are rejected with `[SnowLuma appearance]` console diagnostics; the previous
|
||||
valid snapshot remains. The host cannot supply CSS, URLs or executable content
|
||||
through this protocol. No unauthenticated HTTP write endpoint is introduced.
|
||||
|
||||
## Adding a target
|
||||
|
||||
- Add its identifier, styles and default style to `WEBUI_TARGETS` in the shared
|
||||
contract. Keep validation at this boundary; do not accept arbitrary strings.
|
||||
- Add one lazy loader to the exhaustive [frontend registry](src/appearance/registry.ts).
|
||||
Implement a pure saved-to-effective resolver and scoped stylesheet under
|
||||
`src/appearance/targets`. Keep any fonts local with their redistribution license.
|
||||
- Use semantic color variables, shared component classes and neutral
|
||||
`data-ui-surface` markers. Do not add target-name checks to business pages.
|
||||
- Never persist resolved overrides, mutate saved preferences, introduce a
|
||||
second DOM theme writer, or remount the application to switch presentation.
|
||||
- Test default asset/listener isolation, initial document routes, malformed
|
||||
messages, rapid updates, cleanup and exact restoration of saved preferences.
|
||||
Verify forms, focus, scroll, portals and mobile layout with an isolated backend.
|
||||
Electron integration needs a separate real-host check.
|
||||
|
||||
## Sources and checks
|
||||
|
||||
The adapter follows the main MaiBot repository's
|
||||
[millennium stylesheet](https://github.com/Mai-with-u/MaiBot/blob/5c7637e6913c84d1c3d5e742fa9e95a7e70d3501/dashboard/src/styles/millennium.css),
|
||||
not the archived standalone WebUI. Colors/materials also match the
|
||||
[OneKey shell](https://github.com/Mai-with-u/MaiBotOneKey/blob/e8073b0bce35c8c4da973b06156602e14c42fee5/src/renderer/src/styles/millennium.css).
|
||||
The bundled Jersey 20 font is from that MaiBot revision, copyright The Soft Type
|
||||
Project Authors, under the [SIL Open Font License](src/appearance/targets/assets/Jersey20-OFL.txt).
|
||||
Its license is emitted alongside the font in production assets.
|
||||
|
||||
Focused checks (from the repository root):
|
||||
|
||||
```sh
|
||||
pnpm --filter @snowluma/common exec vitest run tests/webui-target.test.ts
|
||||
pnpm --filter @snowluma/core exec vitest run tests/webui-static-frontend.test.ts
|
||||
pnpm --filter webui exec vitest run tests/target-appearance.test.ts
|
||||
```
|
||||
|
||||
Run the normal workspace checks from `AGENTS.md` before handing off changes.
|
||||
@@ -0,0 +1,130 @@
|
||||
# SnowLuma WebUI
|
||||
|
||||
SnowLuma's browser console is a Vite + React application for managing runtime
|
||||
status, OneBot configuration, logs, storage, and server settings.
|
||||
|
||||
## Default appearance
|
||||
|
||||
New installations and appearance resets use the mascot's lavender accent.
|
||||
The light variant uses a deeper purple for readable button labels; dark mode
|
||||
uses a softer lavender. Existing saved accents and custom colors are preserved.
|
||||
The original sky-blue accent remains available in Settings.
|
||||
|
||||
With the default palette, page, card, input, popover and sidebar surfaces mix a
|
||||
small amount of the active accent into neutral light/dark backgrounds. Text stays
|
||||
neutral for readability. Sidebar-only accents tint only the sidebar; OLED keeps a
|
||||
true-black canvas. Named palettes, custom CSS variables and host appearance
|
||||
overlays retain control of their own surfaces.
|
||||
|
||||
Keep the server defaults in `packages/core/src/webui/ui-config.ts`, the frontend
|
||||
defaults and preset in `src/contexts/ThemeContext.tsx`, and the pre-mount tokens
|
||||
in `src/index.css` aligned when changing the default accent.
|
||||
|
||||
The browser favicon, home-screen icon and in-app logo share the mascot avatar
|
||||
in [`assets/brand/mascot-avatar.png`](../../assets/brand/mascot-avatar.png).
|
||||
Its exported sizes live in `public/`: `logo.png` (512 px), `favicon.png` (64 px),
|
||||
and `apple-touch-icon.png` (180 px). Export from the master without redrawing
|
||||
individual sizes.
|
||||
|
||||
## Host appearance integration
|
||||
|
||||
`SNOWLUMA_WEBUI_TARGET=maibot` opts a deployment into a reversible millennium
|
||||
appearance overlay. Standalone defaults are unchanged. Hosts drive live light/dark
|
||||
updates without saving over personal preferences or adding another selector.
|
||||
See [the integration contract and extension guide](INTEGRATIONS.md) before changing
|
||||
target startup, theme ownership, host messaging or scoped styles.
|
||||
|
||||
## Interaction conventions
|
||||
|
||||
- Destructive actions and second confirmations stay in modal dialogs.
|
||||
- Operations that may take time show a bottom-right running state.
|
||||
- Completed and failed operations use bottom-right result notices that close
|
||||
automatically after a visible countdown.
|
||||
- Routine, high-frequency adjustments should not generate a notice for every
|
||||
intermediate edit.
|
||||
- Foreground polling pauses in hidden tabs and waits for each request to finish
|
||||
before scheduling the next. Connection-state polling runs only while SSE clients
|
||||
are subscribed.
|
||||
- Data-backed surfaces use the Interior Skeleton Swap defaults (120 ms delay,
|
||||
380 ms minimum visibility) so fast local responses do not flash a placeholder.
|
||||
Submission, upload, and other explicit operation progress keeps its spinner.
|
||||
- Variable-height content must release the skeleton's reserved box after it is
|
||||
ready; do not introduce a second scrolling region inside the page.
|
||||
- The mobile navigation menu uses a controlled Icon Morph whose visual state
|
||||
follows the actual drawer state.
|
||||
- Failures must remain visible in the affected control and in the operation
|
||||
result; do not convert failed responses into successful feedback.
|
||||
- JSON endpoints must return an object. Malformed or non-object responses are
|
||||
reported as errors rather than displayed as empty lists or default settings.
|
||||
- Configuration responses include `config`; saves also report `saved`, `applied`,
|
||||
`online`, `errors`, and `message`. The UI uses these fields without legacy aliases
|
||||
or a second request after saving.
|
||||
- Password change flows use floating labels and inline validation consistently.
|
||||
- Password strength details appear only while the new-password field has focus
|
||||
and leave with the same motion language when focus moves away.
|
||||
- The first-run wizard keeps agreement text as the primary reading surface and
|
||||
aligns each step's actions inside one responsive footer. Settings includes a
|
||||
visible developer page for safely replaying this wizard and exercising the
|
||||
page-level crash screen; replayed consent and passwords are never persisted.
|
||||
|
||||
## Authentication behind a reverse proxy
|
||||
|
||||
Authenticated WebUI requests send the session token in `X-SnowLuma-Token`, leaving
|
||||
`Authorization` available for the reverse proxy's HTTP authentication (such as
|
||||
Nginx Basic Auth). Forward `X-SnowLuma-Token` unchanged if the proxy filters
|
||||
request headers. The backend still accepts `Authorization: Bearer <token>` for
|
||||
existing clients when the dedicated header is absent. Both layers still require
|
||||
their own valid credentials.
|
||||
|
||||
Late authentication failures from an old session do not sign out a newer session.
|
||||
Live streams release subscriptions on disconnect and pause with the page.
|
||||
Malformed stream data is reported; a debug stream invocation fails instead of silently succeeding.
|
||||
|
||||
## Process account detection
|
||||
|
||||
Process cards use QQ's local account data before injection and the hook's confirmed
|
||||
identity after login. Missing local data means the account is unknown. It does not
|
||||
prove that QQ is signed out, and local detection alone never marks a bridge online.
|
||||
The manual login probe and `/api/processes/:pid/probe-login` endpoint are removed.
|
||||
|
||||
Linux resolves data roots from each QQ process's `XDG_CONFIG_HOME` or `HOME`.
|
||||
Windows uses the bundled addon’s `getFileHoldersAsync` to find the process holding
|
||||
each account database. Query failures are reported instead of treated as no login.
|
||||
|
||||
## Account notification templates
|
||||
|
||||
Webhook bodies and email subjects/bodies support `{uin}`, `{nickname}`, `{event}`
|
||||
(`offline` or `online`), `{time}`, `{status}` (Hook process state), `{pid}`, and
|
||||
`{error}` (Hook process error). New webhook and email channels include process
|
||||
status in their default body; existing saved templates are preserved.
|
||||
JSON templates escape substituted strings, including quotes, line breaks and control characters.
|
||||
|
||||
Process facts are resolved when the notification is dispatched, after any offline
|
||||
debounce. Each transition carries its process PID, including the final PID detached
|
||||
when an account goes offline. Facts are looked up for that PID and UIN without
|
||||
substituting another process. Missing facts render empty and mean unknown, not
|
||||
proof that QQ exited. Hook status alone does not identify the cause of a QQ login
|
||||
failure. Notifications for one account are delivered in transition order; a slow
|
||||
account does not delay other accounts.
|
||||
|
||||
## Local development
|
||||
|
||||
Packaged installations offer download and restart controls in Settings → About.
|
||||
Downloads are verified and staged; files are applied before the application starts.
|
||||
Restart confirmation uses the shared modal, and download results use action feedback.
|
||||
|
||||
On Windows, the native API logs the process token and EXE integrity label once
|
||||
at startup. A Low (or lower) token triggers a WebUI warning; file labels and
|
||||
query failures alone do not. “临时关闭” lasts for the tab session, including
|
||||
reloads; “永久关闭” persists in this browser's site storage. Neither suppresses
|
||||
startup logs. Failed queries and preference writes remain visible.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
pnpm --filter webui dev
|
||||
pnpm --filter webui typecheck
|
||||
pnpm --filter webui lint
|
||||
pnpm --filter webui test
|
||||
pnpm --filter webui build
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import js from '@eslint/js';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier/flat';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
]);
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="light" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#8561bd" />
|
||||
<!-- WebUI is an admin surface; tell every compliant crawler to skip it. -->
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="googlebot" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="bingbot" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<title>SnowLuma 控制台</title>
|
||||
<!-- Pre-paint theme bootstrap: applies the cached appearance to <html>
|
||||
before the bundle mounts, so dark/OLED/density/scale don't flash from
|
||||
the default light theme. Mirrors a subset of ThemeContext.applyAppearance;
|
||||
defensive (never breaks page load). The bundle re-applies authoritatively. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
// Deployment overlays are prepared before React mounts. Do not flash
|
||||
// a saved palette/font underneath an opted-in host presentation.
|
||||
if (document.getElementById('snowluma-webui-target')) return;
|
||||
var raw = localStorage.getItem('snowluma_ui_appearance');
|
||||
var a = raw ? JSON.parse(raw) : {};
|
||||
if (!a || typeof a !== 'object') a = {};
|
||||
var root = document.documentElement;
|
||||
var mode = a.mode;
|
||||
var pal = a.palette;
|
||||
// A non-default palette fixes light/dark; these flavors are light, the rest dark.
|
||||
var LIGHT_PAL = { 'catppuccin-latte': 1, 'rose-pine-dawn': 1, 'everforest-light': 1 };
|
||||
var isPal = typeof pal === 'string' && pal.length > 0 && pal !== 'default';
|
||||
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var resolved = isPal
|
||||
? (LIGHT_PAL[pal] ? 'light' : 'dark')
|
||||
: (mode === 'light' || mode === 'dark'
|
||||
? mode
|
||||
: (prefersDark ? 'dark' : 'light'));
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolved);
|
||||
root.setAttribute('data-theme', resolved);
|
||||
root.style.colorScheme = resolved;
|
||||
var themeMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeMeta) themeMeta.setAttribute('content', resolved === 'dark' ? '#0a0f1a' : '#f4f7fb');
|
||||
if (isPal) root.setAttribute('data-palette', pal);
|
||||
if (a.density === 'compact' || a.density === 'cozy') root.setAttribute('data-density', a.density);
|
||||
// A palette forces soft so the OLED "black" rule can't fight it.
|
||||
var intensity = isPal ? 'soft' : a.darkIntensity;
|
||||
if (intensity === 'black' || intensity === 'soft') root.setAttribute('data-dark-intensity', intensity);
|
||||
if (a.sidebarStyle === 'follow' || a.sidebarStyle === 'panel' || a.sidebarStyle === 'accent') root.setAttribute('data-sidebar-style', a.sidebarStyle);
|
||||
root.setAttribute('data-contrast', a.highContrast ? 'high' : 'normal');
|
||||
var systemReduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
root.setAttribute('data-reduce-motion', (a.reduceMotion || a.disableMotion || systemReduce) ? '1' : '0');
|
||||
if (a.disableMotion) root.setAttribute('data-no-motion', '1');
|
||||
if (typeof a.radius === 'number' && a.radius >= 0 && a.radius <= 2) root.style.setProperty('--radius', a.radius + 'rem');
|
||||
if (typeof a.uiScale === 'number' && a.uiScale >= 0.5 && a.uiScale <= 2) {
|
||||
root.style.fontSize = (Math.round(16 * a.uiScale * 100) / 100) + 'px';
|
||||
}
|
||||
} catch (e) { /* ignore — fall back to default theme */ }
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"name": "webui",
|
||||
"private": true,
|
||||
"version": "2.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slider": "^1.4.3",
|
||||
"@radix-ui/react-slot": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.9",
|
||||
"@snowluma/common": "workspace:*",
|
||||
"@tanstack/react-router": "^1.169.2",
|
||||
"@tanstack/react-virtual": "^3.14.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"gridstack": "^12.6.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"motion": "^12.0.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"uqr": "^0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jiti": "^2.6.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "catalog:",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.22.1 || >=26.12.1"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 269 KiB |
@@ -0,0 +1,519 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { AlertTriangle, LogOut, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||
import { SessionProvider } from '@/contexts/SessionContext';
|
||||
import { LoginPage } from '@/components/pages/login-page';
|
||||
import {
|
||||
OnboardingWizardPage,
|
||||
type AdditionalOnboardingStep,
|
||||
} from '@/components/pages/onboarding-wizard-page';
|
||||
import { SkeletonSwap, useSkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { ApiProvider, createApiClient, useApi, type ApiClient } from '@/lib/api';
|
||||
import { DebugTaskProvider } from '@/contexts/DebugTaskContext';
|
||||
import { TaskBadge } from '@/components/debug/task-badge';
|
||||
import { AdaptivePointer } from '@/components/ui/adaptive-pointer';
|
||||
import { useFinePointer } from '@/hooks/use-media-query';
|
||||
import { GlobalContextMenu } from '@/components/ui/global-context-menu';
|
||||
import { InsecureRemoteAccessBanner } from '@/components/insecure-remote-access-banner';
|
||||
import {
|
||||
actionErrorMessage,
|
||||
ActionFeedbackProvider,
|
||||
ActionFeedbackViewport,
|
||||
useActionFeedback,
|
||||
} from '@/contexts/ActionFeedbackContext';
|
||||
import type { AgreementsPayload } from '@/lib/api/types';
|
||||
import { appRouter } from '@/router';
|
||||
import {
|
||||
browserPath,
|
||||
DEVELOPER_ONBOARDING_RETURN_KEY,
|
||||
DEVELOPER_ONBOARDING_URL,
|
||||
DEVELOPER_SETTINGS_URL,
|
||||
developerOnboardingReturnPath,
|
||||
isDeveloperOnboardingLocation,
|
||||
} from '@/lib/onboarding-navigation';
|
||||
import { onboardingExecution } from '@/lib/onboarding-actions';
|
||||
|
||||
interface AppProps {
|
||||
onboardingSteps?: AdditionalOnboardingStep[];
|
||||
}
|
||||
|
||||
export default function App({ onboardingSteps = [] }: AppProps) {
|
||||
return (
|
||||
<ActionFeedbackProvider>
|
||||
<ThemeProvider>
|
||||
<InsecureRemoteAccessBanner />
|
||||
<ActionFeedbackViewport />
|
||||
<AdaptivePointer />
|
||||
<GlobalContextMenu />
|
||||
<AuthBoundary onboardingSteps={onboardingSteps} />
|
||||
</ThemeProvider>
|
||||
</ActionFeedbackProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// #194: read a one-shot `?token=<password>` login param and immediately strip
|
||||
// it from the URL (replaceState) so the credential doesn't linger in the
|
||||
// address bar / browser history / Referer header. Named `token` to match the
|
||||
// requested query key; the value is the WebUI login password.
|
||||
function consumeUrlToken(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const value = url.searchParams.get('token');
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function AuthBoundary({ onboardingSteps }: { onboardingSteps: AdditionalOnboardingStep[] }) {
|
||||
const finePointer = useFinePointer();
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [authed, setAuthed] = useState(false);
|
||||
const [mustChange, setMustChange] = useState(false);
|
||||
const [status, setStatus] = useState('未连接');
|
||||
// Agreement consent gate, shown after login but BEFORE the forced password
|
||||
// change. `agreements === null` while the post-auth fetch is in flight.
|
||||
const [agreements, setAgreements] = useState<AgreementsPayload | null>(null);
|
||||
const [agreementsError, setAgreementsError] = useState<string | null>(null);
|
||||
const [needsConsent, setNeedsConsent] = useState(false);
|
||||
const [onboardingFinished, setOnboardingFinished] = useState(false);
|
||||
const [replayingOnboarding, setReplayingOnboarding] = useState(
|
||||
() => typeof window !== 'undefined' && isDeveloperOnboardingLocation(window.location),
|
||||
);
|
||||
const onboardingReturnPath = useRef<string | null>(null);
|
||||
// The password from *this* session's login, carried into the forced
|
||||
// change-password gate so it doesn't have to render an old-password field
|
||||
// (which browsers autofill, misleading users on upgrade). Stays undefined
|
||||
// for a returning session that's already authed but still must change.
|
||||
const [loginPassword, setLoginPassword] = useState<string | undefined>(undefined);
|
||||
const [urlLoginPassword, setUrlLoginPassword] = useState<string | undefined>(undefined);
|
||||
const [urlNeedsTotp, setUrlNeedsTotp] = useState(false);
|
||||
const authLoading = !authChecked || (authed && agreements === null && agreementsError === null);
|
||||
const { showSkeleton: showAuthSkeleton } = useSkeletonSwap({ ready: !authLoading });
|
||||
|
||||
const client = useMemo<ApiClient>(
|
||||
() =>
|
||||
createApiClient({
|
||||
onUnauthorized: () => {
|
||||
setAuthed(false);
|
||||
setStatus('未授权');
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshAgreements = useCallback(async () => {
|
||||
setAgreements(null);
|
||||
setAgreementsError(null);
|
||||
|
||||
try {
|
||||
const payload = await client.agreements.get();
|
||||
setAgreements(payload);
|
||||
setNeedsConsent(payload.consentRequired);
|
||||
} catch (error) {
|
||||
console.error('agreement status load failed', error);
|
||||
setAgreementsError(actionErrorMessage(error));
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let ok = await client.status();
|
||||
// #194: `?token=<password>` in the URL logs in without typing the
|
||||
// password. Only attempted when no stored token already authed us; the
|
||||
// value is the WebUI login password (verified by /api/login server-side,
|
||||
// so a wrong one just falls through to the login page). The param is
|
||||
// consumed once and stripped from the URL first — a password in the
|
||||
// address bar leaks via browser history / access logs / the Referer.
|
||||
let urlPassword: string | undefined;
|
||||
|
||||
if (!ok) {
|
||||
const pw = consumeUrlToken();
|
||||
|
||||
if (pw) {
|
||||
const result = await client.login(pw);
|
||||
|
||||
if (result.ok) {
|
||||
ok = true;
|
||||
urlPassword = pw;
|
||||
} else if ('needsTotp' in result && result.needsTotp) {
|
||||
setUrlLoginPassword(pw);
|
||||
setUrlNeedsTotp(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
setAuthed(true);
|
||||
setStatus('已连接');
|
||||
const [nextMustChange] = await Promise.all([
|
||||
client.mustChangePassword(),
|
||||
refreshAgreements(),
|
||||
]);
|
||||
setMustChange(nextMustChange);
|
||||
|
||||
// Carry the URL password into the forced change-password gate so it
|
||||
// needn't re-prompt for the old password (matches the login flow).
|
||||
if (urlPassword) {
|
||||
setLoginPassword(urlPassword);
|
||||
}
|
||||
}
|
||||
|
||||
setAuthChecked(true);
|
||||
})();
|
||||
}, [client, refreshAgreements]);
|
||||
|
||||
const handleLoggedOut = useCallback(() => {
|
||||
// Reset the URL so the next login lands on the overview page, matching
|
||||
// the pre-router behaviour, and clear every post-auth gate.
|
||||
window.history.replaceState({}, '', '/');
|
||||
setAuthed(false);
|
||||
setStatus('未连接');
|
||||
setMustChange(false);
|
||||
setAgreements(null);
|
||||
setAgreementsError(null);
|
||||
setNeedsConsent(false);
|
||||
setOnboardingFinished(false);
|
||||
setReplayingOnboarding(false);
|
||||
onboardingReturnPath.current = null;
|
||||
setLoginPassword(undefined);
|
||||
setUrlLoginPassword(undefined);
|
||||
setUrlNeedsTotp(false);
|
||||
}, []);
|
||||
|
||||
const handleDecline = useCallback(async () => {
|
||||
await client.logout();
|
||||
handleLoggedOut();
|
||||
}, [client, handleLoggedOut]);
|
||||
|
||||
const restartOnboarding = useCallback(() => {
|
||||
const currentPath = browserPath(window.location);
|
||||
const returnPath = isDeveloperOnboardingLocation(window.location)
|
||||
? DEVELOPER_SETTINGS_URL
|
||||
: currentPath;
|
||||
onboardingReturnPath.current = returnPath;
|
||||
const currentState =
|
||||
typeof window.history.state === 'object' && window.history.state !== null
|
||||
? (window.history.state as Record<string, unknown>)
|
||||
: {};
|
||||
setReplayingOnboarding(true);
|
||||
appRouter.history.push(DEVELOPER_ONBOARDING_URL, {
|
||||
...currentState,
|
||||
[DEVELOPER_ONBOARDING_RETURN_KEY]: returnPath,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const exitOnboardingReplay = useCallback(() => {
|
||||
const returnPath =
|
||||
onboardingReturnPath.current ?? developerOnboardingReturnPath(window.history.state);
|
||||
onboardingReturnPath.current = null;
|
||||
|
||||
if (returnPath && isDeveloperOnboardingLocation(window.location)) {
|
||||
// The developer page pushed the replay entry, so browser Back is the
|
||||
// authoritative way to restore both the URL and its router history.
|
||||
appRouter.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
appRouter.history.replace(returnPath ?? DEVELOPER_SETTINGS_URL);
|
||||
setReplayingOnboarding(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncReplayToLocation = () => {
|
||||
const replaying = isDeveloperOnboardingLocation(appRouter.history.location);
|
||||
|
||||
if (!replaying) {
|
||||
onboardingReturnPath.current = null;
|
||||
}
|
||||
|
||||
setReplayingOnboarding(replaying);
|
||||
};
|
||||
|
||||
return appRouter.history.subscribe(syncReplayToLocation);
|
||||
}, []);
|
||||
|
||||
let view: React.ReactNode;
|
||||
|
||||
if (authLoading || showAuthSkeleton) {
|
||||
view = <Splash>{authChecked ? '加载中…' : '初始化中…'}</Splash>;
|
||||
} else if (!authed) {
|
||||
view = (
|
||||
<LoginGate
|
||||
initialPassword={urlLoginPassword}
|
||||
initialNeedsTotp={urlNeedsTotp}
|
||||
onAuthed={(needsChange, password) => {
|
||||
setAuthed(true);
|
||||
setStatus('已连接');
|
||||
setMustChange(needsChange);
|
||||
setLoginPassword(password);
|
||||
setUrlLoginPassword(undefined);
|
||||
setUrlNeedsTotp(false);
|
||||
setOnboardingFinished(false);
|
||||
void refreshAgreements();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (agreementsError) {
|
||||
view = (
|
||||
<AgreementLoadFailure
|
||||
detail={agreementsError}
|
||||
onRetry={() => {
|
||||
void refreshAgreements();
|
||||
}}
|
||||
onLogout={() => {
|
||||
void handleDecline();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
agreements &&
|
||||
(replayingOnboarding ||
|
||||
(!onboardingFinished && (needsConsent || mustChange || onboardingSteps.length > 0)))
|
||||
) {
|
||||
view = (
|
||||
<OnboardingGate
|
||||
payload={agreements}
|
||||
needsConsent={replayingOnboarding || needsConsent}
|
||||
mustChangePassword={replayingOnboarding || mustChange}
|
||||
replay={replayingOnboarding}
|
||||
additionalSteps={onboardingSteps}
|
||||
knownOldPassword={loginPassword}
|
||||
onConsentComplete={() => setNeedsConsent(false)}
|
||||
onPasswordComplete={() => {
|
||||
setMustChange(false);
|
||||
setLoginPassword(undefined);
|
||||
}}
|
||||
onComplete={() => {
|
||||
if (replayingOnboarding) {
|
||||
exitOnboardingReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
setOnboardingFinished(true);
|
||||
}}
|
||||
onStale={refreshAgreements}
|
||||
onDecline={replayingOnboarding ? exitOnboardingReplay : handleDecline}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
view = (
|
||||
<SessionProvider
|
||||
value={{
|
||||
status,
|
||||
onLogoutComplete: handleLoggedOut,
|
||||
restartOnboarding,
|
||||
}}
|
||||
>
|
||||
<RouterProvider router={appRouter} />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiProvider client={client}>
|
||||
<DebugTaskProvider>
|
||||
<TooltipProvider delayDuration={finePointer ? 150 : Infinity}>
|
||||
{view}
|
||||
</TooltipProvider>
|
||||
<TaskBadge />
|
||||
</DebugTaskProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AgreementLoadFailure({
|
||||
detail,
|
||||
onRetry,
|
||||
onLogout,
|
||||
}: {
|
||||
detail: string;
|
||||
onRetry: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
|
||||
<Card className="w-full max-w-lg border-destructive/30">
|
||||
<CardContent className="p-7 sm:p-9">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold">协议状态加载失败</h1>
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-1.5 text-sm leading-relaxed text-muted-foreground"
|
||||
>
|
||||
无法确认当前用户协议与隐私政策状态。为避免绕过必要确认,控制台暂不放行。
|
||||
</p>
|
||||
<p className="mt-2 break-words text-xs text-destructive">{detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onLogout}>
|
||||
<LogOut className="size-4" />
|
||||
退出登录
|
||||
</Button>
|
||||
<Button type="button" onClick={onRetry}>
|
||||
<RefreshCw className="size-4" />
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Splash({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
<SkeletonSwap
|
||||
ready={false}
|
||||
lines={3}
|
||||
lineHeight={24}
|
||||
reserve={72}
|
||||
label={typeof children === 'string' ? children : 'WebUI'}
|
||||
className="w-64 max-w-[70vw]"
|
||||
>
|
||||
{null}
|
||||
</SkeletonSwap>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginGate({
|
||||
onAuthed,
|
||||
initialPassword,
|
||||
initialNeedsTotp,
|
||||
}: {
|
||||
onAuthed: (mustChange: boolean, password: string) => void;
|
||||
initialPassword?: string;
|
||||
initialNeedsTotp?: boolean;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { startAction, dismiss } = useActionFeedback();
|
||||
const handleLogin = useCallback(
|
||||
async (password: string, secondFactor?: { totp?: string; recoveryCode?: string }) => {
|
||||
const handle = startAction({
|
||||
title: '正在登录',
|
||||
detail: secondFactor ? '正在验证一次性密码' : '正在验证 WebUI 访问密码',
|
||||
});
|
||||
const result = await api.login(password, secondFactor);
|
||||
|
||||
if (result.ok) {
|
||||
handle.succeed({ title: '登录成功', detail: '正在进入控制台' });
|
||||
onAuthed(result.mustChangePassword, password);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if ('message' in result) {
|
||||
handle.fail(result.message, { title: '登录失败' });
|
||||
return { success: false, error: result.message };
|
||||
}
|
||||
|
||||
dismiss();
|
||||
return { success: false, needsTotp: true };
|
||||
},
|
||||
[api, dismiss, onAuthed, startAction],
|
||||
);
|
||||
return (
|
||||
<LoginPage
|
||||
onLogin={handleLogin}
|
||||
initialPassword={initialPassword}
|
||||
initialNeedsTotp={initialNeedsTotp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingGate({
|
||||
payload,
|
||||
needsConsent,
|
||||
mustChangePassword,
|
||||
replay,
|
||||
knownOldPassword,
|
||||
onConsentComplete,
|
||||
onPasswordComplete,
|
||||
onComplete,
|
||||
additionalSteps,
|
||||
onStale,
|
||||
onDecline,
|
||||
}: {
|
||||
payload: AgreementsPayload;
|
||||
needsConsent: boolean;
|
||||
mustChangePassword: boolean;
|
||||
replay: boolean;
|
||||
knownOldPassword?: string;
|
||||
onConsentComplete: () => void;
|
||||
onPasswordComplete: () => void;
|
||||
onComplete: () => void;
|
||||
additionalSteps: AdditionalOnboardingStep[];
|
||||
onStale: () => void;
|
||||
onDecline: () => void;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { runAction } = useActionFeedback();
|
||||
const execution = onboardingExecution(replay, {
|
||||
changePassword: (oldPassword, newPassword) => api.changePassword(oldPassword, newPassword),
|
||||
acceptAgreements: async () => {
|
||||
const result = await runAction(
|
||||
{
|
||||
title: '正在记录协议确认',
|
||||
detail: `协议版本 ${payload.version.slice(0, 8)}`,
|
||||
successTitle: '协议确认已记录',
|
||||
errorTitle: '协议确认失败',
|
||||
resultError: (consent) => (consent.success ? null : consent.message),
|
||||
},
|
||||
() => api.agreements.recordConsent(payload.version),
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (result.currentVersion && result.currentVersion !== payload.version) {
|
||||
onStale();
|
||||
return {
|
||||
success: false,
|
||||
message: '协议已更新,已为你载入最新版本,请重新阅读后确认。',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, message: result.message ?? '提交失败,请重试' };
|
||||
},
|
||||
});
|
||||
return (
|
||||
<OnboardingWizardPage
|
||||
documents={payload.documents}
|
||||
agreementVersion={payload.version}
|
||||
needsConsent={needsConsent}
|
||||
mustChangePassword={mustChangePassword}
|
||||
knownOldPassword={knownOldPassword}
|
||||
passwordMode={execution.passwordMode}
|
||||
onDecline={onDecline}
|
||||
onConsentComplete={replay ? () => undefined : onConsentComplete}
|
||||
onPasswordComplete={replay ? () => undefined : onPasswordComplete}
|
||||
onComplete={onComplete}
|
||||
additionalSteps={additionalSteps}
|
||||
checkStrength={(password) => api.checkPasswordStrength(password)}
|
||||
submitPassword={execution.actions.changePassword}
|
||||
onAccept={execution.actions.acceptAgreements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { WebuiPresentation, WebuiTarget } from '@snowluma/common/webui-target';
|
||||
import type { UiAppearance } from '@/types';
|
||||
|
||||
export interface TargetAppearance {
|
||||
css: string;
|
||||
licenseUrl?: string;
|
||||
resolve: (saved: UiAppearance, presentation: WebuiPresentation) => UiAppearance;
|
||||
}
|
||||
|
||||
export function resolveAppearance(
|
||||
saved: UiAppearance,
|
||||
presentation: WebuiPresentation | null,
|
||||
target: TargetAppearance | null,
|
||||
): UiAppearance {
|
||||
if (!presentation) {
|
||||
return saved;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Target appearance must be prepared before mounting ThemeProvider');
|
||||
}
|
||||
|
||||
return target.resolve(saved, presentation);
|
||||
}
|
||||
|
||||
// Exhaustive: adding a shared target requires a frontend implementation here.
|
||||
export const targetLoaders = {
|
||||
maibot: () => import('./targets/maibot').then((module) => module.maibotAppearance),
|
||||
} satisfies Record<WebuiTarget, () => Promise<TargetAppearance>>;
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
parseWebuiPresentation,
|
||||
WEBUI_PRESENTATION_EVENT,
|
||||
WEBUI_TARGET_BOOTSTRAP_ID,
|
||||
type WebuiPresentation,
|
||||
} from '@snowluma/common/webui-target';
|
||||
import { createPresentationStore, type PresentationStore } from './store';
|
||||
import { targetLoaders, type TargetAppearance } from './registry';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** The embedding host sets this before dispatching WEBUI_PRESENTATION_EVENT. */
|
||||
__SNOWLUMA_HOST_APPEARANCE__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrap =
|
||||
typeof document === 'undefined' ? null : document.getElementById(WEBUI_TARGET_BOOTSTRAP_ID);
|
||||
const initial = bootstrap
|
||||
? parseWebuiPresentation(JSON.parse(bootstrap.textContent || 'null'))
|
||||
: null;
|
||||
export const presentationStore = createPresentationStore(initial?.target ?? null);
|
||||
let targetAppearance: TargetAppearance | null = null;
|
||||
|
||||
function acceptHostValue(store: PresentationStore, value: unknown): void {
|
||||
try {
|
||||
store.receive(value);
|
||||
} catch (error) {
|
||||
// Reject only the bad update; keep the last valid presentation and expose the failure.
|
||||
console.error('[SnowLuma appearance] Rejected host update', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeHostAppearance(host: Window, store: PresentationStore): () => void {
|
||||
if (store.target === null) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const receive = (event: Event) =>
|
||||
acceptHostValue(store, (event as CustomEvent<unknown>).detail);
|
||||
host.addEventListener(WEBUI_PRESENTATION_EVENT, receive);
|
||||
|
||||
// Covers messages sent before React subscribed, including while the lazy CSS was loading.
|
||||
if (host.__SNOWLUMA_HOST_APPEARANCE__ !== undefined) {
|
||||
acceptHostValue(store, host.__SNOWLUMA_HOST_APPEARANCE__);
|
||||
}
|
||||
|
||||
return () => host.removeEventListener(WEBUI_PRESENTATION_EVENT, receive);
|
||||
}
|
||||
|
||||
export function applyPresentationAttributes(presentation: WebuiPresentation | null): void {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (presentation) {
|
||||
root.dataset.webuiTarget = presentation.target;
|
||||
root.dataset.webuiStyle = presentation.style;
|
||||
} else {
|
||||
delete root.dataset.webuiTarget;
|
||||
delete root.dataset.webuiStyle;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTargetAppearance(): TargetAppearance | null {
|
||||
return targetAppearance;
|
||||
}
|
||||
|
||||
/** Called before mounting React; standalone deployments do not load any target assets. */
|
||||
export async function prepareTargetAppearance(): Promise<void> {
|
||||
const target = presentationStore.target;
|
||||
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetAppearance = await targetLoaders[target]();
|
||||
const style = document.createElement('style');
|
||||
style.id = 'snowluma-target-styles';
|
||||
style.textContent = targetAppearance.css;
|
||||
document.head.appendChild(style);
|
||||
|
||||
if (targetAppearance.licenseUrl) {
|
||||
const license = document.createElement('link');
|
||||
license.rel = 'license';
|
||||
license.href = targetAppearance.licenseUrl;
|
||||
document.head.appendChild(license);
|
||||
}
|
||||
|
||||
if (window.__SNOWLUMA_HOST_APPEARANCE__ !== undefined) {
|
||||
acceptHostValue(presentationStore, window.__SNOWLUMA_HOST_APPEARANCE__);
|
||||
}
|
||||
|
||||
const presentation = presentationStore.getSnapshot();
|
||||
applyPresentationAttributes(presentation);
|
||||
|
||||
if (presentation) {
|
||||
const dark =
|
||||
presentation.mode === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: presentation.mode === 'dark';
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
document.documentElement.classList.toggle('light', !dark);
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
defaultWebuiPresentation,
|
||||
parseWebuiPresentation,
|
||||
type WebuiPresentation,
|
||||
type WebuiTarget,
|
||||
} from '@snowluma/common/webui-target';
|
||||
|
||||
/** Synchronous snapshots avoid stale async style loads winning a rapid host theme change. */
|
||||
export function createPresentationStore(target: WebuiTarget | null) {
|
||||
let snapshot: WebuiPresentation | null = target ? defaultWebuiPresentation(target) : null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
return {
|
||||
target,
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener: () => void) {
|
||||
if (target) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
receive(value: unknown) {
|
||||
// Host code cannot opt a standalone deployment into a target.
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = parseWebuiPresentation(value);
|
||||
|
||||
if (update.target !== null && update.target !== target) {
|
||||
throw new Error(
|
||||
`Host appearance target does not match deployment target ${target}`,
|
||||
);
|
||||
}
|
||||
|
||||
const next = update.target === null ? null : update;
|
||||
|
||||
if (JSON.stringify(next) === JSON.stringify(snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot = next;
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type PresentationStore = ReturnType<typeof createPresentationStore>;
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2023 The Soft Type Project Authors (https://github.com/scfried/soft-type-jersey)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,233 @@
|
||||
/* MaiBot millennium: plastic case, recessed screen, raised keys.
|
||||
Only loaded for this target; every rule remains scoped so release is reversible. */
|
||||
@font-face {
|
||||
font-family: "SnowLuma Jersey 20";
|
||||
src: url("./assets/Jersey20-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
size-adjust: 118%;
|
||||
unicode-range: U+0000-024F;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] {
|
||||
--mil-shell: #ddd4bf;
|
||||
--mil-panel: #ebe4d2;
|
||||
--mil-hi: #f6f1e3;
|
||||
--mil-lo: #bdb39c;
|
||||
--mil-drop: #9c927b;
|
||||
--mil-line: #5a544a;
|
||||
--mil-ink: #4a443a;
|
||||
--mil-print: #5f5849;
|
||||
--mil-screen: #fbf9f0;
|
||||
--mil-screen-ink: #3a3833;
|
||||
--mil-key: #f3eee1;
|
||||
--mil-key-side: #c2b8a1;
|
||||
--mil-key-hi: #fffaf0;
|
||||
--mil-key-hover: #fbf7ec;
|
||||
--mil-on: #8fd6a0;
|
||||
--mil-on-ink: #2f3a2b;
|
||||
--mil-on-side: #5fa873;
|
||||
--mil-accent-text: #236b43;
|
||||
|
||||
--background: var(--mil-shell);
|
||||
--foreground: var(--mil-ink);
|
||||
--card: var(--mil-panel);
|
||||
--card-foreground: var(--mil-ink);
|
||||
--popover: var(--mil-panel);
|
||||
--popover-foreground: var(--mil-ink);
|
||||
--primary: var(--mil-accent-text);
|
||||
--primary-foreground: var(--mil-screen);
|
||||
--secondary: var(--mil-key);
|
||||
--secondary-foreground: var(--mil-ink);
|
||||
--muted: var(--mil-shell);
|
||||
--muted-foreground: var(--mil-print);
|
||||
--accent: var(--mil-on);
|
||||
--accent-foreground: var(--mil-on-ink);
|
||||
--destructive: #b53724;
|
||||
--destructive-foreground: #fffaf0;
|
||||
--success: #2a7d50;
|
||||
--warning: #8f4f18;
|
||||
--log-info: #315b7c;
|
||||
--log-success: #2a7d50;
|
||||
--log-warn: #8f4f18;
|
||||
--log-error: #b53724;
|
||||
--border: var(--mil-lo);
|
||||
--input: var(--mil-screen);
|
||||
--ring: var(--mil-accent-text);
|
||||
--sidebar: var(--mil-panel);
|
||||
--sidebar-foreground: var(--mil-ink);
|
||||
--sidebar-primary: var(--mil-accent-text);
|
||||
--sidebar-primary-foreground: var(--mil-screen);
|
||||
--sidebar-accent: var(--mil-on);
|
||||
--sidebar-accent-foreground: var(--mil-on-ink);
|
||||
--sidebar-border: var(--mil-lo);
|
||||
--sidebar-ring: var(--mil-accent-text);
|
||||
--shadow-card: inset 1px 1px var(--mil-hi), inset -1px -1px var(--mil-lo), 0 3px 0 var(--mil-drop);
|
||||
--mil-key-shadow: inset 1px 1px var(--mil-key-hi), inset -1px -1px var(--mil-key-side), 0 2px 0 var(--mil-key-side);
|
||||
--mil-screen-shadow: inset 1px 1px 2px var(--mil-lo), inset -1px -1px var(--mil-hi);
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
--radius-xl: 0px;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"].dark {
|
||||
--mil-shell: #2f2c28;
|
||||
--mil-panel: #3a3631;
|
||||
--mil-hi: #4a453f;
|
||||
--mil-lo: #2a2723;
|
||||
--mil-drop: #1d1b18;
|
||||
--mil-line: #171513;
|
||||
--mil-ink: #ebe4d2;
|
||||
--mil-print: #b5ad9a;
|
||||
--mil-screen: #1e221e;
|
||||
--mil-screen-ink: #e8e2cf;
|
||||
--mil-key: #4d4841;
|
||||
--mil-key-side: #2e2b27;
|
||||
--mil-key-hi: #60594f;
|
||||
--mil-key-hover: #595249;
|
||||
--mil-accent-text: #8fd6a0;
|
||||
--primary-foreground: #2f3a2b;
|
||||
--sidebar-primary-foreground: #2f3a2b;
|
||||
--border: #746c5d;
|
||||
--sidebar-border: #746c5d;
|
||||
--destructive: #f08a77;
|
||||
--destructive-foreground: #352019;
|
||||
--success: #8fd6a0;
|
||||
--warning: #e5b278;
|
||||
--log-info: #99bdd8;
|
||||
--log-success: #8fd6a0;
|
||||
--log-warn: #e5b278;
|
||||
--log-error: #f08a77;
|
||||
}
|
||||
|
||||
/* Preserve accessibility preferences above both target palettes. */
|
||||
html[data-webui-target="maibot"][data-contrast="high"] {
|
||||
--border: var(--mil-ink);
|
||||
--sidebar-border: var(--mil-ink);
|
||||
--muted-foreground: var(--mil-ink);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] :is(.ui-card, [data-ui-surface="panel"], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]) {
|
||||
border: 1px solid var(--mil-line);
|
||||
border-radius: 0;
|
||||
background: var(--mil-panel);
|
||||
color: var(--mil-ink);
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] [data-ui-surface="chrome"] {
|
||||
background: var(--mil-panel);
|
||||
box-shadow: inset 1px 1px var(--mil-hi), inset -1px -1px var(--mil-lo);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] [data-ui-surface="canvas"] {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* The desktop rail clips a fixed-width inner sidebar. Its separator must
|
||||
belong to the animated outer edge, not the clipped inner panel. */
|
||||
html[data-webui-target="maibot"] [data-ui-surface="sidebar"] {
|
||||
border-inline-end: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] :is([data-ui-control], pre, table) {
|
||||
border-radius: 0;
|
||||
background: var(--mil-screen);
|
||||
color: var(--mil-screen-ink);
|
||||
box-shadow: var(--mil-screen-shadow);
|
||||
caret-color: var(--mil-accent-text);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] :is([data-ui-control], input, textarea):focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] :is(.ui-button, [role="tab"], .ui-nav-item) {
|
||||
border: 1px solid var(--mil-line);
|
||||
border-radius: 0;
|
||||
background: var(--mil-key);
|
||||
color: var(--mil-ink);
|
||||
box-shadow: var(--mil-key-shadow);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] :is(.ui-button, [role="tab"], .ui-nav-item):hover:not(:disabled) {
|
||||
background: var(--mil-key-hover);
|
||||
}
|
||||
|
||||
/* Keep the whole keycap inside the collapsed rail instead of cropping the
|
||||
expanded key's right edge. Text stays mounted and retains its nowrap rules. */
|
||||
html[data-webui-target="maibot"] nav[data-collapsed="true"] .ui-nav-item {
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] :is(.ui-button[data-variant="default"], [role="tab"][aria-selected="true"], .ui-nav-item[aria-current="page"]) {
|
||||
background: var(--mil-on);
|
||||
color: var(--mil-on-ink);
|
||||
box-shadow: inset 1px 1px #c1ebcb, inset -1px -1px var(--mil-on-side), 0 2px 0 var(--mil-on-side);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-button[data-variant="destructive"] {
|
||||
background: var(--destructive);
|
||||
color: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-button[data-variant="default"]:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--mil-on) 85%, var(--mil-key-hi));
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-button[data-variant="destructive"]:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--destructive) 90%, var(--mil-ink));
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-button:is([data-variant="ghost"], [data-variant="link"]) {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] :is(.ui-button, [role="tab"], .ui-nav-item):active:not(:disabled) {
|
||||
box-shadow: var(--mil-screen-shadow);
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-nav-item[aria-current="page"] :is(svg, span) {
|
||||
color: var(--mil-on-ink);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-nav-highlight {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-segmented {
|
||||
border-radius: 0;
|
||||
background: var(--mil-screen);
|
||||
box-shadow: var(--mil-screen-shadow);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-segmented-thumb {
|
||||
border-radius: 0;
|
||||
background: var(--mil-on);
|
||||
box-shadow: var(--mil-key-shadow);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] .ui-segmented-thumb span {
|
||||
color: var(--mil-on-ink);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] [data-ui-decoration] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] ::selection {
|
||||
background: var(--mil-on);
|
||||
color: var(--mil-on-ink);
|
||||
}
|
||||
|
||||
html[data-webui-target="maibot"] * {
|
||||
scrollbar-color: var(--mil-print) var(--mil-shell);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TargetAppearance } from '../registry';
|
||||
import css from './maibot.css?inline';
|
||||
import licenseUrl from './assets/Jersey20-OFL.txt?url';
|
||||
|
||||
export const maibotAppearance: TargetAppearance = {
|
||||
css,
|
||||
licenseUrl,
|
||||
resolve: (saved, presentation) => ({
|
||||
...saved,
|
||||
mode: presentation.mode,
|
||||
palette: 'default',
|
||||
accentMode: 'preset',
|
||||
accentScope: 'global',
|
||||
darkIntensity: 'soft',
|
||||
sidebarStyle: 'follow',
|
||||
background: { ...saved.background, type: 'none' },
|
||||
fontSans: 'custom',
|
||||
fontSansCustom:
|
||||
'"SnowLuma Jersey 20", system-ui, "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontMono: 'default',
|
||||
fontMonoCustom: '',
|
||||
radius: 0,
|
||||
customCss: '',
|
||||
cssVars: {},
|
||||
customPointerSystem: false,
|
||||
}),
|
||||
};
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,40 @@
|
||||
import { Modal } from '@/components/interior/modal';
|
||||
import { ChangePasswordForm } from '@/components/pages/change-password-form';
|
||||
import { useApi } from '@/lib/api';
|
||||
|
||||
interface ChangePasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Fired after the password is changed successfully (dialog closes itself). */
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app "修改访问密码" modal. The forced first-time flow uses the full-screen
|
||||
* the onboarding wizard; this is the voluntary change a logged-in admin triggers
|
||||
* from 设置 → 账号安全. Both share ChangePasswordForm, so behaviour is identical.
|
||||
*/
|
||||
export function ChangePasswordDialog({ open, onOpenChange, onSuccess }: ChangePasswordDialogProps) {
|
||||
const api = useApi();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title="修改访问密码"
|
||||
description="设置一个全新的强密码后,其它会话将被立即下线,需要重新登录。"
|
||||
closeLabel="关闭修改密码弹窗"
|
||||
>
|
||||
<ChangePasswordForm
|
||||
checkStrength={(p) => api.checkPasswordStrength(p)}
|
||||
submit={(o, n) => api.changePassword(o, n)}
|
||||
onSuccess={() => {
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
idPrefix="dialog-cpw"
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Account selector strip. Collapsing to a 56px avatar-only column keeps
|
||||
// the right-side editor breathing on smaller screens; expanded it shows
|
||||
// nickname + UIN like the original. Selection routes through
|
||||
// `requestSwitchUin` so the dirty-modify guard still gates the switch.
|
||||
|
||||
import { motion } from 'motion/react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { QQInfo } from '@/types';
|
||||
|
||||
function qqAvatarUrl(uin: string) {
|
||||
return `/avatar/${encodeURIComponent(uin)}`;
|
||||
}
|
||||
|
||||
interface AccountSidebarProps {
|
||||
accounts: QQInfo[];
|
||||
selectedUin: string | null;
|
||||
onSelect: (uin: string) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
export function AccountSidebar({
|
||||
accounts,
|
||||
selectedUin,
|
||||
onSelect,
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: AccountSidebarProps) {
|
||||
const { appearance } = useTheme();
|
||||
const reduceMotion = appearance.reduceMotion || appearance.disableMotion;
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
animate={{ width: collapsed ? 56 : 248 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 32 }}
|
||||
className="shrink-0 rounded-xl border bg-card/40"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
collapsed ? 'justify-center px-1.5 py-2' : 'justify-between px-3 py-2',
|
||||
)}
|
||||
>
|
||||
{!collapsed && (
|
||||
<span className="text-xs font-medium text-muted-foreground">在线连接</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? '展开账号列表' : '收起账号列表'}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="optical-forward size-4" />
|
||||
) : (
|
||||
<ChevronLeft className="optical-back size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<p
|
||||
className={cn(
|
||||
'py-6 text-center text-xs text-muted-foreground',
|
||||
collapsed && 'px-1',
|
||||
)}
|
||||
>
|
||||
{collapsed ? '—' : '暂无在线会话'}
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[70vh]" viewportClassName="[&>div]:!block">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-1',
|
||||
collapsed ? 'px-1.5 pb-2' : 'px-2 pb-2',
|
||||
)}
|
||||
>
|
||||
{accounts.map((q) => {
|
||||
const isActive = selectedUin === q.uin;
|
||||
const button = (
|
||||
<motion.button
|
||||
key={q.uin}
|
||||
type="button"
|
||||
whileHover={reduceMotion ? undefined : { scale: 1.01 }}
|
||||
whileTap={reduceMotion ? undefined : { scale: 0.96 }}
|
||||
onClick={() => onSelect(q.uin)}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-lg border text-left transition-colors cursor-pointer',
|
||||
collapsed ? 'justify-center p-1.5' : 'px-2.5 py-2',
|
||||
isActive
|
||||
? 'border-primary/30 bg-primary/10'
|
||||
: 'border-transparent hover:bg-accent/40',
|
||||
)}
|
||||
>
|
||||
<Avatar size={collapsed ? 32 : 28}>
|
||||
<AvatarImage
|
||||
src={qqAvatarUrl(q.uin)}
|
||||
alt={q.nickname || q.uin}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{(q.nickname || q.uin).slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'truncate text-sm font-medium',
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{q.nickname || q.uin}
|
||||
</div>
|
||||
<div className="truncate font-mono text-micro text-muted-foreground tabular-nums">
|
||||
{q.uin}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
return collapsed ? (
|
||||
<Tooltip key={q.uin}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-medium">
|
||||
{q.nickname || q.uin}
|
||||
</span>
|
||||
<span className="font-mono text-micro text-muted-foreground tabular-nums">
|
||||
{q.uin}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Per-network-kind metadata: localized title, summary projector for the
|
||||
// list view, and factory for "create new" defaults. Centralised here so the
|
||||
// page / list / dialog all agree on naming and shape and a new adapter
|
||||
// kind only needs one entry in NETWORK_TABS to wire end-to-end.
|
||||
|
||||
import type {
|
||||
HttpClientNetwork,
|
||||
HttpServerNetwork,
|
||||
MessageFormat,
|
||||
NetworkKind,
|
||||
OneBotNetworks,
|
||||
WsClientNetwork,
|
||||
WsRole,
|
||||
WsServerNetwork,
|
||||
} from '@/types';
|
||||
|
||||
export type TabKey = 'general' | NetworkKind;
|
||||
|
||||
export interface NetworkTabDescriptor<K extends NetworkKind> {
|
||||
key: K;
|
||||
/** Tab label and dialog title prefix. */
|
||||
title: string;
|
||||
/** Short subtitle shown under the tab when active. */
|
||||
description: string;
|
||||
/** Singular noun for "新建 / 编辑 X". */
|
||||
noun: string;
|
||||
/** Project the adapter into a short list-card summary string. */
|
||||
summarize: (item: OneBotNetworks[K][number]) => string;
|
||||
/** Factory producing a fresh adapter with a unique suffix in its name. */
|
||||
defaultEntry: (suffix: number) => OneBotNetworks[K][number];
|
||||
}
|
||||
|
||||
export function generateAccessToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const httpServersTab: NetworkTabDescriptor<'httpServers'> = {
|
||||
key: 'httpServers',
|
||||
title: 'HTTP API',
|
||||
description: '本地监听 HTTP 端口,OneBot 客户端发起请求',
|
||||
noun: 'HTTP API 服务',
|
||||
summarize: (it: HttpServerNetwork) => {
|
||||
const host = it.host?.trim() || '0.0.0.0';
|
||||
const path = (it.path?.trim() || '/').replace(/^\/?/, '/');
|
||||
return `${host}:${it.port}${path}${it.enableWebSocket === true ? ' · WebSocket' : ''}${it.enableSse === true ? ' · SSE' : ''}`;
|
||||
},
|
||||
defaultEntry: (suffix): HttpServerNetwork => ({
|
||||
name: `http-${suffix}`,
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
path: '/',
|
||||
enableWebSocket: false,
|
||||
enableSse: false,
|
||||
accessToken: generateAccessToken(),
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const httpClientsTab: NetworkTabDescriptor<'httpClients'> = {
|
||||
key: 'httpClients',
|
||||
title: 'HTTP 客户端',
|
||||
description: '主动向远端 URL POST 推送事件',
|
||||
noun: 'HTTP 推送客户端',
|
||||
summarize: (it: HttpClientNetwork) => it.url || '(未设置 URL)',
|
||||
defaultEntry: (suffix): HttpClientNetwork => ({
|
||||
name: `httppost-${suffix}`,
|
||||
url: 'http://127.0.0.1:5700',
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const wsServersTab: NetworkTabDescriptor<'wsServers'> = {
|
||||
key: 'wsServers',
|
||||
title: 'WS 服务端',
|
||||
description: '本地监听 WebSocket 端口,客户端建立持久连接',
|
||||
noun: 'WebSocket 服务',
|
||||
summarize: (it: WsServerNetwork) => {
|
||||
const host = it.host?.trim() || '0.0.0.0';
|
||||
const path = (it.path?.trim() || '/').replace(/^\/?/, '/');
|
||||
const role = it.role ?? 'Universal';
|
||||
return `${host}:${it.port}${path} · ${role}`;
|
||||
},
|
||||
defaultEntry: (suffix): WsServerNetwork => ({
|
||||
name: `ws-${suffix}`,
|
||||
host: '127.0.0.1',
|
||||
port: 3001,
|
||||
path: '/',
|
||||
role: 'Universal' as WsRole,
|
||||
accessToken: generateAccessToken(),
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const wsClientsTab: NetworkTabDescriptor<'wsClients'> = {
|
||||
key: 'wsClients',
|
||||
title: 'WS 客户端',
|
||||
description: '主动连接到外部 WebSocket 服务器(reverse-ws)',
|
||||
noun: 'WebSocket 反向客户端',
|
||||
summarize: (it: WsClientNetwork) => {
|
||||
const role = it.role ?? 'Universal';
|
||||
return `${it.url || '(未设置 URL)'} · ${role}`;
|
||||
},
|
||||
defaultEntry: (suffix): WsClientNetwork => ({
|
||||
name: `wsclient-${suffix}`,
|
||||
url: 'ws://127.0.0.1:8080/ws',
|
||||
role: 'Universal' as WsRole,
|
||||
reconnectIntervalMs: 5000,
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Order here drives both the tab strip order and the dialog/page wiring.
|
||||
* Each entry stays strictly typed on its kind so callers get the narrow
|
||||
* adapter shape (no NetworkBase widening).
|
||||
*/
|
||||
export const NETWORK_TABS = {
|
||||
httpServers: httpServersTab,
|
||||
httpClients: httpClientsTab,
|
||||
wsServers: wsServersTab,
|
||||
wsClients: wsClientsTab,
|
||||
} satisfies { [K in NetworkKind]: NetworkTabDescriptor<K> };
|
||||
|
||||
export const NETWORK_TAB_ORDER: NetworkKind[] = [
|
||||
'httpServers',
|
||||
'httpClients',
|
||||
'wsServers',
|
||||
'wsClients',
|
||||
];
|
||||
|
||||
export const ALL_TABS: TabKey[] = ['general', ...NETWORK_TAB_ORDER];
|
||||
|
||||
/**
|
||||
* Find the smallest suffix N (starting at list.length + 1) such that
|
||||
* `factory(N).name` doesn't collide with any existing adapter. Used by
|
||||
* the page to seed a fresh-default adapter when the user clicks "create".
|
||||
*/
|
||||
export function nextUniqueSuffix(
|
||||
networks: { name: string }[],
|
||||
factory: (suffix: number) => { name: string },
|
||||
): number {
|
||||
const used = new Set(networks.map((n) => n.name));
|
||||
let suffix = networks.length + 1;
|
||||
|
||||
while (used.has(factory(suffix).name)) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return suffix;
|
||||
}
|
||||
|
||||
// Re-exported for callers that just need to type a generic adapter.
|
||||
export interface AdapterCommon {
|
||||
name: string;
|
||||
enabled?: boolean;
|
||||
accessToken?: string;
|
||||
messageFormat: MessageFormat;
|
||||
reportSelfMessage: boolean;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// "通用设置" tab — per-account fields that apply to the whole OneBotInstance
|
||||
// rather than any specific adapter: the built-in `#sl` status command and the
|
||||
// notification opt-in. Edits here mark the config dirty and are auto-saved with
|
||||
// debounce by the parent ConfigPage. (Deployment-wide knobs like the music-sign
|
||||
// URL live in Settings → 全局配置, not here.)
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { NotificationOptIn } from '@/components/config/notification-opt-in';
|
||||
import type { OneBotConfig, StatusCommandConfig } from '@/types';
|
||||
|
||||
interface GeneralSettingsTabProps {
|
||||
config: OneBotConfig;
|
||||
onChange: (next: OneBotConfig) => void;
|
||||
}
|
||||
|
||||
export function GeneralSettingsTab({ config, onChange }: GeneralSettingsTabProps) {
|
||||
const sc = config.statusCommand;
|
||||
const setStatusCommand = (patch: Partial<StatusCommandConfig>) =>
|
||||
onChange({ ...config, statusCommand: { ...sc, ...patch } });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg border bg-card/40 p-4">
|
||||
<div className="min-w-0">
|
||||
<Label>登录时补齐云端历史记录</Label>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
下次账号登录或重启后生效。只静默写入本地消息库,不重放历史事件,也不改变 QQ
|
||||
已读状态。 每次最多处理 3 个群和 3 个好友、每个会话 20 条,自动请求至少间隔
|
||||
2 秒。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={config.historySync.enabled}
|
||||
onChange={(enabled) =>
|
||||
onChange({
|
||||
...config,
|
||||
historySync: { enabled },
|
||||
})
|
||||
}
|
||||
ariaLabel="登录时补齐云端历史记录"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-card/40 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Label>
|
||||
内置状态命令 <code className="font-mono text-xs">#sl</code>
|
||||
</Label>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
收到纯文本 <code className="font-mono">#sl</code> 时回复 SnowLuma 版本 /
|
||||
平台 / 运行时长。任何人可触发,关闭后完全不响应。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={sc.enabled}
|
||||
onChange={(v) => setStatusCommand({ enabled: v })}
|
||||
ariaLabel="启用 #sl 状态命令"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 border-t pt-3">
|
||||
<Label className={sc.enabled ? undefined : 'text-muted-foreground'}>
|
||||
触发词
|
||||
</Label>
|
||||
<Input
|
||||
className="w-full font-mono"
|
||||
value={sc.trigger}
|
||||
disabled={!sc.enabled}
|
||||
maxLength={32}
|
||||
onChange={(e) => setStatusCommand({ trigger: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
自定义触发词,默认 <code className="font-mono">#sl</code>。最长 32
|
||||
字符,匹配前会去除首尾空格并转为小写。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3 border-t pt-3">
|
||||
<div className="min-w-0">
|
||||
<Label className={sc.enabled ? undefined : 'text-muted-foreground'}>
|
||||
不转发给下游(swallow)
|
||||
</Label>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
开启后,命中的 <code className="font-mono">#sl</code> 不再投递给已连接的
|
||||
Bot(仍会回复并本地记录)。默认关闭即透传。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={sc.swallow}
|
||||
onChange={(v) => setStatusCommand({ swallow: v })}
|
||||
ariaLabel="吞掉 #sl 不转发给下游"
|
||||
disabled={!sc.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 border-t pt-3">
|
||||
<Label className={sc.enabled ? undefined : 'text-muted-foreground'}>
|
||||
回复冷却(秒)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-32 tabular-nums"
|
||||
value={sc.cooldownSeconds}
|
||||
disabled={!sc.enabled}
|
||||
onChange={(e) => {
|
||||
const n = Math.trunc(Number(e.target.value));
|
||||
setStatusCommand({
|
||||
cooldownSeconds: Number.isFinite(n) && n >= 0 ? n : 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
同一会话在该秒数内重复 <code className="font-mono">#sl</code>{' '}
|
||||
不再回复,防刷屏。
|
||||
<code className="font-mono">0</code> 表示不限制。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationOptIn
|
||||
selectedIds={config.notifications?.channelIds ?? []}
|
||||
onChange={(channelIds) => onChange({ ...config, notifications: { channelIds } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
// Modal editor used for both "create" and "edit". Holds a local draft
|
||||
// copy of the adapter until the user clicks save (so cancel really does
|
||||
// throw away changes). Validation is minimal — blank-name and dup-name
|
||||
// disable the save button; everything else is best-effort coercion.
|
||||
//
|
||||
// Visual language: Apple-HIG grouped-list flavour (matches debug-page) —
|
||||
// captioned sections, soft rounded cards, settings rows with a label on the
|
||||
// left and a control (toggle / custom dropdown) on the right.
|
||||
|
||||
import { useId, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Server,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '@/components/interior/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownSelect, type DropdownOption } from '@/components/ui/dropdown-select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { cn, copyText } from '@/lib/utils';
|
||||
import { accessTokenFeedback, type AccessTokenFeedback } from '@/lib/access-token-feedback';
|
||||
import { allowEmptyInboundAccessToken } from '@/lib/transport-security';
|
||||
import type {
|
||||
HttpClientNetwork,
|
||||
HttpServerNetwork,
|
||||
MessageFormat,
|
||||
NetworkKind,
|
||||
OneBotNetworks,
|
||||
WsClientNetwork,
|
||||
WsRole,
|
||||
WsServerNetwork,
|
||||
} from '@/types';
|
||||
import { generateAccessToken, NETWORK_TABS } from './defaults';
|
||||
|
||||
type AnyAdapter<K extends NetworkKind> = OneBotNetworks[K][number];
|
||||
|
||||
const KIND_ICON: Record<NetworkKind, LucideIcon> = {
|
||||
httpServers: Server,
|
||||
httpClients: ArrowUpRight,
|
||||
wsServers: Radio,
|
||||
wsClients: ArrowLeftRight,
|
||||
};
|
||||
|
||||
interface NodeEditDialogProps<K extends NetworkKind> {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
kind: K;
|
||||
/** Initial draft state — caller seeds it with `defaultEntry` for create
|
||||
* or the existing item for edit. */
|
||||
initial: AnyAdapter<K>;
|
||||
/** True when editing an existing adapter (drives title + button copy). */
|
||||
isEdit: boolean;
|
||||
/** Names of every other adapter in the same list, for duplicate check. */
|
||||
otherNames: string[];
|
||||
/** Account context used when evaluating user-chosen access tokens. */
|
||||
uin: string;
|
||||
onSubmit: (item: AnyAdapter<K>) => void;
|
||||
}
|
||||
|
||||
export function NodeEditDialog<K extends NetworkKind>(props: NodeEditDialogProps<K>) {
|
||||
const { open, onOpenChange, kind, initial, isEdit, otherNames, uin, onSubmit } = props;
|
||||
const tab = NETWORK_TABS[kind];
|
||||
const Icon: LucideIcon = KIND_ICON[kind];
|
||||
|
||||
// Local draft. The parent unmounts this component on close so each
|
||||
// open gets a fresh `initial` via useState's lazy init — no effect-based
|
||||
// resync needed, which keeps the lifecycle linear.
|
||||
const [draft, setDraft] = useState<AnyAdapter<K>>(initial);
|
||||
|
||||
const trimmedName = draft.name?.trim() ?? '';
|
||||
const blankName = trimmedName.length === 0;
|
||||
const duplicateName = !blankName && otherNames.includes(trimmedName);
|
||||
|
||||
const isInboundServer = kind === 'httpServers' || kind === 'wsServers';
|
||||
const tokenChanged = (draft.accessToken ?? '') !== (initial.accessToken ?? '');
|
||||
const enforceTokenPolicy = isInboundServer && (!isEdit || tokenChanged);
|
||||
const inbound = isInboundServer ? (draft as HttpServerNetwork | WsServerNetwork) : null;
|
||||
const allowEmptyToken =
|
||||
typeof window !== 'undefined' &&
|
||||
allowEmptyInboundAccessToken(window.location.hostname, inbound?.host);
|
||||
const tokenFeedback = useMemo(
|
||||
() =>
|
||||
enforceTokenPolicy
|
||||
? accessTokenFeedback(
|
||||
draft.accessToken ?? '',
|
||||
[uin, draft.name ?? '', kind, inbound?.host ?? '', inbound?.port ?? 0],
|
||||
allowEmptyToken,
|
||||
)
|
||||
: undefined,
|
||||
[
|
||||
allowEmptyToken,
|
||||
draft.accessToken,
|
||||
draft.name,
|
||||
enforceTokenPolicy,
|
||||
inbound?.host,
|
||||
inbound?.port,
|
||||
kind,
|
||||
uin,
|
||||
],
|
||||
);
|
||||
|
||||
const canSave = !blankName && !duplicateName && (tokenFeedback?.valid ?? true);
|
||||
|
||||
const patch = (changes: Partial<AnyAdapter<K>>) =>
|
||||
setDraft({ ...draft, ...changes } as AnyAdapter<K>);
|
||||
|
||||
const isWs = kind === 'wsServers' || kind === 'wsClients';
|
||||
const isHttpServer = kind === 'httpServers';
|
||||
const role = ((draft as WsServerNetwork | WsClientNetwork).role ?? 'Universal') as WsRole;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-[10px] bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
{isEdit ? `编辑 ${tab.noun}` : `新建 ${tab.noun}`}
|
||||
</span>
|
||||
}
|
||||
description={tab.description}
|
||||
closeLabel="关闭节点编辑弹窗"
|
||||
maxWidth={576}
|
||||
maxHeight="calc(100dvh - 2rem)"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
const cleaned = { ...draft, name: trimmedName } as AnyAdapter<K>;
|
||||
onSubmit(cleaned);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{isEdit ? '保存修改' : '创建节点'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0 }}
|
||||
className="flex items-center justify-between gap-4 rounded-2xl border border-border/60 bg-card/60 px-4 py-3.5 shadow-[0_1px_2px_rgb(0_0_0/0.04)]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm font-medium text-foreground">启用</Label>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
关闭后保存即可保留配置但不启动该节点
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={draft.enabled !== false}
|
||||
onChange={(v) =>
|
||||
patch({ enabled: v ? undefined : false } as Partial<AnyAdapter<K>>)
|
||||
}
|
||||
ariaLabel="启用"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<Section caption="连接" delay={0.05}>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Field
|
||||
label="名称"
|
||||
placeholder="自定义"
|
||||
value={draft.name}
|
||||
onChange={(v) => patch({ name: v } as Partial<AnyAdapter<K>>)}
|
||||
error={
|
||||
blankName
|
||||
? '请填写名称'
|
||||
: duplicateName
|
||||
? '名称与其它节点重复'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<KindFields kind={kind} draft={draft} patch={patch} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section caption="鉴权" delay={0.1}>
|
||||
<div className="p-4">
|
||||
<TokenField
|
||||
label="授权 Token"
|
||||
placeholder={
|
||||
isInboundServer
|
||||
? '留空则关闭鉴权(监听本机或从本机打开页面时可保存)'
|
||||
: '按对端要求填写'
|
||||
}
|
||||
value={draft.accessToken}
|
||||
onChange={(v) =>
|
||||
patch({ accessToken: v || undefined } as Partial<AnyAdapter<K>>)
|
||||
}
|
||||
onGenerate={() =>
|
||||
patch({ accessToken: generateAccessToken() } as Partial<
|
||||
AnyAdapter<K>
|
||||
>)
|
||||
}
|
||||
feedback={tokenFeedback}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section caption="行为" delay={0.15}>
|
||||
<div className="divide-y divide-border/60">
|
||||
{isHttpServer && (
|
||||
<SettingRow
|
||||
label="启用 WebSocket"
|
||||
desc="复用当前 HTTP 端口和授权 Token 接收 WebSocket 连接"
|
||||
>
|
||||
<ToggleSwitch
|
||||
value={(draft as HttpServerNetwork).enableWebSocket === true}
|
||||
onChange={(v) =>
|
||||
patch({ enableWebSocket: v } as unknown as Partial<
|
||||
AnyAdapter<K>
|
||||
>)
|
||||
}
|
||||
ariaLabel="启用 WebSocket"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{isHttpServer && (
|
||||
<SettingRow
|
||||
label="启用 SSE"
|
||||
desc="通过当前 HTTP 路径下的 /_events 持续推送事件,支持短暂断线重放"
|
||||
>
|
||||
<ToggleSwitch
|
||||
value={(draft as HttpServerNetwork).enableSse === true}
|
||||
onChange={(v) => patch({ ...draft, enableSse: v })}
|
||||
ariaLabel="启用 SSE"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<SettingRow label="消息格式" desc="数组为标准 OneBot 段,CQ 码为兼容字符串">
|
||||
<DropdownSelect
|
||||
className="w-32"
|
||||
ariaLabel="消息格式"
|
||||
value={(draft.messageFormat ?? 'array') as MessageFormat}
|
||||
options={FORMAT_OPTIONS}
|
||||
onChange={(v) =>
|
||||
patch({ messageFormat: v } as Partial<AnyAdapter<K>>)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{isWs && (
|
||||
<SettingRow label="角色" desc="Universal 收发合一,Event / Api 分离">
|
||||
<DropdownSelect
|
||||
className="w-32"
|
||||
ariaLabel="角色"
|
||||
value={role}
|
||||
options={WS_ROLE_OPTIONS}
|
||||
onChange={(v) =>
|
||||
patch({ role: v } as unknown as Partial<AnyAdapter<K>>)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
label="上报自身消息"
|
||||
desc="将机器人自己发送的消息也作为 message_sent 事件上报"
|
||||
>
|
||||
<ToggleSwitch
|
||||
value={!!draft.reportSelfMessage}
|
||||
onChange={(v) =>
|
||||
patch({ reportSelfMessage: v } as Partial<AnyAdapter<K>>)
|
||||
}
|
||||
ariaLabel="上报自身消息"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── grouped-list scaffolding ───────────────
|
||||
|
||||
/** Captioned inset section — the iOS "grouped list" unit. */
|
||||
function Section({
|
||||
caption,
|
||||
delay,
|
||||
children,
|
||||
}: {
|
||||
caption: string;
|
||||
delay: number;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay }}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<span className="px-1 text-xs font-medium text-muted-foreground">{caption}</span>
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card/40">
|
||||
{children}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings row — label (+ optional subtitle) on the left, control on the right. */
|
||||
function SettingRow({
|
||||
label,
|
||||
desc,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
desc?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-[13px] text-foreground">{label}</Label>
|
||||
{desc && (
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{desc}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── kind-specific field strips ───────────────
|
||||
|
||||
interface KindFieldsProps<K extends NetworkKind> {
|
||||
kind: K;
|
||||
draft: AnyAdapter<K>;
|
||||
patch: (changes: Partial<AnyAdapter<K>>) => void;
|
||||
}
|
||||
|
||||
function KindFields<K extends NetworkKind>({ kind, draft, patch }: KindFieldsProps<K>) {
|
||||
// Per-branch narrowing — TS can't follow the generic relationship so
|
||||
// each arm casts once. Field components receive specific shapes. The WS
|
||||
// `role` lives in the behaviour section, so it is intentionally absent here.
|
||||
if (kind === 'httpServers') {
|
||||
const it = draft as HttpServerNetwork;
|
||||
const set = patch as (c: Partial<HttpServerNetwork>) => void;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_120px_140px]">
|
||||
<Field
|
||||
label="主机"
|
||||
placeholder="127.0.0.1"
|
||||
value={it.host}
|
||||
onChange={(v) => set({ host: v || undefined })}
|
||||
/>
|
||||
<Field
|
||||
label="端口"
|
||||
type="number"
|
||||
value={it.port}
|
||||
onChange={(v) => set({ port: Number(v) || 0 })}
|
||||
/>
|
||||
<Field
|
||||
label="路径"
|
||||
placeholder="/"
|
||||
value={it.path}
|
||||
onChange={(v) => set({ path: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
{/* The cap only exists for the embedded WebSocket listener. */}
|
||||
{it.enableWebSocket === true ? maxPayloadField(it.maxPayloadBytes, set) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'httpClients') {
|
||||
const it = draft as HttpClientNetwork;
|
||||
const set = patch as (c: Partial<HttpClientNetwork>) => void;
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
|
||||
<Field
|
||||
label="目标 URL"
|
||||
type="url"
|
||||
placeholder="http://..."
|
||||
value={it.url}
|
||||
onChange={(v) => set({ url: v })}
|
||||
/>
|
||||
<Field
|
||||
label="超时 (ms)"
|
||||
type="number"
|
||||
placeholder="5000"
|
||||
value={it.timeoutMs}
|
||||
onChange={(v) => set({ timeoutMs: Number(v) || undefined })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'wsServers') {
|
||||
const it = draft as WsServerNetwork;
|
||||
const set = patch as (c: Partial<WsServerNetwork>) => void;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_120px_140px]">
|
||||
<Field
|
||||
label="主机"
|
||||
placeholder="127.0.0.1"
|
||||
value={it.host}
|
||||
onChange={(v) => set({ host: v || undefined })}
|
||||
/>
|
||||
<Field
|
||||
label="端口"
|
||||
type="number"
|
||||
value={it.port}
|
||||
onChange={(v) => set({ port: Number(v) || 0 })}
|
||||
/>
|
||||
<Field
|
||||
label="路径"
|
||||
placeholder="/"
|
||||
value={it.path}
|
||||
onChange={(v) => set({ path: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
{maxPayloadField(it.maxPayloadBytes, set)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'wsClients') {
|
||||
const it = draft as WsClientNetwork;
|
||||
const set = patch as (c: Partial<WsClientNetwork>) => void;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_160px_160px]">
|
||||
<Field
|
||||
label="目标 URL"
|
||||
type="url"
|
||||
placeholder="ws://..."
|
||||
value={it.url}
|
||||
onChange={(v) => set({ url: v })}
|
||||
/>
|
||||
<Field
|
||||
label="重连间隔 (ms)"
|
||||
type="number"
|
||||
value={it.reconnectIntervalMs}
|
||||
onChange={(v) => set({ reconnectIntervalMs: Number(v) || undefined })}
|
||||
/>
|
||||
</div>
|
||||
{maxPayloadField(it.maxPayloadBytes, set)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound WebSocket frame cap. Shared by every editor that owns a WebSocket
|
||||
* transport (wsServers, wsClients, httpServers with the embedded listener): the
|
||||
* value is empty by default (transport default of 100 MiB), and the hint spells
|
||||
* out why inline PCM audio hits it at ~7:26.
|
||||
*/
|
||||
function maxPayloadField(
|
||||
value: number | undefined,
|
||||
set: (patch: { maxPayloadBytes?: number }) => void,
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-3 sm:grid-cols-[160px_1fr] sm:items-end">
|
||||
<Field
|
||||
label="单帧上限 (bytes)"
|
||||
type="number"
|
||||
min={1024}
|
||||
max={2147483648}
|
||||
step={1024}
|
||||
placeholder="104857600"
|
||||
value={value}
|
||||
onChange={(v) => set({ maxPayloadBytes: Number(v) || undefined })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
入站 WebSocket 单帧上限,留空即默认 100 MiB(104857600),有效范围 1024 –
|
||||
2147483648。44.1 kHz 立体声 PCM 语音约 445
|
||||
秒(7:26)就会触顶并断开连接;超长音频应由上游改发 URL/文件或压缩为
|
||||
SILK,调大上限会按帧大小增加内存占用。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── shared form bits ───────────────
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
value: string | number | undefined;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'number' | 'url';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
error,
|
||||
}: FieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
className={cn(error && 'border-destructive focus-visible:ring-destructive/40')}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenFieldProps {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
onGenerate: () => void;
|
||||
placeholder?: string;
|
||||
feedback?: AccessTokenFeedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password-style input for the access token. Hidden by default to keep
|
||||
* the value out of over-the-shoulder reads / screenshots; an eye toggle
|
||||
* unmasks it and a copy button shoves the current value to the system
|
||||
* clipboard with a short "已复制" confirmation flash.
|
||||
*/
|
||||
function TokenField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onGenerate,
|
||||
placeholder,
|
||||
feedback,
|
||||
}: TokenFieldProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimerRef = useRef<number | null>(null);
|
||||
const feedbackId = useId();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await copyText(value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCopied(true);
|
||||
|
||||
if (copiedTimerRef.current != null) {
|
||||
window.clearTimeout(copiedTimerRef.current);
|
||||
}
|
||||
|
||||
copiedTimerRef.current = window.setTimeout(() => {
|
||||
setCopied(false);
|
||||
copiedTimerRef.current = null;
|
||||
}, 1400);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
// Switch input type rather than masking the string so paste /
|
||||
// selection / autofill all behave like a native password field.
|
||||
type={visible ? 'text' : 'password'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={feedback?.tone === 'error' || undefined}
|
||||
aria-describedby={feedback ? feedbackId : undefined}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onGenerate}
|
||||
aria-label="生成新的随机令牌"
|
||||
title="生成新的随机令牌"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
disabled={!value}
|
||||
aria-label={copied ? '已复制' : '复制'}
|
||||
title={copied ? '已复制' : '复制'}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-success" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label={visible ? '隐藏' : '显示'}
|
||||
title={visible ? '隐藏' : '显示'}
|
||||
>
|
||||
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{feedback && (
|
||||
<p
|
||||
id={feedbackId}
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
feedback.tone === 'error'
|
||||
? 'text-destructive'
|
||||
: feedback.tone === 'warning'
|
||||
? 'text-warning'
|
||||
: 'text-success',
|
||||
)}
|
||||
>
|
||||
{feedback.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS: ReadonlyArray<DropdownOption<MessageFormat>> = [
|
||||
{ value: 'array', label: '数组' },
|
||||
{ value: 'string', label: 'CQ 码' },
|
||||
];
|
||||
|
||||
const WS_ROLE_OPTIONS: ReadonlyArray<DropdownOption<WsRole>> = [
|
||||
{ value: 'Universal', label: 'Universal' },
|
||||
{ value: 'Event', label: 'Event' },
|
||||
{ value: 'Api', label: 'Api' },
|
||||
];
|
||||
@@ -0,0 +1,141 @@
|
||||
// One row in the per-kind list view. Shows just enough to identify the
|
||||
// adapter at a glance (name + projected summary + token state + the two
|
||||
// flags) plus the inline enable toggle. Real editing always goes through
|
||||
// the dialog — there are no live-typed inputs here.
|
||||
|
||||
import { motion } from 'motion/react';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import type { AdapterCommon } from './defaults';
|
||||
import type { AdapterStatus } from '@/types';
|
||||
|
||||
const LIVE_STYLE: Record<AdapterStatus['status'], string> = {
|
||||
ok: 'bg-success/10 text-success',
|
||||
warn: 'bg-warning/10 text-warning',
|
||||
down: 'bg-destructive/10 text-destructive',
|
||||
degraded: 'bg-destructive/10 text-destructive',
|
||||
disabled: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
const LIVE_LABEL: Record<AdapterStatus['status'], string> = {
|
||||
ok: '正常',
|
||||
warn: '注意',
|
||||
down: '异常',
|
||||
degraded: '应用失败',
|
||||
disabled: '未启用',
|
||||
};
|
||||
|
||||
interface NodeSummaryCardProps<T extends AdapterCommon> {
|
||||
item: T;
|
||||
summary: string;
|
||||
duplicateName: boolean;
|
||||
/** Live runtime status from the OneBot manager, matched by adapter name. */
|
||||
liveStatus?: AdapterStatus;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function NodeSummaryCard<T extends AdapterCommon>({
|
||||
item,
|
||||
summary,
|
||||
duplicateName,
|
||||
liveStatus,
|
||||
onToggleEnabled,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: NodeSummaryCardProps<T>) {
|
||||
const enabled = item.enabled !== false;
|
||||
const blankName = !item.name?.trim();
|
||||
const off = useTheme().appearance.disableMotion;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={off ? false : { opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={off ? { duration: 0 } : { duration: 0.18 }}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-xl border bg-card/40 p-3.5 transition-[background-color,opacity] duration-150 ease-out hover:bg-accent/20 sm:flex-row sm:items-center sm:gap-3',
|
||||
!enabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn('font-medium', blankName && 'text-destructive')}>
|
||||
{item.name || '(未命名)'}
|
||||
</span>
|
||||
{duplicateName && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
名称重复
|
||||
</Badge>
|
||||
)}
|
||||
{!enabled && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
已停用
|
||||
</Badge>
|
||||
)}
|
||||
{liveStatus && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-micro font-medium',
|
||||
LIVE_STYLE[liveStatus.status],
|
||||
)}
|
||||
title={
|
||||
liveStatus.lastError
|
||||
? `${liveStatus.detail}\n${liveStatus.lastErrorAt ? new Date(liveStatus.lastErrorAt).toLocaleString() : '时间未知'} · ${liveStatus.lastError}`
|
||||
: liveStatus.detail
|
||||
}
|
||||
>
|
||||
{LIVE_LABEL[liveStatus.status]} · {liveStatus.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{summary}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-meta text-muted-foreground">
|
||||
<span>{item.messageFormat === 'string' ? 'CQ 码' : '数组'}</span>
|
||||
<span aria-hidden className="text-border">
|
||||
·
|
||||
</span>
|
||||
<span>{item.reportSelfMessage ? '上报自身' : '不上报自身'}</span>
|
||||
<span aria-hidden className="text-border">
|
||||
·
|
||||
</span>
|
||||
<span className={cn(!item.accessToken && 'text-muted-foreground/60')}>
|
||||
{item.accessToken ? '已设 Token' : '无 Token'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 sm:justify-end">
|
||||
<ToggleSwitch
|
||||
value={enabled}
|
||||
onChange={onToggleEnabled}
|
||||
ariaLabel={`启用 ${item.name || '该节点'}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onEdit}
|
||||
aria-label="编辑"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
aria-label="删除"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// Per-account opt-in to the GLOBAL notification channels. Renders the channels
|
||||
// defined in 设置 → 通知 as toggles; the selection maps to
|
||||
// OneBotConfig.notifications.channelIds. Lives inside the account's 通用设置 tab,
|
||||
// so toggling marks the config dirty and rides the parent's debounced autosave.
|
||||
//
|
||||
// Scales to many channels (40+): collapsed by default to a one-line summary of
|
||||
// selected channels as removable chips; expanded into a searchable, bulk-
|
||||
// actionable, height-bounded scroll list so it never blows up the page.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ChevronDown, Search, X } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NotificationChannel } from '@/types';
|
||||
|
||||
interface NotificationOptInProps {
|
||||
selectedIds: string[];
|
||||
onChange: (channelIds: string[]) => void;
|
||||
}
|
||||
|
||||
/** How many summary chips to show before collapsing the rest into "+N". */
|
||||
const CHIP_CAP = 8;
|
||||
|
||||
export function NotificationOptIn({ selectedIds, onChange }: NotificationOptInProps) {
|
||||
const api = useApi();
|
||||
const [channels, setChannels] = useState<NotificationChannel[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setChannels(null);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
const cfg = await api.notifications.getConfig();
|
||||
setChannels(cfg.channels);
|
||||
} catch (error) {
|
||||
console.error('load notification opt-ins failed', error);
|
||||
setLoadError(error instanceof Error ? error.message : '加载通知渠道失败');
|
||||
setChannels([]);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const selected = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const toggle = (id: string, on: boolean) => {
|
||||
const set = new Set(selectedIds);
|
||||
|
||||
if (on) {
|
||||
set.add(id);
|
||||
} else {
|
||||
set.delete(id);
|
||||
}
|
||||
|
||||
// Order known channels by the global list; keep any stale ids (channel
|
||||
// deleted globally but still opted-in) at the end rather than dropping them.
|
||||
const known = (channels ?? []).map((c) => c.id).filter((cid) => set.has(cid));
|
||||
const extra = selectedIds.filter((sid) => set.has(sid) && !known.includes(sid));
|
||||
onChange([...known, ...extra]);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
// Only enabled channels can ever fire, so "全选" maps to those.
|
||||
onChange((channels ?? []).filter((c) => c.enabled).map((c) => c.id));
|
||||
};
|
||||
|
||||
const clearAll = () => onChange([]);
|
||||
|
||||
// Resolve the selected ids back to channels for the collapsed chip summary.
|
||||
// Stale ids (channel deleted globally) fall back to showing the raw id.
|
||||
const selectedChips = useMemo(() => {
|
||||
const byId = new Map((channels ?? []).map((c) => [c.id, c]));
|
||||
return selectedIds.map((id) => ({ id, name: byId.get(id)?.name || id }));
|
||||
}, [channels, selectedIds]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = channels ?? [];
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
if (!q) {
|
||||
return list;
|
||||
}
|
||||
|
||||
return list.filter((c) => {
|
||||
const hay = [c.name, c.id, c.url, c.smtpHost, c.to, c.from]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [channels, query]);
|
||||
|
||||
const total = channels?.length ?? 0;
|
||||
const selectedCount = selectedIds.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border bg-card/40 p-4">
|
||||
{/* header — toggles the editor open/closed */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-3 text-left"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label className="cursor-pointer text-foreground">上下线通知渠道</Label>
|
||||
{channels !== null && (
|
||||
<Badge variant={selectedCount > 0 ? 'default' : 'outline'}>
|
||||
已选 {selectedCount} / 共 {total}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<SkeletonSwap
|
||||
ready={channels !== null}
|
||||
lines={2}
|
||||
lineHeight={20}
|
||||
reserve={40}
|
||||
label="通知渠道"
|
||||
className={channels !== null ? 'skeleton-swap-fluid min-h-[40px]' : ''}
|
||||
>
|
||||
{channels !== null ? (
|
||||
loadError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 text-xs text-destructive"
|
||||
>
|
||||
<span>{loadError}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
还没有全局渠道——先到「设置 → 通知」添加。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* collapsed: selected channels as removable chips */}
|
||||
{!expanded &&
|
||||
(selectedCount === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未选择任何渠道。展开以选择。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedChips.slice(0, CHIP_CAP).map((ch) => (
|
||||
<Badge
|
||||
key={ch.id}
|
||||
variant="secondary"
|
||||
className="max-w-[12rem] pr-1"
|
||||
>
|
||||
<span className="truncate">{ch.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(ch.id, false)}
|
||||
aria-label={`移除 ${ch.name}`}
|
||||
className="ml-0.5 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedCount > CHIP_CAP && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="rounded-full px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
+{selectedCount - CHIP_CAP} 更多
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* expanded: search + bulk + bounded scroll list */}
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
该账号上线 /
|
||||
下线时向选中的渠道推送通知。渠道在「设置 →
|
||||
通知」中全局定义。
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索渠道…"
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAll}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
无匹配渠道
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-64 rounded-lg border bg-background/40">
|
||||
<div className="divide-y divide-border/60">
|
||||
{filtered.map((ch) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm">
|
||||
{ch.name || ch.id}
|
||||
</span>
|
||||
{!ch.enabled && (
|
||||
<span className="shrink-0 text-micro text-muted-foreground">
|
||||
(已全局禁用)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{ch.type === 'email'
|
||||
? ch.to ||
|
||||
ch.smtpHost ||
|
||||
'邮件'
|
||||
: ch.url}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
// Globally-disabled channel: lock the switch and force it off
|
||||
// (it can never fire, so the per-account opt-in must read off).
|
||||
value={
|
||||
ch.enabled &&
|
||||
selected.has(ch.id)
|
||||
}
|
||||
onChange={(v) =>
|
||||
toggle(ch.id, v)
|
||||
}
|
||||
ariaLabel={`为该账号启用渠道 ${ch.name || ch.id}`}
|
||||
disabled={!ch.enabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useRef, useState, type ReactNode } from 'react';
|
||||
import { Modal } from '@/components/interior/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
actionErrorMessage,
|
||||
useActionFeedback,
|
||||
type ActionFeedbackOptions,
|
||||
} from '@/contexts/ActionFeedbackContext';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
destructive?: boolean;
|
||||
content?: ReactNode;
|
||||
confirmDisabled?: boolean;
|
||||
activity?: ActionFeedbackOptions<void>;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
destructive = false,
|
||||
content,
|
||||
confirmDisabled = false,
|
||||
activity,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { runAction } = useActionFeedback();
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!activity) {
|
||||
await onConfirm();
|
||||
} else {
|
||||
await runAction(activity, onConfirm);
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (caught) {
|
||||
const message = actionErrorMessage(caught);
|
||||
setError(message);
|
||||
console.error('confirm action failed', caught);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (busy && !nextOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextOpen) {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
title={title}
|
||||
description={description}
|
||||
showClose={false}
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
initialFocusRef={cancelRef}
|
||||
maxWidth={512}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={() => {
|
||||
void handleConfirm();
|
||||
}}
|
||||
disabled={busy || confirmDisabled}
|
||||
>
|
||||
{busy ? '处理中…' : confirmText}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{content}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
// ActionTester — the debug console's centrepiece. Pick an account + action and
|
||||
// invoke it; params render as role-driven smart controls (pickers, switches,
|
||||
// file sources) with a raw-JSON escape hatch. Read-only actions are badged
|
||||
// green, side-effecting ones amber; destructive ones confirm first. Stream API
|
||||
// actions invoke over the streaming transport and render frames live (tracked
|
||||
// in the app-level task registry). Every call lands in localStorage history
|
||||
// with one-click replay. Responses render in a collapsible JsonTree.
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
FlaskConical,
|
||||
History,
|
||||
Loader2,
|
||||
Play,
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Picker } from '@/components/ui/picker';
|
||||
import { JsonTree } from '@/components/ui/json-tree';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { ParamField } from '@/components/debug/param-field';
|
||||
import { Segmented } from '@/components/debug/segmented';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { useDebugTasks } from '@/contexts/DebugTaskContext';
|
||||
import { buildActionParams } from '@/lib/action-params';
|
||||
import { clearHistory, loadHistory, pushHistory, type InvokeRecord } from '@/lib/debug-history';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DebugActionDoc, DebugStreamFrame, QQInfo } from '@/types';
|
||||
|
||||
const cardCls =
|
||||
'rounded-2xl border border-border/60 bg-card/80 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_8px_24px_-12px_rgb(0_0_0/0.10)] backdrop-blur-sm';
|
||||
|
||||
// Name patterns for genuinely destructive actions → require a confirm. False
|
||||
// positives just add one extra click; false negatives skip a confirm — both low
|
||||
// stakes (the amber banner already warns that any write is real).
|
||||
const DESTRUCTIVE_RE =
|
||||
/(kick|ban|mute|recall|withdraw|delete|del_|dismiss|disband|leave|quit|remove|revoke)/i;
|
||||
// Cap retained stream frames so a long-running / runaway stream can't grow the
|
||||
// array and DOM without bound. Generous enough to keep a full normal stream.
|
||||
const MAX_STREAM_FRAMES = 2000;
|
||||
|
||||
function qqAvatarUrl(uin: string) {
|
||||
return `/avatar/${encodeURIComponent(uin)}`;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionTester({
|
||||
accounts,
|
||||
docs,
|
||||
presetAction,
|
||||
}: {
|
||||
accounts: QQInfo[];
|
||||
docs: DebugActionDoc[];
|
||||
presetAction?: { name: string; nonce: number };
|
||||
}) {
|
||||
const api = useApi();
|
||||
const tasks = useDebugTasks();
|
||||
|
||||
const [uin, setUin] = useState('');
|
||||
const [actionName, setActionName] = useState('');
|
||||
const [fields, setFields] = useState<Record<string, string>>({});
|
||||
const [paramMode, setParamMode] = useState<'form' | 'json'>('form');
|
||||
const [rawJson, setRawJson] = useState('{}');
|
||||
const [invoking, setInvoking] = useState(false);
|
||||
const [result, setResult] = useState<DebugStreamFrame | { error: string } | null>(null);
|
||||
const [frames, setFrames] = useState<DebugStreamFrame[]>([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [history, setHistory] = useState<InvokeRecord[]>([]);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const streamAbort = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(loadHistory());
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (accounts[0] && !uin) {
|
||||
setUin(accounts[0].uin);
|
||||
}
|
||||
}, [accounts, uin]);
|
||||
// Prefill from the API browser's 试一下 (nonce lets the same action re-trigger).
|
||||
useEffect(() => {
|
||||
if (presetAction) {
|
||||
setActionName(presetAction.name);
|
||||
setFields({});
|
||||
setParamMode('form');
|
||||
setResult(null);
|
||||
setFrames([]);
|
||||
setShowHistory(false);
|
||||
}
|
||||
}, [presetAction]);
|
||||
|
||||
const doc = useMemo(() => docs.find((d) => d.name === actionName), [docs, actionName]);
|
||||
const effectiveMode = paramMode === 'json' || !doc ? 'json' : 'form';
|
||||
const isStream = !!doc?.stream;
|
||||
const isDestructive = DESTRUCTIVE_RE.test(actionName);
|
||||
|
||||
const accountOptions = useMemo(
|
||||
() =>
|
||||
accounts.length === 0
|
||||
? [{ value: '', label: '(无在线账号)' }]
|
||||
: accounts.map((a) => ({
|
||||
value: a.uin,
|
||||
label: a.nickname || a.uin,
|
||||
sub: a.nickname ? a.uin : undefined,
|
||||
avatar: qqAvatarUrl(a.uin),
|
||||
})),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const actionOptions = useMemo(
|
||||
() => docs.map((d) => ({ value: d.name, label: d.name, sub: d.summary })),
|
||||
[docs],
|
||||
);
|
||||
|
||||
const buildParams = (): Record<string, unknown> | { __error: string } => {
|
||||
if (effectiveMode === 'json') {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(rawJson || '{}');
|
||||
} catch {
|
||||
return { __error: 'params JSON 无效' };
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return { __error: 'params 必须是对象' };
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
try {
|
||||
return buildActionParams(doc?.params ?? [], fields);
|
||||
} catch (error) {
|
||||
return { __error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const runInvoke = async () => {
|
||||
if (!uin) {
|
||||
setResult({ error: '请选择账号' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!actionName.trim()) {
|
||||
setResult({ error: '请填写 action' });
|
||||
return;
|
||||
}
|
||||
|
||||
const params = buildParams();
|
||||
|
||||
if ('__error' in params) {
|
||||
setResult({ error: params.__error as string });
|
||||
return;
|
||||
}
|
||||
|
||||
setInvoking(true);
|
||||
setResult(null);
|
||||
setFrames([]);
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (isStream) {
|
||||
const ac = new AbortController();
|
||||
streamAbort.current = ac;
|
||||
const taskId = tasks.start({
|
||||
kind: 'stream',
|
||||
label: `${actionName} @ ${uin}`,
|
||||
progress: undefined,
|
||||
cancel: () => ac.abort(),
|
||||
});
|
||||
let last: DebugStreamFrame | null = null;
|
||||
|
||||
try {
|
||||
await api.debug.invokeStream(
|
||||
uin,
|
||||
actionName.trim(),
|
||||
params,
|
||||
(frame) => {
|
||||
last = frame;
|
||||
setFrames((prev) =>
|
||||
prev.length >= MAX_STREAM_FRAMES
|
||||
? [...prev.slice(-(MAX_STREAM_FRAMES - 1)), frame]
|
||||
: [...prev, frame],
|
||||
);
|
||||
const d = frame.data as
|
||||
| {
|
||||
type?: string;
|
||||
progress?: number;
|
||||
transferred?: number;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (d && typeof d.progress === 'number') {
|
||||
tasks.update(taskId, { progress: d.progress });
|
||||
} else if (
|
||||
d &&
|
||||
typeof d.transferred === 'number' &&
|
||||
typeof d.total === 'number' &&
|
||||
d.total > 0
|
||||
) {
|
||||
tasks.update(taskId, { progress: d.transferred / d.total });
|
||||
}
|
||||
},
|
||||
ac.signal,
|
||||
);
|
||||
// A clean success requires the terminal frame (status ok AND the
|
||||
// NapCat terminal marker data.type==='response') — a stream that closed
|
||||
// without emitting its terminator must not read as success.
|
||||
const term = last as DebugStreamFrame | null;
|
||||
const ok =
|
||||
!!term &&
|
||||
term.status === 'ok' &&
|
||||
(term.data as { type?: string } | undefined)?.type === 'response';
|
||||
setResult(last);
|
||||
tasks.finish(taskId, ok ? 'done' : 'failed');
|
||||
setHistory(
|
||||
pushHistory({
|
||||
at: startedAt,
|
||||
uin,
|
||||
action: actionName.trim(),
|
||||
params,
|
||||
ok,
|
||||
ms: Date.now() - startedAt,
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
const aborted = ac.signal.aborted;
|
||||
const msg = e instanceof Error ? e.message : '流式调用失败';
|
||||
|
||||
if (!aborted) {
|
||||
setResult({ error: msg });
|
||||
}
|
||||
|
||||
tasks.finish(taskId, aborted ? 'canceled' : 'failed', msg);
|
||||
setHistory(
|
||||
pushHistory({
|
||||
at: startedAt,
|
||||
uin,
|
||||
action: actionName.trim(),
|
||||
params,
|
||||
ok: false,
|
||||
ms: Date.now() - startedAt,
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
streamAbort.current = null;
|
||||
setInvoking(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.debug.invoke(uin, actionName.trim(), params);
|
||||
setResult(res);
|
||||
setHistory(
|
||||
pushHistory({
|
||||
at: startedAt,
|
||||
uin,
|
||||
action: actionName.trim(),
|
||||
params,
|
||||
ok: res.status === 'ok',
|
||||
ms: Date.now() - startedAt,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
setResult({ error: e instanceof Error ? e.message : '调用失败' });
|
||||
setHistory(
|
||||
pushHistory({
|
||||
at: startedAt,
|
||||
uin,
|
||||
action: actionName.trim(),
|
||||
params,
|
||||
ok: false,
|
||||
ms: Date.now() - startedAt,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setInvoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onExecute = () => {
|
||||
if (isDestructive) {
|
||||
setConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void runInvoke();
|
||||
};
|
||||
|
||||
const replay = (rec: InvokeRecord) => {
|
||||
setUin(rec.uin);
|
||||
setActionName(rec.action);
|
||||
setParamMode('json');
|
||||
setRawJson(JSON.stringify(rec.params, null, 2));
|
||||
setResult(null);
|
||||
setFrames([]);
|
||||
setShowHistory(false);
|
||||
};
|
||||
|
||||
const resultFailed = !!result && ('error' in result || result.status === 'failed');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12">
|
||||
{/* tester */}
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className={cn(cardCls, 'relative z-20 flex flex-col gap-5 p-6 xl:col-span-7')}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FlaskConical className="h-[18px] w-[18px] text-primary" />
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Action 测试台</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHistory((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" /> 历史{' '}
|
||||
{history.length > 0 && (
|
||||
<span className="tabular-nums">{history.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-xl bg-amber-500/10 px-3 py-2 text-[12px] leading-relaxed text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
调用会<strong className="font-semibold">真实生效</strong>(真发消息 /
|
||||
真踢人等),请谨慎。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="账号">
|
||||
<Picker
|
||||
ariaLabel="选择账号"
|
||||
value={uin}
|
||||
onChange={setUin}
|
||||
options={accountOptions}
|
||||
placeholder="选择账号…"
|
||||
validateRaw={() => false}
|
||||
disabled={accounts.length === 0}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Action">
|
||||
<Picker
|
||||
ariaLabel="选择 action"
|
||||
value={actionName}
|
||||
onChange={(v) => {
|
||||
setActionName(v);
|
||||
setFields({});
|
||||
setResult(null);
|
||||
setFrames([]);
|
||||
}}
|
||||
options={actionOptions}
|
||||
placeholder="search action…"
|
||||
validateRaw={(s) => s.trim().length > 0}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{doc && (
|
||||
<div className="-mt-1 flex flex-wrap items-center gap-2 text-[12px] text-muted-foreground">
|
||||
{doc.readOnly ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/12 px-1.5 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck className="h-3 w-3" /> 只读·安全
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-500/12 px-2 py-0.5 text-sm font-semibold text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="h-3.5 w-3.5" /> 有副作用
|
||||
</span>
|
||||
)}
|
||||
{isStream && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-primary/12 px-1.5 py-0.5 text-xs font-medium text-primary">
|
||||
<Zap className="h-3 w-3" /> 流式
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{doc.summary}
|
||||
{doc.returns ? (
|
||||
<>
|
||||
{' '}
|
||||
· 返回{' '}
|
||||
<code className="font-mono text-meta">{doc.returns}</code>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">参数</span>
|
||||
<Segmented
|
||||
value={effectiveMode}
|
||||
onChange={setParamMode}
|
||||
options={[
|
||||
{ value: 'form', label: '表单' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{effectiveMode === 'json' ? (
|
||||
<Textarea
|
||||
className="min-h-28 rounded-xl font-mono text-xs"
|
||||
value={rawJson}
|
||||
onChange={(e) => setRawJson(e.target.value)}
|
||||
placeholder='{ "group_id": 12345, "message": "hi" }'
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3.5">
|
||||
{doc!.params.length === 0 && (
|
||||
<p className="text-[12px] text-muted-foreground">该接口无参数。</p>
|
||||
)}
|
||||
{doc!.params.map((p) => (
|
||||
<Field
|
||||
key={`${actionName}:${p.name}`}
|
||||
label={
|
||||
<>
|
||||
{p.name}
|
||||
<span className="ml-1 font-normal text-muted-foreground/70">
|
||||
{p.type}
|
||||
{p.required ? ' · 必填' : ''}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ParamField
|
||||
param={p}
|
||||
value={fields[p.name] ?? ''}
|
||||
uin={uin}
|
||||
groupContext={fields['group_id'] ?? ''}
|
||||
actionName={actionName}
|
||||
onChange={(v) =>
|
||||
setFields((f) => {
|
||||
const next = { ...f, [p.name]: v };
|
||||
|
||||
// Changing the group resets any sibling member picker — its
|
||||
// old uin belongs to the previous group.
|
||||
if (p.role === 'group_id' && doc) {
|
||||
for (const mp of doc.params) {
|
||||
if (mp.role === 'member_id') {
|
||||
next[mp.name] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
{p.desc && (
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{p.desc}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
{doc!.invariants && doc!.invariants.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
约束:{doc!.invariants.join(';')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExecute}
|
||||
disabled={invoking}
|
||||
data-press-scale=""
|
||||
className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-xl bg-primary text-[15px] font-medium text-primary-foreground shadow-sm transition-[background-color,opacity,scale] duration-150 ease-out hover:bg-primary/90 active:not-disabled:scale-[0.96] motion-reduce:active:scale-100 disabled:opacity-50"
|
||||
>
|
||||
{invoking ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isStream ? (
|
||||
<Zap className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="optical-forward h-4 w-4" />
|
||||
)}
|
||||
{invoking ? '执行中…' : isStream ? '流式执行' : '执行'}
|
||||
</button>
|
||||
{invoking && isStream && streamAbort.current && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => streamAbort.current?.abort()}
|
||||
className="inline-flex h-11 items-center justify-center gap-1.5 rounded-xl border border-border px-4 text-sm font-medium text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* response / frames / history */}
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, delay: 0.05 }}
|
||||
className={cn(cardCls, 'flex min-h-[20rem] flex-col gap-3 p-6 xl:col-span-5')}
|
||||
>
|
||||
{showHistory ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight">调用历史</h3>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearHistory();
|
||||
setHistory([]);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 overflow-auto">
|
||||
{history.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
暂无历史
|
||||
</p>
|
||||
) : (
|
||||
history.map((rec) => (
|
||||
<button
|
||||
key={rec.id}
|
||||
type="button"
|
||||
onClick={() => replay(rec)}
|
||||
className="group flex items-center gap-2 rounded-lg px-2.5 py-2 text-left text-[13px] hover:bg-muted/50"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
rec.ok ? 'bg-emerald-500' : 'bg-destructive',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono">
|
||||
{rec.action}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
|
||||
{rec.uin}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight">
|
||||
{isStream ? '帧流' : '响应'}
|
||||
</h3>
|
||||
{result && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
resultFailed ? 'text-destructive' : 'text-success',
|
||||
)}
|
||||
>
|
||||
{resultFailed ? '失败' : '成功'}
|
||||
{!('error' in result) &&
|
||||
resultFailed &&
|
||||
(result.message || result.wording)
|
||||
? ` · ${result.message || result.wording}`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{frames.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{frames.length} 帧
|
||||
</span>
|
||||
{frames.map((f, i) => {
|
||||
// On a failed frame the action's `data` is replaced with an
|
||||
// {type:'error'} marker and the reason moves to message/wording
|
||||
// — render the whole frame so the reason stays visible.
|
||||
const failed =
|
||||
f.status === 'failed' ||
|
||||
(f.data as { type?: string } | undefined)?.type === 'error';
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-border/50 bg-muted/20 p-2"
|
||||
>
|
||||
<JsonTree
|
||||
data={failed ? f : (f.data ?? f)}
|
||||
maxHeight="12rem"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{result && !('error' in result) && frames.length === 0 && (
|
||||
<JsonTree data={result} maxHeight="28rem" />
|
||||
)}
|
||||
{result && 'error' in result && (
|
||||
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
{!result && frames.length === 0 && (
|
||||
<p className="flex flex-1 items-center justify-center py-12 text-center text-sm text-muted-foreground">
|
||||
执行后在此查看结果
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title="确认执行破坏性操作"
|
||||
description={
|
||||
<>
|
||||
即将以账号 <code className="font-mono text-foreground">{uin}</code> 执行{' '}
|
||||
<code className="font-mono text-foreground">{actionName}</code>,该操作会
|
||||
<strong className="text-foreground">真实生效且通常不可撤销</strong>
|
||||
。确认继续?
|
||||
</>
|
||||
}
|
||||
confirmText="确认执行"
|
||||
destructive
|
||||
onConfirm={runInvoke}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// ApiBrowser — an embedded, searchable reference for every declarative action
|
||||
// (~520), grouped by category, surfaced straight from /api/debug/actions (the
|
||||
// same docs the tester uses). Each row expands to params / returns / invariants
|
||||
// and a 试一下 button that prefills the tester. No hand-maintained docs — this
|
||||
// is the live catalog.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { BookOpen, ChevronRight, PlayCircle, Search, ShieldCheck, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DebugActionDoc } from '@/types';
|
||||
|
||||
const cardCls =
|
||||
'rounded-2xl border border-border/60 bg-card/80 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_8px_24px_-12px_rgb(0_0_0/0.10)] backdrop-blur-sm';
|
||||
|
||||
function ActionRow({ doc, onTry }: { doc: DebugActionDoc; onTry: (name: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 bg-card/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<code className="shrink-0 font-mono text-[13px] font-medium">{doc.name}</code>
|
||||
{doc.readOnly ? (
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<span
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500"
|
||||
title="有副作用"
|
||||
/>
|
||||
)}
|
||||
{doc.stream && <Zap className="h-3.5 w-3.5 shrink-0 text-primary" />}
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{doc.summary}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="flex flex-col gap-3 border-t border-border/50 px-3 py-3 text-[13px]">
|
||||
{doc.aliases.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
别名:
|
||||
{doc.aliases.map((a) => (
|
||||
<code key={a} className="ml-1 font-mono">
|
||||
{a}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{doc.params.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-lg border border-border/50">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-2 py-1 font-medium">参数</th>
|
||||
<th className="px-2 py-1 font-medium">类型</th>
|
||||
<th className="px-2 py-1 font-medium">必填</th>
|
||||
<th className="px-2 py-1 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.params.map((p) => (
|
||||
<tr key={p.name} className="border-t border-border/40">
|
||||
<td className="px-2 py-1 font-mono">{p.name}</td>
|
||||
<td className="px-2 py-1 font-mono text-muted-foreground">
|
||||
{p.values
|
||||
? p.values.map(String).join(' | ')
|
||||
: p.type}
|
||||
{p.role ? (
|
||||
<span className="ml-1 text-primary/70">
|
||||
·{p.role}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-2 py-1">{p.required ? '✓' : '–'}</td>
|
||||
<td className="px-2 py-1 text-muted-foreground">
|
||||
{p.desc ?? ''}
|
||||
{p.default !== undefined
|
||||
? ` (默认 ${JSON.stringify(p.default)})`
|
||||
: ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">无参数</p>
|
||||
)}
|
||||
{doc.invariants && doc.invariants.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
约束:{doc.invariants.join(';')}
|
||||
</p>
|
||||
)}
|
||||
{doc.returns && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
返回:<code className="font-mono">{doc.returns}</code>
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTry(doc.name)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/15"
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5" /> 试一下
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiBrowser({
|
||||
docs,
|
||||
onTry,
|
||||
}: {
|
||||
docs: DebugActionDoc[];
|
||||
onTry: (name: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [cat, setCat] = useState<string>('');
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
|
||||
for (const d of docs) {
|
||||
if (d.category) {
|
||||
set.add(d.category);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(set).sort();
|
||||
}, [docs]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return docs.filter((d) => {
|
||||
if (cat && d.category !== cat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
d.name.toLowerCase().includes(q) ||
|
||||
(d.summary?.toLowerCase().includes(q) ?? false) ||
|
||||
d.aliases.some((a) => a.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
}, [docs, query, cat]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const m = new Map<string, DebugActionDoc[]>();
|
||||
|
||||
for (const d of filtered) {
|
||||
const k = d.category ?? '其他';
|
||||
const arr = m.get(k) ?? [];
|
||||
arr.push(d);
|
||||
m.set(k, arr);
|
||||
}
|
||||
|
||||
return Array.from(m.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className={cn(cardCls, 'flex min-h-[28rem] flex-col gap-4 p-6')}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BookOpen className="h-[18px] w-[18px] text-primary" />
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">API 浏览器</h2>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{filtered.length}/{docs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/60 px-2.5 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索 action 名 / 摘要 / 别名…"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCat('')}
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
cat === ''
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted/70 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setCat(c)}
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
cat === c
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted/70 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-auto">
|
||||
{grouped.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">无匹配 action</p>
|
||||
) : (
|
||||
grouped.map(([category, items]) => (
|
||||
<div key={category} className="flex flex-col gap-1.5">
|
||||
<h3 className="px-1 text-xs font-semibold text-muted-foreground">
|
||||
{category}{' '}
|
||||
<span className="font-normal tabular-nums">{items.length}</span>
|
||||
</h3>
|
||||
{items.map((d) => (
|
||||
<ActionRow key={d.name} doc={d} onTry={onTry} />
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// FileSource — the control behind a file/image/record/video param. The bot runs
|
||||
// on the SERVER, so a browser-local path is meaningless to it; this offers three
|
||||
// honest sources: a URL the bot fetches, a path that already exists on the
|
||||
// server, or a browser upload that we stream to a server temp path (and then
|
||||
// feed that path to the action). Upload progress flows through the app-level
|
||||
// task registry so it survives tab switches.
|
||||
import { useId, useState } from 'react';
|
||||
import { FileUp, Link2, Loader2, ServerCog, X } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { useDebugTasks } from '@/contexts/DebugTaskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FieldRole } from '@/types';
|
||||
|
||||
type Mode = 'url' | 'server' | 'upload';
|
||||
|
||||
const ACCEPT: Partial<Record<FieldRole, string>> = {
|
||||
image: 'image/*,.png,.jpg,.jpeg,.gif,.webp,.bmp,.heic,.heif',
|
||||
record: 'audio/*,.silk,.amr,.mp3,.wav,.m4a,.ogg',
|
||||
video: 'video/*,.mp4,.mov,.mkv,.webm',
|
||||
};
|
||||
|
||||
function Seg({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'bg-card text-foreground shadow-sm ring-1 ring-border/60'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileSource({
|
||||
value,
|
||||
onChange,
|
||||
role,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
role?: FieldRole;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { start, update, finish } = useDebugTasks();
|
||||
const [mode, setMode] = useState<Mode>('url');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadName, setUploadName] = useState<string | null>(null);
|
||||
const [uploadErr, setUploadErr] = useState<string | null>(null);
|
||||
const inputId = useId();
|
||||
const accept = role ? ACCEPT[role] : undefined;
|
||||
|
||||
const doUpload = async (file: File) => {
|
||||
setUploadErr(null);
|
||||
setUploadName(file.name);
|
||||
setUploading(true);
|
||||
const ac = new AbortController();
|
||||
const taskId = start({
|
||||
kind: 'upload',
|
||||
label: `上传 ${file.name}`,
|
||||
progress: 0,
|
||||
cancel: () => ac.abort(),
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await api.debug.upload(file, {
|
||||
signal: ac.signal,
|
||||
onProgress: (f) => update(taskId, { progress: f }),
|
||||
});
|
||||
|
||||
if (!res.path) {
|
||||
throw new Error(res.message || '上传失败');
|
||||
}
|
||||
|
||||
onChange(res.path);
|
||||
finish(taskId, 'done', res.path);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '上传失败';
|
||||
setUploadErr(msg);
|
||||
finish(taskId, ac.signal.aborted ? 'canceled' : 'failed', msg);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inline-flex w-fit gap-0.5 rounded-xl bg-muted/70 p-0.5">
|
||||
<Seg
|
||||
active={mode === 'url'}
|
||||
onClick={() => setMode('url')}
|
||||
icon={<Link2 className="h-3.5 w-3.5" />}
|
||||
label="URL"
|
||||
/>
|
||||
<Seg
|
||||
active={mode === 'server'}
|
||||
onClick={() => setMode('server')}
|
||||
icon={<ServerCog className="h-3.5 w-3.5" />}
|
||||
label="服务器路径"
|
||||
/>
|
||||
<Seg
|
||||
active={mode === 'upload'}
|
||||
onClick={() => setMode('upload')}
|
||||
icon={<FileUp className="h-3.5 w-3.5" />}
|
||||
label="上传"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'url' && (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || 'https://…'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'server' && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="/path/on/server/file.jpg"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">使用服务器上的文件路径</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'upload' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
|
||||
if (f) {
|
||||
void doUpload(f);
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files[0];
|
||||
|
||||
if (file && !uploading) {
|
||||
void doUpload(file);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-xl border border-dashed border-border/70 px-3 py-3 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground',
|
||||
uploading && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 上传中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileUp className="h-4 w-4" /> 选择或拖入文件
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
{value && uploadName && !uploading && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-2.5 py-1.5 text-meta">
|
||||
<span className="truncate text-muted-foreground">
|
||||
已上传:{uploadName}
|
||||
</span>
|
||||
<code
|
||||
className="ml-auto max-w-[55%] truncate font-mono text-muted-foreground/80"
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange('');
|
||||
setUploadName(null);
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{uploadErr && <span className="text-xs text-destructive">{uploadErr}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// LiveActivity — the merged live SSE of OneBot events + action calls across all
|
||||
// accounts. Extracted from the debug page so it can stand alone as a console
|
||||
// tab. Filter by kind, keyword-search, pause, clear, and export the buffer as
|
||||
// JSON. Rows expand to a JsonTree of the full payload.
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Activity,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Pause,
|
||||
Play,
|
||||
RadioTower,
|
||||
Search,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { JsonTree } from '@/components/ui/json-tree';
|
||||
import { Segmented } from '@/components/debug/segmented';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { cn, downloadBlob } from '@/lib/utils';
|
||||
import type { DebugStreamMessage } from '@/types';
|
||||
|
||||
const STREAM_CAP = 300;
|
||||
const cardCls =
|
||||
'rounded-2xl border border-border/60 bg-card/80 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_8px_24px_-12px_rgb(0_0_0/0.10)] backdrop-blur-sm';
|
||||
|
||||
interface StreamRow {
|
||||
id: number;
|
||||
at: number;
|
||||
msg: Extract<DebugStreamMessage, { kind: 'event' | 'action' | 'dropped' }>;
|
||||
search: string;
|
||||
}
|
||||
|
||||
function IconBtn({
|
||||
onClick,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({
|
||||
status,
|
||||
paused,
|
||||
}: {
|
||||
status: 'open' | 'reconnecting' | 'closed';
|
||||
paused?: boolean;
|
||||
}) {
|
||||
// A deliberate pause reads as "已暂停", distinct from an unwanted disconnect.
|
||||
const map = paused
|
||||
? { dot: 'bg-muted-foreground/50', label: '已暂停', glow: '' }
|
||||
: {
|
||||
open: {
|
||||
dot: 'bg-emerald-500',
|
||||
label: '已连接',
|
||||
glow: 'shadow-[0_0_0_3px_rgb(16_185_129/0.15)]',
|
||||
},
|
||||
reconnecting: {
|
||||
dot: 'bg-amber-500',
|
||||
label: '重连中',
|
||||
glow: 'shadow-[0_0_0_3px_rgb(245_158_11/0.15)]',
|
||||
},
|
||||
closed: { dot: 'bg-muted-foreground/50', label: '未连接', glow: '' },
|
||||
}[status];
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/70 px-3 py-1.5 text-xs font-medium backdrop-blur-sm">
|
||||
<span className={cn('h-2 w-2 rounded-full', map.dot, map.glow)} />
|
||||
<span className="text-muted-foreground">{map.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rowLabel(msg: Extract<DebugStreamMessage, { kind: 'event' | 'action' }>): {
|
||||
label: string;
|
||||
detail: unknown;
|
||||
isAction: boolean;
|
||||
ok: boolean;
|
||||
} {
|
||||
if (msg.kind === 'event') {
|
||||
const e = msg.event as Record<string, unknown>;
|
||||
return {
|
||||
label: `${e.post_type ?? 'event'}${e.message_type ? `.${e.message_type}` : e.notice_type ? `.${e.notice_type}` : ''}`,
|
||||
detail: e,
|
||||
isAction: false,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
// action
|
||||
return {
|
||||
label: msg.action,
|
||||
detail: { params: msg.params, response: msg.response },
|
||||
isAction: true,
|
||||
ok: (msg.response as { status?: string }).status === 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
function StreamRowItem({ row }: { row: StreamRow }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { msg } = row;
|
||||
const time = new Date(row.at).toLocaleTimeString('zh-CN', { hour12: false });
|
||||
|
||||
if (msg.kind === 'dropped') {
|
||||
return (
|
||||
<div className="px-2 py-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
因客户端过慢丢弃了 {msg.count} 条
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { label, detail, isAction, ok } = rowLabel(msg);
|
||||
return (
|
||||
<div className="group rounded-xl transition-colors hover:bg-muted/40">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2.5 px-2.5 py-2 text-left text-[13px]"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
|
||||
{time}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-md px-1.5 py-0.5 text-micro font-semibold',
|
||||
isAction ? 'bg-primary/12 text-primary' : 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isAction ? '调用' : '事件'}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-meta text-muted-foreground/80">
|
||||
{msg.uin}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-foreground/90">
|
||||
{label}
|
||||
</span>
|
||||
{msg.kind === 'action' && (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-meta tabular-nums',
|
||||
ok ? 'text-success' : 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{ok ? 'ok' : 'failed'} · {msg.ms}ms
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mx-2.5 mb-2">
|
||||
<JsonTree data={detail} maxHeight="18rem" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveActivity() {
|
||||
const api = useApi();
|
||||
const [rows, setRows] = useState<StreamRow[]>([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [status, setStatus] = useState<'open' | 'reconnecting' | 'closed'>('closed');
|
||||
const [kindFilter, setKindFilter] = useState<'all' | 'event' | 'action'>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const idRef = useRef(0);
|
||||
|
||||
// Pausing truly drops the live fetch stream instead of connecting-and-discarding:
|
||||
// `paused` is an effect dependency, so it closes the stream on pause and
|
||||
// reopens it on resume. (The always-on-across-tabs behaviour is intentional —
|
||||
// debug-page keeps this mounted so actions fired from other tabs still land in
|
||||
// the feed — so only an explicit pause severs the connection.)
|
||||
useEffect(() => {
|
||||
if (paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const off = api.debug.stream(
|
||||
(m) => {
|
||||
if (m.kind === 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
setRows((prev) => {
|
||||
// Precompute the search haystack once at enqueue, so filtering never
|
||||
// re-stringifies every row on each keystroke.
|
||||
const next = [
|
||||
{
|
||||
id: idRef.current++,
|
||||
at: Date.now(),
|
||||
msg: m,
|
||||
search: JSON.stringify(m).toLowerCase(),
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
return next.length > STREAM_CAP ? next.slice(0, STREAM_CAP) : next;
|
||||
});
|
||||
},
|
||||
(s) => setStatus(s),
|
||||
);
|
||||
return off;
|
||||
}, [paused, api]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (kindFilter !== 'all' && r.msg.kind !== kindFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (r.msg.kind === 'dropped') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { label } = rowLabel(r.msg);
|
||||
return label.toLowerCase().includes(q) || r.msg.uin.includes(q) || r.search.includes(q);
|
||||
});
|
||||
}, [rows, kindFilter, query]);
|
||||
|
||||
const exportJson = () => {
|
||||
downloadBlob(
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify(
|
||||
visible.map((r) => ({ at: r.at, ...r.msg })),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
],
|
||||
{ type: 'application/json' },
|
||||
),
|
||||
`debug-activity-${visible.length}.json`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className={cn(cardCls, 'flex min-h-[28rem] flex-col p-6')}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<RadioTower className="h-[18px] w-[18px] text-primary" />
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">实时活动</h2>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{visible.length}
|
||||
</span>
|
||||
<StatusPill status={status} paused={paused} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Segmented
|
||||
value={kindFilter}
|
||||
onChange={setKindFilter}
|
||||
options={[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'event', label: '事件' },
|
||||
{ value: 'action', label: '调用' },
|
||||
]}
|
||||
/>
|
||||
<IconBtn onClick={() => setPaused((v) => !v)} title={paused ? '继续' : '暂停'}>
|
||||
{paused ? (
|
||||
<Play className="optical-forward h-4 w-4" />
|
||||
) : (
|
||||
<Pause className="h-4 w-4" />
|
||||
)}
|
||||
</IconBtn>
|
||||
<IconBtn onClick={exportJson} title="导出 JSON">
|
||||
<Download className="h-4 w-4" />
|
||||
</IconBtn>
|
||||
<IconBtn onClick={() => setRows([])} title="清空">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-border/60 px-2.5 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索类型 / 账号 / 内容…"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-1 flex-col gap-1 overflow-auto">
|
||||
{visible.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/60">
|
||||
<Activity className="h-6 w-6 text-muted-foreground/70" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{paused
|
||||
? '已暂停'
|
||||
: query || kindFilter !== 'all'
|
||||
? '无匹配'
|
||||
: '等待事件…'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
visible.map((r) => <StreamRowItem key={r.id} row={r} />)
|
||||
)}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
// MessageBuilder — a visual OneBot message composer so you never hand-write a
|
||||
// segment array. Common segments are first-class (text / @ / image / face /
|
||||
// reply / record / video); advanced ones fold away (file / mface / poke / json
|
||||
// / xml / markdown / forward-node). `node` nests a full builder (merged
|
||||
// forward). Rows reorder by drag. Emits the live OneBot segment array up.
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AtSign,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
FileText,
|
||||
Film,
|
||||
Forward,
|
||||
GripVertical,
|
||||
Hand,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
Plus,
|
||||
Quote,
|
||||
Smile,
|
||||
Sticker,
|
||||
Trash2,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Picker } from '@/components/ui/picker';
|
||||
import { FaceGrid } from '@/components/ui/face-grid';
|
||||
import { FileSource } from '@/components/debug/file-source';
|
||||
import { useGroupMembers } from '@/hooks/use-debug-contacts';
|
||||
|
||||
// ── segment model ──
|
||||
// `_id` is a stable React key tied to a segment's identity (so editing one field
|
||||
// doesn't remount the row and lose focus / inner control state); it is NOT
|
||||
// serialised. SegBody is the discriminated union; Seg adds the id.
|
||||
type SegBody =
|
||||
| { k: 'text'; text: string }
|
||||
| { k: 'at'; qq: string }
|
||||
| { k: 'image'; file: string }
|
||||
| { k: 'face'; id: string }
|
||||
| { k: 'reply'; id: string }
|
||||
| { k: 'record'; file: string }
|
||||
| { k: 'video'; file: string }
|
||||
| { k: 'file'; file: string; name: string }
|
||||
| { k: 'mface'; emoji_id: string; emoji_package_id: string; key: string; summary: string }
|
||||
| { k: 'poke'; type: string }
|
||||
| { k: 'json'; data: string }
|
||||
| { k: 'xml'; data: string }
|
||||
| { k: 'markdown'; content: string }
|
||||
| { k: 'node'; user_id: string; nickname: string; content: Seg[] };
|
||||
export type Seg = SegBody & { _id: string };
|
||||
export type MessageBuilderMode = 'auto' | 'message' | 'forward' | 'node-content';
|
||||
|
||||
let segSeq = 0;
|
||||
const nid = () => `s${segSeq++}`;
|
||||
|
||||
const BLANK: Record<SegBody['k'], () => SegBody> = {
|
||||
text: () => ({ k: 'text', text: '' }),
|
||||
at: () => ({ k: 'at', qq: '' }),
|
||||
image: () => ({ k: 'image', file: '' }),
|
||||
face: () => ({ k: 'face', id: '' }),
|
||||
reply: () => ({ k: 'reply', id: '' }),
|
||||
record: () => ({ k: 'record', file: '' }),
|
||||
video: () => ({ k: 'video', file: '' }),
|
||||
file: () => ({ k: 'file', file: '', name: '' }),
|
||||
mface: () => ({ k: 'mface', emoji_id: '', emoji_package_id: '', key: '', summary: '' }),
|
||||
poke: () => ({ k: 'poke', type: '1' }),
|
||||
json: () => ({ k: 'json', data: '' }),
|
||||
xml: () => ({ k: 'xml', data: '' }),
|
||||
markdown: () => ({ k: 'markdown', content: '' }),
|
||||
node: () => ({ k: 'node', user_id: '', nickname: '', content: [] }),
|
||||
};
|
||||
|
||||
/** A fresh segment with a stable id. */
|
||||
export function newSeg(k: SegBody['k']): Seg {
|
||||
return { ...BLANK[k](), _id: nid() } as Seg;
|
||||
}
|
||||
|
||||
/** Serialise the builder model to a OneBot v11 segment array (drops `_id`). */
|
||||
export function toOneBot(segs: Seg[]): Array<{ type: string; data: Record<string, unknown> }> {
|
||||
return segs.map((s): { type: string; data: Record<string, unknown> } => {
|
||||
switch (s.k) {
|
||||
case 'text':
|
||||
return { type: 'text', data: { text: s.text } };
|
||||
case 'at':
|
||||
return { type: 'at', data: { qq: s.qq } };
|
||||
case 'image':
|
||||
return { type: 'image', data: { file: s.file } };
|
||||
case 'face':
|
||||
return { type: 'face', data: { id: Number(s.id) || 0 } };
|
||||
case 'reply':
|
||||
return { type: 'reply', data: { id: Number(s.id) || 0 } };
|
||||
case 'record':
|
||||
return { type: 'record', data: { file: s.file } };
|
||||
case 'video':
|
||||
return { type: 'video', data: { file: s.file } };
|
||||
case 'file':
|
||||
return { type: 'file', data: { file: s.file, name: s.name } };
|
||||
case 'mface':
|
||||
return {
|
||||
type: 'mface',
|
||||
data: {
|
||||
emoji_id: s.emoji_id,
|
||||
emoji_package_id: s.emoji_package_id,
|
||||
key: s.key,
|
||||
summary: s.summary,
|
||||
},
|
||||
};
|
||||
case 'poke':
|
||||
return { type: 'poke', data: { type: Number(s.type) || 1 } };
|
||||
case 'json':
|
||||
return { type: 'json', data: { data: s.data } };
|
||||
case 'xml':
|
||||
return { type: 'xml', data: { data: s.data } };
|
||||
case 'markdown':
|
||||
return { type: 'markdown', data: { content: s.content } };
|
||||
case 'node':
|
||||
return {
|
||||
type: 'node',
|
||||
data: {
|
||||
user_id: Number(s.user_id) || 0,
|
||||
nickname: s.nickname,
|
||||
content: toOneBot(s.content),
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const COMMON: { k: SegBody['k']; label: string; icon: React.ReactNode }[] = [
|
||||
{ k: 'text', label: '文本', icon: <Type className="h-4 w-4" /> },
|
||||
{ k: 'at', label: '@', icon: <AtSign className="h-4 w-4" /> },
|
||||
{ k: 'image', label: '图片', icon: <ImageIcon className="h-4 w-4" /> },
|
||||
{ k: 'face', label: '表情', icon: <Smile className="h-4 w-4" /> },
|
||||
{ k: 'reply', label: '回复', icon: <Quote className="h-4 w-4" /> },
|
||||
{ k: 'record', label: '语音', icon: <Mic className="h-4 w-4" /> },
|
||||
{ k: 'video', label: '视频', icon: <Film className="h-4 w-4" /> },
|
||||
];
|
||||
const ADVANCED: { k: SegBody['k']; label: string; icon: React.ReactNode }[] = [
|
||||
{ k: 'file', label: '文件', icon: <FileText className="h-4 w-4" /> },
|
||||
{ k: 'mface', label: '商城表情', icon: <Sticker className="h-4 w-4" /> },
|
||||
{ k: 'poke', label: '戳一戳', icon: <Hand className="h-4 w-4" /> },
|
||||
{ k: 'json', label: 'JSON 卡片', icon: <Code2 className="h-4 w-4" /> },
|
||||
{ k: 'xml', label: 'XML 卡片', icon: <Code2 className="h-4 w-4" /> },
|
||||
{ k: 'markdown', label: 'Markdown', icon: <Code2 className="h-4 w-4" /> },
|
||||
{ k: 'node', label: '合并转发节点', icon: <Forward className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
/** Segment kinds exposed by the add menu for a builder context. */
|
||||
export function messageBuilderSegmentKinds(
|
||||
mode: MessageBuilderMode,
|
||||
segments: Seg[],
|
||||
): SegBody['k'][] {
|
||||
const all = [...COMMON, ...ADVANCED].map((item) => item.k);
|
||||
|
||||
if (mode === 'auto') {
|
||||
return all;
|
||||
}
|
||||
|
||||
if (mode === 'message') {
|
||||
return all.filter((kind) => kind !== 'node');
|
||||
}
|
||||
|
||||
if (mode === 'forward') {
|
||||
return ['node'];
|
||||
}
|
||||
|
||||
// A forward node may contain either an ordinary message or another list of
|
||||
// nodes, never a mixture. Window shake is also rejected anywhere inside a
|
||||
// forward payload. Lock the menu to the branch selected by the first child.
|
||||
const forwardSafe = all.filter((kind) => kind !== 'poke');
|
||||
|
||||
if (segments.length === 0) {
|
||||
return forwardSafe;
|
||||
}
|
||||
|
||||
if (segments.every((segment) => segment.k === 'node')) {
|
||||
return ['node'];
|
||||
}
|
||||
|
||||
if (segments.every((segment) => segment.k !== 'node')) {
|
||||
return forwardSafe.filter((kind) => kind !== 'node');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const LABELS: Record<SegBody['k'], string> = {
|
||||
text: '文本',
|
||||
at: '@',
|
||||
image: '图片',
|
||||
face: '表情',
|
||||
reply: '回复',
|
||||
record: '语音',
|
||||
video: '视频',
|
||||
file: '文件',
|
||||
mface: '商城表情',
|
||||
poke: '戳一戳',
|
||||
json: 'JSON',
|
||||
xml: 'XML',
|
||||
markdown: 'Markdown',
|
||||
node: '转发节点',
|
||||
};
|
||||
|
||||
export function MessageBuilder({
|
||||
segments,
|
||||
onChange,
|
||||
uin,
|
||||
groupId,
|
||||
depth = 0,
|
||||
mode = 'auto',
|
||||
}: {
|
||||
segments: Seg[];
|
||||
onChange: (segs: Seg[]) => void;
|
||||
uin: string;
|
||||
/** group context for @ member picking (empty for private targets). */
|
||||
groupId: string;
|
||||
depth?: number;
|
||||
mode?: MessageBuilderMode;
|
||||
}) {
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [dragFrom, setDragFrom] = useState<number | null>(null);
|
||||
|
||||
const add = (k: SegBody['k']) => {
|
||||
onChange([...segments, newSeg(k)]);
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
const patch = (i: number, next: Seg) => onChange(segments.map((s, j) => (j === i ? next : s)));
|
||||
const remove = (i: number) => onChange(segments.filter((_, j) => j !== i));
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= segments.length || from === to) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = segments.slice();
|
||||
const [it] = next.splice(from, 1);
|
||||
next.splice(to, 0, it);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
// Keep the top-level canvas concentric with its rows at any operator-selected
|
||||
// corner radius. At the default 12px radius this is 20px → 12px over the 8px
|
||||
// inset. Nested forward builders deliberately avoid another canvas surface:
|
||||
// cards-inside-cards add visual noise and cannot remain concentric with a
|
||||
// parent row once the operator selects a compact radius.
|
||||
const nested = depth > 0;
|
||||
const canvasRadius = 'calc(var(--radius) + 0.5rem)';
|
||||
const rowRadius =
|
||||
depth === 0
|
||||
? 'var(--radius)'
|
||||
: depth === 1
|
||||
? 'max(0px, calc(var(--radius) - 0.25rem))'
|
||||
: 'max(0px, calc(var(--radius) - 0.5rem))';
|
||||
const allowedKinds = new Set(messageBuilderSegmentKinds(mode, segments));
|
||||
const commonOptions = COMMON.filter((item) => allowedKinds.has(item.k));
|
||||
const advancedOptions = ADVANCED.filter((item) => allowedKinds.has(item.k));
|
||||
const primaryOptions = mode === 'forward' ? advancedOptions : commonOptions;
|
||||
const secondaryOptions = mode === 'forward' ? [] : advancedOptions;
|
||||
|
||||
return (
|
||||
// The root is a bounded compose canvas, so its empty hint reads as an
|
||||
// intentional empty state rather than a stray line. Nested builders inherit
|
||||
// the surrounding node surface instead of drawing another card within it.
|
||||
<div
|
||||
className={
|
||||
nested
|
||||
? 'flex flex-col gap-2'
|
||||
: 'flex flex-col gap-2 border border-border/50 bg-muted/20 p-2'
|
||||
}
|
||||
style={nested ? undefined : { borderRadius: canvasRadius }}
|
||||
>
|
||||
{segments.length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
{mode === 'forward'
|
||||
? '空转发 — 点下方添加转发节点'
|
||||
: '空消息 — 点下方「添加段」开始'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{segments.map((s, i) => (
|
||||
<div
|
||||
key={s._id}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (dragFrom !== null) {
|
||||
move(dragFrom, i);
|
||||
}
|
||||
|
||||
setDragFrom(null);
|
||||
}}
|
||||
className="border border-border/50 bg-card/60 p-2"
|
||||
style={{ borderRadius: rowRadius }}
|
||||
>
|
||||
{/* Drag is scoped to the grip handle only — making the whole row
|
||||
draggable hijacked text selection inside the segment inputs (you
|
||||
couldn't select part of a pasted URL without dragging the card).
|
||||
The row stays the drop target; its body is fully selectable. */}
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
draggable
|
||||
// stopPropagation keeps a nested (forward-node) builder's drag
|
||||
// from reaching the parent row; onDragEnd lives here because the
|
||||
// grip — not the row — is now the drag source.
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('text/plain', String(i));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setDragFrom(i);
|
||||
}}
|
||||
onDragEnd={() => setDragFrom(null)}
|
||||
title="拖动此处排序"
|
||||
aria-label="拖动排序"
|
||||
className="flex shrink-0 cursor-grab rounded-md p-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-xs font-semibold text-muted-foreground">
|
||||
{LABELS[s.k]}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, i - 1)}
|
||||
disabled={i === 0}
|
||||
className="rounded px-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
|
||||
title="上移"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, i + 1)}
|
||||
disabled={i === segments.length - 1}
|
||||
className="rounded px-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
|
||||
title="下移"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SegmentEditor
|
||||
seg={s}
|
||||
onChange={(n) => patch(i, n)}
|
||||
uin={uin}
|
||||
groupId={groupId}
|
||||
depth={depth}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdd((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-border/70 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 添加段
|
||||
</button>
|
||||
{showAdd && (
|
||||
<div className="absolute z-30 mt-1.5 w-64 rounded-xl border border-border/60 bg-popover p-2 shadow-lg">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{primaryOptions.map((c) => (
|
||||
<button
|
||||
key={c.k}
|
||||
type="button"
|
||||
onClick={() => add(c.k)}
|
||||
className="flex flex-col items-center gap-1 rounded-sm p-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{c.icon}
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{secondaryOptions.length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer list-none px-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ChevronDown className="h-3 w-3" /> 进阶段
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-1 grid grid-cols-4 gap-1">
|
||||
{secondaryOptions.map((c) => (
|
||||
<button
|
||||
key={c.k}
|
||||
type="button"
|
||||
onClick={() => add(c.k)}
|
||||
className="flex flex-col items-center gap-1 rounded-sm p-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{c.icon}
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AtEditor({
|
||||
seg,
|
||||
onChange,
|
||||
uin,
|
||||
groupId,
|
||||
}: {
|
||||
seg: Extract<Seg, { k: 'at' }>;
|
||||
onChange: (s: Seg) => void;
|
||||
uin: string;
|
||||
groupId: string;
|
||||
}) {
|
||||
const { items, loading, error, refresh } = useGroupMembers(uin, groupId);
|
||||
const options = [{ value: 'all', label: '@全体成员', sub: 'all' }, ...items];
|
||||
return (
|
||||
<Picker
|
||||
ariaLabel="@某人"
|
||||
value={seg.qq}
|
||||
onChange={(v) => onChange({ ...seg, qq: v })}
|
||||
options={options}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
placeholder={groupId ? '选择成员 / @全体' : '输入 QQ 号(私聊无成员)'}
|
||||
validateRaw={(s) => s === 'all' || /^\d{3,}$/.test(s)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentEditor({
|
||||
seg,
|
||||
onChange,
|
||||
uin,
|
||||
groupId,
|
||||
depth,
|
||||
}: {
|
||||
seg: Seg;
|
||||
onChange: (s: Seg) => void;
|
||||
uin: string;
|
||||
groupId: string;
|
||||
depth: number;
|
||||
}) {
|
||||
switch (seg.k) {
|
||||
case 'text':
|
||||
return (
|
||||
<textarea
|
||||
value={seg.text}
|
||||
onChange={(e) => onChange({ ...seg, text: e.target.value })}
|
||||
placeholder="文本内容"
|
||||
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
||||
/>
|
||||
);
|
||||
case 'at':
|
||||
return <AtEditor seg={seg} onChange={onChange} uin={uin} groupId={groupId} />;
|
||||
case 'image':
|
||||
return (
|
||||
<FileSource
|
||||
role="image"
|
||||
value={seg.file}
|
||||
onChange={(v) => onChange({ ...seg, file: v })}
|
||||
/>
|
||||
);
|
||||
case 'record':
|
||||
return (
|
||||
<FileSource
|
||||
role="record"
|
||||
value={seg.file}
|
||||
onChange={(v) => onChange({ ...seg, file: v })}
|
||||
/>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<FileSource
|
||||
role="video"
|
||||
value={seg.file}
|
||||
onChange={(v) => onChange({ ...seg, file: v })}
|
||||
/>
|
||||
);
|
||||
case 'file':
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FileSource
|
||||
role="file"
|
||||
value={seg.file}
|
||||
onChange={(v) => onChange({ ...seg, file: v })}
|
||||
/>
|
||||
<Input
|
||||
value={seg.name}
|
||||
onChange={(e) => onChange({ ...seg, name: e.target.value })}
|
||||
placeholder="文件名(可选)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'face':
|
||||
return <FaceGrid value={seg.id} onChange={(v) => onChange({ ...seg, id: v })} />;
|
||||
case 'reply':
|
||||
return (
|
||||
<Input
|
||||
value={seg.id}
|
||||
onChange={(e) =>
|
||||
onChange({ ...seg, id: e.target.value.replace(/[^\d-]/g, '') })
|
||||
}
|
||||
placeholder="回复的消息 id"
|
||||
className="font-mono"
|
||||
/>
|
||||
);
|
||||
case 'poke':
|
||||
return (
|
||||
<Input
|
||||
value={seg.type}
|
||||
onChange={(e) =>
|
||||
onChange({ ...seg, type: e.target.value.replace(/[^\d]/g, '') })
|
||||
}
|
||||
placeholder="戳一戳类型(默认 1)"
|
||||
className="font-mono"
|
||||
/>
|
||||
);
|
||||
case 'json':
|
||||
return (
|
||||
<textarea
|
||||
value={seg.data}
|
||||
onChange={(e) => onChange({ ...seg, data: e.target.value })}
|
||||
placeholder='JSON 卡片原文 {"app":…}'
|
||||
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
|
||||
/>
|
||||
);
|
||||
case 'xml':
|
||||
return (
|
||||
<textarea
|
||||
value={seg.data}
|
||||
onChange={(e) => onChange({ ...seg, data: e.target.value })}
|
||||
placeholder="XML 卡片原文 <msg>…"
|
||||
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
|
||||
/>
|
||||
);
|
||||
case 'markdown':
|
||||
return (
|
||||
<textarea
|
||||
value={seg.content}
|
||||
onChange={(e) => onChange({ ...seg, content: e.target.value })}
|
||||
placeholder="Markdown 内容"
|
||||
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
|
||||
/>
|
||||
);
|
||||
case 'mface':
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Input
|
||||
value={seg.emoji_id}
|
||||
onChange={(e) => onChange({ ...seg, emoji_id: e.target.value })}
|
||||
placeholder="emoji_id"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={seg.emoji_package_id}
|
||||
onChange={(e) => onChange({ ...seg, emoji_package_id: e.target.value })}
|
||||
placeholder="emoji_package_id"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={seg.key}
|
||||
onChange={(e) => onChange({ ...seg, key: e.target.value })}
|
||||
placeholder="key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={seg.summary}
|
||||
onChange={(e) => onChange({ ...seg, summary: e.target.value })}
|
||||
placeholder="summary(可选)"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'node':
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Input
|
||||
value={seg.user_id}
|
||||
onChange={(e) =>
|
||||
onChange({ ...seg, user_id: e.target.value.replace(/[^\d]/g, '') })
|
||||
}
|
||||
placeholder="发送者 QQ"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={seg.nickname}
|
||||
onChange={(e) => onChange({ ...seg, nickname: e.target.value })}
|
||||
placeholder="发送者昵称"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs text-muted-foreground">
|
||||
节点内容(可再嵌套)
|
||||
</span>
|
||||
{depth < 3 ? (
|
||||
<MessageBuilder
|
||||
segments={seg.content}
|
||||
onChange={(c) => onChange({ ...seg, content: c })}
|
||||
uin={uin}
|
||||
groupId={groupId}
|
||||
depth={depth + 1}
|
||||
mode="node-content"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">嵌套层级过深</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact human-readable preview line of a built message. */
|
||||
export function previewSegments(segs: Seg[]): string {
|
||||
return segs
|
||||
.map((s) => {
|
||||
switch (s.k) {
|
||||
case 'text':
|
||||
return s.text;
|
||||
case 'at':
|
||||
return s.qq === 'all' ? '@全体' : `@${s.qq}`;
|
||||
case 'image':
|
||||
return '[图片]';
|
||||
case 'face':
|
||||
return `[表情${s.id}]`;
|
||||
case 'reply':
|
||||
return '[回复]';
|
||||
case 'record':
|
||||
return '[语音]';
|
||||
case 'video':
|
||||
return '[视频]';
|
||||
case 'file':
|
||||
return `[文件${s.name ? ':' + s.name : ''}]`;
|
||||
case 'mface':
|
||||
return '[商城表情]';
|
||||
case 'poke':
|
||||
return '[戳一戳]';
|
||||
case 'json':
|
||||
return '[JSON卡片]';
|
||||
case 'xml':
|
||||
return '[XML卡片]';
|
||||
case 'markdown':
|
||||
return '[Markdown]';
|
||||
case 'node':
|
||||
return `[转发:${s.nickname || s.user_id}]`;
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** True if the message contains forward nodes (→ send via the forward action). */
|
||||
export function hasNodes(segs: Seg[]): boolean {
|
||||
return segs.some((s) => s.k === 'node');
|
||||
}
|
||||
|
||||
/** Per-segment completeness check (recurses into node content). Returns the
|
||||
* first problem string, or null. The forward "all nodes" rule is enforced
|
||||
* separately at the top level (only the forward action's `messages` array must
|
||||
* be all-nodes; a node's own content is a normal message). */
|
||||
export function validateSegs(segs: Seg[]): string | null {
|
||||
for (const s of segs) {
|
||||
switch (s.k) {
|
||||
case 'at':
|
||||
if (!s.qq.trim()) {
|
||||
return '@ 段未选择对象';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'image':
|
||||
if (!s.file.trim()) {
|
||||
return '图片段未指定来源';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'record':
|
||||
if (!s.file.trim()) {
|
||||
return '语音段未指定来源';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'video':
|
||||
if (!s.file.trim()) {
|
||||
return '视频段未指定来源';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'file':
|
||||
if (!s.file.trim()) {
|
||||
return '文件段未指定来源';
|
||||
}
|
||||
|
||||
break;
|
||||
case 'reply':
|
||||
if (!s.id.trim()) {
|
||||
return '回复段缺少消息 id';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'node': {
|
||||
if (!s.user_id.trim()) {
|
||||
return '转发节点缺少发送者 QQ';
|
||||
}
|
||||
|
||||
if (s.content.length === 0) {
|
||||
return '转发节点内容为空';
|
||||
}
|
||||
|
||||
const inner = validateSegs(s.content);
|
||||
|
||||
if (inner) {
|
||||
return inner;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// MessageComposer — the standalone "消息构建" tab: pick a target (group or
|
||||
// friend), build a message visually, preview it, send it. A message containing
|
||||
// forward nodes goes through the forward-send action; otherwise the plain
|
||||
// send_*_msg. The chosen group provides the @-member context to the builder.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Loader2, MessageSquarePlus, Send } from 'lucide-react';
|
||||
import { Dropdown } from '@/components/interior/dropdown';
|
||||
import { Picker } from '@/components/ui/picker';
|
||||
import { JsonTree } from '@/components/ui/json-tree';
|
||||
import { Segmented } from '@/components/debug/segmented';
|
||||
import {
|
||||
MessageBuilder,
|
||||
hasNodes,
|
||||
previewSegments,
|
||||
toOneBot,
|
||||
validateSegs,
|
||||
type Seg,
|
||||
} from '@/components/debug/message-builder';
|
||||
import { useFriends, useGroups } from '@/hooks/use-debug-contacts';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DebugInvokeResult, QQInfo } from '@/types';
|
||||
|
||||
const cardCls =
|
||||
'rounded-2xl border border-border/60 bg-card/80 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_8px_24px_-12px_rgb(0_0_0/0.10)] backdrop-blur-sm';
|
||||
const looksLikeId = (s: string) => /^\d{3,}$/.test(s.trim());
|
||||
|
||||
function TargetPicker({
|
||||
kind,
|
||||
uin,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
kind: 'group' | 'friend';
|
||||
uin: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const groups = useGroups(kind === 'group' ? uin : '');
|
||||
const friends = useFriends(kind === 'friend' ? uin : '');
|
||||
const src = kind === 'group' ? groups : friends;
|
||||
return (
|
||||
<Picker
|
||||
ariaLabel={kind === 'group' ? '选择群' : '选择好友'}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={src.items}
|
||||
loading={src.loading}
|
||||
error={src.error}
|
||||
onRefresh={src.refresh}
|
||||
placeholder={kind === 'group' ? '选择群…' : '选择好友…'}
|
||||
validateRaw={looksLikeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageComposer({ accounts }: { accounts: QQInfo[] }) {
|
||||
const api = useApi();
|
||||
const [uin, setUin] = useState('');
|
||||
const [kind, setKind] = useState<'group' | 'friend'>('group');
|
||||
const [target, setTarget] = useState('');
|
||||
const [segs, setSegs] = useState<Seg[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [result, setResult] = useState<DebugInvokeResult | { error: string } | null>(null);
|
||||
|
||||
// default to the first account once loaded
|
||||
useEffect(() => {
|
||||
if (!uin && accounts[0]) {
|
||||
setUin(accounts[0].uin);
|
||||
}
|
||||
}, [accounts, uin]);
|
||||
|
||||
const preview = useMemo(() => previewSegments(segs), [segs]);
|
||||
const groupId = kind === 'group' ? target : '';
|
||||
|
||||
const send = async () => {
|
||||
if (!uin) {
|
||||
setResult({ error: '请选择账号' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
setResult({ error: kind === 'group' ? '请选择群' : '请选择好友' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segs.length === 0) {
|
||||
setResult({ error: '消息为空' });
|
||||
return;
|
||||
}
|
||||
|
||||
const forward = hasNodes(segs);
|
||||
|
||||
// Forward messages must be ALL nodes: the backend silently drops top-level
|
||||
// non-node segments, so a mixed message would send less than the preview
|
||||
// shows. Block it with a clear message instead.
|
||||
if (forward && !segs.every((s) => s.k === 'node')) {
|
||||
setResult({
|
||||
error: '合并转发消息只能由转发节点组成 — 请移除非节点段,或把它们放进某个转发节点内',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const incomplete = validateSegs(segs);
|
||||
|
||||
if (incomplete) {
|
||||
setResult({ error: incomplete });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = toOneBot(segs);
|
||||
const action = forward
|
||||
? kind === 'group'
|
||||
? 'send_group_forward_msg'
|
||||
: 'send_private_forward_msg'
|
||||
: kind === 'group'
|
||||
? 'send_group_msg'
|
||||
: 'send_private_msg';
|
||||
const params: Record<string, unknown> =
|
||||
kind === 'group' ? { group_id: target } : { user_id: target };
|
||||
params[forward ? 'messages' : 'message'] = payload;
|
||||
|
||||
setSending(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
setResult(await api.debug.invoke(uin, action, params));
|
||||
} catch (e) {
|
||||
setResult({ error: e instanceof Error ? e.message : '发送失败' });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resultFailed = !!result && ('error' in result || result.status === 'failed');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12">
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className={cn(cardCls, 'flex flex-col gap-5 p-6 xl:col-span-7')}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MessageSquarePlus className="h-[18px] w-[18px] text-primary" />
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">消息构建</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-[auto_1fr]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Fixed h-8 == the target column's segmented-control height, so both
|
||||
label rows are exactly equal and the two selectors below share a
|
||||
baseline (min-h-7 wasn't enough — the segmented is ~32px). */}
|
||||
<div className="flex h-8 items-center">
|
||||
<span className="text-xs font-medium text-muted-foreground">账号</span>
|
||||
</div>
|
||||
<Dropdown
|
||||
value={uin}
|
||||
onChange={setUin}
|
||||
label="账号"
|
||||
placeholder={accounts.length === 0 ? '(无在线账号)' : '选择账号'}
|
||||
items={accounts.map((account) => ({
|
||||
value: account.uin,
|
||||
label: account.nickname || account.uin,
|
||||
}))}
|
||||
className="w-full sm:w-44"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex h-8 items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">目标</span>
|
||||
<Segmented
|
||||
value={kind}
|
||||
onChange={(k) => {
|
||||
setKind(k);
|
||||
setTarget('');
|
||||
}}
|
||||
options={[
|
||||
{ value: 'group', label: '群聊' },
|
||||
{ value: 'friend', label: '私聊' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<TargetPicker kind={kind} uin={uin} value={target} onChange={setTarget} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">消息内容</span>
|
||||
<MessageBuilder
|
||||
segments={segs}
|
||||
onChange={setSegs}
|
||||
uin={uin}
|
||||
groupId={groupId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={sending}
|
||||
data-press-scale=""
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-xl bg-primary text-[15px] font-medium text-primary-foreground shadow-sm transition-[background-color,opacity,scale] duration-150 ease-out hover:bg-primary/90 active:not-disabled:scale-[0.96] motion-reduce:active:scale-100 disabled:opacity-50"
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="optical-forward h-4 w-4" />
|
||||
)}{' '}
|
||||
发送{hasNodes(segs) ? '(合并转发)' : ''}
|
||||
</button>
|
||||
</motion.section>
|
||||
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, delay: 0.05 }}
|
||||
className={cn(cardCls, 'flex min-h-[20rem] flex-col gap-3 p-6 xl:col-span-5')}
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold tracking-tight">预览</h3>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 p-3 text-sm">
|
||||
{preview ? (
|
||||
<span className="whitespace-pre-wrap break-words">{preview}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">消息为空</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">OneBot 段</span>
|
||||
<JsonTree data={toOneBot(segs)} maxHeight="14rem" />
|
||||
|
||||
{result && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
resultFailed ? 'text-destructive' : 'text-success',
|
||||
)}
|
||||
>
|
||||
{resultFailed ? '发送失败' : '已发送'}
|
||||
</span>
|
||||
{'error' in result ? (
|
||||
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{result.error}
|
||||
</div>
|
||||
) : (
|
||||
<JsonTree data={result} maxHeight="12rem" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// ParamField — renders the right smart control for one action param, driven by
|
||||
// its semantic `role` first, then its coercion `type`, with the plain typed
|
||||
// input as the floor. This is what kills "hand-write the JSON": a group_id
|
||||
// becomes a group picker, a member_id a member picker linked to the sibling
|
||||
// group_id, an image a FileSource, a bool a switch, an enum a select.
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Dropdown } from '@/components/interior/dropdown';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { Picker } from '@/components/ui/picker';
|
||||
import { FaceGrid } from '@/components/ui/face-grid';
|
||||
import { FileSource } from '@/components/debug/file-source';
|
||||
import { Segmented } from '@/components/debug/segmented';
|
||||
import {
|
||||
MessageBuilder,
|
||||
toOneBot,
|
||||
type MessageBuilderMode,
|
||||
type Seg,
|
||||
} from '@/components/debug/message-builder';
|
||||
import { useFriends, useGroups, useGroupMembers } from '@/hooks/use-debug-contacts';
|
||||
import type { DebugActionParam, FieldRole } from '@/types';
|
||||
|
||||
const isNumericType = (t: string) => /int|uint|number|messageId/i.test(t);
|
||||
const looksLikeId = (s: string) => /^\d{3,}$/.test(s.trim());
|
||||
|
||||
function GroupPicker({
|
||||
uin,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
uin: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { items, loading, error, refresh } = useGroups(uin);
|
||||
return (
|
||||
<Picker
|
||||
ariaLabel="选择群"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={items}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
placeholder="选择群…"
|
||||
validateRaw={looksLikeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FriendPicker({
|
||||
uin,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
uin: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { items, loading, error, refresh } = useFriends(uin);
|
||||
return (
|
||||
<Picker
|
||||
ariaLabel="选择好友"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={items}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
placeholder="选择好友…"
|
||||
validateRaw={looksLikeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberPicker({
|
||||
uin,
|
||||
groupId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
uin: string;
|
||||
groupId: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { items, loading, error, refresh } = useGroupMembers(uin, groupId);
|
||||
return (
|
||||
<Picker
|
||||
ariaLabel="选择群成员"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={items}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
placeholder={groupId ? '选择群成员…' : '请先选择群号'}
|
||||
validateRaw={looksLikeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MEDIA_ROLES: FieldRole[] = ['file', 'image', 'record', 'video'];
|
||||
|
||||
export function ParamField({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
uin,
|
||||
groupContext,
|
||||
actionName,
|
||||
}: {
|
||||
param: DebugActionParam;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
uin: string;
|
||||
/** value of the sibling group_id field (for member_id pickers). */
|
||||
groupContext: string;
|
||||
/** Selected Action controls which message shapes the visual builder offers. */
|
||||
actionName: string;
|
||||
}) {
|
||||
const role = param.role;
|
||||
|
||||
// ── role-driven widgets ──
|
||||
if (role === 'group_id') {
|
||||
return <GroupPicker uin={uin} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
if (role === 'user_id') {
|
||||
return <FriendPicker uin={uin} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
if (role === 'member_id') {
|
||||
return <MemberPicker uin={uin} groupId={groupContext} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
if (role && MEDIA_ROLES.includes(role)) {
|
||||
return <FileSource role={role} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
if (role === 'face_id') {
|
||||
return <FaceGrid value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
if (role === 'timestamp') {
|
||||
// datetime-local <-> unix seconds
|
||||
const local = (() => {
|
||||
const n = Number(value);
|
||||
|
||||
if (!value || !Number.isFinite(n) || n <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const d = new Date(n * 1000);
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
})();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={local}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onChange(v ? String(Math.floor(new Date(v).getTime() / 1000)) : '');
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/[^\d]/g, ''))}
|
||||
placeholder="unix 秒"
|
||||
className="w-28 font-mono"
|
||||
aria-label="时间戳"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── type-driven widgets ──
|
||||
if (param.values && param.values.length > 0) {
|
||||
return (
|
||||
<Dropdown
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={param.name}
|
||||
placeholder={param.required ? '(请选择)' : '(默认)'}
|
||||
items={[
|
||||
{ value: '', label: param.required ? '(请选择)' : '(默认)' },
|
||||
...param.values.map((nextValue) => ({
|
||||
value: String(nextValue),
|
||||
label: String(nextValue),
|
||||
})),
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === 'bool' || param.type === 'boolean') {
|
||||
return (
|
||||
<div className="flex h-9 items-center">
|
||||
<ToggleSwitch
|
||||
value={value === 'true' || value === '1'}
|
||||
onChange={(v) => onChange(v ? 'true' : 'false')}
|
||||
ariaLabel={param.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === 'message') {
|
||||
return (
|
||||
<MessageParamField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uin={uin}
|
||||
groupContext={groupContext}
|
||||
mode={messageBuilderModeForAction(actionName)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === 'object' || param.type === 'string | object[]') {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={param.name}
|
||||
placeholder={param.type === 'object' ? '{\n \n}' : '"你好"'}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{param.type === 'object'
|
||||
? '粘贴完整 JSON 对象,保留原始字段。'
|
||||
: '文本填写 JSON 字符串(如 "你好"),富文本填写对象数组。'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNumericType(param.type)) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
param.desc ||
|
||||
(param.default !== undefined ? `默认 ${JSON.stringify(param.default)}` : '')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
param.desc ||
|
||||
(param.default !== undefined ? `默认 ${JSON.stringify(param.default)}` : '')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function messageBuilderModeForAction(actionName: string): MessageBuilderMode {
|
||||
return /(?:forward|foward)_msg$/.test(actionName) ? 'forward' : 'message';
|
||||
}
|
||||
|
||||
// Message param: the visual builder (keeps its own Seg[] model and emits the
|
||||
// serialized OneBot array as the string param value) with a raw-text escape
|
||||
// hatch. The builder is one-way (builder → value); switching to 原文 edits the
|
||||
// string directly — the tester's global JSON mode covers full raw editing.
|
||||
function MessageParamField({
|
||||
value,
|
||||
onChange,
|
||||
uin,
|
||||
groupContext,
|
||||
mode,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
uin: string;
|
||||
groupContext: string;
|
||||
mode: MessageBuilderMode;
|
||||
}) {
|
||||
const [inputMode, setInputMode] = useState<'build' | 'raw'>('build');
|
||||
const [segs, setSegs] = useState<Seg[]>([]);
|
||||
|
||||
const updateSegs = (next: Seg[]) => {
|
||||
setSegs(next);
|
||||
onChange(next.length ? JSON.stringify(toOneBot(next)) : '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-end">
|
||||
<Segmented
|
||||
value={inputMode}
|
||||
onChange={setInputMode}
|
||||
options={[
|
||||
{ value: 'build', label: '构建器' },
|
||||
{ value: 'raw', label: '原文' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{inputMode === 'build' ? (
|
||||
<MessageBuilder
|
||||
segments={segs}
|
||||
onChange={updateSegs}
|
||||
uin={uin}
|
||||
groupId={groupContext}
|
||||
mode={mode}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder='文本,或 OneBot 段数组 JSON:[{"type":"text","data":{"text":"hi"}}]'
|
||||
className="min-h-20 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Segmented — an iOS-style segmented control with a sliding active pill. Shared
|
||||
// by the debug console's tester / stream panels. Extracted from the original
|
||||
// debug page so multiple panels reuse one implementation.
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { value: T; label: ReactNode }[];
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div role="radiogroup" className="inline-flex rounded-full bg-muted/70 p-0.5 text-sm">
|
||||
{options.map((o) => {
|
||||
const active = o.value === value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'relative rounded-full px-3.5 py-1 font-medium transition-colors',
|
||||
active
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={`seg-${id}`}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 38 }}
|
||||
className="absolute inset-0 rounded-full bg-card shadow-sm ring-1 ring-border/50"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{o.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { CheckCircle2, Loader2, X, XCircle } from 'lucide-react';
|
||||
import { useDebugTasks, useRunningTaskSummary, type DebugTask } from '@/contexts/DebugTaskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function StatusIcon({ status }: { status: DebugTask['status'] }) {
|
||||
if (status === 'running') {
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />;
|
||||
}
|
||||
|
||||
if (status === 'done') {
|
||||
return <CheckCircle2 className="h-3.5 w-3.5 text-success" />;
|
||||
}
|
||||
|
||||
return <XCircle className="h-3.5 w-3.5 text-destructive" />;
|
||||
}
|
||||
|
||||
function Row({ task, onDismiss }: { task: DebugTask; onDismiss: () => void }) {
|
||||
const pct = typeof task.progress === 'number' ? Math.round(task.progress * 100) : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-lg px-2.5 py-2 hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<StatusIcon status={task.status} />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/90">{task.label}</span>
|
||||
{task.status === 'running' && task.cancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={task.cancel}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
title="取消"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : task.status !== 'running' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="移除"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{task.status === 'running' && (
|
||||
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
|
||||
pct === null && 'animate-pulse',
|
||||
)}
|
||||
style={{ width: pct === null ? '40%' : `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{task.detail && task.status !== 'running' && (
|
||||
<span className="truncate text-xs text-muted-foreground" title={task.detail}>
|
||||
{task.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskBadge() {
|
||||
const { tasks, remove, clearFinished } = useDebugTasks();
|
||||
const { count, progress } = useRunningTaskSummary();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ringPct = progress === null ? null : Math.round(progress * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[60] flex flex-col items-end gap-2"
|
||||
style={{
|
||||
bottom: 'max(1rem, env(safe-area-inset-bottom))',
|
||||
right: 'max(1rem, env(safe-area-inset-right))',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 480, damping: 36 }}
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border/60 bg-popover shadow-xl ring-1 ring-black/5"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
|
||||
<span className="text-xs font-semibold">调试任务</span>
|
||||
{tasks.some((t) => t.status !== 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFinished}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
清除已完成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto p-1.5">
|
||||
{tasks.map((t) => (
|
||||
<Row key={t.id} task={t} onDismiss={() => remove(t.id)} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/90 px-3 py-2 text-xs font-medium shadow-lg ring-1 ring-black/5 backdrop-blur transition-colors hover:bg-card"
|
||||
>
|
||||
{count > 0 ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
)}
|
||||
{count > 0 ? (
|
||||
<span>
|
||||
{count} 个任务{ringPct !== null ? ` · ${ringPct}%` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">任务完成</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { shouldWarnAboutInsecureRemoteAccess } from '@/lib/transport-security';
|
||||
|
||||
const DISMISS_KEY = 'snowluma.insecure-remote-access.dismissed.v1';
|
||||
|
||||
function readDismissed(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(DISMISS_KEY) === '1';
|
||||
} catch (error) {
|
||||
console.warn('failed to read insecure transport notice preference', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function InsecureRemoteAccessBanner() {
|
||||
const [dismissed, setDismissed] = useState(readDismissed);
|
||||
const visible =
|
||||
typeof window !== 'undefined' &&
|
||||
shouldWarnAboutInsecureRemoteAccess(window.location) &&
|
||||
!dismissed;
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
setDismissed(true);
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(DISMISS_KEY, '1');
|
||||
} catch (error) {
|
||||
console.warn('failed to persist insecure transport notice preference', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="sticky top-0 z-[100] flex items-center justify-center gap-3 border-b border-amber-500/35 bg-amber-100 px-4 py-2.5 text-sm text-amber-950 shadow-sm dark:bg-amber-950 dark:text-amber-100"
|
||||
>
|
||||
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
|
||||
<p className="max-w-4xl leading-relaxed">
|
||||
当前页面通过远程 HTTP 访问,登录凭据和操作内容可能被同网络设备读取;请在设置中启用
|
||||
HTTPS,或通过受信任的 HTTPS 反向代理访问。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭远程 HTTP 访问提醒"
|
||||
className="ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-amber-500/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-700"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const EASE = [0.23, 1, 0.32, 1] as const;
|
||||
const DISCLOSE = { type: 'spring', stiffness: 190, damping: 30, mass: 1 } as const;
|
||||
const NUDGE = { type: 'spring', stiffness: 700, damping: 46, mass: 0.5 } as const;
|
||||
const INSTANT = { duration: 0 } as const;
|
||||
|
||||
export type BannerState = 'open' | 'folded' | 'dismissed';
|
||||
|
||||
export type UseCollapsibleBannerOptions = {
|
||||
state?: BannerState;
|
||||
defaultState?: BannerState;
|
||||
onStateChange?: (state: BannerState) => void;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
export type UseCollapsibleBannerResult = {
|
||||
state: BannerState;
|
||||
open: boolean;
|
||||
folded: boolean;
|
||||
dismissed: boolean;
|
||||
fold: () => void;
|
||||
expand: () => void;
|
||||
toggle: () => void;
|
||||
dismiss: () => void;
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
export function useCollapsibleBanner({
|
||||
state: controlled,
|
||||
defaultState = 'open',
|
||||
onStateChange,
|
||||
onDismiss,
|
||||
}: UseCollapsibleBannerOptions = {}): UseCollapsibleBannerResult {
|
||||
const [uncontrolled, setUncontrolled] = useState<BannerState>(defaultState);
|
||||
const state = controlled ?? uncontrolled;
|
||||
|
||||
const changed = useRef(onStateChange);
|
||||
const closed = useRef(onDismiss);
|
||||
useEffect(() => {
|
||||
changed.current = onStateChange;
|
||||
}, [onStateChange]);
|
||||
useEffect(() => {
|
||||
closed.current = onDismiss;
|
||||
}, [onDismiss]);
|
||||
|
||||
const commit = useCallback((next: BannerState) => {
|
||||
setUncontrolled(next);
|
||||
changed.current?.(next);
|
||||
}, []);
|
||||
|
||||
const fold = useCallback(() => commit('folded'), [commit]);
|
||||
const expand = useCallback(() => commit('open'), [commit]);
|
||||
const restore = useCallback(() => commit('open'), [commit]);
|
||||
|
||||
const toggle = useCallback(() => commit(state === 'open' ? 'folded' : 'open'), [commit, state]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
commit('dismissed');
|
||||
closed.current?.();
|
||||
}, [commit]);
|
||||
|
||||
return {
|
||||
state,
|
||||
open: state === 'open',
|
||||
folded: state === 'folded',
|
||||
dismissed: state === 'dismissed',
|
||||
fold,
|
||||
expand,
|
||||
toggle,
|
||||
dismiss,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
|
||||
const NOTICE_GLYPH = (
|
||||
<svg width="16" height="16" viewBox="0 0 256 256" fill="none" aria-hidden="true">
|
||||
<circle cx="128" cy="128" r="96" stroke="currentColor" strokeWidth="16" />
|
||||
<polyline
|
||||
points="120 120 128 120 128 176 136 176"
|
||||
stroke="currentColor"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="124" cy="84" r="12" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CARET_DOWN = (
|
||||
<svg width="14" height="14" viewBox="0 0 256 256" fill="none" aria-hidden="true">
|
||||
<polyline
|
||||
points="208 96 128 176 48 96"
|
||||
stroke="currentColor"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CLOSE = (
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="none" aria-hidden="true">
|
||||
<line
|
||||
x1="200"
|
||||
y1="56"
|
||||
x2="56"
|
||||
y2="200"
|
||||
stroke="currentColor"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="200"
|
||||
y1="200"
|
||||
x2="56"
|
||||
y2="56"
|
||||
stroke="currentColor"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type CollapsibleBannerProps = {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
|
||||
dismissible?: boolean;
|
||||
state?: BannerState;
|
||||
defaultState?: BannerState;
|
||||
onStateChange?: (state: BannerState) => void;
|
||||
onDismiss?: () => void;
|
||||
onDismissed?: () => void;
|
||||
dismissLabel?: string;
|
||||
dismissedMessage?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CollapsibleBanner({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
action,
|
||||
icon,
|
||||
dismissible = true,
|
||||
state: controlled,
|
||||
defaultState = 'open',
|
||||
onStateChange,
|
||||
onDismiss,
|
||||
onDismissed,
|
||||
dismissLabel = 'Dismiss notice',
|
||||
dismissedMessage = 'Notice dismissed.',
|
||||
className = '',
|
||||
}: CollapsibleBannerProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const uid = useId();
|
||||
const bodyId = `${uid}-body`;
|
||||
const titleId = `${uid}-title`;
|
||||
|
||||
const { state, open, dismissed, toggle, fold, dismiss } = useCollapsibleBanner({
|
||||
state: controlled,
|
||||
defaultState,
|
||||
onStateChange,
|
||||
onDismiss,
|
||||
});
|
||||
const wasVisible = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!dismissed) {
|
||||
wasVisible.current = true;
|
||||
}
|
||||
}, [dismissed]);
|
||||
|
||||
const hasBody = Boolean(description || children || action);
|
||||
|
||||
const disclose = reduced
|
||||
? INSTANT
|
||||
: {
|
||||
height: DISCLOSE,
|
||||
opacity: { duration: 0.14, ease: EASE, delay: open ? 0.05 : 0 },
|
||||
y: DISCLOSE,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: dismissed ? 0 : 'auto', opacity: dismissed ? 0 : 1 }}
|
||||
transition={
|
||||
reduced
|
||||
? INSTANT
|
||||
: { height: DISCLOSE, opacity: { duration: 0.14, ease: EASE } }
|
||||
}
|
||||
style={{ overflow: 'hidden' }}
|
||||
onAnimationComplete={() => {
|
||||
if (dismissed && wasVisible.current) {
|
||||
onDismissed?.();
|
||||
}
|
||||
}}
|
||||
className="rounded-[11px]"
|
||||
>
|
||||
<div
|
||||
role="region"
|
||||
aria-labelledby={titleId}
|
||||
className={`rounded-[11px] border border-border/80 bg-popover/95 text-popover-foreground shadow-[0_1px_2px_rgb(0_0_0/0.06),0_16px_36px_-18px_rgb(0_0_0/0.5)] backdrop-blur-xl ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-[26px] shrink-0 place-items-center rounded-[7px] bg-muted/70 text-muted-foreground shadow-[inset_0_1px_2px_rgb(0_0_0/0.08)]"
|
||||
>
|
||||
{icon ?? NOTICE_GLYPH}
|
||||
</span>
|
||||
|
||||
{hasBody ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape' || !open) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
fold();
|
||||
}}
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2 rounded-[7px] text-left outline-none focus-visible:bg-primary/10 focus-visible:ring-1 focus-visible:ring-primary"
|
||||
>
|
||||
<span
|
||||
id={titleId}
|
||||
className="min-w-0 flex-1 truncate text-[13px] font-medium leading-5 text-foreground"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="flex shrink-0 text-muted-foreground group-hover:text-foreground"
|
||||
initial={false}
|
||||
animate={{ rotate: open ? 180 : 0 }}
|
||||
transition={reduced ? INSTANT : NUDGE}
|
||||
>
|
||||
{CARET_DOWN}
|
||||
</motion.span>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
id={titleId}
|
||||
className="min-w-0 flex-1 truncate text-[13px] font-medium leading-5 text-foreground"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{dismissible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label={dismissLabel}
|
||||
className="grid size-[26px] shrink-0 place-items-center rounded-[7px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:bg-primary/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
||||
>
|
||||
{CLOSE}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasBody ? (
|
||||
<motion.div
|
||||
id={bodyId}
|
||||
inert={!open}
|
||||
initial={false}
|
||||
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
|
||||
transition={disclose}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ y: open ? 0 : -6 }}
|
||||
transition={reduced ? INSTANT : DISCLOSE}
|
||||
className="pb-2.5 pl-[46px] pr-2.5"
|
||||
>
|
||||
{description ? (
|
||||
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
<span role="status" aria-live="polite" className="sr-only">
|
||||
{state === 'dismissed' ? dismissedMessage : ''}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const EASE = [0.23, 1, 0.32, 1] as const;
|
||||
const EXIT = [0.4, 0, 1, 1] as const;
|
||||
|
||||
export type ContextMenuPlacement = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
transformOrigin: string;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export type ContextMenuSurfaceProps = {
|
||||
openKey: number | string | null;
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
placement: ContextMenuPlacement;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interior's context-menu surface, kept separate from action discovery so the
|
||||
* WebUI can build native browser-style actions from the element under pointer.
|
||||
*/
|
||||
export function ContextMenuSurface({
|
||||
openKey,
|
||||
menuRef,
|
||||
placement,
|
||||
label,
|
||||
children,
|
||||
}: ContextMenuSurfaceProps) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence initial={false}>
|
||||
{openKey !== null ? (
|
||||
<motion.div
|
||||
key={openKey}
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={
|
||||
reduced
|
||||
? { opacity: 0, transition: { duration: 0 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
transition: { duration: 0.14, ease: EXIT },
|
||||
}
|
||||
}
|
||||
transition={reduced ? { duration: 0 } : { duration: 0.2, ease: EASE }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: placement.left,
|
||||
top: placement.top,
|
||||
width: placement.width,
|
||||
maxHeight: placement.maxHeight,
|
||||
transformOrigin: placement.transformOrigin,
|
||||
visibility: placement.visible === false ? 'hidden' : 'visible',
|
||||
zIndex: 2147483646,
|
||||
}}
|
||||
className="global-context-menu"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const EASE = [0.23, 1, 0.32, 1] as const;
|
||||
const EXIT = [0.4, 0, 1, 1] as const;
|
||||
const CELL = { type: 'spring', stiffness: 520, damping: 34, mass: 0.45 } as const;
|
||||
const NUDGE = { type: 'spring', stiffness: 700, damping: 46, mass: 0.5 } as const;
|
||||
const NONE = { duration: 0 } as const;
|
||||
const SLIDE = { type: 'spring', stiffness: 700, damping: 46, mass: 0.5 } as const;
|
||||
const ROW_H = 32;
|
||||
const OPEN = { type: 'spring', stiffness: 620, damping: 38, mass: 0.6 } as const;
|
||||
|
||||
export type DropdownItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type UseDropdownOptions = {
|
||||
items: DropdownItem[];
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
typeaheadDelay?: number;
|
||||
};
|
||||
|
||||
export function useDropdown({
|
||||
items,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
disabled = false,
|
||||
typeaheadDelay = 600,
|
||||
}: UseDropdownOptions) {
|
||||
const uid = useId();
|
||||
const listId = `${uid}-list`;
|
||||
const itemId = useCallback((i: number) => `${uid}-opt-${i}`, [uid]);
|
||||
|
||||
const [uncontrolled, setUncontrolled] = useState<string | null>(defaultValue ?? null);
|
||||
const selectedValue = value !== undefined ? value : uncontrolled;
|
||||
const selectedIndex = items.findIndex((it) => it.value === selectedValue);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
|
||||
const viaKey = useRef(false);
|
||||
const buffer = useRef('');
|
||||
const bufferTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const emit = useRef(onChange);
|
||||
|
||||
useEffect(() => {
|
||||
emit.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
const step = useCallback(
|
||||
(from: number, dir: 1 | -1) => {
|
||||
const n = items.length;
|
||||
|
||||
if (n === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let i = from;
|
||||
|
||||
for (let k = 0; k < n; k += 1) {
|
||||
i = (i + dir + n) % n;
|
||||
|
||||
if (!items[i].disabled) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return from;
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
const edge = useCallback(
|
||||
(dir: 1 | -1) => step(dir === 1 ? -1 : items.length, dir),
|
||||
[step, items.length],
|
||||
);
|
||||
|
||||
const openMenu = useCallback(
|
||||
(index?: number) => {
|
||||
if (disabled || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usable = selectedIndex >= 0 && !items[selectedIndex].disabled;
|
||||
viaKey.current = true;
|
||||
setActiveIndex(index ?? (usable ? selectedIndex : edge(1)));
|
||||
setOpen(true);
|
||||
},
|
||||
[disabled, items, selectedIndex, edge],
|
||||
);
|
||||
|
||||
const close = useCallback((restoreFocus = true) => {
|
||||
buffer.current = '';
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
|
||||
if (restoreFocus) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const select = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
|
||||
if (!item || item.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
setUncontrolled(item.value);
|
||||
}
|
||||
|
||||
emit.current?.(item.value);
|
||||
close();
|
||||
},
|
||||
[items, value, close],
|
||||
);
|
||||
|
||||
const typeahead = useCallback(
|
||||
(char: string) => {
|
||||
if (bufferTimer.current) {
|
||||
clearTimeout(bufferTimer.current);
|
||||
}
|
||||
|
||||
buffer.current += char.toLowerCase();
|
||||
bufferTimer.current = setTimeout(() => {
|
||||
buffer.current = '';
|
||||
}, typeaheadDelay);
|
||||
|
||||
const q = buffer.current;
|
||||
const n = items.length;
|
||||
const from = activeIndex < 0 ? 0 : activeIndex;
|
||||
const start = q.length > 1 ? from : from + 1;
|
||||
|
||||
for (let k = 0; k < n; k += 1) {
|
||||
const i = (start + k) % n;
|
||||
const it = items[i];
|
||||
|
||||
if (!it.disabled && it.label.toLowerCase().startsWith(q)) {
|
||||
viaKey.current = true;
|
||||
setActiveIndex(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[items, activeIndex, typeaheadDelay],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
listRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
close(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeIndex < 0 || !viaKey.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
viaKey.current = false;
|
||||
itemRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}, [open, activeIndex]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (bufferTimer.current) {
|
||||
clearTimeout(bufferTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
ref: triggerRef,
|
||||
type: 'button' as const,
|
||||
disabled,
|
||||
'aria-haspopup': 'listbox' as const,
|
||||
'aria-expanded': open,
|
||||
'aria-controls': open ? listId : undefined,
|
||||
onClick: () => (open ? close() : openMenu()),
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openMenu();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
openMenu(edge(-1));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const listProps = {
|
||||
ref: listRef,
|
||||
id: listId,
|
||||
role: 'listbox' as const,
|
||||
tabIndex: -1,
|
||||
'aria-activedescendant': activeIndex >= 0 ? itemId(activeIndex) : undefined,
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLUListElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const dir = event.key === 'ArrowDown' ? 1 : -1;
|
||||
viaKey.current = true;
|
||||
setActiveIndex((i) => step(i, dir));
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault();
|
||||
viaKey.current = true;
|
||||
setActiveIndex(edge(event.key === 'Home' ? 1 : -1));
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
select(activeIndex);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
} else if (event.key === 'Tab') {
|
||||
close(false);
|
||||
} else if (
|
||||
event.key.length === 1 &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
typeahead(event.key);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getItemProps = useCallback(
|
||||
(index: number) => ({
|
||||
id: itemId(index),
|
||||
role: 'option' as const,
|
||||
'aria-selected': index === selectedIndex,
|
||||
'aria-disabled': items[index]?.disabled ? (true as const) : undefined,
|
||||
ref: (element: HTMLLIElement | null) => {
|
||||
itemRefs.current[index] = element;
|
||||
},
|
||||
onPointerMove: () => {
|
||||
if (items[index]?.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
viaKey.current = false;
|
||||
setActiveIndex(index);
|
||||
},
|
||||
onClick: () => select(index),
|
||||
}),
|
||||
[itemId, items, selectedIndex, select],
|
||||
);
|
||||
|
||||
return {
|
||||
open,
|
||||
activeIndex,
|
||||
selectedIndex,
|
||||
selectedItem: selectedIndex >= 0 ? items[selectedIndex] : null,
|
||||
rootRef,
|
||||
triggerProps,
|
||||
listProps,
|
||||
getItemProps,
|
||||
};
|
||||
}
|
||||
|
||||
export type DropdownProps = {
|
||||
items: DropdownItem[];
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Dropdown({
|
||||
items,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
label = 'Options',
|
||||
placeholder = 'Select an option',
|
||||
disabled = false,
|
||||
emptyLabel = 'Nothing to choose',
|
||||
className = '',
|
||||
}: DropdownProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const {
|
||||
open,
|
||||
activeIndex,
|
||||
selectedIndex,
|
||||
selectedItem,
|
||||
rootRef,
|
||||
triggerProps,
|
||||
listProps,
|
||||
getItemProps,
|
||||
} = useDropdown({ items, value, defaultValue, onChange, disabled });
|
||||
|
||||
const cell = reduced ? NONE : CELL;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative inline-block text-left ${className}`}>
|
||||
<button
|
||||
{...triggerProps}
|
||||
className={`flex h-9 w-full select-none items-center gap-2 whitespace-nowrap rounded-[9px] border border-border bg-background px-3 text-[13px] font-medium text-foreground outline-none transition-[box-shadow,border-color] duration-150 disabled:opacity-50 ${
|
||||
open
|
||||
? 'shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]'
|
||||
: 'shadow-xs hover:border-foreground/20 hover:shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{label}: {selectedItem ? selectedItem.label : placeholder}
|
||||
</span>
|
||||
<span aria-hidden className="min-w-0 flex-1 truncate text-left">
|
||||
{selectedItem ? selectedItem.label : placeholder}
|
||||
</span>
|
||||
<motion.svg
|
||||
aria-hidden
|
||||
viewBox="0 0 12 12"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
initial={false}
|
||||
animate={{ rotate: open ? 180 : 0 }}
|
||||
transition={reduced ? NONE : NUDGE}
|
||||
>
|
||||
<path
|
||||
d="M3 4.75 6 7.75 9 4.75"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.97,
|
||||
y: -6,
|
||||
transition: reduced ? NONE : { duration: 0.12, ease: EXIT },
|
||||
}}
|
||||
transition={
|
||||
reduced ? NONE : { ...OPEN, opacity: { duration: 0.12, ease: EASE } }
|
||||
}
|
||||
style={{ transformOrigin: 'top left' }}
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-50 min-w-full whitespace-nowrap rounded-[11px] border border-border bg-popover p-[5px] text-popover-foreground shadow-[0_1px_2px_rgb(0_0_0/0.06),0_16px_36px_-18px_rgb(0_0_0/0.5)]"
|
||||
>
|
||||
<ul
|
||||
{...listProps}
|
||||
aria-label={label}
|
||||
className="relative max-h-[216px] overflow-y-auto overscroll-contain outline-none [scrollbar-gutter:stable]"
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-8 rounded-[7px] bg-accent"
|
||||
initial={false}
|
||||
animate={{
|
||||
y: activeIndex < 0 ? 0 : activeIndex * ROW_H,
|
||||
opacity: activeIndex < 0 ? 0 : 1,
|
||||
}}
|
||||
transition={
|
||||
reduced
|
||||
? NONE
|
||||
: { ...SLIDE, opacity: { duration: 0.1, ease: EASE } }
|
||||
}
|
||||
/>
|
||||
{items.map((item, index) => {
|
||||
const active = index === activeIndex && !item.disabled;
|
||||
const picked = index === selectedIndex;
|
||||
return (
|
||||
<li
|
||||
key={item.value}
|
||||
{...getItemProps(index)}
|
||||
className={`relative flex h-8 cursor-default select-none items-center rounded-[7px] px-2.5 text-[13px] ${
|
||||
item.disabled
|
||||
? 'text-muted-foreground/70'
|
||||
: active
|
||||
? 'text-accent-foreground'
|
||||
: 'text-popover-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="relative flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.hint ? (
|
||||
<span className="ml-auto shrink-0 font-mono text-[10.5px] text-muted-foreground">
|
||||
{item.hint}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: picked ? 1 : 0,
|
||||
scale: picked ? 1 : 0.7,
|
||||
}}
|
||||
transition={cell}
|
||||
className="relative ml-2 flex size-[14px] shrink-0 items-center justify-center"
|
||||
>
|
||||
<svg viewBox="0 0 14 14" className="size-[14px]">
|
||||
<path
|
||||
d="M3 7.4 5.8 10.2 11 4.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<li
|
||||
role="presentation"
|
||||
className="flex h-8 items-center px-2.5 text-[13px] text-muted-foreground"
|
||||
>
|
||||
{emptyLabel}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const INSTANT = { duration: 0 } as const;
|
||||
|
||||
const LIFT = { type: 'spring', stiffness: 760, damping: 46, mass: 0.5 } as const;
|
||||
|
||||
const RAISE = -32;
|
||||
const SLIDE = -12;
|
||||
const SHRINK = 0.92;
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
||||
|
||||
export type UseFloatingLabelOptions = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type UseFloatingLabelReturn = {
|
||||
ref: React.RefObject<HTMLInputElement | null>;
|
||||
raised: boolean;
|
||||
focused: boolean;
|
||||
filled: boolean;
|
||||
length: number;
|
||||
instant: boolean;
|
||||
fieldProps: {
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type Fill = { length: number; instant: boolean };
|
||||
|
||||
export function useFloatingLabel({
|
||||
value,
|
||||
defaultValue,
|
||||
disabled = false,
|
||||
}: UseFloatingLabelOptions = {}): UseFloatingLabelReturn {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const mounted = useRef(false);
|
||||
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [fill, setFill] = useState<Fill>({
|
||||
length: (value ?? defaultValue ?? '').length,
|
||||
instant: true,
|
||||
});
|
||||
|
||||
const settle = useCallback((next: number, instant: boolean) => {
|
||||
setFill((prev) =>
|
||||
prev.length === next && prev.instant === instant ? prev : { length: next, instant },
|
||||
);
|
||||
}, []);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
const next = value !== undefined ? value.length : el ? el.value.length : 0;
|
||||
settle(next, !mounted.current);
|
||||
mounted.current = true;
|
||||
}, [value, settle]);
|
||||
|
||||
useEffect(() => {
|
||||
setFill((prev) => (prev.instant ? { ...prev, instant: false } : prev));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el || value !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const read = () => settle(el.value.length, false);
|
||||
el.addEventListener('input', read);
|
||||
el.addEventListener('change', read);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('input', read);
|
||||
el.removeEventListener('change', read);
|
||||
};
|
||||
}, [value, settle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setFocused(false);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const onFocus = useCallback(() => setFocused(true), []);
|
||||
const onBlur = useCallback(() => setFocused(false), []);
|
||||
const onChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
settle(event.currentTarget.value.length, false),
|
||||
[settle],
|
||||
);
|
||||
|
||||
return {
|
||||
ref,
|
||||
raised: focused || fill.length > 0,
|
||||
focused,
|
||||
filled: fill.length > 0,
|
||||
length: fill.length,
|
||||
instant: fill.instant && !focused,
|
||||
fieldProps: { onFocus, onBlur, onChange },
|
||||
};
|
||||
}
|
||||
|
||||
export type FloatingLabelInputProps = {
|
||||
label: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string, event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
hint?: string;
|
||||
invalid?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'search' | 'tel' | 'url';
|
||||
autoComplete?: string;
|
||||
inputMode?: React.ComponentProps<'input'>['inputMode'];
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
spellCheck?: boolean;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
endAdornment?: React.ReactNode;
|
||||
ariaDescribedBy?: string;
|
||||
reserveHintSpace?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FloatingLabelInput({
|
||||
label,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
hint,
|
||||
invalid = false,
|
||||
id,
|
||||
name,
|
||||
type = 'text',
|
||||
autoComplete,
|
||||
inputMode,
|
||||
maxLength,
|
||||
required = false,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
spellCheck,
|
||||
inputRef,
|
||||
endAdornment,
|
||||
ariaDescribedBy,
|
||||
reserveHintSpace = true,
|
||||
className = '',
|
||||
}: FloatingLabelInputProps) {
|
||||
const auto = useId();
|
||||
const fieldId = id ?? `${auto}-field`;
|
||||
const hintId = `${auto}-hint`;
|
||||
|
||||
const reduced = useReducedMotion();
|
||||
const { ref, raised, focused, length, instant, fieldProps } = useFloatingLabel({
|
||||
value,
|
||||
defaultValue,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const move = reduced || instant ? INSTANT : LIFT;
|
||||
|
||||
useImperativeHandle(inputRef, () => ref.current as HTMLInputElement, [ref]);
|
||||
|
||||
const attach = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const describedBy = [ariaDescribedBy, hint ? hintId : null].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<div className="relative pt-[20px]">
|
||||
<div
|
||||
className={`relative h-11 rounded-[10px] border-2 transition-[background-color,border-color,box-shadow] duration-150 sm:h-10 ${
|
||||
invalid
|
||||
? 'border-destructive bg-background'
|
||||
: focused
|
||||
? 'border-primary bg-background'
|
||||
: 'border-input bg-muted/70 shadow-[inset_0_1px_2px_rgb(0_0_0/0.08)]'
|
||||
} ${disabled ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<input
|
||||
ref={attach}
|
||||
id={fieldId}
|
||||
name={name}
|
||||
data-ui-control=""
|
||||
type={type}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
maxLength={maxLength}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
spellCheck={spellCheck}
|
||||
aria-required={required || undefined}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy || undefined}
|
||||
onFocus={() => {
|
||||
fieldProps.onFocus();
|
||||
onFocus?.();
|
||||
}}
|
||||
onBlur={() => {
|
||||
fieldProps.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(event) => {
|
||||
fieldProps.onChange(event);
|
||||
onChange?.(event.currentTarget.value, event);
|
||||
}}
|
||||
className={`absolute inset-0 h-full w-full appearance-none rounded-[9px] bg-transparent px-3 py-0 text-base leading-5 text-foreground outline-none focus-visible:outline-none disabled:cursor-not-allowed ${
|
||||
endAdornment ? 'pr-16' : ''
|
||||
}`}
|
||||
/>
|
||||
{endAdornment ? (
|
||||
<div className="absolute inset-y-0 right-1.5 flex items-center">
|
||||
{endAdornment}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<motion.label
|
||||
htmlFor={fieldId}
|
||||
initial={false}
|
||||
animate={{
|
||||
y: raised ? RAISE : 0,
|
||||
x: raised ? SLIDE : 0,
|
||||
scale: raised ? SHRINK : 1,
|
||||
}}
|
||||
transition={move}
|
||||
style={{ originX: 0, originY: 0 }}
|
||||
className={`absolute left-3 top-[32px] block cursor-text select-none text-base leading-5 sm:text-[13px] sm:leading-[16px] ${
|
||||
invalid
|
||||
? 'text-destructive'
|
||||
: raised
|
||||
? 'text-foreground/80'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden className="ml-0.5 text-muted-foreground">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</motion.label>
|
||||
</div>
|
||||
|
||||
{reserveHintSpace || hint || maxLength !== undefined ? (
|
||||
<div className="mt-1.5 flex h-[16px] items-start gap-3">
|
||||
<p
|
||||
aria-hidden
|
||||
className={`min-w-0 flex-1 truncate text-[11.5px] leading-[16px] ${
|
||||
invalid ? 'text-destructive' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
|
||||
{maxLength !== undefined ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid shrink-0 justify-items-end font-mono text-[10.5px] leading-[16px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
<span className="invisible col-start-1 row-start-1">
|
||||
{maxLength} / {maxLength}
|
||||
</span>
|
||||
<span className="col-start-1 row-start-1">
|
||||
{length} / {maxLength}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{hint ? (
|
||||
<span id={hintId} className="sr-only">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const CELL = { type: 'spring', stiffness: 520, damping: 34, mass: 0.45 } as const;
|
||||
const CROSSFADE = { type: 'spring', stiffness: 260, damping: 34, mass: 0.8 } as const;
|
||||
const INSTANT = { duration: 0 } as const;
|
||||
|
||||
const NUMBER = /-?\d*\.?\d+/g;
|
||||
const CENTER = '12';
|
||||
|
||||
export type MorphShape = {
|
||||
d: readonly string[];
|
||||
rotate?: number;
|
||||
};
|
||||
|
||||
export type IconMorphMode = 'stroke' | 'fill';
|
||||
|
||||
export type IconMorphPreset = 'menu-close' | 'play-pause' | 'plus-minus' | 'check-close';
|
||||
|
||||
export type IconMorphSlot = {
|
||||
key: number;
|
||||
d: string;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type IconMorphSemantics = 'label' | 'pressed' | 'expanded';
|
||||
|
||||
export const iconMorphPresets: Record<
|
||||
IconMorphPreset,
|
||||
{ mode: IconMorphMode; labels: readonly string[]; shapes: readonly MorphShape[] }
|
||||
> = {
|
||||
'menu-close': {
|
||||
mode: 'stroke',
|
||||
labels: ['Menu', 'Close'],
|
||||
shapes: [
|
||||
{
|
||||
rotate: 0,
|
||||
d: ['M 4 7 L 20 7', 'M 4 12 L 20 12', 'M 4 17 L 20 17'],
|
||||
},
|
||||
{
|
||||
rotate: 90,
|
||||
d: ['M 6.5 6.5 L 17.5 17.5', 'M 12 12 L 12 12', 'M 6.5 17.5 L 17.5 6.5'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'play-pause': {
|
||||
mode: 'fill',
|
||||
labels: ['Play', 'Pause'],
|
||||
shapes: [
|
||||
{
|
||||
d: ['M 8 5 L 14 8.5 L 14 15.5 L 8 19 Z', 'M 14 8.5 L 20 12 L 20 12 L 14 15.5 Z'],
|
||||
},
|
||||
{
|
||||
d: ['M 8 5 L 11.5 5 L 11.5 19 L 8 19 Z', 'M 15 5 L 18.5 5 L 18.5 19 L 15 19 Z'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'plus-minus': {
|
||||
mode: 'stroke',
|
||||
labels: ['Add', 'Remove'],
|
||||
shapes: [
|
||||
{ rotate: 0, d: ['M 5 12 L 19 12', 'M 12 5 L 12 19'] },
|
||||
{ rotate: 180, d: ['M 5 12 L 19 12', 'M 5 12 L 19 12'] },
|
||||
],
|
||||
},
|
||||
'check-close': {
|
||||
mode: 'stroke',
|
||||
labels: ['Confirm', 'Cancel'],
|
||||
shapes: [
|
||||
{ d: ['M 5 12.5 L 10 17.5 L 19.5 7', 'M 12 12 L 12 12 L 12 12'] },
|
||||
{ d: ['M 6.5 6.5 L 12 12 L 17.5 17.5', 'M 17.5 6.5 L 12 12 L 6.5 17.5'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function isCollapsed(d: string): boolean {
|
||||
const nums = d.match(NUMBER);
|
||||
|
||||
if (!nums || nums.length < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nums.every((n, i) => n === nums[i % 2]);
|
||||
}
|
||||
|
||||
function normalize(shapes: readonly MorphShape[]): IconMorphSlot[][] {
|
||||
const slots = shapes.reduce((most, s) => Math.max(most, s.d.length), 0);
|
||||
|
||||
return shapes.map((shape) =>
|
||||
Array.from({ length: slots }, (_, i) => {
|
||||
const own = shape.d[i];
|
||||
const sibling = shapes.find((s) => s.d[i] !== undefined)?.d[i] ?? '';
|
||||
const d = own ?? sibling.replace(NUMBER, CENTER);
|
||||
return { key: i, d, visible: !isCollapsed(d) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function toIndex(value: number | boolean): number {
|
||||
return typeof value === 'boolean' ? (value ? 1 : 0) : Math.trunc(value);
|
||||
}
|
||||
|
||||
export type UseIconMorphOptions = {
|
||||
preset?: IconMorphPreset;
|
||||
shapes?: readonly MorphShape[];
|
||||
mode?: IconMorphMode;
|
||||
labels?: readonly string[];
|
||||
active?: number | boolean;
|
||||
defaultActive?: number | boolean;
|
||||
onActiveChange?: (index: number) => void;
|
||||
};
|
||||
|
||||
export function useIconMorph({
|
||||
preset = 'menu-close',
|
||||
shapes,
|
||||
mode,
|
||||
labels,
|
||||
active,
|
||||
defaultActive = 0,
|
||||
onActiveChange,
|
||||
}: UseIconMorphOptions = {}) {
|
||||
const base = iconMorphPresets[preset];
|
||||
const source = shapes ?? base.shapes;
|
||||
const names = labels ?? base.labels;
|
||||
const count = source.length;
|
||||
|
||||
const [internal, setInternal] = useState(() => toIndex(defaultActive));
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const raw = active === undefined ? internal : toIndex(active);
|
||||
const index = count === 0 ? 0 : Math.min(Math.max(raw, 0), count - 1);
|
||||
|
||||
const frames = useMemo(() => normalize(source), [source]);
|
||||
|
||||
const setIndex = useCallback(
|
||||
(next: number) => {
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapped = ((next % count) + count) % count;
|
||||
|
||||
if (active === undefined) {
|
||||
setInternal(wrapped);
|
||||
}
|
||||
|
||||
onActiveChange?.(wrapped);
|
||||
},
|
||||
[active, count, onActiveChange],
|
||||
);
|
||||
|
||||
const toggle = useCallback(() => setIndex(index + 1), [setIndex, index]);
|
||||
|
||||
return {
|
||||
index,
|
||||
count,
|
||||
slots: frames[index] ?? [],
|
||||
rotate: source[index]?.rotate ?? 0,
|
||||
mode: mode ?? base.mode,
|
||||
label: names[index] ?? '',
|
||||
labels: names,
|
||||
transition: reduced ? INSTANT : CELL,
|
||||
labelTransition: reduced ? INSTANT : CROSSFADE,
|
||||
setIndex,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
|
||||
export type IconMorphProps = UseIconMorphOptions & {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
showLabel?: boolean;
|
||||
semantics?: IconMorphSemantics;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function IconMorph({
|
||||
size = 20,
|
||||
strokeWidth = 1.75,
|
||||
showLabel = false,
|
||||
semantics = 'label',
|
||||
disabled = false,
|
||||
className = '',
|
||||
...options
|
||||
}: IconMorphProps) {
|
||||
const { index, slots, rotate, mode, label, labels, transition, labelTransition, toggle } =
|
||||
useIconMorph(options);
|
||||
|
||||
const stroked = mode === 'stroke';
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-label={label}
|
||||
aria-pressed={semantics === 'pressed' ? index === 1 : undefined}
|
||||
aria-expanded={semantics === 'expanded' ? index === 1 : undefined}
|
||||
whileTap={disabled ? undefined : { y: 1 }}
|
||||
transition={transition}
|
||||
className={`inline-flex h-9 shrink-0 select-none items-center justify-center gap-2 rounded-[9px] border border-stone-200 bg-white text-[13px] font-medium text-stone-700 outline-none focus-visible:ring-2 focus-visible:ring-stone-400 disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:focus-visible:ring-white/30 ${
|
||||
showLabel ? 'px-3' : 'w-9'
|
||||
} ${className}`}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
initial={false}
|
||||
animate={{ rotate }}
|
||||
transition={transition}
|
||||
className="grid shrink-0 place-items-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
focusable="false"
|
||||
fill={stroked ? 'none' : 'currentColor'}
|
||||
stroke={stroked ? 'currentColor' : 'none'}
|
||||
strokeWidth={stroked ? strokeWidth : undefined}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{slots.map((slot) => (
|
||||
<motion.path
|
||||
key={slot.key}
|
||||
initial={false}
|
||||
animate={{ d: slot.d, opacity: slot.visible ? 1 : 0 }}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</motion.span>
|
||||
|
||||
{showLabel && (
|
||||
<span aria-hidden="true" className="grid">
|
||||
{labels.map((text, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: i === index ? 1 : 0,
|
||||
y: i === index ? 0 : i < index ? -3 : 3,
|
||||
}}
|
||||
transition={labelTransition}
|
||||
className="col-start-1 row-start-1 whitespace-nowrap"
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
import { FloatingLabelInput } from '@/components/interior/floating-label';
|
||||
|
||||
const CROSSFADE = { type: 'spring', stiffness: 260, damping: 34, mass: 0.8 } as const;
|
||||
const INSTANT = { duration: 0 } as const;
|
||||
|
||||
const LINE = 16;
|
||||
|
||||
export type ValidationStatus = 'idle' | 'pending' | 'valid' | 'invalid';
|
||||
|
||||
export type Validator = (value: string) => string | null;
|
||||
|
||||
export type UseInlineValidationOptions = {
|
||||
value: string;
|
||||
validate: Validator;
|
||||
debounce?: number;
|
||||
validationKey?: unknown;
|
||||
};
|
||||
|
||||
export type UseInlineValidationReturn = {
|
||||
status: ValidationStatus;
|
||||
error: string | null;
|
||||
message: string;
|
||||
touched: boolean;
|
||||
commit: () => void;
|
||||
reset: () => void;
|
||||
fieldProps: {
|
||||
onBlur: () => void;
|
||||
'aria-invalid': boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type Settled = {
|
||||
status: ValidationStatus;
|
||||
error: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const CLEAN: Settled = { status: 'idle', error: null, message: '' };
|
||||
|
||||
export function useInlineValidation({
|
||||
value,
|
||||
validate,
|
||||
debounce = 400,
|
||||
validationKey,
|
||||
}: UseInlineValidationOptions): UseInlineValidationReturn {
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [settled, setSettled] = useState<Settled>(CLEAN);
|
||||
|
||||
const check = useRef(validate);
|
||||
const latest = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
check.current = validate;
|
||||
latest.current = value;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!touched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = check.current(value);
|
||||
const resolved: ValidationStatus = value.length > 0 ? 'valid' : 'idle';
|
||||
|
||||
if (next === null) {
|
||||
setSettled((prev) =>
|
||||
prev.status === resolved && prev.error === null
|
||||
? prev
|
||||
: { status: resolved, error: null, message: prev.message },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSettled((prev) =>
|
||||
prev.status === 'invalid'
|
||||
? prev
|
||||
: { status: 'pending', error: null, message: prev.message },
|
||||
);
|
||||
|
||||
const t = setTimeout(() => {
|
||||
setSettled((prev) =>
|
||||
prev.error === next ? prev : { status: 'invalid', error: next, message: next },
|
||||
);
|
||||
}, debounce);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [value, touched, debounce, validationKey]);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
setTouched(true);
|
||||
const v = latest.current;
|
||||
const next = check.current(v);
|
||||
setSettled((prev) =>
|
||||
next === null
|
||||
? { status: v.length > 0 ? 'valid' : 'idle', error: null, message: prev.message }
|
||||
: { status: 'invalid', error: next, message: next },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setTouched(false);
|
||||
setSettled(CLEAN);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status: settled.status,
|
||||
error: settled.error,
|
||||
message: settled.message,
|
||||
touched,
|
||||
commit,
|
||||
reset,
|
||||
fieldProps: { onBlur: commit, 'aria-invalid': settled.status === 'invalid' },
|
||||
};
|
||||
}
|
||||
|
||||
export type InlineValidationProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
validate: Validator;
|
||||
hint?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'search';
|
||||
autoComplete?: string;
|
||||
inputMode?: React.ComponentProps<'input'>['inputMode'];
|
||||
debounce?: number;
|
||||
validationKey?: unknown;
|
||||
reserveLines?: number;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
spellCheck?: boolean;
|
||||
endAdornment?: React.ReactNode;
|
||||
showValidIcon?: boolean;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InlineValidation({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
validate,
|
||||
hint,
|
||||
id,
|
||||
name,
|
||||
type = 'text',
|
||||
autoComplete,
|
||||
inputMode,
|
||||
debounce = 400,
|
||||
validationKey,
|
||||
reserveLines = 1,
|
||||
disabled = false,
|
||||
required = false,
|
||||
spellCheck,
|
||||
endAdornment,
|
||||
showValidIcon = true,
|
||||
onFocus,
|
||||
onBlur,
|
||||
className = '',
|
||||
}: InlineValidationProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const fade = reduced ? INSTANT : CROSSFADE;
|
||||
|
||||
const auto = useId();
|
||||
const fieldId = id ?? `${auto}-field`;
|
||||
const hintId = `${auto}-hint`;
|
||||
const errorId = `${auto}-error`;
|
||||
|
||||
const { status, error, message, fieldProps } = useInlineValidation({
|
||||
value,
|
||||
validate,
|
||||
debounce,
|
||||
validationKey,
|
||||
});
|
||||
|
||||
const invalid = status === 'invalid';
|
||||
const valid = status === 'valid';
|
||||
|
||||
const described = [hint ? hintId : null, invalid ? errorId : null].filter(Boolean).join(' ');
|
||||
|
||||
const clamp = {
|
||||
display: '-webkit-box' as const,
|
||||
WebkitBoxOrient: 'vertical' as const,
|
||||
WebkitLineClamp: reserveLines,
|
||||
overflow: 'hidden' as const,
|
||||
};
|
||||
|
||||
const statusGlyph = (
|
||||
<span className="pointer-events-none grid size-3.5 shrink-0 place-items-center" aria-hidden>
|
||||
<motion.svg
|
||||
viewBox="0 0 12 12"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
className="col-start-1 row-start-1 text-success"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: showValidIcon && valid ? 1 : 0,
|
||||
scale: showValidIcon && valid ? 1 : 0.7,
|
||||
}}
|
||||
transition={fade}
|
||||
>
|
||||
<path
|
||||
d="M2 6.3 4.7 9 10 3.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
<motion.svg
|
||||
viewBox="0 0 12 12"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
className="col-start-1 row-start-1 text-destructive"
|
||||
initial={false}
|
||||
animate={{ opacity: invalid ? 1 : 0, scale: invalid ? 1 : 0.7 }}
|
||||
transition={fade}
|
||||
>
|
||||
<path d="M6 2v4.4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
|
||||
<rect x="5.15" y="8.4" width="1.7" height="1.7" rx="0.5" fill="currentColor" />
|
||||
</motion.svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<FloatingLabelInput
|
||||
id={fieldId}
|
||||
label={label}
|
||||
name={name}
|
||||
type={type}
|
||||
value={value}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
spellCheck={spellCheck}
|
||||
invalid={invalid}
|
||||
ariaDescribedBy={described || undefined}
|
||||
reserveHintSpace={false}
|
||||
onFocus={onFocus}
|
||||
onBlur={() => {
|
||||
fieldProps.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={onChange}
|
||||
endAdornment={
|
||||
<div className="flex items-center gap-1">
|
||||
{statusGlyph}
|
||||
{endAdornment}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="relative mt-1.5 grid" style={{ height: reserveLines * LINE }}>
|
||||
{hint ? (
|
||||
<motion.p
|
||||
aria-hidden
|
||||
style={clamp}
|
||||
className="col-start-1 row-start-1 text-[11.5px] leading-[16px] text-muted-foreground"
|
||||
initial={false}
|
||||
animate={{ opacity: invalid ? 0 : 1, y: invalid ? 3 : 0 }}
|
||||
transition={fade}
|
||||
>
|
||||
{hint}
|
||||
</motion.p>
|
||||
) : null}
|
||||
|
||||
<motion.p
|
||||
aria-hidden
|
||||
style={clamp}
|
||||
className="col-start-1 row-start-1 text-[11.5px] leading-[16px] text-destructive"
|
||||
initial={false}
|
||||
animate={{ opacity: invalid ? 1 : 0, y: invalid ? 0 : -3 }}
|
||||
transition={fade}
|
||||
>
|
||||
{error ?? message}
|
||||
</motion.p>
|
||||
|
||||
{hint ? (
|
||||
<span id={hintId} className="sr-only">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span
|
||||
id={errorId}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{error ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const SURFACE = { type: 'spring', stiffness: 420, damping: 36, mass: 0.9 } as const;
|
||||
const CROSSFADE = { type: 'spring', stiffness: 260, damping: 34, mass: 0.8 } as const;
|
||||
const SMALL = { type: 'spring', stiffness: 700, damping: 46, mass: 0.5 } as const;
|
||||
const FILL = { type: 'spring', stiffness: 210, damping: 34, mass: 0.9 } as const;
|
||||
const EASE = [0.23, 1, 0.32, 1] as const;
|
||||
const LEAVE = [0.4, 0, 1, 1] as const;
|
||||
const DRAW = { duration: 0.3, ease: EASE } as const;
|
||||
const INSTANT = { duration: 0 } as const;
|
||||
const SPIN = { duration: 0.85, ease: 'linear', repeat: Infinity } as const;
|
||||
|
||||
const PEEK_FOR = 2600;
|
||||
const LEAVE_DELAY = 160;
|
||||
|
||||
const face = (on: boolean) => (on ? '' : 'pointer-events-none');
|
||||
|
||||
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
||||
|
||||
export type ActivityPhase = 'running' | 'success' | 'error';
|
||||
|
||||
export type Activity = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
progress?: number | null;
|
||||
phase: ActivityPhase;
|
||||
action?: { label: string; onClick: () => void };
|
||||
};
|
||||
|
||||
export type ActivityInput = {
|
||||
title: string;
|
||||
detail?: string;
|
||||
progress?: number | null;
|
||||
};
|
||||
|
||||
export type UseLiveActivityOptions = {
|
||||
linger?: number;
|
||||
};
|
||||
|
||||
export function useLiveActivity({ linger = 2000 }: UseLiveActivityOptions = {}) {
|
||||
const [activity, setActivity] = useState<Activity | null>(null);
|
||||
const seq = useRef(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
|
||||
timer.current = null;
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
(input: ActivityInput) => {
|
||||
clear();
|
||||
seq.current += 1;
|
||||
const id = `activity-${seq.current}`;
|
||||
setActivity({ progress: null, ...input, id, phase: 'running' });
|
||||
return id;
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
const update = useCallback((patch: Partial<ActivityInput>) => {
|
||||
setActivity((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
clear();
|
||||
setActivity(null);
|
||||
}, [clear]);
|
||||
|
||||
const succeed = useCallback(
|
||||
(patch?: Partial<ActivityInput>) => {
|
||||
setActivity((prev) =>
|
||||
prev ? { ...prev, ...patch, phase: 'success', progress: 1 } : prev,
|
||||
);
|
||||
clear();
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
setActivity(null);
|
||||
}, linger);
|
||||
},
|
||||
[clear, linger],
|
||||
);
|
||||
|
||||
const fail = useCallback(
|
||||
(patch?: Partial<ActivityInput>, action?: Activity['action']) => {
|
||||
clear();
|
||||
setActivity((prev) => (prev ? { ...prev, ...patch, phase: 'error', action } : prev));
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
useEffect(() => clear, [clear]);
|
||||
|
||||
return { activity, start, update, succeed, fail, dismiss };
|
||||
}
|
||||
|
||||
export type UseLiveActivityReturn = ReturnType<typeof useLiveActivity>;
|
||||
|
||||
export type LiveActivityProps = {
|
||||
activity: Activity | null;
|
||||
onDismiss?: () => void;
|
||||
width?: number;
|
||||
dismissLabel?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LiveActivity({
|
||||
activity,
|
||||
onDismiss,
|
||||
width = 300,
|
||||
dismissLabel = 'Dismiss activity',
|
||||
label = 'Activity',
|
||||
className = '',
|
||||
}: LiveActivityProps) {
|
||||
const reduced = useReducedMotion() === true;
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [peeking, setPeeking] = useState(false);
|
||||
const [spoken, setSpoken] = useState('');
|
||||
|
||||
const compactRef = useRef<HTMLDivElement>(null);
|
||||
const expandedRef = useRef<HTMLDivElement>(null);
|
||||
const sizes = useRef({ c: { w: 0, h: 0 }, e: { w: 0, h: 0 } });
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const peekTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const said = useRef<Set<string>>(new Set());
|
||||
|
||||
const phase = activity?.phase ?? 'running';
|
||||
const expanded = activity !== null && (hovered || focused || peeking || phase === 'error');
|
||||
|
||||
const apply = useCallback((open: boolean) => {
|
||||
const target = open ? sizes.current.e : sizes.current.c;
|
||||
|
||||
if (target.w === 0 || target.h === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDims((prev) =>
|
||||
prev && Math.abs(prev.w - target.w) < 0.5 && Math.abs(prev.h - target.h) < 0.5
|
||||
? prev
|
||||
: { w: target.w, h: target.h },
|
||||
);
|
||||
}, []);
|
||||
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!activity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const read = () => {
|
||||
const c = compactRef.current;
|
||||
const e = expandedRef.current;
|
||||
|
||||
if (c) {
|
||||
sizes.current.c = { w: c.offsetWidth, h: c.offsetHeight };
|
||||
}
|
||||
|
||||
if (e) {
|
||||
sizes.current.e = { w: e.offsetWidth, h: e.offsetHeight };
|
||||
}
|
||||
|
||||
apply(expanded);
|
||||
};
|
||||
|
||||
read();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(read);
|
||||
|
||||
if (compactRef.current) {
|
||||
observer.observe(compactRef.current);
|
||||
}
|
||||
|
||||
if (expandedRef.current) {
|
||||
observer.observe(expandedRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [activity, expanded, apply]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activity) {
|
||||
setPeeking(false);
|
||||
setHovered(false);
|
||||
setFocused(false);
|
||||
setDims(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPeeking(true);
|
||||
|
||||
if (peekTimer.current) {
|
||||
clearTimeout(peekTimer.current);
|
||||
}
|
||||
|
||||
peekTimer.current = setTimeout(() => {
|
||||
peekTimer.current = null;
|
||||
setPeeking(false);
|
||||
}, PEEK_FOR);
|
||||
}, [activity?.id, activity?.phase, activity]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (leaveTimer.current) {
|
||||
clearTimeout(leaveTimer.current);
|
||||
}
|
||||
|
||||
if (peekTimer.current) {
|
||||
clearTimeout(peekTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${activity.id}:${activity.phase}`;
|
||||
|
||||
if (said.current.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (said.current.size > 64) {
|
||||
said.current.clear();
|
||||
}
|
||||
|
||||
said.current.add(key);
|
||||
setSpoken(
|
||||
activity.phase === 'running'
|
||||
? `${activity.title} started.`
|
||||
: activity.phase === 'success'
|
||||
? `${activity.title} finished.`
|
||||
: `${activity.title} failed.`,
|
||||
);
|
||||
}, [activity]);
|
||||
|
||||
const enter = () => {
|
||||
if (leaveTimer.current) {
|
||||
clearTimeout(leaveTimer.current);
|
||||
}
|
||||
|
||||
leaveTimer.current = null;
|
||||
setHovered(true);
|
||||
};
|
||||
|
||||
const leave = () => {
|
||||
if (leaveTimer.current) {
|
||||
clearTimeout(leaveTimer.current);
|
||||
}
|
||||
|
||||
leaveTimer.current = setTimeout(() => {
|
||||
leaveTimer.current = null;
|
||||
setHovered(false);
|
||||
}, LEAVE_DELAY);
|
||||
};
|
||||
|
||||
const percent =
|
||||
activity?.progress === null || activity?.progress === undefined
|
||||
? null
|
||||
: Math.round(Math.min(1, Math.max(0, activity.progress)) * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={label}
|
||||
className={`pointer-events-none flex justify-end ${className}`}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{activity ? (
|
||||
<motion.div
|
||||
key="pod"
|
||||
initial={
|
||||
reduced
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: -10, scale: 0.9, filter: 'blur(6px)' }
|
||||
}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
filter: 'blur(0px)',
|
||||
width: dims?.w,
|
||||
height: dims?.h,
|
||||
}}
|
||||
exit={
|
||||
reduced
|
||||
? { opacity: 0, transition: INSTANT }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: -8,
|
||||
scale: 0.97,
|
||||
filter: 'blur(3px)',
|
||||
transition: { duration: 0.16, ease: LEAVE },
|
||||
}
|
||||
}
|
||||
transition={
|
||||
reduced
|
||||
? INSTANT
|
||||
: {
|
||||
...SURFACE,
|
||||
opacity: { duration: 0.2, ease: EASE },
|
||||
filter: { duration: 0.2, ease: EASE },
|
||||
}
|
||||
}
|
||||
style={{ transformOrigin: '50% 0%' }}
|
||||
onPointerEnter={enter}
|
||||
onPointerLeave={leave}
|
||||
onFocusCapture={() => setFocused(true)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||
setFocused(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (phase === 'running') {
|
||||
setHovered(false);
|
||||
} else {
|
||||
onDismiss?.();
|
||||
}
|
||||
}}
|
||||
className="pointer-events-auto relative overflow-hidden rounded-[11px] border border-border/80 bg-popover/95 text-popover-foreground shadow-[0_1px_2px_rgb(0_0_0/0.07),0_16px_36px_-18px_rgb(0_0_0/0.5)] backdrop-blur-xl"
|
||||
>
|
||||
<motion.div
|
||||
ref={compactRef}
|
||||
aria-hidden={expanded}
|
||||
inert={expanded}
|
||||
initial={false}
|
||||
animate={{ opacity: expanded ? 0 : 1 }}
|
||||
transition={reduced ? INSTANT : CROSSFADE}
|
||||
className={`absolute left-0 top-0 flex h-8 w-max items-center gap-1.5 px-2.5 ${face(!expanded)}`}
|
||||
>
|
||||
<PhaseGlyph phase={phase} reduced={reduced} />
|
||||
{percent !== null && phase === 'running' ? (
|
||||
<span className="font-mono text-[10.5px] tabular-nums text-muted-foreground">
|
||||
{percent}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="max-w-[120px] truncate text-[12px] font-medium text-foreground">
|
||||
{activity.title}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
ref={expandedRef}
|
||||
aria-hidden={!expanded}
|
||||
inert={!expanded}
|
||||
initial={false}
|
||||
animate={{ opacity: expanded ? 1 : 0 }}
|
||||
transition={reduced ? INSTANT : CROSSFADE}
|
||||
style={{ width, maxWidth: 'calc(100vw - 2rem)' }}
|
||||
className={`absolute left-0 top-0 px-3.5 py-3 ${face(expanded)}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseGlyph phase={phase} reduced={reduced} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||
{activity.title}
|
||||
</span>
|
||||
{activity.action ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={expanded ? 0 : -1}
|
||||
onClick={activity.action.onClick}
|
||||
className="inline-flex h-[24px] shrink-0 select-none items-center whitespace-nowrap rounded-[6px] border border-border bg-background px-2 text-[11px] font-medium text-foreground shadow-sm outline-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted focus-visible:border-primary active:translate-y-px"
|
||||
>
|
||||
{activity.action.label}
|
||||
</button>
|
||||
) : null}
|
||||
{onDismiss && phase !== 'running' ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={expanded ? 0 : -1}
|
||||
aria-label={dismissLabel}
|
||||
onClick={onDismiss}
|
||||
className="grid size-[22px] shrink-0 place-items-center rounded-[6px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:bg-primary/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M2.8 2.8l6.4 6.4M9.2 2.8l-6.4 6.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activity.detail ? (
|
||||
<p className="mt-1 truncate pl-[26px] text-[11.5px] text-muted-foreground">
|
||||
{activity.detail}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{percent !== null && phase !== 'error' ? (
|
||||
<div className="mt-2.5 flex items-center gap-2 pl-[26px]">
|
||||
<div className="min-w-0 flex-1 rounded-[4px] bg-muted p-[2px] shadow-[inset_0_1px_2px_rgb(0_0_0/0.12)]">
|
||||
<div className="relative h-[4px] overflow-hidden rounded-[2px]">
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="absolute inset-0 block origin-left rounded-[2px] bg-primary shadow-[inset_0_1px_0_rgb(255_255_255/0.28)]"
|
||||
initial={false}
|
||||
animate={{ scaleX: (percent ?? 0) / 100 }}
|
||||
transition={reduced ? INSTANT : FILL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-[10.5px] tabular-nums text-muted-foreground">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<span role="status" aria-live="polite" className="sr-only">
|
||||
{spoken}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseGlyph({ phase, reduced }: { phase: ActivityPhase; reduced: boolean }) {
|
||||
return (
|
||||
<span className="grid size-[18px] shrink-0 place-items-center">
|
||||
<motion.span
|
||||
className="col-start-1 row-start-1 flex"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: phase === 'running' ? 1 : 0,
|
||||
scale: reduced ? 1 : phase === 'running' ? 1 : 0.7,
|
||||
}}
|
||||
transition={reduced ? INSTANT : SMALL}
|
||||
>
|
||||
{reduced ? (
|
||||
<svg width="13" height="13" viewBox="0 0 12 12" aria-hidden>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="6"
|
||||
r="4.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
fill="none"
|
||||
className="text-muted-foreground"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<motion.svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 12 12"
|
||||
aria-hidden
|
||||
style={{ transformOrigin: '50% 50%' }}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={SPIN}
|
||||
className="text-primary"
|
||||
>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="6"
|
||||
r="4.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M6 1.6a4.4 4.4 0 0 1 4.4 4.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</motion.svg>
|
||||
)}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
className="col-start-1 row-start-1 flex text-success"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: phase === 'success' ? 1 : 0,
|
||||
scale: reduced ? 1 : phase === 'success' ? 1 : 0.7,
|
||||
}}
|
||||
transition={reduced ? INSTANT : SMALL}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<motion.path
|
||||
d="M2.4 6.4 4.8 8.8 9.6 3.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={false}
|
||||
animate={{ pathLength: phase === 'success' ? 1 : 0 }}
|
||||
transition={reduced ? INSTANT : DRAW}
|
||||
/>
|
||||
</svg>
|
||||
</motion.span>
|
||||
<motion.span
|
||||
className="col-start-1 row-start-1 flex text-destructive"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: phase === 'error' ? 1 : 0,
|
||||
scale: reduced ? 1 : phase === 'error' ? 1 : 0.7,
|
||||
}}
|
||||
transition={reduced ? INSTANT : SMALL}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6 2.6v3.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect x="5.2" y="8.2" width="1.6" height="1.6" rx="0.4" fill="currentColor" />
|
||||
</svg>
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const EASE = [0.23, 1, 0.32, 1] as const;
|
||||
|
||||
const LEAVE = [0.4, 0, 1, 1] as const;
|
||||
|
||||
const SURFACE = { type: 'spring', stiffness: 420, damping: 36, mass: 0.9 } as const;
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
||||
|
||||
const FOCUSABLE = [
|
||||
'a[href]',
|
||||
'area[href]',
|
||||
'button:not([disabled])',
|
||||
"input:not([disabled]):not([type='hidden'])",
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'iframe',
|
||||
'summary',
|
||||
"[contenteditable='true']",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(',');
|
||||
|
||||
function focusableWithin(root: HTMLElement): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) =>
|
||||
el.tabIndex !== -1 &&
|
||||
!el.hasAttribute('inert') &&
|
||||
el.getAttribute('aria-hidden') !== 'true' &&
|
||||
el.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
let locks = 0;
|
||||
let releaseLock: (() => void) | null = null;
|
||||
|
||||
function lockDocumentScroll() {
|
||||
locks += 1;
|
||||
|
||||
if (locks > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const gap = window.innerWidth - html.clientWidth;
|
||||
const scrollY = window.scrollY;
|
||||
const htmlOverflow = html.style.overflow;
|
||||
const htmlOverscroll = html.style.overscrollBehavior;
|
||||
const overflow = body.style.overflow;
|
||||
const overscroll = body.style.overscrollBehavior;
|
||||
const paddingRight = body.style.paddingRight;
|
||||
const position = body.style.position;
|
||||
const top = body.style.top;
|
||||
const insetInline = body.style.insetInline;
|
||||
const base = Number.parseFloat(window.getComputedStyle(body).paddingRight);
|
||||
|
||||
html.style.overflow = 'hidden';
|
||||
html.style.overscrollBehavior = 'none';
|
||||
body.style.overflow = 'hidden';
|
||||
body.style.overscrollBehavior = 'none';
|
||||
body.style.position = 'fixed';
|
||||
body.style.insetInline = '0';
|
||||
body.style.top = `-${scrollY}px`;
|
||||
|
||||
if (gap > 0) {
|
||||
body.style.paddingRight = `${(Number.isFinite(base) ? base : 0) + gap}px`;
|
||||
}
|
||||
|
||||
releaseLock = () => {
|
||||
html.style.overflow = htmlOverflow;
|
||||
html.style.overscrollBehavior = htmlOverscroll;
|
||||
body.style.overflow = overflow;
|
||||
body.style.overscrollBehavior = overscroll;
|
||||
body.style.paddingRight = paddingRight;
|
||||
body.style.position = position;
|
||||
body.style.top = top;
|
||||
body.style.insetInline = insetInline;
|
||||
window.scrollTo(0, scrollY);
|
||||
};
|
||||
}
|
||||
|
||||
function unlockDocumentScroll() {
|
||||
locks = Math.max(0, locks - 1);
|
||||
|
||||
if (locks > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
releaseLock?.();
|
||||
releaseLock = null;
|
||||
}
|
||||
|
||||
const stack: object[] = [];
|
||||
|
||||
export type UseModalOptions = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
closeOnEscape?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
lockScroll?: boolean;
|
||||
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
container?: HTMLElement | null;
|
||||
};
|
||||
|
||||
export type ModalOverlayProps = {
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
export type ModalPanelProps = {
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
role: 'dialog';
|
||||
'aria-modal': true;
|
||||
'aria-labelledby': string;
|
||||
tabIndex: -1;
|
||||
onKeyDown: (event: React.KeyboardEvent) => void;
|
||||
};
|
||||
|
||||
export type UseModalResult = {
|
||||
target: HTMLElement | null;
|
||||
titleId: string;
|
||||
descriptionId: string;
|
||||
overlayProps: ModalOverlayProps;
|
||||
panelProps: ModalPanelProps;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export function useModal({
|
||||
open,
|
||||
onClose,
|
||||
closeOnEscape = true,
|
||||
closeOnBackdrop = true,
|
||||
lockScroll = true,
|
||||
initialFocusRef,
|
||||
container,
|
||||
}: UseModalOptions): UseModalResult {
|
||||
const [target, setTarget] = useState<HTMLElement | null>(null);
|
||||
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const downedOutside = useRef(false);
|
||||
|
||||
const baseId = useId();
|
||||
const titleId = `${baseId}-title`;
|
||||
const descriptionId = `${baseId}-description`;
|
||||
|
||||
const latest = useRef({ onClose, closeOnEscape, closeOnBackdrop, initialFocusRef });
|
||||
useEffect(() => {
|
||||
latest.current = { onClose, closeOnEscape, closeOnBackdrop, initialFocusRef };
|
||||
}, [onClose, closeOnEscape, closeOnBackdrop, initialFocusRef]);
|
||||
|
||||
const close = useCallback(() => latest.current.onClose(), []);
|
||||
|
||||
useEffect(() => {
|
||||
setTarget(container === undefined ? document.body : container);
|
||||
}, [container]);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (!open || !lockScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
lockDocumentScroll();
|
||||
return () => unlockDocumentScroll();
|
||||
}, [open, lockScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = overlayRef.current;
|
||||
const parent = overlay?.parentElement;
|
||||
|
||||
if (!overlay || !parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed: Array<[Element, string | null]> = [];
|
||||
|
||||
for (const child of Array.from(parent.children)) {
|
||||
if (child === overlay) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changed.push([child, child.getAttribute('inert')]);
|
||||
child.setAttribute('inert', '');
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [child, previous] of changed) {
|
||||
if (previous === null) {
|
||||
child.removeAttribute('inert');
|
||||
} else {
|
||||
child.setAttribute('inert', previous);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [open, target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = {};
|
||||
stack.push(token);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stack[stack.length - 1] !== token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!latest.current.closeOnEscape) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
latest.current.onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
const index = stack.indexOf(token);
|
||||
|
||||
if (index > -1) {
|
||||
stack.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
const panel = panelRef.current;
|
||||
const node = event.target as Node | null;
|
||||
|
||||
if (!panel || !node || panel.contains(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
document.addEventListener('focusin', onFocusIn);
|
||||
return () => document.removeEventListener('focusin', onFocusIn);
|
||||
}, [open, target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = panelRef.current;
|
||||
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const preferred = latest.current.initialFocusRef?.current;
|
||||
(preferred ?? focusableWithin(panel)[0] ?? panel).focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
if (previous && previous.isConnected) {
|
||||
previous.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
}, [open, target]);
|
||||
|
||||
const onKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = panelRef.current;
|
||||
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = focusableWithin(panel);
|
||||
|
||||
if (items.length === 0) {
|
||||
event.preventDefault();
|
||||
panel.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && (active === first || active === panel)) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent) => {
|
||||
const panel = panelRef.current;
|
||||
downedOutside.current = !panel?.contains(event.target as Node);
|
||||
}, []);
|
||||
|
||||
const onClick = useCallback((event: React.MouseEvent) => {
|
||||
const panel = panelRef.current;
|
||||
|
||||
if (!latest.current.closeOnBackdrop) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!downedOutside.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
downedOutside.current = false;
|
||||
latest.current.onClose();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
target,
|
||||
titleId,
|
||||
descriptionId,
|
||||
overlayProps: { ref: overlayRef, onPointerDown, onClick },
|
||||
panelProps: {
|
||||
ref: panelRef,
|
||||
role: 'dialog',
|
||||
'aria-modal': true,
|
||||
'aria-labelledby': titleId,
|
||||
tabIndex: -1,
|
||||
onKeyDown,
|
||||
},
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
const CLOSE_ICON = (
|
||||
<svg width="14" height="14" viewBox="0 0 256 256" fill="none" aria-hidden="true">
|
||||
<line
|
||||
x1="200"
|
||||
y1="56"
|
||||
x2="56"
|
||||
y2="200"
|
||||
stroke="currentColor"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="200"
|
||||
y1="200"
|
||||
x2="56"
|
||||
y2="56"
|
||||
stroke="currentColor"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type ModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
closeLabel?: string;
|
||||
showClose?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
lockScroll?: boolean;
|
||||
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
container?: HTMLElement | null;
|
||||
maxWidth?: number;
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
closeLabel = 'Close dialog',
|
||||
showClose = true,
|
||||
closeOnEscape = true,
|
||||
closeOnBackdrop = true,
|
||||
lockScroll = true,
|
||||
initialFocusRef,
|
||||
container,
|
||||
maxWidth = 440,
|
||||
maxHeight = 'min(78dvh, 620px)',
|
||||
className = '',
|
||||
}: ModalProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const [viewport, setViewport] = useState({ top: 0, height: '100%', alignStart: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vv = window.visualViewport;
|
||||
|
||||
if (!vv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const keyboard = window.innerHeight - vv.height > 80;
|
||||
setViewport({
|
||||
top: vv.offsetTop,
|
||||
height: `${vv.height}px`,
|
||||
alignStart: keyboard,
|
||||
});
|
||||
};
|
||||
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const { target, titleId, descriptionId, overlayProps, panelProps } = useModal({
|
||||
open,
|
||||
onClose,
|
||||
closeOnEscape,
|
||||
closeOnBackdrop,
|
||||
lockScroll,
|
||||
initialFocusRef,
|
||||
container,
|
||||
});
|
||||
|
||||
const variants = useMemo(() => {
|
||||
if (reduced) {
|
||||
return {
|
||||
backdrop: {
|
||||
closed: { opacity: 0 },
|
||||
open: { opacity: 1, transition: { duration: 0 } },
|
||||
gone: { opacity: 0, transition: { duration: 0 } },
|
||||
},
|
||||
panel: {
|
||||
closed: { opacity: 0 },
|
||||
open: { opacity: 1, transition: { duration: 0 } },
|
||||
gone: { opacity: 0, transition: { duration: 0 } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backdrop: {
|
||||
closed: { opacity: 0 },
|
||||
open: { opacity: 1, transition: { duration: 0.2, ease: EASE } },
|
||||
gone: { opacity: 0, transition: { duration: 0.15, ease: LEAVE } },
|
||||
},
|
||||
panel: {
|
||||
closed: { opacity: 0, scale: 0.96, y: 12 },
|
||||
open: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { ...SURFACE, opacity: { duration: 0.16, ease: EASE } },
|
||||
},
|
||||
|
||||
gone: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
y: 6,
|
||||
transition: { duration: 0.15, ease: LEAVE },
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [reduced]);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
key="modal"
|
||||
{...overlayProps}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="gone"
|
||||
variants={{ closed: {}, open: {}, gone: {} }}
|
||||
className={`fixed inset-x-0 z-50 grid overscroll-none p-4 sm:p-6 ${
|
||||
viewport.alignStart ? 'place-items-start' : 'place-items-center'
|
||||
}`}
|
||||
style={{ top: viewport.top, height: viewport.height }}
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
variants={variants.backdrop}
|
||||
style={{ touchAction: 'none' }}
|
||||
className="absolute inset-0 bg-black/50"
|
||||
/>
|
||||
<motion.div
|
||||
{...panelProps}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
variants={variants.panel}
|
||||
style={{ maxWidth, maxHeight }}
|
||||
className={`relative flex w-full flex-col overflow-hidden rounded-[14px] border border-border bg-card text-foreground shadow-[0_28px_56px_-24px_rgba(24,22,20,0.45)] outline-none ${className}`}
|
||||
>
|
||||
<div className="flex shrink-0 items-start gap-3 px-4 pb-3 pt-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-[15px] font-medium tracking-[-0.01em] text-foreground"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<div
|
||||
id={descriptionId}
|
||||
className="mt-1 text-[12.5px] leading-relaxed text-muted-foreground"
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showClose ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
className="-mr-1 -mt-1 grid size-7 shrink-0 place-items-center rounded-[7px] text-muted-foreground outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:bg-primary/[0.06] focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_1px_var(--ring)]"
|
||||
>
|
||||
{CLOSE_ICON}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{children ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4 text-[13px] leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{footer ? (
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
target,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type Ref,
|
||||
} from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const CROSSFADE = { type: 'spring', stiffness: 260, damping: 34, mass: 0.8 } as const;
|
||||
const EASE = [0.23, 1, 0.32, 1] as const;
|
||||
|
||||
export type OtpMode = 'numeric' | 'alphanumeric';
|
||||
|
||||
const ALLOW: Record<OtpMode, RegExp> = {
|
||||
numeric: /^[0-9]$/,
|
||||
alphanumeric: /^[0-9a-zA-Z]$/,
|
||||
};
|
||||
|
||||
export type UseOtpInputOptions = {
|
||||
length?: number;
|
||||
mode?: OtpMode;
|
||||
defaultValue?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onComplete?: (value: string) => void;
|
||||
};
|
||||
|
||||
export type OtpCellProps = {
|
||||
ref: (el: HTMLInputElement | null) => void;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
type: 'text';
|
||||
inputMode: 'numeric' | 'text';
|
||||
autoComplete: string;
|
||||
pattern?: string;
|
||||
autoCorrect: 'off';
|
||||
autoCapitalize: 'off';
|
||||
spellCheck: false;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onPaste: (e: ClipboardEvent<HTMLInputElement>) => void;
|
||||
onFocus: (e: FocusEvent<HTMLInputElement>) => void;
|
||||
onBlur: (e: FocusEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export type UseOtpInputReturn = {
|
||||
chars: string[];
|
||||
value: string;
|
||||
length: number;
|
||||
complete: boolean;
|
||||
focusedIndex: number;
|
||||
getCellProps: (index: number) => OtpCellProps;
|
||||
focusAt: (index: number) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export function useOtpInput({
|
||||
length = 6,
|
||||
mode = 'numeric',
|
||||
defaultValue = '',
|
||||
disabled = false,
|
||||
onChange,
|
||||
onComplete,
|
||||
}: UseOtpInputOptions = {}): UseOtpInputReturn {
|
||||
const allow = ALLOW[mode];
|
||||
|
||||
const keep = useCallback(
|
||||
(text: string) =>
|
||||
text
|
||||
.split('')
|
||||
.filter((c) => allow.test(c))
|
||||
.join(''),
|
||||
[allow],
|
||||
);
|
||||
|
||||
const [chars, setChars] = useState<string[]>(() => {
|
||||
const seed = defaultValue
|
||||
.split('')
|
||||
.filter((c) => ALLOW[mode].test(c))
|
||||
.slice(0, length);
|
||||
return Array.from({ length }, (_, i) => seed[i] ?? '');
|
||||
});
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
|
||||
const charsRef = useRef(chars);
|
||||
const refs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const changed = useRef(onChange);
|
||||
const completed = useRef(onComplete);
|
||||
|
||||
useEffect(() => {
|
||||
charsRef.current = chars;
|
||||
}, [chars]);
|
||||
|
||||
useEffect(() => {
|
||||
changed.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
completed.current = onComplete;
|
||||
}, [onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
setChars((prev) =>
|
||||
prev.length === length ? prev : Array.from({ length }, (_, i) => prev[i] ?? ''),
|
||||
);
|
||||
refs.current.length = length;
|
||||
}, [length]);
|
||||
|
||||
const commit = useCallback((next: string[]) => {
|
||||
charsRef.current = next;
|
||||
setChars(next);
|
||||
const value = next.join('');
|
||||
changed.current?.(value);
|
||||
|
||||
if (next.length > 0 && next.every((c) => c !== '')) {
|
||||
completed.current?.(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const focusAt = useCallback(
|
||||
(index: number) => {
|
||||
const el = refs.current[Math.max(0, Math.min(length - 1, index))];
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.focus();
|
||||
el.select();
|
||||
},
|
||||
[length],
|
||||
);
|
||||
|
||||
const fillFrom = useCallback(
|
||||
(index: number, text: string) => {
|
||||
const incoming = keep(text);
|
||||
|
||||
if (incoming.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = [...charsRef.current];
|
||||
let cursor = index;
|
||||
|
||||
for (const c of incoming) {
|
||||
if (cursor >= length) {
|
||||
break;
|
||||
}
|
||||
|
||||
next[cursor] = c;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
commit(next);
|
||||
focusAt(cursor);
|
||||
},
|
||||
[commit, focusAt, keep, length],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
commit(Array.from({ length }, () => ''));
|
||||
focusAt(0);
|
||||
}, [commit, focusAt, length]);
|
||||
|
||||
const getCellProps = useCallback(
|
||||
(index: number): OtpCellProps => ({
|
||||
ref: (el) => {
|
||||
refs.current[index] = el;
|
||||
},
|
||||
value: chars[index] ?? '',
|
||||
disabled,
|
||||
type: 'text',
|
||||
inputMode: mode === 'numeric' ? 'numeric' : 'text',
|
||||
autoComplete: index === 0 && mode === 'numeric' ? 'one-time-code' : 'off',
|
||||
pattern: mode === 'numeric' ? '[0-9]*' : undefined,
|
||||
autoCorrect: 'off',
|
||||
autoCapitalize: 'off',
|
||||
spellCheck: false,
|
||||
onChange: (e) => {
|
||||
const previous = charsRef.current[index] ?? '';
|
||||
const raw = e.currentTarget.value;
|
||||
const trimmed =
|
||||
raw.length > 1 && previous && raw.startsWith(previous)
|
||||
? raw.slice(previous.length)
|
||||
: raw;
|
||||
const incoming = keep(trimmed);
|
||||
|
||||
if (incoming.length === 0) {
|
||||
if (raw.length === 0 && previous) {
|
||||
const next = [...charsRef.current];
|
||||
next[index] = '';
|
||||
commit(next);
|
||||
}
|
||||
|
||||
e.currentTarget.value = charsRef.current[index] ?? '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (incoming.length === 1) {
|
||||
const next = [...charsRef.current];
|
||||
next[index] = incoming;
|
||||
e.currentTarget.value = incoming;
|
||||
commit(next);
|
||||
|
||||
if (index < length - 1) {
|
||||
focusAt(index + 1);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
fillFrom(index, incoming);
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
const current = charsRef.current;
|
||||
const next = [...current];
|
||||
|
||||
if (current[index]) {
|
||||
next[index] = '';
|
||||
commit(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
next[index - 1] = '';
|
||||
commit(next);
|
||||
focusAt(index - 1);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
const next = [...charsRef.current];
|
||||
next[index] = '';
|
||||
commit(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
focusAt(index - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
focusAt(index + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
focusAt(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
focusAt(length - 1);
|
||||
}
|
||||
},
|
||||
onPaste: (e) => {
|
||||
e.preventDefault();
|
||||
const text = keep(e.clipboardData.getData('text'));
|
||||
fillFrom(text.length >= length ? 0 : index, text);
|
||||
},
|
||||
onFocus: (e) => {
|
||||
e.currentTarget.select();
|
||||
const firstEmpty = charsRef.current.findIndex((c) => c === '');
|
||||
|
||||
if (firstEmpty !== -1 && firstEmpty < index) {
|
||||
focusAt(firstEmpty);
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedIndex(index);
|
||||
},
|
||||
onBlur: (e) => {
|
||||
const to = e.relatedTarget as HTMLInputElement | null;
|
||||
|
||||
if (to && refs.current.includes(to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedIndex(-1);
|
||||
},
|
||||
}),
|
||||
[chars, commit, disabled, fillFrom, focusAt, keep, length, mode],
|
||||
);
|
||||
|
||||
const value = chars.join('');
|
||||
|
||||
return {
|
||||
chars,
|
||||
value,
|
||||
length,
|
||||
complete: chars.length > 0 && chars.every((c) => c !== ''),
|
||||
focusedIndex,
|
||||
getCellProps,
|
||||
focusAt,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
export type OtpStatus = 'idle' | 'error' | 'success';
|
||||
|
||||
export type OtpInputHandle = {
|
||||
clear: () => void;
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
export type OtpInputProps = {
|
||||
length?: number;
|
||||
mode?: OtpMode;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onComplete?: (value: string) => void;
|
||||
/** Official usage example: red cells + shake on the false → true edge. */
|
||||
error?: boolean;
|
||||
status?: OtpStatus;
|
||||
errorMessage?: string;
|
||||
successMessage?: string;
|
||||
hint?: string;
|
||||
label?: string;
|
||||
groupEvery?: number;
|
||||
/** Draw a rule between groups (recovery XXXX-XXXX). Spacing-only when false. */
|
||||
groupSeparator?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
focusOnError?: boolean;
|
||||
className?: string;
|
||||
ref?: Ref<OtpInputHandle>;
|
||||
};
|
||||
|
||||
export function OtpInput({
|
||||
length = 6,
|
||||
mode = 'numeric',
|
||||
defaultValue = '',
|
||||
onChange,
|
||||
onComplete,
|
||||
error: errorProp = false,
|
||||
status = 'idle',
|
||||
errorMessage = '',
|
||||
successMessage = '',
|
||||
hint = '',
|
||||
label = 'Verification code',
|
||||
groupEvery = 3,
|
||||
groupSeparator = false,
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
focusOnError = true,
|
||||
className = '',
|
||||
ref,
|
||||
}: OtpInputProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const statusId = useId();
|
||||
|
||||
const { chars, focusedIndex, getCellProps, focusAt, clear } = useOtpInput({
|
||||
length,
|
||||
mode,
|
||||
defaultValue,
|
||||
disabled,
|
||||
onChange,
|
||||
onComplete,
|
||||
});
|
||||
|
||||
const wasError = useRef(false);
|
||||
const error = errorProp || status === 'error';
|
||||
const success = status === 'success';
|
||||
const tone = error ? 'error' : success ? 'success' : 'idle';
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
clear: () => {
|
||||
clear();
|
||||
focusAt(0);
|
||||
},
|
||||
focus: () => focusAt(0),
|
||||
}),
|
||||
[clear, focusAt],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error && !wasError.current && focusOnError && !disabled) {
|
||||
focusAt(0);
|
||||
}
|
||||
|
||||
wasError.current = error;
|
||||
}, [error, focusOnError, disabled, focusAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && !disabled) {
|
||||
focusAt(0);
|
||||
}
|
||||
}, [autoFocus, disabled, focusAt]);
|
||||
|
||||
const enter = reduced ? { duration: 0 } : { duration: 0.22, ease: EASE };
|
||||
const swap = reduced ? { duration: 0 } : CROSSFADE;
|
||||
const hasStatus = hint.length > 0 || errorMessage.length > 0 || successMessage.length > 0;
|
||||
|
||||
const message = error ? errorMessage : success ? successMessage : hint;
|
||||
const messageTone = error
|
||||
? 'text-destructive'
|
||||
: success
|
||||
? 'text-success'
|
||||
: 'text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className={`flex w-full flex-col ${className}`}>
|
||||
<motion.div
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="relative flex w-full gap-2.5"
|
||||
initial={false}
|
||||
variants={{ idle: { x: 0 }, wrong: { x: [0, -5, 4, -3, 0] } }}
|
||||
animate={error && !reduced ? 'wrong' : 'idle'}
|
||||
transition={{ duration: 0.32, ease: EASE }}
|
||||
>
|
||||
{Array.from({ length }, (_, i) => {
|
||||
const char = chars[i] ?? '';
|
||||
const active = focusedIndex === i;
|
||||
const gap = groupEvery > 0 && i > 0 && i % groupEvery === 0;
|
||||
|
||||
return (
|
||||
<div key={i} className="contents">
|
||||
{gap && groupSeparator ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-16 w-3.5 shrink-0 items-center justify-center"
|
||||
>
|
||||
<span className="h-[2px] w-3 rounded-full bg-muted-foreground/55" />
|
||||
</span>
|
||||
) : null}
|
||||
<div
|
||||
className={`relative h-16 min-w-0 flex-1 ${gap && !groupSeparator ? 'ms-3' : ''}`}
|
||||
>
|
||||
<input
|
||||
{...getCellProps(i)}
|
||||
aria-label={`${label}, character ${i + 1} of ${length}`}
|
||||
aria-invalid={error || undefined}
|
||||
aria-describedby={hasStatus ? statusId : undefined}
|
||||
className={`size-full rounded-xl border-2 text-center text-xl text-transparent caret-transparent outline-none transition-[background-color,border-color,box-shadow] duration-150 selection:bg-transparent focus-visible:outline-none disabled:opacity-50 ${
|
||||
error
|
||||
? 'border-destructive bg-background'
|
||||
: success
|
||||
? 'border-success bg-background'
|
||||
: active
|
||||
? 'border-primary bg-background'
|
||||
: char
|
||||
? 'border-border bg-background'
|
||||
: 'border-border/80 bg-muted/60 shadow-[inset_0_1px_2px_color-mix(in_oklab,var(--foreground)_8%,transparent)]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 grid place-items-center"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{char ? (
|
||||
<motion.span
|
||||
key={char}
|
||||
initial={reduced ? false : { opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={enter}
|
||||
className="col-start-1 row-start-1 font-mono text-[22px] tabular-nums text-foreground"
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{active && !char && !disabled ? (
|
||||
<motion.span
|
||||
className="col-start-1 row-start-1 block h-6 w-[1.5px] rounded-[1px] bg-foreground"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={
|
||||
reduced ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }
|
||||
}
|
||||
transition={
|
||||
reduced
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 1.06,
|
||||
times: [0, 0.5, 0.5, 1],
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{hasStatus && (
|
||||
<>
|
||||
<div aria-hidden className="mt-2 grid h-4 text-[11.5px] leading-[16px]">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.span
|
||||
key={tone}
|
||||
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 3 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -3 }}
|
||||
transition={swap}
|
||||
className={`col-start-1 row-start-1 ${messageTone}`}
|
||||
>
|
||||
{message}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<span id={statusId} role="status" className="sr-only">
|
||||
{message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const CELL = { type: 'spring', stiffness: 520, damping: 34, mass: 0.45 } as const;
|
||||
const CROSSFADE = { type: 'spring', stiffness: 260, damping: 34, mass: 0.8 } as const;
|
||||
const INSTANT = { duration: 0 } as const;
|
||||
|
||||
const COMMON =
|
||||
/^(?:password|passw0rd|qwerty|letmein|welcome|admin|iloveyou|monkey|dragon|abc123|111111|123123|123456)/i;
|
||||
const RUN = /(.)\1{3,}/;
|
||||
const RUN_UP = /(?:0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef|defg|qwer|wert|erty|asdf)/i;
|
||||
const SYMBOL = /[!-/:-@[-`{-~]/;
|
||||
|
||||
export type PasswordRule = {
|
||||
id: string;
|
||||
label: string;
|
||||
test: (value: string) => boolean;
|
||||
};
|
||||
|
||||
export type EvaluatedRule = PasswordRule & { met: boolean };
|
||||
|
||||
export type UsePasswordStrengthOptions = {
|
||||
rules?: readonly PasswordRule[];
|
||||
labels?: readonly string[];
|
||||
announceDelay?: number;
|
||||
};
|
||||
|
||||
export type PasswordStrengthState = {
|
||||
score: number;
|
||||
max: number;
|
||||
label: string;
|
||||
rules: EvaluatedRule[];
|
||||
guessable: boolean;
|
||||
announcement: string;
|
||||
};
|
||||
|
||||
export const defaultPasswordRules: readonly PasswordRule[] = [
|
||||
{ id: 'length', label: '至少 12 个字符', test: (v) => v.length >= 12 },
|
||||
{
|
||||
id: 'case',
|
||||
label: '同时包含大写和小写字母',
|
||||
test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v),
|
||||
},
|
||||
{ id: 'digit', label: '包含数字', test: (v) => /\d/.test(v) },
|
||||
{ id: 'symbol', label: '包含特殊符号', test: (v) => SYMBOL.test(v) },
|
||||
];
|
||||
|
||||
const defaultLabels = ['未输入', '弱', '一般', '良好', '强'] as const;
|
||||
|
||||
export function usePasswordStrength(
|
||||
value: string,
|
||||
{
|
||||
rules = defaultPasswordRules,
|
||||
labels = defaultLabels,
|
||||
announceDelay = 700,
|
||||
}: UsePasswordStrengthOptions = {},
|
||||
): PasswordStrengthState {
|
||||
const state = useMemo(() => {
|
||||
const evaluated = rules.map((rule) => ({ ...rule, met: rule.test(value) }));
|
||||
const passed = evaluated.reduce((n, r) => n + (r.met ? 1 : 0), 0);
|
||||
const guessable =
|
||||
value.length > 0 && (COMMON.test(value) || RUN.test(value) || RUN_UP.test(value));
|
||||
|
||||
const score =
|
||||
value.length === 0 ? 0 : guessable ? 1 : Math.min(rules.length, Math.max(1, passed));
|
||||
|
||||
const label = labels[Math.min(score, labels.length - 1)] ?? '';
|
||||
const unmet = evaluated.filter((r) => !r.met);
|
||||
|
||||
const announcement =
|
||||
value.length === 0
|
||||
? ''
|
||||
: [
|
||||
`密码强度${label}。`,
|
||||
guessable ? '这是容易被猜中的常见模式。' : '',
|
||||
unmet.length === 0
|
||||
? '已满足全部要求。'
|
||||
: `仍需满足:${unmet.map((r) => r.label).join('、')}。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return { score, max: rules.length, label, rules: evaluated, guessable, announcement };
|
||||
}, [value, rules, labels]);
|
||||
|
||||
const [settled, setSettled] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (state.announcement === '') {
|
||||
setSettled('');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = setTimeout(() => setSettled(state.announcement), announceDelay);
|
||||
return () => clearTimeout(id);
|
||||
}, [state.announcement, announceDelay]);
|
||||
|
||||
return { ...state, announcement: settled };
|
||||
}
|
||||
|
||||
export type PasswordStrengthProps = {
|
||||
value: string;
|
||||
rules?: readonly PasswordRule[];
|
||||
labels?: readonly string[];
|
||||
announceDelay?: number;
|
||||
showRules?: boolean;
|
||||
visible?: boolean;
|
||||
status?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TONES = {
|
||||
none: { bar: 'bg-muted-foreground/30', text: 'text-muted-foreground' },
|
||||
danger: { bar: 'bg-destructive', text: 'text-destructive' },
|
||||
caution: { bar: 'bg-warning', text: 'text-warning' },
|
||||
safe: { bar: 'bg-success', text: 'text-success' },
|
||||
} as const;
|
||||
|
||||
function toneFor(score: number, max: number) {
|
||||
if (score === 0) {
|
||||
return TONES.none;
|
||||
}
|
||||
|
||||
const ratio = score / max;
|
||||
|
||||
if (ratio <= 0.34) {
|
||||
return TONES.danger;
|
||||
}
|
||||
|
||||
if (ratio <= 0.67) {
|
||||
return TONES.caution;
|
||||
}
|
||||
|
||||
return TONES.safe;
|
||||
}
|
||||
|
||||
export function PasswordStrength({
|
||||
value,
|
||||
rules = defaultPasswordRules,
|
||||
labels = defaultLabels,
|
||||
announceDelay = 700,
|
||||
showRules = true,
|
||||
visible = true,
|
||||
status,
|
||||
className = '',
|
||||
}: PasswordStrengthProps) {
|
||||
const {
|
||||
score,
|
||||
max,
|
||||
label,
|
||||
rules: evaluated,
|
||||
guessable,
|
||||
announcement,
|
||||
} = usePasswordStrength(value, { rules, labels, announceDelay });
|
||||
const reduced = useReducedMotion();
|
||||
const tone = toneFor(score, max);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{visible ? (
|
||||
<motion.div
|
||||
key="password-strength"
|
||||
initial={reduced ? false : { height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={reduced ? INSTANT : CROSSFADE}
|
||||
style={{ overflow: 'hidden' }}
|
||||
className={`w-full ${className}`}
|
||||
>
|
||||
<div
|
||||
role="meter"
|
||||
aria-label="密码强度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={score}
|
||||
aria-valuetext={label}
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: `repeat(${max}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative h-1.5 overflow-hidden rounded-[2px] bg-muted"
|
||||
>
|
||||
<motion.span
|
||||
className={`absolute inset-0 origin-left rounded-[2px] transition-colors duration-200 ${tone.bar}`}
|
||||
initial={false}
|
||||
animate={{ scaleX: i < score ? 1 : 0 }}
|
||||
transition={
|
||||
reduced
|
||||
? INSTANT
|
||||
: { ...CELL, delay: i < score ? i * 0.03 : 0 }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex h-5 items-center justify-between gap-3">
|
||||
<span className="inline-grid text-[12.5px] font-medium leading-5">
|
||||
{labels.map((text, i) => (
|
||||
<motion.span
|
||||
key={text}
|
||||
aria-hidden
|
||||
className={`col-start-1 row-start-1 whitespace-nowrap transition-colors duration-200 ${tone.text}`}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: i === Math.min(score, labels.length - 1) ? 1 : 0,
|
||||
}}
|
||||
transition={reduced ? INSTANT : CROSSFADE}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="whitespace-nowrap text-[11.5px] leading-5 text-warning"
|
||||
initial={false}
|
||||
animate={{ opacity: guessable ? 1 : 0 }}
|
||||
transition={reduced ? INSTANT : CROSSFADE}
|
||||
>
|
||||
容易被猜中
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
{showRules && (
|
||||
<ul className="mt-3 grid gap-1.5">
|
||||
{evaluated.map((rule) => (
|
||||
<li key={rule.id} className="flex items-center gap-2">
|
||||
<span className="relative grid size-[14px] shrink-0 place-items-center rounded-[4px] border border-border text-primary-foreground">
|
||||
<motion.span
|
||||
className="absolute inset-0 rounded-[3px] bg-success"
|
||||
initial={false}
|
||||
animate={{ opacity: rule.met ? 1 : 0 }}
|
||||
transition={reduced ? INSTANT : CROSSFADE}
|
||||
/>
|
||||
<motion.svg
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className="relative size-[9px]"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: rule.met ? 1 : 0,
|
||||
scale: rule.met ? 1 : 0.6,
|
||||
}}
|
||||
transition={reduced ? INSTANT : CELL}
|
||||
>
|
||||
<path
|
||||
d="M2 6.2 4.7 8.9 10 3.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.9}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
<span
|
||||
className={`text-[12.5px] leading-5 transition-colors duration-200 ${
|
||||
rule.met ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{rule.label}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{rule.met ? '已满足' : '未满足'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{status ? <div className="mt-2 text-[11.5px] leading-4">{status}</div> : null}
|
||||
|
||||
<p aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react';
|
||||
|
||||
const CELL = { type: 'spring', stiffness: 520, damping: 34, mass: 0.45 } as const;
|
||||
const SEG =
|
||||
'px-3 py-[7px] text-center text-[13px] font-medium leading-[18px] tracking-[-0.01em] whitespace-nowrap';
|
||||
|
||||
export type SegmentedOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type SegmentedControlProps = {
|
||||
options: SegmentedOption[];
|
||||
label: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SegmentedControl({
|
||||
options,
|
||||
label,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
className = '',
|
||||
}: SegmentedControlProps) {
|
||||
const count = Math.max(1, options.length);
|
||||
const template = `repeat(${count}, minmax(0, 1fr))`;
|
||||
|
||||
const [internal, setInternal] = useState(() => defaultValue ?? options[0]?.value ?? '');
|
||||
const [hovered, setHovered] = useState(-1);
|
||||
|
||||
const controlled = value !== undefined;
|
||||
const current = controlled ? value : internal;
|
||||
const found = options.findIndex((option) => option.value === current);
|
||||
const index = found < 0 ? 0 : found;
|
||||
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const emit = useRef(onValueChange);
|
||||
useEffect(() => {
|
||||
emit.current = onValueChange;
|
||||
}, [onValueChange]);
|
||||
|
||||
const reduced = useReducedMotion();
|
||||
const pos = useMotionValue(index);
|
||||
const thumbX = useTransform(pos, (next) => `${next * 100}%`);
|
||||
const maskX = useTransform(pos, (next) => `${next * -100}%`);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced) {
|
||||
pos.set(index);
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = animate(pos, index, CELL);
|
||||
return () => controls.stop();
|
||||
}, [index, reduced, pos]);
|
||||
|
||||
const select = useCallback(
|
||||
(next: string) => {
|
||||
if (!controlled) {
|
||||
setInternal(next);
|
||||
}
|
||||
|
||||
if (next !== current) {
|
||||
emit.current?.(next);
|
||||
}
|
||||
},
|
||||
[controlled, current],
|
||||
);
|
||||
|
||||
const seek = useCallback(
|
||||
(from: number, direction: number) => {
|
||||
let next = from;
|
||||
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
next = (next + direction + count) % count;
|
||||
|
||||
if (!options[next]?.disabled) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
return from;
|
||||
},
|
||||
[count, options],
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
(next: number) => {
|
||||
const option = options[next];
|
||||
|
||||
if (!option || option.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
buttons.current[next]?.focus();
|
||||
select(option.value);
|
||||
},
|
||||
[options, select],
|
||||
);
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent, optionIndex: number) => {
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
go(seek(optionIndex, 1));
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
go(seek(optionIndex, -1));
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
go(seek(count - 1, 1));
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
go(seek(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={`ui-segmented relative inline-block select-none rounded-[9px] border border-border bg-muted/70 p-[3px] shadow-[inset_0_1px_2px_rgb(0_0_0/0.07)] ${className}`}
|
||||
>
|
||||
<div
|
||||
className="relative grid"
|
||||
style={{ gridTemplateColumns: template, touchAction: 'manipulation' }}
|
||||
>
|
||||
{options.map((option, optionIndex) => (
|
||||
<span
|
||||
key={option.value}
|
||||
aria-hidden
|
||||
className={`${SEG} pointer-events-none ${
|
||||
option.disabled
|
||||
? 'text-muted-foreground/40'
|
||||
: hovered === optionIndex && optionIndex !== index
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="ui-segmented-thumb pointer-events-none absolute inset-y-0 left-0 overflow-hidden rounded-[6px] bg-foreground text-background shadow-[0_1px_2px_rgb(0_0_0/0.28)]"
|
||||
style={{ width: `${100 / count}%`, x: thumbX }}
|
||||
initial={false}
|
||||
>
|
||||
<motion.div className="absolute inset-0" style={{ x: maskX }} initial={false}>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 grid"
|
||||
style={{ width: `${count * 100}%`, gridTemplateColumns: template }}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<span key={option.value} className={`${SEG} text-background`}>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div
|
||||
className="absolute inset-0 grid"
|
||||
style={{ gridTemplateColumns: template }}
|
||||
onPointerLeave={() => setHovered(-1)}
|
||||
>
|
||||
{options.map((option, optionIndex) => (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
buttons.current[optionIndex] = node;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={optionIndex === index}
|
||||
aria-disabled={option.disabled || undefined}
|
||||
tabIndex={optionIndex === index ? 0 : -1}
|
||||
onClick={() => !option.disabled && select(option.value)}
|
||||
onKeyDown={(event) => onKeyDown(event, optionIndex)}
|
||||
onPointerEnter={() => !option.disabled && setHovered(optionIndex)}
|
||||
className="cursor-default rounded-[6px] outline-none focus-visible:bg-primary/[0.06] focus-visible:shadow-[inset_0_0_0_1px_var(--ring)]"
|
||||
>
|
||||
<span className="sr-only">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const CROSSFADE = {
|
||||
type: 'spring',
|
||||
stiffness: 260,
|
||||
damping: 34,
|
||||
mass: 0.8,
|
||||
} as const;
|
||||
|
||||
const WIDTHS = [100, 93, 97, 88, 95, 91] as const;
|
||||
|
||||
function widthFor(index: number, total: number) {
|
||||
if (total > 1 && index === total - 1) {
|
||||
return 62;
|
||||
}
|
||||
|
||||
return WIDTHS[(index * 7 + 3) % WIDTHS.length];
|
||||
}
|
||||
|
||||
export type UseSkeletonSwapOptions = {
|
||||
ready: boolean;
|
||||
delay?: number;
|
||||
minVisible?: number;
|
||||
};
|
||||
|
||||
export function useSkeletonSwap({ ready, delay = 120, minVisible = 380 }: UseSkeletonSwapOptions) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const shownAt = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
if (visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const t = setTimeout(() => {
|
||||
shownAt.current = performance.now();
|
||||
setVisible(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = Math.max(0, minVisible - (performance.now() - shownAt.current));
|
||||
const t = setTimeout(() => setVisible(false), rest);
|
||||
return () => clearTimeout(t);
|
||||
}, [ready, visible, delay, minVisible]);
|
||||
|
||||
return { showSkeleton: visible, busy: !ready };
|
||||
}
|
||||
|
||||
export type SkeletonSwapProps = {
|
||||
ready: boolean;
|
||||
children: React.ReactNode;
|
||||
lines?: number;
|
||||
lineHeight?: number;
|
||||
barHeight?: number;
|
||||
reserve?: number;
|
||||
delay?: number;
|
||||
minVisible?: number;
|
||||
label?: string;
|
||||
skeleton?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SkeletonSwap({
|
||||
ready,
|
||||
children,
|
||||
lines = 3,
|
||||
lineHeight = 21,
|
||||
barHeight = 9,
|
||||
reserve,
|
||||
delay = 120,
|
||||
minVisible = 380,
|
||||
label,
|
||||
skeleton,
|
||||
className = '',
|
||||
}: SkeletonSwapProps) {
|
||||
const { showSkeleton } = useSkeletonSwap({ ready, delay, minVisible });
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const shell = useRef<HTMLDivElement>(null);
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
const [scrollable, setScrollable] = useState(false);
|
||||
|
||||
const box = reserve ?? lines * lineHeight;
|
||||
|
||||
useEffect(() => {
|
||||
const el = shell.current;
|
||||
const inner = body.current;
|
||||
|
||||
if (!el || typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const check = () => setScrollable(el.scrollHeight - el.clientHeight > 1);
|
||||
check();
|
||||
|
||||
const ro = new ResizeObserver(check);
|
||||
ro.observe(el);
|
||||
|
||||
if (inner) {
|
||||
ro.observe(inner);
|
||||
}
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={shell}
|
||||
aria-busy={!ready}
|
||||
aria-label={label}
|
||||
tabIndex={scrollable ? 0 : undefined}
|
||||
style={{ height: box }}
|
||||
className={`relative grid overflow-y-auto overscroll-contain text-foreground ${className}`}
|
||||
>
|
||||
<motion.div
|
||||
ref={body}
|
||||
aria-hidden={showSkeleton || undefined}
|
||||
inert={showSkeleton || undefined}
|
||||
className="col-start-1 row-start-1 min-w-0"
|
||||
initial={false}
|
||||
animate={
|
||||
reduced
|
||||
? { opacity: showSkeleton ? 0 : 1 }
|
||||
: {
|
||||
opacity: showSkeleton ? 0 : 1,
|
||||
}
|
||||
}
|
||||
transition={reduced ? { duration: 0 } : CROSSFADE}
|
||||
style={{
|
||||
transformOrigin: 'top left',
|
||||
pointerEvents: showSkeleton ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{showSkeleton ? (
|
||||
<motion.div
|
||||
key="skeleton"
|
||||
aria-hidden
|
||||
className="pointer-events-none col-start-1 row-start-1 w-full self-start"
|
||||
initial={reduced ? { opacity: 1 } : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduced ? { duration: 0 } : CROSSFADE}
|
||||
>
|
||||
{skeleton ?? (
|
||||
<div className="w-full">
|
||||
{Array.from({ length: lines }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center"
|
||||
style={{ height: lineHeight }}
|
||||
>
|
||||
<div
|
||||
className="rounded-[5px] bg-muted"
|
||||
style={{
|
||||
height: barHeight,
|
||||
width: `${widthFor(i, lines)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{label ? (
|
||||
<span role="status" className="sr-only">
|
||||
{ready ? `${label} loaded` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
const EASE = [0.23, 1, 0.32, 1] as const;
|
||||
const EXIT_EASE = [0.4, 0, 1, 1] as const;
|
||||
const RAIL = { type: 'spring', stiffness: 520, damping: 40, mass: 0.5 } as const;
|
||||
const CROSSFADE = { type: 'spring', stiffness: 260, damping: 34, mass: 0.8 } as const;
|
||||
|
||||
export type WizardDirection = 1 | -1;
|
||||
|
||||
export type UseWizardOptions = {
|
||||
total: number;
|
||||
index?: number;
|
||||
defaultIndex?: number;
|
||||
onIndexChange?: (index: number, direction: WizardDirection) => void;
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
function clampIndex(value: number, total: number) {
|
||||
if (total < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(total - 1, Math.trunc(value)));
|
||||
}
|
||||
|
||||
export function useWizard({
|
||||
total,
|
||||
index,
|
||||
defaultIndex = 0,
|
||||
onIndexChange,
|
||||
onComplete,
|
||||
}: UseWizardOptions) {
|
||||
const [internal, setInternal] = useState(() => clampIndex(defaultIndex, total));
|
||||
const current = clampIndex(index ?? internal, total);
|
||||
const [seen, setSeen] = useState<{ index: number; direction: WizardDirection }>({
|
||||
index: current,
|
||||
direction: 1,
|
||||
});
|
||||
|
||||
if (seen.index !== current) {
|
||||
setSeen({ index: current, direction: current > seen.index ? 1 : -1 });
|
||||
}
|
||||
|
||||
const [furthest, setFurthest] = useState(current);
|
||||
|
||||
if (furthest < current) {
|
||||
setFurthest(current);
|
||||
}
|
||||
|
||||
const emit = useRef(onIndexChange);
|
||||
const finish = useRef(onComplete);
|
||||
useEffect(() => {
|
||||
emit.current = onIndexChange;
|
||||
finish.current = onComplete;
|
||||
}, [onIndexChange, onComplete]);
|
||||
|
||||
const controlled = index !== undefined;
|
||||
|
||||
const goTo = useCallback(
|
||||
(to: number) => {
|
||||
const target = clampIndex(to, total);
|
||||
|
||||
if (target === current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction: WizardDirection = target > current ? 1 : -1;
|
||||
|
||||
if (!controlled) {
|
||||
setInternal(target);
|
||||
}
|
||||
|
||||
emit.current?.(target, direction);
|
||||
},
|
||||
[controlled, current, total],
|
||||
);
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (current >= total - 1) {
|
||||
finish.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
goTo(current + 1);
|
||||
}, [current, goTo, total]);
|
||||
|
||||
const back = useCallback(() => goTo(current - 1), [current, goTo]);
|
||||
|
||||
return {
|
||||
index: current,
|
||||
direction: seen.direction,
|
||||
furthest: Math.min(furthest, Math.max(total - 1, 0)),
|
||||
total,
|
||||
isFirst: current === 0,
|
||||
isLast: current === total - 1,
|
||||
next,
|
||||
back,
|
||||
goTo,
|
||||
};
|
||||
}
|
||||
|
||||
export type WizardStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
canSkip?: boolean;
|
||||
onSkip?: () => void;
|
||||
hideAdvance?: boolean;
|
||||
hideBack?: boolean;
|
||||
scrollMode?: 'panel' | 'content';
|
||||
};
|
||||
|
||||
export type WizardNavigationControls = {
|
||||
back: () => void;
|
||||
next: () => void;
|
||||
goTo: (index: number) => void;
|
||||
index: number;
|
||||
total: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
};
|
||||
|
||||
const WizardNavigationContext = createContext<WizardNavigationControls | null>(null);
|
||||
|
||||
export function useWizardNavigation(): WizardNavigationControls {
|
||||
const controls = useContext(WizardNavigationContext);
|
||||
|
||||
if (!controls) {
|
||||
throw new Error('useWizardNavigation must be used inside <WizardSteps>');
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
export type WizardStepsProps = {
|
||||
steps: WizardStep[];
|
||||
index?: number;
|
||||
defaultIndex?: number;
|
||||
onIndexChange?: (index: number, direction: WizardDirection) => void;
|
||||
onComplete?: () => void;
|
||||
complete?: boolean;
|
||||
height?: CSSProperties['height'];
|
||||
backLabel?: string;
|
||||
nextLabel?: string;
|
||||
finishLabel?: string;
|
||||
skipLabel?: string;
|
||||
completeLabel?: string;
|
||||
completeHint?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
spacious?: boolean;
|
||||
fill?: boolean;
|
||||
};
|
||||
|
||||
export function WizardSteps({
|
||||
steps,
|
||||
index,
|
||||
defaultIndex = 0,
|
||||
onIndexChange,
|
||||
onComplete,
|
||||
complete = false,
|
||||
height = 184,
|
||||
backLabel = 'Back',
|
||||
nextLabel = 'Next',
|
||||
finishLabel = 'Finish',
|
||||
skipLabel = 'Skip',
|
||||
completeLabel = 'All set',
|
||||
completeHint = 'Step back to change anything',
|
||||
label = 'Steps',
|
||||
className = '',
|
||||
spacious = false,
|
||||
fill = false,
|
||||
}: WizardStepsProps) {
|
||||
const wizard = useWizard({
|
||||
total: steps.length,
|
||||
index,
|
||||
defaultIndex,
|
||||
onIndexChange,
|
||||
onComplete,
|
||||
});
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const listRef = useRef<HTMLOListElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const intent = useRef<'list' | 'panel' | null>(null);
|
||||
const { index: at, direction, furthest, total, isFirst, isLast, next, back, goTo } = wizard;
|
||||
|
||||
useEffect(() => {
|
||||
const move = intent.current;
|
||||
intent.current = null;
|
||||
|
||||
if (move === 'list') {
|
||||
listRef.current
|
||||
?.querySelector<HTMLButtonElement>('button[data-current="true"]')
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (move === 'panel') {
|
||||
viewportRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [at]);
|
||||
|
||||
const variants = useMemo(
|
||||
() => ({
|
||||
enter: (nextDirection: WizardDirection) =>
|
||||
reduced ? { opacity: 0 } : { opacity: 0, x: nextDirection * 22 },
|
||||
center: reduced ? { opacity: 1 } : { opacity: 1, x: 0 },
|
||||
exit: (nextDirection: WizardDirection) =>
|
||||
reduced
|
||||
? { opacity: 0, transition: { duration: 0 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
x: nextDirection * -22,
|
||||
transition: { duration: 0.14, ease: EXIT_EASE },
|
||||
},
|
||||
}),
|
||||
[reduced],
|
||||
);
|
||||
|
||||
const panelTransition = reduced ? { duration: 0 } : CROSSFADE;
|
||||
|
||||
const onStepKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
let target: number;
|
||||
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
target = at + 1;
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
target = at - 1;
|
||||
} else if (event.key === 'Home') {
|
||||
target = 0;
|
||||
} else if (event.key === 'End') {
|
||||
target = furthest;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
target = Math.min(clampIndex(target, total), furthest);
|
||||
|
||||
if (target === at) {
|
||||
return;
|
||||
}
|
||||
|
||||
intent.current = 'list';
|
||||
goTo(target);
|
||||
};
|
||||
|
||||
const step = steps[at];
|
||||
|
||||
if (!step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = `Step ${at + 1} of ${total}: ${step.label}`;
|
||||
const navigation = {
|
||||
back: () => {
|
||||
intent.current = 'panel';
|
||||
back();
|
||||
},
|
||||
next: () => {
|
||||
if (!isLast) {
|
||||
intent.current = 'panel';
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
goTo,
|
||||
index: at,
|
||||
total,
|
||||
isFirst,
|
||||
isLast,
|
||||
} satisfies WizardNavigationControls;
|
||||
const showBack = !isFirst && !step.hideBack;
|
||||
const showSkip = Boolean(step.canSkip);
|
||||
const showAdvance = !complete && !step.hideAdvance;
|
||||
const showNavigation = showBack || showSkip || showAdvance;
|
||||
|
||||
return (
|
||||
<div className={`${fill ? 'flex min-h-0 flex-1 flex-col' : ''} w-full ${className}`}>
|
||||
<p aria-live="polite" className="sr-only">
|
||||
{position}
|
||||
</p>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${spacious ? 'mb-1 text-sm' : 'mb-2 text-[13px]'} grid select-none font-medium text-foreground`}
|
||||
>
|
||||
{steps.map((candidate, stepIndex) => (
|
||||
<motion.span
|
||||
key={candidate.id}
|
||||
className="col-start-1 row-start-1 truncate"
|
||||
initial={false}
|
||||
animate={{ opacity: stepIndex === at ? 1 : 0 }}
|
||||
transition={reduced ? { duration: 0 } : CROSSFADE}
|
||||
>
|
||||
{candidate.label}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
<ol
|
||||
ref={listRef}
|
||||
aria-label={label}
|
||||
className={`${spacious ? 'mb-3 gap-2' : 'mb-4 gap-1'} flex list-none items-center p-0`}
|
||||
>
|
||||
{steps.map((candidate, stepIndex) => {
|
||||
const done = complete || stepIndex < at;
|
||||
const here = !complete && stepIndex === at;
|
||||
const tile = (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className={`grid size-7 place-items-center rounded-[8px] border text-[11.5px] font-medium tabular-nums shadow-xs transition-colors duration-150 ${
|
||||
done
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: here
|
||||
? 'border-border bg-background text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground'
|
||||
}`}
|
||||
initial={false}
|
||||
animate={{ scale: here ? 1 : 0.92 }}
|
||||
transition={reduced ? { duration: 0 } : RAIL}
|
||||
>
|
||||
{done ? (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 256 256"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
points="216 72 104 184 48 128"
|
||||
stroke="currentColor"
|
||||
strokeWidth="24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
stepIndex + 1
|
||||
)}
|
||||
</motion.span>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={candidate.id}
|
||||
className="flex flex-1 items-center gap-1 last:flex-none"
|
||||
>
|
||||
{stepIndex <= furthest ? (
|
||||
<button
|
||||
type="button"
|
||||
data-current={here ? 'true' : undefined}
|
||||
tabIndex={here ? 0 : -1}
|
||||
aria-current={here ? 'step' : undefined}
|
||||
aria-label={`Step ${stepIndex + 1} of ${total}: ${candidate.label}`}
|
||||
onKeyDown={onStepKeyDown}
|
||||
onClick={() => {
|
||||
if (here) {
|
||||
return;
|
||||
}
|
||||
|
||||
intent.current = 'list';
|
||||
goTo(stepIndex);
|
||||
}}
|
||||
className="relative rounded-[8px] outline-none after:absolute after:-inset-2 after:content-[''] focus-visible:shadow-[0_0_0_1.5px_var(--ring)]"
|
||||
>
|
||||
{tile}
|
||||
</button>
|
||||
) : (
|
||||
<span>
|
||||
<span className="sr-only">
|
||||
{`Step ${stepIndex + 1} of ${total}: ${candidate.label}`}
|
||||
</span>
|
||||
{tile}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{stepIndex < total - 1 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="relative h-[3px] flex-1 overflow-hidden rounded-[2px] bg-muted shadow-[inset_0_1px_2px_rgb(0_0_0/0.07)]"
|
||||
>
|
||||
<motion.span
|
||||
className="absolute inset-0 origin-left rounded-[2px] bg-foreground"
|
||||
initial={false}
|
||||
animate={{ scaleX: complete || stepIndex < at ? 1 : 0 }}
|
||||
transition={reduced ? { duration: 0 } : RAIL}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
tabIndex={-1}
|
||||
role="group"
|
||||
aria-label={position}
|
||||
style={{ height: fill ? undefined : height }}
|
||||
className={`${fill ? 'min-h-0 flex-1' : ''} relative overflow-hidden border border-border bg-card outline-none transition-[border-color,box-shadow] duration-150 focus-visible:border-ring ${
|
||||
spacious
|
||||
? 'rounded-[24px] shadow-[0_0_0_1px_rgb(255_255_255/0.02),0_18px_50px_rgb(0_0_0/0.12)]'
|
||||
: 'rounded-[11px] shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<AnimatePresence initial={false} custom={direction} mode="wait">
|
||||
<motion.div
|
||||
key={complete ? '__complete' : step.id}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className={`absolute inset-0 h-full w-full overscroll-contain text-foreground ${
|
||||
step.scrollMode === 'content' ? 'overflow-hidden' : 'overflow-y-auto'
|
||||
} ${spacious ? 'p-4 text-sm leading-relaxed sm:p-5 lg:p-6' : 'p-4 text-[13.5px] leading-relaxed'}`}
|
||||
>
|
||||
{complete ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1.5">
|
||||
<p className="text-[13px] font-medium text-foreground">
|
||||
{completeLabel}
|
||||
</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
{completeHint}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<WizardNavigationContext.Provider value={navigation}>
|
||||
{step.content}
|
||||
</WizardNavigationContext.Provider>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{showNavigation ? (
|
||||
<div className={`${spacious ? 'mt-4 h-11' : 'mt-3 h-9'} flex items-center gap-3`}>
|
||||
<AnimatePresence initial={false}>
|
||||
{showBack ? (
|
||||
<motion.button
|
||||
key="back"
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: reduced
|
||||
? { duration: 0 }
|
||||
: { duration: 0.12, ease: EXIT_EASE },
|
||||
}}
|
||||
transition={
|
||||
reduced ? { duration: 0 } : { duration: 0.16, ease: EASE }
|
||||
}
|
||||
onClick={() => {
|
||||
navigation.back();
|
||||
}}
|
||||
className={`${spacious ? 'h-11 px-4 text-sm' : 'h-9 px-3 text-[13px]'} rounded-[9px] border border-border bg-background font-medium text-foreground outline-none transition-[border-color,box-shadow] duration-150 hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40`}
|
||||
>
|
||||
{backLabel}
|
||||
</motion.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{showSkip ? (
|
||||
<motion.button
|
||||
key={`skip-${step.id}`}
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: reduced
|
||||
? { duration: 0 }
|
||||
: { duration: 0.12, ease: EXIT_EASE },
|
||||
}}
|
||||
transition={reduced ? { duration: 0 } : { duration: 0.16, ease: EASE }}
|
||||
onClick={() => {
|
||||
step.onSkip?.();
|
||||
|
||||
if (!isLast) {
|
||||
intent.current = 'panel';
|
||||
}
|
||||
|
||||
next();
|
||||
}}
|
||||
className={`${spacious ? 'h-11 px-4 text-sm' : 'h-9 px-3 text-[13px]'} rounded-[9px] font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40`}
|
||||
>
|
||||
{skipLabel}
|
||||
</motion.button>
|
||||
) : null}
|
||||
<AnimatePresence initial={false}>
|
||||
{showAdvance ? (
|
||||
<motion.button
|
||||
key="advance"
|
||||
type="button"
|
||||
aria-label={isLast ? finishLabel : nextLabel}
|
||||
onClick={() => {
|
||||
navigation.next();
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
transition: reduced
|
||||
? { duration: 0 }
|
||||
: { duration: 0.14, ease: EXIT_EASE },
|
||||
}}
|
||||
transition={reduced ? { duration: 0 } : CROSSFADE}
|
||||
className={`${spacious ? 'h-11 px-4 text-sm' : 'h-9 px-3.5 text-[13px]'} ml-auto grid place-items-center rounded-[9px] bg-foreground font-medium text-background outline-none focus-visible:shadow-[inset_0_0_0_1.5px_var(--ring)]`}
|
||||
>
|
||||
<span aria-hidden className="invisible col-start-1 row-start-1">
|
||||
{finishLabel.length > nextLabel.length
|
||||
? finishLabel
|
||||
: nextLabel}
|
||||
</span>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="col-start-1 row-start-1"
|
||||
initial={false}
|
||||
animate={{ opacity: isLast ? 0 : 1 }}
|
||||
transition={reduced ? { duration: 0 } : CROSSFADE}
|
||||
>
|
||||
{nextLabel}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="col-start-1 row-start-1"
|
||||
initial={false}
|
||||
animate={{ opacity: isLast ? 1 : 0 }}
|
||||
transition={reduced ? { duration: 0 } : CROSSFADE}
|
||||
>
|
||||
{finishLabel}
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { Minimize2 } from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { useDesktopSidebar } from '@/hooks/use-media-query';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useLayout } from '@/contexts/LayoutContext';
|
||||
import { useKiosk } from '@/contexts/KioskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MainLayoutProps {
|
||||
status: string;
|
||||
onLogout: () => void;
|
||||
notice?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MainLayout({ status, onLogout, notice, children }: MainLayoutProps) {
|
||||
const isMobile = !useDesktopSidebar();
|
||||
const { appearance } = useTheme();
|
||||
const customBg = appearance.background.type !== 'none';
|
||||
// Framer's reducedMotion only suppresses transforms, so the always-present
|
||||
// width + page-transition animations need an explicit opt-out to make the
|
||||
// “减少动效” setting actually felt.
|
||||
const reduce = appearance.reduceMotion || appearance.disableMotion;
|
||||
const { editing } = useLayout();
|
||||
const { kiosk, exit: exitKiosk } = useKiosk();
|
||||
// The sidebar rests as a slim icon rail and auto-expands on hover/focus (the
|
||||
// peek logic below) — there's no collapse button. Two cases force it fully
|
||||
// expanded: layout-edit mode (the drag-to-reorder list needs room) and the
|
||||
// 「钉住侧栏展开」 appearance pref (operator opted out of the rail entirely).
|
||||
const effectiveCollapsed = !editing && !appearance.sidebarPinned;
|
||||
// Hover/focus peek: while collapsed, hovering (or keyboard-focusing) the rail
|
||||
// temporarily expands it. The rail lives in the flex flow, so the page
|
||||
// content is pushed right rather than overlaid. The pinned `collapsed` pref
|
||||
// (and the TopBar toggle that reflects it) is untouched — this is transient.
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const peeking = hovered || focused;
|
||||
const showCollapsed = effectiveCollapsed && !peeking;
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-dvh w-full max-w-full overflow-hidden text-foreground',
|
||||
customBg ? 'bg-transparent' : 'bg-sidebar',
|
||||
)}
|
||||
>
|
||||
{/* Desktop sidebar (hidden in kiosk) */}
|
||||
{!isMobile && !kiosk && (
|
||||
<motion.aside
|
||||
data-ui-surface="sidebar"
|
||||
initial={false}
|
||||
animate={{ width: showCollapsed ? 64 : 248 }}
|
||||
transition={
|
||||
reduce ? { duration: 0 } : { duration: 0.26, ease: [0.4, 0, 0.1, 1] }
|
||||
}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocusCapture={(e) => {
|
||||
// Only *keyboard* focus (focus-visible) peeks the rail open. A mouse
|
||||
// click on a nav link also focuses it, but must NOT peek — that
|
||||
// click-focus lingers and never blurs when the pointer moves to the
|
||||
// content, so the rail would stay stuck open after navigating.
|
||||
if ((e.target as HTMLElement).matches?.(':focus-visible')) {
|
||||
setFocused(true);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setFocused(false);
|
||||
}
|
||||
}}
|
||||
className="relative h-full shrink-0 overflow-hidden"
|
||||
>
|
||||
{/* Fixed-width inner: the rail's content is ALWAYS laid out at the
|
||||
expanded width and the aside simply clips it as `width` animates.
|
||||
Nothing reflows during the transition, so icons never shift and
|
||||
labels never wrap — they're revealed/clipped, not re-laid-out. */}
|
||||
<div className="h-full w-[248px]">
|
||||
<Sidebar collapsed={showCollapsed} />
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar in sheet (hidden in kiosk) */}
|
||||
{isMobile && !kiosk && (
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showClose={false}
|
||||
className="w-64 max-w-[80vw] p-0 overscroll-contain"
|
||||
>
|
||||
{/* Radix Dialog (the Sheet primitive) requires an accessible name +
|
||||
description; the nav itself is the visible content, so these are
|
||||
screen-reader-only. */}
|
||||
<SheetTitle className="sr-only">导航菜单</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
切换 SnowLuma 控制台的页面。
|
||||
</SheetDescription>
|
||||
<Sidebar onItemClick={() => setMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Kiosk: chrome-free, with a corner button (+ Esc) to exit. */}
|
||||
{kiosk && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitKiosk}
|
||||
title="退出展示模式 (Esc)"
|
||||
aria-label="退出展示模式"
|
||||
className="fixed z-50 inline-flex size-9 items-center justify-center rounded-full border bg-background text-foreground opacity-70 transition-opacity outline-none hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
||||
style={{
|
||||
top: 'max(0.75rem, env(safe-area-inset-top))',
|
||||
right: 'max(0.75rem, env(safe-area-inset-right))',
|
||||
}}
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main column — a content "canvas" nested into the chrome. The rounded
|
||||
top-left corner only exists when the desktop rail is beside it; on
|
||||
mobile the rail becomes a sheet, so a leftover corner would float
|
||||
against the full-bleed canvas. It's a flex sibling of the rail, so as
|
||||
the rail expands/collapses the whole canvas is pushed along with it.
|
||||
Depth comes from a soft shadow + the canvas tone sitting on the
|
||||
(sidebar-toned) chrome, not a border. */}
|
||||
<div
|
||||
data-ui-surface="canvas"
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col overflow-hidden',
|
||||
!isMobile && !kiosk && 'rounded-tl-2xl',
|
||||
!customBg && 'bg-background shadow-[0_0_18px_-6px_rgb(0_0_0/0.14)]',
|
||||
)}
|
||||
>
|
||||
{!kiosk && (
|
||||
<TopBar
|
||||
status={status}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileOpenChange={setMobileOpen}
|
||||
onLogout={onLogout}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!kiosk && notice}
|
||||
|
||||
<main className={cn('flex min-h-0 flex-1 flex-col')}>
|
||||
<ScrollArea className="flex-1 min-h-0" viewportClassName="[&>div]:!block">
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-5 sm:px-6 sm:py-6 lg:px-8 2xl:max-w-[1600px]">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={reduce ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0 }
|
||||
: { duration: 0.22, ease: [0.16, 1, 0.3, 1] }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
Bug,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
LayoutDashboard,
|
||||
Lock,
|
||||
Pin,
|
||||
PinOff,
|
||||
PlugZap,
|
||||
Settings,
|
||||
Sparkles,
|
||||
SlidersHorizontal,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { motion, Reorder, useDragControls } from 'motion/react';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { APP_NAME, APP_VERSION } from '@/types';
|
||||
import { useAppState } from '@/contexts/AppStateContext';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { reconcileLayoutItems, useLayout } from '@/contexts/LayoutContext';
|
||||
import { useDesktopSidebar } from '@/hooks/use-media-query';
|
||||
import type { AppPath } from '@/router';
|
||||
import type { UiLayoutItem } from '@/types';
|
||||
|
||||
export interface NavItem {
|
||||
to: AppPath;
|
||||
label: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: '总览', icon: LayoutDashboard, description: '主机与服务状态' },
|
||||
{ to: '/processes', label: '进程注入', icon: PlugZap, description: '加载 / 卸载 / 登录' },
|
||||
{ to: '/config', label: '节点配置', icon: Settings, description: 'OneBot 协议端点' },
|
||||
{ to: '/logs', label: '日志', icon: Terminal, description: '实时事件流' },
|
||||
{ to: '/debug', label: '调试', icon: Bug, description: '测试台与实时活动' },
|
||||
{ to: '/settings', label: '系统设置', icon: SlidersHorizontal, description: '主题与账号' },
|
||||
];
|
||||
|
||||
// Anti-self-lock: these nav items can be reordered but never hidden.
|
||||
// '/' — hosts the 「编辑布局」 entry point; hiding it would strand the
|
||||
// user with no way back to un-hide anything.
|
||||
// '/settings'— account + appearance.
|
||||
export const PINNED_NAV: AppPath[] = ['/', '/settings'];
|
||||
|
||||
function NavReorderRow({
|
||||
item,
|
||||
meta,
|
||||
onToggle,
|
||||
}: {
|
||||
item: UiLayoutItem;
|
||||
meta: NavItem;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const controls = useDragControls();
|
||||
const Icon = meta.icon;
|
||||
const itemPinned = (PINNED_NAV as string[]).includes(item.id);
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={item.id}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
className={cn(
|
||||
'flex select-none items-center gap-2 rounded-lg bg-sidebar-accent/40 px-2 py-2',
|
||||
!item.visible && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖动排序"
|
||||
className="inline-flex size-8 shrink-0 touch-none items-center justify-center text-muted-foreground"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{meta.label}</span>
|
||||
{itemPinned ? (
|
||||
<span
|
||||
title="必选项,不可隐藏"
|
||||
className="inline-flex size-8 items-center justify-center text-muted-foreground/50"
|
||||
>
|
||||
<Lock className="size-3.5" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(item.id)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title={item.visible ? '隐藏' : '显示'}
|
||||
aria-label={item.visible ? '隐藏' : '显示'}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent/50 hover:text-foreground cursor-pointer"
|
||||
>
|
||||
{item.visible ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed?: boolean;
|
||||
onItemClick?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed = false, onItemClick }: SidebarProps) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const { updateInfo } = useAppState();
|
||||
const { navItems, setNavItems, editing, setEditing } = useLayout();
|
||||
const { appearance, setAppearance } = useTheme();
|
||||
const isDesktop = useDesktopSidebar();
|
||||
const pinned = appearance.sidebarPinned;
|
||||
|
||||
// Full reconciled nav (incl. hidden) — pinned forced visible, forward-compat.
|
||||
const reconciled = reconcileLayoutItems(
|
||||
navItems,
|
||||
NAV_ITEMS.map((i) => i.to),
|
||||
PINNED_NAV,
|
||||
);
|
||||
// View mode: configured order, hidden removed.
|
||||
const orderedNav = reconciled
|
||||
.filter((i) => i.visible)
|
||||
.map((i) => NAV_ITEMS.find((n) => n.to === i.id))
|
||||
.filter((n): n is NavItem => !!n);
|
||||
|
||||
const reorderNav = (ids: string[]) => {
|
||||
const byId = new Map(reconciled.map((i) => [i.id, i]));
|
||||
setNavItems(ids.map((id) => byId.get(id)).filter((x): x is NonNullable<typeof x> => !!x));
|
||||
};
|
||||
|
||||
const toggleNav = (id: string) =>
|
||||
setNavItems(reconciled.map((i) => (i.id === id ? { ...i, visible: !i.visible } : i)));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-ui-surface="chrome"
|
||||
className="flex h-full w-full flex-col overflow-hidden bg-sidebar text-sidebar-foreground"
|
||||
>
|
||||
{/* Brand — logo sits in a fixed 48px column centred in the collapsed
|
||||
rail; the wordmark to its right is clipped (never unmounted) when the
|
||||
rail narrows. */}
|
||||
<div className="flex h-16 shrink-0 items-center px-2">
|
||||
<div className="grid w-12 shrink-0 place-items-center">
|
||||
<div className="relative flex size-9 items-center justify-center overflow-hidden rounded-xl bg-primary/10 ring-1 ring-primary/20">
|
||||
<img src="/logo.png" alt="SnowLuma" className="size-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 overflow-hidden pr-2 transition-opacity duration-200',
|
||||
collapsed ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span className="text-sm font-bold tracking-tight">{APP_NAME}</span>
|
||||
<span className="text-micro font-medium text-muted-foreground tabular-nums">
|
||||
v{APP_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
<span className="block whitespace-nowrap text-xs text-muted-foreground">
|
||||
OneBot v11 控制台
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<ScrollArea className="flex-1 min-h-0" viewportClassName="[&>div]:!block">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
编辑导航
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/15 px-2 py-0.5 text-xs text-primary transition-colors hover:bg-primary/20 cursor-pointer"
|
||||
>
|
||||
<Check className="size-3" /> 完成
|
||||
</button>
|
||||
</div>
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={reconciled.map((i) => i.id)}
|
||||
onReorder={reorderNav}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{reconciled.map((item) => {
|
||||
const meta = NAV_ITEMS.find((n) => n.to === item.id);
|
||||
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavReorderRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
meta={meta}
|
||||
onToggle={toggleNav}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
) : (
|
||||
<nav data-collapsed={collapsed} className="flex flex-col gap-1 px-2 py-2">
|
||||
{orderedNav.map(({ to, label, icon: Icon, description }) => {
|
||||
const isActive = pathname === to;
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
title={collapsed ? label : undefined}
|
||||
onClick={onItemClick}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'ui-nav-item group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer outline-none',
|
||||
isActive
|
||||
? 'text-sidebar-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent/40 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<>
|
||||
{/* Highlight geometry is the ONLY thing that switches on
|
||||
collapse: a full-row pill when open, a centred squircle
|
||||
on the rail. Both are absolutely positioned, so they
|
||||
morph (layout) without reflowing the row. */}
|
||||
<motion.span
|
||||
layoutId="sidebar-active-pill"
|
||||
className={cn(
|
||||
'ui-nav-highlight absolute inset-y-0 rounded-lg bg-sidebar-accent',
|
||||
collapsed ? 'left-0.5 w-11' : 'inset-x-0',
|
||||
)}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 380,
|
||||
damping: 32,
|
||||
}}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
layoutId="sidebar-active-bar"
|
||||
className="ui-nav-highlight absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-primary"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 380,
|
||||
damping: 32,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className="relative z-10 grid w-12 shrink-0 place-items-center">
|
||||
<Icon
|
||||
className={cn('size-4', isActive && 'text-primary')}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3 transition-opacity duration-200',
|
||||
collapsed ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate whitespace-nowrap leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
<span className="w-full truncate whitespace-nowrap text-xs font-normal text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{updateInfo?.hasUpdate && (
|
||||
<div className="shrink-0 px-2 pb-1">
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{ tab: 'about' }}
|
||||
onClick={onItemClick}
|
||||
title={
|
||||
collapsed
|
||||
? updateInfo.latest
|
||||
? `有新版本 v${updateInfo.latest} · 点击查看`
|
||||
: '有可用更新'
|
||||
: undefined
|
||||
}
|
||||
aria-label="有可用更新"
|
||||
className="group relative flex items-center py-2"
|
||||
>
|
||||
{/* Background as an absolute layer so the collapsed rail shows a
|
||||
centred squircle, not a full-width bar cut off with a hard edge. */}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-y-0 rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/[0.15]',
|
||||
collapsed ? 'left-0.5 w-11' : 'inset-x-0',
|
||||
)}
|
||||
/>
|
||||
<span className="relative z-10 grid w-12 shrink-0 place-items-center">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative z-10 flex min-w-0 flex-1 flex-col overflow-hidden pr-3 transition-opacity duration-200',
|
||||
collapsed ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<span className="truncate whitespace-nowrap text-xs font-medium leading-tight text-foreground">
|
||||
有新版本可用
|
||||
</span>
|
||||
<span className="truncate whitespace-nowrap text-xs text-muted-foreground">
|
||||
v{updateInfo.latest} · 点击查看
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: © + the pin toggle (desktop only). Pinning keeps the rail
|
||||
expanded; unpinning returns it to the hover-to-peek rail. Mirrors the
|
||||
「钉住侧栏展开」 appearance setting. */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-2 py-3 pl-4 pr-2.5 transition-opacity duration-200',
|
||||
collapsed ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate whitespace-nowrap text-micro text-muted-foreground">
|
||||
© {new Date().getFullYear()} SnowLuma
|
||||
</span>
|
||||
{isDesktop && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAppearance({ sidebarPinned: !pinned })}
|
||||
title={pinned ? '取消钉住(恢复悬停展开)' : '钉住侧栏(保持展开)'}
|
||||
aria-label={pinned ? '取消钉住侧栏' : '钉住侧栏'}
|
||||
aria-pressed={pinned}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
className={cn(
|
||||
'inline-flex size-7 shrink-0 items-center justify-center rounded-md transition-colors cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40',
|
||||
pinned
|
||||
? 'bg-primary/10 text-primary hover:bg-primary/15'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{pinned ? <Pin className="size-3.5" /> : <PinOff className="size-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import { LogOut, Monitor } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { IconMorph } from '@/components/interior/icon-morph';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { NAV_ITEMS } from '@/components/layout/sidebar';
|
||||
import { reconcileLayoutItems, useLayout } from '@/contexts/LayoutContext';
|
||||
import { useKiosk } from '@/contexts/KioskContext';
|
||||
|
||||
// Toggleable top-bar elements (the menu/title/logout are essential and always
|
||||
// render). Labels drive the settings toggles; ids match `topbarItems`.
|
||||
export const TOPBAR_CATALOGUE: { id: string; label: string }[] = [
|
||||
{ id: 'status', label: '连接状态徽章' },
|
||||
{ id: 'theme', label: '主题切换按钮' },
|
||||
{ id: 'kiosk', label: '展示模式按钮' },
|
||||
];
|
||||
|
||||
interface TopBarProps {
|
||||
status: string;
|
||||
mobileOpen: boolean;
|
||||
onMobileOpenChange: (open: boolean) => void;
|
||||
onLogout: () => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
status,
|
||||
mobileOpen,
|
||||
onMobileOpenChange,
|
||||
onLogout,
|
||||
isMobile,
|
||||
}: TopBarProps) {
|
||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const meta = NAV_ITEMS.find((n) => n.to === pathname);
|
||||
const PageIcon = meta?.icon;
|
||||
const online = status === '已连接';
|
||||
|
||||
// Which optional top-bar elements the operator has kept (reconciled against
|
||||
// the live catalogue, so a new element defaults to shown).
|
||||
const { topbarItems } = useLayout();
|
||||
const { enter: enterKiosk } = useKiosk();
|
||||
const shown = new Set(
|
||||
reconcileLayoutItems(
|
||||
topbarItems,
|
||||
TOPBAR_CATALOGUE.map((t) => t.id),
|
||||
)
|
||||
.filter((i) => i.visible)
|
||||
.map((i) => i.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
data-ui-surface="chrome"
|
||||
className="z-30 flex min-h-16 shrink-0 items-center gap-[4px] bg-background px-[max(10px,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] pt-[env(safe-area-inset-top)] md:gap-2 md:px-4"
|
||||
>
|
||||
{/* Mobile-only menu trigger. On desktop there's no collapse button — the
|
||||
sidebar auto-expands on hover/focus, and its boundary with the content
|
||||
is a soft surface-tone shift, not a hard border. */}
|
||||
{isMobile && (
|
||||
<IconMorph
|
||||
preset="menu-close"
|
||||
labels={['打开菜单', '关闭菜单']}
|
||||
semantics="expanded"
|
||||
active={mobileOpen}
|
||||
onActiveChange={(index) => onMobileOpenChange(index === 1)}
|
||||
className="ui-button ui-button-icon topbar-control cursor-pointer border-0 bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40 dark:border-0 dark:bg-transparent dark:text-foreground dark:focus-visible:ring-ring/40"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Page title */}
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 8 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
{PageIcon && (
|
||||
<PageIcon className="hidden size-4 shrink-0 text-primary min-[400px]:block" />
|
||||
)}
|
||||
<h1
|
||||
className="truncate text-sm font-semibold tracking-tight"
|
||||
title={meta?.label}
|
||||
>
|
||||
{meta?.label}
|
||||
</h1>
|
||||
<span className="hidden md:inline truncate text-xs text-muted-foreground">
|
||||
{meta?.description}
|
||||
</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-[2px] md:gap-2">
|
||||
{shown.has('status') && (
|
||||
<Badge
|
||||
variant={online ? 'success' : 'destructive'}
|
||||
className="hidden md:inline-flex gap-1.5"
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${online ? 'bg-success' : 'bg-destructive'} ${online ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{shown.has('theme') && <ThemeToggle />}
|
||||
|
||||
{shown.has('kiosk') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={enterKiosk}
|
||||
aria-label="展示模式"
|
||||
title="展示模式(隐藏侧栏与顶栏,Esc 退出)"
|
||||
className="topbar-control max-[379px]:hidden text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Monitor className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setConfirmLogout(true)}
|
||||
aria-label="登出"
|
||||
title="登出"
|
||||
className="topbar-control text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmLogout}
|
||||
onOpenChange={setConfirmLogout}
|
||||
title="确认登出?"
|
||||
description="登出后将清除当前会话令牌,您需要重新输入访问密码才能进入控制台。"
|
||||
confirmText="登出"
|
||||
destructive
|
||||
activity={{
|
||||
title: '正在退出登录',
|
||||
successTitle: '已退出登录',
|
||||
errorTitle: '退出登录失败',
|
||||
}}
|
||||
onConfirm={onLogout}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// ── Perlin noise (perlin2) — compact port of Joseph Gentle's noisejs (public domain) ──
|
||||
class Grad {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
constructor(x: number, y: number, z: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
dot2(x: number, y: number) {
|
||||
return this.x * x + this.y * y;
|
||||
}
|
||||
}
|
||||
|
||||
const GRAD3 = [
|
||||
new Grad(1, 1, 0),
|
||||
new Grad(-1, 1, 0),
|
||||
new Grad(1, -1, 0),
|
||||
new Grad(-1, -1, 0),
|
||||
new Grad(1, 0, 1),
|
||||
new Grad(-1, 0, 1),
|
||||
new Grad(1, 0, -1),
|
||||
new Grad(-1, 0, -1),
|
||||
new Grad(0, 1, 1),
|
||||
new Grad(0, -1, 1),
|
||||
new Grad(0, 1, -1),
|
||||
new Grad(0, -1, -1),
|
||||
];
|
||||
|
||||
const P = [
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69,
|
||||
142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219,
|
||||
203, 117, 35, 11, 32, 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175,
|
||||
74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230,
|
||||
220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76,
|
||||
132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,
|
||||
3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59,
|
||||
227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70,
|
||||
221, 153, 101, 155, 167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178,
|
||||
185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81,
|
||||
51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115,
|
||||
121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195,
|
||||
78, 66, 215, 61, 156, 180,
|
||||
];
|
||||
|
||||
class Noise {
|
||||
private perm = new Array<number>(512);
|
||||
private gradP = new Array<Grad>(512);
|
||||
constructor(seed: number) {
|
||||
this.seed(seed);
|
||||
}
|
||||
|
||||
seed(seed: number) {
|
||||
if (seed > 0 && seed < 1) {
|
||||
seed *= 65536;
|
||||
}
|
||||
|
||||
seed = Math.floor(seed);
|
||||
|
||||
if (seed < 256) {
|
||||
seed |= seed << 8;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const v = i & 1 ? P[i] ^ (seed & 255) : P[i] ^ ((seed >> 8) & 255);
|
||||
this.perm[i] = this.perm[i + 256] = v;
|
||||
this.gradP[i] = this.gradP[i + 256] = GRAD3[v % 12];
|
||||
}
|
||||
}
|
||||
|
||||
private fade(t: number) {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
private lerp(a: number, b: number, t: number) {
|
||||
return (1 - t) * a + t * b;
|
||||
}
|
||||
|
||||
perlin2(x: number, y: number) {
|
||||
let X = Math.floor(x),
|
||||
Y = Math.floor(y);
|
||||
x -= X;
|
||||
y -= Y;
|
||||
X &= 255;
|
||||
Y &= 255;
|
||||
const n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
|
||||
const n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
|
||||
const n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
|
||||
const n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
|
||||
const u = this.fade(x);
|
||||
return this.lerp(this.lerp(n00, n10, u), this.lerp(n01, n11, u), this.fade(y));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wavy-lines background (ported from the a-waves effect) ──
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
wave: { x: number; y: number };
|
||||
cursor: { x: number; y: number; vx: number; vy: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative, mouse-reactive wavy-line field rendered as SVG paths and animated
|
||||
* via requestAnimationFrame. Purely presentational (aria-hidden, pointer-events
|
||||
* none) so it sits behind interactive content. Heavy on low-end devices — the
|
||||
* login page lets the user disable it (a device-local preference).
|
||||
*/
|
||||
export function LoginWaves() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const svg = svgRef.current;
|
||||
|
||||
if (!host || !svg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noise = new Noise(Math.random());
|
||||
const mouse = { x: -10, y: 0, lx: 0, ly: 0, sx: 0, sy: 0, v: 0, vs: 0, a: 0, set: false };
|
||||
let bounding = host.getBoundingClientRect();
|
||||
let lines: Pt[][] = [];
|
||||
let paths: SVGPathElement[] = [];
|
||||
let raf = 0;
|
||||
// Honour reduced-motion (the OS setting or the app's own data-reduce-motion
|
||||
// toggle): draw a single static frame instead of running the rAF loop. #185.
|
||||
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const reduced = () =>
|
||||
prefersReduced.matches || document.documentElement.dataset.reduceMotion === '1';
|
||||
|
||||
const setSize = () => {
|
||||
bounding = host.getBoundingClientRect();
|
||||
svg.style.width = `${bounding.width}px`;
|
||||
svg.style.height = `${bounding.height}px`;
|
||||
};
|
||||
|
||||
const setLines = () => {
|
||||
const { width, height } = bounding;
|
||||
lines = [];
|
||||
paths.forEach((p) => p.remove());
|
||||
paths = [];
|
||||
|
||||
const xGap = 10;
|
||||
const yGap = 32;
|
||||
const oWidth = width + 200;
|
||||
const oHeight = height + 30;
|
||||
const totalLines = Math.ceil(oWidth / xGap);
|
||||
const totalPoints = Math.ceil(oHeight / yGap);
|
||||
const xStart = (width - xGap * totalLines) / 2;
|
||||
const yStart = (height - yGap * totalPoints) / 2;
|
||||
|
||||
for (let i = 0; i <= totalLines; i++) {
|
||||
const points: Pt[] = [];
|
||||
|
||||
for (let j = 0; j <= totalPoints; j++) {
|
||||
points.push({
|
||||
x: xStart + xGap * i,
|
||||
y: yStart + yGap * j,
|
||||
wave: { x: 0, y: 0 },
|
||||
cursor: { x: 0, y: 0, vx: 0, vy: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
svg.appendChild(path);
|
||||
paths.push(path);
|
||||
lines.push(points);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMouse = (x: number, y: number) => {
|
||||
// pageX/pageY are already document-relative, so don't re-add scrollY.
|
||||
mouse.x = x - bounding.left;
|
||||
mouse.y = y - bounding.top;
|
||||
|
||||
if (!mouse.set) {
|
||||
mouse.sx = mouse.x;
|
||||
mouse.sy = mouse.y;
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.set = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => updateMouse(e.pageX, e.pageY);
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
const t = e.touches[0];
|
||||
|
||||
if (t) {
|
||||
updateMouse(t.clientX, t.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
setSize();
|
||||
setLines();
|
||||
|
||||
if (reduced()) {
|
||||
movePoints(0);
|
||||
drawLines();
|
||||
}
|
||||
};
|
||||
|
||||
const movePoints = (time: number) => {
|
||||
lines.forEach((points) => {
|
||||
points.forEach((p) => {
|
||||
const move =
|
||||
noise.perlin2(
|
||||
(p.x + time * 0.0125) * 0.002,
|
||||
(p.y + time * 0.005) * 0.0015,
|
||||
) * 12;
|
||||
p.wave.x = Math.cos(move) * 32;
|
||||
p.wave.y = Math.sin(move) * 16;
|
||||
|
||||
const dx = p.x - mouse.sx;
|
||||
const dy = p.y - mouse.sy;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const l = Math.max(175, mouse.vs);
|
||||
|
||||
if (d < l) {
|
||||
const s = 1 - d / l;
|
||||
const f = Math.cos(d * 0.001) * s;
|
||||
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065;
|
||||
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065;
|
||||
}
|
||||
|
||||
p.cursor.vx += (0 - p.cursor.x) * 0.005;
|
||||
p.cursor.vy += (0 - p.cursor.y) * 0.005;
|
||||
p.cursor.vx *= 0.925;
|
||||
p.cursor.vy *= 0.925;
|
||||
p.cursor.x += p.cursor.vx * 2;
|
||||
p.cursor.y += p.cursor.vy * 2;
|
||||
p.cursor.x = Math.min(100, Math.max(-100, p.cursor.x));
|
||||
p.cursor.y = Math.min(100, Math.max(-100, p.cursor.y));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const moved = (point: Pt, withCursor: boolean) => ({
|
||||
x: Math.round((point.x + point.wave.x + (withCursor ? point.cursor.x : 0)) * 10) / 10,
|
||||
y: Math.round((point.y + point.wave.y + (withCursor ? point.cursor.y : 0)) * 10) / 10,
|
||||
});
|
||||
|
||||
const drawLines = () => {
|
||||
lines.forEach((points, lIndex) => {
|
||||
const first = moved(points[0], false);
|
||||
let d = `M ${first.x} ${first.y}`;
|
||||
points.forEach((p, pIndex) => {
|
||||
const isLast = pIndex === points.length - 1;
|
||||
const m = moved(p, !isLast);
|
||||
d += `L ${m.x} ${m.y}`;
|
||||
});
|
||||
paths[lIndex].setAttribute('d', d);
|
||||
});
|
||||
};
|
||||
|
||||
const tick = (time: number) => {
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
||||
const dx = mouse.x - mouse.lx;
|
||||
const dy = mouse.y - mouse.ly;
|
||||
const d = Math.hypot(dx, dy);
|
||||
mouse.v = d;
|
||||
mouse.vs += (d - mouse.vs) * 0.1;
|
||||
mouse.vs = Math.min(100, mouse.vs);
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.a = Math.atan2(dy, dx);
|
||||
movePoints(time);
|
||||
drawLines();
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
setSize();
|
||||
setLines();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
|
||||
// Animate only when motion is allowed; otherwise paint one static frame.
|
||||
// React live to changes of the OS reduced-motion preference.
|
||||
const applyMotion = () => {
|
||||
if (reduced()) {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
|
||||
movePoints(0);
|
||||
drawLines();
|
||||
} else if (!raf) {
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
prefersReduced.addEventListener('change', applyMotion);
|
||||
applyMotion();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
prefersReduced.removeEventListener('change', applyMotion);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
paths.forEach((p) => p.remove());
|
||||
paths = [];
|
||||
lines = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
aria-hidden
|
||||
className="login-waves pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<svg ref={svgRef} className="block size-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Modal } from '@/components/interior/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
LOW_INTEGRITY_PERMANENT_KEY,
|
||||
readLowIntegrityDismissed,
|
||||
saveLowIntegrityDismissed,
|
||||
} from '@/lib/low-integrity-warning';
|
||||
|
||||
/** Mounted in the authenticated layout so polling and route changes cannot reopen it. */
|
||||
export function LowIntegrityWarning({ lowIntegrity }: { lowIntegrity: boolean }) {
|
||||
const [dismissed, setDismissed] = useState(readLowIntegrityDismissed);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const temporaryButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === LOW_INTEGRITY_PERMANENT_KEY && event.newValue === '1') {
|
||||
setDismissed(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const dismiss = (scope: 'session' | 'permanent') => {
|
||||
try {
|
||||
saveLowIntegrityDismissed(scope);
|
||||
} catch (caught) {
|
||||
console.warn('failed to save low integrity warning preference', caught);
|
||||
|
||||
if (scope === 'permanent') {
|
||||
setError('无法保存设置,请允许浏览器存储或选择临时关闭。');
|
||||
return;
|
||||
}
|
||||
// Even with storage blocked, temporary dismissal must let the user continue.
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={lowIntegrity && !dismissed}
|
||||
onClose={() => dismiss('session')}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-warning" />
|
||||
Node 完整性过低
|
||||
</span>
|
||||
}
|
||||
showClose={false}
|
||||
closeOnBackdrop={false}
|
||||
initialFocusRef={temporaryButton}
|
||||
maxWidth={480}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => dismiss('permanent')}>
|
||||
永久关闭
|
||||
</Button>
|
||||
<Button ref={temporaryButton} type="button" onClick={() => dismiss('session')}>
|
||||
临时关闭
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
当前进程可能无法连接
|
||||
QQ。请检查启动程序的完整性标签,修正后重启。详情见启动日志。
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
临时关闭仅对当前标签页会话有效;永久关闭保存在此浏览器。
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Overview widget: the notification subsystem's in-memory delivery history
|
||||
// (most-recent-first, lost on restart). Opt-in (hidden by default); polls
|
||||
// /api/notifications/recent. No per-widget config.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { startPolling } from '@/lib/polling';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import type { NotificationDeliveryRecord } from '@/types';
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
export function DeliveriesWidget() {
|
||||
const api = useApi();
|
||||
const { formatClock } = useTheme();
|
||||
const [records, setRecords] = useState<NotificationDeliveryRecord[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const stop = startPolling(async () => {
|
||||
try {
|
||||
const records = await api.notifications.recent(50);
|
||||
|
||||
if (active) {
|
||||
setLoadError(null);
|
||||
setRecords(records);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('load recent notification deliveries failed', error);
|
||||
|
||||
if (active) {
|
||||
setLoadError(error instanceof Error ? error.message : '加载最近投递失败');
|
||||
setRecords((prev) => prev ?? []);
|
||||
}
|
||||
}
|
||||
}, POLL_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
stop();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col">
|
||||
<CardHeader className="flex flex-row items-center gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="size-4 text-primary" /> 最近投递
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 overflow-auto">
|
||||
<SkeletonSwap
|
||||
ready={records !== null}
|
||||
reserve={120}
|
||||
lines={5}
|
||||
lineHeight={24}
|
||||
label="最近投递"
|
||||
className={records !== null ? 'skeleton-swap-fluid min-h-[120px]' : ''}
|
||||
>
|
||||
{records !== null ? (
|
||||
loadError && records.length === 0 ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-8 text-center text-sm text-destructive"
|
||||
>
|
||||
{loadError}
|
||||
</p>
|
||||
) : records.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed py-10 text-muted-foreground">
|
||||
<Bell className="size-8 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">暂无投递记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 text-meta">
|
||||
{loadError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mb-1 rounded-lg border border-destructive/30 bg-destructive/10 px-2 py-1.5 font-sans text-xs text-destructive"
|
||||
>
|
||||
{loadError};当前显示上次成功读取的投递记录。
|
||||
</p>
|
||||
)}
|
||||
{records.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-accent/30"
|
||||
>
|
||||
{r.ok ? (
|
||||
<CheckCircle2 className="size-3.5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
|
||||
{formatClock(r.time)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 font-medium',
|
||||
r.event === 'online'
|
||||
? 'text-emerald-600'
|
||||
: 'text-amber-600',
|
||||
)}
|
||||
>
|
||||
{r.event === 'online' ? '上线' : '下线'}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono">{r.uin}</span>
|
||||
<span
|
||||
className="ml-auto min-w-0 truncate text-muted-foreground"
|
||||
title={r.error ?? r.channelId}
|
||||
>
|
||||
{r.channelId}
|
||||
{!r.ok && r.error && (
|
||||
<span className="text-xs text-destructive">
|
||||
{' '}
|
||||
· {r.error}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InlineValidation } from '@/components/interior/inline-validation';
|
||||
import {
|
||||
PasswordStrength,
|
||||
type PasswordRule as InteriorPasswordRule,
|
||||
} from '@/components/interior/password-strength';
|
||||
import { PasswordVisibilityIcon } from '@/components/ui/password-visibility-icon';
|
||||
import { actionErrorMessage, useActionFeedback } from '@/contexts/ActionFeedbackContext';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface PasswordRule {
|
||||
id: string;
|
||||
label: string;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_RULES: readonly PasswordRule[] = [
|
||||
{ id: 'length', label: '长度不少于 10 位', ok: false },
|
||||
{ id: 'lower', label: '包含小写字母', ok: false },
|
||||
{ id: 'upper', label: '包含大写字母', ok: false },
|
||||
{ id: 'special', label: '包含特殊符号 (!@#$%…)', ok: false },
|
||||
{ id: 'no-space', label: '不包含空格', ok: false },
|
||||
];
|
||||
|
||||
const STRENGTH_LABELS = ['未输入', '很弱', '较弱', '一般', '良好', '强'] as const;
|
||||
|
||||
/** Shown under the new-password field only while the animated rule list is hidden. */
|
||||
export function passwordRequirementHint(options: {
|
||||
focused: boolean;
|
||||
value: string;
|
||||
checked: boolean;
|
||||
valid: boolean;
|
||||
rules: readonly PasswordRule[];
|
||||
checkError: string;
|
||||
}): string | null {
|
||||
if (options.focused || options.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.checkError) {
|
||||
return `密码强度校验失败:${options.checkError}`;
|
||||
}
|
||||
|
||||
if (!options.checked || options.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unmet = options.rules.filter((rule) => !rule.ok);
|
||||
|
||||
if (unmet.length === 0) {
|
||||
return '密码尚未满足全部要求';
|
||||
}
|
||||
|
||||
return `仍需满足:${unmet.map((rule) => rule.label).join('、')}`;
|
||||
}
|
||||
|
||||
export interface ChangePasswordFormProps {
|
||||
/** Selects whether the form changes credentials or only rehearses validation. */
|
||||
mode?: 'change' | 'rehearsal';
|
||||
/**
|
||||
* When provided, the "current password" field is omitted entirely and this
|
||||
* value is used as the old password.
|
||||
*
|
||||
* Used by the forced first-time flow: the old password is *exactly* what
|
||||
* the user just logged in with, so we fill it ourselves. Rendering the
|
||||
* field at all let Edge/Chrome password-managers autofill it on upgrade —
|
||||
* users saw it pre-filled, assumed it was correct, and could never save
|
||||
* (the autofilled value was stale / not the temp password). Dropping the
|
||||
* field removes the trap entirely.
|
||||
*/
|
||||
knownOldPassword?: string;
|
||||
/** Sends `{ password }`; returns the rule list + valid flag. */
|
||||
checkStrength: (password: string) => Promise<{ rules: PasswordRule[]; valid: boolean }>;
|
||||
/** Sends `{ oldPassword, newPassword }`. Returns success or error message. */
|
||||
submit: (
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
) => Promise<{ success: boolean; message?: string }>;
|
||||
/** Called after a successful submit. */
|
||||
onSuccess: () => void;
|
||||
/** When provided, a cancel button is shown (used by the settings dialog). */
|
||||
onCancel?: () => void;
|
||||
/** Disambiguates input ids if two instances ever mount at once. */
|
||||
idPrefix?: string;
|
||||
submitLabel?: string;
|
||||
className?: string;
|
||||
renderActions?: (state: {
|
||||
canSubmit: boolean;
|
||||
/** Fields are filled enough to try saving; strength may still be unmet. */
|
||||
canAttempt: boolean;
|
||||
submitting: boolean;
|
||||
submitLabel: string;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
export function ChangePasswordForm({
|
||||
mode = 'change',
|
||||
knownOldPassword,
|
||||
checkStrength,
|
||||
submit,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
idPrefix = 'cpw',
|
||||
submitLabel = '保存新密码',
|
||||
className,
|
||||
renderActions,
|
||||
}: ChangePasswordFormProps) {
|
||||
const rehearsing = mode === 'rehearsal';
|
||||
const carriesOld = rehearsing || knownOldPassword !== undefined;
|
||||
const { runAction } = useActionFeedback();
|
||||
const { appearance } = useTheme();
|
||||
const reduceMotion = appearance.reduceMotion || appearance.disableMotion;
|
||||
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showOld, setShowOld] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newPasswordFocused, setNewPasswordFocused] = useState(false);
|
||||
const [strengthResult, setStrengthResult] = useState<{
|
||||
password: string;
|
||||
rules: PasswordRule[];
|
||||
valid: boolean;
|
||||
}>({ password: '', rules: [], valid: false });
|
||||
const [strengthFailure, setStrengthFailure] = useState<{
|
||||
password: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const effectiveOld = rehearsing ? '' : carriesOld ? (knownOldPassword as string) : oldPassword;
|
||||
const checkedCurrentPassword = strengthResult.password === newPassword;
|
||||
const valid = checkedCurrentPassword && strengthResult.valid;
|
||||
const strengthError = strengthFailure?.password === newPassword ? strengthFailure.message : '';
|
||||
const checkingStrength =
|
||||
newPassword.length > 0 && !checkedCurrentPassword && strengthError.length === 0;
|
||||
const displayedRules =
|
||||
checkedCurrentPassword && strengthResult.rules.length > 0
|
||||
? strengthResult.rules
|
||||
: EMPTY_RULES;
|
||||
const strengthRules = useMemo<readonly InteriorPasswordRule[]>(
|
||||
() =>
|
||||
displayedRules.map((rule) => ({
|
||||
id: rule.id,
|
||||
label: rule.label,
|
||||
test: () => rule.ok,
|
||||
})),
|
||||
[displayedRules],
|
||||
);
|
||||
const confirmMatches = newPassword.length > 0 && newPassword === confirmPassword;
|
||||
const canAttempt =
|
||||
!submitting &&
|
||||
(rehearsing || effectiveOld.length > 0) &&
|
||||
confirmMatches &&
|
||||
(rehearsing || effectiveOld !== newPassword);
|
||||
const canSubmit = canAttempt && valid;
|
||||
const requirementHint = passwordRequirementHint({
|
||||
focused: newPasswordFocused,
|
||||
value: newPassword,
|
||||
checked: checkedCurrentPassword,
|
||||
valid,
|
||||
rules: displayedRules,
|
||||
checkError: strengthError,
|
||||
});
|
||||
|
||||
// Debounce the strength check so we don't slam the API on every keystroke.
|
||||
useEffect(() => {
|
||||
if (newPassword.length === 0) {
|
||||
setStrengthFailure(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const handle = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await checkStrength(newPassword);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.rules.length === 0) {
|
||||
throw new Error('服务器未返回密码强度规则');
|
||||
}
|
||||
|
||||
setStrengthResult({ password: newPassword, rules: res.rules, valid: res.valid });
|
||||
setStrengthFailure(null);
|
||||
} catch (caught) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = actionErrorMessage(caught);
|
||||
console.error('check password strength failed', caught);
|
||||
setStrengthFailure({ password: newPassword, message });
|
||||
}
|
||||
}, 180);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [newPassword, checkStrength]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!canAttempt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
setNewPasswordFocused(true);
|
||||
document.getElementById(`${idPrefix}-new`)?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await runAction(
|
||||
{
|
||||
title: rehearsing ? '正在验证密码设置步骤' : '正在更新访问密码',
|
||||
detail: rehearsing ? '演练内容不会保存' : '正在保存新密码并使其他会话失效',
|
||||
successTitle: rehearsing ? '密码设置演练已完成' : '访问密码已更新',
|
||||
successDetail: rehearsing ? '未修改真实访问密码' : '其他会话已失效',
|
||||
errorTitle: rehearsing ? '密码设置演练失败' : '访问密码更新失败',
|
||||
resultError: (result) => (result.success ? null : result.message || '修改失败'),
|
||||
},
|
||||
() => submit(effectiveOld, newPassword),
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(res.message || '修改失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '网络错误');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={cn('flex flex-col gap-3', className)}>
|
||||
{!carriesOld && (
|
||||
<InlineValidation
|
||||
id={`${idPrefix}-old`}
|
||||
label="当前密码"
|
||||
type={showOld ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
required
|
||||
value={oldPassword}
|
||||
onChange={setOldPassword}
|
||||
validate={(value) => (value.length > 0 ? null : '请输入当前密码')}
|
||||
hint="用于确认当前管理员身份"
|
||||
showValidIcon={false}
|
||||
endAdornment={
|
||||
<PasswordVisibilityButton
|
||||
visible={showOld}
|
||||
reduceMotion={reduceMotion}
|
||||
onToggle={() => setShowOld((current) => !current)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<InlineValidation
|
||||
id={`${idPrefix}-new`}
|
||||
label="新密码"
|
||||
type={showNew ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
onFocus={() => setNewPasswordFocused(true)}
|
||||
onBlur={() => setNewPasswordFocused(false)}
|
||||
validationKey={`${effectiveOld}\0${newPasswordFocused}\0${valid}\0${strengthError}\0${requirementHint ?? ''}`}
|
||||
reserveLines={2}
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return '请输入新密码';
|
||||
}
|
||||
|
||||
if (!rehearsing && value === effectiveOld) {
|
||||
return '新密码不能与当前密码相同';
|
||||
}
|
||||
|
||||
return requirementHint;
|
||||
}}
|
||||
showValidIcon={false}
|
||||
endAdornment={
|
||||
<PasswordVisibilityButton
|
||||
visible={showNew}
|
||||
reduceMotion={reduceMotion}
|
||||
onToggle={() => setShowNew((current) => !current)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div aria-busy={checkingStrength || undefined}>
|
||||
<PasswordStrength
|
||||
value={newPassword}
|
||||
rules={strengthRules}
|
||||
labels={STRENGTH_LABELS}
|
||||
visible={newPasswordFocused}
|
||||
className="mt-1"
|
||||
status={
|
||||
checkingStrength ? (
|
||||
<span className="text-muted-foreground">正在校验密码强度…</span>
|
||||
) : strengthError ? (
|
||||
<span role="alert" className="text-destructive">
|
||||
密码强度校验失败:{strengthError}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InlineValidation
|
||||
id={`${idPrefix}-confirm`}
|
||||
label="确认新密码"
|
||||
type={showNew ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
validationKey={newPassword}
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return '请再次输入新密码';
|
||||
}
|
||||
|
||||
if (value !== newPassword) {
|
||||
return '两次输入的密码不一致';
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
hint="需要与新密码完全一致"
|
||||
endAdornment={
|
||||
<PasswordVisibilityButton
|
||||
visible={showNew}
|
||||
reduceMotion={reduceMotion}
|
||||
onToggle={() => setShowNew((current) => !current)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded-md bg-destructive/10 px-3 py-2 text-center text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{renderActions ? (
|
||||
renderActions({ canSubmit, canAttempt, submitting, submitLabel })
|
||||
) : (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="ghost" onClick={onCancel} className="h-10">
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={!canAttempt} className="ml-auto h-10">
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" /> 提交中…
|
||||
</>
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordVisibilityButton({
|
||||
visible,
|
||||
reduceMotion,
|
||||
onToggle,
|
||||
}: {
|
||||
visible: boolean;
|
||||
reduceMotion: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={visible ? '隐藏密码' : '显示密码'}
|
||||
className="flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary sm:size-7"
|
||||
>
|
||||
<PasswordVisibilityIcon visible={visible} reduceMotion={reduceMotion} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
// OneBot per-UIN configuration page.
|
||||
//
|
||||
// Layout: collapsible left sidebar for account selection + tabbed right
|
||||
// pane (通用 / 4 network kinds). Each network tab is a list view of
|
||||
// summary cards with inline enable/disable; create + edit both go
|
||||
// through `NodeEditDialog`. All changes auto-save: network mutations
|
||||
// persist immediately; general-settings edits are debounced (500 ms).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, Loader2, MousePointerClick, Plus, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollableTabList } from '@/components/ui/tabs';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AdapterStatus, NetworkKind, OneBotConfig, OneBotNetworks } from '@/types';
|
||||
import { useOneBotInstanceConfig } from '@/hooks/use-onebot-instance-config';
|
||||
import { useAppState } from '@/contexts/AppStateContext';
|
||||
import { useLayout } from '@/contexts/LayoutContext';
|
||||
import { AccountSidebar } from '@/components/config/account-sidebar';
|
||||
import { GeneralSettingsTab } from '@/components/config/general-settings-tab';
|
||||
import { NodeSummaryCard } from '@/components/config/node-summary-card';
|
||||
import { NodeEditDialog } from '@/components/config/node-edit-dialog';
|
||||
import {
|
||||
ALL_TABS,
|
||||
NETWORK_TABS,
|
||||
nextUniqueSuffix,
|
||||
type TabKey,
|
||||
} from '@/components/config/defaults';
|
||||
|
||||
type DialogState =
|
||||
| { open: false }
|
||||
| {
|
||||
open: true;
|
||||
kind: NetworkKind;
|
||||
index: number | null;
|
||||
seed: OneBotNetworks[NetworkKind][number];
|
||||
};
|
||||
// index: null → create with `seed`, otherwise edit the item at that position.
|
||||
|
||||
export function ConfigPage() {
|
||||
const { qqList, connections, selectedUin, setSelectedUin, resources, refreshQqList } =
|
||||
useAppState();
|
||||
const {
|
||||
config,
|
||||
loading,
|
||||
loadError,
|
||||
reload,
|
||||
setConfig,
|
||||
dirty,
|
||||
requestSwitchUin,
|
||||
pendingSwitchUin,
|
||||
confirmSwitch,
|
||||
cancelSwitch,
|
||||
save,
|
||||
saveStatus,
|
||||
saveStatusTone,
|
||||
} = useOneBotInstanceConfig(qqList, {
|
||||
selectedUin,
|
||||
onSelectedUinChange: setSelectedUin,
|
||||
});
|
||||
const { pages, setPages } = useLayout();
|
||||
const [activeTab, setActiveTabState] = useState<TabKey>(() => {
|
||||
const t = pages.configTab;
|
||||
return t === 'general' || (!!t && t in NETWORK_TABS) ? (t as TabKey) : 'general';
|
||||
});
|
||||
// Persist the last-used tab as the default for next time.
|
||||
const setActiveTab = useCallback(
|
||||
(t: TabKey) => {
|
||||
setActiveTabState(t);
|
||||
setPages({ configTab: t });
|
||||
},
|
||||
[setPages],
|
||||
);
|
||||
// Default the account strip to its 56px avatar-only form on narrow screens
|
||||
// (≤lg) so the editor pane isn't squeezed; the user can still expand it.
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia('(max-width: 1024px)').matches,
|
||||
);
|
||||
// The edit dialog is modal and blocks every other click in the page,
|
||||
// so `selectedUin` cannot change while it's open — no defensive close
|
||||
// wiring needed beyond the dialog's own open/close.
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false });
|
||||
|
||||
const pendingSwitchAccount = useMemo(
|
||||
() => (pendingSwitchUin ? (qqList.find((q) => q.uin === pendingSwitchUin) ?? null) : null),
|
||||
[pendingSwitchUin, qqList],
|
||||
);
|
||||
|
||||
// Live adapter status for the selected account, keyed by adapter name so
|
||||
// each summary card can light up its real connection state.
|
||||
const liveStatusByName = useMemo(() => {
|
||||
const acc = connections.find((c) => c.uin === selectedUin);
|
||||
const map = new Map<string, AdapterStatus>();
|
||||
|
||||
for (const a of acc?.adapters ?? []) {
|
||||
map.set(a.name, a);
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [connections, selectedUin]);
|
||||
|
||||
// Auto-save general settings with debounce. Network mutations
|
||||
// (create / edit / delete / enable-toggle) persist immediately in commitKind.
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (debounceRef.current != null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
void save();
|
||||
debounceRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current != null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [dirty, config, save]);
|
||||
|
||||
/** Immediate save: clear any pending auto-save debounce and persist now. */
|
||||
const immediateSave = useCallback(() => {
|
||||
if (debounceRef.current != null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
|
||||
void save();
|
||||
}, [save]);
|
||||
|
||||
function commitKind<K extends NetworkKind>(kind: K, nextList: OneBotNetworks[K]): void {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = { ...config, networks: { ...config.networks, [kind]: nextList } };
|
||||
setConfig(next);
|
||||
void save(next);
|
||||
}
|
||||
|
||||
function handleCreate<K extends NetworkKind>(kind: K, item: OneBotNetworks[K][number]): void {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = config.networks[kind] as OneBotNetworks[K];
|
||||
commitKind(kind, [...list, item] as OneBotNetworks[K]);
|
||||
}
|
||||
|
||||
function handleEdit<K extends NetworkKind>(
|
||||
kind: K,
|
||||
index: number,
|
||||
item: OneBotNetworks[K][number],
|
||||
): void {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = config.networks[kind] as OneBotNetworks[K];
|
||||
commitKind(kind, list.map((it, i) => (i === index ? item : it)) as OneBotNetworks[K]);
|
||||
}
|
||||
|
||||
function handleDelete<K extends NetworkKind>(kind: K, index: number): void {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = config.networks[kind] as OneBotNetworks[K];
|
||||
commitKind(kind, list.filter((_, i) => i !== index) as OneBotNetworks[K]);
|
||||
}
|
||||
|
||||
function handleToggleEnabled<K extends NetworkKind>(
|
||||
kind: K,
|
||||
index: number,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = config.networks[kind] as OneBotNetworks[K];
|
||||
commitKind(
|
||||
kind,
|
||||
list.map((it, i) =>
|
||||
i === index
|
||||
? ({ ...it, enabled: enabled ? undefined : false } as OneBotNetworks[K][number])
|
||||
: it,
|
||||
) as OneBotNetworks[K],
|
||||
);
|
||||
}
|
||||
|
||||
const openCreate = (kind: NetworkKind) => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = NETWORK_TABS[kind];
|
||||
const list = config.networks[kind];
|
||||
const suffix = nextUniqueSuffix(list, tab.defaultEntry);
|
||||
setDialog({ open: true, kind, index: null, seed: tab.defaultEntry(suffix) });
|
||||
};
|
||||
|
||||
const openEdit = (kind: NetworkKind, index: number) => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = config.networks[kind][index];
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialog({ open: true, kind, index, seed: item });
|
||||
};
|
||||
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready={resources.qqList.ready}
|
||||
lines={7}
|
||||
lineHeight={28}
|
||||
barHeight={11}
|
||||
reserve={196}
|
||||
label="账号列表"
|
||||
className={resources.qqList.ready ? 'skeleton-swap-fluid min-h-[196px]' : ''}
|
||||
>
|
||||
{resources.qqList.ready ? (
|
||||
resources.qqList.error && qqList.length === 0 ? (
|
||||
<ConfigLoadError message={resources.qqList.error} onRetry={refreshQqList} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{resources.qqList.error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{resources.qqList.error};当前显示上次成功读取的账号列表。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<AccountSidebar
|
||||
accounts={qqList}
|
||||
selectedUin={selectedUin}
|
||||
onSelect={requestSwitchUin}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{!selectedUin ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<SkeletonSwap
|
||||
ready={!loading}
|
||||
lines={7}
|
||||
lineHeight={28}
|
||||
barHeight={11}
|
||||
reserve={196}
|
||||
label="账号配置"
|
||||
className={
|
||||
!loading
|
||||
? 'skeleton-swap-fluid min-h-[196px]'
|
||||
: 'w-full'
|
||||
}
|
||||
>
|
||||
{!loading ? (
|
||||
loadError || !config ? (
|
||||
<ConfigLoadError
|
||||
message={loadError ?? '账号配置不可用'}
|
||||
onRetry={reload}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<HeaderBar
|
||||
selectedUin={selectedUin}
|
||||
dirty={dirty}
|
||||
saveStatus={saveStatus}
|
||||
saveStatusTone={saveStatusTone}
|
||||
onSave={immediateSave}
|
||||
activeTab={activeTab}
|
||||
onCreate={
|
||||
activeTab !== 'general'
|
||||
? () =>
|
||||
openCreate(
|
||||
activeTab as NetworkKind,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<TabStrip
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
counts={countMap(config.networks)}
|
||||
/>
|
||||
|
||||
<div
|
||||
id={`config-panel-${activeTab}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`config-tab-${activeTab}`}
|
||||
>
|
||||
{activeTab === 'general' ? (
|
||||
<GeneralSettingsTab
|
||||
config={config}
|
||||
onChange={setConfig}
|
||||
/>
|
||||
) : (
|
||||
<NetworkTabView
|
||||
kind={activeTab}
|
||||
config={config}
|
||||
statusByName={liveStatusByName}
|
||||
onCreateClick={() =>
|
||||
openCreate(activeTab)
|
||||
}
|
||||
onEdit={(idx) =>
|
||||
openEdit(activeTab, idx)
|
||||
}
|
||||
onDelete={(idx) =>
|
||||
handleDelete(activeTab, idx)
|
||||
}
|
||||
onToggleEnabled={(idx, v) =>
|
||||
handleToggleEnabled(
|
||||
activeTab,
|
||||
idx,
|
||||
v,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single dialog covers create + edit across all 4 kinds. */}
|
||||
{dialog.open && config && selectedUin && (
|
||||
<NodeEditDialog
|
||||
open={dialog.open}
|
||||
onOpenChange={(open) => !open && setDialog({ open: false })}
|
||||
kind={dialog.kind}
|
||||
initial={dialog.seed}
|
||||
isEdit={dialog.index != null}
|
||||
otherNames={otherNames(
|
||||
config.networks,
|
||||
dialog.kind,
|
||||
dialog.index,
|
||||
)}
|
||||
uin={selectedUin}
|
||||
onSubmit={(item) => {
|
||||
if (dialog.index == null) {
|
||||
handleCreate(dialog.kind, item);
|
||||
} else {
|
||||
handleEdit(dialog.kind, dialog.index, item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingSwitchUin != null}
|
||||
onOpenChange={(open) => !open && cancelSwitch()}
|
||||
title="放弃未保存的修改?"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
当前会话{' '}
|
||||
<code className="font-mono">{selectedUin}</code>{' '}
|
||||
还有未保存的修改。
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
切换到{' '}
|
||||
<code className="font-mono">
|
||||
{pendingSwitchAccount?.uin ?? pendingSwitchUin}
|
||||
</code>
|
||||
{pendingSwitchAccount?.nickname
|
||||
? `(${pendingSwitchAccount.nickname})`
|
||||
: ''}{' '}
|
||||
会丢弃这些修改。
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
confirmText="放弃并切换"
|
||||
destructive
|
||||
onConfirm={confirmSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── header ───────────────
|
||||
|
||||
interface HeaderBarProps {
|
||||
selectedUin: string;
|
||||
dirty: boolean;
|
||||
saveStatus: string;
|
||||
saveStatusTone: 'idle' | 'saving' | 'success' | 'warning' | 'error';
|
||||
onSave: () => void;
|
||||
activeTab: TabKey;
|
||||
onCreate?: () => void;
|
||||
}
|
||||
|
||||
function HeaderBar({
|
||||
selectedUin,
|
||||
dirty,
|
||||
saveStatus,
|
||||
saveStatusTone,
|
||||
onSave,
|
||||
activeTab,
|
||||
onCreate,
|
||||
}: HeaderBarProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold tracking-tight">OneBot 协议端点</h2>
|
||||
<code className="mt-0.5 block font-mono text-xs text-muted-foreground tabular-nums">
|
||||
UIN {selectedUin}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{(saveStatus || dirty) && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
{saveStatusTone === 'saving' ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">保存中</span>
|
||||
</>
|
||||
) : saveStatusTone === 'success' ? (
|
||||
<>
|
||||
<Check className="size-3.5 text-success" />
|
||||
<span className="text-success">{saveStatus}</span>
|
||||
</>
|
||||
) : saveStatusTone === 'warning' ? (
|
||||
<>
|
||||
<span className="size-1.5 rounded-full bg-warning" />
|
||||
<span className="text-warning">{saveStatus}</span>
|
||||
</>
|
||||
) : saveStatusTone === 'error' ? (
|
||||
<>
|
||||
<span className="size-1.5 rounded-full bg-destructive" />
|
||||
<span className="text-destructive">{saveStatus}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="size-1.5 rounded-full bg-warning" />
|
||||
<span className="text-warning">未保存</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onCreate && (
|
||||
<Button size="sm" variant="outline" onClick={onCreate}>
|
||||
<Plus className="size-3.5" />
|
||||
新建
|
||||
{activeTab === 'general'
|
||||
? ''
|
||||
: NETWORK_TABS[activeTab as NetworkKind].title}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSave} size="sm">
|
||||
<Save className="size-3.5" /> 保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── tab strip ───────────────
|
||||
|
||||
interface TabStripProps {
|
||||
activeTab: TabKey;
|
||||
onChange: (key: TabKey) => void;
|
||||
counts: Record<NetworkKind, number>;
|
||||
}
|
||||
|
||||
function TabStrip({ activeTab, onChange, counts }: TabStripProps) {
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const moveTab = (from: number, direction: 1 | -1 | 'home' | 'end') => {
|
||||
const length = ALL_TABS.length;
|
||||
const nextIndex =
|
||||
direction === 'home'
|
||||
? 0
|
||||
: direction === 'end'
|
||||
? length - 1
|
||||
: (from + direction + length) % length;
|
||||
const next = ALL_TABS[nextIndex];
|
||||
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(next);
|
||||
tabRefs.current[nextIndex]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollableTabList
|
||||
activeValue={activeTab}
|
||||
role="tablist"
|
||||
aria-label="节点配置分类"
|
||||
className="gap-1 border-b"
|
||||
>
|
||||
{ALL_TABS.map((key, index) => {
|
||||
const label = key === 'general' ? '通用设置' : NETWORK_TABS[key].title;
|
||||
const count = key === 'general' ? null : counts[key];
|
||||
const active = activeTab === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
ref={(element) => {
|
||||
tabRefs.current[index] = element;
|
||||
}}
|
||||
id={`config-tab-${key}`}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={active}
|
||||
aria-controls={`config-panel-${key}`}
|
||||
data-tab-active={active ? 'true' : undefined}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(key)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
moveTab(index, 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
moveTab(index, -1);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
moveTab(index, 'home');
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
moveTab(index, 'end');
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 py-2 text-sm outline-none transition-colors cursor-pointer md:min-h-10',
|
||||
'focus-visible:ring-[3px] focus-visible:ring-ring/40',
|
||||
active
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{count != null && (
|
||||
<Badge
|
||||
variant={active ? 'default' : 'secondary'}
|
||||
className="h-4 px-1.5 font-mono text-micro tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="config-tab-underline"
|
||||
className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-primary"
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ScrollableTabList>
|
||||
);
|
||||
}
|
||||
|
||||
function countMap(networks: OneBotNetworks): Record<NetworkKind, number> {
|
||||
return {
|
||||
httpServers: networks.httpServers.length,
|
||||
httpClients: networks.httpClients.length,
|
||||
wsServers: networks.wsServers.length,
|
||||
wsClients: networks.wsClients.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────── network tab body ───────────────
|
||||
|
||||
interface NetworkTabViewProps {
|
||||
kind: NetworkKind;
|
||||
config: OneBotConfig;
|
||||
statusByName: Map<string, AdapterStatus>;
|
||||
onCreateClick: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggleEnabled: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function NetworkTabView({
|
||||
kind,
|
||||
config,
|
||||
statusByName,
|
||||
onCreateClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
}: NetworkTabViewProps) {
|
||||
const tab = NETWORK_TABS[kind];
|
||||
const list = config.networks[kind];
|
||||
// `summarize` is typed per K in NETWORK_TABS; the union here forces a
|
||||
// widening cast at the call site rather than ladder-of-ifs per tab.
|
||||
const summarize = tab.summarize as (it: (typeof list)[number]) => string;
|
||||
|
||||
// Tally name occurrences once instead of doing `list.filter(...).map(...).includes(...)`
|
||||
// inside `.map()` (O(n²) per render). With this lookup, the duplicate
|
||||
// check is O(1) per row — matters once the list grows past a few entries
|
||||
// or when typing renames a node and the tab re-renders on every keystroke.
|
||||
const nameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const item of list) {
|
||||
const name = item.name;
|
||||
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return counts;
|
||||
}, [list]);
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-16 text-muted-foreground">
|
||||
<p className="text-sm">暂无 {tab.title} 节点</p>
|
||||
<Button variant="outline" size="sm" onClick={onCreateClick}>
|
||||
<Plus className="size-3.5" /> 创建第一个
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-muted-foreground">{tab.description}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{list.map((item, idx) => {
|
||||
const duplicate = !!item.name && (nameCounts.get(item.name) ?? 0) > 1;
|
||||
return (
|
||||
<NodeSummaryCard
|
||||
key={`${item.name}-${idx}`}
|
||||
item={item}
|
||||
summary={summarize(item)}
|
||||
duplicateName={duplicate}
|
||||
liveStatus={statusByName.get(item.name)}
|
||||
onToggleEnabled={(v) => onToggleEnabled(idx, v)}
|
||||
onEdit={() => onEdit(idx)}
|
||||
onDelete={() => onDelete(idx)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── empty + loading ───────────────
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-muted-foreground">
|
||||
<MousePointerClick className="size-8 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">请在左栏选择会话以配置通信节点</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed text-muted-foreground">
|
||||
<p className="max-w-md text-center text-sm text-destructive">{message}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── helpers ───────────────
|
||||
|
||||
/** Names of every adapter except the one currently being edited. */
|
||||
function otherNames<K extends NetworkKind>(
|
||||
networks: OneBotNetworks,
|
||||
kind: K,
|
||||
excludeIndex: number | null,
|
||||
): string[] {
|
||||
const list = networks[kind];
|
||||
return list
|
||||
.filter((_, i) => i !== excludeIndex)
|
||||
.map((n) => n.name)
|
||||
.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { GridStack, type GridStackWidget } from 'gridstack';
|
||||
import { GripVertical, Settings2 } from 'lucide-react';
|
||||
import 'gridstack/dist/gridstack.css';
|
||||
import { GRID_CELL_HEIGHT, GRID_MARGIN, minSizeOf } from '@/lib/dashboard-layout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UiLayoutItem } from '@/types';
|
||||
|
||||
export interface GridCoord {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** dataTransfer key carrying the dragged gallery widget id (HTML5 add). */
|
||||
export const WIDGET_DRAG_TYPE = 'text/plain';
|
||||
|
||||
interface DashboardGridProps {
|
||||
/** Visible widgets only — each carries x/y/w/h. Hidden widgets live in the
|
||||
* gallery, not the grid (Apple-widgets model: presence = shown). */
|
||||
blocks: UiLayoutItem[];
|
||||
editing: boolean;
|
||||
/** Column count — 12 on desktop, 1 on narrow screens (auto single-column). */
|
||||
cols: number;
|
||||
/**
|
||||
* Fired (debounced upstream) after a drag/resize/remove settles, with the
|
||||
* grid's current visible set. The consumer derives removal from absence: a
|
||||
* block that was visible but is no longer in `coords` was dragged out.
|
||||
*/
|
||||
onChange: (coords: GridCoord[]) => void;
|
||||
renderWidget: (block: UiLayoutItem) => ReactNode;
|
||||
configurableIds: ReadonlySet<string>;
|
||||
onConfigOpen: (id: string) => void;
|
||||
/** CSS selector for the gallery drop-zone; dragging a card onto it removes
|
||||
* the widget from the grid (gridstack `removable`). Edit mode only. */
|
||||
removableSelector?: string;
|
||||
/** Add a widget dropped from the gallery at the computed grid cell (HTML5
|
||||
* drag-and-drop, driven entirely through React state). */
|
||||
onAdd?: (id: string, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* gridstack-backed dashboard. gridstack owns the item shells (drag/resize/
|
||||
* position); React renders each widget's content into the item's content div
|
||||
* via a portal (React context still flows through portals). We re-init only
|
||||
* when the visible widget SET or the edit mode changes — never on a pure
|
||||
* coord change (gridstack already moved the item; the coord round-trips back
|
||||
* through `onChange` → state and matches what gridstack has).
|
||||
*
|
||||
* Add/remove are NOT done through gridstack's external drag-in: instead the
|
||||
* gallery drives React state (add = HTML5 drop here; remove = drag onto the
|
||||
* gallery via `removable`). Both funnel through the single `onChange` writer,
|
||||
* which reads the post-mutation grid snapshot — so there's no state race.
|
||||
*/
|
||||
export function DashboardGrid({
|
||||
blocks,
|
||||
editing,
|
||||
cols,
|
||||
onChange,
|
||||
renderWidget,
|
||||
configurableIds,
|
||||
onConfigOpen,
|
||||
removableSelector,
|
||||
onAdd,
|
||||
}: DashboardGridProps) {
|
||||
const elRef = useRef<HTMLDivElement | null>(null);
|
||||
const gridRef = useRef<GridStack | null>(null);
|
||||
const [contentEls, setContentEls] = useState<Record<string, HTMLElement>>({});
|
||||
|
||||
// Latest props mirrored into refs so the init effect (keyed only on the
|
||||
// id-set + editing) reads current values without re-running on every render.
|
||||
const blocksRef = useRef(blocks);
|
||||
const onChangeRef = useRef(onChange);
|
||||
useLayoutEffect(() => {
|
||||
blocksRef.current = blocks;
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
// Re-init when the visible widget set or the edit mode changes.
|
||||
const idKey = blocks.map((b) => b.id).join('|');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const host = elRef.current;
|
||||
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = GridStack.init(
|
||||
{
|
||||
column: cols,
|
||||
cellHeight: GRID_CELL_HEIGHT,
|
||||
margin: GRID_MARGIN,
|
||||
float: true, // free placement — items stay where dropped (no auto-compact)
|
||||
disableDrag: !editing,
|
||||
disableResize: !editing,
|
||||
handle: '.grid-drag-handle',
|
||||
// Drag a card onto the gallery to remove it (edit mode only). The whole
|
||||
// card is the drag target; the corner icons are just hints.
|
||||
removable: editing && removableSelector ? removableSelector : false,
|
||||
},
|
||||
host,
|
||||
);
|
||||
gridRef.current = grid;
|
||||
|
||||
const els: Record<string, HTMLElement> = {};
|
||||
grid.batchUpdate();
|
||||
|
||||
for (const b of blocksRef.current) {
|
||||
const { minW, minH } = minSizeOf(b.id);
|
||||
const itemEl = grid.addWidget({ id: b.id, x: b.x, y: b.y, w: b.w, h: b.h, minW, minH });
|
||||
const content = itemEl.querySelector('.grid-stack-item-content') as HTMLElement | null;
|
||||
|
||||
if (content) {
|
||||
els[b.id] = content;
|
||||
}
|
||||
}
|
||||
|
||||
grid.batchUpdate(false);
|
||||
setContentEls(els);
|
||||
|
||||
// Attach AFTER the initial programmatic build so we don't persist the
|
||||
// (unchanged) starting layout back to the server on mount. `change` covers
|
||||
// move/resize; `removed` covers a drag-to-gallery — both re-read the
|
||||
// snapshot, and the consumer treats any now-absent visible block as removed.
|
||||
const persist = () => {
|
||||
const saved = grid.save(false) as GridStackWidget[];
|
||||
onChangeRef.current(
|
||||
saved
|
||||
.filter((n): n is GridStackWidget & { id: string } => n.id != null)
|
||||
.map((n) => ({
|
||||
id: String(n.id),
|
||||
x: n.x ?? 0,
|
||||
y: n.y ?? 0,
|
||||
w: n.w ?? 1,
|
||||
h: n.h ?? 1,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
grid.on('change', persist);
|
||||
grid.on('removed', persist);
|
||||
|
||||
return () => {
|
||||
grid.off('change');
|
||||
grid.off('removed');
|
||||
grid.destroy(false); // tear down gridstack, keep the React-owned container
|
||||
host.replaceChildren();
|
||||
gridRef.current = null;
|
||||
setContentEls({});
|
||||
};
|
||||
}, [idKey, editing, cols, removableSelector]);
|
||||
|
||||
// HTML5 drop from the gallery → add at the computed grid cell.
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
if (!editing || !onAdd) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(WIDGET_DRAG_TYPE);
|
||||
const host = elRef.current;
|
||||
|
||||
if (!id || !host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = host.getBoundingClientRect();
|
||||
const colW = rect.width / cols || 1;
|
||||
const x = Math.max(0, Math.min(cols - 1, Math.floor((e.clientX - rect.left) / colW)));
|
||||
const y = Math.max(
|
||||
0,
|
||||
Math.floor((e.clientY - rect.top) / (GRID_CELL_HEIGHT + GRID_MARGIN)),
|
||||
);
|
||||
onAdd(id, x, y);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elRef}
|
||||
className="grid-stack -mx-1"
|
||||
onDragOver={(e) => {
|
||||
if (editing && onAdd) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{blocks.map((b) => {
|
||||
const target = contentEls[b.id];
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="relative h-full w-full">
|
||||
{editing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="拖动卡片调整位置 · 拖回右侧部件库可移除"
|
||||
aria-label="拖动调整位置"
|
||||
className="grid-drag-handle pointer-events-auto absolute left-1.5 top-1.5 z-20 inline-flex size-11 items-center justify-center rounded-md bg-background/80 text-muted-foreground touch-none outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
{configurableIds.has(b.id) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfigOpen(b.id)}
|
||||
title="设置"
|
||||
aria-label="设置"
|
||||
className="pointer-events-auto absolute right-1.5 top-1.5 z-20 inline-flex size-11 items-center justify-center rounded-md bg-background/80 text-muted-foreground transition-[color,box-shadow] duration-150 ease-out outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40 cursor-pointer"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Clip to the card radius. A tile-wide scrollport would eat the
|
||||
page wheel even when the widget already fits on screen. */}
|
||||
<div
|
||||
className={cn(
|
||||
'h-full w-full overflow-hidden rounded-xl',
|
||||
editing && 'pointer-events-none select-none',
|
||||
)}
|
||||
>
|
||||
{renderWidget(b)}
|
||||
</div>
|
||||
</div>,
|
||||
target,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Debug — a multi-tab developer console over /api/debug/*. Tabs: the action
|
||||
// tester, the visual message composer, the live activity feed, and an embedded
|
||||
// API browser. The browser's 试一下 prefills the tester and switches to it.
|
||||
// Apple-HIG flavour throughout (sliding-indicator tabs, translucent chrome).
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { BookOpen, Bug, FlaskConical, MessageSquarePlus, RadioTower } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tabs } from '@/components/ui/tabs';
|
||||
import { ActionTester } from '@/components/debug/action-tester';
|
||||
import { MessageComposer } from '@/components/debug/message-composer';
|
||||
import { LiveActivity } from '@/components/debug/live-activity';
|
||||
import { ApiBrowser } from '@/components/debug/api-browser';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { useApi } from '@/lib/api';
|
||||
import type { DebugActionDoc, QQInfo } from '@/types';
|
||||
|
||||
type Tab = 'tester' | 'compose' | 'activity' | 'api';
|
||||
|
||||
export function DebugPage() {
|
||||
const api = useApi();
|
||||
const [accounts, setAccounts] = useState<QQInfo[]>([]);
|
||||
const [docs, setDocs] = useState<DebugActionDoc[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>('tester');
|
||||
const [preset, setPreset] = useState<{ name: string; nonce: number } | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [qq, acts] = await Promise.all([api.qqList(), api.debug.actions()]);
|
||||
setAccounts(qq);
|
||||
setDocs(acts.actions);
|
||||
} catch (error) {
|
||||
console.error('load debug tools', error);
|
||||
setLoadError(error instanceof Error ? error.message : '加载开发者工具失败');
|
||||
} finally {
|
||||
setReady(true);
|
||||
}
|
||||
})();
|
||||
}, [api]);
|
||||
|
||||
const tryAction = (name: string) => {
|
||||
setPreset((p) => ({ name, nonce: (p?.nonce ?? 0) + 1 }));
|
||||
setTab('tester');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-6">
|
||||
<motion.header
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="sticky top-0 z-20 -mx-1 flex flex-col gap-3 rounded-b-2xl bg-background/60 px-1 py-3 backdrop-blur-xl"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Bug className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">开发者工具</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
接口测试 · 消息构建 · 实时观测 · API 参考
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
aria-label="调试工具"
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
items={[
|
||||
{
|
||||
value: 'tester',
|
||||
label: '测试台',
|
||||
icon: <FlaskConical className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: 'compose',
|
||||
label: '消息构建',
|
||||
icon: <MessageSquarePlus className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: 'activity',
|
||||
label: '实时活动',
|
||||
icon: <RadioTower className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
label: 'API 浏览器',
|
||||
icon: <BookOpen className="h-4 w-4" />,
|
||||
badge: docs.length || undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</motion.header>
|
||||
|
||||
{loadError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkeletonSwap
|
||||
ready={ready}
|
||||
reserve={224}
|
||||
lines={8}
|
||||
lineHeight={28}
|
||||
barHeight={11}
|
||||
label="开发者工具数据"
|
||||
className={ready ? 'skeleton-swap-fluid' : ''}
|
||||
>
|
||||
{ready ? (
|
||||
<>
|
||||
{/* Tester / compose / activity stay MOUNTED and toggle via CSS so the
|
||||
live feed's SSE never drops (it must capture events fired from other
|
||||
tabs) and in-progress params/messages survive a peek at another tab.
|
||||
ApiBrowser has no state worth preserving and is heavy (~520 rows), so
|
||||
it mounts on demand. */}
|
||||
<div className={cn(tab !== 'tester' && 'hidden')}>
|
||||
<ActionTester accounts={accounts} docs={docs} presetAction={preset} />
|
||||
</div>
|
||||
<div className={cn(tab !== 'compose' && 'hidden')}>
|
||||
<MessageComposer accounts={accounts} />
|
||||
</div>
|
||||
<div className={cn(tab !== 'activity' && 'hidden')}>
|
||||
<LiveActivity />
|
||||
</div>
|
||||
{tab === 'api' && <ApiBrowser docs={docs} onTry={tryAction} />}
|
||||
</>
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowRight, KeyRound, Sparkles } from 'lucide-react';
|
||||
import { Modal } from '@/components/interior/modal';
|
||||
import { OtpInput, type OtpInputHandle } from '@/components/interior/otp-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { PasswordVisibilityIcon } from '@/components/ui/password-visibility-icon';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { LoginWaves } from '@/components/login-waves';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { APP_NAME, APP_VERSION } from '@/types';
|
||||
import { parseSecondFactor } from '@/lib/totp-second-factor';
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (
|
||||
password: string,
|
||||
secondFactor?: { totp?: string; recoveryCode?: string },
|
||||
) => Promise<{ success: boolean; error?: string; needsTotp?: boolean }>;
|
||||
initialPassword?: string;
|
||||
initialNeedsTotp?: boolean;
|
||||
initialUseRecovery?: boolean;
|
||||
}
|
||||
|
||||
// Device-local preference for the (heavy) animated login background. Kept out
|
||||
// of the server-synced appearance on purpose: it's a "this device feels laggy"
|
||||
// choice, and it must work pre-auth (where settings can't persist to the
|
||||
// server). Independent of the system 减弱动效 / 关闭全部动效 settings.
|
||||
const LOGIN_FX_KEY = 'snowluma_login_fx';
|
||||
|
||||
function readLoginFx(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(LOGIN_FX_KEY);
|
||||
|
||||
if (v === '0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (v === '1') {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// No explicit choice yet → on by default, unless the OS asks for reduced
|
||||
// motion (an accessibility default; still fully overridable via the toggle).
|
||||
try {
|
||||
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginPage({
|
||||
onLogin,
|
||||
initialPassword = '',
|
||||
initialNeedsTotp = false,
|
||||
initialUseRecovery = false,
|
||||
}: LoginPageProps) {
|
||||
const { appearance } = useTheme();
|
||||
const customBg = appearance.background.type !== 'none';
|
||||
const reduce = appearance.reduceMotion || appearance.disableMotion;
|
||||
|
||||
// The login page must never carry operator custom CSS. applyAppearance gates
|
||||
// it on a token, but an in-session logout / 401 expiry won't re-run it, so
|
||||
// clear any lingering custom-CSS <style> whenever the login page shows.
|
||||
useEffect(() => {
|
||||
document.getElementById('snowluma-custom-css')?.remove();
|
||||
}, []);
|
||||
|
||||
const [password, setPassword] = useState(initialPassword);
|
||||
const [useRecovery, setUseRecovery] = useState(initialUseRecovery);
|
||||
const [needsTotp, setNeedsTotp] = useState(initialNeedsTotp);
|
||||
const [otpStatus, setOtpStatus] = useState<'idle' | 'checking' | 'rejected'>('idle');
|
||||
const otpRef = useRef<OtpInputHandle>(null);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shake, setShake] = useState(0);
|
||||
const [fxOn, setFxOn] = useState(readLoginFx);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const toggleFx = (v: boolean) => {
|
||||
setFxOn(v);
|
||||
|
||||
try {
|
||||
localStorage.setItem(LOGIN_FX_KEY, v ? '1' : '0');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (otpStatus !== 'rejected') {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = window.setTimeout(() => {
|
||||
otpRef.current?.clear();
|
||||
setOtpStatus('idle');
|
||||
setError('');
|
||||
}, 1600);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [otpStatus]);
|
||||
|
||||
const goBackToPassword = () => {
|
||||
setNeedsTotp(false);
|
||||
setUseRecovery(false);
|
||||
setOtpStatus('idle');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
const result = await onLogin(password);
|
||||
setLoading(false);
|
||||
|
||||
if (result.needsTotp) {
|
||||
setNeedsTotp(true);
|
||||
setUseRecovery(false);
|
||||
setOtpStatus('idle');
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || '登录失败');
|
||||
setShake((k) => k + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const submitTotp = async (code: string) => {
|
||||
setOtpStatus('checking');
|
||||
const result = await onLogin(password, parseSecondFactor(code));
|
||||
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.needsTotp) {
|
||||
setOtpStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(
|
||||
result.error ||
|
||||
(useRecovery
|
||||
? '恢复码不正确,改错的那一位即可。'
|
||||
: '验证码不正确,改错的那一位即可。'),
|
||||
);
|
||||
setOtpStatus('rejected');
|
||||
};
|
||||
|
||||
const handleTotpSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginShell
|
||||
customBg={customBg}
|
||||
reduce={reduce}
|
||||
fxOn={fxOn}
|
||||
helpOpen={helpOpen}
|
||||
onHelpOpen={setHelpOpen}
|
||||
onToggleFx={toggleFx}
|
||||
wide={useRecovery}
|
||||
>
|
||||
{needsTotp ? (
|
||||
<>
|
||||
<LoginBrand
|
||||
subtitle={
|
||||
useRecovery
|
||||
? '输入保存的一次性恢复码'
|
||||
: '输入 Authenticator 中的 6 位验证码'
|
||||
}
|
||||
/>
|
||||
<form onSubmit={handleTotpSubmit} className="mt-8 flex flex-col gap-3.5">
|
||||
<OtpInput
|
||||
key={useRecovery ? 'recovery' : 'totp'}
|
||||
ref={otpRef}
|
||||
autoFocus
|
||||
className="w-full"
|
||||
length={useRecovery ? 8 : 6}
|
||||
mode={useRecovery ? 'alphanumeric' : 'numeric'}
|
||||
groupEvery={useRecovery ? 4 : 3}
|
||||
groupSeparator={useRecovery}
|
||||
label={useRecovery ? '恢复码' : '验证码'}
|
||||
disabled={otpStatus === 'checking'}
|
||||
error={otpStatus === 'rejected'}
|
||||
errorMessage={
|
||||
error ||
|
||||
(useRecovery
|
||||
? '恢复码不正确,改错的那一位即可。'
|
||||
: '验证码不正确,改错的那一位即可。')
|
||||
}
|
||||
hint={
|
||||
useRecovery
|
||||
? '可以把完整恢复码粘贴到任意一格,连字符会自动忽略。'
|
||||
: '可以把完整验证码粘贴到任意一格。'
|
||||
}
|
||||
onChange={() => setOtpStatus((s) => (s === 'rejected' ? 'idle' : s))}
|
||||
onComplete={(code) => {
|
||||
void submitTotp(code);
|
||||
}}
|
||||
/>
|
||||
{!useRecovery && (
|
||||
<input
|
||||
type="text"
|
||||
name="one-time-code"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(e) => {
|
||||
const digits = e.currentTarget.value
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, 6);
|
||||
|
||||
if (digits.length === 6) {
|
||||
void submitTotp(digits);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-[12px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBackToPassword}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground cursor-pointer"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseRecovery((v) => !v);
|
||||
setOtpStatus('idle');
|
||||
setError('');
|
||||
}}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground cursor-pointer"
|
||||
>
|
||||
{useRecovery ? '改用验证码' : '使用恢复码'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LoginBrand subtitle="OneBot v11 协议网关 · 安全登录" />
|
||||
<form onSubmit={handlePasswordSubmit} className="mt-8 flex flex-col gap-3.5">
|
||||
<motion.div
|
||||
key={shake}
|
||||
animate={shake > 0 ? { x: [0, -8, 8, -6, 6, -3, 3, 0] } : {}}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="relative"
|
||||
>
|
||||
<KeyRound className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
placeholder="输入访问令牌"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
className="h-12 rounded-xl bg-background/40 pl-10 pr-11 text-base"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPwd((v) => !v)}
|
||||
className="absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"
|
||||
tabIndex={-1}
|
||||
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
<PasswordVisibilityIcon visible={showPwd} reduceMotion={reduce} />
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={reduce ? false : { opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-lg bg-destructive/10 px-3 py-2 text-center text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="h-12 rounded-xl text-[15px]"
|
||||
>
|
||||
{loading ? (
|
||||
'验证中…'
|
||||
) : (
|
||||
<>
|
||||
进入控制台 <ArrowRight className="optical-forward size-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginBrand({ subtitle }: { subtitle: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 shadow-sm ring-1 ring-primary/20">
|
||||
<img src="/logo.png" alt="SnowLuma" className="size-10 rounded-xl object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-2xl font-semibold tracking-tight">{APP_NAME}</span>
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 font-mono text-micro text-primary tabular-nums">
|
||||
v{APP_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginShell({
|
||||
customBg,
|
||||
reduce,
|
||||
fxOn,
|
||||
helpOpen,
|
||||
onHelpOpen,
|
||||
onToggleFx,
|
||||
wide = false,
|
||||
children,
|
||||
}: {
|
||||
customBg: boolean;
|
||||
reduce: boolean;
|
||||
fxOn: boolean;
|
||||
helpOpen: boolean;
|
||||
onHelpOpen: (open: boolean) => void;
|
||||
onToggleFx: (value: boolean) => void;
|
||||
wide?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { managedBy } = useTheme();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex min-h-dvh items-center justify-center overflow-x-hidden px-4 py-8 pb-[max(2rem,env(safe-area-inset-bottom))] pt-[max(2rem,env(safe-area-inset-top))]',
|
||||
customBg ? 'bg-transparent' : 'bg-background',
|
||||
)}
|
||||
>
|
||||
{fxOn && !managedBy && <LoginWaves />}
|
||||
|
||||
<div
|
||||
data-ui-decoration=""
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(75% 55% at 50% 0%, color-mix(in oklab, var(--primary) 20%, transparent) 0%, transparent 68%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute right-[max(1rem,env(safe-area-inset-right))] top-[max(1rem,env(safe-area-inset-top))] z-20">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={reduce ? false : { opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={reduce ? { duration: 0 } : { duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn(
|
||||
'relative z-10 flex w-full flex-col items-center',
|
||||
wide ? 'max-w-2xl' : 'max-w-md',
|
||||
)}
|
||||
>
|
||||
<Card className="w-full overflow-hidden border-border/50 bg-card/75 shadow-2xl shadow-primary/5 backdrop-blur-2xl supports-[backdrop-filter]:bg-card/65">
|
||||
<CardContent className="px-7 py-9 sm:px-10">
|
||||
{children}
|
||||
<p className="mt-7 text-center text-meta text-muted-foreground">
|
||||
© {new Date().getFullYear()} SnowLuma. All rights reserved.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<motion.button
|
||||
style={{ display: managedBy ? 'none' : undefined }}
|
||||
type="button"
|
||||
onClick={() => onHelpOpen(true)}
|
||||
whileHover={reduce ? undefined : { scale: 1.04 }}
|
||||
whileTap={reduce ? undefined : { scale: 0.96 }}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 26 }}
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-full border border-border/50 bg-background/45 px-4 py-2 text-[12px] font-medium text-muted-foreground shadow-lg shadow-black/5 backdrop-blur-xl transition-colors hover:text-foreground supports-[backdrop-filter]:bg-background/35 cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
||||
>
|
||||
<Sparkles className="size-3.5 text-primary" />
|
||||
我觉得这个界面很卡怎么办?
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
|
||||
<Modal
|
||||
open={helpOpen}
|
||||
onClose={() => onHelpOpen(false)}
|
||||
title="界面有点卡顿?"
|
||||
description="登录页背景是一个跟随鼠标的动态线条效果,在部分设备上可能比较吃性能。可以在这里关掉它——该开关仅作用于本设备的登录页,与系统的动效设置相互独立。"
|
||||
closeLabel="关闭性能设置弹窗"
|
||||
maxWidth={384}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border bg-muted/30 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">登录页动态背景</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{fxOn ? '已开启 · 关闭后背景为静态' : '已关闭 · 背景为静态'}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={fxOn}
|
||||
onChange={onToggleFx}
|
||||
ariaLabel="登录页动态背景"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
登录后,你还可以在「系统设置 →
|
||||
无障碍」里进一步「减弱动效」或「关闭全部动效」。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type RequiredOnboardingStepId = 'agreements' | 'password';
|
||||
|
||||
export interface OnboardingRequirements {
|
||||
needsConsent: boolean;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server has already applied environment policy before returning these
|
||||
* booleans. Accepted legal env flags therefore omit the agreement step, while
|
||||
* a configured bootstrap password omits forced password rotation.
|
||||
*/
|
||||
export function buildRequiredOnboardingStepIds({
|
||||
needsConsent,
|
||||
mustChangePassword,
|
||||
}: OnboardingRequirements): RequiredOnboardingStepId[] {
|
||||
const steps: RequiredOnboardingStepId[] = [];
|
||||
|
||||
if (needsConsent) {
|
||||
steps.push('agreements');
|
||||
}
|
||||
|
||||
if (mustChangePassword) {
|
||||
steps.push('password');
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
export type OnboardingAdvance =
|
||||
{ nextIndex: number; complete: false } | { nextIndex: null; complete: true };
|
||||
|
||||
export function advanceOnboardingStep(
|
||||
stepIds: readonly string[],
|
||||
currentStepId: string,
|
||||
): OnboardingAdvance {
|
||||
const current = stepIds.indexOf(currentStepId);
|
||||
|
||||
if (current < 0) {
|
||||
throw new Error(`Unknown onboarding step: ${currentStepId}`);
|
||||
}
|
||||
|
||||
if (current === stepIds.length - 1) {
|
||||
return { nextIndex: null, complete: true };
|
||||
}
|
||||
|
||||
return { nextIndex: current + 1, complete: false };
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, Loader2, ScrollText, ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { SegmentedControl } from '@/components/interior/segmented-control';
|
||||
import {
|
||||
useWizardNavigation,
|
||||
WizardSteps,
|
||||
type WizardStep,
|
||||
} from '@/components/interior/wizard-steps';
|
||||
import { ChangePasswordForm, type PasswordRule } from '@/components/pages/change-password-form';
|
||||
import {
|
||||
advanceOnboardingStep,
|
||||
buildRequiredOnboardingStepIds,
|
||||
} from '@/components/pages/onboarding-steps';
|
||||
import { Markdown } from '@/lib/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AgreementDoc } from '@/lib/api/types';
|
||||
|
||||
export interface AdditionalOnboardingStep {
|
||||
id: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
canSkip: boolean;
|
||||
onSkip?: () => void;
|
||||
hideAdvance?: boolean;
|
||||
}
|
||||
|
||||
interface OnboardingWizardPageProps {
|
||||
documents: AgreementDoc[];
|
||||
agreementVersion: string;
|
||||
needsConsent: boolean;
|
||||
mustChangePassword: boolean;
|
||||
knownOldPassword?: string;
|
||||
passwordMode?: 'change' | 'rehearsal';
|
||||
onAccept: () => Promise<{ success: boolean; message?: string }>;
|
||||
onConsentComplete: () => void;
|
||||
onDecline: () => void;
|
||||
checkStrength: (password: string) => Promise<{ rules: PasswordRule[]; valid: boolean }>;
|
||||
submitPassword: (
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
) => Promise<{ success: boolean; message?: string }>;
|
||||
onPasswordComplete: () => void;
|
||||
onComplete: () => void;
|
||||
additionalSteps?: AdditionalOnboardingStep[];
|
||||
}
|
||||
|
||||
const TAB_FALLBACK_TITLE: Record<string, string> = {
|
||||
eula: '用户协议 / EULA',
|
||||
privacy: '隐私政策 / Privacy',
|
||||
};
|
||||
|
||||
export function OnboardingWizardPage({
|
||||
documents,
|
||||
agreementVersion,
|
||||
needsConsent,
|
||||
mustChangePassword,
|
||||
knownOldPassword,
|
||||
passwordMode = 'change',
|
||||
onAccept,
|
||||
onConsentComplete,
|
||||
onDecline,
|
||||
checkStrength,
|
||||
submitPassword,
|
||||
onPasswordComplete,
|
||||
onComplete,
|
||||
additionalSteps = [],
|
||||
}: OnboardingWizardPageProps) {
|
||||
const [requiredStepIds] = useState(() =>
|
||||
buildRequiredOnboardingStepIds({
|
||||
needsConsent,
|
||||
mustChangePassword,
|
||||
}),
|
||||
);
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const total = requiredStepIds.length + additionalSteps.length;
|
||||
|
||||
const advanceFrom = (stepId: string) => {
|
||||
const order = [...requiredStepIds, ...additionalSteps.map((step) => step.id)];
|
||||
const advance = advanceOnboardingStep(order, stepId);
|
||||
|
||||
if (advance.complete) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
setIndex(advance.nextIndex);
|
||||
};
|
||||
|
||||
const required: WizardStep[] = requiredStepIds.map((stepId) => {
|
||||
if (stepId === 'agreements') {
|
||||
return {
|
||||
id: stepId,
|
||||
label: '阅读并同意协议',
|
||||
canSkip: false,
|
||||
hideAdvance: true,
|
||||
hideBack: true,
|
||||
scrollMode: 'content',
|
||||
content: (
|
||||
<AgreementStep
|
||||
documents={documents}
|
||||
version={agreementVersion}
|
||||
onAccept={onAccept}
|
||||
onDecline={onDecline}
|
||||
onComplete={() => {
|
||||
onConsentComplete();
|
||||
advanceFrom(stepId);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: stepId,
|
||||
label: '设置访问密码',
|
||||
canSkip: false,
|
||||
hideAdvance: true,
|
||||
hideBack: true,
|
||||
content: (
|
||||
<PasswordStep
|
||||
knownOldPassword={knownOldPassword}
|
||||
mode={passwordMode}
|
||||
checkStrength={checkStrength}
|
||||
submit={submitPassword}
|
||||
onComplete={() => {
|
||||
onPasswordComplete();
|
||||
advanceFrom(stepId);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const steps: WizardStep[] = [
|
||||
...required,
|
||||
...additionalSteps.map((step) => ({
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
content: step.content,
|
||||
canSkip: step.canSkip,
|
||||
onSkip: step.onSkip,
|
||||
hideAdvance: step.hideAdvance,
|
||||
})),
|
||||
];
|
||||
|
||||
if (total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-svh min-h-svh flex-col overflow-x-hidden bg-background antialiased">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(80% 60% at 50% 0%, color-mix(in oklab, var(--primary) 18%, transparent) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-[max(1rem,env(safe-area-inset-right))] top-[max(1rem,env(safe-area-inset-top))] z-20 sm:right-6">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<main className="relative z-10 mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-14 sm:px-6 sm:pb-5 sm:pt-4">
|
||||
<header className="mx-auto mb-2 w-full max-w-2xl shrink-0 text-center">
|
||||
<p className="text-xs font-semibold tracking-wide text-primary">
|
||||
SnowLuma WebUI
|
||||
</p>
|
||||
<h1 className="mt-1 text-balance text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
完成首次使用设置
|
||||
</h1>
|
||||
<p className="mx-auto mt-1 max-w-xl text-pretty text-sm leading-relaxed text-muted-foreground">
|
||||
按顺序完成必要步骤后即可进入控制台。
|
||||
</p>
|
||||
</header>
|
||||
<WizardSteps
|
||||
steps={steps}
|
||||
index={index}
|
||||
onIndexChange={setIndex}
|
||||
onComplete={onComplete}
|
||||
label="首次使用设置步骤"
|
||||
backLabel="上一步"
|
||||
nextLabel="下一步"
|
||||
finishLabel="完成"
|
||||
skipLabel="跳过"
|
||||
spacious
|
||||
fill
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgreementStep({
|
||||
documents,
|
||||
version,
|
||||
onAccept,
|
||||
onDecline,
|
||||
onComplete,
|
||||
}: {
|
||||
documents: AgreementDoc[];
|
||||
version: string;
|
||||
onAccept: () => Promise<{ success: boolean; message?: string }>;
|
||||
onDecline: () => void;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const [activeId, setActiveId] = useState(documents[0]?.id ?? 'eula');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const active = documents.find((document) => document.id === activeId) ?? documents[0];
|
||||
|
||||
const accept = async () => {
|
||||
if (!agreed || submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await onAccept();
|
||||
|
||||
if (result.success) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
setError(result.message ?? '提交失败,请重试');
|
||||
} catch (caught) {
|
||||
console.error('record onboarding consent failed', caught);
|
||||
setError(caught instanceof Error ? caught.message : '网络错误,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 ring-1 ring-primary/20 sm:size-12 sm:rounded-2xl">
|
||||
<ScrollText className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-balance text-lg font-semibold tracking-tight sm:text-xl">
|
||||
请阅读并同意以下协议
|
||||
</h2>
|
||||
<p className="mt-0.5 max-w-2xl text-pretty text-xs leading-relaxed text-muted-foreground sm:mt-1 sm:text-sm">
|
||||
同意一次后无需重复确认;仅当协议内容更新时才会再次请求确认。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{documents.length > 1 ? (
|
||||
<div className="mt-3 max-w-full overflow-x-auto pb-1 sm:mt-4">
|
||||
<SegmentedControl
|
||||
label="协议文档"
|
||||
value={activeId}
|
||||
onValueChange={(next) => setActiveId(next as AgreementDoc['id'])}
|
||||
options={documents.map((document) => ({
|
||||
value: document.id,
|
||||
label:
|
||||
document.title?.split('/')[0]?.trim() ||
|
||||
TAB_FALLBACK_TITLE[document.id] ||
|
||||
document.id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{active && (active.declaredVersion || active.effectiveDate) ? (
|
||||
<p className="mt-1.5 px-0.5 text-meta leading-relaxed text-muted-foreground">
|
||||
{active.declaredVersion ? `版本 / Version ${active.declaredVersion}` : ''}
|
||||
{active.declaredVersion && active.effectiveDate ? ' · ' : ''}
|
||||
{active.effectiveDate ? `生效 / Effective ${active.effectiveDate}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
tabIndex={0}
|
||||
aria-label={active?.title ? `${active.title}正文` : '协议正文'}
|
||||
className="mt-3 min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-2xl border border-border/80 bg-background/55 p-3 outline-none shadow-[inset_0_1px_2px_rgb(0_0_0/0.06)] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 sm:p-5"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{active ? (
|
||||
<Markdown content={active.text} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
未能加载协议文本,请刷新页面重试。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgreed((current) => !current)}
|
||||
aria-pressed={agreed}
|
||||
className={cn(
|
||||
'mt-3 flex min-h-11 w-full items-center gap-3 rounded-xl border px-3.5 py-2.5 text-start outline-none transition-[background-color,border-color,box-shadow] duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/40 sm:min-h-12 sm:px-4 sm:py-3',
|
||||
agreed
|
||||
? 'border-primary/60 bg-primary/5'
|
||||
: 'border-border hover:border-primary/40 hover:bg-accent/40',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-[5px] border-2 transition-colors',
|
||||
agreed
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/50 bg-background',
|
||||
)}
|
||||
>
|
||||
{agreed ? <Check className="size-3.5" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
<span className="text-sm font-medium leading-relaxed">
|
||||
我已阅读并同意《用户协议》与《隐私政策》
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="mt-2 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex items-center gap-3 sm:justify-between">
|
||||
<span className="hidden text-micro text-muted-foreground/70 sm:inline">
|
||||
agreements {version.slice(0, 8)}
|
||||
</span>
|
||||
<div className="grid w-full grid-cols-2 gap-3 sm:ml-auto sm:flex sm:w-auto sm:items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onDecline}
|
||||
disabled={submitting}
|
||||
className="h-11 w-full sm:w-auto"
|
||||
>
|
||||
不同意并退出
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void accept();
|
||||
}}
|
||||
disabled={!agreed || submitting}
|
||||
className="h-11 w-full sm:min-w-36 sm:w-auto"
|
||||
>
|
||||
<ShieldCheck className="size-4" />
|
||||
{submitting ? '提交中…' : '同意并继续'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordStep({
|
||||
knownOldPassword,
|
||||
mode,
|
||||
checkStrength,
|
||||
submit,
|
||||
onComplete,
|
||||
}: {
|
||||
knownOldPassword?: string;
|
||||
mode: 'change' | 'rehearsal';
|
||||
checkStrength: (password: string) => Promise<{ rules: PasswordRule[]; valid: boolean }>;
|
||||
submit: (
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
) => Promise<{ success: boolean; message?: string }>;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const { back } = useWizardNavigation();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-start py-2 sm:py-5">
|
||||
<div className="mb-8 flex items-start gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-primary/20">
|
||||
<ShieldAlert className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-balance text-lg font-semibold tracking-tight sm:text-xl">
|
||||
设置新的访问密码
|
||||
</h2>
|
||||
<p className="mt-1 text-pretty text-sm leading-relaxed text-muted-foreground">
|
||||
{mode === 'rehearsal'
|
||||
? '验证密码设置步骤的界面与规则;本次演练不会保存输入内容。'
|
||||
: '将首次启动生成的临时密码替换为符合要求的强密码。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChangePasswordForm
|
||||
knownOldPassword={knownOldPassword}
|
||||
mode={mode}
|
||||
checkStrength={checkStrength}
|
||||
submit={submit}
|
||||
onSuccess={onComplete}
|
||||
idPrefix="onboarding-cpw"
|
||||
submitLabel={mode === 'rehearsal' ? '完成演练' : '保存并完成'}
|
||||
className="gap-5"
|
||||
renderActions={({ canAttempt, submitting, submitLabel }) => (
|
||||
<div className="grid gap-3 pt-2 sm:flex sm:items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={back}
|
||||
disabled={submitting}
|
||||
className="h-11 w-full sm:w-auto"
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canAttempt}
|
||||
className="h-11 w-full sm:ml-auto sm:min-w-36 sm:w-auto"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" /> 提交中…
|
||||
</>
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AlertCircle, Cpu, Loader2, RefreshCw, Unplug } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { HookProcessInfo } from '@/types';
|
||||
import { useAppState } from '@/contexts/AppStateContext';
|
||||
import { useLayout } from '@/contexts/LayoutContext';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
const SORT_OPTIONS: { id: string; label: string }[] = [
|
||||
{ id: 'pid', label: 'PID' },
|
||||
{ id: 'name', label: '名称' },
|
||||
{ id: 'status', label: '状态' },
|
||||
];
|
||||
|
||||
function qqAvatarUrl(uin: string) {
|
||||
return `/avatar/${encodeURIComponent(uin)}`;
|
||||
}
|
||||
|
||||
// Online-first, then by how actionable the state is.
|
||||
const STATUS_ORDER: Record<HookProcessInfo['status'], number> = {
|
||||
online: 0,
|
||||
loaded: 1,
|
||||
connecting: 2,
|
||||
loading: 3,
|
||||
available: 4,
|
||||
disconnected: 5,
|
||||
error: 6,
|
||||
};
|
||||
|
||||
const processStatusLabel: Record<HookProcessInfo['status'], string> = {
|
||||
available: '可加载',
|
||||
loading: '加载中',
|
||||
connecting: '等待连接',
|
||||
loaded: '等待登录',
|
||||
online: '已在线',
|
||||
error: '错误',
|
||||
disconnected: '已断开',
|
||||
};
|
||||
|
||||
function processBadgeVariant(status: HookProcessInfo['status']) {
|
||||
if (status === 'online') {
|
||||
return 'success' as const;
|
||||
}
|
||||
|
||||
if (status === 'error' || status === 'disconnected') {
|
||||
return 'destructive' as const;
|
||||
}
|
||||
|
||||
if (status === 'loading' || status === 'connecting' || status === 'loaded') {
|
||||
return 'default' as const;
|
||||
}
|
||||
|
||||
return 'secondary' as const;
|
||||
}
|
||||
|
||||
export function ProcessesPage() {
|
||||
const { processList, processOps, refreshProcesses, resources } = useAppState();
|
||||
const { statusOf, banner: processActionStatus, load, unload, refresh } = processOps;
|
||||
const { pages, setPages } = useLayout();
|
||||
const off = useTheme().appearance.disableMotion;
|
||||
const [confirm, setConfirm] = useState<{
|
||||
kind: 'load' | 'unload';
|
||||
pid: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const sortKey = pages.processesSort;
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...processList];
|
||||
|
||||
if (sortKey === 'name') {
|
||||
arr.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (sortKey === 'status') {
|
||||
arr.sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || a.pid - b.pid);
|
||||
} else {
|
||||
arr.sort((a, b) => a.pid - b.pid);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [processList, sortKey]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>进程注入</CardTitle>
|
||||
<CardDescription>加载后,已登录账号将自动接入 OneBot</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{processList.length > 1 && (
|
||||
<div
|
||||
className="hidden items-center gap-1 rounded-lg bg-muted/60 p-1 sm:flex"
|
||||
role="radiogroup"
|
||||
aria-label="排序方式"
|
||||
>
|
||||
{SORT_OPTIONS.map((o) => {
|
||||
const active = sortKey === o.id;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setPages({ processesSort: o.id })}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-[background-color,color,box-shadow] duration-150 ease-out cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40',
|
||||
active
|
||||
? 'bg-card font-semibold text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshProcesses}
|
||||
disabled={!resources.processList.ready}
|
||||
>
|
||||
<RefreshCw className="size-3.5" /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{processActionStatus && (
|
||||
<motion.div
|
||||
initial={off ? false : { opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-3 flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/[0.06] px-3 py-2 text-xs text-primary"
|
||||
>
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
{processActionStatus}
|
||||
</motion.div>
|
||||
)}
|
||||
{resources.processList.error && processList.length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{resources.processList.error};当前显示上次成功读取的进程状态。
|
||||
</div>
|
||||
)}
|
||||
<SkeletonSwap
|
||||
ready={resources.processList.ready}
|
||||
reserve={192}
|
||||
lines={6}
|
||||
lineHeight={32}
|
||||
barHeight={10}
|
||||
label="进程列表"
|
||||
className={
|
||||
resources.processList.ready ? 'skeleton-swap-fluid min-h-[192px]' : ''
|
||||
}
|
||||
>
|
||||
{resources.processList.ready ? (
|
||||
resources.processList.error && processList.length === 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center justify-center gap-3 rounded-xl border border-destructive/30 bg-destructive/10 py-16 text-destructive"
|
||||
>
|
||||
<AlertCircle className="size-8 opacity-70" strokeWidth={1.5} />
|
||||
<p className="text-sm">{resources.processList.error}</p>
|
||||
<Button variant="outline" size="sm" onClick={refreshProcesses}>
|
||||
<RefreshCw className="size-3.5" /> 重试
|
||||
</Button>
|
||||
</div>
|
||||
) : processList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed py-16 text-muted-foreground">
|
||||
<Cpu className="size-8 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">未检测到可加载 QQ 主进程</p>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
启动 QQ 后刷新
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{sorted.map((proc, idx) => {
|
||||
const uin = proc.loggedIn ? proc.uin : proc.account?.uin;
|
||||
const nickname =
|
||||
proc.account?.uin === uin ? proc.account?.nickname : '';
|
||||
const name = nickname || uin || proc.name;
|
||||
const op = statusOf(proc.pid);
|
||||
const loading = op === 'load' || proc.status === 'loading';
|
||||
const unloading = op === 'unload';
|
||||
const refreshing = op === 'refresh';
|
||||
const busy = op != null || proc.status === 'loading';
|
||||
const canUnload = proc.injected;
|
||||
// Refresh is meaningful whenever a hook may exist (so the user
|
||||
// can re-check the pipe and trigger a reconnect on demand).
|
||||
const showRefresh =
|
||||
proc.injected ||
|
||||
proc.status === 'connecting' ||
|
||||
proc.status === 'disconnected';
|
||||
return (
|
||||
<motion.div
|
||||
key={proc.pid}
|
||||
initial={off ? false : { opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={
|
||||
off
|
||||
? { duration: 0 }
|
||||
: {
|
||||
delay: 0.03 + idx * 0.025,
|
||||
duration: 0.22,
|
||||
}
|
||||
}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card/50 p-3.5 transition-colors hover:bg-accent/20 sm:flex-row sm:items-center"
|
||||
>
|
||||
{/* icon + info stay horizontal; on phones the actions drop to
|
||||
a second row so name / PID / path get the full width
|
||||
instead of collapsing to "Q..". */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{uin ? (
|
||||
<Avatar size={40}>
|
||||
<AvatarImage
|
||||
src={qqAvatarUrl(uin)}
|
||||
alt={name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-xl',
|
||||
proc.status === 'error'
|
||||
? 'bg-destructive/15 text-destructive'
|
||||
: 'bg-primary/10 text-primary',
|
||||
)}
|
||||
>
|
||||
{proc.status === 'error' ? (
|
||||
<AlertCircle className="size-5" />
|
||||
) : (
|
||||
<Cpu className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-semibold">
|
||||
{name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={processBadgeVariant(
|
||||
proc.status,
|
||||
)}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
>
|
||||
{processStatusLabel[proc.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-meta text-muted-foreground tabular-nums">
|
||||
PID {proc.pid}
|
||||
{uin ? ` · QQ ${uin}` : ''}
|
||||
</div>
|
||||
{proc.path && (
|
||||
<div
|
||||
className="truncate text-meta text-muted-foreground/80"
|
||||
title={proc.path}
|
||||
>
|
||||
{proc.path}
|
||||
</div>
|
||||
)}
|
||||
{proc.error && (
|
||||
<div
|
||||
className="mt-0.5 truncate text-xs text-destructive"
|
||||
title={proc.error}
|
||||
>
|
||||
{proc.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
{showRefresh && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
aria-label={`刷新进程 ${proc.pid} 管道状态`}
|
||||
title="刷新管道状态 / 重连"
|
||||
onClick={() => refresh(proc.pid)}
|
||||
className="size-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canUnload ? 'outline' : 'default'}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: canUnload ? 'unload' : 'load',
|
||||
pid: proc.pid,
|
||||
name:
|
||||
proc.name || `PID ${proc.pid}`,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
canUnload &&
|
||||
'text-destructive hover:bg-destructive/10 hover:text-destructive',
|
||||
)}
|
||||
>
|
||||
{(loading || unloading) && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{!loading && !unloading && canUnload && (
|
||||
<Unplug className="size-3.5" />
|
||||
)}
|
||||
{canUnload
|
||||
? unloading
|
||||
? '卸载中'
|
||||
: '卸载'
|
||||
: loading
|
||||
? '加载中'
|
||||
: '加载'}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
onOpenChange={(o) => !o && setConfirm(null)}
|
||||
title={confirm?.kind === 'unload' ? '确认卸载 SnowLuma?' : '确认加载 SnowLuma?'}
|
||||
description={
|
||||
confirm
|
||||
? confirm.kind === 'unload'
|
||||
? `将从进程 ${confirm.name} 卸载 SnowLuma 注入,可能导致当前会话断开。`
|
||||
: `将向进程 ${confirm.name} 注入 SnowLuma,并开始监听登录状态。`
|
||||
: ''
|
||||
}
|
||||
confirmText={confirm?.kind === 'unload' ? '卸载' : '加载'}
|
||||
destructive={confirm?.kind === 'unload'}
|
||||
onConfirm={async () => {
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm.kind === 'unload') {
|
||||
await unload(confirm.pid);
|
||||
} else {
|
||||
await load(confirm.pid);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AlertTriangle, Compass, Home, RotateCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Tone = 'primary' | 'destructive';
|
||||
|
||||
function StatusShell({
|
||||
tone,
|
||||
icon,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
tone: Tone;
|
||||
icon: ReactNode;
|
||||
code?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] w-full flex-col items-center justify-center px-4 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex w-full max-w-md flex-col items-center"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
tone === 'destructive'
|
||||
? 'flex size-14 items-center justify-center rounded-2xl bg-destructive/10 ring-1 ring-destructive/20'
|
||||
: 'flex size-14 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-primary/20'
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
{code && (
|
||||
<span className="mt-4 font-mono text-4xl font-bold tracking-tight tabular-nums text-muted-foreground/60">
|
||||
{code}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="mt-2 text-xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rendered by the router for any unmatched path. */
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<StatusShell
|
||||
tone="primary"
|
||||
icon={<Compass className="size-7 text-primary" strokeWidth={1.75} />}
|
||||
code="404"
|
||||
title="页面不存在"
|
||||
description="你访问的地址没有对应的页面,可能链接已失效或输入有误。"
|
||||
>
|
||||
<Button onClick={() => window.location.assign('/')}>
|
||||
<Home className="size-4" /> 返回首页
|
||||
</Button>
|
||||
</StatusShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rendered by the router when a route component throws while rendering. */
|
||||
export function ErrorPage({ error, reset }: { error?: Error; reset?: () => void }) {
|
||||
const message = error?.message?.trim();
|
||||
return (
|
||||
<StatusShell
|
||||
tone="destructive"
|
||||
icon={<AlertTriangle className="size-7 text-destructive" strokeWidth={1.75} />}
|
||||
title="页面出错了"
|
||||
description="渲染该页面时发生了意外错误。可以重试,或返回首页继续操作。"
|
||||
>
|
||||
{reset && (
|
||||
<Button variant="outline" onClick={reset}>
|
||||
<RotateCw className="size-4" /> 重试
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => window.location.assign('/')}>
|
||||
<Home className="size-4" /> 返回首页
|
||||
</Button>
|
||||
{message && (
|
||||
<pre className="mt-3 max-h-32 w-full overflow-auto rounded-md border bg-muted/40 px-3 py-2 text-left text-meta leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||
{message}
|
||||
</pre>
|
||||
)}
|
||||
</StatusShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useId } from 'react';
|
||||
import { Bell, BookOpen, ExternalLink, Github, Globe, Link2, Server, Star } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppState } from '@/contexts/AppStateContext';
|
||||
import {
|
||||
ALL_LOG_LEVELS,
|
||||
LINK_ICON_IDS,
|
||||
NOTE_MAX,
|
||||
parseAlertsConfig,
|
||||
parseAccountConfig,
|
||||
parseConnectionsConfig,
|
||||
parseHostConfig,
|
||||
parseLinkConfig,
|
||||
parseNoteConfig,
|
||||
parseSessionsConfig,
|
||||
} from '@/lib/dashboard-layout';
|
||||
|
||||
/** Curated link-card icons, shared by the picker form and the widget renderer. */
|
||||
export const LINK_ICON_COMPONENTS: Record<string, typeof Link2> = {
|
||||
link: Link2,
|
||||
external: ExternalLink,
|
||||
github: Github,
|
||||
book: BookOpen,
|
||||
server: Server,
|
||||
globe: Globe,
|
||||
star: Star,
|
||||
bell: Bell,
|
||||
};
|
||||
|
||||
// Per-widget config forms, shown inside a dialog opened from a dashboard
|
||||
// widget's edit-overlay gear. `config` is the widget's opaque block.config;
|
||||
// `onChange` merges a partial back via setBlockConfig.
|
||||
|
||||
type SortOpt = 'recent' | 'uin' | 'nickname';
|
||||
const SORT_LABELS: Record<SortOpt, string> = { recent: '最近', uin: 'QQ 号', nickname: '昵称' };
|
||||
|
||||
interface FormProps {
|
||||
config: Record<string, unknown> | undefined;
|
||||
onChange: (c: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function AlertsConfigForm({ config, onChange }: FormProps) {
|
||||
const c = parseAlertsConfig(config);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">显示条数</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={c.count}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
count: Math.min(
|
||||
50,
|
||||
Math.max(1, Math.trunc(Number(e.target.value) || 1)),
|
||||
),
|
||||
})
|
||||
}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">显示级别</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_LOG_LEVELS.map((lv) => {
|
||||
const on = c.levels.includes(lv);
|
||||
return (
|
||||
<button
|
||||
key={lv}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = on
|
||||
? c.levels.filter((x) => x !== lv)
|
||||
: [...c.levels, lv];
|
||||
|
||||
if (next.length > 0) {
|
||||
onChange({ levels: next });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border px-2.5 py-1 text-xs uppercase transition-colors cursor-pointer',
|
||||
on
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent/40',
|
||||
)}
|
||||
>
|
||||
{lv}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsConfigForm({ config, onChange }: FormProps) {
|
||||
const c = parseSessionsConfig(config);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">排序</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(['recent', 'uin', 'nickname'] as SortOpt[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onChange({ sort: s })}
|
||||
className={cn(
|
||||
'rounded-md border px-2.5 py-1 text-xs transition-colors cursor-pointer',
|
||||
c.sort === s
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent/40',
|
||||
)}
|
||||
>
|
||||
{SORT_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-muted-foreground">筛选(昵称 / QQ 号)</span>
|
||||
<Input
|
||||
value={c.filter}
|
||||
placeholder="留空显示全部"
|
||||
maxLength={100}
|
||||
onChange={(e) => onChange({ filter: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HOST_PANELS: { key: 'cpu' | 'memory' | 'runtime'; label: string }[] = [
|
||||
{ key: 'cpu', label: 'CPU 使用率' },
|
||||
{ key: 'memory', label: '内存使用' },
|
||||
{ key: 'runtime', label: '运行进程' },
|
||||
];
|
||||
|
||||
export function HostConfigForm({ config, onChange }: FormProps) {
|
||||
const c = parseHostConfig(config);
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-sm text-muted-foreground">显示的指标</span>
|
||||
{HOST_PANELS.map((p) => (
|
||||
<label key={p.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>{p.label}</span>
|
||||
<ToggleSwitch
|
||||
value={c[p.key]}
|
||||
ariaLabel={p.label}
|
||||
onChange={(v) => {
|
||||
// Keep at least one panel on (an all-off card is empty).
|
||||
const next = { ...c, [p.key]: v };
|
||||
|
||||
if (next.cpu || next.memory || next.runtime) {
|
||||
onChange({ [p.key]: v });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CONN_SORTS: { key: 'default' | 'name' | 'status'; label: string }[] = [
|
||||
{ key: 'default', label: '默认' },
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'status', label: '异常优先' },
|
||||
];
|
||||
|
||||
export function ConnectionsConfigForm({ config, onChange }: FormProps) {
|
||||
const c = parseConnectionsConfig(config);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">排序</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CONN_SORTS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
onClick={() => onChange({ sort: s.key })}
|
||||
className={cn(
|
||||
'rounded-md border px-2.5 py-1 text-xs transition-colors cursor-pointer',
|
||||
c.sort === s.key
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent/40',
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">仅显示异常端点</span>
|
||||
<ToggleSwitch
|
||||
value={c.onlyIssues}
|
||||
ariaLabel="仅显示异常端点"
|
||||
onChange={(v) => onChange({ onlyIssues: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-muted-foreground">筛选(账号昵称 / QQ 号)</span>
|
||||
<Input
|
||||
value={c.filter}
|
||||
placeholder="留空显示全部"
|
||||
maxLength={100}
|
||||
onChange={(e) => onChange({ filter: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoteConfigForm({ config, onChange }: FormProps) {
|
||||
const c = parseNoteConfig(config);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm text-muted-foreground">便签内容(纯文本,保留换行)</span>
|
||||
<Textarea
|
||||
value={c.text}
|
||||
onChange={(e) => onChange({ text: e.target.value.slice(0, NOTE_MAX) })}
|
||||
maxLength={NOTE_MAX}
|
||||
spellCheck={false}
|
||||
placeholder="写点备注、待办或快捷信息…"
|
||||
className="h-40 resize-y rounded-lg bg-card/40 p-3 text-sm leading-relaxed"
|
||||
/>
|
||||
<span className="text-right text-meta text-muted-foreground">
|
||||
{c.text.length} / {NOTE_MAX}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkConfigForm({ config, onChange }: FormProps) {
|
||||
const c = parseLinkConfig(config);
|
||||
const urlInvalid = c_urlRaw(config) !== '' && !c.url;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-muted-foreground">标题</span>
|
||||
<Input
|
||||
value={c.label}
|
||||
placeholder="例如:项目文档"
|
||||
maxLength={60}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-muted-foreground">链接(http/https)</span>
|
||||
<Input
|
||||
value={c_urlRaw(config)}
|
||||
placeholder="https://example.com"
|
||||
maxLength={2048}
|
||||
onChange={(e) => onChange({ url: e.target.value })}
|
||||
className={cn('h-8', urlInvalid && 'border-destructive')}
|
||||
/>
|
||||
{urlInvalid && (
|
||||
<span className="text-xs text-destructive">
|
||||
仅支持 http:// 或 https:// 链接
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">图标</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LINK_ICON_IDS.map((ic) => {
|
||||
const Icon = LINK_ICON_COMPONENTS[ic];
|
||||
const on = c.icon === ic;
|
||||
return (
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
onClick={() => onChange({ icon: ic })}
|
||||
aria-label={ic}
|
||||
className={cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-md border transition-colors cursor-pointer',
|
||||
on
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent/40',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The raw (unvalidated) url so the field stays editable while typing an
|
||||
// incomplete URL; parseLinkConfig only blesses it once it's http(s).
|
||||
function c_urlRaw(config: Record<string, unknown> | undefined): string {
|
||||
const v = config?.url;
|
||||
return typeof v === 'string' ? v.slice(0, 2048) : '';
|
||||
}
|
||||
|
||||
export function AccountConfigForm({ config, onChange }: FormProps) {
|
||||
const c = parseAccountConfig(config);
|
||||
const { qqList } = useAppState();
|
||||
const listId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm text-muted-foreground">QQ 号</span>
|
||||
<Input
|
||||
value={c.uin}
|
||||
list={listId}
|
||||
inputMode="numeric"
|
||||
placeholder="指定一个账号的 QQ 号"
|
||||
maxLength={15}
|
||||
onChange={(e) => onChange({ uin: e.target.value.replace(/\D/g, '').slice(0, 15) })}
|
||||
className="h-8"
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{qqList.map((q) => (
|
||||
<option key={q.uin} value={q.uin}>
|
||||
{q.nickname || q.uin}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
可从已接入账号中选择,或手动输入。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const DEVELOPER_CRASH_MESSAGE = '开发者工具主动触发的 WebUI 崩溃测试';
|
||||
|
||||
/** Throws the deliberate render-time exception used to exercise ErrorPage. */
|
||||
export function raiseDeveloperCrash(): never {
|
||||
throw new Error(DEVELOPER_CRASH_MESSAGE);
|
||||
}
|
||||
|
||||
export function developerCrashReducer(_requested: boolean, action: 'confirm'): boolean {
|
||||
if (action === 'confirm') {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown developer crash action: ${String(action)}`);
|
||||
}
|
||||
|
||||
export function DeveloperCrashProbe({ requested }: { requested: boolean }): null {
|
||||
if (requested) {
|
||||
raiseDeveloperCrash();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle, KeyRound, Music, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DropdownSelect } from '@/components/ui/dropdown-select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useActionFeedback } from '@/contexts/ActionFeedbackContext';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { useFlashMessage } from '@/hooks/use-flash-message';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GlobalSettings } from '@/types';
|
||||
|
||||
const MUSIC_SIGN_DEFAULT = 'https://ss.xingzhige.com/music_card/card';
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return (u.protocol === 'http:' || u.protocol === 'https:') && u.host.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function GlobalConfigPanel() {
|
||||
const api = useApi();
|
||||
const { runAction } = useActionFeedback();
|
||||
const [config, setConfig] = useState<GlobalSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { msg, flash } = useFlashMessage();
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
const saveGen = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
setConfig(await api.globalConfig.get());
|
||||
} catch (error) {
|
||||
console.error('load global config failed', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Keep partially typed URLs in the draft until the next load.
|
||||
const commit = (
|
||||
next: GlobalSettings,
|
||||
feedback: false | { title: string; successTitle: string } = {
|
||||
title: '正在更新全局配置',
|
||||
successTitle: '全局配置已更新',
|
||||
},
|
||||
) => {
|
||||
setConfig(next);
|
||||
const gen = ++saveGen.current;
|
||||
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
}
|
||||
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
const dropped =
|
||||
next.rkey.fallbackServers.some((s) => s.trim() && !isHttpUrl(s.trim())) ||
|
||||
(next.musicSignUrl.trim().length > 0 && !isHttpUrl(next.musicSignUrl.trim()));
|
||||
const save =
|
||||
feedback === false
|
||||
? api.globalConfig.save(next)
|
||||
: runAction(
|
||||
{
|
||||
title: feedback.title,
|
||||
successTitle: feedback.successTitle,
|
||||
successDetail: dropped ? '已保存,无效项已忽略' : '配置已同步',
|
||||
errorTitle: '全局配置更新失败',
|
||||
},
|
||||
() => api.globalConfig.save(next),
|
||||
);
|
||||
void save
|
||||
.then(() => {
|
||||
if (saveGen.current !== gen) {
|
||||
return;
|
||||
}
|
||||
|
||||
flash('ok', dropped ? '已保存(无效项已忽略)' : '已保存');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (saveGen.current !== gen) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('save global config failed', error);
|
||||
flash('err', '保存失败,请检查服务器日志');
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
if (loading || !config) {
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready={!loading}
|
||||
lines={6}
|
||||
lineHeight={26}
|
||||
reserve={156}
|
||||
label="全局配置"
|
||||
className={!loading ? 'skeleton-swap-fluid min-h-[156px]' : ''}
|
||||
>
|
||||
{!loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-16 text-muted-foreground">
|
||||
<AlertTriangle className="size-8 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">加载全局配置失败</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
<RefreshCw className="size-3.5" /> 重试
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
|
||||
const servers = config.rkey.fallbackServers;
|
||||
const setServers = (next: string[]) =>
|
||||
commit({ ...config, rkey: { fallbackServers: next } }, false);
|
||||
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready
|
||||
lines={6}
|
||||
lineHeight={26}
|
||||
reserve={156}
|
||||
label="全局配置"
|
||||
className="skeleton-swap-fluid min-h-[156px]"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* rkey fallback servers */}
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-card/40 p-4">
|
||||
<div className="flex items-start gap-2 text-xs leading-relaxed text-muted-foreground">
|
||||
<KeyRound className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>
|
||||
内核无法获取 <code className="font-mono">rkey</code>{' '}
|
||||
时,依次请求下列服务器。留空关闭,建议使用自建服务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>rkey 回退服务器</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setServers([...servers, ''])}
|
||||
>
|
||||
<Plus className="size-3.5" /> 添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
回退服务器未启用
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{servers.map((url, i) => {
|
||||
const invalid = url.trim().length > 0 && !isHttpUrl(url.trim());
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
spellCheck={false}
|
||||
placeholder="https://your-rkey-server.example/rkeys"
|
||||
value={url}
|
||||
aria-invalid={invalid}
|
||||
className={cn(
|
||||
'font-mono text-xs',
|
||||
invalid &&
|
||||
'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setServers(
|
||||
servers.map((s, idx) =>
|
||||
idx === i ? e.target.value : s,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="删除"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
setServers(
|
||||
servers.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
端点需返回{' '}
|
||||
<code className="font-mono">
|
||||
{'{ group_rkey, private_rkey, expired_time }'}
|
||||
</code>
|
||||
,仅支持 HTTP(S)。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border bg-card/40 p-4">
|
||||
<Label>视频上传连接数</Label>
|
||||
<DropdownSelect
|
||||
ariaLabel="视频上传连接数"
|
||||
value={String(config.videoUploadConcurrency ?? 1)}
|
||||
options={[
|
||||
{ value: '1', label: '1 路(默认)' },
|
||||
{ value: '2', label: '2 路' },
|
||||
{ value: '3', label: '3 路' },
|
||||
{ value: '4', label: '4 路' },
|
||||
]}
|
||||
onChange={(value) =>
|
||||
commit({ ...config, videoUploadConcurrency: Number(value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
对所有账号的下一次视频上传生效。小视频仍使用单连接;多路上传的提速效果取决于网络和服务器,遇到拥塞时会降低并发。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* music sign URL */}
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-card/40 p-4">
|
||||
<div className="flex items-start gap-2 text-xs leading-relaxed text-muted-foreground">
|
||||
<Music className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>音乐分享卡片的签名服务,对所有账号统一生效。留空则使用内置默认服务。</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 border-t pt-3">
|
||||
<Label>音乐签名服务 URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
spellCheck={false}
|
||||
placeholder={MUSIC_SIGN_DEFAULT}
|
||||
value={config.musicSignUrl}
|
||||
aria-invalid={
|
||||
config.musicSignUrl.trim().length > 0 &&
|
||||
!isHttpUrl(config.musicSignUrl.trim())
|
||||
}
|
||||
className={cn(
|
||||
'font-mono text-xs',
|
||||
config.musicSignUrl.trim().length > 0 &&
|
||||
!isHttpUrl(config.musicSignUrl.trim()) &&
|
||||
'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
onChange={(e) => commit({ ...config, musicSignUrl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
msg.kind === 'ok' ? 'text-success' : 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
// Modal editor for a notification channel — used for both create and edit,
|
||||
// mirroring NodeEditDialog. Holds a local draft until 保存 (cancel discards).
|
||||
// The channel id is the stable key per-account opt-ins reference, so it's
|
||||
// editable only on create and locked on edit.
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DEFAULT_WEBHOOK_BODY,
|
||||
DEFAULT_EMAIL_BODY,
|
||||
DEFAULT_EMAIL_SUBJECT,
|
||||
} from '@snowluma/common/notification-templates';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Modal } from '@/components/interior/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PasswordVisibilityIcon } from '@/components/ui/password-visibility-icon';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
notificationChannelType,
|
||||
type NotificationChannel,
|
||||
type NotificationChannelType,
|
||||
} from '@/types';
|
||||
|
||||
interface HeaderRow {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function headersToRows(headers?: Record<string, string>): HeaderRow[] {
|
||||
const rows = Object.entries(headers ?? {}).map(([name, value]) => ({ name, value }));
|
||||
return rows.length > 0 ? rows : [{ name: '', value: '' }];
|
||||
}
|
||||
|
||||
function rowsToHeaders(rows: HeaderRow[]): Record<string, string> | undefined {
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim();
|
||||
const value = row.value.trim();
|
||||
|
||||
if (!name || !value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out[name] = value;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
const CHANNEL_ID_RE = /^[\w.-]+$/;
|
||||
|
||||
function isHttpUrl(u: string): boolean {
|
||||
try {
|
||||
const x = new URL(u.trim());
|
||||
return x.protocol === 'http:' || x.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeEmail(value: string): boolean {
|
||||
return /^[^\s<>@]+@[^\s<>@]+$/.test(value.trim()) || /<[^<>@\s]+@[^<>@\s]+>/.test(value);
|
||||
}
|
||||
|
||||
function hasRecipient(value: string): boolean {
|
||||
return value.split(/[,;\n]+/).some((part) => looksLikeEmail(part));
|
||||
}
|
||||
|
||||
export function applyType(
|
||||
draft: NotificationChannel,
|
||||
type: NotificationChannelType,
|
||||
): NotificationChannel {
|
||||
if (type === notificationChannelType(draft)) {
|
||||
return { ...draft, type };
|
||||
}
|
||||
|
||||
if (type === 'email') {
|
||||
const keepBody = draft.bodyTemplate && draft.bodyTemplate !== DEFAULT_WEBHOOK_BODY;
|
||||
return {
|
||||
...draft,
|
||||
type,
|
||||
url: '',
|
||||
headers: undefined,
|
||||
smtpHost: draft.smtpHost ?? '',
|
||||
smtpPort: draft.smtpPort ?? 465,
|
||||
smtpSecure: draft.smtpSecure ?? true,
|
||||
smtpUser: draft.smtpUser ?? '',
|
||||
smtpPass: draft.smtpPass ?? '',
|
||||
from: draft.from ?? '',
|
||||
to: draft.to ?? '',
|
||||
subjectTemplate: draft.subjectTemplate || DEFAULT_EMAIL_SUBJECT,
|
||||
bodyTemplate: keepBody ? draft.bodyTemplate : DEFAULT_EMAIL_BODY,
|
||||
};
|
||||
}
|
||||
|
||||
const keepBody = draft.bodyTemplate && draft.bodyTemplate !== DEFAULT_EMAIL_BODY;
|
||||
return {
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
type: 'webhook',
|
||||
url: draft.url,
|
||||
bodyTemplate: keepBody ? draft.bodyTemplate : DEFAULT_WEBHOOK_BODY,
|
||||
enabled: draft.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function toChannel(
|
||||
draft: NotificationChannel,
|
||||
headerRows: HeaderRow[],
|
||||
): NotificationChannel {
|
||||
const id = draft.id.trim();
|
||||
const name = draft.name.trim();
|
||||
const enabled = draft.enabled;
|
||||
|
||||
if (notificationChannelType(draft) === 'email') {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'email',
|
||||
url: '',
|
||||
bodyTemplate: draft.bodyTemplate,
|
||||
enabled,
|
||||
smtpHost: draft.smtpHost?.trim() ?? '',
|
||||
smtpPort: draft.smtpPort ?? 465,
|
||||
smtpSecure: draft.smtpSecure ?? true,
|
||||
smtpUser: draft.smtpUser?.trim() || undefined,
|
||||
smtpPass: draft.smtpPass || undefined,
|
||||
from: draft.from?.trim() ?? '',
|
||||
to: draft.to?.trim() ?? '',
|
||||
subjectTemplate: draft.subjectTemplate?.trim() || DEFAULT_EMAIL_SUBJECT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'webhook',
|
||||
url: draft.url.trim(),
|
||||
bodyTemplate: draft.bodyTemplate,
|
||||
enabled,
|
||||
headers: rowsToHeaders(headerRows),
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isEdit: boolean;
|
||||
/** Seed draft — a blank channel for create, the existing one for edit. */
|
||||
initial: NotificationChannel;
|
||||
/** Ids of every other channel, for the duplicate check. */
|
||||
otherIds: string[];
|
||||
onSubmit: (channel: NotificationChannel) => void;
|
||||
}
|
||||
|
||||
export function NotificationChannelDialog(props: NotificationChannelDialogProps) {
|
||||
const { open, onOpenChange, isEdit, initial, otherIds, onSubmit } = props;
|
||||
// Parent unmounts on close, so each open re-seeds via lazy init.
|
||||
const [draft, setDraft] = useState<NotificationChannel>(() => ({
|
||||
...initial,
|
||||
type: notificationChannelType(initial),
|
||||
}));
|
||||
const [headerRows, setHeaderRows] = useState<HeaderRow[]>(() => headersToRows(initial.headers));
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const reduce = useTheme().appearance.disableMotion;
|
||||
const patch = (p: Partial<NotificationChannel>) => setDraft({ ...draft, ...p });
|
||||
|
||||
const patchHeader = (index: number, p: Partial<HeaderRow>) => {
|
||||
setHeaderRows(headerRows.map((row, i) => (i === index ? { ...row, ...p } : row)));
|
||||
};
|
||||
|
||||
const type = notificationChannelType(draft);
|
||||
const id = draft.id.trim();
|
||||
const idBlank = id.length === 0;
|
||||
const idBad = !idBlank && (!CHANNEL_ID_RE.test(id) || id.length > 64);
|
||||
const idDup = !idBlank && otherIds.includes(id);
|
||||
const urlBlank = draft.url.trim().length === 0;
|
||||
const urlBad = !urlBlank && !isHttpUrl(draft.url);
|
||||
const hostBlank = !(draft.smtpHost ?? '').trim();
|
||||
const fromBlank = !(draft.from ?? '').trim();
|
||||
const fromBad = !fromBlank && !looksLikeEmail(draft.from ?? '');
|
||||
const toBlank = !(draft.to ?? '').trim();
|
||||
const toBad = !toBlank && !hasRecipient(draft.to ?? '');
|
||||
const port = draft.smtpPort ?? 465;
|
||||
const portBad = !Number.isInteger(port) || port < 1 || port > 65535;
|
||||
const canSave =
|
||||
type === 'email'
|
||||
? !idBlank &&
|
||||
!idBad &&
|
||||
!idDup &&
|
||||
!hostBlank &&
|
||||
!fromBlank &&
|
||||
!fromBad &&
|
||||
!toBlank &&
|
||||
!toBad &&
|
||||
!portBad
|
||||
: !idBlank && !idBad && !idDup && !urlBlank && !urlBad;
|
||||
|
||||
const idError = idBlank
|
||||
? '请填写渠道 ID'
|
||||
: idBad
|
||||
? '只能用字母 / 数字 / . _ - ,≤64 字符'
|
||||
: idDup
|
||||
? 'ID 与其它渠道重复'
|
||||
: undefined;
|
||||
const urlError = urlBlank ? '请填写 Webhook URL' : urlBad ? '必须是 http(s) 地址' : undefined;
|
||||
const hostError = hostBlank ? '请填写 SMTP 主机' : undefined;
|
||||
const fromError = fromBlank ? '请填写发件人' : fromBad ? '必须是邮箱地址' : undefined;
|
||||
const toError = toBlank ? '请填写收件人' : toBad ? '至少填写一个邮箱' : undefined;
|
||||
const portError = portBad ? '端口 1–65535' : undefined;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={isEdit ? '编辑渠道' : '新建渠道'}
|
||||
closeLabel="关闭渠道编辑弹窗"
|
||||
maxWidth={576}
|
||||
maxHeight="min(86dvh, 780px)"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
onSubmit(toChannel(draft, headerRows));
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{isEdit ? '保存修改' : '创建渠道'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg border bg-muted/40 p-1">
|
||||
{(
|
||||
[
|
||||
['webhook', 'Webhook'],
|
||||
['email', '邮件'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={type === value}
|
||||
onClick={() => setDraft(applyType(draft, value))}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
type === value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label="渠道 ID"
|
||||
placeholder="dingtalk"
|
||||
value={draft.id}
|
||||
disabled={isEdit}
|
||||
hint={isEdit ? '创建后不可更改' : '账号据此勾选;字母/数字/. _ -'}
|
||||
error={idError}
|
||||
onChange={(v) => patch({ id: v })}
|
||||
/>
|
||||
<Field
|
||||
label="显示名"
|
||||
placeholder={type === 'email' ? '运维邮箱' : '钉钉群机器人'}
|
||||
value={draft.name}
|
||||
onChange={(v) => patch({ name: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{type === 'webhook' ? (
|
||||
<>
|
||||
<Field
|
||||
label="Webhook URL"
|
||||
type="url"
|
||||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=…"
|
||||
value={draft.url}
|
||||
error={urlError}
|
||||
onChange={(v) => patch({ url: v })}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label>请求头</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setHeaderRows([...headerRows, { name: '', value: '' }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{headerRows.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={row.name}
|
||||
placeholder="Authorization"
|
||||
spellCheck={false}
|
||||
onChange={(e) =>
|
||||
patchHeader(i, { name: e.target.value })
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder="Bearer …"
|
||||
spellCheck={false}
|
||||
onChange={(e) =>
|
||||
patchHeader(i, { value: e.target.value })
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="删除请求头"
|
||||
onClick={() => {
|
||||
const next = headerRows.filter(
|
||||
(_, idx) => idx !== i,
|
||||
);
|
||||
setHeaderRows(
|
||||
next.length > 0
|
||||
? next
|
||||
: [{ name: '', value: '' }],
|
||||
);
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
可选,默认 Content-Type 为 application/json。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_7rem]">
|
||||
<Field
|
||||
label="SMTP 主机"
|
||||
placeholder="smtp.qq.com"
|
||||
value={draft.smtpHost ?? ''}
|
||||
error={hostError}
|
||||
onChange={(v) => patch({ smtpHost: v })}
|
||||
/>
|
||||
<Field
|
||||
label="端口"
|
||||
type="number"
|
||||
placeholder="465"
|
||||
value={String(draft.smtpPort ?? 465)}
|
||||
error={portError}
|
||||
onChange={(v) => {
|
||||
const n = Math.trunc(Number(v));
|
||||
|
||||
if (!Number.isFinite(n)) {
|
||||
patch({ smtpPort: 465 });
|
||||
return;
|
||||
}
|
||||
|
||||
patch({
|
||||
smtpPort: n,
|
||||
smtpSecure:
|
||||
n === 465 ? true : n === 587 ? false : draft.smtpSecure,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label="SMTP 用户名"
|
||||
placeholder="bot@qq.com"
|
||||
value={draft.smtpUser ?? ''}
|
||||
hint="部分内网中继可不填"
|
||||
onChange={(v) => patch({ smtpUser: v })}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>SMTP 密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={draft.smtpPass ?? ''}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
placeholder="授权码 / 密码"
|
||||
onChange={(e) => patch({ smtpPass: e.target.value })}
|
||||
className="pr-11 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass((v) => !v)}
|
||||
className="absolute right-1.5 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
aria-label={showPass ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
<PasswordVisibilityIcon
|
||||
visible={showPass}
|
||||
reduceMotion={reduce}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card/40 p-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">隐式 TLS</Label>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
465 开启,587 关闭(STARTTLS)。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={draft.smtpSecure ?? true}
|
||||
onChange={(v) => patch({ smtpSecure: v })}
|
||||
ariaLabel="隐式 TLS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label="发件人"
|
||||
placeholder="SnowLuma <bot@qq.com>"
|
||||
value={draft.from ?? ''}
|
||||
error={fromError}
|
||||
onChange={(v) => patch({ from: v })}
|
||||
/>
|
||||
<Field
|
||||
label="收件人"
|
||||
placeholder="ops@example.com, a@b.com"
|
||||
value={draft.to ?? ''}
|
||||
error={toError}
|
||||
hint="多个地址用逗号或分号分隔"
|
||||
onChange={(v) => patch({ to: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="主题模板"
|
||||
placeholder={DEFAULT_EMAIL_SUBJECT}
|
||||
value={draft.subjectTemplate ?? DEFAULT_EMAIL_SUBJECT}
|
||||
onChange={(v) => patch({ subjectTemplate: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{type === 'email' ? '正文模板' : 'Body 模板'}</Label>
|
||||
<Textarea
|
||||
value={draft.bodyTemplate}
|
||||
rows={type === 'email' ? 6 : 3}
|
||||
spellCheck={false}
|
||||
onChange={(e) => patch({ bodyTemplate: e.target.value })}
|
||||
className="font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
变量:<code className="font-mono">{'{uin}'}</code>{' '}
|
||||
<code className="font-mono">{'{nickname}'}</code>{' '}
|
||||
<code className="font-mono">{'{event}'}</code>(offline/online){' '}
|
||||
<code className="font-mono">{'{time}'}</code>{' '}
|
||||
<code className="font-mono">{'{status}'}</code>(Hook 状态){' '}
|
||||
<code className="font-mono">{'{pid}'}</code>(进程 ID){' '}
|
||||
<code className="font-mono">{'{error}'}</code>(进程错误)
|
||||
。进程信息为空表示未知。
|
||||
{type === 'email' ? '正文以 < 开头时按 HTML 发送。' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card/40 p-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">启用</Label>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
关闭后停止推送,保留配置。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={draft.enabled}
|
||||
onChange={(v) => patch({ enabled: v })}
|
||||
ariaLabel="启用渠道"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'url' | 'number';
|
||||
error?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
error,
|
||||
hint,
|
||||
disabled,
|
||||
}: FieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
error && 'border-destructive focus-visible:ring-destructive/40',
|
||||
disabled && 'opacity-60',
|
||||
)}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
) : hint ? (
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { DEFAULT_WEBHOOK_BODY } from '@snowluma/common/notification-templates';
|
||||
import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { useActionFeedback } from '@/contexts/ActionFeedbackContext';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { useFlashMessage } from '@/hooks/use-flash-message';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
notificationChannelType,
|
||||
type NotificationChannel,
|
||||
type NotificationsConfig,
|
||||
} from '@/types';
|
||||
import { NotificationChannelDialog } from './notification-channel-dialog';
|
||||
|
||||
/** A fresh channel with a non-colliding default id. */
|
||||
function blankChannel(existing: NotificationChannel[]): NotificationChannel {
|
||||
const used = new Set(existing.map((c) => c.id));
|
||||
let n = existing.length + 1;
|
||||
|
||||
while (used.has(`channel-${n}`)) {
|
||||
n += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `channel-${n}`,
|
||||
name: '',
|
||||
type: 'webhook',
|
||||
url: '',
|
||||
bodyTemplate: DEFAULT_WEBHOOK_BODY,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
index: number | null; // null → create
|
||||
seed: NotificationChannel;
|
||||
}
|
||||
|
||||
export function NotificationsPanel() {
|
||||
const api = useApi();
|
||||
const { runAction } = useActionFeedback();
|
||||
const [config, setConfig] = useState<NotificationsConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const { msg, flash, setMsg } = useFlashMessage();
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
const saveGen = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMsg(null);
|
||||
|
||||
try {
|
||||
setConfig(await api.notifications.getConfig());
|
||||
} catch (error) {
|
||||
console.error('load notifications config failed', error);
|
||||
setMsg({ kind: 'err', text: '加载通知配置失败' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api, setMsg]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Apply locally + debounced auto-save. A generation guard reconciles with
|
||||
* the server's normalized result only if no newer edit landed meanwhile. */
|
||||
const commit = (
|
||||
next: NotificationsConfig,
|
||||
copy: { title: string; successTitle: string } = {
|
||||
title: '正在更新通知配置',
|
||||
successTitle: '通知配置已更新',
|
||||
},
|
||||
) => {
|
||||
setConfig(next);
|
||||
const gen = ++saveGen.current;
|
||||
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
}
|
||||
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
void runAction(
|
||||
{
|
||||
title: copy.title,
|
||||
detail: '正在同步通知渠道设置',
|
||||
successTitle: copy.successTitle,
|
||||
successDetail: '通知配置已同步',
|
||||
errorTitle: '通知配置更新失败',
|
||||
},
|
||||
() => api.notifications.saveConfig(next),
|
||||
)
|
||||
.then((result) => {
|
||||
// Only the latest save reconciles + confirms; a superseded in-flight
|
||||
// save stays silent (its successor will confirm).
|
||||
if (saveGen.current !== gen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConfig(result);
|
||||
flash('ok', '已保存');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (saveGen.current !== gen) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('save notifications config failed', error);
|
||||
flash('err', '保存失败,请检查服务器日志');
|
||||
});
|
||||
}, 350);
|
||||
};
|
||||
|
||||
if (loading || !config) {
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready={!loading}
|
||||
lines={6}
|
||||
lineHeight={26}
|
||||
reserve={156}
|
||||
label="通知配置"
|
||||
className={!loading ? 'skeleton-swap-fluid min-h-[156px]' : ''}
|
||||
>
|
||||
{!loading && !config ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
>
|
||||
<span>{msg?.text ?? '加载通知配置失败'}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
|
||||
const channels = config.channels;
|
||||
const setChannels = (
|
||||
next: NotificationChannel[],
|
||||
copy?: { title: string; successTitle: string },
|
||||
) => commit({ ...config, channels: next }, copy);
|
||||
const otherIds = (index: number | null) =>
|
||||
channels.filter((_, i) => i !== index).map((c) => c.id);
|
||||
|
||||
const openCreate = () => setDialog({ index: null, seed: blankChannel(channels) });
|
||||
const openEdit = (i: number) => setDialog({ index: i, seed: channels[i] });
|
||||
|
||||
const submitChannel = (ch: NotificationChannel, index: number | null) => {
|
||||
if (index === null) {
|
||||
setChannels([...channels, ch], {
|
||||
title: '正在新增通知渠道',
|
||||
successTitle: '通知渠道已新增',
|
||||
});
|
||||
} else {
|
||||
setChannels(
|
||||
channels.map((c, idx) => (idx === index ? ch : c)),
|
||||
{ title: '正在更新通知渠道', successTitle: '通知渠道已更新' },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async (id: string) => {
|
||||
setTesting(id);
|
||||
|
||||
// Persist any pending edit first — the server tests the stored channel by id.
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await runAction(
|
||||
{
|
||||
title: '正在测试通知渠道',
|
||||
detail: id,
|
||||
successTitle: '测试通知已发送',
|
||||
successDetail: id,
|
||||
errorTitle: '通知渠道测试失败',
|
||||
resultError: (result) =>
|
||||
result.success ? null : result.message || '测试发送失败',
|
||||
},
|
||||
async () => {
|
||||
await api.notifications.saveConfig(config);
|
||||
return api.notifications.test(id);
|
||||
},
|
||||
);
|
||||
flash(
|
||||
res.success ? 'ok' : 'err',
|
||||
res.message ?? (res.success ? '测试发送成功' : '测试发送失败'),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('test notification channel failed', error);
|
||||
flash('err', '测试请求失败');
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready
|
||||
lines={6}
|
||||
lineHeight={26}
|
||||
reserve={156}
|
||||
label="通知配置"
|
||||
className="skeleton-swap-fluid min-h-[156px]"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Global settings */}
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-card/40 p-4">
|
||||
<div className="flex items-start gap-2 text-xs leading-relaxed text-muted-foreground">
|
||||
<Bell className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>在此管理通知渠道,再到账号「配置」页选择启用。</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 border-t pt-3">
|
||||
<Label>去抖窗口(秒)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
className="w-32 tabular-nums"
|
||||
value={config.debounceSeconds}
|
||||
onChange={(e) => {
|
||||
const n = Math.trunc(Number(e.target.value));
|
||||
commit({
|
||||
...config,
|
||||
debounceSeconds: Number.isFinite(n)
|
||||
? Math.min(3600, Math.max(0, n))
|
||||
: 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
下线持续超过此时长才通知,恢复时再通知。0 表示立即通知。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold tracking-tight">通知渠道</h3>
|
||||
{channels.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={openCreate}>
|
||||
<Plus className="size-3.5" /> 新增渠道
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{channels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-16 text-muted-foreground">
|
||||
<Bell className="size-8 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">还没有通知渠道</p>
|
||||
<Button variant="outline" size="sm" onClick={openCreate}>
|
||||
<Plus className="size-3.5" /> 创建第一个
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{channels.map((ch, i) => (
|
||||
<ChannelCard
|
||||
key={ch.id}
|
||||
channel={ch}
|
||||
testing={testing === ch.id}
|
||||
onToggle={(v) =>
|
||||
setChannels(
|
||||
channels.map((c, idx) =>
|
||||
idx === i ? { ...c, enabled: v } : c,
|
||||
),
|
||||
{
|
||||
title: '正在更新通知渠道',
|
||||
successTitle: '通知渠道已更新',
|
||||
},
|
||||
)
|
||||
}
|
||||
onTest={() => void test(ch.id)}
|
||||
onEdit={() => openEdit(i)}
|
||||
onDelete={() =>
|
||||
setChannels(
|
||||
channels.filter((_, idx) => idx !== i),
|
||||
{
|
||||
title: '正在删除通知渠道',
|
||||
successTitle: '通知渠道已删除',
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
msg.kind === 'ok' ? 'text-success' : 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<NotificationChannelDialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setDialog(null)}
|
||||
isEdit={dialog.index != null}
|
||||
initial={dialog.seed}
|
||||
otherIds={otherIds(dialog.index)}
|
||||
onSubmit={(channel) => submitChannel(channel, dialog.index)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChannelCardProps {
|
||||
channel: NotificationChannel;
|
||||
testing: boolean;
|
||||
onToggle: (v: boolean) => void;
|
||||
onTest: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/** Info-only row + inline actions. No live-typed fields — editing is the dialog. */
|
||||
function ChannelCard({ channel, testing, onToggle, onTest, onEdit, onDelete }: ChannelCardProps) {
|
||||
const off = useTheme().appearance.disableMotion;
|
||||
return (
|
||||
<motion.div
|
||||
initial={off ? false : { opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={off ? { duration: 0 } : { duration: 0.18 }}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-xl border bg-card/40 p-3.5 transition-[background-color,opacity] duration-150 ease-out hover:bg-accent/20 sm:flex-row sm:items-center sm:gap-3',
|
||||
!channel.enabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{channel.name || channel.id}</span>
|
||||
<Badge variant="secondary" className="font-mono font-normal">
|
||||
{channel.id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{notificationChannelType(channel) === 'email' ? '邮件' : 'Webhook'}
|
||||
</Badge>
|
||||
{!channel.enabled && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
已停用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{notificationChannelType(channel) === 'email'
|
||||
? [channel.to, channel.smtpHost].filter(Boolean).join(' · ')
|
||||
: channel.url}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-meta text-muted-foreground/80">
|
||||
{notificationChannelType(channel) === 'email'
|
||||
? channel.subjectTemplate || channel.bodyTemplate
|
||||
: channel.bodyTemplate}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:justify-end">
|
||||
<ToggleSwitch
|
||||
value={channel.enabled}
|
||||
onChange={onToggle}
|
||||
ariaLabel={`启用 ${channel.name || channel.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
aria-label="测试发送"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="optical-forward size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onEdit}
|
||||
aria-label="编辑"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
aria-label="删除"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Clock3,
|
||||
Database,
|
||||
FileClock,
|
||||
HardDrive,
|
||||
Image,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Save,
|
||||
SmilePlus,
|
||||
Trash2,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { useActionFeedback } from '@/contexts/ActionFeedbackContext';
|
||||
import { useFlashMessage } from '@/hooks/use-flash-message';
|
||||
import { useApi } from '@/lib/api';
|
||||
import {
|
||||
buildLogSettingsPatch,
|
||||
formatBytes,
|
||||
isAllAccountsConfirmation,
|
||||
logStoragePresentation,
|
||||
type StorageTone,
|
||||
} from '@/lib/storage-presentation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ALL_ACCOUNTS_CONFIRMATION,
|
||||
type AccountStorageCategory,
|
||||
type AccountStorageSnapshot,
|
||||
type LastStorageCleanup,
|
||||
type LogStorageSettings,
|
||||
type LogStorageSettingsField,
|
||||
type StorageCleanupRequest,
|
||||
type StorageCleanupResponse,
|
||||
type StorageOverviewResponse,
|
||||
} from '@/types';
|
||||
|
||||
const CATEGORY_INFO: Record<
|
||||
AccountStorageCategory,
|
||||
{ label: string; description: string; icon: LucideIcon; bytes: keyof AccountStorageSnapshot }
|
||||
> = {
|
||||
messages: {
|
||||
label: '消息历史',
|
||||
description: 'OneBot 消息与回复索引',
|
||||
icon: MessageSquare,
|
||||
bytes: 'messagesBytes',
|
||||
},
|
||||
media: {
|
||||
label: '媒体索引',
|
||||
description: '图片、语音与视频的元数据',
|
||||
icon: Image,
|
||||
bytes: 'mediaBytes',
|
||||
},
|
||||
reactions: {
|
||||
label: '表情回应',
|
||||
description: '群消息表情回应记录',
|
||||
icon: SmilePlus,
|
||||
bytes: 'reactionsBytes',
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORIES = Object.keys(CATEGORY_INFO) as AccountStorageCategory[];
|
||||
|
||||
interface PendingCleanup {
|
||||
request: Exclude<StorageCleanupRequest, { scope: 'allAccounts' }>;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function StoragePanel() {
|
||||
const api = useApi();
|
||||
const { runAction } = useActionFeedback();
|
||||
const [data, setData] = useState<StorageOverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [operation, setOperation] = useState<string | null>(null);
|
||||
const [maxTotalMb, setMaxTotalMb] = useState('');
|
||||
const [retainDays, setRetainDays] = useState('');
|
||||
const [perUin, setPerUin] = useState(false);
|
||||
const [pendingCleanup, setPendingCleanup] = useState<PendingCleanup | null>(null);
|
||||
const [allAccountsCategory, setAllAccountsCategory] = useState<AccountStorageCategory | null>(
|
||||
null,
|
||||
);
|
||||
const { msg, flash, setMsg } = useFlashMessage(5000);
|
||||
|
||||
const applyOverview = (overview: StorageOverviewResponse) => {
|
||||
setData(overview);
|
||||
setMaxTotalMb(String(overview.settings.effective.logMaxTotalMb));
|
||||
setRetainDays(String(overview.settings.effective.logRetainDays));
|
||||
setPerUin(overview.settings.effective.logPerUin);
|
||||
};
|
||||
|
||||
const load = async (showSpinner = false): Promise<boolean> => {
|
||||
if (showSpinner) {
|
||||
if (data === null) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setRefreshing(true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const overview = await api.storage.get();
|
||||
applyOverview(overview);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('load storage overview failed', error);
|
||||
setMsg({
|
||||
kind: 'err',
|
||||
text: error instanceof Error ? error.message : '加载存储信息失败',
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const isLocked = (field: LogStorageSettingsField): boolean =>
|
||||
data?.settings.envOverrides.includes(field) ?? false;
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!data || saving || operation !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMaxTotalMb = Number(maxTotalMb);
|
||||
const nextRetainDays = Number(retainDays);
|
||||
|
||||
if (!Number.isSafeInteger(nextMaxTotalMb) || nextMaxTotalMb < 1) {
|
||||
flash('err', '日志总量上限必须是大于 0 的整数');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isSafeInteger(nextRetainDays) || nextRetainDays < 0) {
|
||||
flash('err', '保留天数必须是大于或等于 0 的整数');
|
||||
return;
|
||||
}
|
||||
|
||||
const current: LogStorageSettings = {
|
||||
logMaxTotalMb: nextMaxTotalMb,
|
||||
logRetainDays: nextRetainDays,
|
||||
logPerUin: perUin,
|
||||
};
|
||||
const patch = buildLogSettingsPatch(
|
||||
current,
|
||||
data.settings.saved,
|
||||
data.settings.envOverrides,
|
||||
);
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const result = await runAction(
|
||||
{
|
||||
title: '正在更新日志策略',
|
||||
detail: '正在保存并应用存储限制',
|
||||
successTitle: '日志策略已更新',
|
||||
successDetail: '新策略已立即生效',
|
||||
errorTitle: '日志策略更新失败',
|
||||
},
|
||||
() => api.storage.saveSettings(patch),
|
||||
);
|
||||
applyOverview({
|
||||
settings: result.settings,
|
||||
snapshot: result.snapshot,
|
||||
lastCleanup: data.lastCleanup,
|
||||
});
|
||||
flash('ok', '日志策略已保存并立即生效');
|
||||
} catch (error) {
|
||||
flash('err', error instanceof Error ? error.message : '保存失败');
|
||||
await load();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runCleanup = async (request: StorageCleanupRequest): Promise<boolean> => {
|
||||
if (saving || operation !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = cleanupKey(request);
|
||||
setOperation(key);
|
||||
|
||||
try {
|
||||
const result = await runAction(
|
||||
{
|
||||
title: '正在清理存储数据',
|
||||
detail: cleanupLabel(request),
|
||||
successTitle: '存储数据清理完成',
|
||||
successDetail: cleanupResultSummary,
|
||||
errorTitle: '存储数据清理失败',
|
||||
resultError: (outcome) =>
|
||||
outcome.success ? null : cleanupResultSummary(outcome),
|
||||
},
|
||||
() => api.storage.cleanup(request),
|
||||
);
|
||||
|
||||
if (result.snapshot) {
|
||||
setData((previous) =>
|
||||
previous
|
||||
? {
|
||||
...previous,
|
||||
snapshot: result.snapshot!,
|
||||
lastCleanup: result.lastCleanup ?? previous.lastCleanup,
|
||||
}
|
||||
: previous,
|
||||
);
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
|
||||
flash(result.success ? 'ok' : 'err', cleanupResultSummary(result));
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
flash('err', error instanceof Error ? error.message : '清理失败');
|
||||
await load();
|
||||
return false;
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready={!loading}
|
||||
lines={7}
|
||||
lineHeight={26}
|
||||
reserve={182}
|
||||
label="存储信息"
|
||||
className={!loading ? 'skeleton-swap-fluid min-h-[182px]' : ''}
|
||||
>
|
||||
{!loading && !data ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-start gap-3 pt-5">
|
||||
<p className="text-sm text-muted-foreground">存储信息暂时不可用。</p>
|
||||
<Button variant="outline" onClick={() => void load(true)}>
|
||||
<RefreshCw className="size-4" />
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
|
||||
const { snapshot } = data;
|
||||
const logPresentation = logStoragePresentation(snapshot.logs);
|
||||
const onlineCount = snapshot.accounts.filter((account) => account.online).length;
|
||||
const allCategoryBytes = Object.fromEntries(
|
||||
CATEGORIES.map((category) => [
|
||||
category,
|
||||
snapshot.accounts.reduce(
|
||||
(sum, account) => sum + Number(account[CATEGORY_INFO[category].bytes]),
|
||||
0,
|
||||
),
|
||||
]),
|
||||
) as Record<AccountStorageCategory, number>;
|
||||
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready
|
||||
lines={7}
|
||||
lineHeight={26}
|
||||
reserve={182}
|
||||
label="存储信息"
|
||||
className="skeleton-swap-fluid min-h-[182px]"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{msg && (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 text-xs',
|
||||
msg.kind === 'ok'
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
)}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<HardDrive className="size-4" />
|
||||
存储概览
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
只统计 SnowLuma 管理的日志、账号数据库和文件传输临时数据。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load(true)}
|
||||
disabled={refreshing || saving || operation !== null}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw className={cn('size-4', refreshing && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Metric
|
||||
label="受管数据总量"
|
||||
value={formatBytes(snapshot.totals.managedBytes)}
|
||||
/>
|
||||
<Metric label="日志" value={formatBytes(snapshot.totals.logsBytes)} />
|
||||
<Metric
|
||||
label="账号数据库"
|
||||
value={formatBytes(snapshot.totals.accountDataBytes)}
|
||||
/>
|
||||
<Metric
|
||||
label="传输临时数据"
|
||||
value={formatBytes(snapshot.totals.temporaryBytes)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/35 p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">日志容量</p>
|
||||
<p className="mt-0.5 text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(snapshot.logs.totalBytes)} /{' '}
|
||||
{formatBytes(snapshot.logs.maxTotalBytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={toneBadgeVariant(logPresentation.tone)}>
|
||||
{logPresentation.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={logPresentation.percent}
|
||||
aria-label="日志容量使用率"
|
||||
indicatorClassName={toneProgressClass(logPresentation.tone)}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{snapshot.logs.fileCount} 个文件</span>
|
||||
<span>{snapshot.logs.activeFileCount} 个活动文件</span>
|
||||
<span>丢弃 {snapshot.logs.droppedLines} 行磁盘日志</span>
|
||||
</div>
|
||||
{snapshot.logs.lastError && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="break-words">{snapshot.logs.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.lastCleanup && <LastCleanupSummary cleanup={data.lastCleanup} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<FileClock className="size-4" />
|
||||
日志策略
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
总量上限始终生效;保留天数设为 0 时仅按总量淘汰。保存后立即应用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor="storage-log-max"
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
>
|
||||
日志总量上限(MB)
|
||||
<EnvironmentBadge locked={isLocked('logMaxTotalMb')} />
|
||||
</Label>
|
||||
<Input
|
||||
id="storage-log-max"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={maxTotalMb}
|
||||
onChange={(event) => setMaxTotalMb(event.target.value)}
|
||||
disabled={
|
||||
isLocked('logMaxTotalMb') || saving || operation !== null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor="storage-log-days"
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
>
|
||||
保留天数
|
||||
<EnvironmentBadge locked={isLocked('logRetainDays')} />
|
||||
</Label>
|
||||
<Input
|
||||
id="storage-log-days"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={retainDays}
|
||||
onChange={(event) => setRetainDays(event.target.value)}
|
||||
disabled={
|
||||
isLocked('logRetainDays') || saving || operation !== null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-11 items-center justify-between gap-4 rounded-lg bg-muted/35 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="flex flex-wrap items-center gap-1 text-sm font-medium">
|
||||
按账号额外写入日志
|
||||
<EnvironmentBadge locked={isLocked('logPerUin')} />
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
开启后,同一条带账号上下文的日志还会写入账号子目录,占用会增加。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={perUin}
|
||||
onChange={setPerUin}
|
||||
ariaLabel="按账号额外写入日志"
|
||||
disabled={isLocked('logPerUin') || saving || operation !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => void saveSettings()}
|
||||
disabled={
|
||||
saving ||
|
||||
operation !== null ||
|
||||
data.settings.envOverrides.length === 3
|
||||
}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
保存并应用
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Trash2 className="size-4" />
|
||||
全局数据清理
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
日志清理会轮转活动文件;临时数据清理会自动跳过正在进行的上传和下载。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y divide-border/60 p-0">
|
||||
<CleanupRow
|
||||
icon={FileClock}
|
||||
title="日志"
|
||||
description={`${snapshot.logs.fileCount} 个文件 · ${formatBytes(snapshot.logs.totalBytes)}`}
|
||||
busy={operation === 'logs'}
|
||||
disabled={saving || operation !== null}
|
||||
onClick={() =>
|
||||
setPendingCleanup({
|
||||
request: { scope: 'logs' },
|
||||
title: '清理全部日志?',
|
||||
description:
|
||||
'SnowLuma 会先轮转活动日志,再删除旧日志,只保留新建的空文件。',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<CleanupRow
|
||||
icon={Activity}
|
||||
title="文件传输临时数据"
|
||||
description={`${snapshot.temporary.fileCount} 个文件 · ${formatBytes(snapshot.temporary.totalBytes)} · ${snapshot.temporary.activeItemCount} 个活动传输`}
|
||||
busy={operation === 'temporary'}
|
||||
disabled={saving || operation !== null}
|
||||
onClick={() =>
|
||||
setPendingCleanup({
|
||||
request: { scope: 'temporary' },
|
||||
title: '清理非活动临时数据?',
|
||||
description:
|
||||
'正在上传或下载的项目会保留,其余 SnowLuma 文件传输临时数据将被删除。',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Database className="size-4" />
|
||||
账号数据
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
仅清理选中的数据库分类。账号必须离线,配置、凭据和聊天内容导出不在此页面提供。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{snapshot.accounts.length === 0 ? (
|
||||
<div className="rounded-lg bg-muted/35 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
尚未发现账号数据库。
|
||||
</div>
|
||||
) : (
|
||||
snapshot.accounts.map((account) => (
|
||||
<AccountStorageCard
|
||||
key={account.uin}
|
||||
account={account}
|
||||
operation={operation}
|
||||
saving={saving}
|
||||
onRequest={(category) => {
|
||||
const info = CATEGORY_INFO[category];
|
||||
setPendingCleanup({
|
||||
request: {
|
||||
scope: 'account',
|
||||
category,
|
||||
uin: account.uin,
|
||||
},
|
||||
title: `清理 ${account.nickname || account.uin} 的${info.label}?`,
|
||||
description: `将删除账号 ${account.uin} 的${info.label}数据库及其边车文件,其他分类和配置不受影响。`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{snapshot.accounts.length > 0 && (
|
||||
<div className="rounded-lg bg-muted/35 p-3">
|
||||
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<Users className="size-4" />
|
||||
全部账号
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{onlineCount > 0
|
||||
? `${onlineCount} 个账号仍在线,全部账号清理暂不可用`
|
||||
: '选择一个分类,并在弹窗中输入确认短语'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{CATEGORIES.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant="outline"
|
||||
onClick={() => setAllAccountsCategory(category)}
|
||||
disabled={
|
||||
operation !== null ||
|
||||
saving ||
|
||||
onlineCount > 0 ||
|
||||
allCategoryBytes[category] === 0
|
||||
}
|
||||
className="justify-between"
|
||||
>
|
||||
<span>清理全部{CATEGORY_INFO[category].label}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(allCategoryBytes[category])}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingCleanup !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && operation === null) {
|
||||
setPendingCleanup(null);
|
||||
}
|
||||
}}
|
||||
title={pendingCleanup?.title ?? ''}
|
||||
description={pendingCleanup?.description}
|
||||
confirmText="确认清理"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!pendingCleanup) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runCleanup(pendingCleanup.request);
|
||||
setPendingCleanup(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AllAccountsCleanupDialog
|
||||
category={allAccountsCategory}
|
||||
busy={operation?.startsWith('allAccounts:') ?? false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && operation === null) {
|
||||
setAllAccountsCategory(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async (category) => {
|
||||
await runCleanup({
|
||||
scope: 'allAccounts',
|
||||
category,
|
||||
confirmation: ALL_ACCOUNTS_CONFIRMATION,
|
||||
});
|
||||
setAllAccountsCategory(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/35 p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold tabular-nums">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentBadge({ locked }: { locked: boolean }) {
|
||||
if (!locked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 text-micro">
|
||||
<Lock className="size-3" />
|
||||
环境变量锁定
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function CleanupRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
busy,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full text-destructive hover:bg-destructive/10 hover:text-destructive sm:w-auto"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
清理
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountStorageCard({
|
||||
account,
|
||||
operation,
|
||||
saving,
|
||||
onRequest,
|
||||
}: {
|
||||
account: AccountStorageSnapshot;
|
||||
operation: string | null;
|
||||
saving: boolean;
|
||||
onRequest: (category: AccountStorageCategory) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{account.nickname || `账号 ${account.uin}`}
|
||||
</p>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">{account.uin}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(account.totalBytes)}
|
||||
</span>
|
||||
<Badge variant={account.online ? 'warning' : 'secondary'}>
|
||||
{account.online ? '在线' : '离线'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{CATEGORIES.map((category) => {
|
||||
const info = CATEGORY_INFO[category];
|
||||
const Icon = info.icon;
|
||||
const bytes = Number(account[info.bytes]);
|
||||
const key = `account:${account.uin}:${category}`;
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="flex flex-col gap-3 rounded-md bg-muted/35 p-3"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium">{info.label}</p>
|
||||
<p className="mt-0.5 text-micro leading-relaxed text-muted-foreground">
|
||||
{info.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(bytes)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRequest(category)}
|
||||
disabled={
|
||||
saving ||
|
||||
operation !== null ||
|
||||
account.online ||
|
||||
bytes === 0
|
||||
}
|
||||
aria-label={`清理 ${account.uin} 的${info.label}`}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{operation === key ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
清理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LastCleanupSummary({ cleanup }: { cleanup: LastStorageCleanup }) {
|
||||
const label = cleanup.category
|
||||
? CATEGORY_INFO[cleanup.category].label
|
||||
: cleanup.scope === 'logs'
|
||||
? '日志'
|
||||
: '传输临时数据';
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Clock3 className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p>
|
||||
最近清理:{label},删除 {cleanup.deletedFiles} 个文件,释放{' '}
|
||||
{formatBytes(cleanup.freedBytes)}
|
||||
{cleanup.failureCount > 0 ? `,${cleanup.failureCount} 项失败` : ''}
|
||||
{' · '}
|
||||
{formatTimestamp(cleanup.at)}
|
||||
</p>
|
||||
{cleanup.failures.length > 0 && (
|
||||
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-destructive">
|
||||
{cleanup.failures.slice(0, 3).map((failure, index) => (
|
||||
<li key={`${failure.item}:${String(index)}`} className="break-words">
|
||||
{failure.item}:{failure.message}
|
||||
</li>
|
||||
))}
|
||||
{cleanup.failures.length > 3 && (
|
||||
<li>另有 {cleanup.failures.length - 3} 项失败,请检查服务器日志</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllAccountsCleanupDialog({
|
||||
category,
|
||||
busy,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
category: AccountStorageCategory | null;
|
||||
busy: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (category: AccountStorageCategory) => Promise<void>;
|
||||
}) {
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
const open = category !== null;
|
||||
|
||||
const close = () => {
|
||||
setConfirmation('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !busy) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
title={`清理全部账号的${category ? CATEGORY_INFO[category].label : '数据'}?`}
|
||||
description={
|
||||
<>
|
||||
该操作会删除所有离线账号的对应数据库及边车文件,无法撤销。请输入
|
||||
<strong className="mx-1 text-foreground">{ALL_ACCOUNTS_CONFIRMATION}</strong>
|
||||
继续。
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="all-account-cleanup-confirmation">确认短语</Label>
|
||||
<Input
|
||||
id="all-account-cleanup-confirmation"
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
placeholder={ALL_ACCOUNTS_CONFIRMATION}
|
||||
autoComplete="off"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
confirmText="确认清理"
|
||||
destructive
|
||||
confirmDisabled={!isAllAccountsConfirmation(confirmation) || busy || !category}
|
||||
onConfirm={async () => {
|
||||
if (!category || !isAllAccountsConfirmation(confirmation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onConfirm(category);
|
||||
setConfirmation('');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanupKey(request: StorageCleanupRequest): string {
|
||||
if (request.scope === 'account') {
|
||||
return `account:${request.uin}:${request.category}`;
|
||||
}
|
||||
|
||||
if (request.scope === 'allAccounts') {
|
||||
return `allAccounts:${request.category}`;
|
||||
}
|
||||
|
||||
return request.scope;
|
||||
}
|
||||
|
||||
function cleanupLabel(request: StorageCleanupRequest): string {
|
||||
if (request.scope === 'logs') {
|
||||
return '全部日志';
|
||||
}
|
||||
|
||||
if (request.scope === 'temporary') {
|
||||
return '非活动文件传输临时数据';
|
||||
}
|
||||
|
||||
const label = CATEGORY_INFO[request.category].label;
|
||||
return request.scope === 'allAccounts'
|
||||
? `全部账号的${label}`
|
||||
: `账号 ${request.uin} 的${label}`;
|
||||
}
|
||||
|
||||
function cleanupResultSummary(result: StorageCleanupResponse): string {
|
||||
const summary = [
|
||||
`删除 ${result.cleanup.deletedFiles} 个文件`,
|
||||
`释放 ${formatBytes(result.cleanup.freedBytes)}`,
|
||||
];
|
||||
|
||||
if ('skippedActiveItems' in result.cleanup && result.cleanup.skippedActiveItems > 0) {
|
||||
summary.push(`跳过 ${result.cleanup.skippedActiveItems} 个活动传输`);
|
||||
}
|
||||
|
||||
if (result.cleanup.failures.length > 0) {
|
||||
summary.push(`${result.cleanup.failures.length} 项失败`);
|
||||
}
|
||||
|
||||
return summary.join(',');
|
||||
}
|
||||
|
||||
function toneBadgeVariant(tone: StorageTone): BadgeProps['variant'] {
|
||||
if (tone === 'success') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (tone === 'warning') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (tone === 'danger') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
function toneProgressClass(tone: StorageTone): string {
|
||||
if (tone === 'danger') {
|
||||
return 'bg-destructive';
|
||||
}
|
||||
|
||||
if (tone === 'warning') {
|
||||
return 'bg-warning';
|
||||
}
|
||||
|
||||
if (tone === 'neutral') {
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
|
||||
return 'bg-success';
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
// Listener changes apply after restart; environment overrides remain authoritative.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Download,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useActionFeedback } from '@/contexts/ActionFeedbackContext';
|
||||
import { useApi } from '@/lib/api';
|
||||
import { useFlashMessage } from '@/hooks/use-flash-message';
|
||||
import { cn, downloadBlob } from '@/lib/utils';
|
||||
import type { SystemSettingsResponse } from '@/types';
|
||||
|
||||
export function SystemPanel() {
|
||||
const api = useApi();
|
||||
const { runAction } = useActionFeedback();
|
||||
const [data, setData] = useState<SystemSettingsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { msg, flash, setMsg } = useFlashMessage(4000);
|
||||
|
||||
// editable form state
|
||||
const [port, setPort] = useState('');
|
||||
const [host, setHost] = useState('127.0.0.1');
|
||||
const [trustProxy, setTrustProxy] = useState('');
|
||||
const [tlsEnabled, setTlsEnabled] = useState(false);
|
||||
const [certPem, setCertPem] = useState('');
|
||||
const [keyPem, setKeyPem] = useState('');
|
||||
// When a cert is already installed we hide the PEM inputs (sensitive) and
|
||||
// show a "已安装" summary + 更改 button; editing reveals the inputs again.
|
||||
const [editingCert, setEditingCert] = useState(false);
|
||||
|
||||
// backup / restore
|
||||
const [exportCreds, setExportCreds] = useState(false);
|
||||
const [restoreCreds, setRestoreCreds] = useState(false);
|
||||
const [confirmDeleteCert, setConfirmDeleteCert] = useState(false);
|
||||
const [pendingBackup, setPendingBackup] = useState<File | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await api.systemSettings.get();
|
||||
setData(res);
|
||||
setPort(String(res.settings.webuiPort));
|
||||
setHost(res.settings.webuiHost);
|
||||
setTrustProxy(res.settings.trustProxy);
|
||||
setTlsEnabled(res.settings.tlsEnabled);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('load system settings failed', error);
|
||||
setMsg({ kind: 'err', text: '加载系统设置失败' });
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const overridden = (field: string) => data?.envOverrides.includes(field) ?? false;
|
||||
|
||||
const saveSettings = async () => {
|
||||
const portNum = Number(port);
|
||||
|
||||
if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) {
|
||||
flash('err', '端口必须是 1–65535 的整数');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await runAction(
|
||||
{
|
||||
title: '正在更新服务设置',
|
||||
detail: `${host.trim()}:${portNum}`,
|
||||
successTitle: '服务设置已更新',
|
||||
successDetail: '重启 SnowLuma 后生效',
|
||||
errorTitle: '服务设置更新失败',
|
||||
},
|
||||
() =>
|
||||
api.systemSettings.save({
|
||||
webuiPort: portNum,
|
||||
webuiHost: host.trim(),
|
||||
trustProxy,
|
||||
tlsEnabled,
|
||||
}),
|
||||
);
|
||||
await load();
|
||||
flash('ok', '已保存,重启 SnowLuma 后生效');
|
||||
} catch (e) {
|
||||
flash('err', e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCert = async () => {
|
||||
if (!certPem.trim() || !keyPem.trim()) {
|
||||
flash('err', '请同时填入证书与私钥');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await runAction(
|
||||
{
|
||||
title: '正在更新 TLS 证书',
|
||||
detail: '正在校验证书与私钥',
|
||||
successTitle: 'TLS 证书已更新',
|
||||
successDetail: '重启 SnowLuma 后生效',
|
||||
errorTitle: 'TLS 证书更新失败',
|
||||
},
|
||||
() => api.systemSettings.uploadCert(certPem, keyPem),
|
||||
);
|
||||
setCertPem('');
|
||||
setKeyPem('');
|
||||
setEditingCert(false);
|
||||
await load();
|
||||
flash('ok', '证书已保存,重启后生效');
|
||||
} catch (e) {
|
||||
flash('err', e instanceof Error ? e.message : '证书保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCert = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await runAction(
|
||||
{
|
||||
title: '正在删除 TLS 证书',
|
||||
detail: '正在移除服务器上的证书与私钥',
|
||||
successTitle: 'TLS 证书已删除',
|
||||
errorTitle: 'TLS 证书删除失败',
|
||||
},
|
||||
() => api.systemSettings.deleteCert(),
|
||||
);
|
||||
setEditingCert(false);
|
||||
await load();
|
||||
flash('ok', '证书已删除');
|
||||
} catch (e) {
|
||||
flash('err', e instanceof Error ? e.message : '删除失败');
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportBackup = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const bundle = await runAction(
|
||||
{
|
||||
title: '正在导出配置备份',
|
||||
detail: exportCreds ? '包含敏感配置' : '不包含敏感配置',
|
||||
successTitle: '配置备份已导出',
|
||||
errorTitle: '配置备份导出失败',
|
||||
},
|
||||
() => api.systemSettings.exportBackup(exportCreds),
|
||||
);
|
||||
downloadBlob(
|
||||
new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }),
|
||||
`snowluma-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json`,
|
||||
);
|
||||
flash('ok', '已导出备份');
|
||||
} catch (e) {
|
||||
flash('err', e instanceof Error ? e.message : '导出失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importBackup = async (file: File) => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
let parsed: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('文件不是有效的 JSON');
|
||||
}
|
||||
|
||||
const res = await runAction(
|
||||
{
|
||||
title: '正在恢复配置备份',
|
||||
detail: file.name,
|
||||
successTitle: '配置备份已恢复',
|
||||
successDetail: '恢复结果已写入服务器',
|
||||
errorTitle: '配置备份恢复失败',
|
||||
},
|
||||
() => api.systemSettings.importBackup(parsed, restoreCreds),
|
||||
);
|
||||
const parts = res.restored.length
|
||||
? [`已恢复 ${res.restored.length} 项`]
|
||||
: ['没有可恢复的配置'];
|
||||
|
||||
if (res.skipped.length) {
|
||||
parts.push(`跳过 ${res.skipped.length} 项(敏感配置,含 OneBot 配置)`);
|
||||
}
|
||||
|
||||
if (res.migrated.length) {
|
||||
parts.push(`兼容转换 ${res.migrated.length} 项`);
|
||||
}
|
||||
|
||||
const summary = `${parts.join(',')}${res.restartRequiredToApply ? ',重启后生效' : ''}`;
|
||||
const refreshed = await load();
|
||||
flash(
|
||||
refreshed ? 'ok' : 'err',
|
||||
refreshed ? summary : `${summary};但界面状态刷新失败,请手动刷新页面`,
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : '恢复失败';
|
||||
const refreshed = await load();
|
||||
flash('err', refreshed ? message : `${message};界面状态刷新失败,请手动刷新页面`);
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
if (fileRef.current) {
|
||||
fileRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready={!loading}
|
||||
lines={7}
|
||||
lineHeight={26}
|
||||
reserve={182}
|
||||
label="系统设置"
|
||||
className={!loading ? 'skeleton-swap-fluid min-h-[182px]' : ''}
|
||||
>
|
||||
{!loading && !data ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
>
|
||||
<span>{msg?.text ?? '加载系统设置失败'}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
|
||||
const EnvBadge = ({ field }: { field: string }) =>
|
||||
overridden(field) ? (
|
||||
<Badge variant="secondary" className="ml-2 gap-1 text-micro">
|
||||
<Lock className="h-3 w-3" /> 被环境变量覆盖
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SkeletonSwap
|
||||
ready
|
||||
lines={7}
|
||||
lineHeight={26}
|
||||
reserve={182}
|
||||
label="系统设置"
|
||||
className="skeleton-swap-fluid min-h-[182px]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
{/* restart-to-apply notice */}
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
监听相关设置(端口 / 绑定地址 / HTTPS)属服务级配置,保存后需
|
||||
<strong>重启 SnowLuma</strong> 才生效;本机无自动重启。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 text-xs',
|
||||
msg.kind === 'ok'
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
)}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">服务监听</CardTitle>
|
||||
<CardDescription>WebUI 当前监听端口:{data?.listeningPort}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="flex items-center">
|
||||
端口
|
||||
<EnvBadge field="webuiPort" />
|
||||
</Label>
|
||||
<Input
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="5099"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="flex items-center">
|
||||
绑定地址
|
||||
<EnvBadge field="webuiHost" />
|
||||
</Label>
|
||||
<Input
|
||||
value={host}
|
||||
onChange={(event) => setHost(event.target.value)}
|
||||
placeholder="127.0.0.1"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setHost('127.0.0.1')}
|
||||
>
|
||||
仅本机
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setHost('0.0.0.0')}
|
||||
>
|
||||
所有网卡
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
host.trim() === '0.0.0.0'
|
||||
? 'text-amber-700 dark:text-amber-300'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{host.trim() === '0.0.0.0'
|
||||
? '所有网卡会允许局域网访问;建议同时启用 HTTPS,并设置强访问凭据。'
|
||||
: '默认仅本机访问;也可填写其他明确的绑定地址。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="flex items-center">
|
||||
信任代理 (trust-proxy)
|
||||
<EnvBadge field="trustProxy" />
|
||||
</Label>
|
||||
<Input
|
||||
value={trustProxy}
|
||||
onChange={(e) => setTrustProxy(e.target.value)}
|
||||
placeholder="留空=只信任 socket 对端;1=信任反代头"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
仅在 WebUI 位于受信任反向代理之后时才设为 1 / loopback / IP 列表。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={saveSettings} disabled={saving} className="gap-1.5">
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}{' '}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="h-4 w-4" /> HTTPS / TLS
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{data?.hasCert ? '已安装证书。' : '尚未安装证书。'} 启用 TLS
|
||||
需先安装有效的证书与私钥(PEM)。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>启用 HTTPS</Label>
|
||||
<ToggleSwitch
|
||||
value={tlsEnabled}
|
||||
onChange={setTlsEnabled}
|
||||
ariaLabel="启用 HTTPS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data?.hasCert && !editingCert ? (
|
||||
// Cert already installed — don't re-render the sensitive PEM. Show a
|
||||
// summary + actions; private key is never sent back to the client.
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||
<span>证书与私钥已安装(出于安全不回显,未改变)。</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditingCert(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Pencil className="h-4 w-4" /> 更改证书
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDeleteCert(true)}
|
||||
disabled={saving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> 删除证书
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>证书 (cert.pem)</Label>
|
||||
<Textarea
|
||||
className="min-h-28 font-mono text-xs"
|
||||
value={certPem}
|
||||
onChange={(e) => setCertPem(e.target.value)}
|
||||
placeholder="-----BEGIN CERTIFICATE-----"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>私钥 (key.pem)</Label>
|
||||
<Textarea
|
||||
className="min-h-28 font-mono text-xs"
|
||||
value={keyPem}
|
||||
onChange={(e) => setKeyPem(e.target.value)}
|
||||
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={uploadCert}
|
||||
disabled={saving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}{' '}
|
||||
保存证书
|
||||
</Button>
|
||||
{data?.hasCert && editingCert && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingCert(false);
|
||||
setCertPem('');
|
||||
setKeyPem('');
|
||||
}}
|
||||
className="gap-1.5"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
私钥仅写入服务器 config/key.pem(0600
|
||||
权限),不会回显。证书无效时保存会被拒绝;启用 TLS
|
||||
后证书无法加载将阻止 WebUI 启动,不会降级为 HTTP。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Database className="h-4 w-4" /> 配置备份 / 恢复
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
备份外观、系统设置和证书等配置;通知渠道与 OneBot
|
||||
配置仅在包含敏感配置时导出。不含消息数据库。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>导出时包含敏感配置</Label>
|
||||
<ToggleSwitch
|
||||
value={exportCreds}
|
||||
onChange={setExportCreds}
|
||||
ariaLabel="导出包含敏感配置"
|
||||
/>
|
||||
</div>
|
||||
{exportCreds && (
|
||||
<p className="text-sm leading-relaxed text-red-600 dark:text-red-400">
|
||||
⚠ 将额外包含 WebUI 登录状态、TLS 私钥、通知渠道地址与 OneBot
|
||||
访问令牌,请妥善保管。
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
onClick={exportBackup}
|
||||
disabled={saving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}{' '}
|
||||
导出备份
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>恢复时一并恢复敏感配置</Label>
|
||||
<ToggleSwitch
|
||||
value={restoreCreds}
|
||||
onChange={setRestoreCreds}
|
||||
ariaLabel="恢复包含敏感配置"
|
||||
/>
|
||||
</div>
|
||||
{restoreCreds && (
|
||||
<p className="text-sm leading-relaxed text-red-600 dark:text-red-400">
|
||||
⚠ 将覆盖当前 WebUI 登录状态、TLS 私钥、通知渠道地址与 OneBot
|
||||
访问令牌;若备份口令未知可能登不进。
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
setPendingBackup(file);
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={saving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}{' '}
|
||||
选择备份文件并恢复
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
恢复前会校验本次要恢复的配置;写入过程中出错会自动回滚,成功后临时快照会删除。恢复后需重启生效。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteCert}
|
||||
onOpenChange={setConfirmDeleteCert}
|
||||
title="删除 TLS 证书?"
|
||||
description="将移除服务器上的证书与私钥。若 HTTPS 仍处于启用状态,下次启动将无法继续使用当前证书。"
|
||||
confirmText="删除证书"
|
||||
destructive
|
||||
onConfirm={deleteCert}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingBackup !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingBackup(null);
|
||||
}
|
||||
}}
|
||||
title="恢复配置备份?"
|
||||
description={
|
||||
pendingBackup
|
||||
? `将从 ${pendingBackup.name} 覆盖当前系统配置${restoreCreds ? ',包括敏感配置、TLS 私钥与 OneBot 配置' : ''}。成功后需要重启 SnowLuma。`
|
||||
: ''
|
||||
}
|
||||
confirmText="确认恢复"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!pendingBackup) {
|
||||
return;
|
||||
}
|
||||
|
||||
await importBackup(pendingBackup);
|
||||
setPendingBackup(null);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { KeyRound, ShieldCheck } from 'lucide-react';
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Modal } from '@/components/interior/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useApi } from '@/lib/api';
|
||||
import type { TotpEnrollment, TotpStatus } from '@/lib/api/types';
|
||||
import { parseSecondFactor } from '@/lib/totp-second-factor';
|
||||
import { TotpQr } from '@/components/settings/totp-qr';
|
||||
import { TotpRecoveryCodes } from '@/components/settings/totp-recovery-codes';
|
||||
import { copyText } from '@/lib/utils';
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{hint && (
|
||||
<p className="mt-0.5 text-[12px] leading-snug text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TotpPanel() {
|
||||
const api = useApi();
|
||||
const [status, setStatus] = useState<TotpStatus | 'hidden' | 'loading'>('loading');
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [disableOpen, setDisableOpen] = useState(false);
|
||||
const [regenOpen, setRegenOpen] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await api.totp.status());
|
||||
} catch {
|
||||
setStatus('hidden');
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
if (status === 'hidden') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="gap-1 pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||||
<ShieldCheck className="size-4 text-primary" />
|
||||
双重验证
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[12px] leading-relaxed">
|
||||
登录时在访问密码之外再验证 Authenticator 一次性密码。默认关闭。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="divide-y divide-border/60 border-t border-border/60">
|
||||
{status === 'loading' ? (
|
||||
<SettingRow label="2FA" hint="正在读取…">
|
||||
<span className="text-sm text-muted-foreground">…</span>
|
||||
</SettingRow>
|
||||
) : status.enabled ? (
|
||||
<>
|
||||
<SettingRow
|
||||
label="状态"
|
||||
hint={`已绑定 ${status.label} · 剩余 ${status.remainingRecoveryCodes} 个恢复码`}
|
||||
>
|
||||
<span className="text-sm text-success">已开启</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="恢复码" hint="重新生成后旧码立即作废。">
|
||||
<Button variant="outline" size="sm" onClick={() => setRegenOpen(true)}>
|
||||
重新生成
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="关闭 2FA"
|
||||
hint="需要当前密码,以及验证码或恢复码。其他设备的会话将失效。"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDisableOpen(true)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</SettingRow>
|
||||
</>
|
||||
) : (
|
||||
<SettingRow
|
||||
label="Authenticator"
|
||||
hint="扫描二维码或手动输入密钥,再用 6 位验证码确认。"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => setSetupOpen(true)}>
|
||||
<KeyRound className="size-4" /> 开启 2FA
|
||||
</Button>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TotpSetupDialog
|
||||
open={setupOpen}
|
||||
onOpenChange={setSetupOpen}
|
||||
onEnabled={async () => {
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
<TotpChallengeDialog
|
||||
open={disableOpen}
|
||||
onOpenChange={setDisableOpen}
|
||||
title="关闭双重验证"
|
||||
description="关闭后,只需访问密码即可登录。其他已登录会话会立即失效。"
|
||||
confirmText="关闭 2FA"
|
||||
destructive
|
||||
onSubmit={async (password, second) => api.totp.disable(password, second)}
|
||||
onDone={refresh}
|
||||
/>
|
||||
<TotpRegenDialog open={regenOpen} onOpenChange={setRegenOpen} onDone={refresh} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TotpSetupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onEnabled,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEnabled: () => void | Promise<void>;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const [enrollment, setEnrollment] = useState<TotpEnrollment | null>(null);
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [accountName, setAccountName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
|
||||
const loadEnrollment = useCallback(
|
||||
async (nextIssuer?: string, nextAccount?: string) => {
|
||||
const result = await api.totp.begin({
|
||||
...(nextIssuer ? { issuer: nextIssuer } : {}),
|
||||
...(nextAccount ? { accountName: nextAccount } : {}),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setEnrollment(result);
|
||||
setIssuer(result.issuer);
|
||||
setAccountName(result.accountName);
|
||||
setError('');
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setEnrollment(null);
|
||||
setPassword('');
|
||||
setCode('');
|
||||
setError('');
|
||||
setRecoveryCodes(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadEnrollment();
|
||||
}, [open, loadEnrollment]);
|
||||
|
||||
const refreshLabels = async () => {
|
||||
if (!enrollment) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadEnrollment(issuer, accountName);
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
await loadEnrollment(issuer, accountName);
|
||||
const result = await api.totp.confirm(password, code);
|
||||
setBusy(false);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecoveryCodes(result.recoveryCodes);
|
||||
await onEnabled();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!recoveryCodes) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
title={recoveryCodes ? '保存恢复码' : '开启双重验证'}
|
||||
description={
|
||||
recoveryCodes
|
||||
? '这些恢复码只显示一次。丢失 Authenticator 时用它们登录。'
|
||||
: '用 Authenticator 扫描二维码,或手动输入密钥,然后输入当前访问密码和 6 位验证码。'
|
||||
}
|
||||
closeLabel="关闭 2FA 绑定"
|
||||
maxWidth={440}
|
||||
>
|
||||
{recoveryCodes ? (
|
||||
<TotpRecoveryCodes codes={recoveryCodes} onConfirm={() => onOpenChange(false)} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{enrollment && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<TotpQr value={enrollment.otpauthUrl} label="2FA 绑定二维码" />
|
||||
<p className="break-all text-center font-mono text-xs text-muted-foreground">
|
||||
{enrollment.secret}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void copyText(enrollment.secret);
|
||||
}}
|
||||
>
|
||||
复制密钥
|
||||
</Button>
|
||||
<a
|
||||
href={enrollment.otpauthUrl}
|
||||
className="inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium text-foreground"
|
||||
>
|
||||
在验证器中打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
||||
服务名
|
||||
<Input
|
||||
value={issuer}
|
||||
onChange={(e) => setIssuer(e.target.value)}
|
||||
onBlur={() => {
|
||||
void refreshLabels();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
||||
账号名
|
||||
<Input
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.target.value)}
|
||||
onBlur={() => {
|
||||
void refreshLabels();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="当前访问密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
pattern="[0-9]*"
|
||||
placeholder="6 位验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || !password || !code}
|
||||
onClick={() => {
|
||||
void confirm();
|
||||
}}
|
||||
>
|
||||
{busy ? '正在开启…' : '确认开启'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TotpChallengeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmText,
|
||||
destructive,
|
||||
onSubmit,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText: string;
|
||||
destructive?: boolean;
|
||||
onSubmit: (
|
||||
password: string,
|
||||
second: { totp?: string; recoveryCode?: string },
|
||||
) => Promise<{ success: boolean; message?: string }>;
|
||||
onDone: () => void | Promise<void>;
|
||||
}) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [second, setSecond] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPassword('');
|
||||
setSecond('');
|
||||
setError('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
const result = await onSubmit(password, parseSecondFactor(second));
|
||||
setBusy(false);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message ?? '操作失败');
|
||||
return;
|
||||
}
|
||||
|
||||
await onDone();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={title}
|
||||
description={description}
|
||||
closeLabel={`关闭${title}`}
|
||||
maxWidth={400}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="当前访问密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<Input
|
||||
placeholder="6 位验证码或恢复码"
|
||||
value={second}
|
||||
onChange={(e) => setSecond(e.target.value)}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<Button
|
||||
type="button"
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={busy || !password || !second.trim()}
|
||||
onClick={() => {
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{busy ? '处理中…' : confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TotpRegenDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onDone: () => void | Promise<void>;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const [password, setPassword] = useState('');
|
||||
const [totp, setTotp] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [codes, setCodes] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPassword('');
|
||||
setTotp('');
|
||||
setError('');
|
||||
setCodes(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
const result = await api.totp.regenerateRecoveryCodes(password, totp.trim());
|
||||
setBusy(false);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setCodes(result.recoveryCodes);
|
||||
await onDone();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!codes) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
title={codes ? '新的恢复码' : '重新生成恢复码'}
|
||||
description={
|
||||
codes
|
||||
? '旧恢复码已作废。请保存下面的新码。'
|
||||
: '需要当前密码和 Authenticator 验证码。旧恢复码会立即作废。'
|
||||
}
|
||||
closeLabel="关闭恢复码"
|
||||
maxWidth={440}
|
||||
>
|
||||
{codes ? (
|
||||
<TotpRecoveryCodes codes={codes} onConfirm={() => onOpenChange(false)} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="当前访问密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
placeholder="6 位验证码"
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || !password || !totp.trim()}
|
||||
onClick={() => {
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{busy ? '正在生成…' : '生成新恢复码'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMemo } from 'react';
|
||||
import { encode } from 'uqr';
|
||||
|
||||
export function TotpQr({ value, label }: { value: string; label: string }) {
|
||||
const qr = useMemo(() => encode(value, { ecc: 'M', border: 4 }), [value]);
|
||||
const modulePx = 4;
|
||||
const px = qr.size * modulePx;
|
||||
const d = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
qr.data.forEach((row, y) => {
|
||||
row.forEach((on, x) => {
|
||||
if (on) {
|
||||
parts.push(
|
||||
`M${x * modulePx},${y * modulePx}h${modulePx}v${modulePx}h-${modulePx}z`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return parts.join('');
|
||||
}, [qr]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-2">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={label}
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox={`0 0 ${px} ${px}`}
|
||||
className="block text-black"
|
||||
shapeRendering="crispEdges"
|
||||
>
|
||||
<rect width={px} height={px} fill="white" />
|
||||
<path d={d} fill="black" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { copyText } from '@/lib/utils';
|
||||
|
||||
export function TotpRecoveryCodes({
|
||||
codes,
|
||||
onConfirm,
|
||||
}: {
|
||||
codes: string[];
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
每个恢复码只能用一次。请立刻抄写或保存到离线位置,关闭后将无法再查看。
|
||||
</p>
|
||||
<ol className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{codes.map((code) => (
|
||||
<li key={code}>
|
||||
<input
|
||||
readOnly
|
||||
value={code}
|
||||
className="w-full rounded-md border bg-muted/40 px-3 py-2 text-center tracking-wide outline-none"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={async () => {
|
||||
const ok = await copyText(codes.join('\n'));
|
||||
setCopied(ok);
|
||||
setCopyError(!ok);
|
||||
|
||||
if (ok) {
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
{copied ? '已复制' : '复制全部'}
|
||||
</Button>
|
||||
{copyError && (
|
||||
<p className="text-xs text-destructive">复制失败,请长按上面的码手动拷贝。</p>
|
||||
)}
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saved}
|
||||
onChange={(e) => setSaved(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
我已把恢复码保存到安全的地方
|
||||
</label>
|
||||
<Button type="button" disabled={!saved} onClick={onConfirm}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useActionFeedback } from '@/contexts/ActionFeedbackContext';
|
||||
import { useApi } from '@/lib/api';
|
||||
import type { UpdateStatus } from '@/types';
|
||||
|
||||
export function UpdateInstaller({ version }: { version: string | null }) {
|
||||
const api = useApi();
|
||||
const { startAction, runAction } = useActionFeedback();
|
||||
const [status, setStatus] = useState<UpdateStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRestart, setConfirmRestart] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const phase = status?.phase;
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== undefined && phase !== 'downloading') {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const activity = phase === 'downloading' ? startAction({ title: '正在下载更新' }) : null;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.update.status();
|
||||
|
||||
if (disposed && !activity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!disposed) {
|
||||
setStatus(next);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
if (next.phase === 'ready') {
|
||||
activity?.succeed({ title: '更新已就绪', detail: '重启后生效' });
|
||||
} else if (next.phase === 'error') {
|
||||
activity?.fail(next.message, { title: '更新失败' });
|
||||
} else if (next.phase === 'downloading') {
|
||||
timer = setTimeout(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
}
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
activity?.fail(message, { title: '读取更新状态失败' });
|
||||
|
||||
if (!disposed) {
|
||||
setError(message);
|
||||
timer = setTimeout(() => {
|
||||
void poll();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
|
||||
if (!activity) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [api, phase, startAction]);
|
||||
|
||||
const download = async () => {
|
||||
if (!version) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setStatus(await api.update.download(version));
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
setError(message);
|
||||
setStatus(null);
|
||||
startAction({ title: '下载更新' }).fail(message, { title: '下载失败' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restart = async () => {
|
||||
await runAction(
|
||||
{
|
||||
title: '正在重启',
|
||||
successTitle: '已请求重启',
|
||||
successDetail: '稍后刷新页面',
|
||||
errorTitle: '重启失败',
|
||||
},
|
||||
async () => {
|
||||
setStatus(await api.update.restart());
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (status?.phase === 'idle' && !version && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{status?.phase === 'error' && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{status.message}
|
||||
</p>
|
||||
)}
|
||||
{status?.phase === 'unavailable' && (
|
||||
<p className="text-xs text-muted-foreground">{status.message}</p>
|
||||
)}
|
||||
{status?.phase === 'downloading' && (
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在下载 v{status.version}
|
||||
</p>
|
||||
)}
|
||||
{status?.phase === 'restarting' && <p className="text-sm">正在重启,稍后刷新页面。</p>}
|
||||
{status?.phase === 'ready' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">v{status.version} 已就绪</span>
|
||||
<Button size="sm" onClick={() => setConfirmRestart(true)}>
|
||||
<RefreshCw className="size-4" />
|
||||
重启更新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{version && (phase === 'idle' || phase === 'error') && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
void download();
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
下载更新
|
||||
</Button>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmRestart}
|
||||
onOpenChange={setConfirmRestart}
|
||||
title="重启并更新?"
|
||||
description="连接将暂时断开,配置与数据保留。"
|
||||
confirmText="重启更新"
|
||||
onConfirm={restart}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { normalizeUpdateSourceConfig } from '@snowluma/common/update-source';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { DropdownSelect } from '@/components/ui/dropdown-select';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { useActionFeedback } from '@/contexts/ActionFeedbackContext';
|
||||
import { useApi } from '@/lib/api';
|
||||
import type { UpdateSourceConfig, UpdateSourceProbe } from '@/types';
|
||||
|
||||
export function UpdateSources() {
|
||||
const api = useApi();
|
||||
const { runAction } = useActionFeedback();
|
||||
const id = useId();
|
||||
const [config, setConfig] = useState<UpdateSourceConfig | null>(null);
|
||||
const [mirrors, setMirrors] = useState('');
|
||||
const [results, setResults] = useState<UpdateSourceProbe[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
setReady(false);
|
||||
void api.update
|
||||
.sources()
|
||||
.then(({ config: next }) => {
|
||||
if (!disposed) {
|
||||
setConfig(next);
|
||||
setMirrors(next.mirrors.join('\n'));
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!disposed) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) {
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [api, reload]);
|
||||
|
||||
const act = async (testing: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await runAction(
|
||||
{
|
||||
title: testing ? '正在检测更新来源' : '正在保存更新来源',
|
||||
successTitle: testing ? '来源检测完成' : '更新来源已保存',
|
||||
successDetail: testing
|
||||
? '检测从 SnowLuma 所在服务器发起'
|
||||
: '下次检查和下载时生效',
|
||||
errorTitle: testing ? '来源检测失败' : '保存失败',
|
||||
},
|
||||
async () => {
|
||||
const next = normalizeUpdateSourceConfig({
|
||||
...config,
|
||||
mirrors: mirrors
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
|
||||
if (testing) {
|
||||
setResults((await api.update.testSources(next)).results);
|
||||
} else {
|
||||
const saved = await api.update.saveSources(next);
|
||||
|
||||
if (!saved.saved || !saved.applied) {
|
||||
throw new Error(saved.errors.join(';') || saved.message);
|
||||
}
|
||||
|
||||
setConfig(saved.config);
|
||||
setMirrors(saved.config.mirrors.join('\n'));
|
||||
setResults([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<p className="text-sm font-medium">更新来源</p>
|
||||
{error && (
|
||||
<p role="alert" className="break-words text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<SkeletonSwap ready={ready} reserve={100} lines={3} label="更新来源">
|
||||
{config ? (
|
||||
<fieldset
|
||||
disabled={busy}
|
||||
className="flex min-w-0 flex-col gap-3 disabled:opacity-60"
|
||||
>
|
||||
<DropdownSelect
|
||||
value={config.mode}
|
||||
ariaLabel="下载来源模式"
|
||||
options={[
|
||||
{ value: 'auto', label: '自动切换' },
|
||||
{ value: 'direct', label: 'GitHub 直连' },
|
||||
{ value: 'mirror', label: '指定镜像' },
|
||||
]}
|
||||
onChange={(mode) => {
|
||||
setConfig({ ...config, mode });
|
||||
setResults([]);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
自动模式先尝试
|
||||
GitHub,失败后按列表顺序切换。镜像用于文件下载,版本查询也会尝试 GitHub
|
||||
发布页面。
|
||||
</p>
|
||||
{config.mode === 'mirror' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={`${id}-mirror`} className="text-xs">
|
||||
指定镜像地址
|
||||
</label>
|
||||
<Input
|
||||
id={`${id}-mirror`}
|
||||
value={config.mirror}
|
||||
placeholder="https://ghfast.top"
|
||||
onChange={(event) => {
|
||||
setConfig({ ...config, mirror: event.target.value });
|
||||
setResults([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs">
|
||||
编辑候选镜像(每行一个地址)
|
||||
</summary>
|
||||
<Textarea
|
||||
aria-label="候选镜像地址"
|
||||
className="mt-2 min-h-32 font-mono text-xs"
|
||||
value={mirrors}
|
||||
onChange={(event) => {
|
||||
setMirrors(event.target.value);
|
||||
setResults([]);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
地址后会追加完整 GitHub 链接;支持带路径的代理前缀。
|
||||
</p>
|
||||
</details>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void act(true);
|
||||
}}
|
||||
>
|
||||
检测可用性
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void act(false);
|
||||
}}
|
||||
>
|
||||
保存来源
|
||||
</Button>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
按可用性和响应延迟排序;检测结果不代表下载速度。
|
||||
</p>
|
||||
<ul className="flex max-h-64 flex-col gap-2 overflow-auto">
|
||||
{results.map((result) => (
|
||||
<li
|
||||
key={result.mirror}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-all">
|
||||
{result.mirror || 'GitHub 直连'}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
result.available
|
||||
? 'text-success'
|
||||
: 'text-destructive'
|
||||
}
|
||||
>
|
||||
{result.available
|
||||
? `可用 · ${result.latencyMs} ms`
|
||||
: result.error || '不可用'}
|
||||
</p>
|
||||
</div>
|
||||
{result.available && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfig({
|
||||
...config,
|
||||
mode: result.mirror
|
||||
? 'mirror'
|
||||
: 'direct',
|
||||
mirror: result.mirror,
|
||||
});
|
||||
}}
|
||||
>
|
||||
选用
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
setMirrors(
|
||||
results
|
||||
.filter(
|
||||
(result) => result.available && result.mirror,
|
||||
)
|
||||
.map((result) => result.mirror)
|
||||
.join('\n'),
|
||||
);
|
||||
setConfig({ ...config, mode: 'auto' });
|
||||
setResults([]);
|
||||
}}
|
||||
>
|
||||
仅保留可用镜像
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选用或筛选后,点击「保存来源」生效。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
) : (
|
||||
ready && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setReload((value) => value + 1)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</SkeletonSwap>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { paletteResolved, useTheme, type ThemeMode } from '@/contexts/ThemeContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const OPTIONS: { mode: ThemeMode; icon: typeof Sun; label: string }[] = [
|
||||
{ mode: 'system', icon: Monitor, label: '跟随系统' },
|
||||
{ mode: 'light', icon: Sun, label: '浅色' },
|
||||
{ mode: 'dark', icon: Moon, label: '深色' },
|
||||
];
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const { appearance, mode, setMode, managedBy } = useTheme();
|
||||
|
||||
// A Catppuccin flavor fixes light/dark, so this quick toggle would be inert
|
||||
// (and its pill would desync from the forced scheme) — hide it. Light/dark is
|
||||
// then changed by switching the palette back to “默认” in settings.
|
||||
if (managedBy || paletteResolved(appearance.palette)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative inline-flex shrink-0 items-center rounded-full border bg-card/60 p-0 shadow-xs md:p-0.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{OPTIONS.map(({ mode: m, icon: Icon, label }) => {
|
||||
const active = mode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => setMode(m)}
|
||||
data-press-scale=""
|
||||
className={cn(
|
||||
'relative flex size-[44px] items-center justify-center rounded-full transition-[color,scale] duration-150 ease-out active:scale-[0.96] motion-reduce:active:scale-100 md:size-10 cursor-pointer',
|
||||
active
|
||||
? 'text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="theme-toggle-pill"
|
||||
className="absolute inset-[8px] rounded-full bg-primary md:inset-1.5"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative z-10 size-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import * as React from 'react';
|
||||
import { motion, useMotionValue, useReducedMotion, useSpring } from 'motion/react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { FINE_POINTER_QUERY } from '@/lib/utils';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
|
||||
const POINTER_MEDIA = FINE_POINTER_QUERY;
|
||||
const MODE_PROPERTY = '--snowluma-cursor-mode';
|
||||
const RESIZE_MODE_PROPERTY = '--snowluma-cursor-resize-mode';
|
||||
const POINTER_SPRING = { stiffness: 1000, damping: 50 } as const;
|
||||
|
||||
const CURSOR_MODES = [
|
||||
'default',
|
||||
'target',
|
||||
'text',
|
||||
'grab',
|
||||
'grabbing',
|
||||
'move',
|
||||
'ew-resize',
|
||||
'ns-resize',
|
||||
'nwse-resize',
|
||||
'nesw-resize',
|
||||
'not-allowed',
|
||||
'wait',
|
||||
'crosshair',
|
||||
'copy',
|
||||
'zoom-in',
|
||||
'zoom-out',
|
||||
'help',
|
||||
'hidden',
|
||||
] as const;
|
||||
|
||||
type CursorMode = (typeof CURSOR_MODES)[number];
|
||||
|
||||
interface CursorContext {
|
||||
mode: CursorMode;
|
||||
anchor: Element | null;
|
||||
tracksPointerRegion: boolean;
|
||||
}
|
||||
|
||||
function readCursorMode(element: Element): CursorMode {
|
||||
const value = getComputedStyle(element).getPropertyValue(MODE_PROPERTY).trim();
|
||||
return (CURSOR_MODES as readonly string[]).includes(value) ? (value as CursorMode) : 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS owns the semantic mapping. JavaScript only reads the resolved custom
|
||||
* property and finds the outermost element that still belongs to that mode.
|
||||
* This keeps nested SVGs/spans inside a button attached to the whole button.
|
||||
*/
|
||||
function resolveCursorContext(
|
||||
target: EventTarget | null,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): CursorContext {
|
||||
if (!(target instanceof Element)) {
|
||||
return { mode: 'default', anchor: null, tracksPointerRegion: false };
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(target);
|
||||
const resizeModeValue = styles.getPropertyValue(RESIZE_MODE_PROPERTY).trim();
|
||||
const resizeMode = (CURSOR_MODES as readonly string[]).includes(resizeModeValue)
|
||||
? (resizeModeValue as CursorMode)
|
||||
: null;
|
||||
|
||||
if (resizeMode) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const inResizeCorner =
|
||||
clientX >= rect.right - 18 &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.bottom - 18 &&
|
||||
clientY <= rect.bottom;
|
||||
|
||||
if (inResizeCorner) {
|
||||
return { mode: resizeMode, anchor: null, tracksPointerRegion: true };
|
||||
}
|
||||
}
|
||||
|
||||
const modeValue = styles.getPropertyValue(MODE_PROPERTY).trim();
|
||||
const mode = (CURSOR_MODES as readonly string[]).includes(modeValue)
|
||||
? (modeValue as CursorMode)
|
||||
: 'default';
|
||||
|
||||
if (mode !== 'target') {
|
||||
return { mode, anchor: null, tracksPointerRegion: Boolean(resizeMode) };
|
||||
}
|
||||
|
||||
let anchor = target;
|
||||
let parent = target.parentElement;
|
||||
|
||||
while (parent && readCursorMode(parent) === mode) {
|
||||
anchor = parent;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return { mode, anchor, tracksPointerRegion: Boolean(resizeMode) };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Global adaptive cursor for fine-pointer desktop environments.
|
||||
*
|
||||
* All cursor semantics and visual variants live in index.css. The component
|
||||
* only tracks the pointer, reads the CSS-selected mode, and supplies target
|
||||
* geometry for Mikk Martin-style target morphing.
|
||||
*/
|
||||
export function AdaptivePointer() {
|
||||
const { appearance } = useTheme();
|
||||
const systemReducedMotion = useReducedMotion();
|
||||
const finePointer = useMediaQuery(POINTER_MEDIA);
|
||||
const enabled =
|
||||
appearance.customPointerSystem &&
|
||||
finePointer &&
|
||||
!appearance.reduceMotion &&
|
||||
!appearance.disableMotion &&
|
||||
!systemReducedMotion;
|
||||
|
||||
const cursorRef = React.useRef<HTMLDivElement>(null);
|
||||
const rawX = useMotionValue(-100);
|
||||
const rawY = useMotionValue(-100);
|
||||
const x = useSpring(rawX, POINTER_SPRING);
|
||||
const y = useSpring(rawY, POINTER_SPRING);
|
||||
|
||||
React.useEffect(() => {
|
||||
const cursor = cursorRef.current;
|
||||
|
||||
if (!enabled || !cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const lastPoint = { x: -100, y: -100 };
|
||||
let context: CursorContext = { mode: 'default', anchor: null, tracksPointerRegion: false };
|
||||
let lastEventTarget: EventTarget | null = null;
|
||||
let targetObserver: ResizeObserver | null = null;
|
||||
|
||||
root.dataset.cursorSystem = 'custom';
|
||||
delete root.dataset.cursorArmed;
|
||||
cursor.dataset.visible = 'false';
|
||||
|
||||
const arm = () => {
|
||||
root.dataset.cursorArmed = '1';
|
||||
cursor.dataset.visible = 'true';
|
||||
};
|
||||
|
||||
const disarm = () => {
|
||||
delete root.dataset.cursorArmed;
|
||||
cursor.dataset.visible = 'false';
|
||||
};
|
||||
|
||||
const stopObservingTarget = () => {
|
||||
targetObserver?.disconnect();
|
||||
targetObserver = null;
|
||||
};
|
||||
|
||||
const setMode = (mode: CursorMode) => {
|
||||
cursor.dataset.mode = mode;
|
||||
|
||||
if (mode !== 'target') {
|
||||
cursor.style.removeProperty('--cursor-target-width');
|
||||
cursor.style.removeProperty('--cursor-target-height');
|
||||
cursor.style.removeProperty('--cursor-target-radius');
|
||||
}
|
||||
};
|
||||
|
||||
const positionTarget = () => {
|
||||
const anchor = context.anchor;
|
||||
|
||||
if (context.mode !== 'target' || !anchor?.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const magneticX =
|
||||
clamp((lastPoint.x - centerX) / Math.max(rect.width / 2, 1), -1, 1) * 10;
|
||||
const magneticY =
|
||||
clamp((lastPoint.y - centerY) / Math.max(rect.height / 2, 1), -1, 1) * 10;
|
||||
const radius = getComputedStyle(anchor).borderRadius || '0.75rem';
|
||||
|
||||
cursor.style.setProperty('--cursor-target-width', `${rect.width}px`);
|
||||
cursor.style.setProperty('--cursor-target-height', `${rect.height}px`);
|
||||
cursor.style.setProperty('--cursor-target-radius', radius);
|
||||
rawX.set(centerX + magneticX);
|
||||
rawY.set(centerY + magneticY);
|
||||
return true;
|
||||
};
|
||||
|
||||
const observeTarget = (anchor: Element | null) => {
|
||||
stopObservingTarget();
|
||||
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetObserver = new ResizeObserver(positionTarget);
|
||||
targetObserver.observe(anchor);
|
||||
};
|
||||
|
||||
const applyContext = (next: CursorContext) => {
|
||||
const targetChanged = next.anchor !== context.anchor;
|
||||
context = next;
|
||||
setMode(next.mode);
|
||||
|
||||
if (targetChanged) {
|
||||
observeTarget(next.mode === 'target' ? next.anchor : null);
|
||||
}
|
||||
};
|
||||
|
||||
const positionAtPointer = () => {
|
||||
if (context.mode === 'target' && positionTarget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
rawX.set(lastPoint.x);
|
||||
rawY.set(lastPoint.y);
|
||||
};
|
||||
|
||||
const refreshContext = (target: EventTarget | null) => {
|
||||
lastEventTarget = target;
|
||||
applyContext(resolveCursorContext(target, lastPoint.x, lastPoint.y));
|
||||
positionAtPointer();
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPoint.x = event.clientX;
|
||||
lastPoint.y = event.clientY;
|
||||
arm();
|
||||
|
||||
if (event.target !== lastEventTarget || context.tracksPointerRegion) {
|
||||
refreshContext(event.target);
|
||||
} else {
|
||||
positionAtPointer();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerOver = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPoint.x = event.clientX;
|
||||
lastPoint.y = event.clientY;
|
||||
arm();
|
||||
refreshContext(event.target);
|
||||
};
|
||||
|
||||
const onPointerOut = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.relatedTarget === null) {
|
||||
disarm();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
|
||||
cursor.dataset.pressed = 'true';
|
||||
refreshContext(event.target);
|
||||
|
||||
if (context.mode === 'grab') {
|
||||
setMode('grabbing');
|
||||
}
|
||||
};
|
||||
|
||||
const restorePointerContext = () => {
|
||||
cursor.dataset.pressed = 'false';
|
||||
const target = document.elementFromPoint(lastPoint.x, lastPoint.y);
|
||||
refreshContext(target);
|
||||
};
|
||||
|
||||
const onDragStart = () => {
|
||||
cursor.dataset.pressed = 'true';
|
||||
setMode('grabbing');
|
||||
};
|
||||
|
||||
const onViewportChange = () => positionTarget();
|
||||
|
||||
const hide = () => {
|
||||
disarm();
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||
window.addEventListener('pointerover', onPointerOver, { passive: true });
|
||||
window.addEventListener('pointerout', onPointerOut, { passive: true });
|
||||
window.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true });
|
||||
window.addEventListener('pointerup', restorePointerContext, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('pointercancel', restorePointerContext, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('dragstart', onDragStart, { capture: true });
|
||||
window.addEventListener('dragend', restorePointerContext, { capture: true });
|
||||
window.addEventListener('scroll', onViewportChange, { capture: true, passive: true });
|
||||
window.addEventListener('resize', onViewportChange, { passive: true });
|
||||
window.addEventListener('blur', hide);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
stopObservingTarget();
|
||||
delete root.dataset.cursorSystem;
|
||||
delete root.dataset.cursorArmed;
|
||||
cursor.dataset.visible = 'false';
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerover', onPointerOver);
|
||||
window.removeEventListener('pointerout', onPointerOut);
|
||||
window.removeEventListener('pointerdown', onPointerDown, { capture: true });
|
||||
window.removeEventListener('pointerup', restorePointerContext, { capture: true });
|
||||
window.removeEventListener('pointercancel', restorePointerContext, { capture: true });
|
||||
window.removeEventListener('dragstart', onDragStart, { capture: true });
|
||||
window.removeEventListener('dragend', restorePointerContext, { capture: true });
|
||||
window.removeEventListener('scroll', onViewportChange, { capture: true });
|
||||
window.removeEventListener('resize', onViewportChange);
|
||||
window.removeEventListener('blur', hide);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, [enabled, rawX, rawY]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={cursorRef}
|
||||
className="adaptive-cursor"
|
||||
data-mode="default"
|
||||
data-visible="false"
|
||||
data-pressed="false"
|
||||
style={{ x, y }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="adaptive-cursor-shape" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
||||
({ className, size = 36, style, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'media-outline relative inline-flex shrink-0 overflow-hidden rounded-full bg-muted',
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Avatar.displayName = 'Avatar';
|
||||
|
||||
const AvatarImage = React.forwardRef<HTMLImageElement, React.ImgHTMLAttributes<HTMLImageElement>>(
|
||||
({ className, alt = '', ...props }, ref) => (
|
||||
<img
|
||||
ref={ref}
|
||||
alt={alt}
|
||||
className={cn('aspect-square size-full object-cover', className)}
|
||||
referrerPolicy="no-referrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
AvatarImage.displayName = 'AvatarImage';
|
||||
|
||||
const AvatarFallback = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center bg-muted text-xs font-medium text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
AvatarFallback.displayName = 'AvatarFallback';
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary/15 text-primary',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive/15 text-destructive',
|
||||
success: 'border-transparent bg-success/15 text-success',
|
||||
warning: 'border-transparent bg-warning/15 text-warning',
|
||||
outline: 'border-border text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"ui-button inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,scale,opacity] duration-150 ease-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 active:bg-primary/95',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-10 rounded-md px-3 text-xs',
|
||||
lg: 'h-12 rounded-md px-6',
|
||||
icon: 'ui-button-icon size-10',
|
||||
'icon-sm': 'ui-button-icon size-10',
|
||||
},
|
||||
press: {
|
||||
true: 'active:scale-[0.96] motion-reduce:active:scale-100',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default', press: true },
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonVariantProps = Omit<VariantProps<typeof buttonVariants>, 'press'>;
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariantProps {
|
||||
asChild?: boolean;
|
||||
/** Disable tactile press scaling when motion would distract from the action. */
|
||||
static?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, static: isStatic = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, press: !isStatic }), className)}
|
||||
ref={ref}
|
||||
data-press-scale={isStatic ? undefined : ''}
|
||||
data-variant={variant ?? 'default'}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ui-card rounded-xl border border-border/60 bg-card text-card-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col gap-1.5 p-5', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('font-semibold tracking-tight text-base', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Dropdown } from '@/components/interior/dropdown';
|
||||
|
||||
export interface DropdownOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DropdownSelectProps<T extends string> {
|
||||
value: T;
|
||||
options: ReadonlyArray<DropdownOption<T>>;
|
||||
onChange: (v: T) => void;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom (non-native) dropdown with an Apple-HIG flavour: a calm trigger that
|
||||
* mirrors {@link Input}'s height/border, a soft floating menu that springs in,
|
||||
* and a trailing check on the selected row. Keyboard-driven (arrows / enter /
|
||||
* escape) and closes on outside pointer-down. Use when the design wants a menu
|
||||
* that matches the surrounding chrome — the plain `Select` stays the zero-dep
|
||||
* native fallback for dense forms.
|
||||
*/
|
||||
export function DropdownSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: DropdownSelectProps<T>) {
|
||||
return (
|
||||
<Dropdown
|
||||
value={value}
|
||||
items={options.map((option) => ({ ...option }))}
|
||||
onChange={(next) => onChange(next as T)}
|
||||
label={ariaLabel}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// FaceGrid — pick a QQ classic face id. Renders the classic face sprites from
|
||||
// QQ's long-standing public CDN; any id whose image 404s hides itself, so a
|
||||
// curated-but-imperfect id list still looks clean. A manual id box is the
|
||||
// escape hatch for faces outside the classic range.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Classic QQ face ids that resolve on the gtimg CDN. Curated to avoid the gaps
|
||||
// in the 0..170 range (deprecated / never-existed ids) that would 404.
|
||||
const CLASSIC_FACE_IDS: number[] = [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 41, 42, 43, 46, 49, 53, 60, 63, 64, 66, 74,
|
||||
75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 96, 97, 98, 99, 100, 101, 102, 103,
|
||||
104, 106, 109, 111, 116, 118, 120, 122, 123, 124, 125, 129, 144, 147, 171, 173, 174, 175, 176,
|
||||
179, 180, 181, 182, 183, 201, 203, 212, 214, 219, 222, 227,
|
||||
];
|
||||
|
||||
const faceUrl = (id: number) => `https://qzonestyle.gtimg.cn/qzone/em/e${id}.png`;
|
||||
|
||||
export function FaceGrid({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [q, setQ] = useState('');
|
||||
const [broken, setBroken] = useState<Set<number>>(() => new Set());
|
||||
|
||||
const ids = useMemo(() => {
|
||||
const qq = q.trim();
|
||||
const base = CLASSIC_FACE_IDS.filter((id) => !broken.has(id));
|
||||
|
||||
if (!qq) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return base.filter((id) => String(id).includes(qq));
|
||||
}, [q, broken]);
|
||||
|
||||
const selected = value.trim();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="按 id 过滤…"
|
||||
className="h-8 flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/[^\d]/g, ''))}
|
||||
placeholder="id"
|
||||
inputMode="numeric"
|
||||
className="h-8 w-20 text-center font-mono"
|
||||
aria-label="表情 id"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-auto rounded-xl border border-border/60 bg-muted/30 p-2 sm:grid-cols-10">
|
||||
{ids.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
title={`face ${id}`}
|
||||
onClick={() => onChange(String(id))}
|
||||
className={cn(
|
||||
'flex aspect-square items-center justify-center rounded-lg p-1 transition-colors hover:bg-accent',
|
||||
selected === String(id) && 'bg-primary/15 ring-1 ring-primary/50',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={faceUrl(id)}
|
||||
alt={`face ${id}`}
|
||||
className="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
onError={() =>
|
||||
setBroken((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{ids.length === 0 && (
|
||||
<span className="col-span-full py-4 text-center text-xs text-muted-foreground">
|
||||
无匹配表情
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Image as ImageIcon,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Scissors,
|
||||
TextSelect,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { ContextMenuSurface } from '@/components/interior/context-menu';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { FINE_POINTER_QUERY } from '@/lib/utils';
|
||||
|
||||
type TextEditor = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
|
||||
|
||||
interface SelectionSnapshot {
|
||||
editor: TextEditor | null;
|
||||
editorStart: number | null;
|
||||
editorEnd: number | null;
|
||||
range: Range | null;
|
||||
selectedText: string;
|
||||
}
|
||||
|
||||
interface ContextSnapshot extends SelectionSnapshot {
|
||||
target: Element;
|
||||
link: HTMLAnchorElement | null;
|
||||
image: HTMLImageElement | null;
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
keyboard: boolean;
|
||||
snapshot: ContextSnapshot;
|
||||
}
|
||||
|
||||
interface MenuAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
run: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const TEXT_INPUT_SELECTOR = [
|
||||
'input:not([type])',
|
||||
'input[type="text"]',
|
||||
'input[type="search"]',
|
||||
'input[type="url"]',
|
||||
'input[type="tel"]',
|
||||
'input[type="password"]',
|
||||
'input[type="email"]',
|
||||
'textarea',
|
||||
'[contenteditable]:not([contenteditable="false"])',
|
||||
].join(',');
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function findEditor(target: Element): TextEditor | null {
|
||||
const candidate = target.closest(TEXT_INPUT_SELECTOR);
|
||||
|
||||
if (
|
||||
candidate instanceof HTMLInputElement ||
|
||||
candidate instanceof HTMLTextAreaElement ||
|
||||
(candidate instanceof HTMLElement && candidate.isContentEditable)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function captureSelection(target: Element): SelectionSnapshot {
|
||||
const editor = findEditor(target);
|
||||
|
||||
if (editor instanceof HTMLInputElement || editor instanceof HTMLTextAreaElement) {
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
return {
|
||||
editor,
|
||||
editorStart: start,
|
||||
editorEnd: end,
|
||||
range: null,
|
||||
selectedText: start !== null && end !== null ? editor.value.slice(start, end) : '',
|
||||
};
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
|
||||
const selectionInsideEditor =
|
||||
editor instanceof HTMLElement &&
|
||||
range !== null &&
|
||||
editor.contains(range.commonAncestorContainer);
|
||||
|
||||
return {
|
||||
editor,
|
||||
editorStart: null,
|
||||
editorEnd: null,
|
||||
range: selectionInsideEditor ? range : null,
|
||||
selectedText:
|
||||
editor instanceof HTMLElement && !selectionInsideEditor
|
||||
? ''
|
||||
: (selection?.toString() ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreRange(snapshot: SelectionSnapshot): Range {
|
||||
if (!(snapshot.editor instanceof HTMLElement) || !snapshot.range) {
|
||||
throw new Error('无法恢复文本选区');
|
||||
}
|
||||
|
||||
snapshot.editor.focus({ preventScroll: true });
|
||||
const range = snapshot.range.cloneRange();
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('浏览器未提供文本选区');
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return range;
|
||||
}
|
||||
|
||||
function emitInput(target: TextEditor, inputType: string, data: string | null) {
|
||||
target.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
inputType,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function replaceSelection(snapshot: SelectionSnapshot, value: string, inputType: string) {
|
||||
const { editor } = snapshot;
|
||||
|
||||
if (editor instanceof HTMLInputElement || editor instanceof HTMLTextAreaElement) {
|
||||
if (snapshot.editorStart === null || snapshot.editorEnd === null) {
|
||||
throw new Error('当前输入框不支持选区替换');
|
||||
}
|
||||
|
||||
editor.focus({ preventScroll: true });
|
||||
editor.setRangeText(value, snapshot.editorStart, snapshot.editorEnd, 'end');
|
||||
emitInput(editor, inputType, value || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(editor instanceof HTMLElement)) {
|
||||
throw new Error('缺少可编辑文本目标');
|
||||
}
|
||||
|
||||
const range = restoreRange(snapshot);
|
||||
range.deleteContents();
|
||||
|
||||
if (value) {
|
||||
const text = document.createTextNode(value);
|
||||
range.insertNode(text);
|
||||
range.setStartAfter(text);
|
||||
}
|
||||
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
emitInput(editor, inputType, value || null);
|
||||
}
|
||||
|
||||
function selectEditorContents(editor: TextEditor) {
|
||||
editor.focus({ preventScroll: true });
|
||||
|
||||
if (editor instanceof HTMLInputElement || editor instanceof HTMLTextAreaElement) {
|
||||
editor.select();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('浏览器未提供文本选区');
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function legacyCopy(value: string): boolean {
|
||||
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const selection = window.getSelection();
|
||||
const ranges = selection
|
||||
? Array.from({ length: selection.rangeCount }, (_, index) =>
|
||||
selection.getRangeAt(index).cloneRange(),
|
||||
)
|
||||
: [];
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const exec = (document as unknown as { execCommand(command: string): boolean }).execCommand;
|
||||
return exec.call(document, 'copy');
|
||||
} finally {
|
||||
textarea.remove();
|
||||
active?.focus({ preventScroll: true });
|
||||
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
ranges.forEach((range) => selection.addRange(range));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeClipboard(value: string) {
|
||||
let clipboardError: unknown;
|
||||
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API unavailable');
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
} catch (error) {
|
||||
clipboardError = error;
|
||||
}
|
||||
|
||||
if (legacyCopy(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw clipboardError instanceof Error ? clipboardError : new Error('浏览器拒绝写入剪贴板');
|
||||
}
|
||||
|
||||
async function readClipboard(): Promise<string> {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
throw new Error('当前页面没有读取剪贴板的权限');
|
||||
}
|
||||
|
||||
return navigator.clipboard.readText();
|
||||
}
|
||||
|
||||
function openInNewTab(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
function platformShortcut(key: string): string {
|
||||
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? `⌘${key}` : `Ctrl+${key}`;
|
||||
}
|
||||
|
||||
function buildActionGroups(snapshot: ContextSnapshot): MenuAction[][] {
|
||||
const groups: MenuAction[][] = [];
|
||||
const contextual: MenuAction[] = [];
|
||||
|
||||
if (snapshot.link) {
|
||||
const href = snapshot.link.href;
|
||||
contextual.push(
|
||||
{
|
||||
id: 'open-link',
|
||||
label: '在新标签页打开链接',
|
||||
icon: ExternalLink,
|
||||
run: () => openInNewTab(href),
|
||||
},
|
||||
{
|
||||
id: 'copy-link',
|
||||
label: '复制链接地址',
|
||||
icon: Link2,
|
||||
run: () => writeClipboard(href),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.image) {
|
||||
const src = snapshot.image.currentSrc || snapshot.image.src;
|
||||
contextual.push(
|
||||
{
|
||||
id: 'open-image',
|
||||
label: '在新标签页打开图片',
|
||||
icon: ImageIcon,
|
||||
run: () => openInNewTab(src),
|
||||
},
|
||||
{
|
||||
id: 'copy-image-url',
|
||||
label: '复制图片地址',
|
||||
icon: Copy,
|
||||
run: () => writeClipboard(src),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (contextual.length) {
|
||||
groups.push(contextual);
|
||||
}
|
||||
|
||||
if (snapshot.editor) {
|
||||
const hasSelection = snapshot.selectedText.length > 0;
|
||||
groups.push([
|
||||
{
|
||||
id: 'cut',
|
||||
label: '剪切',
|
||||
icon: Scissors,
|
||||
shortcut: platformShortcut('X'),
|
||||
disabled: !hasSelection,
|
||||
run: async () => {
|
||||
await writeClipboard(snapshot.selectedText);
|
||||
replaceSelection(snapshot, '', 'deleteByCut');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'copy',
|
||||
label: '复制',
|
||||
icon: Copy,
|
||||
shortcut: platformShortcut('C'),
|
||||
disabled: !hasSelection,
|
||||
run: () => writeClipboard(snapshot.selectedText),
|
||||
},
|
||||
{
|
||||
id: 'paste',
|
||||
label: '粘贴',
|
||||
icon: ClipboardPaste,
|
||||
shortcut: platformShortcut('V'),
|
||||
disabled: !navigator.clipboard?.readText,
|
||||
disabledReason: '当前页面没有读取剪贴板的权限',
|
||||
run: async () =>
|
||||
replaceSelection(snapshot, await readClipboard(), 'insertFromPaste'),
|
||||
},
|
||||
{
|
||||
id: 'select-all',
|
||||
label: '全选',
|
||||
icon: TextSelect,
|
||||
shortcut: platformShortcut('A'),
|
||||
run: () => selectEditorContents(snapshot.editor!),
|
||||
},
|
||||
]);
|
||||
} else if (snapshot.selectedText) {
|
||||
groups.push([
|
||||
{
|
||||
id: 'copy-selection',
|
||||
label: '复制选中文本',
|
||||
icon: Copy,
|
||||
shortcut: platformShortcut('C'),
|
||||
run: () => writeClipboard(snapshot.selectedText),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
groups.push([
|
||||
{
|
||||
id: 'back',
|
||||
label: '返回上一页',
|
||||
icon: ArrowLeft,
|
||||
disabled: window.history.length <= 1,
|
||||
run: () => window.history.back(),
|
||||
},
|
||||
{
|
||||
id: 'reload',
|
||||
label: '刷新页面',
|
||||
icon: RefreshCw,
|
||||
shortcut: platformShortcut('R'),
|
||||
run: () => window.location.reload(),
|
||||
},
|
||||
{
|
||||
id: 'copy-page-url',
|
||||
label: '复制当前页面地址',
|
||||
icon: Link2,
|
||||
run: () => writeClipboard(window.location.href),
|
||||
},
|
||||
]);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function GlobalContextMenu() {
|
||||
const { appearance } = useTheme();
|
||||
const enabled = appearance.customContextMenu;
|
||||
const [menu, setMenu] = React.useState<MenuState | null>(null);
|
||||
const [position, setPosition] = React.useState({ x: -9999, y: -9999, ready: false });
|
||||
const [busyAction, setBusyAction] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const sequence = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
if (event.shiftKey || !window.matchMedia(FINE_POINTER_QUERY).matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(event.target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('[data-context-menu="native"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.global-context-menu')) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const target = event.target;
|
||||
const keyboard = event.clientX === 0 && event.clientY === 0;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = keyboard ? rect.left + Math.min(rect.width / 2, 24) : event.clientX;
|
||||
const y = keyboard ? rect.top + Math.min(rect.height, 24) : event.clientY;
|
||||
|
||||
setError(null);
|
||||
setBusyAction(null);
|
||||
setPosition({ x, y, ready: false });
|
||||
setMenu({
|
||||
id: ++sequence.current,
|
||||
x,
|
||||
y,
|
||||
keyboard,
|
||||
snapshot: {
|
||||
target,
|
||||
link: target.closest('a[href]'),
|
||||
image: target.closest('img'),
|
||||
...captureSelection(target),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('contextmenu', onContextMenu, { capture: true });
|
||||
return () => window.removeEventListener('contextmenu', onContextMenu, { capture: true });
|
||||
}, [enabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMenu(null);
|
||||
setError(null);
|
||||
setBusyAction(null);
|
||||
}, [enabled]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const node = menuRef.current;
|
||||
|
||||
if (!menu || !node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const edge = 8;
|
||||
setPosition({
|
||||
x: clamp(menu.x, edge, Math.max(edge, window.innerWidth - rect.width - edge)),
|
||||
y: clamp(menu.y, edge, Math.max(edge, window.innerHeight - rect.height - edge)),
|
||||
ready: true,
|
||||
});
|
||||
}, [error, menu]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
const close = () => setMenu(null);
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (menuRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.from(
|
||||
menuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitem"]:not(:disabled)',
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let next = 0;
|
||||
|
||||
if (event.key === 'End') {
|
||||
next = items.length - 1;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
next = current <= 0 ? items.length - 1 : current - 1;
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
next = current < 0 || current === items.length - 1 ? 0 : current + 1;
|
||||
}
|
||||
|
||||
items[next]?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', onPointerDown, { capture: true });
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
window.addEventListener('scroll', close, { capture: true, passive: true });
|
||||
window.addEventListener('resize', close, { passive: true });
|
||||
window.addEventListener('blur', close);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', onPointerDown, { capture: true });
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||
window.removeEventListener('scroll', close, { capture: true });
|
||||
window.removeEventListener('resize', close);
|
||||
window.removeEventListener('blur', close);
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (menu?.keyboard && position.ready) {
|
||||
menuRef.current
|
||||
?.querySelector<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [menu, position.ready]);
|
||||
|
||||
const groups = enabled && menu ? buildActionGroups(menu.snapshot) : [];
|
||||
|
||||
const runAction = async (action: MenuAction) => {
|
||||
if (action.disabled || busyAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyAction(action.id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await action.run();
|
||||
|
||||
if (menu?.keyboard && menu.snapshot.target instanceof HTMLElement) {
|
||||
menu.snapshot.target.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
setMenu(null);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
console.error(`[context-menu] ${action.id} failed`, cause);
|
||||
setError(message || '操作失败');
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuSurface
|
||||
openKey={enabled && menu ? menu.id : null}
|
||||
menuRef={menuRef}
|
||||
label="右键菜单"
|
||||
placement={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: Math.min(224, Math.max(160, window.innerWidth - 16)),
|
||||
maxHeight: Math.max(42, window.innerHeight - 16),
|
||||
transformOrigin: menu
|
||||
? `${clamp(menu.x - position.x, 0, 224)}px ${clamp(menu.y - position.y, 0, window.innerHeight)}px`
|
||||
: '0 0',
|
||||
visible: position.ready,
|
||||
}}
|
||||
>
|
||||
{enabled && menu ? (
|
||||
<>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<React.Fragment key={group.map((action) => action.id).join(':')}>
|
||||
{groupIndex > 0 && (
|
||||
<div className="global-context-menu-separator" role="separator" />
|
||||
)}
|
||||
<div className="global-context-menu-group">
|
||||
{group.map((action) => {
|
||||
const Icon = action.icon;
|
||||
const busy = busyAction === action.id;
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
disabled={action.disabled || Boolean(busyAction)}
|
||||
aria-busy={busy || undefined}
|
||||
data-cursor={busy ? 'wait' : undefined}
|
||||
title={
|
||||
action.disabled ? action.disabledReason : undefined
|
||||
}
|
||||
className="global-context-menu-item"
|
||||
onClick={() => void runAction(action)}
|
||||
>
|
||||
<Icon
|
||||
className="global-context-menu-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="global-context-menu-label">
|
||||
{action.label}
|
||||
</span>
|
||||
{action.shortcut && (
|
||||
<kbd className="global-context-menu-shortcut">
|
||||
{action.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{error && (
|
||||
<div className="global-context-menu-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</ContextMenuSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
data-ui-control=""
|
||||
className={cn(
|
||||
'flex h-10 w-full min-w-0 appearance-none rounded-md border border-border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm',
|
||||
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,218 @@
|
||||
// JsonTree — a calm, collapsible JSON viewer for action responses / event
|
||||
// payloads. Replaces a raw <pre> dump: nodes fold, values are colour-typed, the
|
||||
// root offers copy, and a string that looks like an image URL gets an inline
|
||||
// preview toggle. Read-only; no external deps.
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, ChevronRight, Copy, Image as ImageIcon } from 'lucide-react';
|
||||
import { cn, copyText } from '@/lib/utils';
|
||||
|
||||
const IMAGE_URL_RE = /^https?:\/\/[^\s]+?\.(?:png|jpe?g|gif|webp|bmp)(?:[?#][^\s]*)?$/i;
|
||||
// QQ multimedia rkey image URLs don't end in an extension but carry these hints.
|
||||
const IMAGE_HINT_RE =
|
||||
/^https?:\/\/[^\s]*(?:rkey=|gchatpic|multimedia|c2cpicdw|offpic|gchat\.qpic)/i;
|
||||
|
||||
function isImageUrl(s: string): boolean {
|
||||
return IMAGE_URL_RE.test(s) || IMAGE_HINT_RE.test(s);
|
||||
}
|
||||
|
||||
function Punct({ children }: { children: ReactNode }) {
|
||||
return <span className="text-muted-foreground/60">{children}</span>;
|
||||
}
|
||||
|
||||
function Leaf({ value }: { value: string | number | boolean | null }) {
|
||||
const [showImg, setShowImg] = useState(false);
|
||||
|
||||
if (value === null) {
|
||||
return <span className="text-muted-foreground/70">null</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className="text-sky-600 dark:text-sky-400 tabular-nums">{value}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className="text-violet-600 dark:text-violet-400">{String(value)}</span>;
|
||||
}
|
||||
|
||||
// string
|
||||
const img = isImageUrl(value);
|
||||
return (
|
||||
<span className="break-all">
|
||||
<span className="text-emerald-700 dark:text-emerald-400">"{value}"</span>
|
||||
{img && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImg((v) => !v)}
|
||||
className="ml-1.5 inline-flex items-center gap-0.5 rounded-md bg-muted/70 px-1 py-0.5 align-middle text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ImageIcon className="h-3 w-3" /> {showImg ? '隐藏' : '预览'}
|
||||
</button>
|
||||
{showImg && (
|
||||
<span className="mt-1.5 block">
|
||||
<img
|
||||
src={value}
|
||||
alt="预览"
|
||||
className="media-outline max-h-48 rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const NODE_CAP = 100; // render at most this many children before "show more"
|
||||
|
||||
function Node({
|
||||
k,
|
||||
value,
|
||||
depth,
|
||||
defaultOpen,
|
||||
}: {
|
||||
k?: string;
|
||||
value: unknown;
|
||||
depth: number;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const isObj = typeof value === 'object' && value !== null;
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [limit, setLimit] = useState(NODE_CAP);
|
||||
|
||||
const keyLabel =
|
||||
k != null ? (
|
||||
<>
|
||||
<span className="text-foreground/80">{k}</span>
|
||||
<Punct>: </Punct>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (!isObj) {
|
||||
return (
|
||||
<div
|
||||
className="flex gap-1 py-px pl-[1.1rem]"
|
||||
style={{ paddingLeft: `${depth * 0.9 + 1.1}rem` }}
|
||||
>
|
||||
{keyLabel}
|
||||
<Leaf value={value as string | number | boolean | null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isArr = Array.isArray(value);
|
||||
const count = isArr ? (value as unknown[]).length : Object.keys(value as object).length;
|
||||
const openB = isArr ? '[' : '{';
|
||||
const closeB = isArr ? ']' : '}';
|
||||
const shown = open ? Math.min(count, limit) : 0;
|
||||
// Only materialise child entries when expanded — a deep tree has many
|
||||
// collapsed nodes, and enumerating each on every render is wasted work.
|
||||
const entries: ReadonlyArray<readonly [string, unknown]> = open
|
||||
? isArr
|
||||
? (value as unknown[]).map((v, i) => [String(i), v] as const)
|
||||
: Object.entries(value as Record<string, unknown>)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1 py-px text-left hover:bg-muted/40"
|
||||
style={{ paddingLeft: `${depth * 0.9}rem` }}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
{keyLabel}
|
||||
<Punct>{openB}</Punct>
|
||||
{!open && (
|
||||
<span className="text-muted-foreground/50">
|
||||
{count}
|
||||
{isArr ? '' : ' keys'}
|
||||
<Punct>{closeB}</Punct>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
{entries.slice(0, shown).map(([ck, cv]) => (
|
||||
<Node
|
||||
key={ck}
|
||||
k={Array.isArray(value) ? undefined : ck}
|
||||
value={cv}
|
||||
depth={depth + 1}
|
||||
defaultOpen={depth < 1}
|
||||
/>
|
||||
))}
|
||||
{count > shown && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLimit((l) => l + 500)}
|
||||
className="py-0.5 text-xs text-primary hover:underline"
|
||||
style={{ paddingLeft: `${depth * 0.9 + 1.1}rem` }}
|
||||
>
|
||||
还有 {count - shown} 项,点击展开…
|
||||
</button>
|
||||
)}
|
||||
<div style={{ paddingLeft: `${depth * 0.9 + 0.9}rem` }}>
|
||||
<Punct>{closeB}</Punct>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JsonTree({
|
||||
data,
|
||||
className,
|
||||
maxHeight = '20rem',
|
||||
}: {
|
||||
data: unknown;
|
||||
className?: string;
|
||||
maxHeight?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
// Stringify lazily, only on an actual copy — not on every render (a large
|
||||
// payload would otherwise re-serialise the whole tree each commit).
|
||||
const text = (() => {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
})();
|
||||
|
||||
if (await copyText(text)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative rounded-xl border border-border/60 bg-muted/30', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title="复制 JSON"
|
||||
className="absolute right-2 top-2 z-10 inline-flex items-center gap-1 rounded-md bg-card/80 px-1.5 py-1 text-xs text-muted-foreground shadow-sm ring-1 ring-border/50 backdrop-blur hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-success" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
<div
|
||||
className="overflow-auto p-3 font-mono text-[11.5px] leading-relaxed"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<Node value={data} depth={0} defaultOpen />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-xs font-medium leading-none text-muted-foreground select-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface PasswordVisibilityIconProps {
|
||||
visible: boolean;
|
||||
reduceMotion?: boolean;
|
||||
}
|
||||
|
||||
/** A stable-size password icon whose state change remains interruptible. */
|
||||
export function PasswordVisibilityIcon({
|
||||
visible,
|
||||
reduceMotion = false,
|
||||
}: PasswordVisibilityIconProps) {
|
||||
const Icon = visible ? EyeOff : Eye;
|
||||
|
||||
return (
|
||||
<span aria-hidden className="relative block size-4 overflow-hidden">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.span
|
||||
key={visible ? 'visible' : 'hidden'}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
initial={reduceMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : { duration: 0.15 }}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
// Picker — a searchable, windowed combobox for choosing from large lists
|
||||
// (group members can be thousands). Apple-HIG flavour built on the same idiom
|
||||
// as DropdownSelect: calm trigger matching Input height, a soft floating panel
|
||||
// with a search field, virtualised rows (avatar + name + sub), keyboard nav,
|
||||
// and an always-present escape hatch — if what you typed isn't in the list you
|
||||
// can still commit it as a raw value. Loading / error / empty states degrade to
|
||||
// the raw-value input so the picker never traps you.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
import { RefreshCw, Search } from 'lucide-react';
|
||||
import { SkeletonSwap } from '@/components/interior/skeleton-swap';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface PickerOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Secondary line (e.g. the uin under a nickname). */
|
||||
sub?: string;
|
||||
/** Avatar URL. */
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: ReadonlyArray<PickerOption>;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh?: () => void;
|
||||
placeholder?: string;
|
||||
/** Validates a typed raw value before it can be committed via the escape
|
||||
* hatch. Defaults to "non-empty". */
|
||||
validateRaw?: (raw: string) => boolean;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ROW_H = 44; // px — fixed row height drives the windowing math
|
||||
const OVERSCAN = 6;
|
||||
const PANEL_MAX = 264; // px of scroll viewport
|
||||
const EASE = [0.23, 1, 0.32, 1] as const;
|
||||
const EXIT = [0.4, 0, 1, 1] as const;
|
||||
const NUDGE = { type: 'spring', stiffness: 700, damping: 46, mass: 0.5 } as const;
|
||||
const SLIDE = { type: 'spring', stiffness: 700, damping: 46, mass: 0.5 } as const;
|
||||
const OPEN = { type: 'spring', stiffness: 620, damping: 38, mass: 0.6 } as const;
|
||||
const NONE = { duration: 0 } as const;
|
||||
|
||||
export function Picker({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
placeholder = '选择…',
|
||||
validateRaw,
|
||||
ariaLabel,
|
||||
className,
|
||||
disabled,
|
||||
}: PickerProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [active, setActive] = useState(0);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const suppressTriggerClick = useRef(false);
|
||||
const dragRef = useRef<{ id: number; y: number; dragged: boolean } | null>(null);
|
||||
const waitingForOptions = Boolean(loading && options.length === 0);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
if (!q) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return options.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(q) ||
|
||||
o.value.includes(q) ||
|
||||
(o.sub?.toLowerCase().includes(q) ?? false),
|
||||
);
|
||||
}, [options, query]);
|
||||
|
||||
// The escape-hatch row: show when the query is a committable raw value that
|
||||
// isn't already an exact option.
|
||||
const canRaw =
|
||||
query.trim() !== '' &&
|
||||
(validateRaw ? validateRaw(query.trim()) : true) &&
|
||||
!options.some((o) => o.value === query.trim());
|
||||
const rawRow = canRaw ? query.trim() : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScrollTop(0);
|
||||
setActive(0);
|
||||
const coarse = window.matchMedia('(pointer: coarse)').matches;
|
||||
const t = coarse ? undefined : setTimeout(() => searchRef.current?.focus(), 10);
|
||||
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onPointer);
|
||||
|
||||
return () => {
|
||||
if (t !== undefined) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
document.removeEventListener('pointerdown', onPointer);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const totalRows = filtered.length + (rawRow ? 1 : 0);
|
||||
// Keep the keyboard cursor in range when the list shrinks (refresh / typing)
|
||||
// without a keystroke that would have reset it.
|
||||
useEffect(() => {
|
||||
setActive((a) => Math.min(a, Math.max(0, totalRows - 1)));
|
||||
}, [totalRows]);
|
||||
const viewH = Math.min(PANEL_MAX, totalRows * ROW_H);
|
||||
const start = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN);
|
||||
const end = Math.min(totalRows, Math.ceil((scrollTop + viewH) / ROW_H) + OVERSCAN);
|
||||
|
||||
const commit = (v: string) => {
|
||||
onChange(v);
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const choose = (idx: number) => {
|
||||
if (rawRow && idx === filtered.length) {
|
||||
commit(rawRow);
|
||||
} else {
|
||||
const o = filtered[idx];
|
||||
|
||||
if (o) {
|
||||
commit(o.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(totalRows - 1, a + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
choose(active);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the active row scrolled into view.
|
||||
useEffect(() => {
|
||||
if (!open || !listRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const top = active * ROW_H;
|
||||
const el = listRef.current;
|
||||
|
||||
if (top < el.scrollTop) {
|
||||
el.scrollTop = top;
|
||||
} else if (top + ROW_H > el.scrollTop + el.clientHeight) {
|
||||
el.scrollTop = top + ROW_H - el.clientHeight;
|
||||
}
|
||||
}, [active, open]);
|
||||
|
||||
const rows: { idx: number; opt: PickerOption | null }[] = [];
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
if (rawRow && i === filtered.length) {
|
||||
rows.push({ idx: i, opt: null });
|
||||
} else {
|
||||
rows.push({ idx: i, opt: filtered[i] ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn('relative', className)} onKeyDown={onKeyDown}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
if (suppressTriggerClick.current) {
|
||||
suppressTriggerClick.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!disabled) {
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center gap-2 rounded-md border border-border bg-transparent px-3 text-sm outline-none transition-colors',
|
||||
'focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring disabled:opacity-50',
|
||||
!selected && !value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{selected?.avatar && (
|
||||
<img
|
||||
src={selected.avatar}
|
||||
alt=""
|
||||
className="media-outline h-5 w-5 shrink-0 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{selected ? selected.label : value || placeholder}
|
||||
{selected?.sub && (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground tabular-nums">
|
||||
{selected.sub}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<motion.svg
|
||||
aria-hidden
|
||||
viewBox="0 0 12 12"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
initial={false}
|
||||
animate={{ rotate: open ? 180 : 0 }}
|
||||
transition={reduced ? NONE : NUDGE}
|
||||
>
|
||||
<path
|
||||
d="M3 4.75 6 7.75 9 4.75"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.97,
|
||||
y: -6,
|
||||
transition: reduced ? NONE : { duration: 0.12, ease: EXIT },
|
||||
}}
|
||||
transition={
|
||||
reduced ? NONE : { ...OPEN, opacity: { duration: 0.12, ease: EASE } }
|
||||
}
|
||||
style={{ transformOrigin: 'top left' }}
|
||||
className="absolute z-[200] mt-1.5 w-full overflow-hidden rounded-[11px] border border-border bg-popover shadow-[0_1px_2px_rgb(0_0_0/0.06),0_16px_36px_-18px_rgb(0_0_0/0.5)]"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-2.5 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
setScrollTop(0);
|
||||
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = 0;
|
||||
}
|
||||
}}
|
||||
placeholder="搜索名称 / 号码…"
|
||||
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground md:text-sm"
|
||||
/>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
title="刷新"
|
||||
onClick={onRefresh}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('h-3.5 w-3.5', loading && 'animate-spin')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SkeletonSwap
|
||||
ready={!waitingForOptions}
|
||||
lines={4}
|
||||
lineHeight={28}
|
||||
reserve={112}
|
||||
label={ariaLabel}
|
||||
className={waitingForOptions ? 'mx-3 my-2' : 'skeleton-swap-fluid'}
|
||||
>
|
||||
{!waitingForOptions ? (
|
||||
error ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<p className="mt-1">可直接在上方输入号码后回车使用</p>
|
||||
</div>
|
||||
) : totalRows === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
无匹配项{query && ';输入完整号码可直接使用'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
onScroll={(e) =>
|
||||
setScrollTop((e.target as HTMLDivElement).scrollTop)
|
||||
}
|
||||
className="overflow-auto overscroll-contain"
|
||||
style={{ height: viewH, touchAction: 'pan-y' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: totalRows * ROW_H,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 rounded-[7px] bg-accent"
|
||||
style={{ height: ROW_H }}
|
||||
initial={false}
|
||||
animate={{
|
||||
y: active * ROW_H,
|
||||
opacity: totalRows > 0 ? 1 : 0,
|
||||
}}
|
||||
transition={
|
||||
reduced
|
||||
? NONE
|
||||
: {
|
||||
...SLIDE,
|
||||
opacity: {
|
||||
duration: 0.1,
|
||||
ease: EASE,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
{rows.map(({ idx, opt }) => (
|
||||
<div
|
||||
key={opt ? opt.value : `__raw-${idx}`}
|
||||
role="option"
|
||||
aria-selected={idx === active}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
suppressTriggerClick.current = true;
|
||||
choose(idx);
|
||||
return;
|
||||
}
|
||||
|
||||
dragRef.current = {
|
||||
id: e.pointerId,
|
||||
y: e.clientY,
|
||||
dragged: false,
|
||||
};
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const drag = dragRef.current;
|
||||
|
||||
if (!drag || drag.id !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(e.clientY - drag.y) > 8) {
|
||||
drag.dragged = true;
|
||||
}
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
const drag = dragRef.current;
|
||||
dragRef.current = null;
|
||||
|
||||
if (
|
||||
!drag ||
|
||||
drag.id !== e.pointerId ||
|
||||
drag.dragged
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
suppressTriggerClick.current = true;
|
||||
choose(idx);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
dragRef.current = null;
|
||||
}}
|
||||
className={cn(
|
||||
'absolute left-0 right-0 z-[1] flex cursor-pointer items-center gap-2.5 px-3',
|
||||
idx === active
|
||||
? 'text-accent-foreground'
|
||||
: 'hover:bg-accent/50',
|
||||
)}
|
||||
style={{ top: idx * ROW_H, height: ROW_H }}
|
||||
>
|
||||
{opt ? (
|
||||
<>
|
||||
{opt.avatar ? (
|
||||
<img
|
||||
src={opt.avatar}
|
||||
alt=""
|
||||
className="media-outline h-7 w-7 shrink-0 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<span className="media-outline h-7 w-7 shrink-0 rounded-full bg-muted" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{opt.label}
|
||||
</span>
|
||||
{opt.sub && (
|
||||
<span className="shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
|
||||
{opt.sub}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm">
|
||||
使用{' '}
|
||||
<span className="font-mono text-primary">
|
||||
{rawRow}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number;
|
||||
indicatorClassName?: string;
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, indicatorClassName, ...props }, ref) => {
|
||||
const clamped = Math.max(0, Math.min(100, value));
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuenow={clamped}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full bg-primary transition-[width] duration-500 ease-out',
|
||||
indicatorClassName,
|
||||
)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Progress.displayName = 'Progress';
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
viewportClassName?: string;
|
||||
/** Ref to the scrollable viewport element — needed to drive a virtualizer
|
||||
* (TanStack Virtual's getScrollElement) off the actual scroll container. */
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
ScrollAreaProps
|
||||
>(({ className, children, viewportClassName, viewportRef, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
className={cn(
|
||||
'size-full rounded-[inherit] overscroll-contain [&>div]:!block',
|
||||
viewportClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = 'ScrollArea';
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none p-0.5 transition-colors cursor-grab active:cursor-grabbing',
|
||||
orientation === 'vertical' && 'h-full w-2 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2 w-full flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border hover:bg-muted-foreground/40 transition-colors" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = 'ScrollBar';
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = 'Separator';
|
||||
|
||||
export { Separator };
|
||||