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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+24
View File
@@ -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?
+111
View File
@@ -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.
+130
View File
@@ -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
```
+21
View File
@@ -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"
}
+32
View File
@@ -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,
]);
+67
View File
@@ -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>
+58
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+24
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

+519
View File
@@ -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}
/>
);
}
+29
View File
@@ -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>>;
+104
View File
@@ -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';
}
}
+51
View File
@@ -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,
}),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -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

+1
View File
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
@@ -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 };
+57
View File
@@ -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">&quot;{value}&quot;</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>
);
}
+457
View File
@@ -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 };
Loaded 100 of 1367 files, more files were not shown because too many files have changed in this diff. Show more