feat: establish shared web UI lifecycle and primitives

This commit is contained in:
cita-777
2026-08-31 21:55:57 +08:00
parent f4114ada6d
commit e887fbd517
42 changed files with 4193 additions and 2039 deletions
+458 -4
View File
@@ -14,6 +14,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@radix-ui/react-dialog": "^1.1.23",
"@visactor/react-vchart": "^2.0.22",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
@@ -3505,6 +3506,320 @@
"node": ">=14"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
"license": "MIT"
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.23",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz",
"integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-dismissable-layer": "1.1.19",
"@radix-ui/react-focus-guards": "1.1.6",
"@radix-ui/react-focus-scope": "1.1.16",
"@radix-ui/react-id": "1.1.4",
"@radix-ui/react-portal": "1.1.17",
"@radix-ui/react-presence": "1.1.10",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-layout-effect": "1.1.4",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
"integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-effect-event": "0.0.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
"integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
"integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
"integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.3.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
"integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-use-effect-event": "0.0.5",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@resvg/resvg-js": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.4.1.tgz",
@@ -5131,14 +5446,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -5149,7 +5464,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -6205,6 +6520,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/array-source": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/array-source/-/array-source-0.0.4.tgz",
@@ -7277,7 +7604,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/cytoscape": {
@@ -8112,6 +8439,12 @@
"license": "MIT",
"optional": true
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -9653,6 +9986,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-port": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
@@ -12623,6 +12965,53 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
@@ -12677,6 +13066,28 @@
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-test-renderer": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz",
@@ -14936,6 +15347,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
+1
View File
@@ -72,6 +72,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@radix-ui/react-dialog": "^1.1.23",
"@visactor/react-vchart": "^2.0.22",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
+19 -62
View File
@@ -1,8 +1,9 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
import type React from 'react';
import { Dialog } from './ui/Dialog.js';
type CenteredModalProps = {
// Compatibility facade: the Dialog wrapper owns useAnimatedVisibility and
// createPortal while preserving the modal-backdrop/modal-content class names.
export type CenteredModalProps = {
open: boolean;
onClose: () => void;
title: React.ReactNode;
@@ -27,64 +28,20 @@ export default function CenteredModal({
closeOnEscape = false,
showCloseButton = true,
}: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220);
const canUsePortal = typeof document !== 'undefined'
&& !!document.body
&& typeof document.body.appendChild === 'function'
&& typeof document.body.removeChild === 'function';
useEffect(() => {
if (!open || !canUsePortal) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [canUsePortal, open]);
useEffect(() => {
if (!open || !closeOnEscape || !canUsePortal) return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [canUsePortal, closeOnEscape, open, onClose]);
if (!presence.shouldRender) return null;
const modal = (
<div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={closeOnBackdrop ? onClose : undefined}
return (
<Dialog
open={open}
onClose={onClose}
title={title}
footer={footer}
maxWidth={maxWidth}
bodyStyle={bodyStyle}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
showCloseButton={showCloseButton}
closeLabel="关闭弹框"
>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth }}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<div className="modal-title">{title}</div>
{showCloseButton ? (
<button
type="button"
className="modal-close-button"
onClick={onClose}
aria-label="关闭弹框"
>
×
</button>
) : null}
</div>
<div className="modal-body" style={bodyStyle}>
{children}
</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</div>
</div>
{children}
</Dialog>
);
return canUsePortal ? createPortal(modal, document.body) : modal;
}
+27 -35
View File
@@ -1,12 +1,11 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import React, { useState } from 'react';
import { api } from '../api.js';
import { useToast } from './Toast.js';
import { persistAuthSession } from '../authSession.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
import CenteredModal from './CenteredModal.js';
import { AsyncButton, Button } from './ui/Button.js';
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const presence = useAnimatedVisibility(open, 200);
const [oldToken, setOldToken] = useState('');
const [newToken, setNewToken] = useState('');
const [confirmToken, setConfirmToken] = useState('');
@@ -14,15 +13,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
const [error, setError] = useState('');
const toast = useToast();
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [open]);
const handleSubmit = async () => {
setError('');
if (!oldToken || !newToken || !confirmToken) {
@@ -51,27 +41,39 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
} else {
setError(res.message || '更新失败');
}
} catch (e: any) {
setError(e.message || '更新失败');
} catch (error: unknown) {
setError(error instanceof Error ? error.message : '更新失败');
} finally {
setSaving(false);
}
};
if (!presence.shouldRender) return null;
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
};
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className="modal-header">修改管理员 Token</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
return (
<CenteredModal
open={open}
onClose={onClose}
title="修改管理员 Token"
maxWidth={420}
closeOnBackdrop
closeOnEscape={false}
showCloseButton={false}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<Button onClick={onClose} variant="ghost">取消</Button>
<AsyncButton onClick={() => void handleSubmit()} variant="primary" loading={saving} loadingLabel="更新中...">
确认修改
</AsyncButton>
</>
)}
>
<>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
<input
@@ -108,17 +110,7 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
</button>
</div>
</div>
</div>
</>
</CenteredModal>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+5 -6
View File
@@ -1,5 +1,6 @@
import React from 'react';
import CenteredModal from './CenteredModal.js';
import { AsyncButton, Button } from './ui/Button.js';
type DeleteConfirmModalProps = {
open: boolean;
@@ -31,12 +32,10 @@ export default function DeleteConfirmModal({
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
{loading
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
: confirmText}
</button>
<Button onClick={onClose} variant="ghost" disabled={loading}>{cancelText}</Button>
<AsyncButton onClick={onConfirm} variant="danger" loading={loading} loadingLabel="删除中...">
{confirmText}
</AsyncButton>
</>
)}
>
+28 -75
View File
@@ -1,13 +1,18 @@
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type React from 'react';
import { Drawer } from './ui/Dialog.js';
type MobileDrawerProps = {
export type MobileDrawerProps = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: React.ReactNode;
closeLabel?: string;
side?: 'left' | 'right';
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
ariaLabel?: string;
ariaDescribedBy?: string;
};
function MobileDrawer({
@@ -17,80 +22,28 @@ function MobileDrawer({
title,
closeLabel = '关闭导航',
side = 'left',
closeOnBackdrop = true,
closeOnEscape = true,
showCloseButton = true,
ariaLabel,
ariaDescribedBy,
}: MobileDrawerProps) {
const [shouldRender, setShouldRender] = useState(open);
const [isClosing, setIsClosing] = useState(false);
const titleId = useId();
const labelledBy = title ? titleId : undefined;
useEffect(() => {
if (open) {
setShouldRender(true);
setIsClosing(false);
} else if (shouldRender) {
setIsClosing(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsClosing(false);
}, 280);
return () => clearTimeout(timer);
}
}, [open]);
const handleClose = useCallback(() => {
setIsClosing(true);
onClose();
}, [onClose]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleClose, open]);
if (!shouldRender) return null;
const drawer = (
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
<div
className="mobile-drawer-backdrop"
onClick={handleClose}
aria-hidden="true"
/>
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
<div className="mobile-drawer-toolbar">
{title ? (
<div className="mobile-drawer-title" id={titleId}>
{title}
</div>
) : <div />}
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
×
</button>
</div>
{children}
</div>
</div>
return (
<Drawer
open={open}
onClose={onClose}
title={title}
closeLabel={closeLabel}
side={side}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
showCloseButton={showCloseButton}
ariaLabel={ariaLabel}
ariaDescribedBy={ariaDescribedBy}
>
{children}
</Drawer>
);
const portalTarget = typeof document !== 'undefined' ? document.body : null;
return portalTarget ? createPortal(drawer, portalTarget) : drawer;
}
export { MobileDrawer };
+44 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModernSelect from './ModernSelect.js';
@@ -90,4 +90,47 @@ describe('ModernSelect', () => {
));
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
});
it('keeps keyboard navigation and selection inside the facade contract', async () => {
const onChange = vi.fn();
const root = create(
<ModernSelect
value="alpha"
onChange={onChange}
options={[
{ value: 'alpha', label: 'Alpha' },
{ value: 'disabled', label: 'Disabled', disabled: true },
{ value: 'beta', label: 'Beta' },
]}
/>,
);
const trigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
));
await act(async () => {
trigger.props.onClick();
});
expect(trigger.props['aria-expanded']).toBe(true);
await act(async () => {
trigger.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
});
let highlighted = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& node.props.className.includes('is-highlighted')
));
expect(highlighted).toHaveLength(1);
expect(collectInstanceText(highlighted[0])).toContain('Beta');
await act(async () => {
trigger.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
expect(onChange).toHaveBeenCalledWith('beta');
expect(trigger.props['aria-expanded']).toBe(false);
});
});
+9 -202
View File
@@ -1,202 +1,9 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
type ModernSelectOption = {
value: string;
label: string;
description?: string;
disabled?: boolean;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
};
type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
'data-testid'?: string;
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
menuMaxHeight?: number;
className?: string;
size?: 'md' | 'sm';
searchable?: boolean;
searchPlaceholder?: string;
};
export default function ModernSelect({
value,
onChange,
options,
'data-testid': dataTestId,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
menuMaxHeight = 280,
className = '',
size = 'md',
searchable = false,
searchPlaceholder = 'Search...',
}: ModernSelectProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
const selected = useMemo(
() => options.find((item) => item.value === value),
[options, value],
);
const visibleOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!searchable || !query) return options;
return options.filter((item) => {
const haystack = [
item.label,
item.description,
item.value,
]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [options, searchQuery, searchable]);
useEffect(() => {
if (!open) return;
if (typeof document === 'undefined') return;
const handleOutsideClick = (event: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
useEffect(() => {
if (!open && searchQuery) {
setSearchQuery('');
}
}, [open, searchQuery]);
const renderOptionIcon = (item: ModernSelectOption) => {
if (item.iconNode) {
return item.iconNode;
}
if (item.iconUrl) {
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
}
if (item.iconText) {
return <span className="modern-select-option-icon-text">{item.iconText}</span>;
}
return null;
};
return (
<div
ref={rootRef}
data-testid={dataTestId}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
type="button"
className="modern-select-trigger"
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
}}
aria-expanded={open}
disabled={disabled}
>
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
{selected ? (
<span className="modern-select-value-content">
{renderOptionIcon(selected)}
<span>{selected.label}</span>
</span>
) : (
placeholder
)}
</span>
<svg
className="modern-select-chevron"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
{searchable && (
<div className="modern-select-search-shell">
<input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={searchPlaceholder}
className="modern-select-search-input"
/>
</div>
)}
{visibleOptions.length === 0 ? (
<div className="modern-select-empty">{emptyLabel}</div>
) : (
visibleOptions.map((item) => {
const active = item.value === value;
return (
<button
key={item.value}
type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
}}
disabled={item.disabled}
>
<div className="modern-select-option-main">
{renderOptionIcon(item)}
<div style={{ minWidth: 0 }}>
<div className="modern-select-option-label">{item.label}</div>
{item.description && (
<div className="modern-select-option-desc">{item.description}</div>
)}
</div>
</div>
{active && (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})
)}
</div>
</div>
);
}
/**
* Backward-compatible import path. Keep this file deliberately tiny so the
* project-owned `components/ui` layer remains the single implementation.
*/
export {
default,
type ModernSelectOption,
type ModernSelectProps,
} from './ui/Select.js';
@@ -10,7 +10,7 @@ describe('ResponsiveBatchActionBar page adoption', () => {
it('is used by the repeated admin list pages instead of open-coded mobile and desktop batch wrappers', () => {
expect(readPageSource('src/web/pages/Accounts.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Sites.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Tokens.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/tokens/TokensPanel.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/DownstreamKeys.tsx')).toContain('ResponsiveBatchActionBar');
});
});
+9 -8
View File
@@ -1,4 +1,5 @@
import CenteredModal from './CenteredModal.js';
import { Button } from './ui/Button.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
type NextStepChoice = 'session' | 'apikey' | 'later';
@@ -56,22 +57,22 @@ export default function SiteCreatedModal({
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={() => onChoice('later')} className="btn btn-ghost">
<Button onClick={() => onChoice('later')} variant="ghost">
稍后配置
</button>
<button
</Button>
<Button
onClick={() => onChoice(secondaryAction.choice)}
className="btn btn-ghost"
variant="ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{secondaryAction.label}
</button>
<button
</Button>
<Button
onClick={() => onChoice(primaryAction.choice)}
className="btn btn-primary"
variant="primary"
>
{primaryAction.label}
</button>
</Button>
</>
)}
>
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { useEffect } from 'react';
import { ToastProvider, useToast } from './Toast.js';
afterEach(() => {
vi.useRealTimers();
});
describe('ToastProvider accessibility and lifecycle', () => {
it('keeps the public useToast API and exposes live status semantics', async () => {
function EffectEmitter() {
const toast = useToast();
useEffect(() => {
toast.success('保存成功');
}, [toast]);
return null;
}
let root!: WebTestRenderer;
await act(async () => {
root = create(
<ToastProvider>
<EffectEmitter />
</ToastProvider>,
);
});
const region = root.root.find((node) => node.props.role === 'region');
expect(region.props['aria-live']).toBe('polite');
const status = root.root.find((node) => node.props.role === 'status');
expect(status.props['aria-live']).toBe('polite');
expect(status.props['aria-label']).toBe('保存成功');
root.unmount();
});
it('cleans auto-dismiss and exit timers when the provider unmounts', async () => {
vi.useFakeTimers();
let root!: WebTestRenderer;
function Emitter() {
const toast = useToast();
useEffect(() => {
toast.info('稍后刷新');
}, [toast]);
return null;
}
await act(async () => {
root = create(
<ToastProvider>
<Emitter />
</ToastProvider>,
);
});
expect(vi.getTimerCount()).toBeGreaterThan(0);
await act(async () => {
root.unmount();
});
expect(vi.getTimerCount()).toBe(0);
});
});
+68 -8
View File
@@ -1,8 +1,16 @@
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
type ToastType = 'success' | 'error' | 'info';
export type ToastType = 'success' | 'error' | 'info';
interface Toast {
type Toast = {
id: number;
type: ToastType;
message: string;
@@ -33,18 +41,53 @@ const icons: Record<ToastType, React.ReactNode> = {
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const idRef = useRef(0);
// React runs child effects before parent effects. Start true so a child that
// emits a toast from its first effect is not dropped before this provider's
// mount effect has run; the cleanup flips it to false on unmount.
const mountedRef = useRef(true);
const dismissTimersRef = useRef(new Map<number, ReturnType<typeof globalThis.setTimeout>>());
const removeTimersRef = useRef(new Map<number, ReturnType<typeof globalThis.setTimeout>>());
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
dismissTimersRef.current.forEach((timer) => globalThis.clearTimeout(timer));
removeTimersRef.current.forEach((timer) => globalThis.clearTimeout(timer));
dismissTimersRef.current.clear();
removeTimersRef.current.clear();
};
}, []);
const clearDismissTimer = useCallback((id: number) => {
const timer = dismissTimersRef.current.get(id);
if (timer === undefined) return;
globalThis.clearTimeout(timer);
dismissTimersRef.current.delete(id);
}, []);
const removeToast = useCallback((id: number) => {
if (!mountedRef.current) return;
clearDismissTimer(id);
if (removeTimersRef.current.has(id)) return;
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
setTimeout(() => {
const timer = globalThis.setTimeout(() => {
removeTimersRef.current.delete(id);
if (!mountedRef.current) return;
setToasts(prev => prev.filter(t => t.id !== id));
}, 250);
}, []);
removeTimersRef.current.set(id, timer);
}, [clearDismissTimer]);
const addToast = useCallback((type: ToastType, message: string) => {
const id = ++idRef.current;
if (!mountedRef.current) return;
setToasts(prev => [...prev, { id, type, message }]);
setTimeout(() => removeToast(id), 3200);
const timer = globalThis.setTimeout(() => {
dismissTimersRef.current.delete(id);
removeToast(id);
}, 3200);
dismissTimersRef.current.set(id, timer);
}, [removeToast]);
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
@@ -63,17 +106,34 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-container">
<div
className="toast-container"
role="region"
aria-live="polite"
aria-relevant="additions text"
aria-label="通知"
>
{toasts.map(t => (
<div
key={t.id}
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
onClick={() => removeToast(t.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
removeToast(t.id);
}
}}
role="status"
aria-live="polite"
aria-atomic="true"
tabIndex={0}
aria-label={t.message}
style={{ cursor: 'pointer' }}
>
<span style={{ flexShrink: 0, marginTop: 1 }}>{icons[t.type]}</span>
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span>
<div className="toast-progress" />
<div className="toast-progress" aria-hidden="true" />
</div>
))}
</div>
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import { AsyncButton, Button } from './Button.js';
describe('Button primitives', () => {
it('keeps the existing btn theme and defaults non-submit actions to button', () => {
const root = create(<Button variant="ghost">取消</Button>);
const button = root.root.findByType('button');
expect(button.props.type).toBe('button');
expect(button.props.className).toContain('btn btn-ghost');
expect(button.props.disabled).toBeFalsy();
});
it('marks async actions busy, disabled, and visibly loading', () => {
const onClick = vi.fn();
const root = create(
<AsyncButton variant="primary" pending onClick={onClick} loadingLabel="保存中...">
保存
</AsyncButton>,
);
const button = root.root.findByType('button');
expect(button.props.disabled).toBe(true);
expect(button.props['aria-busy']).toBe(true);
expect(button.findAll((node) => node.props.className === 'spinner spinner-sm')).toHaveLength(1);
expect(button.children.join('')).toContain('保存中...');
// The native disabled contract prevents activation; the facade does not
// swallow the callback when a test invokes it directly.
expect(button.props.type).toBe('button');
});
});
+81
View File
@@ -0,0 +1,81 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
export type ButtonVariant =
| 'primary'
| 'soft-primary'
| 'danger'
| 'success'
| 'ghost'
| 'link';
export type ButtonSize = 'sm' | 'md' | 'lg';
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & {
/** Native type is opt-in; non-form actions default to `button`. */
type?: ButtonHTMLAttributes<HTMLButtonElement>['type'];
variant?: ButtonVariant;
size?: ButtonSize;
/** Shows a spinner and disables the button while an action is pending. */
loading?: boolean;
/** Optional replacement text announced/rendered while loading. */
loadingLabel?: ReactNode;
};
function classNames(
className: string | undefined,
variant: ButtonVariant,
size: ButtonSize,
): string {
const classes = [
'btn',
`btn-${variant}`,
size === 'md' ? undefined : `btn-${size}`,
className,
].filter(Boolean);
return [...new Set(classes.flatMap((value) => value!.split(/\s+/).filter(Boolean)))].join(' ');
}
/** Project-owned button facade that preserves the existing .btn-* theme. */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
type = 'button',
variant = 'primary',
size = 'md',
loading = false,
loadingLabel,
className,
children,
disabled,
...props
}, ref) {
const isDisabled = Boolean(disabled || loading);
return (
<button
{...props}
ref={ref}
type={type}
className={classNames(className, variant, size)}
disabled={isDisabled}
aria-busy={loading || undefined}
>
{loading ? <span className="spinner spinner-sm" aria-hidden="true" /> : null}
{loading && loadingLabel !== undefined ? loadingLabel : children}
</button>
);
});
export type AsyncButtonProps = Omit<ButtonProps, 'loading'> & {
/** `pending` is an ergonomic alias for callers that model async state that way. */
pending?: boolean;
loading?: boolean;
};
/** Semantic alias for mutations; supports both loading and pending naming. */
export const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(function AsyncButton({
pending = false,
loading = false,
...props
}, ref) {
return <Button ref={ref} {...props} loading={loading || pending} />;
});
export default Button;
+122
View File
@@ -0,0 +1,122 @@
/** @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Dialog, Drawer } from './Dialog.js';
import { getBodyScrollLockCount } from './lifecycle.js';
describe('Radix browser renderer', () => {
let host: HTMLDivElement;
let root: Root;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
const previousForceRadix = (globalThis as { __METAPI_FORCE_RADIX_RENDERER__?: boolean }).__METAPI_FORCE_RADIX_RENDERER__;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
(globalThis as { __METAPI_FORCE_RADIX_RENDERER__?: boolean }).__METAPI_FORCE_RADIX_RENDERER__ = true;
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
host.remove();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
(globalThis as { __METAPI_FORCE_RADIX_RENDERER__?: boolean }).__METAPI_FORCE_RADIX_RENDERER__ = previousForceRadix;
});
it('mounts an accessible dialog into the body portal and closes from the backdrop once', async () => {
const onClose = vi.fn();
await act(async () => {
root.render(
<Dialog open onClose={onClose} title="浏览器对话框" closeOnBackdrop>
<div>内容</div>
</Dialog>,
);
});
const panel = document.body.querySelector('[data-ui-surface="dialog"]');
expect(panel).toBeTruthy();
expect(panel?.getAttribute('role')).toBe('dialog');
// Radix's browser RemoveScroll is the sole browser lock owner; the
// project reference-counted fallback must not be acquired a second time.
expect(getBodyScrollLockCount()).toBe(0);
const backdrop = document.body.querySelector('.modal-backdrop') as HTMLElement;
await act(async () => {
backdrop.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it('restores the document scroll state after the browser surface unmounts', async () => {
document.body.style.overflow = 'scroll';
await act(async () => {
root.render(
<Dialog open onClose={() => {}} title="滚动清理">
<div>内容</div>
</Dialog>,
);
});
await act(async () => {
root.render(null);
});
expect(document.body.style.overflow).toBe('scroll');
expect(getBodyScrollLockCount()).toBe(0);
});
it('uses the same Radix lifecycle for a right-side drawer', async () => {
await act(async () => {
root.render(
<Drawer open onClose={() => {}} title="浏览器抽屉" side="right">
<div>内容</div>
</Drawer>,
);
});
const panel = document.body.querySelector('[data-ui-surface="drawer"]');
expect(panel?.className).toContain('mobile-drawer-panel-right');
expect(document.body.querySelector('.mobile-drawer-backdrop')).toBeTruthy();
});
it('honors the explicit Escape policy in the Radix path', async () => {
const onClose = vi.fn();
await act(async () => {
root.render(
<Dialog open onClose={onClose} title="禁用 Escape" closeOnEscape={false}>
<div>内容</div>
</Dialog>,
);
});
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(onClose).not.toHaveBeenCalled();
});
it('closes a backdrop gesture exactly once when pointerdown precedes click', async () => {
const onClose = vi.fn();
await act(async () => {
root.render(
<Drawer open onClose={onClose} title="单次关闭" closeOnBackdrop closeOnEscape>
<div>内容</div>
</Drawer>,
);
});
const backdrop = document.body.querySelector('.mobile-drawer-backdrop') as HTMLElement;
expect(backdrop).toBeTruthy();
// Radix's DismissableLayer observes pointerdown on the document before
// the browser dispatches the matching click. The facade must keep that
// gesture from invoking the close callback twice.
backdrop.dispatchEvent(new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0,
}));
await act(async () => {
backdrop.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+195
View File
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { Dialog, Drawer } from './Dialog.js';
import { getBodyScrollLockCount } from './lifecycle.js';
type FakeDocument = {
body: {
style: { overflow: string };
appendChild: () => unknown;
removeChild: () => unknown;
};
activeElement: { focus: ReturnType<typeof vi.fn> };
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
};
function installDocument(initialOverflow = 'auto'): FakeDocument {
const fakeDocument: FakeDocument = {
body: {
style: { overflow: initialOverflow },
appendChild: vi.fn(),
removeChild: vi.fn(),
},
activeElement: { focus: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
vi.stubGlobal('document', fakeDocument);
return fakeDocument;
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('project-owned dialog and drawer wrappers', () => {
it('preserves modal classes while exposing an accessible dialog contract', async () => {
const document = installDocument();
const onClose = vi.fn();
let root!: WebTestRenderer;
await act(async () => {
root = create(
<Dialog open onClose={onClose} title="设置">
<div>内容</div>
</Dialog>,
);
});
const panel = root.root.find((node) => node.props['data-ui-surface'] === 'dialog');
expect(panel.props.role).toBe('dialog');
expect(panel.props['aria-modal']).toBe(true);
expect(panel.props['aria-labelledby']).toEqual(expect.any(String));
expect(panel.props.className).toContain('modal-content');
const backdrop = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-backdrop')
));
expect(backdrop.props.onClick).toBeUndefined();
expect(document.body.style.overflow).toBe('hidden');
await act(async () => {
root.unmount();
});
expect(document.body.style.overflow).toBe('auto');
expect(getBodyScrollLockCount()).toBe(0);
});
it('provides an explicit aria-label when a drawer has no visible title', async () => {
installDocument();
let root!: WebTestRenderer;
await act(async () => {
root = create(
<Drawer open onClose={() => {}} showCloseButton={false} ariaLabel="筛选面板">
<div>筛选内容</div>
</Drawer>,
);
});
const panel = root.root.find((node) => node.props['data-ui-surface'] === 'drawer');
expect(panel.props['aria-labelledby']).toBeUndefined();
expect(panel.props['aria-label']).toBe('筛选面板');
root.unmount();
});
it('applies drawer content styles through the shared facade', async () => {
installDocument();
let root!: WebTestRenderer;
await act(async () => {
root = create(
<Drawer
open
onClose={() => {}}
contentStyle={{ width: 420, maxWidth: '90vw' }}
>
<div>内容</div>
</Drawer>,
);
});
const panel = root.root.find((node) => node.props['data-ui-surface'] === 'drawer');
expect(panel.props.style).toMatchObject({ width: 420, maxWidth: '90vw' });
root.unmount();
});
it('keeps body locked until every independently mounted surface closes', async () => {
const document = installDocument('visible');
let root!: WebTestRenderer;
await act(async () => {
root = create(
<>
<Dialog open onClose={() => {}} title="一个">
<div />
</Dialog>
<Drawer open onClose={() => {}} title="两个">
<div />
</Drawer>
</>,
);
});
expect(document.body.style.overflow).toBe('hidden');
expect(getBodyScrollLockCount()).toBe(2);
await act(async () => {
root.update(
<>
<Dialog open={false} onClose={() => {}} title="一个">
<div />
</Dialog>
<Drawer open onClose={() => {}} title="两个">
<div />
</Drawer>
</>,
);
});
expect(document.body.style.overflow).toBe('hidden');
expect(getBodyScrollLockCount()).toBe(1);
await act(async () => {
root.update(
<>
<Dialog open={false} onClose={() => {}} title="一个">
<div />
</Dialog>
<Drawer open={false} onClose={() => {}} title="两个">
<div />
</Drawer>
</>,
);
});
expect(document.body.style.overflow).toBe('visible');
expect(getBodyScrollLockCount()).toBe(0);
root.unmount();
});
it('restores focus to the opener and only the top overlay handles Escape', async () => {
vi.useFakeTimers();
const document = installDocument();
const opener = { focus: vi.fn() };
document.activeElement = opener;
const outerClose = vi.fn();
const innerClose = vi.fn();
let root!: WebTestRenderer;
await act(async () => {
root = create(
<>
<Dialog open closeOnEscape onClose={outerClose} title="外层">
<div />
</Dialog>
<Drawer open closeOnEscape onClose={innerClose} title="内层">
<div />
</Drawer>
</>,
);
});
const keydownHandlers = document.addEventListener.mock.calls
.map((call) => call[1])
.filter((handler): handler is (event: KeyboardEvent) => void => typeof handler === 'function');
expect(keydownHandlers.length).toBeGreaterThan(0);
keydownHandlers.forEach((handler) => handler({ key: 'Escape' } as KeyboardEvent));
expect(innerClose).toHaveBeenCalledTimes(1);
expect(outerClose).not.toHaveBeenCalled();
await act(async () => {
root.update(<Dialog open={false} onClose={() => {}} title="外层"><div /></Dialog>);
});
vi.runAllTimers();
expect(opener.focus).toHaveBeenCalled();
root.unmount();
});
});
+590
View File
@@ -0,0 +1,590 @@
import { useCallback, useId, useMemo, useRef, type CSSProperties, type ReactNode } from 'react';
import * as RadixDialog from '@radix-ui/react-dialog';
import { useAnimatedVisibility } from '../useAnimatedVisibility.js';
import {
getPortalTarget,
renderInPortal,
useBodyScrollLock,
useEscapeKey,
useFocusReturn,
} from './lifecycle.js';
type SurfaceKind = 'dialog' | 'drawer';
/** Compatibility shape for callers that already own an exit-animation hook. */
export type DialogPresence = {
shouldRender: boolean;
isVisible: boolean;
};
export type DialogProps = {
open: boolean;
onClose: () => void;
title?: ReactNode;
children: ReactNode;
footer?: ReactNode;
maxWidth?: number | string;
bodyStyle?: CSSProperties;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
closeLabel?: string;
ariaLabel?: string;
ariaDescribedBy?: string;
/** Optional state announced while an async action keeps the surface busy. */
ariaBusy?: boolean;
className?: string;
contentClassName?: string;
/** Additional styles for the surface panel (merged after the max-width). */
contentStyle?: CSSProperties;
animationDuration?: number;
/** Optional externally managed presence, retained for incremental migrations. */
presence?: DialogPresence;
};
export type DrawerProps = {
open: boolean;
onClose: () => void;
children: ReactNode;
title?: ReactNode;
closeLabel?: string;
side?: 'left' | 'right';
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
ariaLabel?: string;
ariaDescribedBy?: string;
/** Optional state announced while an async action keeps the surface busy. */
ariaBusy?: boolean;
className?: string;
contentClassName?: string;
/** Additional styles for the surface panel. */
contentStyle?: CSSProperties;
animationDuration?: number;
/** Optional externally managed presence, retained for incremental migrations. */
presence?: DialogPresence;
};
type DialogSurfaceProps = {
kind: SurfaceKind;
open: boolean;
onClose: () => void;
title?: ReactNode;
children: ReactNode;
footer?: ReactNode;
maxWidth?: number | string;
bodyStyle?: CSSProperties;
closeOnBackdrop: boolean;
closeOnEscape: boolean;
showCloseButton: boolean;
closeLabel: string;
ariaLabel?: string;
ariaDescribedBy?: string;
ariaBusy?: boolean;
side?: 'left' | 'right';
className?: string;
contentClassName?: string;
contentStyle?: CSSProperties;
animationDuration: number;
presence?: DialogPresence;
};
function joinClasses(...classes: Array<string | undefined | false>): string | undefined {
const value = classes.filter(Boolean).join(' ').trim();
return value || undefined;
}
function isTestRuntime(): boolean {
const processLike = (globalThis as {
process?: { env?: Record<string, string | undefined> };
}).process;
return processLike?.env?.NODE_ENV === 'test';
}
/**
* Radix's DOM portal is enabled for the browser build. Vitest's
* react-test-renderer intentionally has no DOM host, so the same semantic
* shell falls back to a plain React tree there. This keeps SSR/test rendering
* deterministic while retaining Radix focus/dismiss behavior for users.
*/
function canUseRadixRenderer(target: HTMLElement | null): target is HTMLElement {
const forceRadix = (globalThis as { __METAPI_FORCE_RADIX_RENDERER__?: boolean }).__METAPI_FORCE_RADIX_RENDERER__ === true;
if ((isTestRuntime() && !forceRadix) || typeof window === 'undefined' || !target) return false;
return typeof target.ownerDocument?.createElement === 'function';
}
function hasTitle(title: ReactNode): boolean {
return title !== undefined && title !== null && title !== false;
}
type SurfaceInnerProps = {
kind: SurfaceKind;
title?: ReactNode;
titleId: string;
children: ReactNode;
footer?: ReactNode;
bodyStyle?: CSSProperties;
closeLabel: string;
showCloseButton: boolean;
onClose: () => void;
useRadixTitle: boolean;
};
function SurfaceTitle({
title,
titleId,
useRadixTitle,
}: Pick<SurfaceInnerProps, 'title' | 'titleId' | 'useRadixTitle'>) {
if (!hasTitle(title)) {
return <div className="modal-title" aria-hidden="true" />;
}
const titleNode = (
<div className={useRadixTitle ? 'modal-title' : 'modal-title'} id={titleId}>
{title}
</div>
);
return useRadixTitle ? (
<RadixDialog.Title asChild>{titleNode}</RadixDialog.Title>
) : titleNode;
}
function DialogInner({
kind,
title,
titleId,
children,
footer,
bodyStyle,
closeLabel,
showCloseButton,
onClose,
useRadixTitle,
}: SurfaceInnerProps) {
if (kind === 'drawer') {
return (
<>
<div className="mobile-drawer-toolbar">
{hasTitle(title) ? (
<div className="mobile-drawer-title" id={titleId}>
{useRadixTitle ? (
<RadixDialog.Title asChild>
<span>{title}</span>
</RadixDialog.Title>
) : title}
</div>
) : (useRadixTitle
? <RadixDialog.Title asChild><span className="ui-visually-hidden">抽屉</span></RadixDialog.Title>
: <div />)}
{showCloseButton ? (
<button
type="button"
className="mobile-drawer-close"
onClick={onClose}
aria-label={closeLabel}
>
×
</button>
) : null}
</div>
{children}
</>
);
}
return (
<>
{!hasTitle(title) && useRadixTitle ? (
<RadixDialog.Title asChild>
<span className="ui-visually-hidden" id={titleId}>对话框</span>
</RadixDialog.Title>
) : null}
{hasTitle(title) || showCloseButton ? (
<div className="modal-header">
<SurfaceTitle title={title} titleId={titleId} useRadixTitle={useRadixTitle} />
{showCloseButton ? (
<button
type="button"
className="modal-close-button"
onClick={onClose}
aria-label={closeLabel}
>
×
</button>
) : null}
</div>
) : null}
<div className="modal-body" style={bodyStyle}>
{children}
</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</>
);
}
function DialogSurface({
kind,
open,
onClose,
title,
children,
footer,
maxWidth = 860,
bodyStyle,
closeOnBackdrop,
closeOnEscape,
showCloseButton,
closeLabel,
ariaLabel,
ariaDescribedBy,
ariaBusy,
side = 'left',
className,
contentClassName,
contentStyle: contentStyleProp,
animationDuration,
presence,
}: DialogSurfaceProps) {
const internalPresence = useAnimatedVisibility(
open,
animationDuration,
!presence,
);
const resolvedPresence = presence ?? internalPresence;
const lifecycleOpen = presence ? open && presence.isVisible : open;
const portalTarget = getPortalTarget();
const useRadixRenderer = canUseRadixRenderer(portalTarget);
const titleId = useId();
const labelledBy = hasTitle(title) ? titleId : undefined;
const isVisible = resolvedPresence.isVisible;
const closingClass = isVisible ? undefined : 'is-closing';
const fallbackFocusTarget = typeof document !== 'undefined' ? document.body : null;
const closeGuardRef = useRef(false);
const requestClose = useCallback(() => {
// Radix can report both an outside interaction and an overlay click for
// one pointer gesture. Keep all close paths single-flight for that tick.
if (closeGuardRef.current) return;
closeGuardRef.current = true;
const resetGuard = () => {
closeGuardRef.current = false;
};
if (typeof globalThis.queueMicrotask === 'function') {
globalThis.queueMicrotask(resetGuard);
} else {
void Promise.resolve().then(resetGuard);
}
onClose();
}, [onClose]);
// Radix's modal overlay owns body scrolling in the browser renderer. The
// project lock remains the fallback for SSR/test/partial DOM renderers, and
// avoiding two independent owners prevents cleanup order from restoring a
// stale overflow value after the last surface closes.
useBodyScrollLock(lifecycleOpen && !useRadixRenderer);
useFocusReturn(lifecycleOpen, fallbackFocusTarget);
// CenteredModal historically skipped document listeners when no usable
// portal/body existed; retain that SSR/test fallback. Drawers still accept
// a lightweight document stub because their close policy is independent of
// whether the portal target can be attached. Radix owns Escape handling in
// the browser path, so registering a second listener there would double
// invoke the caller.
useEscapeKey(
lifecycleOpen && closeOnEscape && !useRadixRenderer && (portalTarget !== null || kind === 'drawer'),
requestClose,
);
const rootClassName = useMemo(
() => kind === 'dialog'
? joinClasses('modal-backdrop', closingClass, className)
: joinClasses('mobile-drawer-root', closingClass, className),
[className, closingClass, kind],
);
const panelClassName = useMemo(
() => kind === 'dialog'
? joinClasses('modal-content', closingClass, contentClassName)
: joinClasses('mobile-drawer-panel', `mobile-drawer-panel-${side}`, contentClassName),
[closingClass, contentClassName, kind, side],
);
if (!resolvedPresence.shouldRender) return null;
const contentStyle = kind === 'dialog'
? { maxWidth, ...contentStyleProp }
: contentStyleProp;
const contentAria = {
role: 'dialog' as const,
'aria-modal': true,
'aria-labelledby': labelledBy,
'aria-label': labelledBy ? undefined : (ariaLabel ?? (kind === 'drawer' ? '抽屉' : '对话框')),
'aria-describedby': ariaDescribedBy,
'aria-busy': ariaBusy || undefined,
tabIndex: -1,
'data-ui-surface': kind,
'data-state': lifecycleOpen ? 'open' : 'closed',
};
const handleBackdropClick = closeOnBackdrop ? requestClose : undefined;
const inner = (
<DialogInner
kind={kind}
title={title}
titleId={titleId}
footer={footer}
bodyStyle={bodyStyle}
closeLabel={closeLabel}
showCloseButton={showCloseButton}
onClose={requestClose}
useRadixTitle={false}
>
{children}
</DialogInner>
);
if (useRadixRenderer) {
const radixInner = (
<DialogInner
kind={kind}
title={title}
titleId={titleId}
footer={footer}
bodyStyle={bodyStyle}
closeLabel={closeLabel}
showCloseButton={showCloseButton}
onClose={requestClose}
useRadixTitle
>
{children}
</DialogInner>
);
const radixSurface = kind === 'dialog' ? (
<RadixDialog.Root
open={lifecycleOpen}
onOpenChange={(nextOpen) => {
// Root can receive a dismissal event from Radix's document-level
// listener even when the controlled surface explicitly disables
// Escape. The facade's close policy is authoritative.
if (!nextOpen && lifecycleOpen && closeOnEscape) requestClose();
}}
>
<RadixDialog.Portal container={portalTarget} forceMount={resolvedPresence.shouldRender}>
<RadixDialog.Overlay
asChild
forceMount={resolvedPresence.shouldRender}
onClick={kind === 'dialog' ? handleBackdropClick : undefined}
>
<div className={rootClassName}>
<RadixDialog.Content
forceMount={resolvedPresence.shouldRender}
{...contentAria}
className={panelClassName}
style={contentStyle}
onClick={(event) => event.stopPropagation()}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={(event) => {
if (!closeOnEscape) event.preventDefault();
}}
onPointerDownOutside={(event) => {
// The backdrop has an explicit click owner below. Prevent
// Radix's dismissable layer from also closing on the
// preceding pointerdown; otherwise one gesture can invoke
// onClose twice (pointerdown + click), especially when
// closeOnEscape is enabled as well.
event.preventDefault();
}}
onInteractOutside={(event) => {
// Keep backdrop dismissal under the facade's explicit
// closeOnBackdrop click policy (see onPointerDownOutside).
event.preventDefault();
}}
>
{radixInner}
</RadixDialog.Content>
</div>
</RadixDialog.Overlay>
</RadixDialog.Portal>
</RadixDialog.Root>
) : (
<RadixDialog.Root
open={lifecycleOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen && lifecycleOpen && closeOnEscape) requestClose();
}}
>
<RadixDialog.Portal container={portalTarget} forceMount={resolvedPresence.shouldRender}>
<RadixDialog.Overlay
asChild
forceMount={resolvedPresence.shouldRender}
onClick={undefined}
>
<div className={rootClassName}>
<div
className="mobile-drawer-backdrop"
onClick={handleBackdropClick}
aria-hidden="true"
/>
<RadixDialog.Content
forceMount={resolvedPresence.shouldRender}
{...contentAria}
className={panelClassName}
style={contentStyle}
onClick={(event) => event.stopPropagation()}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={(event) => {
if (!closeOnEscape) event.preventDefault();
}}
onPointerDownOutside={(event) => {
// The nested drawer backdrop owns pointer dismissal. Stop
// Radix's pointerdown dismissal so the following click does
// not produce a second onClose call.
event.preventDefault();
}}
onInteractOutside={(event) => {
event.preventDefault();
}}
>
{radixInner}
</RadixDialog.Content>
</div>
</RadixDialog.Overlay>
</RadixDialog.Portal>
</RadixDialog.Root>
);
return radixSurface;
}
const fallbackSurface = kind === 'dialog' ? (
<div className={rootClassName} onClick={handleBackdropClick}>
<div
className={panelClassName}
style={contentStyle}
{...contentAria}
onClick={(event) => event.stopPropagation()}
>
{inner}
</div>
</div>
) : (
<div className={rootClassName}>
<div
className="mobile-drawer-backdrop"
onClick={handleBackdropClick}
aria-hidden="true"
/>
<div
className={panelClassName}
style={contentStyle}
{...contentAria}
onClick={(event) => event.stopPropagation()}
>
{inner}
</div>
</div>
);
return renderInPortal(fallbackSurface);
}
/** Project-owned centered dialog API. */
export function Dialog({
open,
onClose,
title,
children,
footer,
maxWidth = 860,
bodyStyle,
closeOnBackdrop = false,
closeOnEscape = false,
showCloseButton = true,
closeLabel = '关闭弹框',
ariaLabel,
ariaDescribedBy,
ariaBusy,
className,
contentClassName,
contentStyle,
animationDuration = 220,
presence,
}: DialogProps) {
return (
<DialogSurface
kind="dialog"
open={open}
onClose={onClose}
title={title}
footer={footer}
maxWidth={maxWidth}
bodyStyle={bodyStyle}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
showCloseButton={showCloseButton}
closeLabel={closeLabel}
ariaLabel={ariaLabel}
ariaDescribedBy={ariaDescribedBy}
ariaBusy={ariaBusy}
className={className}
contentClassName={contentClassName}
contentStyle={contentStyle}
animationDuration={animationDuration}
presence={presence}
>
{children}
</DialogSurface>
);
}
/** Project-owned drawer API sharing the dialog lifecycle implementation. */
export function Drawer({
open,
onClose,
children,
title,
closeLabel = '关闭导航',
side = 'left',
closeOnBackdrop = true,
closeOnEscape = true,
showCloseButton = true,
ariaLabel,
ariaDescribedBy,
ariaBusy,
className,
contentClassName,
contentStyle,
animationDuration = 280,
presence,
}: DrawerProps) {
return (
<DialogSurface
kind="drawer"
open={open}
onClose={onClose}
title={title}
side={side}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
showCloseButton={showCloseButton}
closeLabel={closeLabel}
ariaLabel={ariaLabel}
ariaDescribedBy={ariaDescribedBy}
ariaBusy={ariaBusy}
className={className}
contentClassName={contentClassName}
contentStyle={contentStyle}
animationDuration={animationDuration}
presence={presence}
>
{children}
</DialogSurface>
);
}
export { DialogSurface };
export default Dialog;
+5
View File
@@ -0,0 +1,5 @@
export {
Drawer as default,
Drawer,
type DrawerProps,
} from './Dialog.js';
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
export type ModernSelectOption = {
value: string;
label: string;
description?: string;
disabled?: boolean;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
};
export type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
'data-testid'?: string;
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
menuMaxHeight?: number;
className?: string;
size?: 'md' | 'sm';
searchable?: boolean;
searchPlaceholder?: string;
};
function optionText(option: ModernSelectOption): string {
return [option.label, option.description, option.value]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join(' ')
.toLowerCase();
}
function firstEnabledIndex(options: ModernSelectOption[], start = 0): number {
for (let index = Math.max(0, start); index < options.length; index += 1) {
if (!options[index]?.disabled) return index;
}
return -1;
}
/**
* Metapi's select facade. The public contract intentionally stays independent
* of a third-party Select primitive so pages and tests can migrate in place.
* Its listbox semantics and keyboard behavior match the eventual Radix Select
* adapter without exposing Radix nodes to callers.
*/
export default function ModernSelect({
value,
onChange,
options,
'data-testid': dataTestId,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
menuMaxHeight = 280,
className = '',
size = 'md',
searchable = false,
searchPlaceholder = 'Search...',
}: ModernSelectProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listboxId = useId();
const selected = useMemo(
() => options.find((item) => item.value === value),
[options, value],
);
const visibleOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!searchable || !query) return options;
return options.filter((item) => optionText(item).includes(query));
}, [options, searchQuery, searchable]);
useEffect(() => {
if (!open || typeof document === 'undefined') return undefined;
const handleOutsideClick = (event: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(event.target as Node)) setOpen(false);
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
useEffect(() => {
if (!open) {
if (searchQuery) setSearchQuery('');
setHighlightedIndex(-1);
return;
}
const selectedIndex = visibleOptions.findIndex((item) => item.value === value && !item.disabled);
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(visibleOptions));
}, [open, searchQuery, value, visibleOptions]);
const selectOption = (item: ModernSelectOption) => {
if (disabled || item.disabled) return;
onChange(item.value);
setOpen(false);
triggerRef.current?.focus();
};
const handleTriggerKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (!open) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
setOpen(true);
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
return;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
const enabledIndices = visibleOptions
.map((item, index) => (item.disabled ? -1 : index))
.filter((index) => index >= 0);
if (enabledIndices.length === 0) return;
const currentPosition = enabledIndices.indexOf(highlightedIndex);
const basePosition = currentPosition >= 0
? currentPosition
: (direction > 0 ? -1 : 0);
const nextPosition = (basePosition + direction + enabledIndices.length) % enabledIndices.length;
setHighlightedIndex(enabledIndices[nextPosition] ?? -1);
return;
}
if ((event.key === 'Enter' || event.key === ' ') && highlightedIndex >= 0) {
event.preventDefault();
const item = visibleOptions[highlightedIndex];
if (item) selectOption(item);
}
};
const renderOptionIcon = (item: ModernSelectOption) => {
if (item.iconNode) return item.iconNode;
if (item.iconUrl) {
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
}
if (item.iconText) return <span className="modern-select-option-icon-text">{item.iconText}</span>;
return null;
};
return (
<div
ref={rootRef}
data-testid={dataTestId}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
ref={triggerRef}
type="button"
className="modern-select-trigger"
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
}}
onKeyDown={handleTriggerKeyDown}
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={open && highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
disabled={disabled}
>
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
{selected ? (
<span className="modern-select-value-content">
{renderOptionIcon(selected)}
<span>{selected.label}</span>
</span>
) : placeholder}
</span>
<svg
className="modern-select-chevron"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
id={listboxId}
className="modern-select-panel"
style={{ maxHeight: menuMaxHeight }}
role="listbox"
aria-hidden={!open}
aria-label={placeholder}
>
{searchable && (
<div className="modern-select-search-shell">
<input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="modern-select-search-input"
/>
</div>
)}
{visibleOptions.length === 0 ? (
<div className="modern-select-empty" role="status">{emptyLabel}</div>
) : (
visibleOptions.map((item, index) => {
const active = item.value === value;
const highlighted = index === highlightedIndex;
return (
<button
key={item.value}
id={`${listboxId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`modern-select-option ${active ? 'is-active' : ''} ${highlighted ? 'is-highlighted' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => selectOption(item)}
disabled={item.disabled}
>
<div className="modern-select-option-main">
{renderOptionIcon(item)}
<div style={{ minWidth: 0 }}>
<div className="modern-select-option-label">{item.label}</div>
{item.description && (
<div className="modern-select-option-desc">{item.description}</div>
)}
</div>
</div>
{active && (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
const webRoot = join(process.cwd(), 'src/web');
function collectSourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return collectSourceFiles(path);
if (!/\.(?:ts|tsx)$/.test(entry.name) || /\.test\.(?:ts|tsx)$/.test(entry.name)) return [];
return [path];
});
}
describe('web UI architecture boundary', () => {
it('keeps Radix imports inside components/ui', () => {
const allSources = collectSourceFiles(webRoot);
const uiSources = allSources.filter((path) => {
const relativePath = relative(webRoot, path).replaceAll('\\', '/');
return relativePath.startsWith('components/ui/');
});
expect(uiSources.some((path) => /@radix-ui\//.test(readFileSync(path, 'utf8')))).toBe(true);
const directImportsOutsideBoundary = allSources
.filter((path) => {
const relativePath = relative(webRoot, path).replaceAll('\\', '/');
return !relativePath.startsWith('components/ui/');
})
.filter((path) => /@radix-ui\//.test(readFileSync(path, 'utf8')))
.map((path) => relative(process.cwd(), path));
expect(directImportsOutsideBoundary).toEqual([]);
});
it('keeps the compatibility facades third-party-free', () => {
for (const path of [
join(webRoot, 'components/CenteredModal.tsx'),
join(webRoot, 'components/MobileDrawer.tsx'),
join(webRoot, 'components/ModernSelect.tsx'),
]) {
expect(readFileSync(path, 'utf8')).not.toContain('@radix-ui/');
}
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { EmptyState, ErrorState, LoadingState } from './feedback.js';
describe('feedback primitives', () => {
it('exposes translated content through the existing semantic state classes', () => {
const root = create(
<>
<LoadingState label="加载账号" size="sm" />
<EmptyState title="暂无账号" description="请先添加一个站点" />
<ErrorState title="加载失败" description="请重试" />
</>,
);
const statusNodes = root.root.findAll((node) => node.props?.role === 'status');
expect(statusNodes).toHaveLength(2);
expect(statusNodes[0].props['aria-live']).toBe('polite');
expect(statusNodes[1].props.className).toContain('empty-state');
const error = root.root.find((node) => node.props?.role === 'alert');
expect(error.props['aria-live']).toBe('assertive');
expect(error.props.className).toContain('alert-error');
});
});
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from 'react';
export type LoadingStateProps = {
label?: ReactNode;
className?: string;
size?: 'sm' | 'md' | 'lg';
};
export function LoadingState({
label = '加载中...',
className,
size = 'md',
}: LoadingStateProps) {
return (
<div
className={['ui-loading-state', className].filter(Boolean).join(' ')}
role="status"
aria-live="polite"
aria-label={typeof label === 'string' ? label : undefined}
>
<span className={`spinner spinner-${size}`} aria-hidden="true" />
{label !== null ? <span>{label}</span> : null}
</div>
);
}
export type EmptyStateProps = {
title?: ReactNode;
description?: ReactNode;
icon?: ReactNode;
action?: ReactNode;
className?: string;
};
export function EmptyState({
title = '暂无数据',
description,
icon,
action,
className,
}: EmptyStateProps) {
return (
<div className={['empty-state', className].filter(Boolean).join(' ')} role="status">
{icon ? <div className="empty-state-icon">{icon}</div> : null}
<div className="empty-state-title">{title}</div>
{description ? <div className="empty-state-desc">{description}</div> : null}
{action ? <div className="empty-state-action">{action}</div> : null}
</div>
);
}
export type ErrorStateProps = {
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
};
export function ErrorState({
title = '加载失败',
description,
action,
className,
}: ErrorStateProps) {
return (
<div
className={['alert', 'alert-error', className].filter(Boolean).join(' ')}
role="alert"
aria-live="assertive"
>
<div className="alert-title">{title}</div>
{description ? <div>{description}</div> : null}
{action ? <div className="error-state-action">{action}</div> : null}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
export {
Dialog,
Drawer,
type DialogPresence,
type DialogProps,
type DrawerProps,
} from './Dialog.js';
export { default as Button, Button as ButtonPrimitive, AsyncButton, type AsyncButtonProps, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button.js';
export { EmptyState, ErrorState, LoadingState, type EmptyStateProps, type ErrorStateProps, type LoadingStateProps } from './feedback.js';
export { default as ModernSelect, type ModernSelectOption, type ModernSelectProps } from './Select.js';
export {
acquireBodyScrollLock,
getBodyScrollLockCount,
getPortalTarget,
renderInPortal,
useBodyScrollLock,
useEscapeKey,
useFocusReturn,
} from './lifecycle.js';
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
/**
* The web app has a number of independently mounted overlays. Keeping the
* lock in this module (instead of in each overlay) means closing one overlay
* cannot accidentally re-enable page scrolling while another one is open.
*/
type BodyLockState = {
body: HTMLElement;
count: number;
previousOverflow: string;
};
let bodyLockState: BodyLockState | null = null;
function getDocument(): Document | null {
return typeof document === 'undefined' ? null : document;
}
function isTestRuntime(): boolean {
const processLike = (globalThis as {
process?: { env?: Record<string, string | undefined> };
}).process;
return processLike?.env?.NODE_ENV === 'test';
}
function getBody(): HTMLElement | null {
const body = getDocument()?.body;
if (!body || !body.style) return null;
return body;
}
/** Return the current portal target when the browser exposes a usable body. */
export function getPortalTarget(): HTMLElement | null {
const body = getBody();
if (!body) return null;
// ReactDOM.createPortal validates these methods. Test renderers often
// provide a small document stub, so gracefully use the inline fallback in
// that environment instead of throwing while rendering an overlay.
if (
typeof body.appendChild !== 'function'
|| typeof body.removeChild !== 'function'
) {
return null;
}
return body;
}
/**
* Render a node in document.body when possible, otherwise keep it in the
* current React tree. The fallback is useful for SSR and react-test-renderer
* and also keeps a broken document stub from crashing the whole page.
*/
export function renderInPortal(node: ReactNode): ReactNode {
// React test renderer cannot reconcile a ReactDOM portal even when a jsdom
// document is present. Keep tests deterministic and exercise the same
// inline fallback used by SSR; browser builds still portal to body.
if (isTestRuntime()) return node;
const target = getPortalTarget();
if (!target) return node;
try {
return createPortal(node, target);
} catch {
return node;
}
}
/** Acquire one reference-counted body-scroll lock. */
export function acquireBodyScrollLock(): () => void {
const body = getBody();
if (!body) return () => undefined;
if (bodyLockState && bodyLockState.body === body) {
bodyLockState.count += 1;
body.style.overflow = 'hidden';
} else {
// A document can be replaced by a host shell (or by a test) while an
// overlay is mounted. Restore the old body before switching ownership.
if (bodyLockState) {
bodyLockState.body.style.overflow = bodyLockState.previousOverflow;
}
bodyLockState = {
body,
count: 1,
previousOverflow: body.style.overflow || '',
};
body.style.overflow = 'hidden';
}
let released = false;
return () => {
if (released) return;
released = true;
if (!bodyLockState || bodyLockState.body !== body) return;
bodyLockState.count -= 1;
if (bodyLockState.count > 0) return;
body.style.overflow = bodyLockState.previousOverflow;
bodyLockState = null;
};
}
/** React hook wrapper for the shared body lock. */
export function useBodyScrollLock(active: boolean): void {
useEffect(() => {
if (!active) return undefined;
return acquireBodyScrollLock();
}, [active]);
}
type FocusTarget = HTMLElement & {
isConnected?: boolean;
};
function isFocusableTarget(value: Element | null): value is FocusTarget {
if (!value || typeof (value as FocusTarget).focus !== 'function') return false;
// Older test doubles do not expose isConnected. In that case a focus call
// is still the safest best effort and mirrors browser behavior.
return (value as FocusTarget).isConnected !== false;
}
function focusWithoutScroll(target: FocusTarget): void {
try {
target.focus({ preventScroll: true });
} catch {
// Some narrow DOM/test doubles only implement focus() without options.
try {
target.focus();
} catch {
// Focus restoration is an enhancement; never make closing an overlay
// fail because a host document has a partial focus implementation.
}
}
}
// Capture the trigger before Radix (or a browser) moves focus into the panel.
const useSafeLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
/**
* Remember the element that was focused when an overlay opened and return
* focus to it after close. The cleanup path also handles an unmount while the
* overlay is still open.
*/
export function useFocusReturn(active: boolean, fallback?: FocusTarget | null): void {
const triggerRef = useRef<FocusTarget | null>(null);
const wasActiveRef = useRef(false);
useSafeLayoutEffect(() => {
const doc = getDocument();
if (active) {
if (!wasActiveRef.current && doc && isFocusableTarget(doc.activeElement)) {
// Do not remember body itself as a trigger. Returning focus to body is
// both noisy for keyboard users and less useful than the supplied
// fallback (typically the close button or page heading).
triggerRef.current = doc.activeElement === doc.body
? (fallback ?? null)
: doc.activeElement;
}
wasActiveRef.current = true;
return undefined;
}
if (!wasActiveRef.current) return undefined;
wasActiveRef.current = false;
const remembered = triggerRef.current;
const target = remembered && isFocusableTarget(remembered)
? remembered
: (fallback ?? null);
triggerRef.current = null;
if (target && isFocusableTarget(target)) {
// Queue after the close state has committed, so a parent that removes
// the trigger and re-adds it in the same tick gets the final node.
const timer = globalThis.setTimeout(() => focusWithoutScroll(target), 0);
return () => globalThis.clearTimeout(timer);
}
return undefined;
}, [active, fallback]);
useEffect(() => () => {
if (!wasActiveRef.current) return;
wasActiveRef.current = false;
const remembered = triggerRef.current;
const target = remembered && isFocusableTarget(remembered)
? remembered
: (fallback ?? null);
triggerRef.current = null;
if (target && isFocusableTarget(target)) focusWithoutScroll(target);
}, [fallback]);
}
type EscapeRegistration = {
token: symbol;
callbackRef: { current: (event: KeyboardEvent) => void };
};
// Only the top-most overlay handles Escape. This matters for a drawer opened
// over a dialog and avoids closing two surfaces from one key press.
const escapeStack: EscapeRegistration[] = [];
/** Register a top-most Escape handler with complete listener cleanup. */
export function useEscapeKey(active: boolean, onEscape: (event: KeyboardEvent) => void): void {
const callbackRef = useRef(onEscape);
callbackRef.current = onEscape;
const tokenRef = useRef<symbol | undefined>(undefined);
if (!tokenRef.current) tokenRef.current = Symbol('overlay-escape');
useEffect(() => {
const doc = getDocument();
if (!active || !doc || typeof doc.addEventListener !== 'function') return undefined;
const registration: EscapeRegistration = {
token: tokenRef.current as symbol,
callbackRef,
};
escapeStack.push(registration);
const handleKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
const top = escapeStack[escapeStack.length - 1];
if (!top || top.token !== registration.token) return;
top.callbackRef.current(event);
};
doc.addEventListener('keydown', handleKeydown);
return () => {
doc.removeEventListener('keydown', handleKeydown);
const index = escapeStack.findIndex((item) => item.token === registration.token);
if (index >= 0) escapeStack.splice(index, 1);
};
}, [active, callbackRef, tokenRef]);
}
/** Exposed only for focused tests and diagnostics; production code uses the hook. */
export function getBodyScrollLockCount(): number {
return bodyLockState?.count ?? 0;
}
+10 -2
View File
@@ -1,10 +1,18 @@
import { useEffect, useState } from 'react';
export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
export function useAnimatedVisibility(
visible: boolean,
durationMs = 220,
manageLifecycle = true,
) {
const [shouldRender, setShouldRender] = useState(visible);
const [isVisible, setIsVisible] = useState(visible);
useEffect(() => {
// A shared surface may receive an already-managed presence object from a
// parent during an incremental migration. Keep the hook call unconditional
// (React's rules of hooks) but do not schedule a second RAF/timer owner.
if (!manageLifecycle) return undefined;
if (visible) {
setShouldRender(true);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
@@ -23,7 +31,7 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
const timerId = globalThis.setTimeout(() => setShouldRender(false), durationMs);
return () => globalThis.clearTimeout(timerId);
}, [visible, durationMs]);
}, [durationMs, manageLifecycle, visible]);
return { shouldRender, isVisible };
}
+15
View File
@@ -125,4 +125,19 @@ describe('useIsMobile', () => {
}
}
});
it('detaches from the captured host when global window is replaced before unmount', async () => {
let root!: WebTestRenderer;
await act(async () => {
root = create(<Probe />);
});
// Simulate an embedded host teardown/replacement while the React tree is
// still mounted. Cleanup should use the original listener owner.
vi.stubGlobal('window', undefined);
await expect(act(async () => {
root.unmount();
})).resolves.toBeUndefined();
});
});
+12 -7
View File
@@ -7,12 +7,17 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
));
useEffect(() => {
if (typeof window === 'undefined') return;
// Capture the host object for the complete effect lifetime. Embedded
// shells and tests may replace globalThis.window while the component is
// still mounted; cleanup must detach from the same object that received
// the listener instead of reading a possibly missing global later.
const hostWindow = typeof window === 'undefined' ? null : window;
if (!hostWindow) return;
const query = breakpoint === MOBILE_BREAKPOINT
? MOBILE_MEDIA_QUERY
: `(max-width: ${breakpoint}px)`;
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null;
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint);
const media = typeof hostWindow.matchMedia === 'function' ? hostWindow.matchMedia(query) : null;
const update = () => setIsMobile(media ? media.matches : hostWindow.innerWidth <= breakpoint);
update();
if (media && typeof media.addEventListener === 'function') {
@@ -24,14 +29,14 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
};
}
const addResizeListener = typeof window.addEventListener === 'function';
const removeResizeListener = typeof window.removeEventListener === 'function';
const addResizeListener = typeof hostWindow.addEventListener === 'function';
const removeResizeListener = typeof hostWindow.removeEventListener === 'function';
if (!addResizeListener) return;
window.addEventListener('resize', update);
hostWindow.addEventListener('resize', update);
return () => {
if (removeResizeListener) {
window.removeEventListener('resize', update);
hostWindow.removeEventListener('resize', update);
}
};
}, [breakpoint]);
+47
View File
@@ -429,6 +429,32 @@ body {
border-width: 3px;
}
.ui-loading-state {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--color-text-muted);
font-size: 13px;
}
.ui-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.empty-state-action,
.error-state-action {
margin-top: 12px;
}
/* ===== Topbar ===== */
.topbar {
height: var(--topbar-height);
@@ -796,6 +822,13 @@ body {
box-shadow: none !important;
}
.btn-primary .spinner,
.btn-danger .spinner,
.btn-success .spinner {
border-color: rgba(255, 255, 255, 0.3);
border-top-color: white;
}
.btn::after {
content: '';
position: absolute;
@@ -4216,6 +4249,11 @@ textarea {
opacity: 0.4;
}
.empty-state-icon > svg {
width: 100%;
height: 100%;
}
.empty-state-title {
font-size: 15px;
font-weight: 600;
@@ -4545,6 +4583,15 @@ textarea {
color: #1d4ed8;
}
.modern-select-option.is-highlighted {
background: rgba(37, 99, 235, 0.1);
outline: none;
}
.modern-select-option.is-active.is-highlighted {
background: rgba(37, 99, 235, 0.18);
}
.modern-select-option.is-disabled {
color: var(--color-text-muted);
cursor: not-allowed;
+35 -50
View File
@@ -10,6 +10,8 @@ import ModernSelect from "../components/ModernSelect.js";
import { MobileCard, MobileField } from "../components/MobileCard.js";
import { useIsMobile } from "../components/useIsMobile.js";
import DeleteConfirmModal from "../components/DeleteConfirmModal.js";
import { EmptyState } from "../components/ui/feedback.js";
import { AsyncButton, Button } from "../components/ui/Button.js";
import SiteBadgeLink from "../components/SiteBadgeLink.js";
import AccountModelsModal from "./accounts/AccountModelsModal.js";
import {
@@ -26,7 +28,7 @@ import {
clearFocusParams,
readFocusAccountIntent,
} from "./helpers/navigationFocus.js";
import { TokensPanel } from "./Tokens.js";
import { TokensPanel } from "./tokens/TokensPanel.js";
import { tr } from "../i18n.js";
import {
buildCustomReorderUpdates,
@@ -2586,29 +2588,17 @@ export default function Accounts() {
bodyStyle={{ display: "flex", flexDirection: "column", gap: 12 }}
footer={
<>
<button onClick={closeEditPanel} className="btn btn-ghost">
<Button onClick={closeEditPanel} variant="ghost">
取消
</button>
<button
</Button>
<AsyncButton
onClick={saveEditPanel}
disabled={savingEdit}
className="btn btn-primary"
loading={savingEdit}
variant="primary"
loadingLabel="保存中..."
>
{savingEdit ? (
<>
<span
className="spinner spinner-sm"
style={{
borderTopColor: "white",
borderColor: "rgba(255,255,255,0.3)",
}}
/>{" "}
保存中...
</>
) : (
"保存修改"
)}
</button>
保存修改
</AsyncButton>
</>
}
>
@@ -3425,35 +3415,30 @@ export default function Accounts() {
</table>
)
) : (
<div className="empty-state">
<svg
className="empty-state-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<div className="empty-state-title">
{activeSegment === "apikey"
? "暂无 API Key 连接"
: "暂无 Session 连接"}
</div>
<div className="empty-state-desc">
{activeSegment === "apikey"
? sites.length > 0
? "请为现有站点补充 API Key 连接"
: "请先添加站点,然后为站点补充 API Key 连接"
: sites.length > 0
? "请为现有站点添加 Session 连接"
: "请先添加站点,然后添加 Session 连接"}
</div>
</div>
<EmptyState
title={activeSegment === "apikey" ? "暂无 API Key 连接" : "暂无 Session 连接"}
description={activeSegment === "apikey"
? sites.length > 0
? "请为现有站点补充 API Key 连接"
: "请先添加站点,然后为站点补充 API Key 连接"
: sites.length > 0
? "请为现有站点添加 Session 连接"
: "请先添加站点,然后添加 Session 连接"}
icon={(
<svg
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
)}
/>
)}
</div>
</>
+9 -3
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { AsyncButton } from '../components/ui/Button.js';
import { useIsMobile } from '../components/useIsMobile.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -1446,9 +1447,14 @@ export default function Settings() {
</div>
</div>
<div style={{ marginTop: 12 }}>
<button onClick={saveSchedule} disabled={savingSchedule} className="btn btn-primary">
{savingSchedule ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存定时任务'}
</button>
<AsyncButton
onClick={saveSchedule}
loading={savingSchedule}
variant="primary"
loadingLabel="保存中..."
>
保存定时任务
</AsyncButton>
</div>
</div>
+6 -1456
View File
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,6 @@ const pages = [
'src/web/pages/ProxyLogs.tsx',
'src/web/pages/Sites.tsx',
'src/web/pages/TokenRoutes.tsx',
'src/web/pages/Tokens.tsx',
];
describe('ResponsiveFilterPanel adoption', () => {
@@ -23,5 +22,15 @@ describe('ResponsiveFilterPanel adoption', () => {
expect(source, page).not.toContain("import MobileFilterSheet from '../components/MobileFilterSheet.js'");
expect(source, page).not.toContain('<MobileFilterSheet');
}
const tokenPanel = readFileSync(
resolve(process.cwd(), 'src/web/pages/tokens/TokensPanel.tsx'),
'utf8',
).replace(/\r\n/g, '\n');
expect(tokenPanel, 'src/web/pages/tokens/TokensPanel.tsx').toMatch(
/import\s+ResponsiveFilterPanel\s+from\s+['"]\.\.\/\.\.\/components\/ResponsiveFilterPanel\.js['"]/,
);
expect(tokenPanel).not.toContain("import MobileFilterSheet from '../../components/MobileFilterSheet.js'");
expect(tokenPanel).not.toContain('<MobileFilterSheet');
});
});
+49 -40
View File
@@ -1,10 +1,7 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { Dialog, type DialogPresence } from '../../components/ui/Dialog.js';
import { AsyncButton, Button } from '../../components/ui/Button.js';
type ModalPresence = {
shouldRender: boolean;
isVisible: boolean;
};
type ModalPresence = DialogPresence;
type FactoryResetModalProps = {
presence: ModalPresence;
@@ -15,6 +12,13 @@ type FactoryResetModalProps = {
onConfirm: () => void;
};
/**
* Destructive reset confirmation surface.
*
* The settings page owns the open/exit presence state; handing that state to
* the shared Dialog keeps the existing animation timing while centralising
* portal, focus, scroll-lock, and accessibility behaviour.
*/
export default function FactoryResetModal({
presence,
factoryResetting,
@@ -23,46 +27,51 @@ export default function FactoryResetModal({
onClose,
onConfirm,
}: FactoryResetModalProps) {
if (!presence.shouldRender) {
return null;
}
const confirmLabel = factoryResetting
? '重新初始化中...'
: (factoryResetSecondsLeft > 0
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
: '确认重新初始化系统');
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
<div>• 系统随后会强制切回默认 SQLite。</div>
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
</div>
</div>
<div className="modal-footer">
<button onClick={onClose} disabled={factoryResetting} className="btn btn-ghost">取消</button>
<button onClick={onConfirm} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
{factoryResetting
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
: confirmLabel}
</button>
</div>
return (
<Dialog
open={presence.isVisible}
onClose={onClose}
presence={presence}
title={<span style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</span>}
maxWidth={720}
contentStyle={{ border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
closeOnBackdrop
closeOnEscape={false}
showCloseButton={false}
ariaLabel="确认重新初始化系统"
footer={(
<>
<Button onClick={onClose} disabled={factoryResetting} variant="ghost">
取消
</Button>
<AsyncButton
onClick={onConfirm}
disabled={factoryResetting || factoryResetSecondsLeft > 0}
variant="danger"
pending={factoryResetting}
loadingLabel={confirmLabel}
>
{confirmLabel}
</AsyncButton>
</>
)}
>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
</div>
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
<div>• 系统随后会强制切回默认 SQLite。</div>
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
</div>
</Dialog>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
@@ -1,10 +1,7 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { Dialog, type DialogPresence } from '../../components/ui/Dialog.js';
import { AsyncButton, Button } from '../../components/ui/Button.js';
type ModalPresence = {
shouldRender: boolean;
isVisible: boolean;
};
type ModalPresence = DialogPresence;
type ModelAvailabilityProbeConfirmModalProps = {
presence: ModalPresence;
@@ -16,6 +13,7 @@ type ModelAvailabilityProbeConfirmModalProps = {
onConfirm: () => void;
};
/** Confirmation gate for the high-risk model availability probe setting. */
export default function ModelAvailabilityProbeConfirmModal({
presence,
confirmText,
@@ -25,81 +23,83 @@ export default function ModelAvailabilityProbeConfirmModal({
onClose,
onConfirm,
}: ModelAvailabilityProbeConfirmModalProps) {
if (!presence.shouldRender) {
return null;
}
const canConfirm = confirmationInput.trim() === confirmText;
const titleId = 'model-availability-probe-confirm-modal-title';
const descriptionId = 'model-availability-probe-confirm-modal-description';
const handleRequestClose = () => {
if (saving) return;
onClose();
};
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={handleRequestClose}>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 760, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
aria-busy={saving}
onClick={(e) => e.stopPropagation()}
>
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
开启后,metapi 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
请手动输入以下整句后再开启:
</div>
<div
style={{
padding: 12,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.8,
}}
return (
<Dialog
open={presence.isVisible}
onClose={handleRequestClose}
presence={presence}
title={<span style={{ color: 'var(--color-danger)' }}>确认开启批量测活</span>}
maxWidth={760}
contentStyle={{ border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
closeOnBackdrop
closeOnEscape={false}
showCloseButton={false}
ariaLabel="确认开启批量测活"
ariaDescribedBy={descriptionId}
ariaBusy={saving}
footer={(
<>
<Button onClick={handleRequestClose} disabled={saving} variant="ghost">
取消
</Button>
<AsyncButton
onClick={onConfirm}
disabled={saving || !canConfirm}
variant="danger"
pending={saving}
loadingLabel="确认开启中..."
>
{confirmText}
</div>
<textarea
value={confirmationInput}
onChange={(e) => onConfirmationInputChange(e.target.value)}
placeholder="请输入上方确认语句"
spellCheck={false}
style={{
width: '100%',
minHeight: 96,
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
resize: 'vertical',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
确认开启批量测活
</AsyncButton>
</>
)}
>
<div id={descriptionId} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
开启后,metapi 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
</div>
<div className="modal-footer">
<button onClick={handleRequestClose} disabled={saving} className="btn btn-ghost">取消</button>
<button onClick={onConfirm} disabled={saving || !canConfirm} className="btn btn-danger">
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 确认开启中...</>
: '确认开启批量测活'}
</button>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
请手动输入以下整句后再开启:
</div>
<div
style={{
padding: 12,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.8,
}}
>
{confirmText}
</div>
<textarea
value={confirmationInput}
onChange={(event) => onConfirmationInputChange(event.target.value)}
placeholder="请输入上方确认语句"
spellCheck={false}
style={{
width: '100%',
minHeight: 96,
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
resize: 'vertical',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</div>
</div>
</Dialog>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
describe('Tokens actions layout', () => {
it('reuses a wrapping actions cell layout so token row actions do not overflow', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/tokens/TokensPanel.tsx'), 'utf8');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(source).toContain('className="token-actions-cell"');
+1 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import { TokensPanel } from './Tokens.js';
import { TokensPanel } from './tokens/TokensPanel.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
+2 -2
View File
@@ -4,9 +4,9 @@ import { resolve } from 'node:path';
describe('Tokens centered modal adoption', () => {
it('uses CenteredModal for add/edit token flows instead of inline form cards', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/tokens/TokensPanel.tsx'), 'utf8');
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
expect(source).toContain("import CenteredModal from '../../components/CenteredModal.js'");
expect(source).toContain('<CenteredModal');
expect(source).not.toContain('addPanelPresence.shouldRender && (');
});
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { TokensPanel } from './Tokens.js';
import { TokensPanel } from './tokens/TokensPanel.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
+1 -1
View File
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
describe('Tokens embedded layout', () => {
it('reuses accounts-page-actions layout when embedded in 连接管理', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/tokens/TokensPanel.tsx'), 'utf8');
expect(source).toContain("className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}");
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import { TokensPanel } from './Tokens.js';
import { TokensPanel } from './tokens/TokensPanel.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function readSource(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
describe('Tokens panel page boundary', () => {
it('embeds the token domain panel without importing the legacy top-level route page', () => {
const accounts = readSource('src/web/pages/Accounts.tsx');
expect(accounts).toMatch(
/import\s+\{\s*TokensPanel\s*\}\s+from\s+["']\.\/tokens\/TokensPanel\.js["']/
);
expect(accounts).not.toMatch(/from\s+["']\.\/Tokens\.js["']/);
});
it('keeps the legacy route as a thin redirect and compatibility export', () => {
const route = readSource('src/web/pages/Tokens.tsx');
expect(route).toMatch(
/export\s+\{\s*TokensPanel\s*\}\s+from\s+['"]\.\/tokens\/TokensPanel\.js['"]/
);
expect(route).toContain('return <Navigate to={`/accounts');
expect(route).not.toContain('function TokensPanel(');
});
});
File diff suppressed because it is too large Load Diff