Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1751ba325d | |||
| 066b045822 | |||
| 3b21c42cdb | |||
| 8b1e91d665 | |||
| aa5909264a | |||
| b6232d4e21 | |||
| 0d3cef5578 | |||
| 1e737f3a71 | |||
| 2f2bdb4697 | |||
| 3fa7c8b85a | |||
| 7efce91a91 | |||
| a11d370f2b | |||
| d7bfa42f85 | |||
| 551ba49280 | |||
| adab24bb22 | |||
| 64aea7e84c | |||
| 67f6bd619e | |||
| 35d1fd1166 | |||
| d5b8019fb1 | |||
| 526cb4dbb3 | |||
| 6dc56c57c6 | |||
| a3263fdd3a | |||
| 14974c0257 | |||
| e70ae40096 | |||
| 3e04a131f1 | |||
| b487a7900b | |||
| 78b49c8b7a | |||
| 949e511dcf | |||
| d9faca5d01 | |||
| 398c6d0869 | |||
| 54d478df1e | |||
| 43c80f5418 | |||
| b186ffcaa6 | |||
| 778bbda4b8 |
@@ -9,9 +9,10 @@ This is a **monorepo** with npm workspaces:
|
|||||||
|
|
||||||
Internal packages:
|
Internal packages:
|
||||||
|
|
||||||
|
- **CDN** (`packages/cdn/`) - IIFE builds for script tag usage (npm: `@page-agent/cdn`)
|
||||||
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||||
- **Page Controller** (`packages/page-controller/`) - DOM operations, independent of LLM
|
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (SimulatorMask), independent of LLM
|
||||||
- **UI** (`packages/ui/`) - Panel, SimulatorMask, i18n. Decoupled from PageAgent
|
- **UI** (`packages/ui/`) - Panel and i18n. Decoupled from PageAgent
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig
|
|||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
├── page-agent/ # npm: "page-agent" ⭐ MAIN
|
├── page-agent/ # npm: "page-agent" ⭐ MAIN
|
||||||
|
├── cdn/ # npm: "@page-agent/cdn" (IIFE builds)
|
||||||
├── website/ # @page-agent/website (private)
|
├── website/ # @page-agent/website (private)
|
||||||
├── llms/ # @page-agent/llms
|
├── llms/ # @page-agent/llms
|
||||||
├── page-controller/ # @page-agent/page-controller
|
├── page-controller/ # @page-agent/page-controller
|
||||||
@@ -43,8 +45,8 @@ packages/
|
|||||||
|
|
||||||
- **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui`
|
- **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui`
|
||||||
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
||||||
- **UI**: Panel, Mask, i18n. No dependency on page-agent
|
- **UI**: Panel and i18n. No dependency on page-agent
|
||||||
- **Page Controller**: Pure DOM operations. No LLM or UI dependency
|
- **Page Controller**: DOM operations with optional visual feedback (SimulatorMask). No LLM dependency. Enable mask via `enableMask: true` config
|
||||||
|
|
||||||
### PageController ↔ PageAgent Communication
|
### PageController ↔ PageAgent Communication
|
||||||
|
|
||||||
@@ -69,41 +71,43 @@ const pageInfo = await this.pageController.getPageInfo()
|
|||||||
3. **LLM Processing**: AI returns action plans (page-agent)
|
3. **LLM Processing**: AI returns action plans (page-agent)
|
||||||
4. **Indexed Operations**: PageAgent calls PageController by element index
|
4. **Indexed Operations**: PageAgent calls PageController by element index
|
||||||
|
|
||||||
### CDN Auto-Injection
|
### CDN Builds (`packages/cdn/`)
|
||||||
|
|
||||||
Library auto-initializes via script tag:
|
Two IIFE builds for script tag usage:
|
||||||
|
|
||||||
```html
|
| Build | File | Description |
|
||||||
<script src="page-agent.js?model=gpt-4"></script>
|
| ----- | -------------------- | -------------------------------- |
|
||||||
```
|
| Demo | `page-agent.demo.js` | Auto-init with built-in test API |
|
||||||
|
| Full | `page-agent.js` | Exposes `PageAgent` class only |
|
||||||
|
|
||||||
Query params configure `PageAgentConfig` in `src/umd.ts`.
|
Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
||||||
|
|
||||||
## Key Files Reference
|
## Key Files Reference
|
||||||
|
|
||||||
### Page Agent (`packages/page-agent/`)
|
### Page Agent (`packages/page-agent/`)
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|------|-------------|
|
| ------------------ | --------------------------------------- |
|
||||||
| `src/PageAgent.ts` | ⭐ Main AI agent class |
|
| `src/PageAgent.ts` | ⭐ Main AI agent class |
|
||||||
| `src/umd.ts` | CDN/UMD entry with auto-init |
|
| `src/umd.ts` | CDN/UMD entry with auto-init |
|
||||||
| `src/tools/` | Tool definitions calling PageController |
|
| `src/tools/` | Tool definitions calling PageController |
|
||||||
|
|
||||||
### LLMs (`packages/llms/`)
|
### LLMs (`packages/llms/`)
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|------|-------------|
|
| ---------------------------- | ------------------------------------- |
|
||||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||||
| `src/OpenAILenientClient.ts` | OpenAI-compatible client |
|
| `src/OpenAILenientClient.ts` | OpenAI-compatible client |
|
||||||
|
|
||||||
### Page Controller (`packages/page-controller/`)
|
### Page Controller (`packages/page-controller/`)
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|------|-------------|
|
| --------------------------- | ---------------------------------------------------------- |
|
||||||
| `src/PageController.ts` | ⭐ Main controller class |
|
| `src/PageController.ts` | ⭐ Main controller class with optional mask support |
|
||||||
| `src/actions.ts` | Element interactions (click, input, scroll) |
|
| `src/SimulatorMask.ts` | Visual overlay blocking user interaction during automation |
|
||||||
| `src/dom/dom_tree/index.js` | Core DOM extraction engine |
|
| `src/actions.ts` | Element interactions (click, input, scroll) |
|
||||||
|
| `src/dom/dom_tree/index.js` | Core DOM extraction engine |
|
||||||
|
|
||||||
## Adding New Features
|
## Adding New Features
|
||||||
|
|
||||||
@@ -123,4 +127,5 @@ Query params configure `PageAgentConfig` in `src/umd.ts`.
|
|||||||
|
|
||||||
- Explicit typing for exported/public APIs
|
- Explicit typing for exported/public APIs
|
||||||
- ESLint relaxes some unsafe rules for rapid iteration
|
- ESLint relaxes some unsafe rules for rapid iteration
|
||||||
|
- Every change you make should not only implement the desired functionality but also improve the quality of the codebase
|
||||||
- All code and comments must be in English.
|
- All code and comments must be in English.
|
||||||
|
|||||||
+21
-15
@@ -24,22 +24,10 @@
|
|||||||
|
|
||||||
## 🗺️ Roadmap
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
👉 [**Roadmap**](./ROADMAP.md)
|
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### CDN 集成
|
|
||||||
|
|
||||||
```html
|
|
||||||
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js
|
|
||||||
// Mirror(CN) - https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js
|
|
||||||
<script
|
|
||||||
src="https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js"
|
|
||||||
crossorigin="true"
|
|
||||||
type="text/javascript"
|
|
||||||
></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### NPM 安装
|
### NPM 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -66,6 +54,23 @@ const agent = new PageAgent({
|
|||||||
await agent.execute('点击登录按钮')
|
await agent.execute('点击登录按钮')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CDN 集成
|
||||||
|
|
||||||
|
Fastest way to try PageAgent is to include it via CDN. Demo model will be used by default.
|
||||||
|
|
||||||
|
| Location | URL |
|
||||||
|
| -------- | ----------------------------------------------------------------------------- |
|
||||||
|
| Global | https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js |
|
||||||
|
| China | https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script
|
||||||
|
src="https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js"
|
||||||
|
crossorigin="true"
|
||||||
|
type="text/javascript"
|
||||||
|
></script>
|
||||||
|
```
|
||||||
|
|
||||||
## 🏗️ 架构设计
|
## 🏗️ 架构设计
|
||||||
|
|
||||||
PageAgent adopts a simplified monorepo structure:
|
PageAgent adopts a simplified monorepo structure:
|
||||||
@@ -74,8 +79,9 @@ PageAgent adopts a simplified monorepo structure:
|
|||||||
packages/
|
packages/
|
||||||
├── page-agent/ # AI agent (npm: page-agent)
|
├── page-agent/ # AI agent (npm: page-agent)
|
||||||
├── llms/ # LLM 客户端 (npm: @page-agent/llms)
|
├── llms/ # LLM 客户端 (npm: @page-agent/llms)
|
||||||
├── page-controller/ # DOM 操作 (npm: @page-agent/page-controller)
|
├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller)
|
||||||
├── ui/ # 面板 & 蒙层 & 模拟鼠标 (npm: @page-agent/ui)
|
├── ui/ # 面板 & i18n (npm: @page-agent/ui)
|
||||||
|
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||||
└── website/ # 文档站点
|
└── website/ # 文档站点
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -24,22 +24,10 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
|||||||
|
|
||||||
## 🗺️ Roadmap
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
👉 [**Roadmap**](./ROADMAP.md)
|
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### CDN Integration
|
|
||||||
|
|
||||||
```html
|
|
||||||
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js
|
|
||||||
// Mirror(CN) - https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js
|
|
||||||
<script
|
|
||||||
src="https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js"
|
|
||||||
crossorigin="true"
|
|
||||||
type="text/javascript"
|
|
||||||
></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### NPM Installation
|
### NPM Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -66,6 +54,23 @@ const agent = new PageAgent({
|
|||||||
await agent.execute('Click the login button')
|
await agent.execute('Click the login button')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CDN Integration
|
||||||
|
|
||||||
|
Fastest way to try PageAgent is to include it via CDN. Demo model will be used by default.
|
||||||
|
|
||||||
|
| Location | URL |
|
||||||
|
| -------- | ----------------------------------------------------------------------------- |
|
||||||
|
| Global | https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js |
|
||||||
|
| China | https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js |
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js"
|
||||||
|
crossorigin="true"
|
||||||
|
type="text/javascript"
|
||||||
|
></script>
|
||||||
|
```
|
||||||
|
|
||||||
## 🏗️ Structure
|
## 🏗️ Structure
|
||||||
|
|
||||||
PageAgent adopts a simplified monorepo structure:
|
PageAgent adopts a simplified monorepo structure:
|
||||||
@@ -74,8 +79,9 @@ PageAgent adopts a simplified monorepo structure:
|
|||||||
packages/
|
packages/
|
||||||
├── page-agent/ # AI agent (npm: page-agent)
|
├── page-agent/ # AI agent (npm: page-agent)
|
||||||
├── llms/ # LLM client (npm: @page-agent/llms)
|
├── llms/ # LLM client (npm: @page-agent/llms)
|
||||||
├── page-controller/ # DOM operations (npm: @page-agent/page-controller)
|
├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller)
|
||||||
├── ui/ # Panel & Mask & Mouse Animation (npm: @page-agent/ui)
|
├── ui/ # Panel & i18n (npm: @page-agent/ui)
|
||||||
|
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||||
└── website/ # Demo & Documentation site
|
└── website/ # Demo & Documentation site
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
# 🗺️ PageAgent Roadmap
|
|
||||||
|
|
||||||
The development progress and future plans for PageAgent.
|
|
||||||
|
|
||||||
## 🚀 Current Works
|
|
||||||
|
|
||||||
- [x] **MVP**
|
|
||||||
- Core functionality implemented.
|
|
||||||
- [x] **SPA interaction**
|
|
||||||
- [x] **Reasoning and (short) memory**
|
|
||||||
- [x] **Multi model provider integration and testing**
|
|
||||||
- [x] **UI with HITL**
|
|
||||||
- Human-in-the-loop user interface. Agent can ask user questions.
|
|
||||||
- [x] **Landing and doc pages**
|
|
||||||
- [x] **Remove `ai-sdk`**
|
|
||||||
- Only one function of AI-ADK is being used.
|
|
||||||
- Our agent memory and thinking mechanism does not suite ai-sdk.
|
|
||||||
- [x] **Robust LLM output**
|
|
||||||
- Auto-fix incomplete output format of DeepSeek and QWen.
|
|
||||||
- [x] **Working homepage with live LLM API**
|
|
||||||
- [x] **~~free~~ CDN**
|
|
||||||
- [x] **Free evaluation plan**
|
|
||||||
- [x] **Custom actions and HITL**
|
|
||||||
- [ ] **Hooks and Events**
|
|
||||||
- [x] **lifecycle hooks**
|
|
||||||
- [ ] **lifecycle events**
|
|
||||||
- [ ] **User takeover**
|
|
||||||
- [#64](https://github.com/alibaba/page-agent/issues/64)
|
|
||||||
- [ ] **❗Hijack `page_open/page_change/page_unload` behavior**
|
|
||||||
- [ ] **Custom knowledge base and instructions**
|
|
||||||
- [#45](https://github.com/alibaba/page-agent/issues/45)
|
|
||||||
- [ ] **Black/white-list safeguard**
|
|
||||||
- [ ] **Data-masking**
|
|
||||||
- [#44](https://github.com/alibaba/page-agent/issues/44)
|
|
||||||
- [ ] **Improve Memory**
|
|
||||||
- [#66](https://github.com/alibaba/page-agent/issues/66)
|
|
||||||
- [#65](https://github.com/alibaba/page-agent/issues/65)
|
|
||||||
- [ ] **Optimize for popular UI frameworks**
|
|
||||||
- [x] **i18n of the website**
|
|
||||||
- [x] Chinese version
|
|
||||||
- [x] English version
|
|
||||||
- [x] **Refactor: Separate `Agent` and `PageController`**
|
|
||||||
- [ ] **Chrome-ext wrapper for multi-page tasks**
|
|
||||||
|
|
||||||
♻️ Following browser-use's update and contribute back.
|
|
||||||
|
|
||||||
## 📋 Pending Features
|
|
||||||
|
|
||||||
- [ ] **Tools for more complex tasks**
|
|
||||||
- todo list
|
|
||||||
- file sys
|
|
||||||
- [x] **Support custom llm fetch**
|
|
||||||
- [ ] **Testing suits**
|
|
||||||
- [ ] **Same-origin multi-page-app rally**
|
|
||||||
- [ ] **Local MCP proxy**
|
|
||||||
|
|
||||||
## 🤔 To Be Decided
|
|
||||||
|
|
||||||
- [ ] **Cross-origin multi-page?**
|
|
||||||
- Tricky
|
|
||||||
- Need some kind of "memory rally"
|
|
||||||
+1
-6
@@ -8,12 +8,7 @@ import globals from 'globals'
|
|||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores([
|
globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']),
|
||||||
'**/dist',
|
|
||||||
'**/test-pages',
|
|
||||||
'**/node_modules',
|
|
||||||
'packages/website/src/components/ui',
|
|
||||||
]),
|
|
||||||
{
|
{
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
'react-hooks': reactHooks,
|
||||||
|
|||||||
Generated
+264
-251
@@ -1,43 +1,44 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/page-controller",
|
"packages/page-controller",
|
||||||
"packages/ui",
|
"packages/ui",
|
||||||
"packages/llms",
|
"packages/llms",
|
||||||
"packages/page-agent",
|
"packages/page-agent",
|
||||||
|
"packages/cdn",
|
||||||
"packages/website"
|
"packages/website"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.3.0",
|
"@commitlint/cli": "^20.3.1",
|
||||||
"@commitlint/config-conventional": "^20.3.0",
|
"@commitlint/config-conventional": "^20.3.1",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@microsoft/api-extractor": "^7.55.2",
|
"@microsoft/api-extractor": "^7.55.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.1",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.7",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-dom": "^2.5.1",
|
"eslint-plugin-react-dom": "^2.5.7",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"eslint-plugin-react-x": "^2.5.1",
|
"eslint-plugin-react-x": "^2.5.7",
|
||||||
"globals": "^16.4.0",
|
"globals": "^17.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.4",
|
"lint-staged": "^16.2.4",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.52.0",
|
"typescript-eslint": "^8.53.0",
|
||||||
"unplugin-dts": "^1.0.0-beta.6",
|
"unplugin-dts": "^1.0.0-beta.6",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -316,17 +317,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/cli": {
|
"node_modules/@commitlint/cli": {
|
||||||
"version": "20.3.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.3.1.tgz",
|
||||||
"integrity": "sha512-HXO8YVfqdBK+MnlX2zqNrv6waGYPs6Ysjm5W2Y0GMagWXwiIKx7C8dcIX9ca+QdHq4WA0lcMnZLQ0pzQh1piZg==",
|
"integrity": "sha512-NtInjSlyev/+SLPvx/ulz8hRE25Wf5S9dLNDcIwazq0JyB4/w1ROF/5nV0ObPTX8YpRaKYeKtXDYWqumBNHWsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/format": "^20.2.0",
|
"@commitlint/format": "^20.3.1",
|
||||||
"@commitlint/lint": "^20.3.0",
|
"@commitlint/lint": "^20.3.1",
|
||||||
"@commitlint/load": "^20.3.0",
|
"@commitlint/load": "^20.3.1",
|
||||||
"@commitlint/read": "^20.2.0",
|
"@commitlint/read": "^20.3.1",
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"tinyexec": "^1.0.0",
|
"tinyexec": "^1.0.0",
|
||||||
"yargs": "^17.0.0"
|
"yargs": "^17.0.0"
|
||||||
},
|
},
|
||||||
@@ -338,13 +339,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/config-conventional": {
|
"node_modules/@commitlint/config-conventional": {
|
||||||
"version": "20.3.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.3.1.tgz",
|
||||||
"integrity": "sha512-g1OXVl6E2v0xF1Ru2RpxQ+Vfy7XUcUsCmLKzGUrhFLS4hSNykje0QSy6djBtzOiOBQCepBrmIlqx/gRlzrSh5A==",
|
"integrity": "sha512-NCzwvxepstBZbmVXsvg49s+shCxlJDJPWxXqONVcAtJH9wWrOlkMQw/zyl+dJmt8lyVopt5mwQ3mR5M2N2rUWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"conventional-changelog-conventionalcommits": "^7.0.2"
|
"conventional-changelog-conventionalcommits": "^7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -352,13 +353,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/config-validator": {
|
"node_modules/@commitlint/config-validator": {
|
||||||
"version": "20.2.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.3.1.tgz",
|
||||||
"integrity": "sha512-SQCBGsL9MFk8utWNSthdxd9iOD1pIVZSHxGBwYIGfd67RTjxqzFOSAYeQVXOu3IxRC3YrTOH37ThnTLjUlyF2w==",
|
"integrity": "sha512-ErVLC/IsHhcvxCyh+FXo7jy12/nkQySjWXYgCoQbZLkFp4hysov8KS6CdxBB0cWjbZWjvNOKBMNoUVqkmGmahw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"ajv": "^8.11.0"
|
"ajv": "^8.11.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -366,13 +367,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/ensure": {
|
"node_modules/@commitlint/ensure": {
|
||||||
"version": "20.2.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.3.1.tgz",
|
||||||
"integrity": "sha512-+8TgIGv89rOWyt3eC6lcR1H7hqChAKkpawytlq9P1i/HYugFRVqgoKJ8dhd89fMnlrQTLjA5E97/4sF09QwdoA==",
|
"integrity": "sha512-h664FngOEd7bHAm0j8MEKq+qm2mH+V+hwJiIE2bWcw3pzJMlO0TPKtk0ATyRAtV6jQw+xviRYiIjjSjfajiB5w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"lodash.camelcase": "^4.3.0",
|
"lodash.camelcase": "^4.3.0",
|
||||||
"lodash.kebabcase": "^4.1.1",
|
"lodash.kebabcase": "^4.1.1",
|
||||||
"lodash.snakecase": "^4.1.1",
|
"lodash.snakecase": "^4.1.1",
|
||||||
@@ -394,13 +395,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/format": {
|
"node_modules/@commitlint/format": {
|
||||||
"version": "20.2.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.3.1.tgz",
|
||||||
"integrity": "sha512-PhNoLNhxpfIBlW/i90uZ3yG3hwSSYx7n4d9Yc+2FAorAHS0D9btYRK4ZZXX+Gm3W5tDtu911ow/eWRfcRVgNWg==",
|
"integrity": "sha512-jfsjGPFTd2Yti2YHwUH4SPRPbWKAJAwrfa3eNa9bXEdrXBb9mCwbIrgYX38LdEJK9zLJ3AsLBP4/FLEtxyu2AA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"chalk": "^5.3.0"
|
"chalk": "^5.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -408,13 +409,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/is-ignored": {
|
"node_modules/@commitlint/is-ignored": {
|
||||||
"version": "20.2.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.3.1.tgz",
|
||||||
"integrity": "sha512-Lz0OGeZCo/QHUDLx5LmZc0EocwanneYJUM8z0bfWexArk62HKMLfLIodwXuKTO5y0s6ddXaTexrYHs7v96EOmw==",
|
"integrity": "sha512-tWwAoh93QvAhxgp99CzCuHD86MgxE4NBtloKX+XxQxhfhSwHo7eloiar/yzx53YW9eqSLP95zgW2KDDk4/WX+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"semver": "^7.6.0"
|
"semver": "^7.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -422,32 +423,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/lint": {
|
"node_modules/@commitlint/lint": {
|
||||||
"version": "20.3.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.3.1.tgz",
|
||||||
"integrity": "sha512-X19HOGU5nRo6i9DIY0kG0mhgtvpn1UGO1D6aLX1ILLyeqSM5yJyMcrRqNj8SLgeSeUDODhLY9QYsBIG0LdNHkA==",
|
"integrity": "sha512-LaOtrQ24+6SfUaWg8A+a+Wc77bvLbO5RIr6iy9F7CI3/0iq1uPEWgGRCwqWTuLGHkZDAcwaq0gZ01zpwZ1jCGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/is-ignored": "^20.2.0",
|
"@commitlint/is-ignored": "^20.3.1",
|
||||||
"@commitlint/parse": "^20.2.0",
|
"@commitlint/parse": "^20.3.1",
|
||||||
"@commitlint/rules": "^20.3.0",
|
"@commitlint/rules": "^20.3.1",
|
||||||
"@commitlint/types": "^20.2.0"
|
"@commitlint/types": "^20.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=v18"
|
"node": ">=v18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/load": {
|
"node_modules/@commitlint/load": {
|
||||||
"version": "20.3.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.3.1.tgz",
|
||||||
"integrity": "sha512-amkdVZTXp5R65bsRXRSCwoNXbJHR2aAIY/RGFkoyd63t8UEwqEgT3f0MgeLqYw4hwXyq+TYXKdaW133E29pnGQ==",
|
"integrity": "sha512-YDD9XA2XhgYgbjju8itZ/weIvOOobApDqwlPYCX5NLO/cPtw2UMO5Cmn44Ks8RQULUVI5fUT6roKvyxcoLbNmw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/config-validator": "^20.2.0",
|
"@commitlint/config-validator": "^20.3.1",
|
||||||
"@commitlint/execute-rule": "^20.0.0",
|
"@commitlint/execute-rule": "^20.0.0",
|
||||||
"@commitlint/resolve-extends": "^20.2.0",
|
"@commitlint/resolve-extends": "^20.3.1",
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"cosmiconfig": "^9.0.0",
|
"cosmiconfig": "^9.0.0",
|
||||||
"cosmiconfig-typescript-loader": "^6.1.0",
|
"cosmiconfig-typescript-loader": "^6.1.0",
|
||||||
@@ -470,13 +471,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/parse": {
|
"node_modules/@commitlint/parse": {
|
||||||
"version": "20.2.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.3.1.tgz",
|
||||||
"integrity": "sha512-LXStagGU1ivh07X7sM+hnEr4BvzFYn1iBJ6DRg2QsIN8lBfSzyvkUcVCDwok9Ia4PWiEgei5HQjju6xfJ1YaSQ==",
|
"integrity": "sha512-TuUTdbLpyUNLgDzLDYlI2BeTE6V/COZbf3f8WwsV0K6eq/2nSpNTMw7wHtXb+YxeY9wwxBp/Ldad4P+YIxHJoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"conventional-changelog-angular": "^7.0.0",
|
"conventional-changelog-angular": "^7.0.0",
|
||||||
"conventional-commits-parser": "^5.0.0"
|
"conventional-commits-parser": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -485,14 +486,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/read": {
|
"node_modules/@commitlint/read": {
|
||||||
"version": "20.2.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.3.1.tgz",
|
||||||
"integrity": "sha512-+SjF9mxm5JCbe+8grOpXCXMMRzAnE0WWijhhtasdrpJoAFJYd5UgRTj/oCq5W3HJTwbvTOsijEJ0SUGImECD7Q==",
|
"integrity": "sha512-nCmJAdIg3OdNVUpQW0Idk/eF/vfOo2W2xzmvRmNeptLrzFK7qhwwl/kIwy1Q1LZrKHUFNj7PGNpIT5INbgZWzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/top-level": "^20.0.0",
|
"@commitlint/top-level": "^20.0.0",
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"git-raw-commits": "^4.0.0",
|
"git-raw-commits": "^4.0.0",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"tinyexec": "^1.0.0"
|
"tinyexec": "^1.0.0"
|
||||||
@@ -502,14 +503,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/resolve-extends": {
|
"node_modules/@commitlint/resolve-extends": {
|
||||||
"version": "20.2.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.3.1.tgz",
|
||||||
"integrity": "sha512-KVoLDi9BEuqeq+G0wRABn4azLRiCC22/YHR2aCquwx6bzCHAIN8hMt3Nuf1VFxq/c8ai6s8qBxE8+ZD4HeFTlQ==",
|
"integrity": "sha512-iGTGeyaoDyHDEZNjD8rKeosjSNs8zYanmuowY4ful7kFI0dnY4b5QilVYaFQJ6IM27S57LAeH5sKSsOHy4bw5w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/config-validator": "^20.2.0",
|
"@commitlint/config-validator": "^20.3.1",
|
||||||
"@commitlint/types": "^20.2.0",
|
"@commitlint/types": "^20.3.1",
|
||||||
"global-directory": "^4.0.1",
|
"global-directory": "^4.0.1",
|
||||||
"import-meta-resolve": "^4.0.0",
|
"import-meta-resolve": "^4.0.0",
|
||||||
"lodash.mergewith": "^4.6.2",
|
"lodash.mergewith": "^4.6.2",
|
||||||
@@ -520,16 +521,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/rules": {
|
"node_modules/@commitlint/rules": {
|
||||||
"version": "20.3.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.3.1.tgz",
|
||||||
"integrity": "sha512-TGgXN/qBEhbzVD13crE1l7YSMJRrbPbUL0OBZALbUM5ER36RZmiZRu2ud2W/AA7HO9YLBRbyx6YVi2t/2Be0yQ==",
|
"integrity": "sha512-/uic4P+4jVNpqQxz02+Y6vvIC0A2J899DBztA1j6q3f3MOKwydlNrojSh0dQmGDxxT1bXByiRtDhgFnOFnM6Pg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/ensure": "^20.2.0",
|
"@commitlint/ensure": "^20.3.1",
|
||||||
"@commitlint/message": "^20.0.0",
|
"@commitlint/message": "^20.0.0",
|
||||||
"@commitlint/to-lines": "^20.0.0",
|
"@commitlint/to-lines": "^20.0.0",
|
||||||
"@commitlint/types": "^20.2.0"
|
"@commitlint/types": "^20.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=v18"
|
"node": ">=v18"
|
||||||
@@ -559,9 +560,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@commitlint/types": {
|
"node_modules/@commitlint/types": {
|
||||||
"version": "20.2.0",
|
"version": "20.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.3.1.tgz",
|
||||||
"integrity": "sha512-KTy0OqRDLR5y/zZMnizyx09z/rPlPC/zKhYgH8o/q6PuAjoQAKlRfY4zzv0M64yybQ//6//4H1n14pxaLZfUnA==",
|
"integrity": "sha512-VmIFV/JkBRhDRRv7N5B7zEUkNZIx9Mp+8Pe65erz0rKycXLsi8Epcw0XJ+btSeRXgTzE7DyOyA9bkJ9mn/yqVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1057,16 +1058,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint-react/ast": {
|
"node_modules/@eslint-react/ast": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-2.5.7.tgz",
|
||||||
"integrity": "sha512-l+pEb3kS/RhFN+8ZOhL5+P5+Yj7iw+ta7+abUBNH+5PMyiixlnAJlJ7Wy+rsTGxjUJtzEff3CzSr06V7qwJuwQ==",
|
"integrity": "sha512-yQvKvyIzo9DPKFUOtFSEfpX0He763G1jFf/LQcIY9xoSOWP9pQ2X8ADNE6ZnmIc0L5zZLQ9nF/H0+MGh2NF3fA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-react/eff": "2.5.1",
|
"@eslint-react/eff": "2.5.7",
|
||||||
"@typescript-eslint/types": "^8.51.0",
|
"@typescript-eslint/types": "^8.53.0",
|
||||||
"@typescript-eslint/typescript-estree": "^8.51.0",
|
"@typescript-eslint/typescript-estree": "^8.53.0",
|
||||||
"@typescript-eslint/utils": "^8.51.0",
|
"@typescript-eslint/utils": "^8.53.0",
|
||||||
"string-ts": "^2.3.1"
|
"string-ts": "^2.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1078,19 +1079,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint-react/core": {
|
"node_modules/@eslint-react/core": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-2.5.7.tgz",
|
||||||
"integrity": "sha512-48YQYhjHga0TAv7FI0uGGT+X3SITec89fJLKSTqIEa7jTLpXTV56B+B/V/IcfZ0s8I0lNVEmtHFTXaibAxmfyw==",
|
"integrity": "sha512-LqY4YV4Z/uiymdy2OLXkjsEVnoDjoFsxJZI4GWsZ9aBgGWYAsuoKKUG0bdMLp1U9jwMaY0OY9bYgBB5R1ANkoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-react/ast": "2.5.1",
|
"@eslint-react/ast": "2.5.7",
|
||||||
"@eslint-react/eff": "2.5.1",
|
"@eslint-react/eff": "2.5.7",
|
||||||
"@eslint-react/shared": "2.5.1",
|
"@eslint-react/shared": "2.5.7",
|
||||||
"@eslint-react/var": "2.5.1",
|
"@eslint-react/var": "2.5.7",
|
||||||
"@typescript-eslint/scope-manager": "^8.51.0",
|
"@typescript-eslint/scope-manager": "^8.53.0",
|
||||||
"@typescript-eslint/types": "^8.51.0",
|
"@typescript-eslint/types": "^8.53.0",
|
||||||
"@typescript-eslint/utils": "^8.51.0",
|
"@typescript-eslint/utils": "^8.53.0",
|
||||||
"birecord": "^0.1.1",
|
"birecord": "^0.1.1",
|
||||||
"ts-pattern": "^5.9.0"
|
"ts-pattern": "^5.9.0"
|
||||||
},
|
},
|
||||||
@@ -1103,9 +1104,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint-react/eff": {
|
"node_modules/@eslint-react/eff": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-react/eff/-/eff-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-react/eff/-/eff-2.5.7.tgz",
|
||||||
"integrity": "sha512-henDz7sTB67Q+YPb+Kiy0UpD7eHLvDtB2nOrRtHLTatS/LFr7wVr4J2muQPkPzY9KFF8pv811nrwW58FuDphPg==",
|
"integrity": "sha512-oxd8eP+dykyOUmQUEItnOZhitlKyo9yFq8Ne0k9+CJeV0Pk1db1+lNcDWswWYoeb1IFEloqHo6y63fVxQXvJew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1113,14 +1114,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint-react/shared": {
|
"node_modules/@eslint-react/shared": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-2.5.7.tgz",
|
||||||
"integrity": "sha512-JSTnpZdBu57ZvrJkBwUZXgkBW8iPiQfSiQMYHEjVFR2NHWFgcOd9qYJLX7mJLrdcw/j+GgRQeOAGok0a0xcxGA==",
|
"integrity": "sha512-ZjKO3rNDoLN2ZTn3PxmMcgoASPNiL3umK1BHQvYwCXqqnXlLEouu7sxK6htOvDu1NC+Dbwsqpu4JGUY7t0XQTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-react/eff": "2.5.1",
|
"@eslint-react/eff": "2.5.7",
|
||||||
"@typescript-eslint/utils": "^8.51.0",
|
"@typescript-eslint/utils": "^8.53.0",
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "^5.9.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
@@ -1133,17 +1134,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint-react/var": {
|
"node_modules/@eslint-react/var": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-2.5.7.tgz",
|
||||||
"integrity": "sha512-O3rbdK8pHKZ4NYPD39Zq7zhWu76dvhyeroqG8nQzf667F/FdHGpGhsCPJOeIU5L/ooYO/3/Qz2vJD1pB5JeROQ==",
|
"integrity": "sha512-VWoyZENbK+OV70s/sE9E6T4HD0WO6lsg2CnYjjpKBKBDOBW67SRdIlM7dUPKzqQKsj6p4yNh59eQ5iKaH82LLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-react/ast": "2.5.1",
|
"@eslint-react/ast": "2.5.7",
|
||||||
"@eslint-react/eff": "2.5.1",
|
"@eslint-react/eff": "2.5.7",
|
||||||
"@typescript-eslint/scope-manager": "^8.51.0",
|
"@typescript-eslint/scope-manager": "^8.53.0",
|
||||||
"@typescript-eslint/types": "^8.51.0",
|
"@typescript-eslint/types": "^8.53.0",
|
||||||
"@typescript-eslint/utils": "^8.51.0",
|
"@typescript-eslint/utils": "^8.53.0",
|
||||||
"ts-pattern": "^5.9.0"
|
"ts-pattern": "^5.9.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1597,6 +1598,10 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@page-agent/cdn": {
|
||||||
|
"resolved": "packages/cdn",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@page-agent/llms": {
|
"node_modules/@page-agent/llms": {
|
||||||
"resolved": "packages/llms",
|
"resolved": "packages/llms",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -3458,9 +3463,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@trivago/prettier-plugin-sort-imports": {
|
"node_modules/@trivago/prettier-plugin-sort-imports": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.2.tgz",
|
||||||
"integrity": "sha512-6B13DCWDfAfh4AEJ43gRgeCSAQmlKG5LHqHzHc0lbUwgBy0rX7o41US+46Fd4XiXBx+JDGEz3NBadCbUls0dUQ==",
|
"integrity": "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3556,9 +3561,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.0.3",
|
"version": "25.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.7.tgz",
|
||||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
"integrity": "sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3566,9 +3571,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3586,17 +3591,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
|
||||||
"integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==",
|
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.52.0",
|
"@typescript-eslint/scope-manager": "8.53.0",
|
||||||
"@typescript-eslint/type-utils": "8.52.0",
|
"@typescript-eslint/type-utils": "8.53.0",
|
||||||
"@typescript-eslint/utils": "8.52.0",
|
"@typescript-eslint/utils": "8.53.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.52.0",
|
"@typescript-eslint/visitor-keys": "8.53.0",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.4.0"
|
"ts-api-utils": "^2.4.0"
|
||||||
@@ -3609,7 +3614,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.52.0",
|
"@typescript-eslint/parser": "^8.53.0",
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
@@ -3625,16 +3630,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz",
|
||||||
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.52.0",
|
"@typescript-eslint/scope-manager": "8.53.0",
|
||||||
"@typescript-eslint/types": "8.52.0",
|
"@typescript-eslint/types": "8.53.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.52.0",
|
"@typescript-eslint/typescript-estree": "8.53.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.52.0",
|
"@typescript-eslint/visitor-keys": "8.53.0",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3650,14 +3655,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz",
|
||||||
"integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==",
|
"integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.52.0",
|
"@typescript-eslint/tsconfig-utils": "^8.53.0",
|
||||||
"@typescript-eslint/types": "^8.52.0",
|
"@typescript-eslint/types": "^8.53.0",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3672,14 +3677,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz",
|
||||||
"integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==",
|
"integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.52.0",
|
"@typescript-eslint/types": "8.53.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.52.0"
|
"@typescript-eslint/visitor-keys": "8.53.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3690,9 +3695,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz",
|
||||||
"integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==",
|
"integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3707,15 +3712,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz",
|
||||||
"integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==",
|
"integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.52.0",
|
"@typescript-eslint/types": "8.53.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.52.0",
|
"@typescript-eslint/typescript-estree": "8.53.0",
|
||||||
"@typescript-eslint/utils": "8.52.0",
|
"@typescript-eslint/utils": "8.53.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.4.0"
|
"ts-api-utils": "^2.4.0"
|
||||||
},
|
},
|
||||||
@@ -3732,9 +3737,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz",
|
||||||
"integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==",
|
"integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3746,16 +3751,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz",
|
||||||
"integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==",
|
"integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.52.0",
|
"@typescript-eslint/project-service": "8.53.0",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.52.0",
|
"@typescript-eslint/tsconfig-utils": "8.53.0",
|
||||||
"@typescript-eslint/types": "8.52.0",
|
"@typescript-eslint/types": "8.53.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.52.0",
|
"@typescript-eslint/visitor-keys": "8.53.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^9.0.5",
|
"minimatch": "^9.0.5",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -3800,16 +3805,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz",
|
||||||
"integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==",
|
"integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.52.0",
|
"@typescript-eslint/scope-manager": "8.53.0",
|
||||||
"@typescript-eslint/types": "8.52.0",
|
"@typescript-eslint/types": "8.53.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.52.0"
|
"@typescript-eslint/typescript-estree": "8.53.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3824,13 +3829,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz",
|
||||||
"integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==",
|
"integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.52.0",
|
"@typescript-eslint/types": "8.53.0",
|
||||||
"eslint-visitor-keys": "^4.2.1"
|
"eslint-visitor-keys": "^4.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3911,9 +3916,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ai-motion": {
|
"node_modules/ai-motion": {
|
||||||
"version": "0.4.7",
|
"version": "0.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/ai-motion/-/ai-motion-0.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/ai-motion/-/ai-motion-0.4.8.tgz",
|
||||||
"integrity": "sha512-yuFtVgWeaxKyvfSFocA01Mr4UbRvwwy7HWZ6Mq9NM/yccAkqp3UCGdWX6AflNB8cU27PgwTBiMRfx6C9di7xkQ==",
|
"integrity": "sha512-9nQVjf8lrYQWSW0sZdb9bES4x8Ei1w9C0S62po2KvRbMjslmjm0WMeszyW771SEauDnu4YGWqurLZ/gCkCTosQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
@@ -4860,20 +4865,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react-dom": {
|
"node_modules/eslint-plugin-react-dom": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-2.5.7.tgz",
|
||||||
"integrity": "sha512-7x3k0bQwtTOSxjReH11buYL6aSMopBzSaWr2bU/LPOuy1dDxvqE6HC2Y6QPEyZjLJ1ppUSKwRmcX85uiF0jHAA==",
|
"integrity": "sha512-KHCU7KRXeLYlrrXKtInpCqHzt+nYhIGpYIuMM4Q0FyGBOcswRS2Gv9PhN+yQqDI9PRBhnjCuRf+q2Y3ZrYWjsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-react/ast": "2.5.1",
|
"@eslint-react/ast": "2.5.7",
|
||||||
"@eslint-react/core": "2.5.1",
|
"@eslint-react/core": "2.5.7",
|
||||||
"@eslint-react/eff": "2.5.1",
|
"@eslint-react/eff": "2.5.7",
|
||||||
"@eslint-react/shared": "2.5.1",
|
"@eslint-react/shared": "2.5.7",
|
||||||
"@eslint-react/var": "2.5.1",
|
"@eslint-react/var": "2.5.7",
|
||||||
"@typescript-eslint/scope-manager": "^8.51.0",
|
"@typescript-eslint/scope-manager": "^8.53.0",
|
||||||
"@typescript-eslint/types": "^8.51.0",
|
"@typescript-eslint/types": "^8.53.0",
|
||||||
"@typescript-eslint/utils": "^8.51.0",
|
"@typescript-eslint/utils": "^8.53.0",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"string-ts": "^2.3.1",
|
"string-ts": "^2.3.1",
|
||||||
"ts-pattern": "^5.9.0"
|
"ts-pattern": "^5.9.0"
|
||||||
@@ -4917,21 +4922,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react-x": {
|
"node_modules/eslint-plugin-react-x": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-2.5.7.tgz",
|
||||||
"integrity": "sha512-w3KtEG8M7sFRh/psUmep3H+i2O/5Nn9wI8GXjlqzwp8zLb4ax66Ue+1oecLTzY/0j/M1xRfB3hzk5oio54RwLA==",
|
"integrity": "sha512-EU+h7eUHNg9ikLaGOa0Tjsc/o4FO2KdyT3M97TkQmaKc9tzpcy8nlPQp/czBMbUuxAgbj2495wzOvCZ0fEK4mw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-react/ast": "2.5.1",
|
"@eslint-react/ast": "2.5.7",
|
||||||
"@eslint-react/core": "2.5.1",
|
"@eslint-react/core": "2.5.7",
|
||||||
"@eslint-react/eff": "2.5.1",
|
"@eslint-react/eff": "2.5.7",
|
||||||
"@eslint-react/shared": "2.5.1",
|
"@eslint-react/shared": "2.5.7",
|
||||||
"@eslint-react/var": "2.5.1",
|
"@eslint-react/var": "2.5.7",
|
||||||
"@typescript-eslint/scope-manager": "^8.51.0",
|
"@typescript-eslint/scope-manager": "^8.53.0",
|
||||||
"@typescript-eslint/type-utils": "^8.51.0",
|
"@typescript-eslint/type-utils": "^8.53.0",
|
||||||
"@typescript-eslint/types": "^8.51.0",
|
"@typescript-eslint/types": "^8.53.0",
|
||||||
"@typescript-eslint/utils": "^8.51.0",
|
"@typescript-eslint/utils": "^8.53.0",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"is-immutable-type": "^5.0.1",
|
"is-immutable-type": "^5.0.1",
|
||||||
"string-ts": "^2.3.1",
|
"string-ts": "^2.3.1",
|
||||||
@@ -5310,13 +5315,13 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.24.0",
|
"version": "12.26.1",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.1.tgz",
|
||||||
"integrity": "sha512-ggTMRkIDPc76lHmM+dRT1MmVfFV6t/y+jkWjWuzR7FG5xRvnAAl/5wFPjzSkLE8Nu5E5uIQRCNxmIXtWJVo6XQ==",
|
"integrity": "sha512-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-dom": "^12.24.0",
|
"motion-dom": "^12.24.11",
|
||||||
"motion-utils": "^12.23.28",
|
"motion-utils": "^12.24.10",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -5457,9 +5462,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "16.5.0",
|
"version": "17.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz",
|
||||||
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
|
"integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5543,9 +5548,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.7.3",
|
"version": "25.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz",
|
||||||
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
|
"integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6523,12 +6528,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion": {
|
"node_modules/motion": {
|
||||||
"version": "12.24.0",
|
"version": "12.26.1",
|
||||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion/-/motion-12.26.1.tgz",
|
||||||
"integrity": "sha512-FAnpl/DhCFct3p+T2hCjAY95w+3EggUk3b8Ql4jQ6mmdRbEaGD1000goqWeEYVoD7mqx9H0As9ORmRADD4LQAw==",
|
"integrity": "sha512-IVhzx9HOQTiJ9ykthMOlZPnLwrkXziN5Q/yebsqBYlFJb2rHP8yhmKc8O/YUT9byPJlxOeqkzfNYCrVKZx8vqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^12.24.0",
|
"framer-motion": "^12.26.1",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -6549,18 +6554,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.24.0",
|
"version": "12.24.11",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz",
|
||||||
"integrity": "sha512-RD2kZkFd/GH4fITI8IJvypGgn0vIu5vkrJaXIAkYqORGs5P0CKDHKNvswmoY1H+tbUAOPSh6VtUqoAmc/3Gvig==",
|
"integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-utils": "^12.23.28"
|
"motion-utils": "^12.24.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-utils": {
|
"node_modules/motion-utils": {
|
||||||
"version": "12.23.28",
|
"version": "12.24.10",
|
||||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.28.tgz",
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
|
||||||
"integrity": "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ==",
|
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
@@ -6935,9 +6940,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "16.5.1",
|
"version": "16.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.2.tgz",
|
||||||
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==",
|
"integrity": "sha512-GG/SBVxx9dvrO1uCs8VYdKfOP8NEBUhNP+2VDQLCifRJ8DL1qPq296k2ACNGyZMDe7iyIlz/LMJTQOs8HXSRvw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7174,9 +7179,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-icons": {
|
"node_modules/simple-icons": {
|
||||||
"version": "16.4.0",
|
"version": "16.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.5.0.tgz",
|
||||||
"integrity": "sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==",
|
"integrity": "sha512-72nn0oHADKx6Hknu7q6M0vfL8LiCUMKABOHane2+4xdqaFBSHfNNBjuZioihiqVQMz7IvVle4NKAM0IlXvl/9A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -7577,16 +7582,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.52.0",
|
"version": "8.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz",
|
||||||
"integrity": "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==",
|
"integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.52.0",
|
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||||
"@typescript-eslint/parser": "8.52.0",
|
"@typescript-eslint/parser": "8.53.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.52.0",
|
"@typescript-eslint/typescript-estree": "8.53.0",
|
||||||
"@typescript-eslint/utils": "8.52.0"
|
"@typescript-eslint/utils": "8.53.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -7772,9 +7777,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -8140,9 +8145,17 @@
|
|||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/cdn": {
|
||||||
|
"name": "@page-agent/cdn",
|
||||||
|
"version": "0.0.22",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"page-agent": "0.0.22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/llms": {
|
"packages/llms": {
|
||||||
"name": "@page-agent/llms",
|
"name": "@page-agent/llms",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@@ -8150,32 +8163,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/page-agent": {
|
"packages/page-agent": {
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@page-agent/llms": "0.0.19",
|
"@page-agent/llms": "0.0.22",
|
||||||
"@page-agent/page-controller": "0.0.19",
|
"@page-agent/page-controller": "0.0.22",
|
||||||
"@page-agent/ui": "0.0.19",
|
"@page-agent/ui": "0.0.22",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/page-controller": {
|
"packages/page-controller": {
|
||||||
"name": "@page-agent/page-controller",
|
"name": "@page-agent/page-controller",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ai-motion": "^0.4.8"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@page-agent/ui",
|
"name": "@page-agent/ui",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"ai-motion": "^0.4.7"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@page-agent/website",
|
"name": "@page-agent/website",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
@@ -8185,23 +8198,23 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.24.0",
|
"motion": "^12.26.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.4.0",
|
"simple-icons": "^16.5.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-i18next": "^16.5.1",
|
"react-i18next": "^16.5.2",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"wouter": "^3.9.0"
|
"wouter": "^3.9.0"
|
||||||
|
|||||||
+11
-10
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/page-controller",
|
"packages/page-controller",
|
||||||
"packages/ui",
|
"packages/ui",
|
||||||
"packages/llms",
|
"packages/llms",
|
||||||
"packages/page-agent",
|
"packages/page-agent",
|
||||||
|
"packages/cdn",
|
||||||
"packages/website"
|
"packages/website"
|
||||||
],
|
],
|
||||||
"description": "AI-powered UI agent for web applications",
|
"description": "AI-powered UI agent for web applications",
|
||||||
@@ -34,28 +35,28 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.3.0",
|
"@commitlint/cli": "^20.3.1",
|
||||||
"@commitlint/config-conventional": "^20.3.0",
|
"@commitlint/config-conventional": "^20.3.1",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@microsoft/api-extractor": "^7.55.2",
|
"@microsoft/api-extractor": "^7.55.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.1",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.7",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-dom": "^2.5.1",
|
"eslint-plugin-react-dom": "^2.5.7",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"eslint-plugin-react-x": "^2.5.1",
|
"eslint-plugin-react-x": "^2.5.7",
|
||||||
"globals": "^16.4.0",
|
"globals": "^17.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.4",
|
"lint-staged": "^16.2.4",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.52.0",
|
"typescript-eslint": "^8.53.0",
|
||||||
"unplugin-dts": "^1.0.0-beta.6",
|
"unplugin-dts": "^1.0.0-beta.6",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@page-agent/cdn",
|
||||||
|
"private": false,
|
||||||
|
"version": "0.0.22",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist/"
|
||||||
|
],
|
||||||
|
"description": "CDN builds for page-agent - IIFE bundles for script tag usage",
|
||||||
|
"author": "Simon<gaomeng1900>",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/alibaba/page-agent.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build && vite build --mode demo"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"page-agent": "0.0.22"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
/**
|
||||||
|
* Demo CDN build for page-agent
|
||||||
|
* Auto-initializes with built-in demo API for testing
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.demo.js"></script>
|
||||||
|
*/
|
||||||
|
import { PageAgent } from 'page-agent'
|
||||||
|
|
||||||
|
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||||
|
if (window.pageAgent) {
|
||||||
|
window.pageAgent.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount to global window object
|
||||||
|
window.PageAgent = PageAgent
|
||||||
|
|
||||||
|
// Export for IIFE
|
||||||
|
export { PageAgent }
|
||||||
|
|
||||||
|
console.log('🚀 page-agent.js loaded!')
|
||||||
|
|
||||||
|
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||||
|
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||||
|
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||||
|
|
||||||
|
// Demo warning
|
||||||
|
console.log(
|
||||||
|
'%c⚠️ DEMO MODE %c\n' +
|
||||||
|
'This build uses a shared testing LLM with rate limits.\n' +
|
||||||
|
'For production, use page-agent.js with your own API key:\n' +
|
||||||
|
'https://alibaba.github.io/page-agent/#/docs/features/models',
|
||||||
|
'background: #f59e0b; color: #000; font-size: 14px; font-weight: bold; padding: 4px 8px; border-radius: 4px;',
|
||||||
|
'color: #f59e0b; font-size: 12px;'
|
||||||
|
)
|
||||||
|
|
||||||
|
// in case document.x is not ready yet
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentScript = document.currentScript
|
||||||
|
|
||||||
|
if (currentScript) {
|
||||||
|
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||||
|
const url = new URL(currentScript.src)
|
||||||
|
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||||
|
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||||
|
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||||
|
const language = url.searchParams.get('lang') || 'zh-CN'
|
||||||
|
const config = { model, baseURL, apiKey, language }
|
||||||
|
window.pageAgent = new PageAgent(config)
|
||||||
|
} else {
|
||||||
|
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||||
|
window.pageAgent = new PageAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||||
|
|
||||||
|
window.pageAgent.panel.show()
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
/**
|
||||||
|
* Full CDN build for page-agent
|
||||||
|
* Exposes PageAgent class for manual instantiation and configuration
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.js"></script>
|
||||||
|
* <script>
|
||||||
|
* const agent = new PageAgent({ model: 'gpt-4o', apiKey: 'your-key' })
|
||||||
|
* agent.panel.show()
|
||||||
|
* </script>
|
||||||
|
*/
|
||||||
|
import { PageAgent } from 'page-agent'
|
||||||
|
|
||||||
|
export { PageAgent }
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { dirname, resolve } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDN IIFE builds for page-agent
|
||||||
|
*
|
||||||
|
* Build targets (via --mode):
|
||||||
|
* - demo: page-agent.demo.js with auto-init and built-in API
|
||||||
|
* - full (default): page-agent.js exposes PageAgent class only
|
||||||
|
*/
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const isDemo = mode === 'demo'
|
||||||
|
const entry = isDemo ? resolve(__dirname, 'src/demo.js') : resolve(__dirname, 'src/full.js')
|
||||||
|
const fileName = isDemo ? 'page-agent.demo' : 'page-agent'
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||||
|
publicDir: false,
|
||||||
|
esbuild: {
|
||||||
|
keepNames: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry,
|
||||||
|
name: 'PageAgent',
|
||||||
|
fileName: () => `${fileName}.js`,
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
outDir: resolve(__dirname, 'dist'),
|
||||||
|
emptyOutDir: !isDemo, // only empty on first build (full)
|
||||||
|
minify: false,
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': '"production"',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/llms",
|
"name": "@page-agent/llms",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-llms.js",
|
"main": "./dist/lib/page-agent-llms.js",
|
||||||
"module": "./dist/lib/page-agent-llms.js",
|
"module": "./dist/lib/page-agent-llms.js",
|
||||||
|
|||||||
@@ -1,60 +1,58 @@
|
|||||||
/**
|
/**
|
||||||
* OpenAI Client implementation
|
* OpenAI Client implementation
|
||||||
* @note This client is only for demonstrating how to implement a LLM client.
|
|
||||||
* @note Use OpenAILenientClient instead.
|
|
||||||
*/
|
*/
|
||||||
import { InvokeError, InvokeErrorType } from './errors'
|
import { InvokeError, InvokeErrorType } from './errors'
|
||||||
import type { InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||||
import { modelPatch, zodToOpenAITool } from './utils'
|
import { modelPatch, zodToOpenAITool } from './utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use OpenAILenientClient instead.
|
* Client for OpenAI compatible APIs
|
||||||
*/
|
*/
|
||||||
export class OpenAIClient implements LLMClient {
|
export class OpenAIClient implements LLMClient {
|
||||||
config: LLMConfig
|
config: Required<LLMConfig>
|
||||||
|
private fetch: typeof globalThis.fetch
|
||||||
|
|
||||||
constructor(config: LLMConfig) {
|
constructor(config: Required<LLMConfig>) {
|
||||||
this.config = config
|
this.config = config
|
||||||
|
this.fetch = config.customFetch
|
||||||
}
|
}
|
||||||
|
|
||||||
async invoke(
|
async invoke(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: Record<string, Tool>,
|
tools: Record<string, Tool>,
|
||||||
abortSignal?: AbortSignal
|
abortSignal?: AbortSignal,
|
||||||
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
// 1. Convert tools to OpenAI format
|
// 1. Convert tools to OpenAI format
|
||||||
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool))
|
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||||
|
|
||||||
|
// Build request body
|
||||||
|
const requestBody: Record<string, unknown> = {
|
||||||
|
model: this.config.model,
|
||||||
|
temperature: this.config.temperature,
|
||||||
|
messages,
|
||||||
|
tools: openaiTools,
|
||||||
|
parallel_tool_calls: false,
|
||||||
|
// Require tool call: specific tool if provided, otherwise any tool
|
||||||
|
tool_choice: options?.toolChoiceName
|
||||||
|
? { type: 'function', function: { name: options.toolChoiceName } }
|
||||||
|
: 'required',
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Call API
|
// 2. Call API
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
response = await this.fetch(`${this.config.baseURL}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${this.config.apiKey}`,
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(modelPatch(requestBody)),
|
||||||
modelPatch({
|
|
||||||
model: this.config.model,
|
|
||||||
temperature: this.config.temperature,
|
|
||||||
messages,
|
|
||||||
|
|
||||||
tools: openaiTools,
|
|
||||||
// tool_choice: 'required',
|
|
||||||
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
|
|
||||||
|
|
||||||
// model specific params
|
|
||||||
|
|
||||||
// reasoning_effort: 'minimal',
|
|
||||||
// verbosity: 'low',
|
|
||||||
parallel_tool_calls: false,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Network error
|
console.error(error)
|
||||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,77 +90,94 @@ export class OpenAIClient implements LLMClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Parse and validate response
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
// 4. Check finish_reason
|
|
||||||
const choice = data.choices?.[0]
|
const choice = data.choices?.[0]
|
||||||
if (!choice) {
|
if (!choice) {
|
||||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check finish_reason
|
||||||
switch (choice.finish_reason) {
|
switch (choice.finish_reason) {
|
||||||
case 'tool_calls':
|
case 'tool_calls':
|
||||||
// ✅ Normal
|
case 'function_call': // gemini
|
||||||
|
case 'stop': // some models use this even with tool calls
|
||||||
break
|
break
|
||||||
case 'length':
|
case 'length':
|
||||||
// ⚠️ Token limit reached
|
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.CONTEXT_LENGTH,
|
InvokeErrorType.CONTEXT_LENGTH,
|
||||||
'Response truncated: max tokens reached',
|
'Response truncated: max tokens reached'
|
||||||
data
|
|
||||||
)
|
)
|
||||||
case 'content_filter':
|
case 'content_filter':
|
||||||
// ❌ Content filtered
|
throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.CONTENT_FILTER,
|
|
||||||
'Content filtered by safety system',
|
|
||||||
data
|
|
||||||
)
|
|
||||||
case 'stop':
|
|
||||||
// ❌ Did not call tool (we require tool call)
|
|
||||||
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'Model did not call any tool', data)
|
|
||||||
default:
|
default:
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.UNKNOWN,
|
InvokeErrorType.UNKNOWN,
|
||||||
`Unexpected finish_reason: ${choice.finish_reason}`,
|
`Unexpected finish_reason: ${choice.finish_reason}`
|
||||||
data
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Parse tool call
|
// Apply normalizeResponse if provided (for fixing format issues automatically)
|
||||||
const toolCall = choice.message?.tool_calls?.[0]
|
const normalizedData = options?.normalizeResponse ? options.normalizeResponse(data) : data
|
||||||
if (!toolCall) {
|
const normalizedChoice = (normalizedData as any).choices?.[0]
|
||||||
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'No tool call found in response', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolName = toolCall.function.name
|
// Get tool name from response
|
||||||
const tool = tools[toolName]
|
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
|
||||||
if (!tool) {
|
if (!toolCallName) {
|
||||||
throw new InvokeError(InvokeErrorType.UNKNOWN, `Tool ${toolName} not found`, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Parse and validate arguments
|
|
||||||
let toolArgs: unknown
|
|
||||||
try {
|
|
||||||
toolArgs = JSON.parse(toolCall.function.arguments)
|
|
||||||
} catch (e) {
|
|
||||||
throw new InvokeError(InvokeErrorType.INVALID_TOOL_ARGS, 'Invalid JSON in tool arguments', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate against zod schema
|
|
||||||
const validation = tool.inputSchema.safeParse(toolArgs)
|
|
||||||
if (!validation.success) {
|
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
InvokeErrorType.NO_TOOL_CALL,
|
||||||
`Tool arguments validation failed: ${validation.error.message}`,
|
'No tool call found in response',
|
||||||
validation.error
|
normalizedData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Execute tool
|
const tool = tools[toolCallName]
|
||||||
|
if (!tool) {
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorType.UNKNOWN,
|
||||||
|
`Tool "${toolCallName}" not found in tools`,
|
||||||
|
normalizedData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and parse tool arguments
|
||||||
|
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
|
||||||
|
if (!argString) {
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||||
|
'No tool call arguments found',
|
||||||
|
normalizedData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedArgs: unknown
|
||||||
|
try {
|
||||||
|
parsedArgs = JSON.parse(argString)
|
||||||
|
} catch (error) {
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||||
|
'Failed to parse tool arguments as JSON',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate with schema
|
||||||
|
const validation = tool.inputSchema.safeParse(parsedArgs)
|
||||||
|
if (!validation.success) {
|
||||||
|
console.error(validation.error)
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||||
|
'Tool arguments validation failed',
|
||||||
|
validation.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const toolInput = validation.data
|
||||||
|
|
||||||
|
// 5. Execute tool
|
||||||
let toolResult: unknown
|
let toolResult: unknown
|
||||||
try {
|
try {
|
||||||
toolResult = await tool.execute(validation.data)
|
toolResult = await tool.execute(toolInput)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||||
@@ -171,12 +186,11 @@ export class OpenAIClient implements LLMClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Return result (including cache tokens)
|
// Return result
|
||||||
return {
|
return {
|
||||||
toolCall: {
|
toolCall: {
|
||||||
// id: toolCall.id,
|
name: toolCallName,
|
||||||
name: toolName,
|
args: toolInput,
|
||||||
args: validation.data as Record<string, unknown>,
|
|
||||||
},
|
},
|
||||||
toolResult,
|
toolResult,
|
||||||
usage: {
|
usage: {
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
/**
|
|
||||||
* OpenAI Client implementation
|
|
||||||
*/
|
|
||||||
import { InvokeError, InvokeErrorType } from './errors'
|
|
||||||
import type { InvokeResult, LLMClient, LLMConfig, MacroToolInput, Message, Tool } from './types'
|
|
||||||
import { lenientParseMacroToolCall, modelPatch, zodToOpenAITool } from './utils'
|
|
||||||
|
|
||||||
export class OpenAIClient implements LLMClient {
|
|
||||||
config: Required<LLMConfig>
|
|
||||||
private fetch: typeof globalThis.fetch
|
|
||||||
|
|
||||||
constructor(config: Required<LLMConfig>) {
|
|
||||||
this.config = config
|
|
||||||
this.fetch = config.customFetch
|
|
||||||
}
|
|
||||||
|
|
||||||
async invoke(
|
|
||||||
messages: Message[],
|
|
||||||
tools: { AgentOutput: Tool<MacroToolInput> },
|
|
||||||
abortSignal?: AbortSignal
|
|
||||||
): Promise<InvokeResult> {
|
|
||||||
// 1. Convert tools to OpenAI format
|
|
||||||
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool))
|
|
||||||
|
|
||||||
// 2. Call API
|
|
||||||
let response: Response
|
|
||||||
try {
|
|
||||||
response = await this.fetch(`${this.config.baseURL}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${this.config.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(
|
|
||||||
modelPatch({
|
|
||||||
model: this.config.model,
|
|
||||||
temperature: this.config.temperature,
|
|
||||||
messages,
|
|
||||||
|
|
||||||
tools: openaiTools,
|
|
||||||
// tool_choice: 'required',
|
|
||||||
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
|
|
||||||
parallel_tool_calls: false,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
signal: abortSignal,
|
|
||||||
})
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Network error
|
|
||||||
console.error(error)
|
|
||||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Handle HTTP errors
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch()
|
|
||||||
const errorMessage =
|
|
||||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.AUTH_ERROR,
|
|
||||||
`Authentication failed: ${errorMessage}`,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (response.status === 429) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.RATE_LIMIT,
|
|
||||||
`Rate limit exceeded: ${errorMessage}`,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (response.status >= 500) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.SERVER_ERROR,
|
|
||||||
`Server error: ${errorMessage}`,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.UNKNOWN,
|
|
||||||
`HTTP ${response.status}: ${errorMessage}`,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse response
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
const tool = tools.AgentOutput
|
|
||||||
const macroToolInput = lenientParseMacroToolCall(data, tool.inputSchema as any)
|
|
||||||
|
|
||||||
// Execute tool
|
|
||||||
let toolResult: unknown
|
|
||||||
try {
|
|
||||||
toolResult = await tool.execute(macroToolInput)
|
|
||||||
} catch (e) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
|
||||||
`Tool execution failed: ${(e as Error).message}`,
|
|
||||||
e
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return result (including cache tokens)
|
|
||||||
return {
|
|
||||||
toolCall: {
|
|
||||||
// id: toolCall.id,
|
|
||||||
name: 'AgentOutput',
|
|
||||||
args: macroToolInput,
|
|
||||||
},
|
|
||||||
toolResult,
|
|
||||||
usage: {
|
|
||||||
promptTokens: data.usage?.prompt_tokens ?? 0,
|
|
||||||
completionTokens: data.usage?.completion_tokens ?? 0,
|
|
||||||
totalTokens: data.usage?.total_tokens ?? 0,
|
|
||||||
cachedTokens: data.usage?.prompt_tokens_details?.cached_tokens,
|
|
||||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
|
||||||
},
|
|
||||||
rawResponse: data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +1,4 @@
|
|||||||
/**
|
import { OpenAIClient } from './OpenAIClient'
|
||||||
* @topic LLM 与主流程的隔离
|
|
||||||
* @reasoning
|
|
||||||
* 将 llm 的调用和主流程分开是复杂的,
|
|
||||||
* 因为 agent 的 tool call 通常集成在 llm 模块中,而而先得到 llm 返回,然后处理工具调用
|
|
||||||
* tools 和 llm 调用的逻辑不可避免地耦合在一起,tool 的执行又和主流程耦合在一起
|
|
||||||
* 而 history 的维护和更新逻辑,又必须嵌入多轮 tool call 中
|
|
||||||
* @reasoning
|
|
||||||
* - 放弃框架提供的自动的多轮调用,每轮调用都由主流程发起
|
|
||||||
* - 理想情况下,llm 调用应该获得 structured output,然后由额外的模块触发 tool call,目前模型和框架都无法实现
|
|
||||||
* - 当前只能将 llm api 和 本地 tool call 耦合在一起,不关心其中的衔接方式
|
|
||||||
* @conclusion
|
|
||||||
* - @llm responsibility boundary:
|
|
||||||
* - call llm api with given messages and tools
|
|
||||||
* - invoke tool call and get the result of the tool
|
|
||||||
* - return the result to main loop
|
|
||||||
* - @main_loop responsibility boundary:
|
|
||||||
* - maintain all behaviors of an **agent**
|
|
||||||
* @conclusion
|
|
||||||
* - 这里的 llm 模块不是 agent,只负责一轮 llm 调用和工具调用,无状态
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* @topic 结构化输出
|
|
||||||
* @facts
|
|
||||||
* - 几乎所有模型都支持 tool call schema
|
|
||||||
* - 几乎所有模型都支持返回 json
|
|
||||||
* - 只有 openAI/grok/gemini 支持 schema 并保证格式
|
|
||||||
* - 主流模型都支持 tool_choice: required
|
|
||||||
* - 除了 qwen 必须指定一个函数名 (9月上新后支持)
|
|
||||||
* @conclusion
|
|
||||||
* - 永远使用 tool call 来返回结构化数据,禁止模型直接返回(视为出错)
|
|
||||||
* - 不能假设 tool 参数合法,必须有修复机制,而且修复也应该使用 tool call 返回
|
|
||||||
*/
|
|
||||||
import { OpenAIClient } from './OpenAILenientClient'
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_API_KEY,
|
DEFAULT_API_KEY,
|
||||||
DEFAULT_BASE_URL,
|
DEFAULT_BASE_URL,
|
||||||
@@ -40,27 +7,9 @@ import {
|
|||||||
LLM_MAX_RETRIES,
|
LLM_MAX_RETRIES,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import { InvokeError } from './errors'
|
import { InvokeError } from './errors'
|
||||||
import type {
|
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||||
AgentBrain,
|
|
||||||
InvokeResult,
|
|
||||||
LLMClient,
|
|
||||||
LLMConfig,
|
|
||||||
MacroToolInput,
|
|
||||||
MacroToolResult,
|
|
||||||
Message,
|
|
||||||
Tool,
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
export type {
|
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||||
AgentBrain,
|
|
||||||
InvokeResult,
|
|
||||||
LLMClient,
|
|
||||||
LLMConfig,
|
|
||||||
MacroToolInput,
|
|
||||||
MacroToolResult,
|
|
||||||
Message,
|
|
||||||
Tool,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||||
return {
|
return {
|
||||||
@@ -93,11 +42,12 @@ export class LLM extends EventTarget {
|
|||||||
async invoke(
|
async invoke(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: Record<string, Tool>,
|
tools: Record<string, Tool>,
|
||||||
abortSignal: AbortSignal
|
abortSignal: AbortSignal,
|
||||||
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
return await withRetry(
|
return await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
const result = await this.client.invoke(messages, tools, abortSignal)
|
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|||||||
+20
-34
@@ -32,6 +32,24 @@ export interface Tool<TParams = any, TResult = any> {
|
|||||||
execute: (args: TParams) => Promise<TResult>
|
execute: (args: TParams) => Promise<TResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke options for LLM call
|
||||||
|
*/
|
||||||
|
export interface InvokeOptions {
|
||||||
|
/**
|
||||||
|
* Force LLM to call a specific tool by name.
|
||||||
|
* If provided: tool_choice = { type: 'function', function: { name: toolChoiceName } }
|
||||||
|
* If not provided: tool_choice = 'required' (must call some tool, but model chooses which)
|
||||||
|
*/
|
||||||
|
toolChoiceName?: string
|
||||||
|
/**
|
||||||
|
* Response normalization function.
|
||||||
|
* Called before parsing the response.
|
||||||
|
* Used to fix various response format errors from the model.
|
||||||
|
*/
|
||||||
|
normalizeResponse?: (response: any) => any
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM Client interface
|
* LLM Client interface
|
||||||
* Note: Does not use generics because each tool in the tools array has different types
|
* Note: Does not use generics because each tool in the tools array has different types
|
||||||
@@ -40,7 +58,8 @@ export interface LLMClient {
|
|||||||
invoke(
|
invoke(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: Record<string, Tool>,
|
tools: Record<string, Tool>,
|
||||||
abortSignal?: AbortSignal
|
abortSignal?: AbortSignal,
|
||||||
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult>
|
): Promise<InvokeResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,36 +101,3 @@ export interface LLMConfig {
|
|||||||
*/
|
*/
|
||||||
customFetch?: typeof globalThis.fetch
|
customFetch?: typeof globalThis.fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Agent brain state - the reflection-before-action model
|
|
||||||
*
|
|
||||||
* Every tool call must first reflect on:
|
|
||||||
* - evaluation_previous_goal: How well did the previous action achieve its goal?
|
|
||||||
* - memory: Key information to remember for future steps
|
|
||||||
* - next_goal: What should be accomplished in the next action?
|
|
||||||
*/
|
|
||||||
export interface AgentBrain {
|
|
||||||
// thinking?: string
|
|
||||||
evaluation_previous_goal: string
|
|
||||||
memory: string
|
|
||||||
next_goal: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MacroTool input structure
|
|
||||||
*
|
|
||||||
* This is the core abstraction that enforces the "reflection-before-action" mental model.
|
|
||||||
* Before executing any action, the LLM must output its reasoning state.
|
|
||||||
*/
|
|
||||||
export interface MacroToolInput extends AgentBrain {
|
|
||||||
action: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MacroTool output structure
|
|
||||||
*/
|
|
||||||
export interface MacroToolResult {
|
|
||||||
input: MacroToolInput
|
|
||||||
output: string
|
|
||||||
}
|
|
||||||
|
|||||||
+18
-175
@@ -4,8 +4,7 @@
|
|||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { InvokeError, InvokeErrorType } from './errors'
|
import type { Tool } from './types'
|
||||||
import type { MacroToolInput, Tool } from './types'
|
|
||||||
|
|
||||||
function debug(message: string) {
|
function debug(message: string) {
|
||||||
console.debug(chalk.gray('[LLM]'), message)
|
console.debug(chalk.gray('[LLM]'), message)
|
||||||
@@ -26,176 +25,6 @@ export function zodToOpenAITool(name: string, tool: Tool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Although some models cannot guarantee correct response. Common issues are fixable:
|
|
||||||
* - Instead of returning a proper tool call. Return the tool call parameters in the message content.
|
|
||||||
* - Returned tool calls or messages don't follow the nested MacroToolInput format.
|
|
||||||
*/
|
|
||||||
export function lenientParseMacroToolCall(
|
|
||||||
responseData: any,
|
|
||||||
inputSchema: z.ZodObject<MacroToolInput & Record<string, any>>
|
|
||||||
): MacroToolInput {
|
|
||||||
// check
|
|
||||||
const choice = responseData.choices?.[0]
|
|
||||||
if (!choice) {
|
|
||||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', responseData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// check
|
|
||||||
switch (choice.finish_reason) {
|
|
||||||
case 'tool_calls':
|
|
||||||
case 'function_call': // gemini
|
|
||||||
case 'stop': // will try a robust parse
|
|
||||||
// ✅ Normal
|
|
||||||
break
|
|
||||||
case 'length':
|
|
||||||
// ⚠️ Token limit reached
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.CONTEXT_LENGTH,
|
|
||||||
'Response truncated: max tokens reached'
|
|
||||||
)
|
|
||||||
case 'content_filter':
|
|
||||||
// ❌ Content filtered
|
|
||||||
throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
|
|
||||||
default:
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.UNKNOWN,
|
|
||||||
`Unexpected finish_reason: ${choice.finish_reason}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract action schema from MacroToolInput schema
|
|
||||||
const actionSchema = inputSchema.shape.action
|
|
||||||
if (!actionSchema) {
|
|
||||||
throw new Error('inputSchema must have an "action" field')
|
|
||||||
}
|
|
||||||
|
|
||||||
// patch stopReason mis-format
|
|
||||||
|
|
||||||
let arg: string | null = null
|
|
||||||
|
|
||||||
// try to use tool call
|
|
||||||
const toolCall = choice.message?.tool_calls?.[0]?.function
|
|
||||||
arg = toolCall?.arguments ?? null
|
|
||||||
|
|
||||||
if (arg && toolCall.name !== 'AgentOutput') {
|
|
||||||
// TODO: check if toolCall.name is a valid action name
|
|
||||||
// case: instead of AgentOutput, the model returned a action name as tool call
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #1 fixing incorrect tool call'))
|
|
||||||
let tmpArg
|
|
||||||
try {
|
|
||||||
tmpArg = JSON.parse(arg)
|
|
||||||
} catch (error) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
'Failed to parse tool arguments as JSON',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
arg = JSON.stringify({ action: { [toolCall.name]: tmpArg } })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!arg) {
|
|
||||||
// try to use message content as JSON
|
|
||||||
arg = choice.message?.content.trim() || null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!arg) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.NO_TOOL_CALL,
|
|
||||||
'No tool call or content found in response',
|
|
||||||
responseData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure is valid JSON
|
|
||||||
|
|
||||||
let parsedArgs: any
|
|
||||||
try {
|
|
||||||
parsedArgs = JSON.parse(arg)
|
|
||||||
} catch (error) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
'Failed to parse tool arguments as JSON',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// patch incomplete formats
|
|
||||||
|
|
||||||
if (parsedArgs.action || parsedArgs.evaluation_previous_goal || parsedArgs.next_goal) {
|
|
||||||
// case: nested MacroToolInput format (correct format)
|
|
||||||
|
|
||||||
// some models may give a empty action (they may think reasoning and action should be separate)
|
|
||||||
if (!parsedArgs.action) {
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #2 fixing incorrect tool call'))
|
|
||||||
parsedArgs.action = {
|
|
||||||
wait: { seconds: 1 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (parsedArgs.type && parsedArgs.function) {
|
|
||||||
// case: upper level function call format provided. only keep its arguments
|
|
||||||
// TODO: check if function name is a valid action name
|
|
||||||
if (parsedArgs.function.name !== 'AgentOutput')
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
`Expected function name "AgentOutput", got "${parsedArgs.function.name}"`,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #3 fixing incorrect tool call'))
|
|
||||||
parsedArgs = parsedArgs.function.arguments
|
|
||||||
} else if (parsedArgs.name && parsedArgs.arguments) {
|
|
||||||
// case: upper level function call format provided. only keep its arguments
|
|
||||||
// TODO: check if function name is a valid action name
|
|
||||||
if (parsedArgs.name !== 'AgentOutput')
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
`Expected function name "AgentOutput", got "${parsedArgs.name}"`,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #4 fixing incorrect tool call'))
|
|
||||||
parsedArgs = parsedArgs.arguments
|
|
||||||
} else {
|
|
||||||
// case: only action parameters provided, wrap into MacroToolInput
|
|
||||||
// TODO: check if action name is valid
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #5 fixing incorrect tool call'))
|
|
||||||
parsedArgs = { action: parsedArgs } as MacroToolInput
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure it's not wrapped as string
|
|
||||||
if (typeof parsedArgs === 'string') {
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #6 fixing incorrect tool call'))
|
|
||||||
try {
|
|
||||||
parsedArgs = JSON.parse(parsedArgs)
|
|
||||||
} catch (error) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
'Failed to parse nested tool arguments as JSON',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validation = inputSchema.safeParse(parsedArgs)
|
|
||||||
if (validation.success) {
|
|
||||||
return validation.data as unknown as MacroToolInput
|
|
||||||
} else {
|
|
||||||
const action = parsedArgs.action ?? {}
|
|
||||||
const actionName = Object.keys(action)[0] || 'unknown'
|
|
||||||
const actionArgs = JSON.stringify(action[actionName] || 'unknown')
|
|
||||||
|
|
||||||
// TODO: check if action name is valid. give a readable error message
|
|
||||||
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
`Tool arguments validation failed: action "${actionName}" with args ${actionArgs}`,
|
|
||||||
validation.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patch model specific parameters
|
* Patch model specific parameters
|
||||||
*/
|
*/
|
||||||
@@ -205,11 +34,25 @@ export function modelPatch(body: Record<string, any>) {
|
|||||||
|
|
||||||
const modelName = normalizeModelName(model)
|
const modelName = normalizeModelName(model)
|
||||||
|
|
||||||
|
if (modelName.startsWith('qwen')) {
|
||||||
|
debug('Applying Qwen patch: use higher temperature for auto fixing')
|
||||||
|
body.temperature = Math.max(body.temperature || 0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
if (modelName.startsWith('claude')) {
|
if (modelName.startsWith('claude')) {
|
||||||
debug('Applying Claude patch: change tool_choice and disable thinking')
|
debug('Applying Claude patch: disable thinking')
|
||||||
body.tool_choice = { type: 'tool', name: 'AgentOutput' }
|
|
||||||
body.thinking = { type: 'disabled' }
|
body.thinking = { type: 'disabled' }
|
||||||
// body.reasoning = { enabled: 'disabled' }
|
|
||||||
|
// Convert tool_choice to Claude format
|
||||||
|
if (body.tool_choice === 'required') {
|
||||||
|
// 'required' -> { type: 'any' } (must call some tool)
|
||||||
|
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
|
||||||
|
body.tool_choice = { type: 'any' }
|
||||||
|
} else if (body.tool_choice?.function?.name) {
|
||||||
|
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
|
||||||
|
debug('Applying Claude patch: convert tool_choice format')
|
||||||
|
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelName.startsWith('grok')) {
|
if (modelName.startsWith('grok')) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "page-agent",
|
"name": "page-agent",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/esm/page-agent.js",
|
"main": "./dist/esm/page-agent.js",
|
||||||
"module": "./dist/esm/page-agent.js",
|
"module": "./dist/esm/page-agent.js",
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
"@page-agent/llms": "0.0.19",
|
"@page-agent/llms": "0.0.22",
|
||||||
"@page-agent/page-controller": "0.0.19",
|
"@page-agent/page-controller": "0.0.22",
|
||||||
"@page-agent/ui": "0.0.19"
|
"@page-agent/ui": "0.0.22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,9 @@
|
|||||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||||
* All rights reserved.
|
* All rights reserved.
|
||||||
*/
|
*/
|
||||||
import {
|
import { LLM, type Tool } from '@page-agent/llms'
|
||||||
type AgentBrain,
|
|
||||||
LLM,
|
|
||||||
type MacroToolInput,
|
|
||||||
type MacroToolResult,
|
|
||||||
type Tool,
|
|
||||||
} from '@page-agent/llms'
|
|
||||||
import { PageController } from '@page-agent/page-controller'
|
import { PageController } from '@page-agent/page-controller'
|
||||||
import { Panel, SimulatorMask } from '@page-agent/ui'
|
import { Panel } from '@page-agent/ui'
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
import zod from 'zod'
|
import zod from 'zod'
|
||||||
|
|
||||||
@@ -18,15 +12,46 @@ import type { PageAgentConfig } from './config'
|
|||||||
import { MAX_STEPS } from './config/constants'
|
import { MAX_STEPS } from './config/constants'
|
||||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||||
import { tools } from './tools'
|
import { tools } from './tools'
|
||||||
import { trimLines, uid, waitUntil } from './utils'
|
import { normalizeResponse, trimLines, uid, waitUntil } from './utils'
|
||||||
import { assert } from './utils/assert'
|
import { assert } from './utils/assert'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent brain state - the reflection-before-action model
|
||||||
|
*
|
||||||
|
* Every tool call must first reflect on:
|
||||||
|
* - evaluation_previous_goal: How well did the previous action achieve its goal?
|
||||||
|
* - memory: Key information to remember for future steps
|
||||||
|
* - next_goal: What should be accomplished in the next action?
|
||||||
|
*/
|
||||||
|
export interface AgentReflection {
|
||||||
|
evaluation_previous_goal: string
|
||||||
|
memory: string
|
||||||
|
next_goal: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MacroTool input structure
|
||||||
|
*
|
||||||
|
* This is the core abstraction that enforces the "reflection-before-action" mental model.
|
||||||
|
* Before executing any action, the LLM must output its reasoning state.
|
||||||
|
*/
|
||||||
|
export interface MacroToolInput extends Partial<AgentReflection> {
|
||||||
|
action: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MacroTool output structure
|
||||||
|
*/
|
||||||
|
export interface MacroToolResult {
|
||||||
|
input: MacroToolInput
|
||||||
|
output: string
|
||||||
|
}
|
||||||
|
|
||||||
export type { PageAgentConfig }
|
export type { PageAgentConfig }
|
||||||
export { tool, type PageAgentTool } from './tools'
|
export { tool, type PageAgentTool } from './tools'
|
||||||
export type { AgentBrain, MacroToolInput, MacroToolResult }
|
|
||||||
|
|
||||||
export interface AgentHistory {
|
export interface AgentHistory {
|
||||||
brain: AgentBrain
|
brain: Partial<AgentReflection>
|
||||||
action: {
|
action: {
|
||||||
name: string
|
name: string
|
||||||
input: any
|
input: any
|
||||||
@@ -67,8 +92,6 @@ export class PageAgent extends EventTarget {
|
|||||||
/** PageController for DOM operations */
|
/** PageController for DOM operations */
|
||||||
pageController: PageController
|
pageController: PageController
|
||||||
|
|
||||||
/** Fullscreen mask */
|
|
||||||
mask = new SimulatorMask()
|
|
||||||
/** History records */
|
/** History records */
|
||||||
history: AgentHistory[] = []
|
history: AgentHistory[] = []
|
||||||
|
|
||||||
@@ -89,8 +112,11 @@ export class PageAgent extends EventTarget {
|
|||||||
})
|
})
|
||||||
this.tools = new Map(tools)
|
this.tools = new Map(tools)
|
||||||
|
|
||||||
// Initialize PageController with config
|
// Initialize PageController with config (mask enabled by default)
|
||||||
this.pageController = new PageController(this.config)
|
this.pageController = new PageController({
|
||||||
|
...this.config,
|
||||||
|
enableMask: this.config.enableMask ?? true,
|
||||||
|
})
|
||||||
|
|
||||||
// Listen to LLM events
|
// Listen to LLM events
|
||||||
this.#llmRetryListener = (e) => {
|
this.#llmRetryListener = (e) => {
|
||||||
@@ -124,9 +150,6 @@ export class PageAgent extends EventTarget {
|
|||||||
window.addEventListener('beforeunload', this.#beforeUnloadListener)
|
window.addEventListener('beforeunload', this.#beforeUnloadListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @todo maybe return something?
|
|
||||||
*/
|
|
||||||
async execute(task: string): Promise<ExecutionResult> {
|
async execute(task: string): Promise<ExecutionResult> {
|
||||||
if (!task) throw new Error('Task is required')
|
if (!task) throw new Error('Task is required')
|
||||||
this.task = task
|
this.task = task
|
||||||
@@ -140,7 +163,7 @@ export class PageAgent extends EventTarget {
|
|||||||
await onBeforeTask.call(this)
|
await onBeforeTask.call(this)
|
||||||
|
|
||||||
// Show mask and panel
|
// Show mask and panel
|
||||||
this.mask.show()
|
this.pageController.showMask()
|
||||||
|
|
||||||
this.panel.show()
|
this.panel.show()
|
||||||
this.panel.reset()
|
this.panel.reset()
|
||||||
@@ -183,7 +206,11 @@ export class PageAgent extends EventTarget {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ AgentOutput: this.#packMacroTool() },
|
{ AgentOutput: this.#packMacroTool() },
|
||||||
this.#abortController.signal
|
this.#abortController.signal,
|
||||||
|
{
|
||||||
|
toolChoiceName: 'AgentOutput',
|
||||||
|
normalizeResponse,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const macroResult = result.toolResult as MacroToolResult
|
const macroResult = result.toolResult as MacroToolResult
|
||||||
@@ -279,6 +306,7 @@ export class PageAgent extends EventTarget {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
description: 'You MUST call this tool every step. Outputs your reflections and next action.',
|
||||||
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
||||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||||
// abort
|
// abort
|
||||||
@@ -458,7 +486,7 @@ export class PageAgent extends EventTarget {
|
|||||||
// Task completed
|
// Task completed
|
||||||
this.panel.update({ type: 'completed' })
|
this.panel.update({ type: 'completed' })
|
||||||
|
|
||||||
this.mask.hide()
|
this.pageController.hideMask()
|
||||||
|
|
||||||
this.#abortController.abort()
|
this.#abortController.abort()
|
||||||
}
|
}
|
||||||
@@ -469,11 +497,13 @@ export class PageAgent extends EventTarget {
|
|||||||
const pi = await this.pageController.getPageInfo()
|
const pi = await this.pageController.getPageInfo()
|
||||||
const viewportExpansion = await this.pageController.getViewportExpansion()
|
const viewportExpansion = await this.pageController.getViewportExpansion()
|
||||||
|
|
||||||
this.mask.wrapper.style.pointerEvents = 'none'
|
|
||||||
await this.pageController.updateTree()
|
await this.pageController.updateTree()
|
||||||
this.mask.wrapper.style.pointerEvents = 'auto'
|
|
||||||
|
|
||||||
const simplifiedHTML = await this.pageController.getSimplifiedHTML()
|
let simplifiedHTML = await this.pageController.getSimplifiedHTML()
|
||||||
|
|
||||||
|
if (this.config.transformPageContent) {
|
||||||
|
simplifiedHTML = await this.config.transformPageContent(simplifiedHTML)
|
||||||
|
}
|
||||||
|
|
||||||
let prompt = trimLines(`<browser_state>
|
let prompt = trimLines(`<browser_state>
|
||||||
Current Page: [${pageTitle}](${pageUrl})
|
Current Page: [${pageTitle}](${pageUrl})
|
||||||
@@ -514,7 +544,6 @@ export class PageAgent extends EventTarget {
|
|||||||
this.disposed = true
|
this.disposed = true
|
||||||
this.pageController.dispose()
|
this.pageController.dispose()
|
||||||
this.panel.dispose()
|
this.panel.dispose()
|
||||||
this.mask.dispose()
|
|
||||||
this.history = []
|
this.history = []
|
||||||
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface AgentConfig {
|
|||||||
|
|
||||||
// lifecycle hooks
|
// lifecycle hooks
|
||||||
// @todo: use event instead of hooks
|
// @todo: use event instead of hooks
|
||||||
|
// @todo: remove `this` binding, pass agent as explicit parameter instead
|
||||||
|
|
||||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
||||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
||||||
@@ -71,6 +72,7 @@ export interface AgentConfig {
|
|||||||
/**
|
/**
|
||||||
* @note this hook can block the disposal process
|
* @note this hook can block the disposal process
|
||||||
* @note when dispose caused by page unload, reason will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
* @note when dispose caused by page unload, reason will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
||||||
|
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||||
*/
|
*/
|
||||||
onDispose?: (this: PageAgent, reason?: string) => void
|
onDispose?: (this: PageAgent, reason?: string) => void
|
||||||
|
|
||||||
@@ -84,10 +86,27 @@ export interface AgentConfig {
|
|||||||
*/
|
*/
|
||||||
experimentalScriptExecutionTool?: boolean
|
experimentalScriptExecutionTool?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform page content before sending to LLM.
|
||||||
|
* Called after DOM extraction and simplification, before LLM invocation.
|
||||||
|
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||||
|
*
|
||||||
|
* @param content - Simplified page content that will be sent to LLM
|
||||||
|
* @returns Transformed content
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Mask phone numbers
|
||||||
|
* transformPageContent: async (content) => {
|
||||||
|
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
transformPageContent?: (content: string) => Promise<string> | string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: @unimplemented
|
* TODO: @unimplemented
|
||||||
* hook when action causes a new page to be opened
|
* hook when action causes a new page to be opened
|
||||||
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
|
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
|
||||||
|
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||||
*/
|
*/
|
||||||
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import chalk from 'chalk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize LLM response and fix common format issues.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - No tool_calls but JSON in message.content (fallback)
|
||||||
|
* - Model returns action name as tool call instead of AgentOutput
|
||||||
|
* - Arguments wrapped as double JSON string
|
||||||
|
* - Nested function call format
|
||||||
|
* - Missing action field (fallback to wait)
|
||||||
|
* - etc.
|
||||||
|
*/
|
||||||
|
export function normalizeResponse(response: any): any {
|
||||||
|
let resolvedArguments = null as any
|
||||||
|
|
||||||
|
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
||||||
|
if (!choice) throw new Error('No choices in response')
|
||||||
|
|
||||||
|
const message = choice.message
|
||||||
|
if (!message) throw new Error('No message in choice')
|
||||||
|
|
||||||
|
const toolCall = message.tool_calls?.[0]
|
||||||
|
|
||||||
|
// fix level and location of arguments
|
||||||
|
|
||||||
|
if (toolCall?.function?.arguments) {
|
||||||
|
resolvedArguments = safeJsonParse(toolCall.function.arguments)
|
||||||
|
|
||||||
|
// case: sometimes the model only returns the action level
|
||||||
|
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #1: fixing tool_call`))
|
||||||
|
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// case: sometimes the model returns json in content instead of tool_calls
|
||||||
|
if (message.content) {
|
||||||
|
const content = message.content.trim()
|
||||||
|
const jsonInContent = retrieveJsonFromString(content)
|
||||||
|
if (jsonInContent) {
|
||||||
|
resolvedArguments = safeJsonParse(jsonInContent)
|
||||||
|
|
||||||
|
// case: sometimes the content json includes upper level wrapper
|
||||||
|
if (resolvedArguments?.name === 'AgentOutput') {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #2: fixing tool_call`))
|
||||||
|
resolvedArguments = safeJsonParse(resolvedArguments.arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// case: sometimes even 2-levels of wrapping
|
||||||
|
if (resolvedArguments?.type === 'function') {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #3: fixing tool_call`))
|
||||||
|
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// case: and sometimes action level only
|
||||||
|
// todo: needs better detection logic
|
||||||
|
if (
|
||||||
|
!resolvedArguments?.action &&
|
||||||
|
!resolvedArguments?.evaluation_previous_goal &&
|
||||||
|
!resolvedArguments?.memory &&
|
||||||
|
!resolvedArguments?.next_goal &&
|
||||||
|
!resolvedArguments?.thinking
|
||||||
|
) {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #4: fixing tool_call`))
|
||||||
|
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No tool_call and the message content does not contain valid JSON')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No tool_call nor message content is present')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix double stringified arguments
|
||||||
|
resolvedArguments = safeJsonParse(resolvedArguments)
|
||||||
|
if (resolvedArguments.action) {
|
||||||
|
resolvedArguments.action = safeJsonParse(resolvedArguments.action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix incomplete formats
|
||||||
|
if (!resolvedArguments.action) {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #5: fixing tool_call`))
|
||||||
|
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// pack back to standard format
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
...choice,
|
||||||
|
message: {
|
||||||
|
...message,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
...(toolCall || {}),
|
||||||
|
function: {
|
||||||
|
...(toolCall?.function || {}),
|
||||||
|
name: 'AgentOutput',
|
||||||
|
arguments: JSON.stringify(resolvedArguments),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse JSON, return original input if not json.
|
||||||
|
*/
|
||||||
|
function safeJsonParse(input: any): any {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(input.trim())
|
||||||
|
} catch {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and parse JSON from a string.
|
||||||
|
* - Treat content between the first `{` and the last `}` as JSON.
|
||||||
|
* - Try to parse that content as JSON and return the parsed value (object/array/primitive) if successful, otherwise return null.
|
||||||
|
*/
|
||||||
|
function retrieveJsonFromString(str: string): any {
|
||||||
|
try {
|
||||||
|
const json = /({[\s\S]*})/.exec(str) ?? []
|
||||||
|
if (json.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return JSON.parse(json[0]!)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Choice {
|
||||||
|
message?: {
|
||||||
|
role?: 'assistant'
|
||||||
|
content?: string
|
||||||
|
tool_calls?: {
|
||||||
|
id?: string
|
||||||
|
type?: 'function'
|
||||||
|
function?: {
|
||||||
|
name?: string
|
||||||
|
arguments?: string
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
index?: 0
|
||||||
|
finish_reason?: 'tool_calls'
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export { normalizeResponse } from './autoFixer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait until condition becomes true
|
* Wait until condition becomes true
|
||||||
* @returns Returns when condition becomes true, throws otherwise
|
* @returns Returns when condition becomes true, throws otherwise
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/page-controller",
|
"name": "@page-agent/page-controller",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-controller.js",
|
"main": "./dist/lib/page-controller.js",
|
||||||
"module": "./dist/lib/page-controller.js",
|
"module": "./dist/lib/page-controller.js",
|
||||||
@@ -34,5 +34,8 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ai-motion": "^0.4.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,18 @@ import { VIEWPORT_EXPANSION } from './constants'
|
|||||||
import * as dom from './dom'
|
import * as dom from './dom'
|
||||||
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||||
import { getPageInfo } from './dom/getPageInfo'
|
import { getPageInfo } from './dom/getPageInfo'
|
||||||
|
import { SimulatorMask } from './mask/SimulatorMask'
|
||||||
import { patchReact } from './patches/react'
|
import { patchReact } from './patches/react'
|
||||||
|
|
||||||
|
export { SimulatorMask }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for PageController
|
* Configuration for PageController
|
||||||
*/
|
*/
|
||||||
export interface PageControllerConfig extends dom.DomConfig {
|
export interface PageControllerConfig extends dom.DomConfig {
|
||||||
viewportExpansion?: number
|
viewportExpansion?: number
|
||||||
|
/** Enable visual mask overlay during operations (default: false) */
|
||||||
|
enableMask?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
@@ -64,12 +69,19 @@ export class PageController extends EventTarget {
|
|||||||
/** last time the tree was updated */
|
/** last time the tree was updated */
|
||||||
private lastTimeUpdate = 0
|
private lastTimeUpdate = 0
|
||||||
|
|
||||||
|
/** Visual mask overlay for blocking user interaction during automation */
|
||||||
|
private mask: SimulatorMask | null = null
|
||||||
|
|
||||||
constructor(config: PageControllerConfig = {}) {
|
constructor(config: PageControllerConfig = {}) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.config = config
|
this.config = config
|
||||||
|
|
||||||
patchReact(this)
|
patchReact(this)
|
||||||
|
|
||||||
|
if (config.enableMask) {
|
||||||
|
this.mask = new SimulatorMask()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======= State Queries =======
|
// ======= State Queries =======
|
||||||
@@ -136,12 +148,18 @@ export class PageController extends EventTarget {
|
|||||||
/**
|
/**
|
||||||
* Update DOM tree, returns simplified HTML for LLM.
|
* Update DOM tree, returns simplified HTML for LLM.
|
||||||
* This is the main method to refresh the page state.
|
* This is the main method to refresh the page state.
|
||||||
|
* Automatically bypasses mask during DOM extraction if enabled.
|
||||||
*/
|
*/
|
||||||
async updateTree(): Promise<string> {
|
async updateTree(): Promise<string> {
|
||||||
this.dispatchEvent(new Event('beforeUpdate'))
|
this.dispatchEvent(new Event('beforeUpdate'))
|
||||||
|
|
||||||
this.lastTimeUpdate = Date.now()
|
this.lastTimeUpdate = Date.now()
|
||||||
|
|
||||||
|
// Temporarily bypass mask to allow DOM extraction
|
||||||
|
if (this.mask) {
|
||||||
|
this.mask.wrapper.style.pointerEvents = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
dom.cleanUpHighlights()
|
dom.cleanUpHighlights()
|
||||||
|
|
||||||
const blacklist = [
|
const blacklist = [
|
||||||
@@ -162,6 +180,11 @@ export class PageController extends EventTarget {
|
|||||||
this.elementTextMap.clear()
|
this.elementTextMap.clear()
|
||||||
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
|
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
|
||||||
|
|
||||||
|
// Restore mask blocking
|
||||||
|
if (this.mask) {
|
||||||
|
this.mask.wrapper.style.pointerEvents = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
this.dispatchEvent(new Event('afterUpdate'))
|
this.dispatchEvent(new Event('afterUpdate'))
|
||||||
|
|
||||||
return this.simplifiedHTML
|
return this.simplifiedHTML
|
||||||
@@ -326,6 +349,24 @@ export class PageController extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======= Mask Operations =======
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the visual mask overlay.
|
||||||
|
* Only works if enableMask was set to true in config.
|
||||||
|
*/
|
||||||
|
showMask(): void {
|
||||||
|
this.mask?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the visual mask overlay.
|
||||||
|
* Only works if enableMask was set to true in config.
|
||||||
|
*/
|
||||||
|
hideMask(): void {
|
||||||
|
this.mask?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispose and clean up resources
|
* Dispose and clean up resources
|
||||||
*/
|
*/
|
||||||
@@ -335,5 +376,7 @@ export class PageController extends EventTarget {
|
|||||||
this.selectorMap.clear()
|
this.selectorMap.clear()
|
||||||
this.elementTextMap.clear()
|
this.elementTextMap.clear()
|
||||||
this.simplifiedHTML = '<EMPTY>'
|
this.simplifiedHTML = '<EMPTY>'
|
||||||
|
this.mask?.dispose()
|
||||||
|
this.mask = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: Record<string, string>
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['@page-agent/*'],
|
external: ['@page-agent/*', 'ai-motion'],
|
||||||
},
|
},
|
||||||
minify: false,
|
minify: false,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/ui",
|
"name": "@page-agent/ui",
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-ui.js",
|
"main": "./dist/lib/page-agent-ui.js",
|
||||||
"module": "./dist/lib/page-agent-ui.js",
|
"module": "./dist/lib/page-agent-ui.js",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
"description": "UI components for page-agent - Panel, SimulatorMask, and i18n",
|
"description": "UI components for page-agent - Panel and i18n",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"page-agent",
|
"page-agent",
|
||||||
"ui",
|
"ui",
|
||||||
@@ -34,8 +34,5 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"ai-motion": "^0.4.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
|
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
|
||||||
export { SimulatorMask } from './SimulatorMask'
|
|
||||||
export { UIState, type Step, type AgentStatus } from './UIState'
|
export { UIState, type Step, type AgentStatus } from './UIState'
|
||||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['ai-motion'],
|
external: [],
|
||||||
},
|
},
|
||||||
minify: false,
|
minify: false,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
+16
-21
@@ -37,14 +37,17 @@ Available Magic UI components: https://magicui.design/docs/components
|
|||||||
Located in `src/components/ui/`:
|
Located in `src/components/ui/`:
|
||||||
|
|
||||||
**From shadcn/ui:**
|
**From shadcn/ui:**
|
||||||
|
|
||||||
- `alert`, `badge`, `button`, `separator`, `sonner`, `switch`, `tooltip`
|
- `alert`, `badge`, `button`, `separator`, `sonner`, `switch`, `tooltip`
|
||||||
|
|
||||||
**From Magic UI:**
|
**From Magic UI:**
|
||||||
|
|
||||||
- `animated-gradient-text`, `animated-shiny-text`, `aurora-text`
|
- `animated-gradient-text`, `animated-shiny-text`, `aurora-text`
|
||||||
- `hyper-text`, `magic-card`, `neon-gradient-card`, `particles`
|
- `hyper-text`, `magic-card`, `neon-gradient-card`, `particles`
|
||||||
- `sparkles-text`, `text-animate`, `typing-animation`
|
- `sparkles-text`, `text-animate`, `typing-animation`
|
||||||
|
|
||||||
**Custom:**
|
**Custom:**
|
||||||
|
|
||||||
- `highlighter`, `kbd`, `spinner`
|
- `highlighter`, `kbd`, `spinner`
|
||||||
|
|
||||||
### Styling Rules
|
### Styling Rules
|
||||||
@@ -58,17 +61,17 @@ Located in `src/components/ui/`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
|
├── pages/
|
||||||
|
│ ├── Home.tsx # Homepage
|
||||||
|
│ └── docs/
|
||||||
|
│ ├── Layout.tsx # Documentation sidebar
|
||||||
|
│ └── [section]/[topic]/page.tsx
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── ui/ # shadcn/ui + Magic UI components
|
│ ├── ui/ # shadcn/ui + Magic UI components
|
||||||
│ ├── Header.tsx # Site header
|
│ ├── Header.tsx # Site header
|
||||||
│ ├── Footer.tsx # Site footer
|
│ └── Footer.tsx # Site footer
|
||||||
│ └── DocsLayout.tsx # Documentation sidebar
|
|
||||||
├── docs/ # Documentation pages
|
|
||||||
│ └── [section]/[topic]/page.tsx
|
|
||||||
├── test-pages/ # Library integration tests
|
|
||||||
├── i18n/ # Internationalization
|
├── i18n/ # Internationalization
|
||||||
├── router.tsx # Central routing
|
├── router.tsx # Central routing
|
||||||
├── page.tsx # Homepage
|
|
||||||
└── main.tsx # App entry
|
└── main.tsx # App entry
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,14 +79,9 @@ src/
|
|||||||
|
|
||||||
### Documentation Page
|
### Documentation Page
|
||||||
|
|
||||||
1. Create `src/docs/<section>/<slug>/page.tsx`
|
1. Create `src/pages/docs/<section>/<slug>/page.tsx`
|
||||||
2. Add route to `src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
2. Add route to `src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
||||||
3. Add navigation item to `DocsLayout.tsx`
|
3. Add navigation item to `pages/docs/Layout.tsx`
|
||||||
|
|
||||||
### Test Page
|
|
||||||
|
|
||||||
1. Create `src/test-pages/<name>.tsx`
|
|
||||||
2. Add route to `src/test-pages/router.tsx`
|
|
||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|
||||||
@@ -93,18 +91,16 @@ Uses hash-based routing for static hosting:
|
|||||||
import { Router } from 'wouter'
|
import { Router } from 'wouter'
|
||||||
import { useHashLocation } from 'wouter/use-hash-location'
|
import { useHashLocation } from 'wouter/use-hash-location'
|
||||||
|
|
||||||
<Router hook={useHashLocation}>
|
;<Router hook={useHashLocation}>{/* routes */}</Router>
|
||||||
{/* routes */}
|
|
||||||
</Router>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Files
|
## Configuration Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| ----------------- | ----------------------- |
|
||||||
| `components.json` | shadcn/ui configuration |
|
| `components.json` | shadcn/ui configuration |
|
||||||
| `vite.config.js` | Vite build settings |
|
| `vite.config.js` | Vite build settings |
|
||||||
| `tsconfig.json` | TypeScript config |
|
| `tsconfig.json` | TypeScript config |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -112,4 +108,3 @@ import { useHashLocation } from 'wouter/use-hash-location'
|
|||||||
npm start # Dev server (from root)
|
npm start # Dev server (from root)
|
||||||
npm run build:website # Build website (from root)
|
npm run build:website # Build website (from root)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/website",
|
"name": "@page-agent/website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.19",
|
"version": "0.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-i18next": "^16.5.1",
|
"react-i18next": "^16.5.2",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"wouter": "^3.9.0"
|
"wouter": "^3.9.0"
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.24.0",
|
"motion": "^12.26.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.4.0",
|
"simple-icons": "^16.5.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import BetaNotice from '@/components/BetaNotice'
|
|
||||||
import CodeEditor from '@/components/CodeEditor'
|
|
||||||
|
|
||||||
export default function DataMasking() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-6">数据脱敏</h1>
|
|
||||||
|
|
||||||
<BetaNotice />
|
|
||||||
|
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
|
||||||
保护敏感数据,确保 AI 处理过程中的数据安全。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold mb-3">脱敏策略</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
|
|
||||||
🔒 自动脱敏
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
|
||||||
自动识别并脱敏手机号、身份证号、银行卡号等敏感信息。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
|
|
||||||
⚙️ 自定义规则
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
|
||||||
支持自定义脱敏规则,适应不同业务场景的数据保护需求。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CodeEditor
|
|
||||||
code={`// 数据脱敏配置
|
|
||||||
// @todo
|
|
||||||
const rules = [
|
|
||||||
{ pattern: /\\d{11}/, replacement: '***-****-****' },
|
|
||||||
{ pattern: /\\d{4}-\\d{4}-\\d{4}-\\d{4}/, replacement: '****-****-****-****' }
|
|
||||||
]
|
|
||||||
pageAgent.maskData(rules)`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import BetaNotice from '@/components/BetaNotice'
|
|
||||||
|
|
||||||
export default function SecurityPermissions() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-6">安全与权限</h1>
|
|
||||||
|
|
||||||
<BetaNotice />
|
|
||||||
|
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
|
||||||
page-agent 提供四种安全机制,确保 AI 操作在可控范围内进行。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<section>
|
|
||||||
<h2 className="text-2xl font-bold mb-3">元素操作黑白名单</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold text-red-900 dark:text-red-300">
|
|
||||||
🚫 操作黑名单
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
|
||||||
禁止 AI 操作敏感元素,如删除按钮、支付按钮等。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold text-green-900 dark:text-green-300">
|
|
||||||
✅ 操作白名单
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">明确定义 AI 可以操作的元素范围。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-2xl font-bold mb-3">URL 黑白名单</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold text-red-900 dark:text-red-300">
|
|
||||||
🚫 URL 黑名单
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">禁止 AI 访问敏感页面和危险链接。</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold text-green-900 dark:text-green-300">
|
|
||||||
✅ URL 白名单
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">限制 AI 只能访问预定义的安全页面。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-2xl font-bold mb-3">Instruction 安全约束</h2>
|
|
||||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
|
|
||||||
⚠️ 高危操作控制
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-3">
|
|
||||||
在 AI 指令中明确列举高危操作,通过两种策略进行控制:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="pl-3 border-l-2 border-red-400">
|
|
||||||
<p className="font-medium text-red-700 dark:text-red-300">完全禁止操作</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
对极高风险操作明确禁止执行
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="pl-3 border-l-2 border-orange-400">
|
|
||||||
<p className="font-medium text-orange-700 dark:text-orange-300">需用户确认操作</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
对中等风险操作要求用户明确同意
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import BetaNotice from '@/components/BetaNotice'
|
|
||||||
import CodeEditor from '@/components/CodeEditor'
|
|
||||||
import { CDN_CN_URL, CDN_URL } from '@/constants'
|
|
||||||
|
|
||||||
export default function CdnSetup() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-6">CDN 引入</h1>
|
|
||||||
|
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
|
||||||
通过 CDN 快速集成 page-agent,无需复杂的构建配置。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold mb-3">快速开始</h2>
|
|
||||||
|
|
||||||
<CodeEditor
|
|
||||||
className="mb-8"
|
|
||||||
code={`
|
|
||||||
// CDN: \t${CDN_URL}
|
|
||||||
// Mirror: \t${CDN_CN_URL}
|
|
||||||
<script
|
|
||||||
src="${CDN_URL}"
|
|
||||||
crossorigin="true"
|
|
||||||
type="text/javascript"
|
|
||||||
></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// window.pageAgent
|
|
||||||
</script>`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
|
|
||||||
⚠️ 注意事项
|
|
||||||
</h3>
|
|
||||||
<ul className="text-gray-600 dark:text-gray-300 space-y-1">
|
|
||||||
<li>• 生产环境建议使用固定版本号</li>
|
|
||||||
<li>• 确保 HTTPS 环境下使用</li>
|
|
||||||
<li>• 配置 CSP 策略允许脚本执行</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import CodeEditor from '@/components/CodeEditor'
|
|
||||||
|
|
||||||
export default function Configuration() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-6">配置选项</h1>
|
|
||||||
|
|
||||||
<CodeEditor
|
|
||||||
className="mb-8"
|
|
||||||
language="typescript"
|
|
||||||
code={`// config
|
|
||||||
type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
|
||||||
|
|
||||||
interface LLMConfig {
|
|
||||||
baseURL?: string
|
|
||||||
apiKey?: string
|
|
||||||
model?: string
|
|
||||||
temperature?: number
|
|
||||||
maxRetries?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom fetch function for LLM API requests.
|
|
||||||
* Use this to customize headers, credentials, proxy, etc.
|
|
||||||
* The response should follow OpenAI API format.
|
|
||||||
*/
|
|
||||||
customFetch?: typeof globalThis.fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentConfig {
|
|
||||||
language?: "en-US" | "zh-CN"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom tools to extend PageAgent capabilities
|
|
||||||
* @experimental
|
|
||||||
* @note You can also override or remove internal tools by using the same name.
|
|
||||||
* @see [tools](../tools/index.ts)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // override internal tool
|
|
||||||
* import { tool } from 'page-agent'
|
|
||||||
* const customTools = {
|
|
||||||
* ask_user: tool({
|
|
||||||
* description:
|
|
||||||
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
|
|
||||||
* inputSchema: zod.object({
|
|
||||||
* question: zod.string(),
|
|
||||||
* }),
|
|
||||||
* execute: async function (this: PageAgent, input) {
|
|
||||||
* const answer = await do_some_thing(input.question)
|
|
||||||
* return "✅ Received user answer: " + answer
|
|
||||||
* },
|
|
||||||
* })
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // remove internal tool
|
|
||||||
* const customTools = {
|
|
||||||
* ask_user: null // never ask user questions
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
customTools?: Record<string, PageAgentTool | null>
|
|
||||||
|
|
||||||
// lifecycle hooks
|
|
||||||
// @todo: use event instead of hooks
|
|
||||||
|
|
||||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
|
||||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
|
||||||
onBeforeTask?: (this: PageAgent) => Promise<void> | void
|
|
||||||
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @note this hook can block the disposal process
|
|
||||||
* @note when dispose caused by page unload, "reason" will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
|
||||||
*/
|
|
||||||
onDispose?: (this: PageAgent, reason?: string) => void
|
|
||||||
|
|
||||||
// page behavior hooks
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @experimental
|
|
||||||
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
|
||||||
* @note Can cause unpredictable side effects.
|
|
||||||
* @note May bypass some safe guards and data-masking mechanisms.
|
|
||||||
*/
|
|
||||||
experimentalScriptExecutionTool?: boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: @unimplemented
|
|
||||||
* hook when action causes a new page to be opened
|
|
||||||
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
|
|
||||||
*/
|
|
||||||
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: @unimplemented
|
|
||||||
* try to navigate to a new page instead of opening a new tab/window.
|
|
||||||
* @note will unload the current page when a action tries to open a new page. so that things keep in the same tab/window.
|
|
||||||
*/
|
|
||||||
experimentalPreventNewPage?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageControllerConfig {
|
|
||||||
interactiveBlacklist?: (Element | (() => Element))[]
|
|
||||||
interactiveWhitelist?: (Element | (() => Element))[]
|
|
||||||
include_attributes?: string[]
|
|
||||||
highlightOpacity?: number
|
|
||||||
highlightLabelOpacity?: number
|
|
||||||
viewportExpansion?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -27,14 +27,14 @@ export default {
|
|||||||
overview: 'Overview',
|
overview: 'Overview',
|
||||||
quick_start: 'Quick Start',
|
quick_start: 'Quick Start',
|
||||||
limitations: 'Limitations',
|
limitations: 'Limitations',
|
||||||
model_integration: 'Model Integration',
|
models: 'Models',
|
||||||
custom_tools: 'Custom Tools',
|
custom_tools: 'Custom Tools',
|
||||||
knowledge_injection: 'Instructions',
|
knowledge_injection: 'Instructions',
|
||||||
security_permissions: 'Security & Permissions',
|
|
||||||
data_masking: 'Data Masking',
|
data_masking: 'Data Masking',
|
||||||
cdn_setup: 'CDN Setup',
|
cdn_setup: 'CDN Setup',
|
||||||
configuration: 'Configuration',
|
configuration: 'Configuration',
|
||||||
best_practices: 'Best Practices',
|
best_practices: 'Best Practices',
|
||||||
third_party_agent: 'Third-party Agent',
|
third_party_agent: 'Third-party Agent',
|
||||||
|
security_permissions: 'Security & Permissions',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default {
|
|||||||
'page-agent is a purely web-based GUI Agent. Gives your website an AI operator in simple steps.',
|
'page-agent is a purely web-based GUI Agent. Gives your website an AI operator in simple steps.',
|
||||||
what_is: 'What is page-agent?',
|
what_is: 'What is page-agent?',
|
||||||
what_is_desc:
|
what_is_desc:
|
||||||
'page-agent is an embedded GUI Agent. Unlike traditional browser automation tools, page-agent targets web developers, not scrapers or agent clients builders. Integrate it into your site to let users interact with pages through natural language.',
|
'page-agent is an embedded GUI Agent. Unlike traditional browser automation tools, page-agent is built for web developers and web applications first. Integrate it into your site to let users interact with pages through natural language.',
|
||||||
features_title: 'Core Features',
|
features_title: 'Core Features',
|
||||||
feature_dom: {
|
feature_dom: {
|
||||||
title: '🧠 Smart DOM Analysis',
|
title: '🧠 Smart DOM Analysis',
|
||||||
@@ -112,8 +112,8 @@ export default {
|
|||||||
future_3: '• Basic visual understanding',
|
future_3: '• Basic visual understanding',
|
||||||
future_4: '• Smarter error recovery',
|
future_4: '• Smarter error recovery',
|
||||||
},
|
},
|
||||||
model_integration: {
|
models: {
|
||||||
title: 'Model Integration',
|
title: 'Models',
|
||||||
subtitle:
|
subtitle:
|
||||||
'Supports models that comply with OpenAI API specification and support tool calls, including public cloud services and private deployments.',
|
'Supports models that comply with OpenAI API specification and support tool calls, including public cloud services and private deployments.',
|
||||||
recommendation_logic: 'Recommended: Lightweight models with strong ToolCall capabilities.',
|
recommendation_logic: 'Recommended: Lightweight models with strong ToolCall capabilities.',
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ export default {
|
|||||||
overview: '概览',
|
overview: '概览',
|
||||||
quick_start: '快速开始',
|
quick_start: '快速开始',
|
||||||
limitations: '使用限制',
|
limitations: '使用限制',
|
||||||
model_integration: '模型接入',
|
models: '模型',
|
||||||
custom_tools: '自定义工具',
|
custom_tools: '自定义工具',
|
||||||
knowledge_injection: '知识注入',
|
knowledge_injection: '知识注入',
|
||||||
security_permissions: '安全与权限',
|
|
||||||
data_masking: '数据脱敏',
|
data_masking: '数据脱敏',
|
||||||
cdn_setup: 'CDN 引入',
|
cdn_setup: 'CDN 引入',
|
||||||
configuration: '配置选项',
|
configuration: '配置选项',
|
||||||
best_practices: '最佳实践',
|
best_practices: '最佳实践',
|
||||||
third_party_agent: '接入第三方 Agent',
|
third_party_agent: '接入第三方 Agent',
|
||||||
|
security_permissions: '安全与权限',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ export default {
|
|||||||
future_3: '• 基础的视觉理解能力',
|
future_3: '• 基础的视觉理解能力',
|
||||||
future_4: '• 更智能的错误恢复机制',
|
future_4: '• 更智能的错误恢复机制',
|
||||||
},
|
},
|
||||||
model_integration: {
|
models: {
|
||||||
title: '模型接入',
|
title: '模型',
|
||||||
subtitle: '当前支持符合 OpenAI 接口规范且支持 tool call 的模型,包括公有云服务和私有部署方案。',
|
subtitle: '当前支持符合 OpenAI 接口规范且支持 tool call 的模型,包括公有云服务和私有部署方案。',
|
||||||
recommendation_logic: '推荐使用 ToolCall 能力强的轻量级模型。',
|
recommendation_logic: '推荐使用 ToolCall 能力强的轻量级模型。',
|
||||||
recommended: '推荐模型',
|
recommended: '推荐模型',
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { Route, Router, Switch } from 'wouter'
|
import { Router } from 'wouter'
|
||||||
import { useHashLocation } from 'wouter/use-hash-location'
|
import { useHashLocation } from 'wouter/use-hash-location'
|
||||||
|
|
||||||
import './i18n/config'
|
import './i18n/config'
|
||||||
import './i18n/types'
|
import './i18n/types'
|
||||||
import { default as PagesRouter } from './router'
|
import { default as PagesRouter } from './router'
|
||||||
import { default as TestPagesRouter } from './test-pages/router'
|
|
||||||
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<Router hook={useHashLocation}>
|
<Router hook={useHashLocation}>
|
||||||
<Switch>
|
<PagesRouter />
|
||||||
<Route path="/test-pages" component={TestPagesRouter} nest />
|
|
||||||
<Route path="/" component={PagesRouter} nest />
|
|
||||||
</Switch>
|
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link, useSearchParams } from 'wouter'
|
import { Link, useSearchParams } from 'wouter'
|
||||||
|
|
||||||
import Footer from './components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import Header from './components/Header'
|
import Header from '../components/Header'
|
||||||
import { AnimatedGradientText } from './components/ui/animated-gradient-text'
|
import { AnimatedGradientText } from '../components/ui/animated-gradient-text'
|
||||||
import { Highlighter } from './components/ui/highlighter'
|
import { Highlighter } from '../components/ui/highlighter'
|
||||||
import { NeonGradientCard } from './components/ui/neon-gradient-card'
|
import { NeonGradientCard } from '../components/ui/neon-gradient-card'
|
||||||
import { Particles } from './components/ui/particles'
|
import { Particles } from '../components/ui/particles'
|
||||||
import { SparklesText } from './components/ui/sparkles-text'
|
import { SparklesText } from '../components/ui/sparkles-text'
|
||||||
import { CDN_CN_URL, CDN_URL } from './constants'
|
import { CDN_CN_URL, CDN_URL } from '../constants'
|
||||||
|
|
||||||
function getInjection(useCN?: boolean) {
|
function getInjection(useCN?: boolean) {
|
||||||
const cdn = useCN ? CDN_CN_URL : CDN_URL
|
const cdn = useCN ? CDN_CN_URL : CDN_URL
|
||||||
+8
-5
@@ -32,20 +32,23 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
|
|||||||
{
|
{
|
||||||
title: t('nav.features'),
|
title: t('nav.features'),
|
||||||
items: [
|
items: [
|
||||||
{ title: t('nav.model_integration'), path: '/docs/features/model-integration' },
|
{ title: t('nav.models'), path: '/docs/features/models' },
|
||||||
{ title: t('nav.custom_tools'), path: '/docs/features/custom-tools' },
|
{ title: t('nav.custom_tools'), path: '/docs/features/custom-tools' },
|
||||||
{ title: t('nav.knowledge_injection'), path: '/docs/features/custom-instructions' },
|
{ title: t('nav.knowledge_injection'), path: '/docs/features/custom-instructions' },
|
||||||
{ title: t('nav.security_permissions'), path: '/docs/features/security-permissions' },
|
|
||||||
{ title: t('nav.data_masking'), path: '/docs/features/data-masking' },
|
{ title: t('nav.data_masking'), path: '/docs/features/data-masking' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('nav.integration'),
|
title: t('nav.integration'),
|
||||||
items: [
|
items: [
|
||||||
{ title: t('nav.cdn_setup'), path: '/docs/integration/cdn-setup' },
|
|
||||||
{ title: t('nav.configuration'), path: '/docs/integration/configuration' },
|
{ title: t('nav.configuration'), path: '/docs/integration/configuration' },
|
||||||
{ title: t('nav.best_practices'), path: '/docs/integration/best-practices' },
|
|
||||||
{ title: t('nav.third_party_agent'), path: '/docs/integration/third-party-agent' },
|
{ title: t('nav.third_party_agent'), path: '/docs/integration/third-party-agent' },
|
||||||
|
{ title: '🚧 ' + t('nav.cdn_setup'), path: '/docs/integration/cdn-setup' },
|
||||||
|
{
|
||||||
|
title: '🚧 ' + t('nav.security_permissions'),
|
||||||
|
path: '/docs/integration/security-permissions',
|
||||||
|
},
|
||||||
|
{ title: '🚧 ' + t('nav.best_practices'), path: '/docs/integration/best-practices' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -55,7 +58,7 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
|
|||||||
<div className="flex gap-8 min-w-[900px]">
|
<div className="flex gap-8 min-w-[900px]">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-64 shrink-0" role="complementary" aria-label="文档导航">
|
<aside className="w-64 shrink-0" role="complementary" aria-label="文档导航">
|
||||||
<div className="sticky top-8">
|
<div className="sticky">
|
||||||
<nav className="space-y-8" role="navigation" aria-label="文档章节">
|
<nav className="space-y-8" role="navigation" aria-label="文档章节">
|
||||||
{navigationSections.map((section) => (
|
{navigationSections.map((section) => (
|
||||||
<section key={section.title}>
|
<section key={section.title}>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import CodeEditor from '@/components/CodeEditor'
|
||||||
|
|
||||||
|
export default function DataMasking() {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const isZh = i18n.language === 'zh-CN'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold mb-6">{isZh ? '数据脱敏' : 'Data Masking'}</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||||
|
{isZh
|
||||||
|
? '使用 transformPageContent 钩子在页面内容发送给 LLM 之前进行处理,可用于检查清洗效果、修改页面信息、隐藏敏感数据等。'
|
||||||
|
: 'Use the transformPageContent hook to process page content before sending to LLM. Useful for inspecting extraction results, modifying page info, and masking sensitive data.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="text-3xl font-bold mb-6">{isZh ? '接口定义' : 'API Definition'}</h2>
|
||||||
|
|
||||||
|
<CodeEditor
|
||||||
|
className="mb-6"
|
||||||
|
code={`interface PageAgentConfig {
|
||||||
|
/**
|
||||||
|
* Transform page content before sending to LLM.
|
||||||
|
* Called after DOM extraction and simplification.
|
||||||
|
*/
|
||||||
|
transformPageContent?: (content: string) => Promise<string> | string
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="text-3xl font-bold mb-6">
|
||||||
|
{isZh ? '常用脱敏规则' : 'Common Masking Patterns'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||||
|
{isZh
|
||||||
|
? '以下示例展示了如何脱敏常见的敏感信息:'
|
||||||
|
: 'The following example shows how to mask common sensitive data:'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeEditor
|
||||||
|
code={`const agent = new PageAgent({
|
||||||
|
transformPageContent: async (content) => {
|
||||||
|
// China phone number (11 digits starting with 1)
|
||||||
|
content = content.replace(/\\b(1[3-9]\\d)(\\d{4})(\\d{4})\\b/g, '$1****$3')
|
||||||
|
|
||||||
|
// Email address
|
||||||
|
content = content.replace(
|
||||||
|
/\\b([a-zA-Z0-9._%+-])[^@]*(@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})\\b/g,
|
||||||
|
'$1***$2'
|
||||||
|
)
|
||||||
|
|
||||||
|
// China ID card number (18 digits)
|
||||||
|
content = content.replace(
|
||||||
|
/\\b(\\d{6})(19|20\\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])(\\d{3}[\\dXx])\\b/g,
|
||||||
|
'$1********$5'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bank card number (16-19 digits)
|
||||||
|
content = content.replace(/\\b(\\d{4})\\d{8,11}(\\d{4})\\b/g, '$1********$2')
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
})`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+25
-21
@@ -4,13 +4,19 @@ import CodeEditor from '@/components/CodeEditor'
|
|||||||
|
|
||||||
// Recommended models: lightweight with excellent tool call capabilities
|
// Recommended models: lightweight with excellent tool call capabilities
|
||||||
const MODELS = {
|
const MODELS = {
|
||||||
recommended: ['gpt-4.1-mini', 'claude-haiku-4.5', 'gemini-3-flash', 'deepseek-3.2', 'gpt-5.2'],
|
recommended: [
|
||||||
verified: [
|
'gpt-4.1-mini',
|
||||||
|
'claude-haiku-4.5',
|
||||||
|
'gemini-3-flash',
|
||||||
|
'deepseek-3.2',
|
||||||
|
'gpt-5.2',
|
||||||
'qwen-3-max',
|
'qwen-3-max',
|
||||||
|
],
|
||||||
|
verified: [
|
||||||
|
'qwen-3-plus',
|
||||||
'gpt-4.1',
|
'gpt-4.1',
|
||||||
'gpt-5',
|
'gpt-5',
|
||||||
'gpt-5-mini',
|
'gpt-5-mini',
|
||||||
'gpt-5.1',
|
|
||||||
'grok-4',
|
'grok-4',
|
||||||
'grok-code-fast',
|
'grok-code-fast',
|
||||||
'claude-sonnet-3.5',
|
'claude-sonnet-3.5',
|
||||||
@@ -21,22 +27,20 @@ const MODELS = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModelIntegration() {
|
export default function Models() {
|
||||||
const { t } = useTranslation('docs')
|
const { t } = useTranslation('docs')
|
||||||
const allModels = [...MODELS.recommended, ...MODELS.verified]
|
const allModels = [...MODELS.recommended, ...MODELS.verified]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<h1 className="text-4xl font-bold mb-4">{t('model_integration.title')}</h1>
|
<h1 className="text-4xl font-bold mb-4">{t('models.title')}</h1>
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">{t('models.subtitle')}</p>
|
||||||
{t('model_integration.subtitle')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Models Section */}
|
{/* Models Section */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-3">{t('model_integration.available')}</h2>
|
<h2 className="text-2xl font-semibold mb-3">{t('models.available')}</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
{t('model_integration.recommendation_logic')}
|
{t('models.recommendation_logic')}
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50">
|
<div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -63,41 +67,41 @@ export default function ModelIntegration() {
|
|||||||
|
|
||||||
{/* Tips Section */}
|
{/* Tips Section */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">{t('model_integration.tips')}</h2>
|
<h2 className="text-2xl font-semibold mb-4">{t('models.tips')}</h2>
|
||||||
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
|
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
|
||||||
<li>{t('model_integration.tip_2')}</li>
|
<li>{t('models.tip_2')}</li>
|
||||||
<li>{t('model_integration.tip_3')}</li>
|
<li>{t('models.tip_3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Security Section */}
|
{/* Security Section */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">{t('model_integration.security')}</h2>
|
<h2 className="text-2xl font-semibold mb-4">{t('models.security')}</h2>
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-5 rounded-r-lg mb-4">
|
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-5 rounded-r-lg mb-4">
|
||||||
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
|
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
|
||||||
{t('model_integration.security_warning')}
|
{t('models.security_warning')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg p-5 border border-gray-200 dark:border-gray-800">
|
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg p-5 border border-gray-200 dark:border-gray-800">
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
{t('model_integration.security_backend_proxy')}
|
{t('models.security_backend_proxy')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
{t('model_integration.security_backend_desc')}
|
{t('models.security_backend_desc')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
<li>{t('model_integration.security_method_1')}</li>
|
<li>{t('models.security_method_1')}</li>
|
||||||
<li>{t('model_integration.security_method_2')}</li>
|
<li>{t('models.security_method_2')}</li>
|
||||||
<li>{t('model_integration.security_method_3')}</li>
|
<li>{t('models.security_method_3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Configuration Section */}
|
{/* Configuration Section */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">{t('model_integration.configuration')}</h2>
|
<h2 className="text-2xl font-semibold mb-4">{t('models.configuration')}</h2>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
code={`// OpenAI-compatible services (e.g., Alibaba Bailian)
|
code={`// OpenAI-compatible services (e.g., Alibaba Bailian)
|
||||||
const pageAgent = new PageAgent({
|
const pageAgent = new PageAgent({
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import BetaNotice from '@/components/BetaNotice'
|
||||||
|
import CodeEditor from '@/components/CodeEditor'
|
||||||
|
import { CDN_CN_URL, CDN_URL } from '@/constants'
|
||||||
|
|
||||||
|
export default function CdnSetup() {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const isZh = i18n.language === 'zh-CN'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BetaNotice />
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold mb-6">{isZh ? 'CDN 引入' : 'CDN Setup'}</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||||
|
{isZh
|
||||||
|
? '通过 CDN 快速集成 page-agent,无需复杂的构建配置。默认使用演示模型。'
|
||||||
|
: 'Fastest way to integrate page-agent via CDN. No complex build setup needed. Demo model will be used by default.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{isZh ? 'CDN 地址' : 'CDN URLs'}</h2>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto mb-6">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-3 px-4 font-semibold">{isZh ? '位置' : 'Location'}</th>
|
||||||
|
<th className="text-left py-3 px-4 font-semibold">URL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<td className="py-3 px-4">{isZh ? '全球' : 'Global'}</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-xs break-all">{CDN_URL}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<td className="py-3 px-4">{isZh ? '中国镜像' : 'China Mirror'}</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-xs break-all">{CDN_CN_URL}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{isZh ? '快速开始' : 'Quick Start'}</h2>
|
||||||
|
|
||||||
|
<CodeEditor
|
||||||
|
className="mb-6"
|
||||||
|
code={`<script
|
||||||
|
src="${CDN_URL}"
|
||||||
|
crossorigin="true"
|
||||||
|
type="text/javascript"
|
||||||
|
></script>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
|
||||||
|
⚠️ {isZh ? '注意事项' : 'Notes'}
|
||||||
|
</h3>
|
||||||
|
<ul className="text-gray-600 dark:text-gray-300 space-y-1">
|
||||||
|
<li>
|
||||||
|
• {isZh ? '生产环境建议使用固定版本号' : 'Use fixed version number in production'}
|
||||||
|
</li>
|
||||||
|
<li>• {isZh ? '确保 HTTPS 环境下使用' : 'Ensure HTTPS environment'}</li>
|
||||||
|
<li>
|
||||||
|
•{' '}
|
||||||
|
{isZh
|
||||||
|
? '配置 CSP 策略允许脚本执行'
|
||||||
|
: 'Configure CSP policy to allow script execution'}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import CodeEditor from '@/components/CodeEditor'
|
||||||
|
|
||||||
|
export default function Configuration() {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const isZh = i18n.language === 'zh-CN'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold mb-6">{isZh ? '配置选项' : 'Configuration'}</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||||
|
{isZh
|
||||||
|
? 'PageAgent 的完整配置接口定义。'
|
||||||
|
: 'Complete configuration interface for PageAgent.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* LLM Configuration */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">{isZh ? 'LLM 配置' : 'LLM Configuration'}</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
{isZh
|
||||||
|
? '配置与大语言模型的连接参数。'
|
||||||
|
: 'Configure connection parameters for the language model.'}
|
||||||
|
</p>
|
||||||
|
<CodeEditor
|
||||||
|
className="mb-4"
|
||||||
|
language="typescript"
|
||||||
|
code={`interface LLMConfig {
|
||||||
|
baseURL?: string
|
||||||
|
apiKey?: string
|
||||||
|
model?: string
|
||||||
|
temperature?: number
|
||||||
|
maxRetries?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom fetch function for LLM API requests.
|
||||||
|
* Use this to customize headers, credentials, proxy, etc.
|
||||||
|
*/
|
||||||
|
customFetch?: typeof globalThis.fetch
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Agent Configuration */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">
|
||||||
|
{isZh ? 'Agent 配置' : 'Agent Configuration'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
{isZh
|
||||||
|
? '配置 Agent 的行为、生命周期钩子和扩展能力。'
|
||||||
|
: 'Configure agent behavior, lifecycle hooks, and extension capabilities.'}
|
||||||
|
</p>
|
||||||
|
<CodeEditor
|
||||||
|
className="mb-4"
|
||||||
|
language="typescript"
|
||||||
|
code={`interface AgentConfig {
|
||||||
|
language?: 'en-US' | 'zh-CN'
|
||||||
|
|
||||||
|
/** Custom tools to extend or override built-in tools */
|
||||||
|
customTools?: Record<string, PageAgentTool | null>
|
||||||
|
|
||||||
|
/** Instructions to guide the agent's behavior */
|
||||||
|
instructions?: {
|
||||||
|
/** Global system-level instructions, applied to all tasks */
|
||||||
|
system?: string
|
||||||
|
|
||||||
|
/** Dynamic page-level instructions callback */
|
||||||
|
getPageInstructions?: (url: string) => string | undefined | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle hooks
|
||||||
|
onBeforeStep?: (stepCnt: number) => Promise<void> | void
|
||||||
|
onAfterStep?: (stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
||||||
|
onBeforeTask?: () => Promise<void> | void
|
||||||
|
onAfterTask?: (result: ExecutionResult) => Promise<void> | void
|
||||||
|
onDispose?: (reason?: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform page content before sending to LLM.
|
||||||
|
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||||
|
*/
|
||||||
|
transformPageContent?: (content: string) => Promise<string> | string
|
||||||
|
|
||||||
|
/** @experimental Enable JavaScript execution tool */
|
||||||
|
experimentalScriptExecutionTool?: boolean
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* PageController Configuration */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">
|
||||||
|
{isZh ? 'PageController 配置' : 'PageController Configuration'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
{isZh
|
||||||
|
? '配置 DOM 提取、元素交互和视觉高亮的细节。'
|
||||||
|
: 'Configure DOM extraction, element interaction, and visual highlighting.'}
|
||||||
|
</p>
|
||||||
|
<CodeEditor
|
||||||
|
className="mb-4"
|
||||||
|
language="typescript"
|
||||||
|
code={`interface PageControllerConfig {
|
||||||
|
/** Elements to exclude from interaction */
|
||||||
|
interactiveBlacklist?: (Element | (() => Element))[]
|
||||||
|
|
||||||
|
/** Elements to force include for interaction */
|
||||||
|
interactiveWhitelist?: (Element | (() => Element))[]
|
||||||
|
|
||||||
|
/** Additional attributes to include in DOM extraction */
|
||||||
|
include_attributes?: string[]
|
||||||
|
|
||||||
|
/** Highlight overlay opacity (0-1) */
|
||||||
|
highlightOpacity?: number
|
||||||
|
|
||||||
|
/** Highlight label opacity (0-1) */
|
||||||
|
highlightLabelOpacity?: number
|
||||||
|
|
||||||
|
/** Viewport expansion in pixels (-1 for full page) */
|
||||||
|
viewportExpansion?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable visual mask overlay during automation.
|
||||||
|
* Blocks user interaction while agent is running.
|
||||||
|
* Default: false for PageController, true for PageAgent.
|
||||||
|
*/
|
||||||
|
enableMask?: boolean
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Complete Type */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">{isZh ? '完整类型' : 'Complete Type'}</h2>
|
||||||
|
<CodeEditor
|
||||||
|
language="typescript"
|
||||||
|
code={`type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import BetaNotice from '@/components/BetaNotice'
|
||||||
|
|
||||||
|
export default function SecurityPermissions() {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const isZh = i18n.language === 'zh-CN'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BetaNotice />
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold mb-6">{isZh ? '安全与权限' : 'Security & Permissions'}</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||||
|
{isZh
|
||||||
|
? 'page-agent 提供多种安全机制,确保 AI 操作在可控范围内进行。'
|
||||||
|
: 'page-agent provides multiple security mechanisms to ensure AI operations stay within controlled boundaries.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold mb-3">
|
||||||
|
{isZh ? '元素操作黑白名单' : 'Element Interaction Allowlist/Blocklist'}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-red-900 dark:text-red-300">
|
||||||
|
🚫 {isZh ? '操作黑名单' : 'Blocklist'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
|
{isZh
|
||||||
|
? '禁止 AI 操作敏感元素,如删除按钮、支付按钮等。'
|
||||||
|
: 'Prevent AI from interacting with sensitive elements like delete buttons, payment buttons, etc.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-green-900 dark:text-green-300">
|
||||||
|
✅ {isZh ? '操作白名单' : 'Allowlist'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
|
{isZh
|
||||||
|
? '明确定义 AI 可以操作的元素范围。'
|
||||||
|
: 'Explicitly define which elements AI can interact with.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold mb-3">
|
||||||
|
{isZh ? 'Instruction 安全约束' : 'Instruction Safety Constraints'}
|
||||||
|
</h2>
|
||||||
|
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
|
||||||
|
⚠️ {isZh ? '高危操作控制' : 'High-Risk Operation Control'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-3">
|
||||||
|
{isZh
|
||||||
|
? '在 AI 指令中明确列举高危操作,通过两种策略进行控制:'
|
||||||
|
: 'Define high-risk operations in AI instructions and control them through two strategies:'}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="pl-3 border-l-2 border-red-400">
|
||||||
|
<p className="font-medium text-red-700 dark:text-red-300">
|
||||||
|
{isZh ? '完全禁止操作' : 'Completely Forbidden'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{isZh
|
||||||
|
? '对极高风险操作明确禁止执行'
|
||||||
|
: 'Explicitly prohibit execution of extremely high-risk operations'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="pl-3 border-l-2 border-orange-400">
|
||||||
|
<p className="font-medium text-orange-700 dark:text-orange-300">
|
||||||
|
{isZh ? '需用户确认操作' : 'Requires User Confirmation'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{isZh
|
||||||
|
? '对中等风险操作要求用户明确同意'
|
||||||
|
: 'Require explicit user consent for medium-risk operations'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
-26
@@ -74,19 +74,6 @@ const pageAgentTool = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold mb-4">最佳实践</h2>
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">错误处理</h3>
|
|
||||||
<CodeEditor code={`// @TODO`} language="javascript" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">权限控制</h3>
|
|
||||||
<CodeEditor code={`// @TODO`} language="javascript" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||||
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
|
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
|
||||||
⚠️ 注意事项
|
⚠️ 注意事项
|
||||||
@@ -97,19 +84,6 @@ const pageAgentTool = {
|
|||||||
<li>• 敏感操作建议要求人工确认</li>
|
<li>• 敏感操作建议要求人工确认</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-linear-to-r from-green-50 to-blue-50 dark:from-green-900/20 dark:to-blue-900/20 p-4 rounded-lg">
|
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">🎉 开始集成</h3>
|
|
||||||
<p className="mb-3 text-gray-700 dark:text-gray-300">
|
|
||||||
通过这种方式,你的 Agent 系统就能真正成为用户的智能助手。
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/docs/integration/configuration"
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
查看配置选项 →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+2
-2
@@ -6,13 +6,13 @@ export default function Overview() {
|
|||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
{/* 头图 */}
|
{/* 头图 */}
|
||||||
<figure className="mb-8 rounded-xl overflow-hidden">
|
{/* <figure className="mb-8 rounded-xl overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="https://img.alicdn.com/imgextra/i3/O1CN014xLeic1gnF2iWdzjJ_!!6000000004186-49-tps-1280-353.webp"
|
src="https://img.alicdn.com/imgextra/i3/O1CN014xLeic1gnF2iWdzjJ_!!6000000004186-49-tps-1280-353.webp"
|
||||||
alt="page-agent"
|
alt="page-agent"
|
||||||
className="w-full h-64 object-cover"
|
className="w-full h-64 object-cover"
|
||||||
/>
|
/>
|
||||||
</figure>
|
</figure> */}
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold mb-4">{t('overview.title')}</h1>
|
<h1 className="text-4xl font-bold mb-4">{t('overview.title')}</h1>
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
import { Route, Switch } from 'wouter'
|
import { Route, Switch } from 'wouter'
|
||||||
|
|
||||||
import DocsLayout from './components/DocsLayout'
|
|
||||||
import Header from './components/Header'
|
import Header from './components/Header'
|
||||||
import Instructions from './docs/features/custom-instructions/page'
|
import HomePage from './pages/Home'
|
||||||
|
import DocsLayout from './pages/docs/Layout'
|
||||||
// Features pages
|
// Features pages
|
||||||
import CustomTools from './docs/features/custom-tools/page'
|
import Instructions from './pages/docs/features/custom-instructions/page'
|
||||||
import DataMasking from './docs/features/data-masking/page'
|
import CustomTools from './pages/docs/features/custom-tools/page'
|
||||||
import ModelIntegration from './docs/features/model-integration/page'
|
import DataMasking from './pages/docs/features/data-masking/page'
|
||||||
import SecurityPermissions from './docs/features/security-permissions/page'
|
import Models from './pages/docs/features/models/page'
|
||||||
import BestPractices from './docs/integration/best-practices/page'
|
|
||||||
// Integration pages
|
// Integration pages
|
||||||
import CdnSetup from './docs/integration/cdn-setup/page'
|
import BestPractices from './pages/docs/integration/best-practices/page'
|
||||||
import Configuration from './docs/integration/configuration/page'
|
import CdnSetup from './pages/docs/integration/cdn-setup/page'
|
||||||
import ThirdPartyAgent from './docs/integration/third-party-agent/page'
|
import Configuration from './pages/docs/integration/configuration/page'
|
||||||
import Limitations from './docs/introduction/limitations/page'
|
import SecurityPermissions from './pages/docs/integration/security-permissions/page'
|
||||||
|
import ThirdPartyAgent from './pages/docs/integration/third-party-agent/page'
|
||||||
// Introduction pages
|
// Introduction pages
|
||||||
import Overview from './docs/introduction/overview/page'
|
import Limitations from './pages/docs/introduction/limitations/page'
|
||||||
import QuickStart from './docs/introduction/quick-start/page'
|
import Overview from './pages/docs/introduction/overview/page'
|
||||||
import HomePage from './page'
|
import QuickStart from './pages/docs/introduction/quick-start/page'
|
||||||
|
|
||||||
export default function Router() {
|
export default function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -53,15 +53,6 @@ export default function Router() {
|
|||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/docs/features/security-permissions">
|
|
||||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
|
||||||
<Header />
|
|
||||||
<DocsLayout>
|
|
||||||
<SecurityPermissions />
|
|
||||||
</DocsLayout>
|
|
||||||
</div>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/docs/features/custom-tools">
|
<Route path="/docs/features/custom-tools">
|
||||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||||
<Header />
|
<Header />
|
||||||
@@ -89,11 +80,11 @@ export default function Router() {
|
|||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/docs/features/model-integration">
|
<Route path="/docs/features/models">
|
||||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||||
<Header />
|
<Header />
|
||||||
<DocsLayout>
|
<DocsLayout>
|
||||||
<ModelIntegration />
|
<Models />
|
||||||
</DocsLayout>
|
</DocsLayout>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
@@ -107,6 +98,15 @@ export default function Router() {
|
|||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/docs/integration/security-permissions">
|
||||||
|
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||||
|
<Header />
|
||||||
|
<DocsLayout>
|
||||||
|
<SecurityPermissions />
|
||||||
|
</DocsLayout>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/docs/integration/configuration">
|
<Route path="/docs/integration/configuration">
|
||||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||||
<Header />
|
<Header />
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
# Page Use Agent 测试页面
|
|
||||||
|
|
||||||
这个目录包含了一系列专门设计的测试页面,用于验证 Page Use Agent 的各种能力。每个页面都模拟了真实 Web 应用中的常见交互模式和边界情况。
|
|
||||||
|
|
||||||
## 测试页面列表
|
|
||||||
|
|
||||||
### 1. 表单测试页面 (`/test-pages/form`)
|
|
||||||
**测试目标:** 表单填写、验证和提交能力
|
|
||||||
|
|
||||||
**包含功能:**
|
|
||||||
- 各种输入类型(文本、邮箱、密码、数字、日期、电话、URL)
|
|
||||||
- 下拉选择框和复选框
|
|
||||||
- 实时表单验证
|
|
||||||
- 异步提交和错误处理
|
|
||||||
- 重置表单功能
|
|
||||||
|
|
||||||
**测试任务示例:**
|
|
||||||
- 填写完整的用户注册表单并提交
|
|
||||||
- 故意输入错误信息触发验证错误
|
|
||||||
- 测试密码确认功能
|
|
||||||
- 尝试提交空表单查看错误提示
|
|
||||||
|
|
||||||
### 2. 导航测试页面 (`/test-pages/navigation`)
|
|
||||||
**测试目标:** 复杂导航和交互元素处理
|
|
||||||
|
|
||||||
**包含功能:**
|
|
||||||
- 顶部导航栏和下拉菜单
|
|
||||||
- 面包屑导航
|
|
||||||
- 标签页切换
|
|
||||||
- 模态框弹窗
|
|
||||||
- 通知系统
|
|
||||||
- 用户菜单
|
|
||||||
|
|
||||||
**测试任务示例:**
|
|
||||||
- 点击产品下拉菜单选择不同选项
|
|
||||||
- 切换不同的标签页查看内容
|
|
||||||
- 打开和关闭模态框
|
|
||||||
- 点击面包屑导航
|
|
||||||
- 添加新通知并标记为已读
|
|
||||||
|
|
||||||
### 3. 列表测试页面 (`/test-pages/list`)
|
|
||||||
**测试目标:** 列表操作、搜索、过滤和分页
|
|
||||||
|
|
||||||
**包含功能:**
|
|
||||||
- 产品列表展示(网格和列表视图)
|
|
||||||
- 搜索功能
|
|
||||||
- 类别过滤
|
|
||||||
- 排序功能
|
|
||||||
- 分页导航
|
|
||||||
- 加载状态和骨架屏
|
|
||||||
|
|
||||||
**测试任务示例:**
|
|
||||||
- 搜索特定产品名称
|
|
||||||
- 按价格排序产品列表
|
|
||||||
- 切换网格和列表视图
|
|
||||||
- 使用分页浏览不同页面
|
|
||||||
- 按类别过滤产品
|
|
||||||
|
|
||||||
### 4. 复杂交互测试页面 (`/test-pages/complex`)
|
|
||||||
**测试目标:** 多步骤流程和状态管理
|
|
||||||
|
|
||||||
**包含功能:**
|
|
||||||
- 购物车管理(添加、删除、修改数量)
|
|
||||||
- 多步骤向导流程
|
|
||||||
- 步骤验证和导航
|
|
||||||
- 订单确认流程
|
|
||||||
- 异步提交处理
|
|
||||||
|
|
||||||
**测试任务示例:**
|
|
||||||
- 完成完整的购买流程
|
|
||||||
- 在向导中前进和后退
|
|
||||||
- 修改购物车中的商品数量
|
|
||||||
- 添加新商品到购物车
|
|
||||||
- 提交订单并处理可能的错误
|
|
||||||
|
|
||||||
### 5. 错误处理测试页面 (`/test-pages/errors`)
|
|
||||||
**测试目标:** 错误识别和重试机制
|
|
||||||
|
|
||||||
**包含功能:**
|
|
||||||
- 网络连接错误模拟
|
|
||||||
- 表单验证错误
|
|
||||||
- 权限不足错误
|
|
||||||
- 请求超时错误
|
|
||||||
- 服务器内部错误
|
|
||||||
- 文件上传错误处理
|
|
||||||
|
|
||||||
**测试任务示例:**
|
|
||||||
- 触发网络错误并重试
|
|
||||||
- 提交不完整表单查看验证错误
|
|
||||||
- 测试权限验证(用户名需为"admin")
|
|
||||||
- 上传超大文件触发错误
|
|
||||||
- 处理各种错误场景的重试逻辑
|
|
||||||
|
|
||||||
### 6. 异步操作测试页面 (`/test-pages/async`)
|
|
||||||
**测试目标:** 等待和异步操作处理
|
|
||||||
|
|
||||||
**包含功能:**
|
|
||||||
- 文件上传进度条
|
|
||||||
- 实时数据更新
|
|
||||||
- 数据加载骨架屏
|
|
||||||
- 长时间运行任务
|
|
||||||
- 进度跟踪和日志显示
|
|
||||||
|
|
||||||
**测试任务示例:**
|
|
||||||
- 启动文件上传并等待完成
|
|
||||||
- 开启实时数据更新功能
|
|
||||||
- 加载数据并等待所有项目完成
|
|
||||||
- 执行长时间任务并监控进度
|
|
||||||
- 处理上传失败的重试
|
|
||||||
|
|
||||||
## 测试任务集合
|
|
||||||
|
|
||||||
### 基础操作测试
|
|
||||||
1. **导航测试**
|
|
||||||
- 前往表单测试页面
|
|
||||||
- 返回测试页面首页
|
|
||||||
- 前往导航测试页面
|
|
||||||
|
|
||||||
2. **表单填写测试**
|
|
||||||
- 填写用户注册表单的所有必填字段
|
|
||||||
- 提交表单并等待结果
|
|
||||||
- 重置表单并重新填写
|
|
||||||
|
|
||||||
3. **搜索和过滤测试**
|
|
||||||
- 在列表页面搜索"Apple"
|
|
||||||
- 按价格降序排列产品
|
|
||||||
- 过滤显示"手机"类别的产品
|
|
||||||
|
|
||||||
### 中级交互测试
|
|
||||||
4. **购物流程测试**
|
|
||||||
- 前往复杂交互页面
|
|
||||||
- 添加商品到购物车
|
|
||||||
- 完成多步骤购买流程
|
|
||||||
- 填写个人信息、地址和支付信息
|
|
||||||
- 提交订单
|
|
||||||
|
|
||||||
5. **导航和菜单测试**
|
|
||||||
- 点击产品下拉菜单选择"手机"
|
|
||||||
- 切换到"订单管理"标签页
|
|
||||||
- 打开模态框并关闭
|
|
||||||
- 添加新的面包屑导航
|
|
||||||
|
|
||||||
6. **异步操作测试**
|
|
||||||
- 启动文件上传
|
|
||||||
- 开启实时数据更新
|
|
||||||
- 执行长时间任务并等待完成
|
|
||||||
|
|
||||||
### 高级错误处理测试
|
|
||||||
7. **错误恢复测试**
|
|
||||||
- 触发网络连接错误
|
|
||||||
- 重试失败的操作
|
|
||||||
- 处理表单验证错误
|
|
||||||
- 测试权限验证(用户名输入"admin")
|
|
||||||
|
|
||||||
8. **边界情况测试**
|
|
||||||
- 提交空表单查看错误
|
|
||||||
- 上传不支持的文件类型
|
|
||||||
- 在向导中跳过必填步骤
|
|
||||||
- 处理超时错误
|
|
||||||
|
|
||||||
### 综合场景测试
|
|
||||||
9. **完整用户流程**
|
|
||||||
- 浏览产品列表
|
|
||||||
- 搜索并过滤产品
|
|
||||||
- 添加产品到购物车
|
|
||||||
- 完成购买流程
|
|
||||||
- 处理可能出现的错误
|
|
||||||
|
|
||||||
10. **压力和边界测试**
|
|
||||||
- 快速连续点击按钮
|
|
||||||
- 在加载过程中尝试其他操作
|
|
||||||
- 测试各种错误恢复场景
|
|
||||||
- 验证所有异步操作的完成
|
|
||||||
|
|
||||||
## 使用说明
|
|
||||||
|
|
||||||
### 对于 Agent 开发者
|
|
||||||
- 每个页面都包含了详细的状态指示器和反馈信息
|
|
||||||
- 错误信息清晰明确,便于 Agent 理解和处理
|
|
||||||
- 异步操作都有明确的完成标志
|
|
||||||
- 所有交互元素都有适当的可访问性标记
|
|
||||||
|
|
||||||
### 对于测试人员
|
|
||||||
- 可以按照测试任务逐一验证 Agent 的能力
|
|
||||||
- 每个页面都是独立的,可以单独测试
|
|
||||||
- 包含了各种真实场景的模拟
|
|
||||||
- 错误场景是随机的,确保测试的真实性
|
|
||||||
|
|
||||||
### 技术特性
|
|
||||||
- 使用 React + TypeScript 构建
|
|
||||||
- 响应式设计,支持不同屏幕尺寸
|
|
||||||
- 深色模式支持
|
|
||||||
- 无需外部依赖,完全自包含
|
|
||||||
- 模拟真实的网络延迟和错误
|
|
||||||
|
|
||||||
## 扩展建议
|
|
||||||
|
|
||||||
如需添加新的测试场景,建议考虑以下方面:
|
|
||||||
- 特定行业的业务流程
|
|
||||||
- 更复杂的数据可视化交互
|
|
||||||
- 多媒体内容处理
|
|
||||||
- 实时协作功能
|
|
||||||
- 移动端特有的交互模式
|
|
||||||
|
|
||||||
每个新页面都应该:
|
|
||||||
- 有明确的测试目标
|
|
||||||
- 包含多种难度级别的任务
|
|
||||||
- 提供清晰的状态反馈
|
|
||||||
- 模拟真实的用户场景
|
|
||||||
@@ -1,543 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Link } from 'wouter'
|
|
||||||
|
|
||||||
interface UploadProgress {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
progress: number
|
|
||||||
status: 'uploading' | 'completed' | 'error'
|
|
||||||
speed: string
|
|
||||||
timeRemaining: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataItem {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
timestamp: string
|
|
||||||
status: 'loading' | 'loaded' | 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AsyncTestPage() {
|
|
||||||
const [uploads, setUploads] = useState<UploadProgress[]>([])
|
|
||||||
const [dataItems, setDataItems] = useState<DataItem[]>([])
|
|
||||||
const [isLoadingData, setIsLoadingData] = useState(false)
|
|
||||||
const [realTimeData, setRealTimeData] = useState<string[]>([])
|
|
||||||
const [isRealTimeActive, setIsRealTimeActive] = useState(false)
|
|
||||||
const [longRunningTask, setLongRunningTask] = useState<{
|
|
||||||
isRunning: boolean
|
|
||||||
progress: number
|
|
||||||
currentStep: string
|
|
||||||
logs: string[]
|
|
||||||
}>({
|
|
||||||
isRunning: false,
|
|
||||||
progress: 0,
|
|
||||||
currentStep: '',
|
|
||||||
logs: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 模拟实时数据更新
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: NodeJS.Timeout
|
|
||||||
if (isRealTimeActive) {
|
|
||||||
interval = setInterval(() => {
|
|
||||||
const newData = `数据更新 ${new Date().toLocaleTimeString()}: ${Math.floor(Math.random() * 1000)}`
|
|
||||||
setRealTimeData((prev) => [newData, ...prev.slice(0, 9)]) // 保持最新10条
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (interval) clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [isRealTimeActive])
|
|
||||||
|
|
||||||
// 模拟文件上传
|
|
||||||
const simulateFileUpload = (fileName: string) => {
|
|
||||||
const uploadId = Date.now().toString()
|
|
||||||
const newUpload: UploadProgress = {
|
|
||||||
id: uploadId,
|
|
||||||
name: fileName,
|
|
||||||
progress: 0,
|
|
||||||
status: 'uploading',
|
|
||||||
speed: '0 KB/s',
|
|
||||||
timeRemaining: '计算中...',
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploads((prev) => [...prev, newUpload])
|
|
||||||
|
|
||||||
// 模拟上传进度
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setUploads((prev) =>
|
|
||||||
prev.map((upload) => {
|
|
||||||
if (upload.id === uploadId) {
|
|
||||||
const newProgress = Math.min(upload.progress + Math.random() * 15, 100)
|
|
||||||
const speed = `${(Math.random() * 500 + 100).toFixed(0)} KB/s`
|
|
||||||
const timeRemaining =
|
|
||||||
newProgress >= 100 ? '完成' : `${Math.ceil((100 - newProgress) / 10)}秒`
|
|
||||||
|
|
||||||
// 模拟随机失败
|
|
||||||
if (newProgress > 50 && Math.random() < 0.1) {
|
|
||||||
clearInterval(interval)
|
|
||||||
return {
|
|
||||||
...upload,
|
|
||||||
status: 'error' as const,
|
|
||||||
speed: '0 KB/s',
|
|
||||||
timeRemaining: '失败',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newProgress >= 100) {
|
|
||||||
clearInterval(interval)
|
|
||||||
return {
|
|
||||||
...upload,
|
|
||||||
progress: 100,
|
|
||||||
status: 'completed' as const,
|
|
||||||
speed,
|
|
||||||
timeRemaining,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...upload,
|
|
||||||
progress: newProgress,
|
|
||||||
speed,
|
|
||||||
timeRemaining,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return upload
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟数据加载
|
|
||||||
const loadData = async () => {
|
|
||||||
setIsLoadingData(true)
|
|
||||||
setDataItems([])
|
|
||||||
|
|
||||||
// 创建骨架屏数据
|
|
||||||
const skeletonItems: DataItem[] = Array.from({ length: 6 }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
title: '',
|
|
||||||
content: '',
|
|
||||||
timestamp: '',
|
|
||||||
status: 'loading',
|
|
||||||
}))
|
|
||||||
setDataItems(skeletonItems)
|
|
||||||
|
|
||||||
// 逐个加载数据项
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 800 + Math.random() * 1000))
|
|
||||||
|
|
||||||
setDataItems((prev) =>
|
|
||||||
prev.map((item) => {
|
|
||||||
if (item.id === i) {
|
|
||||||
// 模拟随机加载失败
|
|
||||||
if (Math.random() < 0.15) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
status: 'error',
|
|
||||||
title: '加载失败',
|
|
||||||
content: '数据加载失败,请重试',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
status: 'loaded',
|
|
||||||
title: `数据项 ${i + 1}`,
|
|
||||||
content: `这是第 ${i + 1} 个数据项的内容,包含了一些示例文本用于展示加载效果。`,
|
|
||||||
timestamp: new Date().toLocaleString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingData(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟长时间运行的任务
|
|
||||||
const startLongRunningTask = async () => {
|
|
||||||
setLongRunningTask({
|
|
||||||
isRunning: true,
|
|
||||||
progress: 0,
|
|
||||||
currentStep: '初始化任务...',
|
|
||||||
logs: ['任务开始'],
|
|
||||||
})
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ name: '初始化任务...', duration: 2000 },
|
|
||||||
{ name: '连接服务器...', duration: 1500 },
|
|
||||||
{ name: '验证权限...', duration: 1000 },
|
|
||||||
{ name: '下载数据...', duration: 3000 },
|
|
||||||
{ name: '处理数据...', duration: 2500 },
|
|
||||||
{ name: '生成报告...', duration: 2000 },
|
|
||||||
{ name: '保存结果...', duration: 1000 },
|
|
||||||
{ name: '清理资源...', duration: 500 },
|
|
||||||
]
|
|
||||||
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
|
||||||
const step = steps[i]
|
|
||||||
|
|
||||||
setLongRunningTask((prev) => ({
|
|
||||||
...prev,
|
|
||||||
currentStep: step.name,
|
|
||||||
logs: [...prev.logs, `开始: ${step.name}`],
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 模拟步骤执行时间
|
|
||||||
const startTime = Date.now()
|
|
||||||
while (Date.now() - startTime < step.duration) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
const elapsed = Date.now() - startTime
|
|
||||||
const stepProgress = Math.min((elapsed / step.duration) * 100, 100)
|
|
||||||
const totalProgress = ((i + stepProgress / 100) / steps.length) * 100
|
|
||||||
|
|
||||||
setLongRunningTask((prev) => ({
|
|
||||||
...prev,
|
|
||||||
progress: totalProgress,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
setLongRunningTask((prev) => ({
|
|
||||||
...prev,
|
|
||||||
logs: [...prev.logs, `完成: ${step.name}`],
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 模拟随机失败
|
|
||||||
if (i === 3 && Math.random() < 0.2) {
|
|
||||||
setLongRunningTask((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isRunning: false,
|
|
||||||
currentStep: '任务失败',
|
|
||||||
logs: [...prev.logs, '错误: 数据下载失败,请重试'],
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLongRunningTask((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isRunning: false,
|
|
||||||
progress: 100,
|
|
||||||
currentStep: '任务完成',
|
|
||||||
logs: [...prev.logs, '任务成功完成!'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearUploads = () => {
|
|
||||||
setUploads([])
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryFailedUpload = (uploadId: string) => {
|
|
||||||
const failedUpload = uploads.find((u) => u.id === uploadId)
|
|
||||||
if (failedUpload) {
|
|
||||||
setUploads((prev) => prev.filter((u) => u.id !== uploadId))
|
|
||||||
simulateFileUpload(failedUpload.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryDataLoad = (itemId: number) => {
|
|
||||||
setDataItems((prev) =>
|
|
||||||
prev.map((item) => {
|
|
||||||
if (item.id === itemId) {
|
|
||||||
return { ...item, status: 'loading', title: '', content: '', timestamp: '' }
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setDataItems((prev) =>
|
|
||||||
prev.map((item) => {
|
|
||||||
if (item.id === itemId) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
status: 'loaded',
|
|
||||||
title: `数据项 ${itemId + 1}`,
|
|
||||||
content: `这是重新加载的第 ${itemId + 1} 个数据项的内容。`,
|
|
||||||
timestamp: new Date().toLocaleString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">异步操作测试</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
|
||||||
测试等待、加载状态识别和异步操作处理能力
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* 文件上传进度 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
||||||
文件上传进度
|
|
||||||
</h2>
|
|
||||||
<div className="space-x-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => simulateFileUpload(`文件_${Date.now()}.pdf`)}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm"
|
|
||||||
>
|
|
||||||
开始上传
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearUploads}
|
|
||||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors text-sm"
|
|
||||||
>
|
|
||||||
清空列表
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{uploads.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
点击"开始上传"来模拟文件上传
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
uploads.map((upload) => (
|
|
||||||
<div
|
|
||||||
key={upload.id}
|
|
||||||
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{upload.name}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-sm ${
|
|
||||||
upload.status === 'completed'
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
|
||||||
: upload.status === 'error'
|
|
||||||
? 'text-red-600 dark:text-red-400'
|
|
||||||
: 'text-blue-600 dark:text-blue-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{upload.status === 'completed'
|
|
||||||
? '✓ 完成'
|
|
||||||
: upload.status === 'error'
|
|
||||||
? '✗ 失败'
|
|
||||||
: '上传中...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${
|
|
||||||
upload.status === 'completed'
|
|
||||||
? 'bg-green-500'
|
|
||||||
: upload.status === 'error'
|
|
||||||
? 'bg-red-500'
|
|
||||||
: 'bg-blue-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${upload.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
<span>{upload.progress.toFixed(1)}%</span>
|
|
||||||
<span>{upload.speed}</span>
|
|
||||||
<span>{upload.timeRemaining}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{upload.status === 'error' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => retryFailedUpload(upload.id)}
|
|
||||||
className="mt-2 px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
|
|
||||||
>
|
|
||||||
重试上传
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 实时数据更新 */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
||||||
实时数据更新
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsRealTimeActive(!isRealTimeActive)}
|
|
||||||
className={`px-4 py-2 rounded-md transition-colors text-sm ${
|
|
||||||
isRealTimeActive
|
|
||||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
|
||||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isRealTimeActive ? '停止更新' : '开始更新'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
||||||
{realTimeData.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
点击"开始更新"来查看实时数据
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
realTimeData.map((data) => (
|
|
||||||
<div
|
|
||||||
key={data}
|
|
||||||
className={`p-3 rounded-lg border transition-all duration-300 ${
|
|
||||||
data === realTimeData[0]
|
|
||||||
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700'
|
|
||||||
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-900 dark:text-white">{data}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 数据加载和长时间任务 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 数据加载骨架屏 */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
||||||
数据加载测试
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={loadData}
|
|
||||||
disabled={isLoadingData}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{isLoadingData ? '加载中...' : '加载数据'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{dataItems.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
|
|
||||||
>
|
|
||||||
{item.status === 'loading' ? (
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4 mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-full mb-1"></div>
|
|
||||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-2/3"></div>
|
|
||||||
</div>
|
|
||||||
) : item.status === 'error' ? (
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-red-600 dark:text-red-400 mb-2">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400 mb-2">
|
|
||||||
{item.content}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => retryDataLoad(item.id)}
|
|
||||||
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
|
||||||
{item.content}
|
|
||||||
</p>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{item.timestamp}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 长时间运行任务 */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">长时间任务</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={startLongRunningTask}
|
|
||||||
disabled={longRunningTask.isRunning}
|
|
||||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-md transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{longRunningTask.isRunning ? '执行中...' : '开始任务'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{longRunningTask.progress > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{longRunningTask.currentStep}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{longRunningTask.progress.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-purple-500 h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${longRunningTask.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{longRunningTask.logs.length > 0 && (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 max-h-48 overflow-y-auto">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
执行日志:
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{longRunningTask.logs.map((log, logIdx) => {
|
|
||||||
const logKey = `${logIdx + 1}-${log.substring(0, 30)}`
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={logKey}
|
|
||||||
className="text-sm text-gray-600 dark:text-gray-300 font-mono"
|
|
||||||
>
|
|
||||||
{log}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 返回链接 */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
|
||||||
← 返回测试页面列表
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,582 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Link } from 'wouter'
|
|
||||||
|
|
||||||
interface CartItem {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
price: number
|
|
||||||
quantity: number
|
|
||||||
image: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WizardStep {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
completed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ComplexTestPage() {
|
|
||||||
const [currentStep, setCurrentStep] = useState(1)
|
|
||||||
const [cartItems, setCartItems] = useState<CartItem[]>([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'iPhone 15 Pro',
|
|
||||||
price: 7999,
|
|
||||||
quantity: 1,
|
|
||||||
image: 'https://picsum.photos/100/100?random=1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'MacBook Air',
|
|
||||||
price: 8999,
|
|
||||||
quantity: 1,
|
|
||||||
image: 'https://picsum.photos/100/100?random=2',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const [wizardData, setWizardData] = useState({
|
|
||||||
personalInfo: { name: '', email: '', phone: '' },
|
|
||||||
address: { street: '', city: '', zipCode: '' },
|
|
||||||
payment: { cardNumber: '', expiryDate: '', cvv: '' },
|
|
||||||
})
|
|
||||||
const [wizardSteps, setWizardSteps] = useState<WizardStep[]>([
|
|
||||||
{ id: 1, title: '个人信息', description: '填写基本信息', completed: false },
|
|
||||||
{ id: 2, title: '收货地址', description: '填写收货地址', completed: false },
|
|
||||||
{ id: 3, title: '支付方式', description: '选择支付方式', completed: false },
|
|
||||||
{ id: 4, title: '确认订单', description: '确认订单信息', completed: false },
|
|
||||||
])
|
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
|
||||||
const [orderComplete, setOrderComplete] = useState(false)
|
|
||||||
|
|
||||||
// 购物车操作
|
|
||||||
const updateQuantity = (id: number, newQuantity: number) => {
|
|
||||||
if (newQuantity <= 0) {
|
|
||||||
removeItem(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCartItems((prev) =>
|
|
||||||
prev.map((item) => (item.id === id ? { ...item, quantity: newQuantity } : item))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeItem = (id: number) => {
|
|
||||||
setCartItems((prev) => prev.filter((item) => item.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const addItem = () => {
|
|
||||||
const newItem: CartItem = {
|
|
||||||
id: Date.now(),
|
|
||||||
name: `新产品 ${cartItems.length + 1}`,
|
|
||||||
price: Math.floor(Math.random() * 5000) + 1000,
|
|
||||||
quantity: 1,
|
|
||||||
image: `https://picsum.photos/100/100?random=${Date.now()}`,
|
|
||||||
}
|
|
||||||
setCartItems((prev) => [...prev, newItem])
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTotalPrice = () => {
|
|
||||||
return cartItems.reduce((total, item) => total + item.price * item.quantity, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 向导步骤验证
|
|
||||||
const validateStep = (step: number): boolean => {
|
|
||||||
switch (step) {
|
|
||||||
case 1:
|
|
||||||
return !!(
|
|
||||||
wizardData.personalInfo.name &&
|
|
||||||
wizardData.personalInfo.email &&
|
|
||||||
wizardData.personalInfo.phone
|
|
||||||
)
|
|
||||||
case 2:
|
|
||||||
return !!(
|
|
||||||
wizardData.address.street &&
|
|
||||||
wizardData.address.city &&
|
|
||||||
wizardData.address.zipCode
|
|
||||||
)
|
|
||||||
case 3:
|
|
||||||
return !!(
|
|
||||||
wizardData.payment.cardNumber &&
|
|
||||||
wizardData.payment.expiryDate &&
|
|
||||||
wizardData.payment.cvv
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToStep = (step: number) => {
|
|
||||||
// 验证当前步骤
|
|
||||||
if (step > currentStep && !validateStep(currentStep)) {
|
|
||||||
alert('请完成当前步骤的必填信息')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新步骤完成状态
|
|
||||||
if (step > currentStep) {
|
|
||||||
setWizardSteps((prev) =>
|
|
||||||
prev.map((s) => (s.id === currentStep ? { ...s, completed: true } : s))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentStep(step)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange = (section: string, field: string, value: string) => {
|
|
||||||
setWizardData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[section]: {
|
|
||||||
...prev[section as keyof typeof prev],
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmitOrder = async () => {
|
|
||||||
setIsProcessing(true)
|
|
||||||
|
|
||||||
// 模拟处理时间
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
|
||||||
|
|
||||||
// 模拟随机失败
|
|
||||||
if (Math.random() < 0.2) {
|
|
||||||
setIsProcessing(false)
|
|
||||||
alert('订单提交失败,请重试')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(false)
|
|
||||||
setOrderComplete(true)
|
|
||||||
setShowConfirmDialog(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetWizard = () => {
|
|
||||||
setCurrentStep(1)
|
|
||||||
setWizardData({
|
|
||||||
personalInfo: { name: '', email: '', phone: '' },
|
|
||||||
address: { street: '', city: '', zipCode: '' },
|
|
||||||
payment: { cardNumber: '', expiryDate: '', cvv: '' },
|
|
||||||
})
|
|
||||||
setWizardSteps((prev) => prev.map((s) => ({ ...s, completed: false })))
|
|
||||||
setOrderComplete(false)
|
|
||||||
setShowConfirmDialog(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orderComplete) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
|
||||||
<div className="max-w-md mx-auto text-center">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
|
||||||
<div className="text-6xl mb-4">🎉</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
|
||||||
订单提交成功!
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
|
||||||
您的订单已成功提交,我们将尽快为您处理。
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetWizard}
|
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
重新开始
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/test-pages"
|
|
||||||
className="block w-full bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-md transition-colors text-center"
|
|
||||||
>
|
|
||||||
返回测试页面
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">复杂交互测试</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">测试多步骤操作、状态管理和复杂用户交互</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{/* 购物车区域 */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 sticky top-8">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
||||||
购物车 ({cartItems.length})
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
{cartItems.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={item.image}
|
|
||||||
alt={item.name}
|
|
||||||
className="w-12 h-12 object-cover rounded"
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
||||||
{item.name}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
¥{item.price.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
|
||||||
className="w-6 h-6 flex items-center justify-center bg-gray-200 dark:bg-gray-600 rounded text-sm hover:bg-gray-300 dark:hover:bg-gray-500"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<span className="text-sm font-medium w-8 text-center">{item.quantity}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
|
||||||
className="w-6 h-6 flex items-center justify-center bg-gray-200 dark:bg-gray-600 rounded text-sm hover:bg-gray-300 dark:hover:bg-gray-500"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => removeItem(item.id)}
|
|
||||||
className="w-6 h-6 flex items-center justify-center bg-red-500 text-white rounded text-sm hover:bg-red-600"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={addItem}
|
|
||||||
className="w-full mb-4 py-2 px-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-gray-500 dark:text-gray-400 hover:border-blue-500 hover:text-blue-500 transition-colors"
|
|
||||||
>
|
|
||||||
+ 添加商品
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-600 pt-4">
|
|
||||||
<div className="flex justify-between items-center text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
<span>总计:</span>
|
|
||||||
<span>¥{getTotalPrice().toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 向导区域 */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
|
||||||
{/* 步骤指示器 */}
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-600">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{wizardSteps.map((step, index) => (
|
|
||||||
<div key={step.id} className="flex items-center">
|
|
||||||
<button
|
|
||||||
onClick={() => goToStep(step.id)}
|
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
|
||||||
step.completed
|
|
||||||
? 'bg-green-500 text-white'
|
|
||||||
: step.id === currentStep
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step.completed ? '✓' : step.id}
|
|
||||||
</button>
|
|
||||||
{index < wizardSteps.length - 1 && (
|
|
||||||
<div
|
|
||||||
className={`w-16 h-1 mx-2 ${
|
|
||||||
step.completed ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{wizardSteps[currentStep - 1].title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
|
||||||
{wizardSteps[currentStep - 1].description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 步骤内容 */}
|
|
||||||
<div className="p-6">
|
|
||||||
{currentStep === 1 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
姓名 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wizardData.personalInfo.name}
|
|
||||||
onChange={(e) => handleInputChange('personalInfo', 'name', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入您的姓名"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
邮箱 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={wizardData.personalInfo.email}
|
|
||||||
onChange={(e) => handleInputChange('personalInfo', 'email', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入您的邮箱"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
手机号 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={wizardData.personalInfo.phone}
|
|
||||||
onChange={(e) => handleInputChange('personalInfo', 'phone', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入您的手机号"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
详细地址 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wizardData.address.street}
|
|
||||||
onChange={(e) => handleInputChange('address', 'street', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入详细地址"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
城市 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wizardData.address.city}
|
|
||||||
onChange={(e) => handleInputChange('address', 'city', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入城市"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
邮政编码 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wizardData.address.zipCode}
|
|
||||||
onChange={(e) => handleInputChange('address', 'zipCode', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入邮政编码"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 3 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
银行卡号 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wizardData.payment.cardNumber}
|
|
||||||
onChange={(e) => handleInputChange('payment', 'cardNumber', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入银行卡号"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
有效期 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wizardData.payment.expiryDate}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange('payment', 'expiryDate', e.target.value)
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="MM/YY"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
CVV *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wizardData.payment.cvv}
|
|
||||||
onChange={(e) => handleInputChange('payment', 'cvv', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="CVV"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 4 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
订单确认
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
|
||||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
个人信息
|
|
||||||
</h5>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{wizardData.personalInfo.name} | {wizardData.personalInfo.email} |{' '}
|
|
||||||
{wizardData.personalInfo.phone}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
|
||||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
收货地址
|
|
||||||
</h5>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{wizardData.address.street}, {wizardData.address.city}{' '}
|
|
||||||
{wizardData.address.zipCode}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
|
||||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
支付方式
|
|
||||||
</h5>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
**** **** **** {wizardData.payment.cardNumber.slice(-4)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 导航按钮 */}
|
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-between">
|
|
||||||
<button
|
|
||||||
onClick={() => goToStep(currentStep - 1)}
|
|
||||||
disabled={currentStep === 1}
|
|
||||||
className="px-4 py-2 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
上一步
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{currentStep < 4 ? (
|
|
||||||
<button
|
|
||||||
onClick={() => goToStep(currentStep + 1)}
|
|
||||||
disabled={!validateStep(currentStep)}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
下一步
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowConfirmDialog(true)}
|
|
||||||
disabled={cartItems.length === 0}
|
|
||||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
提交订单
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 确认对话框 */}
|
|
||||||
{showConfirmDialog && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
确认提交订单
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
|
||||||
您确定要提交这个订单吗?订单总金额为 ¥{getTotalPrice().toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowConfirmDialog(false)}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSubmitOrder}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors flex items-center"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
处理中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'确认提交'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 返回链接 */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
|
||||||
← 返回测试页面列表
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Link } from 'wouter'
|
|
||||||
|
|
||||||
interface ErrorScenario {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
type: 'network' | 'validation' | 'permission' | 'timeout' | 'server'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ErrorTestPage() {
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
|
||||||
const [retryCount, setRetryCount] = useState(0)
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
email: '',
|
|
||||||
file: null as File | null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const errorScenarios: ErrorScenario[] = [
|
|
||||||
{
|
|
||||||
id: 'network-error',
|
|
||||||
title: '网络连接错误',
|
|
||||||
description: '模拟网络连接失败,测试重试机制',
|
|
||||||
type: 'network',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'validation-error',
|
|
||||||
title: '表单验证错误',
|
|
||||||
description: '模拟表单验证失败,测试错误提示',
|
|
||||||
type: 'validation',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'permission-error',
|
|
||||||
title: '权限不足错误',
|
|
||||||
description: '模拟权限验证失败,测试权限处理',
|
|
||||||
type: 'permission',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'timeout-error',
|
|
||||||
title: '请求超时错误',
|
|
||||||
description: '模拟请求超时,测试超时处理',
|
|
||||||
type: 'timeout',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'server-error',
|
|
||||||
title: '服务器内部错误',
|
|
||||||
description: '模拟服务器500错误,测试错误恢复',
|
|
||||||
type: 'server',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const simulateError = async (scenario: ErrorScenario): Promise<void> => {
|
|
||||||
setIsLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
|
|
||||||
// 模拟网络延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 2000))
|
|
||||||
|
|
||||||
switch (scenario.type) {
|
|
||||||
case 'network':
|
|
||||||
// 70% 概率失败
|
|
||||||
if (Math.random() < 0.7) {
|
|
||||||
throw new Error('网络连接失败:无法连接到服务器,请检查您的网络连接')
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'validation':
|
|
||||||
// 检查表单数据
|
|
||||||
if (!formData.username || formData.username.length < 3) {
|
|
||||||
throw new Error('用户名验证失败:用户名至少需要3个字符')
|
|
||||||
}
|
|
||||||
if (!formData.password || formData.password.length < 6) {
|
|
||||||
throw new Error('密码验证失败:密码至少需要6个字符')
|
|
||||||
}
|
|
||||||
if (!formData.email?.includes('@')) {
|
|
||||||
throw new Error('邮箱验证失败:请输入有效的邮箱地址')
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'permission':
|
|
||||||
// 模拟权限检查
|
|
||||||
if (formData.username !== 'admin') {
|
|
||||||
throw new Error('权限不足:您没有执行此操作的权限,请联系管理员')
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'timeout':
|
|
||||||
// 模拟超时
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 8000))
|
|
||||||
throw new Error('请求超时:服务器响应时间过长,请稍后重试')
|
|
||||||
|
|
||||||
case 'server':
|
|
||||||
// 50% 概率服务器错误
|
|
||||||
if (Math.random() < 0.5) {
|
|
||||||
throw new Error('服务器内部错误:服务器遇到了一个错误,请稍后重试')
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error('未知错误:发生了未预期的错误')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成功情况
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScenarioTest = async (scenario: ErrorScenario) => {
|
|
||||||
try {
|
|
||||||
await simulateError(scenario)
|
|
||||||
setSuccess(`${scenario.title} 测试成功完成!`)
|
|
||||||
setRetryCount(0)
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : '未知错误'
|
|
||||||
setError(errorMessage)
|
|
||||||
setRetryCount((prev) => prev + 1)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRetry = async (scenario: ErrorScenario) => {
|
|
||||||
if (retryCount >= 3) {
|
|
||||||
setError('重试次数已达上限,请稍后再试或联系技术支持')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await handleScenarioTest(scenario)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
|
||||||
if (!formData.file) {
|
|
||||||
setError('请选择要上传的文件')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 模拟文件大小检查
|
|
||||||
if (formData.file.size > 5 * 1024 * 1024) {
|
|
||||||
throw new Error('文件上传失败:文件大小不能超过5MB')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟文件类型检查
|
|
||||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']
|
|
||||||
if (!allowedTypes.includes(formData.file.type)) {
|
|
||||||
throw new Error('文件上传失败:不支持的文件类型,请上传图片或PDF文件')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟上传过程
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
||||||
|
|
||||||
// 模拟随机失败
|
|
||||||
if (Math.random() < 0.3) {
|
|
||||||
throw new Error('文件上传失败:上传过程中发生错误,请重试')
|
|
||||||
}
|
|
||||||
|
|
||||||
setSuccess('文件上传成功!')
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : '文件上传失败'
|
|
||||||
setError(errorMessage)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearMessages = () => {
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
setRetryCount(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getErrorIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'network':
|
|
||||||
return '🌐'
|
|
||||||
case 'validation':
|
|
||||||
return '⚠️'
|
|
||||||
case 'permission':
|
|
||||||
return '🔒'
|
|
||||||
case 'timeout':
|
|
||||||
return '⏰'
|
|
||||||
case 'server':
|
|
||||||
return '🔧'
|
|
||||||
default:
|
|
||||||
return '❌'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">错误处理测试</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
|
||||||
测试各种错误场景和重试机制,验证 Agent 的错误处理能力
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 全局消息显示 */}
|
|
||||||
{(error || success) && (
|
|
||||||
<div className="mb-8">
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 flex-1">
|
|
||||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">操作失败</h3>
|
|
||||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">{error}</p>
|
|
||||||
{retryCount > 0 && (
|
|
||||||
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
|
|
||||||
已重试 {retryCount} 次 {retryCount >= 3 && '(已达最大重试次数)'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={clearMessages}
|
|
||||||
className="ml-3 text-red-400 hover:text-red-600 dark:hover:text-red-300"
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 flex-1">
|
|
||||||
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
|
|
||||||
操作成功
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-green-700 dark:text-green-300">{success}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={clearMessages}
|
|
||||||
className="ml-3 text-green-400 hover:text-green-600 dark:hover:text-green-300"
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* 错误场景测试 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">错误场景测试</h2>
|
|
||||||
|
|
||||||
{errorScenarios.map((scenario) => (
|
|
||||||
<div key={scenario.id} className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="text-3xl">{getErrorIcon(scenario.type)}</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
{scenario.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4">
|
|
||||||
{scenario.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleScenarioTest(scenario)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white rounded-md transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{isLoading ? '测试中...' : '触发错误'}
|
|
||||||
</button>
|
|
||||||
{error && retryCount > 0 && retryCount < 3 && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRetry(scenario)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors text-sm"
|
|
||||||
>
|
|
||||||
重试 ({retryCount}/3)
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 表单验证测试 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">表单验证测试</h2>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
用户信息表单
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
用户名 (至少3个字符)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, username: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
密码 (至少6个字符)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, password: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
邮箱地址
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请输入邮箱地址"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
handleScenarioTest(errorScenarios.find((s) => s.type === 'validation')!)
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
{isLoading ? '验证中...' : '提交表单'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 文件上传测试 */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
文件上传测试
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
选择文件 (最大5MB,支持图片和PDF)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData((prev) => ({ ...prev, file: e.target.files?.[0] || null }))
|
|
||||||
}
|
|
||||||
accept="image/*,.pdf"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{formData.file && (
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
已选择: {formData.file.name} ({(formData.file.size / 1024 / 1024).toFixed(2)}{' '}
|
|
||||||
MB)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleFileUpload}
|
|
||||||
disabled={isLoading || !formData.file}
|
|
||||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
{isLoading ? '上传中...' : '上传文件'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 权限测试说明 */}
|
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
|
||||||
<h4 className="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">
|
|
||||||
💡 权限测试提示
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
|
||||||
要通过权限测试,请在用户名字段输入 "admin",然后点击"触发错误"按钮测试权限验证。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 加载状态指示器 */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 flex items-center space-x-4">
|
|
||||||
<svg
|
|
||||||
className="animate-spin h-8 w-8 text-blue-600"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span className="text-gray-900 dark:text-white">处理中,请稍候...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 返回链接 */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
|
||||||
← 返回测试页面列表
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Link } from 'wouter'
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
username: string
|
|
||||||
email: string
|
|
||||||
password: string
|
|
||||||
confirmPassword: string
|
|
||||||
age: string
|
|
||||||
birthDate: string
|
|
||||||
phone: string
|
|
||||||
website: string
|
|
||||||
bio: string
|
|
||||||
country: string
|
|
||||||
newsletter: boolean
|
|
||||||
terms: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormErrors = Record<string, string>
|
|
||||||
|
|
||||||
export default function FormTestPage() {
|
|
||||||
const [formData, setFormData] = useState<FormData>({
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
age: '',
|
|
||||||
birthDate: '',
|
|
||||||
phone: '',
|
|
||||||
website: '',
|
|
||||||
bio: '',
|
|
||||||
country: '',
|
|
||||||
newsletter: false,
|
|
||||||
terms: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<FormErrors>({})
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [submitResult, setSubmitResult] = useState<'success' | 'error' | null>(null)
|
|
||||||
const [submitMessage, setSubmitMessage] = useState('')
|
|
||||||
|
|
||||||
const validateField = (name: string, value: string | boolean): string => {
|
|
||||||
switch (name) {
|
|
||||||
case 'username':
|
|
||||||
if (!value) return '用户名不能为空'
|
|
||||||
if (typeof value === 'string' && value.length < 3) return '用户名至少需要3个字符'
|
|
||||||
if (typeof value === 'string' && !/^[a-zA-Z0-9_]+$/.test(value))
|
|
||||||
return '用户名只能包含字母、数字和下划线'
|
|
||||||
return ''
|
|
||||||
case 'email':
|
|
||||||
if (!value) return '邮箱不能为空'
|
|
||||||
if (typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
||||||
return '请输入有效的邮箱地址'
|
|
||||||
return ''
|
|
||||||
case 'password':
|
|
||||||
if (!value) return '密码不能为空'
|
|
||||||
if (typeof value === 'string' && value.length < 6) return '密码至少需要6个字符'
|
|
||||||
if (typeof value === 'string' && !/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value))
|
|
||||||
return '密码必须包含大小写字母和数字'
|
|
||||||
return ''
|
|
||||||
case 'confirmPassword':
|
|
||||||
if (!value) return '请确认密码'
|
|
||||||
if (value !== formData.password) return '两次输入的密码不一致'
|
|
||||||
return ''
|
|
||||||
case 'age': {
|
|
||||||
if (!value) return '年龄不能为空'
|
|
||||||
const age = parseInt(value as string)
|
|
||||||
if (isNaN(age) || age < 18 || age > 120) return '年龄必须在18-120之间'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
case 'phone':
|
|
||||||
if (!value) return '手机号不能为空'
|
|
||||||
if (typeof value === 'string' && !/^1[3-9]\d{9}$/.test(value)) return '请输入有效的手机号'
|
|
||||||
return ''
|
|
||||||
case 'terms':
|
|
||||||
if (!value) return '请同意服务条款'
|
|
||||||
return ''
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange = (name: string, value: string | boolean) => {
|
|
||||||
console.log(`Input changed: ${name} = ${value}`)
|
|
||||||
|
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
|
||||||
|
|
||||||
// 实时验证
|
|
||||||
const error = validateField(name, value)
|
|
||||||
setErrors((prev) => ({ ...prev, [name]: error }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const newErrors: FormErrors = {}
|
|
||||||
let isValid = true
|
|
||||||
|
|
||||||
Object.keys(formData).forEach((key) => {
|
|
||||||
const error = validateField(key, formData[key as keyof FormData])
|
|
||||||
if (error) {
|
|
||||||
newErrors[key] = error
|
|
||||||
isValid = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setErrors(newErrors)
|
|
||||||
return isValid
|
|
||||||
}
|
|
||||||
|
|
||||||
const simulateSubmit = async (): Promise<{ success: boolean; message: string }> => {
|
|
||||||
// 模拟网络延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000 + Math.random() * 2000))
|
|
||||||
|
|
||||||
// 模拟随机失败
|
|
||||||
if (Math.random() < 0.3) {
|
|
||||||
throw new Error('网络错误:服务器暂时不可用,请稍后重试')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟服务器验证错误
|
|
||||||
if (formData.username.toLowerCase() === 'admin') {
|
|
||||||
throw new Error('用户名 "admin" 已被占用,请选择其他用户名')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: '注册成功!欢迎加入我们的平台。',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (!validateForm()) {
|
|
||||||
setSubmitResult('error')
|
|
||||||
setSubmitMessage('请修正表单中的错误')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
setSubmitResult(null)
|
|
||||||
setSubmitMessage('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await simulateSubmit()
|
|
||||||
setSubmitResult('success')
|
|
||||||
setSubmitMessage(result.message)
|
|
||||||
} catch (error) {
|
|
||||||
setSubmitResult('error')
|
|
||||||
setSubmitMessage(error instanceof Error ? error.message : '提交失败,请重试')
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormData({
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
age: '',
|
|
||||||
birthDate: '',
|
|
||||||
phone: '',
|
|
||||||
website: '',
|
|
||||||
bio: '',
|
|
||||||
country: '',
|
|
||||||
newsletter: false,
|
|
||||||
terms: false,
|
|
||||||
})
|
|
||||||
setErrors({})
|
|
||||||
setSubmitResult(null)
|
|
||||||
setSubmitMessage('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
|
||||||
<div className="max-w-2xl mx-auto px-4">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">用户注册表单</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">测试各种表单输入、验证和提交功能</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* 用户名 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
用户名 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
|
||||||
errors.username ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
/>
|
|
||||||
{errors.username && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.username}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 邮箱 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
邮箱地址 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
|
||||||
errors.email ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
placeholder="请输入邮箱地址"
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.email}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 密码 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
密码 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
|
||||||
errors.password ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
placeholder="请输入密码"
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.password}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
确认密码 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.confirmPassword}
|
|
||||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
|
||||||
errors.confirmPassword
|
|
||||||
? 'border-red-500'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
placeholder="请再次输入密码"
|
|
||||||
/>
|
|
||||||
{errors.confirmPassword && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
|
||||||
{errors.confirmPassword}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 年龄和生日 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
年龄 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.age}
|
|
||||||
onChange={(e) => handleInputChange('age', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
|
||||||
errors.age ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
placeholder="请输入年龄"
|
|
||||||
min="18"
|
|
||||||
max="120"
|
|
||||||
/>
|
|
||||||
{errors.age && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.age}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
出生日期
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.birthDate}
|
|
||||||
onChange={(e) => handleInputChange('birthDate', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 手机和网站 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
手机号 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
|
||||||
errors.phone ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
/>
|
|
||||||
{errors.phone && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.phone}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
个人网站
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={formData.website}
|
|
||||||
onChange={(e) => handleInputChange('website', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="https://example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 国家选择 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
国家/地区
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.country}
|
|
||||||
onChange={(e) => handleInputChange('country', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="">请选择国家/地区</option>
|
|
||||||
<option value="CN">中国</option>
|
|
||||||
<option value="US">美国</option>
|
|
||||||
<option value="JP">日本</option>
|
|
||||||
<option value="KR">韩国</option>
|
|
||||||
<option value="GB">英国</option>
|
|
||||||
<option value="DE">德国</option>
|
|
||||||
<option value="FR">法国</option>
|
|
||||||
<option value="CA">加拿大</option>
|
|
||||||
<option value="AU">澳大利亚</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 个人简介 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
个人简介
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.bio}
|
|
||||||
onChange={(e) => handleInputChange('bio', e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="请简单介绍一下自己..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 复选框 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="newsletter"
|
|
||||||
checked={formData.newsletter}
|
|
||||||
onChange={(e) => handleInputChange('newsletter', e.target.checked)}
|
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="newsletter"
|
|
||||||
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
订阅我们的新闻通讯
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="terms"
|
|
||||||
checked={formData.terms}
|
|
||||||
onChange={(e) => handleInputChange('terms', e.target.checked)}
|
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="terms"
|
|
||||||
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
我同意{' '}
|
|
||||||
<a href="#" className="text-blue-600 hover:text-blue-500">
|
|
||||||
服务条款
|
|
||||||
</a>{' '}
|
|
||||||
和{' '}
|
|
||||||
<a href="#" className="text-blue-600 hover:text-blue-500">
|
|
||||||
隐私政策
|
|
||||||
</a>{' '}
|
|
||||||
*
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{errors.terms && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{errors.terms}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提交结果 */}
|
|
||||||
{submitResult && (
|
|
||||||
<div
|
|
||||||
className={`p-4 rounded-md ${
|
|
||||||
submitResult === 'success'
|
|
||||||
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
|
|
||||||
: 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className={`text-sm ${
|
|
||||||
submitResult === 'success'
|
|
||||||
? 'text-green-800 dark:text-green-200'
|
|
||||||
: 'text-red-800 dark:text-red-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{submitMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 按钮组 */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
提交中...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'注册账户'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetForm}
|
|
||||||
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
重置表单
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
|
||||||
← 返回测试页面列表
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { Link } from 'wouter'
|
|
||||||
|
|
||||||
export default function IndexPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-linear-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-8">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
|
||||||
Page Use Agent 测试页面
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-300">
|
|
||||||
用于测试 AI Agent 网页操作能力的综合测试套件
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
|
||||||
<TestPageCard
|
|
||||||
title="表单测试"
|
|
||||||
description="测试输入、验证、提交等表单操作"
|
|
||||||
path="/form"
|
|
||||||
icon="📝"
|
|
||||||
difficulty="简单"
|
|
||||||
/>
|
|
||||||
<TestPageCard
|
|
||||||
title="导航测试"
|
|
||||||
description="测试菜单、下拉框、弹窗等交互"
|
|
||||||
path="/navigation"
|
|
||||||
icon="🧭"
|
|
||||||
difficulty="中等"
|
|
||||||
/>
|
|
||||||
<TestPageCard
|
|
||||||
title="列表测试"
|
|
||||||
description="测试滚动、分页、搜索、排序"
|
|
||||||
path="/list"
|
|
||||||
icon="📋"
|
|
||||||
difficulty="中等"
|
|
||||||
/>
|
|
||||||
<TestPageCard
|
|
||||||
title="复杂交互"
|
|
||||||
description="测试多步骤操作和状态管理"
|
|
||||||
path="/complex"
|
|
||||||
icon="⚙️"
|
|
||||||
difficulty="困难"
|
|
||||||
/>
|
|
||||||
<TestPageCard
|
|
||||||
title="错误处理"
|
|
||||||
description="测试错误识别和重试机制"
|
|
||||||
path="/errors"
|
|
||||||
icon="⚠️"
|
|
||||||
difficulty="困难"
|
|
||||||
/>
|
|
||||||
<TestPageCard
|
|
||||||
title="异步操作"
|
|
||||||
description="测试等待、加载状态识别"
|
|
||||||
path="/async"
|
|
||||||
icon="⏳"
|
|
||||||
difficulty="中等"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex items-center px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
← 回到 Page Use 首页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestPageCardProps {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
path: string
|
|
||||||
icon: string
|
|
||||||
difficulty: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestPageCard({ title, description, path, icon, difficulty }: TestPageCardProps) {
|
|
||||||
const difficultyColors = {
|
|
||||||
简单: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
||||||
中等: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
||||||
困难: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={path}>
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 cursor-pointer border border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="text-4xl mb-4">{icon}</div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{title}</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-4 text-sm">{description}</p>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded-full text-xs font-medium ${difficultyColors[difficulty as keyof typeof difficultyColors]}`}
|
|
||||||
>
|
|
||||||
{difficulty}
|
|
||||||
</span>
|
|
||||||
<span className="text-blue-600 dark:text-blue-400 text-sm font-medium">开始测试 →</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { Link } from 'wouter'
|
|
||||||
|
|
||||||
interface Product {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
category: string
|
|
||||||
price: number
|
|
||||||
stock: number
|
|
||||||
rating: number
|
|
||||||
image: string
|
|
||||||
description: string
|
|
||||||
tags: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateProducts = (count: number): Product[] => {
|
|
||||||
const categories = ['手机', '电脑', '平板', '耳机', '手表', '相机']
|
|
||||||
const brands = ['Apple', 'Samsung', 'Huawei', 'Xiaomi', 'Sony', 'Dell']
|
|
||||||
const adjectives = ['Pro', 'Max', 'Ultra', 'Plus', 'Air', 'Mini']
|
|
||||||
|
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
name: `${brands[i % brands.length]} ${categories[i % categories.length]} ${adjectives[i % adjectives.length]}`,
|
|
||||||
category: categories[i % categories.length],
|
|
||||||
price: Math.floor(Math.random() * 10000) + 500,
|
|
||||||
stock: Math.floor(Math.random() * 100),
|
|
||||||
rating: Math.round((Math.random() * 2 + 3) * 10) / 10,
|
|
||||||
image: `https://picsum.photos/200/200?random=${i}`,
|
|
||||||
description: `这是一款优秀的${categories[i % categories.length]}产品,具有出色的性能和设计。`,
|
|
||||||
tags: ['热销', '新品', '推荐'].slice(0, Math.floor(Math.random() * 3) + 1),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading skeleton component
|
|
||||||
const LoadingSkeleton = () => (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
{Array.from({ length: 12 }, (_, i) => `skeleton-item-${i}`).map((id) => (
|
|
||||||
<div key={id} className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 animate-pulse">
|
|
||||||
<div className="bg-gray-300 dark:bg-gray-600 h-48 rounded-lg mb-4"></div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-3/4"></div>
|
|
||||||
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-1/2"></div>
|
|
||||||
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-1/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Product card component
|
|
||||||
const ProductCard = ({ product }: { product: Product }) => (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-4">
|
|
||||||
<div className="relative mb-4">
|
|
||||||
<img
|
|
||||||
src={product.image}
|
|
||||||
alt={product.name}
|
|
||||||
className="w-full h-48 object-cover rounded-lg"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-2 right-2 flex flex-wrap gap-1">
|
|
||||||
{product.tags.map((tag) => (
|
|
||||||
<span key={tag} className="bg-red-500 text-white text-xs px-2 py-1 rounded-full">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-2 line-clamp-2">
|
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-3 line-clamp-2">
|
|
||||||
{product.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
|
||||||
¥{product.price.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-yellow-400">★</span>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300 ml-1">{product.rating}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">库存: {product.stock}</span>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">{product.category}</span>
|
|
||||||
</div>
|
|
||||||
<button className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors">
|
|
||||||
加入购物车
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Product list item component
|
|
||||||
const ProductListItem = ({ product }: { product: Product }) => (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center space-x-4">
|
|
||||||
<img
|
|
||||||
src={product.image}
|
|
||||||
alt={product.name}
|
|
||||||
className="w-20 h-20 object-cover rounded-lg shrink-0"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">{product.name}</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-2 line-clamp-1">
|
|
||||||
{product.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span>{product.category}</span>
|
|
||||||
<span>库存: {product.stock}</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-yellow-400">★</span>
|
|
||||||
<span className="ml-1">{product.rating}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<span className="text-xl font-bold text-blue-600 dark:text-blue-400">
|
|
||||||
¥{product.price.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<button className="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors">
|
|
||||||
加入购物车
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pagination component
|
|
||||||
interface PaginationProps {
|
|
||||||
currentPage: number
|
|
||||||
totalPages: number
|
|
||||||
startIndex: number
|
|
||||||
endIndex: number
|
|
||||||
totalItems: number
|
|
||||||
onPageChange: (page: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const Pagination = ({
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
startIndex,
|
|
||||||
endIndex,
|
|
||||||
totalItems,
|
|
||||||
onPageChange,
|
|
||||||
}: PaginationProps) => {
|
|
||||||
const getPageNumbers = () => {
|
|
||||||
const pages = []
|
|
||||||
const maxVisible = 5
|
|
||||||
let start = Math.max(1, currentPage - Math.floor(maxVisible / 2))
|
|
||||||
const end = Math.min(totalPages, start + maxVisible - 1)
|
|
||||||
|
|
||||||
if (end - start + 1 < maxVisible) {
|
|
||||||
start = Math.max(1, end - maxVisible + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = start; i <= end; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between mt-8">
|
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
显示 {startIndex + 1}-{Math.min(endIndex, totalItems)} 条, 共 {totalItems} 条结果
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</button>
|
|
||||||
{getPageNumbers().map((page) => (
|
|
||||||
<button
|
|
||||||
key={page}
|
|
||||||
onClick={() => onPageChange(page)}
|
|
||||||
className={`px-3 py-2 text-sm font-medium rounded-md ${
|
|
||||||
page === currentPage
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ListTestPage() {
|
|
||||||
const [products, setProducts] = useState<Product[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('全部')
|
|
||||||
const [sortBy, setSortBy] = useState('name')
|
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
|
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(12)
|
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
|
||||||
|
|
||||||
const categories = ['全部', '手机', '电脑', '平板', '耳机', '手表', '相机']
|
|
||||||
|
|
||||||
// Helper to set filters and reset page
|
|
||||||
const handleSearchChange = (term: string) => {
|
|
||||||
setSearchTerm(term)
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCategoryChange = (category: string) => {
|
|
||||||
setSelectedCategory(category)
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSortChange = (sort: string) => {
|
|
||||||
setSortBy(sort)
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSortOrderChange = (order: 'asc' | 'desc') => {
|
|
||||||
setSortOrder(order)
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟数据加载
|
|
||||||
useEffect(() => {
|
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
// 模拟网络延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
||||||
const data = generateProducts(150)
|
|
||||||
setProducts(data)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
loadData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 搜索和过滤 - Use useMemo to compute filtered products
|
|
||||||
const filteredProducts = useMemo(() => {
|
|
||||||
let filtered = [...products]
|
|
||||||
|
|
||||||
// 按类别过滤
|
|
||||||
if (selectedCategory !== '全部') {
|
|
||||||
filtered = filtered.filter((product) => product.category === selectedCategory)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按搜索词过滤
|
|
||||||
if (searchTerm) {
|
|
||||||
filtered = filtered.filter(
|
|
||||||
(product) =>
|
|
||||||
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
product.description.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 排序
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
let aValue: any = a[sortBy as keyof Product]
|
|
||||||
let bValue: any = b[sortBy as keyof Product]
|
|
||||||
|
|
||||||
if (typeof aValue === 'string') {
|
|
||||||
aValue = aValue.toLowerCase()
|
|
||||||
bValue = bValue.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortOrder === 'asc') {
|
|
||||||
return aValue > bValue ? 1 : -1
|
|
||||||
} else {
|
|
||||||
return aValue < bValue ? 1 : -1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
}, [products, searchTerm, selectedCategory, sortBy, sortOrder])
|
|
||||||
|
|
||||||
// 分页计算
|
|
||||||
const totalPages = Math.ceil(filteredProducts.length / itemsPerPage)
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage
|
|
||||||
const endIndex = startIndex + itemsPerPage
|
|
||||||
const currentProducts = filteredProducts.slice(startIndex, endIndex)
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setCurrentPage(page)
|
|
||||||
// 滚动到顶部
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">产品列表测试</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">测试搜索、过滤、排序、分页和滚动功能</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索和过滤栏 */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
|
||||||
{/* 搜索框 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
搜索产品
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
|
||||||
placeholder="输入产品名称或描述..."
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 类别过滤 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
产品类别
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
{categories.map((category) => (
|
|
||||||
<option key={category} value={category}>
|
|
||||||
{category}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 排序方式 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
排序方式
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => handleSortChange(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="name">名称</option>
|
|
||||||
<option value="price">价格</option>
|
|
||||||
<option value="rating">评分</option>
|
|
||||||
<option value="stock">库存</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 排序顺序 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
排序顺序
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={sortOrder}
|
|
||||||
onChange={(e) => handleSortOrderChange(e.target.value as 'asc' | 'desc')}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="asc">升序</option>
|
|
||||||
<option value="desc">降序</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 视图控制 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
每页显示:
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={itemsPerPage}
|
|
||||||
onChange={(e) => setItemsPerPage(Number(e.target.value))}
|
|
||||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value={12}>12</option>
|
|
||||||
<option value={24}>24</option>
|
|
||||||
<option value={48}>48</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">视图:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
className={`p-2 rounded-md ${
|
|
||||||
viewMode === 'grid'
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
className={`p-2 rounded-md ${
|
|
||||||
viewMode === 'list'
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 8a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 12a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 16a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 产品列表 */}
|
|
||||||
{loading ? (
|
|
||||||
<LoadingSkeleton />
|
|
||||||
) : filteredProducts.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-6xl mb-4">🔍</div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
没有找到匹配的产品
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">请尝试调整搜索条件或过滤器</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{viewMode === 'grid' ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
{currentProducts.map((product: Product) => (
|
|
||||||
<ProductCard key={product.id} product={product} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{currentProducts.map((product: Product) => (
|
|
||||||
<ProductListItem key={product.id} product={product} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
startIndex={startIndex}
|
|
||||||
endIndex={endIndex}
|
|
||||||
totalItems={filteredProducts.length}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 返回顶部按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
||||||
className="fixed bottom-8 right-8 bg-blue-600 hover:bg-blue-700 text-white p-3 rounded-full shadow-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 返回链接 */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
|
||||||
← 返回测试页面列表
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Link } from 'wouter'
|
|
||||||
|
|
||||||
export default function NavigationTestPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState('home')
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
|
|
||||||
const [breadcrumbs, setBreadcrumbs] = useState(['首页', '产品', '手机'])
|
|
||||||
const [notifications, setNotifications] = useState([
|
|
||||||
{ id: 1, title: '新消息', content: '您有一条新的私信', time: '2分钟前', unread: true },
|
|
||||||
{ id: 2, title: '系统通知', content: '系统将于今晚维护', time: '1小时前', unread: true },
|
|
||||||
{ id: 3, title: '订单更新', content: '您的订单已发货', time: '3小时前', unread: false },
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleBreadcrumbClick = (index: number) => {
|
|
||||||
const newBreadcrumbs = breadcrumbs.slice(0, index + 1)
|
|
||||||
setBreadcrumbs(newBreadcrumbs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const markNotificationAsRead = (id: number) => {
|
|
||||||
setNotifications((prev) =>
|
|
||||||
prev.map((notif) => (notif.id === id ? { ...notif, unread: false } : notif))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const unreadCount = notifications.filter((n) => n.unread).length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
||||||
{/* 顶部导航栏 */}
|
|
||||||
<nav className="bg-white dark:bg-gray-800 shadow-lg border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
|
||||||
<div className="flex justify-between items-center h-16">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">TestNav</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 主导航菜单 */}
|
|
||||||
<div className="hidden md:flex space-x-8">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
首页
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* 产品下拉菜单 */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
||||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center"
|
|
||||||
>
|
|
||||||
产品
|
|
||||||
<svg
|
|
||||||
className="ml-1 h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isDropdownOpen && (
|
|
||||||
<div className="absolute top-full left-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
|
||||||
<div className="py-1">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
手机
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
电脑
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
平板
|
|
||||||
</a>
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-600 my-1"></div>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
配件
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
服务
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
支持
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧菜单 */}
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{/* 通知铃铛 */}
|
|
||||||
<div className="relative">
|
|
||||||
<button className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 rounded-full transition-colors">
|
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 17h5l-5 5v-5zM10.5 3.75a6 6 0 0 1 6 6v2.25l2.25 2.25v2.25H2.25V14.25L4.5 12V9.75a6 6 0 0 1 6-6z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
|
|
||||||
{unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 用户菜单 */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
|
||||||
className="flex items-center text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
<div className="h-8 w-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
|
||||||
U
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isUserMenuOpen && (
|
|
||||||
<div className="absolute top-full right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
|
||||||
<div className="py-1">
|
|
||||||
<div className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-600">
|
|
||||||
user@example.com
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
个人资料
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
设置
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
帮助
|
|
||||||
</a>
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-600 my-1"></div>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
退出登录
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* 面包屑导航 */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
|
||||||
<nav className="flex" aria-label="Breadcrumb">
|
|
||||||
<ol className="flex items-center space-x-2">
|
|
||||||
{breadcrumbs.map((crumb, crumbIdx) => {
|
|
||||||
const isLast = crumbIdx === breadcrumbs.length - 1
|
|
||||||
const showSeparator = crumbIdx > 0
|
|
||||||
return (
|
|
||||||
<li key={`${crumb}-${crumbIdx + 1}`} className="flex items-center">
|
|
||||||
{showSeparator && (
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-gray-400 mx-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleBreadcrumbClick(crumbIdx)}
|
|
||||||
className={`text-sm font-medium transition-colors ${
|
|
||||||
isLast
|
|
||||||
? 'text-gray-500 dark:text-gray-400 cursor-default'
|
|
||||||
: 'text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{crumb}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 主要内容区域 */}
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
{/* 标签页导航 */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<nav className="-mb-px flex space-x-8">
|
|
||||||
{[
|
|
||||||
{ id: 'home', label: '概览', icon: '🏠' },
|
|
||||||
{ id: 'products', label: '产品列表', icon: '📱' },
|
|
||||||
{ id: 'orders', label: '订单管理', icon: '📦' },
|
|
||||||
{ id: 'analytics', label: '数据分析', icon: '📊' },
|
|
||||||
{ id: 'settings', label: '设置', icon: '⚙️' },
|
|
||||||
].map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="mr-2">{tab.icon}</span>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标签页内容 */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
{activeTab === 'home' && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">概览</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
|
||||||
欢迎来到导航测试页面!这里展示了各种常见的导航模式。
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold text-blue-900 dark:text-blue-100">总销售额</h3>
|
|
||||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">¥123,456</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 dark:bg-green-900 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold text-green-900 dark:text-green-100">订单数量</h3>
|
|
||||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">1,234</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 dark:bg-purple-900 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold text-purple-900 dark:text-purple-100">用户数量</h3>
|
|
||||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">5,678</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'products' && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">产品列表</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{['iPhone 15 Pro', 'MacBook Air', 'iPad Pro', 'Apple Watch'].map((product) => (
|
|
||||||
<div
|
|
||||||
key={product}
|
|
||||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white">{product}</h3>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">产品描述...</p>
|
|
||||||
</div>
|
|
||||||
<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">
|
|
||||||
查看详情
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'orders' && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">订单管理</h2>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
订单号
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
客户
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
状态
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
金额
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
|
|
||||||
{[
|
|
||||||
{ id: '#001', customer: '张三', status: '已发货', amount: '¥1,299' },
|
|
||||||
{ id: '#002', customer: '李四', status: '处理中', amount: '¥2,599' },
|
|
||||||
{ id: '#003', customer: '王五', status: '已完成', amount: '¥899' },
|
|
||||||
].map((order) => (
|
|
||||||
<tr key={order.id}>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
|
||||||
{order.id}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
|
||||||
{order.customer}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
|
||||||
order.status === '已完成'
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|
||||||
: order.status === '已发货'
|
|
||||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
|
||||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{order.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
|
||||||
{order.amount}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'analytics' && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">数据分析</h2>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
销售趋势
|
|
||||||
</h3>
|
|
||||||
<div className="h-32 bg-linear-to-r from-blue-400 to-purple-500 rounded-lg flex items-center justify-center text-white">
|
|
||||||
📈 图表占位符
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
用户分布
|
|
||||||
</h3>
|
|
||||||
<div className="h-32 bg-linear-to-r from-green-400 to-blue-500 rounded-lg flex items-center justify-center text-white">
|
|
||||||
🗺️ 地图占位符
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">设置</h2>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
通知设置
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
defaultChecked
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-gray-700 dark:text-gray-300">邮件通知</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-gray-700 dark:text-gray-300">短信通知</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
隐私设置
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
defaultChecked
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-gray-700 dark:text-gray-300">公开个人资料</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
defaultChecked
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-gray-700 dark:text-gray-300">允许搜索</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="mt-8 flex flex-wrap gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
打开模态框
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setBreadcrumbs([...breadcrumbs, `新页面${breadcrumbs.length}`])}
|
|
||||||
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
添加面包屑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const newNotif = {
|
|
||||||
id: Date.now(),
|
|
||||||
title: '新通知',
|
|
||||||
content: `这是第 ${notifications.length + 1} 条通知`,
|
|
||||||
time: '刚刚',
|
|
||||||
unread: true,
|
|
||||||
}
|
|
||||||
setNotifications((prev) => [newNotif, ...prev])
|
|
||||||
}}
|
|
||||||
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
添加通知
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 通知列表 */}
|
|
||||||
{notifications.length > 0 && (
|
|
||||||
<div className="mt-8">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">通知中心</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{notifications.map((notification) => (
|
|
||||||
<div
|
|
||||||
key={notification.id}
|
|
||||||
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
|
|
||||||
notification.unread
|
|
||||||
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700'
|
|
||||||
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
onClick={() => markNotificationAsRead(notification.id)}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{notification.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
|
||||||
{notification.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{notification.time}
|
|
||||||
</span>
|
|
||||||
{notification.unread && (
|
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 模态框 */}
|
|
||||||
{isModalOpen && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">模态框标题</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
|
||||||
这是一个模态框示例,用于测试弹窗交互。Agent 需要能够识别并操作这类覆盖层元素。
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
确认
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 返回链接 */}
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
|
||||||
← 返回测试页面列表
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Route, Switch } from 'wouter'
|
|
||||||
|
|
||||||
import AsyncTestPage from './async-test'
|
|
||||||
import ComplexTestPage from './complex-test'
|
|
||||||
import ErrorTestPage from './error-test'
|
|
||||||
import FormTestPage from './form-test'
|
|
||||||
import IndexPage from './index'
|
|
||||||
import ListTestPage from './list-test'
|
|
||||||
import NavigationTestPage from './navigation-test'
|
|
||||||
|
|
||||||
export default function Router() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Switch>
|
|
||||||
<Route path="/form" component={FormTestPage} />
|
|
||||||
<Route path="/navigation" component={NavigationTestPage} />
|
|
||||||
<Route path="/list" component={ListTestPage} />
|
|
||||||
<Route path="/complex" component={ComplexTestPage} />
|
|
||||||
<Route path="/errors" component={ErrorTestPage} />
|
|
||||||
<Route path="/async" component={AsyncTestPage} />
|
|
||||||
<Route path="" component={IndexPage} />
|
|
||||||
</Switch>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user