mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 添加初次安装引导向导
- 实现 5 步引导流程:欢迎 → 选择身份 → 选择插件 → 安装进度 → 完成 - 支持两种用户群体:程序员(预选 config-switch + flow-monitor)和普通用户 - 批量顺序安装选中的插件,显示实时进度 - 设置页添加"重新引导"按钮,可随时重新运行向导 - 重构浏览器拦截器和插件管理器代码 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,916 @@
|
||||
# ProxyCast Connect - 中转商生态合作方案
|
||||
|
||||
> 版本: 1.0.0
|
||||
> 日期: 2026-01-05
|
||||
> 状态: Draft
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
AI API 中转市场蓬勃发展,涌现出大量中转服务商:
|
||||
- 基于 new-api、one-api 搭建的中转站
|
||||
- ccr、claude-relay-service 等专用中转
|
||||
- 各类 Token 分销平台
|
||||
|
||||
这些中转商面临的痛点:
|
||||
1. **用户配置复杂** - 用户需要手动配置 base_url、协议等
|
||||
2. **接入门槛高** - 不同客户端(Cursor、Claude Code)配置方式不同
|
||||
3. **缺乏差异化** - 各家中转体验雷同
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
**ProxyCast Connect** 是一套中转商生态合作方案:
|
||||
|
||||
1. **一键配置** - 用户点击链接即可完成 ProxyCast + Key 配置
|
||||
2. **品牌展示** - 中转商在 ProxyCast 内有专属展示位
|
||||
3. **双向引流** - 中转商推广 ProxyCast,ProxyCast 为中转商导流
|
||||
4. **生态共赢** - 降低用户门槛,提升转化率
|
||||
|
||||
### 1.3 核心价值
|
||||
|
||||
| 角色 | 价值 |
|
||||
|------|------|
|
||||
| **中转商** | 用户转化率提升、品牌曝光、差异化竞争 |
|
||||
| **用户** | 一键配置、开箱即用、统一管理多个中转 |
|
||||
| **ProxyCast** | 用户增长、生态繁荣、市场占有率 |
|
||||
|
||||
---
|
||||
|
||||
## 二、产品方案
|
||||
|
||||
### 2.1 一键配置流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ ProxyCast Connect 流程 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 中转商用户后台 │ │
|
||||
│ │ │ │
|
||||
│ │ 我的 API Key: sk-xxxxxxxxxxxxxxxx [复制] │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 🚀 快速接入 │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
|
||||
│ │ │ │ ProxyCast │ │ Cursor │ │Claude Code │ │ │ │
|
||||
│ │ │ │ [一键配置] │ │ [查看教程] │ │ [查看教程] │ │ │ │
|
||||
│ │ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ 用户点击 [一键配置] │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 浏览器打开 proxycast:// 协议 │ │
|
||||
│ │ │ │
|
||||
│ │ proxycast://connect? │ │
|
||||
│ │ relay=xxx-relay& │ │
|
||||
│ │ key=sk-xxxxxxxx& │ │
|
||||
│ │ name=我的Key │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ├─── 已安装 ProxyCast ───────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ProxyCast 打开,显示确认弹窗 │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ 🔗 添加 API Key │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ 来源: XXX中转站 │ │ │
|
||||
│ │ │ Key: sk-xxxx...xxxx │ │ │
|
||||
│ │ │ 名称: 我的Key │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ [取消] [确认添加] │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ 配置完成,可以使用 ✓ │ │
|
||||
│ │ │ │
|
||||
│ └─── 未安装 ProxyCast ───────────────────────────────────────┐ │
|
||||
│ │ │
|
||||
│ 跳转 proxycast.dev/download?connect=... │ │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ 下载并安装 ProxyCast │ │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ 首次启动,自动读取参数并配置 │ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Deep Link 协议设计
|
||||
|
||||
#### 2.2.1 协议格式
|
||||
|
||||
```
|
||||
proxycast://connect?relay={relay_id}&key={api_key}&name={key_name}
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `relay` | ✅ | 中转商 ID(需在 ProxyCast 注册) |
|
||||
| `key` | ✅ | API Key |
|
||||
| `name` | ❌ | Key 名称(默认使用中转商名称) |
|
||||
| `ref` | ❌ | 推广码(用于统计) |
|
||||
|
||||
#### 2.2.2 示例
|
||||
|
||||
```
|
||||
# 基础用法
|
||||
proxycast://connect?relay=openrouter&key=sk-or-v1-xxxx
|
||||
|
||||
# 带名称
|
||||
proxycast://connect?relay=siliconflow&key=sk-xxxx&name=硅基流动-主账号
|
||||
|
||||
# 带推广码
|
||||
proxycast://connect?relay=myrelay&key=sk-xxxx&ref=promo2024
|
||||
```
|
||||
|
||||
#### 2.2.3 Web 中转页面
|
||||
|
||||
对于不支持自定义协议的场景,提供 Web 中转:
|
||||
|
||||
```
|
||||
https://proxycast.dev/connect?relay=xxx&key=sk-xxxx
|
||||
```
|
||||
|
||||
页面逻辑:
|
||||
1. 尝试唤起 `proxycast://` 协议
|
||||
2. 如果 3 秒内未成功,显示下载引导
|
||||
3. 下载链接带上 connect 参数,安装后首次启动自动配置
|
||||
|
||||
### 2.3 中转商注册(开源方式)
|
||||
|
||||
ProxyCast 是开源软件,中转商通过 **GitHub PR** 方式注册,无需网站注册。
|
||||
|
||||
#### 2.3.1 注册仓库
|
||||
|
||||
中转商注册信息统一管理在独立仓库:
|
||||
|
||||
```
|
||||
https://github.com/AiClientProxy/transit-service-provider
|
||||
```
|
||||
|
||||
#### 2.3.2 注册流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 中转商注册流程(GitHub PR) │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Step 1: Fork 仓库 │
|
||||
│ ───────────────── │
|
||||
│ Fork https://github.com/AiClientProxy/transit-service-provider │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 2: 创建配置文件 │
|
||||
│ ───────────────── │
|
||||
│ 在 providers/ 目录下创建 {your-id}.json │
|
||||
│ 参考 providers/_example.json 模板 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 3: 提交 PR │
|
||||
│ ───────────────── │
|
||||
│ 提交 Pull Request 到主仓库 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 4: 自动化检查 │
|
||||
│ ───────────────── │
|
||||
│ GitHub Actions 自动验证: │
|
||||
│ ├── JSON Schema 验证 │
|
||||
│ ├── Logo 图片可访问性 │
|
||||
│ └── API 地址可访问性 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 5: 社区审核 │
|
||||
│ ───────────────── │
|
||||
│ 维护者审核并合并 PR │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 6: 自动发布 │
|
||||
│ ───────────────── │
|
||||
│ 合并后自动构建 registry.json │
|
||||
│ ProxyCast 客户端定期同步更新 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2.3.3 配置文件格式
|
||||
|
||||
在 `providers/` 目录下创建 `{your-id}.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myrelay",
|
||||
"name": "我的中转站",
|
||||
"description": "稳定、便宜、快速的 AI API 中转服务",
|
||||
|
||||
"branding": {
|
||||
"logo": "https://myrelay.com/logo.png",
|
||||
"color": "#6366f1"
|
||||
},
|
||||
|
||||
"links": {
|
||||
"homepage": "https://myrelay.com",
|
||||
"register": "https://myrelay.com/register",
|
||||
"recharge": "https://myrelay.com/recharge",
|
||||
"docs": "https://docs.myrelay.com",
|
||||
"status": "https://status.myrelay.com"
|
||||
},
|
||||
|
||||
"api": {
|
||||
"base_url": "https://api.myrelay.com/v1",
|
||||
"protocol": "openai",
|
||||
"auth_header": "Authorization",
|
||||
"auth_prefix": "Bearer "
|
||||
},
|
||||
|
||||
"contact": {
|
||||
"email": "support@myrelay.com",
|
||||
"telegram": "@myrelay"
|
||||
},
|
||||
|
||||
"features": {
|
||||
"streaming": true,
|
||||
"models_endpoint": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3.4 字段说明
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | ✅ | 唯一标识,小写字母、数字、连字符 |
|
||||
| `name` | ✅ | 显示名称 |
|
||||
| `description` | ✅ | 简短描述,≤100 字 |
|
||||
| `branding.logo` | ✅ | Logo URL,256x256 PNG |
|
||||
| `branding.color` | ❌ | 主题色,默认 `#6366f1` |
|
||||
| `links.homepage` | ✅ | 官网地址 |
|
||||
| `links.register` | ❌ | 注册页面 |
|
||||
| `links.recharge` | ❌ | 充值页面 |
|
||||
| `links.docs` | ❌ | 文档地址 |
|
||||
| `links.status` | ❌ | 状态页面 |
|
||||
| `api.base_url` | ✅ | API 地址(必须 HTTPS) |
|
||||
| `api.protocol` | ✅ | 协议:`openai` 或 `anthropic` |
|
||||
| `contact.email` | ✅ | 联系邮箱 |
|
||||
|
||||
#### 2.3.5 审核标准
|
||||
|
||||
PR 合并前需满足:
|
||||
|
||||
- [ ] JSON Schema 验证通过
|
||||
- [ ] 文件名与 `id` 字段一致
|
||||
- [ ] Logo 图片可访问(256x256 PNG)
|
||||
- [ ] API 地址使用 HTTPS
|
||||
- [ ] 官网可访问
|
||||
- [ ] 联系方式有效
|
||||
|
||||
#### 2.3.6 仓库结构
|
||||
|
||||
```
|
||||
transit-service-provider/
|
||||
├── README.md # 注册指南
|
||||
├── providers/ # 中转商配置目录
|
||||
│ ├── _example.json # 示例配置(不会被加载)
|
||||
│ ├── openrouter.json # OpenRouter
|
||||
│ ├── siliconflow.json # 硅基流动
|
||||
│ └── ... # 其他中转商
|
||||
├── schema/
|
||||
│ └── provider.schema.json # JSON Schema
|
||||
├── scripts/
|
||||
│ ├── validate.js # 验证脚本
|
||||
│ ├── check-api.js # API 检查脚本
|
||||
│ └── build-registry.js # 构建注册表
|
||||
├── dist/
|
||||
│ └── registry.json # 构建产物(自动生成)
|
||||
└── .github/
|
||||
├── workflows/
|
||||
│ └── validate.yml # PR 自动验证
|
||||
└── PULL_REQUEST_TEMPLATE.md
|
||||
```
|
||||
|
||||
#### 2.3.7 注册表同步
|
||||
|
||||
ProxyCast 客户端通过以下方式获取中转商列表:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/AiClientProxy/transit-service-provider/main/dist/registry.json
|
||||
```
|
||||
|
||||
客户端定期(每 24 小时)同步更新,也可手动刷新
|
||||
|
||||
### 2.4 中转商品牌展示
|
||||
|
||||
#### 2.4.1 扩展市场展示
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 扩展市场 > 中转服务 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ [Logo] │ │ [Logo] │ │ [Logo] │ │ [Logo] │ │
|
||||
│ │ OpenRouter │ │ 硅基流动 │ │ XXX中转 │ │ YYY中转 │ │
|
||||
│ │ ✓ 官方认证 │ │ ✓ 官方认证 │ │ │ │ │ │
|
||||
│ │ ⭐ 4.9 │ │ ⭐ 4.8 │ │ ⭐ 4.5 │ │ ⭐ 4.3 │ │
|
||||
│ │ [安装] │ │ [安装] │ │ [安装] │ │ [安装] │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2.4.2 已安装中转商页面
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ XXX中转站 [设置] [×]│
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [Logo] XXX中转站 │ │
|
||||
│ │ 稳定、便宜、快速的 AI API 中转服务 │ │
|
||||
│ │ │ │
|
||||
│ │ [官网] [充值] [文档] [状态] │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 我的 API Keys │ │
|
||||
│ ├─────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 主账号 sk-xxxx...xxxx │ │ │
|
||||
│ │ │ ● 正常 [设为默认] [删除] │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 备用账号 sk-yyyy...yyyy │ │ │
|
||||
│ │ │ ● 正常 [设为默认] [删除] │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ [+ 添加 Key] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 📢 公告 │ │
|
||||
│ ├─────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ │ 🎉 新用户注册送 $5 额度! │ │
|
||||
│ │ 📅 2026-01-01 ~ 2026-01-31 │ │
|
||||
│ │ [立即注册] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、技术实现
|
||||
|
||||
### 3.1 Deep Link 注册 (Tauri)
|
||||
|
||||
```rust
|
||||
// src-tauri/src/main.rs
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
// 注册自定义协议
|
||||
.register_uri_scheme_protocol("proxycast", |app, request| {
|
||||
handle_deep_link(app, request)
|
||||
})
|
||||
// ...
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn handle_deep_link(app: &AppHandle, request: &HttpRequest) -> Result<Response> {
|
||||
let url = request.uri();
|
||||
|
||||
// 解析 proxycast://connect?relay=xxx&key=xxx
|
||||
if url.path() == "connect" {
|
||||
let params = parse_query_params(url.query());
|
||||
|
||||
// 发送事件到前端
|
||||
app.emit_all("deep-link-connect", ConnectPayload {
|
||||
relay: params.get("relay"),
|
||||
key: params.get("key"),
|
||||
name: params.get("name"),
|
||||
ref_code: params.get("ref"),
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(Response::default())
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 前端处理
|
||||
|
||||
```typescript
|
||||
// src/hooks/useDeepLink.ts
|
||||
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ConnectPayload {
|
||||
relay: string;
|
||||
key: string;
|
||||
name?: string;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
export function useDeepLink() {
|
||||
useEffect(() => {
|
||||
const unlisten = listen<ConnectPayload>('deep-link-connect', (event) => {
|
||||
const { relay, key, name, ref } = event.payload;
|
||||
|
||||
// 显示确认弹窗
|
||||
showConnectConfirmDialog({
|
||||
relay,
|
||||
key,
|
||||
name,
|
||||
ref,
|
||||
onConfirm: () => addApiKey(relay, key, name),
|
||||
onCancel: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
return () => { unlisten.then(fn => fn()); };
|
||||
}, []);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 确认弹窗组件
|
||||
|
||||
```tsx
|
||||
// src/components/ConnectConfirmDialog.tsx
|
||||
|
||||
interface Props {
|
||||
relay: RelayInfo;
|
||||
key: string;
|
||||
name?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConnectConfirmDialog({ relay, key, name, onConfirm, onCancel }: Props) {
|
||||
const maskedKey = maskApiKey(key); // sk-xxxx...xxxx
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onCancel}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LinkIcon className="w-5 h-5" />
|
||||
添加 API Key
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={relay.logo} className="w-10 h-10 rounded" />
|
||||
<div>
|
||||
<div className="font-medium">{relay.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{relay.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted p-3 rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Key</span>
|
||||
<span className="font-mono">{maskedKey}</span>
|
||||
</div>
|
||||
{name && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">名称</span>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确认后,此 Key 将被添加到 ProxyCast,您可以立即开始使用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>取消</Button>
|
||||
<Button onClick={onConfirm}>确认添加</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 中转商注册表(从 GitHub 加载)
|
||||
|
||||
```rust
|
||||
// src-tauri/src/relay/registry.rs
|
||||
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 注册表数据结构(对应 registry.json)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RelayRegistry {
|
||||
pub version: String,
|
||||
pub updated_at: String,
|
||||
pub providers: Vec<RelayInfo>,
|
||||
|
||||
#[serde(skip)]
|
||||
index: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RelayInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub branding: RelayBranding,
|
||||
pub links: RelayLinks,
|
||||
pub api: RelayApi,
|
||||
pub contact: RelayContact,
|
||||
#[serde(default)]
|
||||
pub features: RelayFeatures,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RelayBranding {
|
||||
pub logo: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
fn default_color() -> String { "#6366f1".to_string() }
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RelayLinks {
|
||||
pub homepage: String,
|
||||
pub register: Option<String>,
|
||||
pub recharge: Option<String>,
|
||||
pub docs: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RelayApi {
|
||||
pub base_url: String,
|
||||
pub protocol: String,
|
||||
#[serde(default = "default_auth_header")]
|
||||
pub auth_header: String,
|
||||
#[serde(default = "default_auth_prefix")]
|
||||
pub auth_prefix: String,
|
||||
}
|
||||
|
||||
fn default_auth_header() -> String { "Authorization".to_string() }
|
||||
fn default_auth_prefix() -> String { "Bearer ".to_string() }
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RelayContact {
|
||||
pub email: String,
|
||||
pub telegram: Option<String>,
|
||||
pub discord: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RelayFeatures {
|
||||
#[serde(default = "default_true")]
|
||||
pub streaming: bool,
|
||||
#[serde(default)]
|
||||
pub models_endpoint: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
impl RelayRegistry {
|
||||
/// 注册表 URL(从 GitHub 加载)
|
||||
const REGISTRY_URL: &'static str =
|
||||
"https://raw.githubusercontent.com/AiClientProxy/transit-service-provider/main/dist/registry.json";
|
||||
|
||||
/// 从 GitHub 加载中转商列表
|
||||
pub async fn load_from_github(&mut self) -> Result<()> {
|
||||
let response = reqwest::get(Self::REGISTRY_URL).await?;
|
||||
let mut registry: RelayRegistry = response.json().await?;
|
||||
|
||||
// 构建索引
|
||||
for (i, provider) in registry.providers.iter().enumerate() {
|
||||
registry.index.insert(provider.id.clone(), i);
|
||||
}
|
||||
|
||||
*self = registry;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取中转商信息
|
||||
pub fn get(&self, id: &str) -> Option<&RelayInfo> {
|
||||
self.index.get(id).map(|&i| &self.providers[i])
|
||||
}
|
||||
|
||||
/// 验证 relay_id 是否有效
|
||||
pub fn is_valid(&self, id: &str) -> bool {
|
||||
self.index.contains_key(id)
|
||||
}
|
||||
|
||||
/// 获取所有中转商
|
||||
pub fn list(&self) -> &[RelayInfo] {
|
||||
&self.providers
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、中转商接入指南
|
||||
|
||||
### 4.1 接入流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 中转商接入流程(GitHub PR) │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Step 1: Fork 仓库 │
|
||||
│ ───────────────── │
|
||||
│ Fork https://github.com/AiClientProxy/transit-service-provider │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 2: 创建配置文件 │
|
||||
│ ───────────────── │
|
||||
│ 在 providers/ 目录下创建 {your-id}.json │
|
||||
│ 参考 providers/_example.json 模板 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 3: 提交 PR │
|
||||
│ ───────────────── │
|
||||
│ 提交 Pull Request,填写 PR 模板 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 4: 等待审核 │
|
||||
│ ───────────────── │
|
||||
│ GitHub Actions 自动验证 + 维护者审核 │
|
||||
│ 通常 1-3 个工作日 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 5: 合并上线 │
|
||||
│ ───────────────── │
|
||||
│ PR 合并后自动构建 registry.json │
|
||||
│ ProxyCast 客户端自动同步 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Step 6: 集成使用 │
|
||||
│ ───────────────── │
|
||||
│ 在用户后台添加"一键配置"按钮 │
|
||||
│ 用户可以一键配置 ProxyCast │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 集成方式
|
||||
|
||||
#### 方式一:直接链接
|
||||
|
||||
最简单的方式,直接在页面放置链接:
|
||||
|
||||
```html
|
||||
<a href="proxycast://connect?relay=myrelay&key=USER_API_KEY">
|
||||
一键配置 ProxyCast
|
||||
</a>
|
||||
```
|
||||
|
||||
#### 方式二:JavaScript SDK
|
||||
|
||||
提供更好的用户体验:
|
||||
|
||||
```html
|
||||
<script src="https://proxycast.dev/sdk/connect.js"></script>
|
||||
|
||||
<button onclick="ProxyCast.connect({ relay: 'myrelay', key: userApiKey })">
|
||||
一键配置 ProxyCast
|
||||
</button>
|
||||
```
|
||||
|
||||
SDK 功能:
|
||||
- 自动检测 ProxyCast 是否安装
|
||||
- 未安装时显示下载引导
|
||||
- 支持回调函数
|
||||
|
||||
```javascript
|
||||
ProxyCast.connect({
|
||||
relay: 'myrelay',
|
||||
key: userApiKey,
|
||||
name: '我的Key',
|
||||
onSuccess: () => {
|
||||
showToast('配置成功!');
|
||||
},
|
||||
onNotInstalled: () => {
|
||||
// 显示自定义的下载引导
|
||||
showDownloadModal();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 方式三:配置文件下载
|
||||
|
||||
生成 `.proxycast` 配置文件供用户下载:
|
||||
|
||||
```javascript
|
||||
function downloadConfig(apiKey) {
|
||||
const config = {
|
||||
relay: 'myrelay',
|
||||
key: apiKey,
|
||||
name: '我的Key'
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(config)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'myrelay.proxycast';
|
||||
a.click();
|
||||
}
|
||||
```
|
||||
|
||||
用户双击 `.proxycast` 文件,ProxyCast 自动打开并导入配置。
|
||||
|
||||
### 4.3 品牌素材要求
|
||||
|
||||
| 素材 | 规格 | 说明 |
|
||||
|------|------|------|
|
||||
| Logo | 256x256 PNG | 透明背景,正方形 |
|
||||
| 主题色 | HEX 色值 | 用于 UI 强调色 |
|
||||
| 简介 | ≤50 字 | 一句话描述 |
|
||||
| 详细描述 | ≤200 字 | 详细介绍 |
|
||||
|
||||
### 4.4 API 要求
|
||||
|
||||
中转商的 API 需要满足:
|
||||
|
||||
| 要求 | 说明 |
|
||||
|------|------|
|
||||
| 协议兼容 | OpenAI 或 Anthropic 协议 |
|
||||
| HTTPS | 必须使用 HTTPS |
|
||||
| 模型列表 | 提供 `/models` 端点(可选) |
|
||||
| 稳定性 | 99% 以上可用性 |
|
||||
|
||||
---
|
||||
|
||||
## 五、安全设计
|
||||
|
||||
### 5.1 Deep Link 安全
|
||||
|
||||
| 风险 | 防护措施 |
|
||||
|------|---------|
|
||||
| 恶意链接 | relay_id 必须在注册表中存在 |
|
||||
| Key 泄露 | 确认弹窗显示脱敏 Key,用户确认后才添加 |
|
||||
| 钓鱼攻击 | 显示中转商完整信息,用户可核实 |
|
||||
|
||||
### 5.2 确认弹窗
|
||||
|
||||
所有通过 Deep Link 添加的 Key 必须经过用户确认:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ⚠️ 安全提示 │
|
||||
│ │
|
||||
│ 您正在添加来自 [XXX中转站] 的 API Key │
|
||||
│ │
|
||||
│ 请确认: │
|
||||
│ 1. 您确实在 XXX中转站 申请了此 Key │
|
||||
│ 2. 您信任 XXX中转站 的服务 │
|
||||
│ │
|
||||
│ Key: sk-xxxx...xxxx │
|
||||
│ │
|
||||
│ [取消] [我确认,添加此 Key] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 中转商审核
|
||||
|
||||
| 审核项 | 说明 |
|
||||
|--------|------|
|
||||
| 身份核实 | 验证中转商运营者身份 |
|
||||
| API 测试 | 测试 API 可用性和兼容性 |
|
||||
| 安全检查 | 检查是否有恶意行为历史 |
|
||||
| 持续监控 | 定期检查服务状态 |
|
||||
|
||||
---
|
||||
|
||||
## 七、商业模式
|
||||
|
||||
### 7.1 免费服务
|
||||
|
||||
基础功能对中转商免费:
|
||||
- 注册和审核
|
||||
- Deep Link 功能
|
||||
- 扩展市场展示
|
||||
- 基础统计
|
||||
|
||||
### 7.2 增值服务(可选)
|
||||
|
||||
| 服务 | 说明 | 定价 |
|
||||
|------|------|------|
|
||||
| 官方认证 | 显示认证徽章,优先展示 | 待定 |
|
||||
| 推荐位 | 扩展市场首页推荐 | 待定 |
|
||||
| 高级统计 | 详细用户行为分析 | 待定 |
|
||||
| 定制 UI | 自定义品牌页面 | 待定 |
|
||||
|
||||
### 7.3 合作模式
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 合作共赢 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 中转商 ProxyCast │
|
||||
│ ────── ───────── │
|
||||
│ │
|
||||
│ 推广 ProxyCast ──────────▶ 用户增长 │
|
||||
│ │
|
||||
│ 用户转化率提升 ◀────────── 一键配置功能 │
|
||||
│ │
|
||||
│ 品牌曝光 ◀────────── 扩展市场展示 │
|
||||
│ │
|
||||
│ 用户粘性 ◀────────── 统一管理体验 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实施计划
|
||||
|
||||
### 6.1 Phase 1: MVP (2 周)
|
||||
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| Deep Link 协议 | 实现 `proxycast://connect` |
|
||||
| 确认弹窗 | 安全确认 UI |
|
||||
| 注册表加载 | 从 GitHub 加载 registry.json |
|
||||
| transit-service-provider 仓库 | 初始化仓库结构和 CI |
|
||||
|
||||
### 6.2 Phase 2: 完善 (2 周)
|
||||
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| Web 中转页 | proxycast.dev/connect |
|
||||
| JS SDK | 前端集成 SDK |
|
||||
| 扩展市场展示 | 中转商品牌页 |
|
||||
| .proxycast 文件支持 | 配置文件导入 |
|
||||
|
||||
### 6.3 Phase 3: 生态 (持续)
|
||||
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| 中转商招募 | 邀请主流中转商提交 PR |
|
||||
| 文档完善 | 接入指南、最佳实践 |
|
||||
| 社区运营 | GitHub Discussions |
|
||||
|
||||
---
|
||||
|
||||
## 七、总结
|
||||
|
||||
### 7.1 核心价值
|
||||
|
||||
**ProxyCast Connect** 通过一键配置功能,实现:
|
||||
|
||||
1. **用户** - 零配置接入,开箱即用
|
||||
2. **中转商** - 用户转化率提升,品牌曝光
|
||||
3. **ProxyCast** - 用户增长,生态繁荣
|
||||
|
||||
### 7.2 开源优势
|
||||
|
||||
| 优势 | 说明 |
|
||||
|------|------|
|
||||
| 透明 | 所有中转商信息公开可查 |
|
||||
| 去中心化 | 无需依赖 ProxyCast 官方服务器 |
|
||||
| 社区驱动 | 任何人都可以贡献和审核 |
|
||||
| 低成本 | 无需维护注册后台 |
|
||||
|
||||
### 7.3 关键指标
|
||||
|
||||
| 指标 | 目标 |
|
||||
|------|------|
|
||||
| 接入中转商数量 | 20+ |
|
||||
| GitHub Stars | 1,000+ |
|
||||
| 一键配置转化率 | 80%+ |
|
||||
|
||||
### 7.4 相关仓库
|
||||
|
||||
| 仓库 | 说明 |
|
||||
|------|------|
|
||||
| [proxycast](https://github.com/AiClientProxy/proxycast) | ProxyCast 主项目 |
|
||||
| [transit-service-provider](https://github.com/AiClientProxy/transit-service-provider) | 中转商注册仓库 |
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.28.0",
|
||||
"version": "0.29.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+1
-1
@@ -3674,7 +3674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.28.0"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.28.0"
|
||||
version = "0.29.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -7,7 +7,6 @@ use tokio::sync::RwLock;
|
||||
|
||||
use crate::agent::NativeAgentState;
|
||||
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
|
||||
use crate::commands::browser_interceptor_cmd::BrowserInterceptorState;
|
||||
use crate::commands::flow_monitor_cmd::{
|
||||
BatchOperationsState, BookmarkManagerState, EnhancedStatsServiceState, FlowInterceptorState,
|
||||
FlowMonitorState, FlowQueryServiceState, FlowReplayerState, QuickFilterManagerState,
|
||||
@@ -128,7 +127,6 @@ pub struct AppStates {
|
||||
pub bookmark_manager: BookmarkManagerState,
|
||||
pub enhanced_stats_service: EnhancedStatsServiceState,
|
||||
pub batch_operations: BatchOperationsState,
|
||||
pub browser_interceptor: BrowserInterceptorState,
|
||||
pub native_agent: NativeAgentState,
|
||||
pub oauth_plugin_manager: crate::commands::oauth_plugin_cmd::OAuthPluginManagerState,
|
||||
pub orchestrator: OrchestratorState,
|
||||
@@ -183,7 +181,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
// 遥测系统
|
||||
let (telemetry_state, shared_stats, shared_tokens, shared_logger) = init_telemetry(config)?;
|
||||
|
||||
// Flow Monitor 系统
|
||||
// Flow Monitor 系统(根据插件安装状态启用/禁用)
|
||||
let (
|
||||
flow_monitor_state,
|
||||
flow_query_service_state,
|
||||
@@ -196,10 +194,9 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
batch_operations_state,
|
||||
flow_monitor_arc,
|
||||
flow_interceptor_arc,
|
||||
) = init_flow_monitor(&provider_pool_service_state, &db)?;
|
||||
) = init_flow_monitor(&provider_pool_service_state, &db, &plugin_installer_state)?;
|
||||
|
||||
// 其他状态
|
||||
let browser_interceptor_state = BrowserInterceptorState::default();
|
||||
let native_agent_state = NativeAgentState::new();
|
||||
let oauth_plugin_manager_state =
|
||||
crate::commands::oauth_plugin_cmd::OAuthPluginManagerState::with_defaults();
|
||||
@@ -236,7 +233,6 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
bookmark_manager: bookmark_manager_state,
|
||||
enhanced_stats_service: enhanced_stats_service_state,
|
||||
batch_operations: batch_operations_state,
|
||||
browser_interceptor: browser_interceptor_state,
|
||||
native_agent: native_agent_state,
|
||||
oauth_plugin_manager: oauth_plugin_manager_state,
|
||||
orchestrator: orchestrator_state,
|
||||
@@ -327,10 +323,13 @@ fn init_telemetry(
|
||||
}
|
||||
|
||||
/// 初始化 Flow Monitor 系统
|
||||
///
|
||||
/// 如果 flow-monitor 插件已安装,则启用监控功能;否则禁用。
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn init_flow_monitor(
|
||||
provider_pool_service_state: &ProviderPoolServiceState,
|
||||
db: &DbConnection,
|
||||
plugin_installer_state: &PluginInstallerState,
|
||||
) -> Result<
|
||||
(
|
||||
FlowMonitorState,
|
||||
@@ -347,7 +346,21 @@ fn init_flow_monitor(
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let flow_monitor_config = FlowMonitorConfig::default();
|
||||
// 检查 flow-monitor 插件是否已安装
|
||||
let is_plugin_installed = {
|
||||
let installer = plugin_installer_state.0.blocking_read();
|
||||
installer.is_installed("flow-monitor").unwrap_or(false)
|
||||
};
|
||||
|
||||
// 根据插件安装状态设置 enabled
|
||||
let mut flow_monitor_config = FlowMonitorConfig::default();
|
||||
flow_monitor_config.enabled = is_plugin_installed;
|
||||
|
||||
if is_plugin_installed {
|
||||
tracing::info!("[启动] flow-monitor 插件已安装,启用 Flow 监控");
|
||||
} else {
|
||||
tracing::info!("[启动] flow-monitor 插件未安装,禁用 Flow 监控");
|
||||
}
|
||||
|
||||
// 初始化文件存储
|
||||
let data_dir = dirs::data_dir()
|
||||
|
||||
@@ -67,7 +67,6 @@ pub fn run() {
|
||||
bookmark_manager: bookmark_manager_state,
|
||||
enhanced_stats_service: enhanced_stats_service_state,
|
||||
batch_operations: batch_operations_state,
|
||||
browser_interceptor: browser_interceptor_state,
|
||||
native_agent: native_agent_state,
|
||||
oauth_plugin_manager: oauth_plugin_manager_state,
|
||||
orchestrator: orchestrator_state,
|
||||
@@ -101,21 +100,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args);
|
||||
|
||||
// 处理传入的 URL 参数
|
||||
for arg in args.iter().skip(1) {
|
||||
// 跳过第一个参数(程序路径)
|
||||
if arg.starts_with("http://") || arg.starts_with("https://") {
|
||||
tracing::info!("[单实例] 收到 URL: {}", arg);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::browser_interceptor::platform::macos::handle_deep_link_url(
|
||||
arg.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将窗口带到前台
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
@@ -123,12 +107,6 @@ pub fn run() {
|
||||
}
|
||||
}));
|
||||
|
||||
// 添加 Deep Link 插件(用于浏览器拦截)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_deep_link::init());
|
||||
}
|
||||
|
||||
builder
|
||||
.manage(state)
|
||||
.manage(logs)
|
||||
@@ -153,7 +131,6 @@ pub fn run() {
|
||||
.manage(bookmark_manager_state)
|
||||
.manage(enhanced_stats_service_state)
|
||||
.manage(batch_operations_state)
|
||||
.manage(browser_interceptor_state)
|
||||
.manage(native_agent_state)
|
||||
.manage(oauth_plugin_manager_state)
|
||||
.manage(orchestrator_state)
|
||||
@@ -183,21 +160,6 @@ pub fn run() {
|
||||
}
|
||||
})
|
||||
.setup(move |app| {
|
||||
// 设置 deep-link 事件监听(用于浏览器拦截)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
let _listener_id = app.deep_link().on_open_url(|event| {
|
||||
for url in event.urls() {
|
||||
tracing::info!("[Deep Link] 收到 URL: {}", url);
|
||||
crate::browser_interceptor::platform::macos::handle_deep_link_url(
|
||||
url.to_string(),
|
||||
);
|
||||
}
|
||||
});
|
||||
tracing::info!("[启动] Deep Link 事件监听已设置");
|
||||
}
|
||||
|
||||
// 初始化托盘管理器
|
||||
// Requirements 1.4: 应用启动时显示停止状态图标
|
||||
match TrayManager::new(app.handle()) {
|
||||
@@ -768,7 +730,6 @@ pub fn run() {
|
||||
commands::browser_interceptor_cmd::validate_browser_interceptor_config,
|
||||
commands::browser_interceptor_cmd::is_browser_interceptor_running,
|
||||
commands::browser_interceptor_cmd::get_browser_interceptor_statistics,
|
||||
// Browser Interceptor notification commands
|
||||
commands::browser_interceptor_cmd::show_notification,
|
||||
commands::browser_interceptor_cmd::show_url_intercept_notification,
|
||||
commands::browser_interceptor_cmd::show_status_notification,
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::sync::Arc;
|
||||
use tauri::{App, Manager};
|
||||
|
||||
use crate::agent::NativeAgentState;
|
||||
use crate::commands::browser_interceptor_cmd::BrowserInterceptorState;
|
||||
use crate::commands::oauth_plugin_cmd::OAuthPluginManagerState;
|
||||
use crate::database;
|
||||
use crate::flow_monitor::FlowInterceptor;
|
||||
@@ -33,19 +32,6 @@ pub fn setup_app(
|
||||
flow_monitor: Arc<crate::flow_monitor::FlowMonitor>,
|
||||
flow_interceptor: Arc<FlowInterceptor>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 设置 deep-link 事件监听(用于浏览器拦截)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
let _listener_id = app.deep_link().on_open_url(|event| {
|
||||
for url in event.urls() {
|
||||
tracing::info!("[Deep Link] 收到 URL: {}", url);
|
||||
crate::browser_interceptor::platform::macos::handle_deep_link_url(url.to_string());
|
||||
}
|
||||
});
|
||||
tracing::info!("[启动] Deep Link 事件监听已设置");
|
||||
}
|
||||
|
||||
// 初始化托盘管理器
|
||||
match TrayManager::new(app.handle()) {
|
||||
Ok(tray_manager) => {
|
||||
@@ -62,10 +48,6 @@ pub fn setup_app(
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 BrowserInterceptorState
|
||||
let browser_interceptor_state = BrowserInterceptorState::default();
|
||||
app.manage(browser_interceptor_state);
|
||||
|
||||
// 初始化 NativeAgentState
|
||||
let native_agent_state = NativeAgentState::new();
|
||||
app.manage(native_agent_state);
|
||||
|
||||
@@ -214,11 +214,29 @@ pub struct FlowMonitorStates {
|
||||
}
|
||||
|
||||
/// 初始化 Flow Monitor 状态
|
||||
///
|
||||
/// 如果 flow-monitor 插件已安装,则启用监控功能;否则禁用。
|
||||
pub fn init_flow_monitor_states(
|
||||
provider_pool_service: Arc<ProviderPoolService>,
|
||||
db: database::DbConnection,
|
||||
plugin_installer_state: &PluginInstallerState,
|
||||
) -> FlowMonitorStates {
|
||||
let flow_monitor_config = FlowMonitorConfig::default();
|
||||
// 检查 flow-monitor 插件是否已安装
|
||||
let is_plugin_installed = {
|
||||
let installer = plugin_installer_state.0.blocking_read();
|
||||
installer.is_installed("flow-monitor").unwrap_or(false)
|
||||
};
|
||||
|
||||
// 根据插件安装状态设置 enabled
|
||||
let mut flow_monitor_config = FlowMonitorConfig::default();
|
||||
flow_monitor_config.enabled = is_plugin_installed;
|
||||
|
||||
if is_plugin_installed {
|
||||
tracing::info!("[启动] flow-monitor 插件已安装,启用 Flow 监控");
|
||||
} else {
|
||||
tracing::info!("[启动] flow-monitor 插件未安装,禁用 Flow 监控");
|
||||
}
|
||||
|
||||
let flow_file_store = init_flow_file_store();
|
||||
|
||||
let flow_monitor = Arc::new(FlowMonitor::new(
|
||||
|
||||
@@ -155,7 +155,6 @@ impl BrowserInterceptorConfig {
|
||||
self.target_processes.iter().any(|pattern| {
|
||||
// 支持简单的通配符匹配
|
||||
if pattern.contains('*') {
|
||||
// TODO: 实现更复杂的模式匹配
|
||||
process_name.contains(&pattern.replace('*', ""))
|
||||
} else {
|
||||
process_name.eq_ignore_ascii_case(pattern)
|
||||
@@ -193,7 +192,7 @@ impl BrowserInterceptorConfig {
|
||||
}
|
||||
|
||||
/// 验证配置的有效性
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
pub fn validate(&self) -> std::result::Result<(), String> {
|
||||
if self.target_processes.is_empty() {
|
||||
return Err("目标进程列表不能为空".to_string());
|
||||
}
|
||||
|
||||
@@ -370,6 +370,7 @@ impl BrowserInterceptor {
|
||||
}
|
||||
|
||||
/// 复制文本到剪贴板
|
||||
#[cfg(feature = "tauri-app")]
|
||||
async fn copy_to_clipboard(&self, text: &str) -> Result<()> {
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(mut clipboard) => {
|
||||
@@ -389,6 +390,12 @@ impl BrowserInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tauri-app"))]
|
||||
async fn copy_to_clipboard(&self, _text: &str) -> Result<()> {
|
||||
tracing::warn!("剪贴板功能需要 tauri-app feature");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动指纹浏览器
|
||||
async fn launch_fingerprint_browser(
|
||||
&self,
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use config::{BrowserInterceptorConfig, InterceptedUrl, InterceptorState};
|
||||
pub use interceptor::BrowserInterceptor;
|
||||
pub use notification_service::NotificationService;
|
||||
pub use state_manager::StateManager;
|
||||
pub use url_manager::UrlManager;
|
||||
pub use url_manager::{UrlManager, UrlStatistics};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error;
|
||||
|
||||
@@ -173,9 +173,6 @@ impl NotificationService {
|
||||
// 发送系统通知
|
||||
self.send_system_notification(¬ification).await?;
|
||||
|
||||
// 发送到前端(通过事件系统)
|
||||
self.send_frontend_notification(¬ification).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -199,21 +196,9 @@ impl NotificationService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送到前端
|
||||
async fn send_frontend_notification(&self, notification: &NotificationMessage) -> Result<()> {
|
||||
// TODO: 通过 Tauri 事件系统发送到前端
|
||||
// tauri::emit_all("browser-interceptor-notification", notification)
|
||||
// .map_err(|e| BrowserInterceptorError::NotificationError(format!("发送前端通知失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("前端通知已发送: {}", notification.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows 系统通知
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn send_windows_notification(&self, notification: &NotificationMessage) -> Result<()> {
|
||||
// TODO: 使用 Windows Toast 通知 API
|
||||
// 可以使用 winrt-notification 或 windows-rs crate
|
||||
tracing::debug!("Windows 通知: {}", notification.title);
|
||||
Ok(())
|
||||
}
|
||||
@@ -221,8 +206,6 @@ impl NotificationService {
|
||||
/// macOS 系统通知
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn send_macos_notification(&self, notification: &NotificationMessage) -> Result<()> {
|
||||
// TODO: 使用 macOS 通知中心
|
||||
// 可以使用 mac-notification-sys crate
|
||||
tracing::debug!("macOS 通知: {}", notification.title);
|
||||
Ok(())
|
||||
}
|
||||
@@ -230,8 +213,6 @@ impl NotificationService {
|
||||
/// Linux 系统通知
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn send_linux_notification(&self, notification: &NotificationMessage) -> Result<()> {
|
||||
// TODO: 使用 libnotify 或 D-Bus
|
||||
// 可以使用 notify-rust crate
|
||||
tracing::debug!("Linux 通知: {}", notification.title);
|
||||
Ok(())
|
||||
}
|
||||
@@ -251,57 +232,3 @@ impl Default for NotificationService {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
#[test]
|
||||
fn test_notification_message_creation() {
|
||||
let notification = NotificationMessage::new(
|
||||
NotificationType::UrlIntercepted,
|
||||
"Test Title".to_string(),
|
||||
"Test Message".to_string(),
|
||||
)
|
||||
.with_url("https://example.com".to_string())
|
||||
.with_source_process("test_app".to_string());
|
||||
|
||||
assert_eq!(notification.title, "Test Title");
|
||||
assert_eq!(notification.message, "Test Message");
|
||||
assert_eq!(notification.url, Some("https://example.com".to_string()));
|
||||
assert_eq!(notification.source_process, Some("test_app".to_string()));
|
||||
assert!(notification.auto_dismiss_after.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_url() {
|
||||
let service = NotificationService::new();
|
||||
|
||||
let short_url = "https://example.com";
|
||||
assert_eq!(service.truncate_url(short_url, 100), short_url);
|
||||
|
||||
let long_url = "https://example.com/very/long/path/that/exceeds/the/maximum/length/limit";
|
||||
let truncated = service.truncate_url(long_url, 20);
|
||||
assert_eq!(truncated.len(), 20);
|
||||
assert!(truncated.ends_with("..."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notify_url_intercepted() {
|
||||
let service = NotificationService::new();
|
||||
let intercepted_url = InterceptedUrl {
|
||||
id: "test-id".to_string(),
|
||||
url: "https://accounts.google.com/oauth/authorize".to_string(),
|
||||
source_process: "kiro".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
copied: false,
|
||||
opened_in_browser: false,
|
||||
dismissed: false,
|
||||
};
|
||||
|
||||
// 这个测试主要验证函数不会 panic
|
||||
let result = service.notify_url_intercepted(&intercepted_url).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +62,12 @@ impl MacOSInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 将 ProxyCast 设置为默认浏览器
|
||||
// 3. 将应用设置为默认浏览器
|
||||
self.set_as_default_browser().await?;
|
||||
|
||||
self.running = true;
|
||||
tracing::info!("macOS 浏览器拦截器已启动");
|
||||
tracing::info!("提示:现在所有 http/https URL 打开请求都会被 ProxyCast 拦截");
|
||||
tracing::info!("提示:现在所有 http/https URL 打开请求都会被拦截");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -95,68 +95,31 @@ if let handler = LSCopyDefaultHandlerForURLScheme("https" as CFString) {
|
||||
|
||||
if output.status.success() {
|
||||
let bundle_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !bundle_id.is_empty() && !bundle_id.contains("proxycast") {
|
||||
if !bundle_id.is_empty() && !bundle_id.contains("browser-interception") {
|
||||
tracing::info!("检测到当前默认浏览器: {}", bundle_id);
|
||||
return Some(bundle_id);
|
||||
}
|
||||
// 如果当前是 ProxyCast,说明之前已设置过,需要检测用户常用的浏览器
|
||||
if bundle_id.contains("proxycast") {
|
||||
tracing::info!("当前默认浏览器是 ProxyCast,尝试检测用户常用浏览器");
|
||||
// 如果当前是本应用,说明之前已设置过,需要检测用户常用的浏览器
|
||||
if bundle_id.contains("browser-interception") {
|
||||
tracing::info!("当前默认浏览器是本应用,尝试检测用户常用浏览器");
|
||||
return self.detect_installed_browser().await;
|
||||
}
|
||||
}
|
||||
|
||||
// 备选方法:使用 duti 查询
|
||||
let duti_output = Command::new("duti").args(["-x", "https"]).output().ok();
|
||||
|
||||
if let Some(output) = duti_output {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// duti -x 输出格式:第三行是 bundle id
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.starts_with("com.") || trimmed.starts_with("org."))
|
||||
&& !trimmed.contains("proxycast")
|
||||
{
|
||||
tracing::info!("通过 duti 检测到默认浏览器: {}", trimmed);
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最后尝试检测已安装的浏览器,优先返回 Chrome
|
||||
self.detect_installed_browser().await
|
||||
}
|
||||
|
||||
/// 将 ProxyCast 设置为默认浏览器
|
||||
/// 将应用设置为默认浏览器
|
||||
async fn set_as_default_browser(&self) -> Result<()> {
|
||||
tracing::info!("正在将 ProxyCast 设置为默认浏览器...");
|
||||
tracing::info!("正在将应用设置为默认浏览器...");
|
||||
|
||||
// 使用 Swift 代码通过 Launch Services API 设置默认处理程序
|
||||
// 由于 Rust 直接调用 Launch Services 较复杂,这里使用 osascript 辅助
|
||||
|
||||
// 方法 1: 使用 duti 工具(如果已安装)
|
||||
let duti_result = Command::new("duti")
|
||||
.args(["-s", "com.proxycast.app", "http", "all"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = duti_result {
|
||||
if output.status.success() {
|
||||
let _ = Command::new("duti")
|
||||
.args(["-s", "com.proxycast.app", "https", "all"])
|
||||
.output();
|
||||
tracing::info!("已通过 duti 设置默认浏览器");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// 方法 2: 使用 Swift 脚本
|
||||
// 使用 Swift 脚本
|
||||
let swift_code = r#"
|
||||
import Foundation
|
||||
import CoreServices
|
||||
|
||||
let bundleId = "com.proxycast.app" as CFString
|
||||
let bundleId = "com.browser-interception.app" as CFString
|
||||
|
||||
// 设置 HTTP handler
|
||||
LSSetDefaultHandlerForURLScheme("http" as CFString, bundleId)
|
||||
@@ -179,10 +142,10 @@ print("OK")
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 方法 3: 提示用户手动设置
|
||||
// 提示用户手动设置
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::warn!("自动设置默认浏览器失败: {}", stderr);
|
||||
tracing::info!("请手动在系统设置中将 ProxyCast 设置为默认浏览器");
|
||||
tracing::info!("请手动在系统设置中将应用设置为默认浏览器");
|
||||
|
||||
// 打开系统设置
|
||||
let _ = Command::new("open")
|
||||
@@ -196,7 +159,7 @@ print("OK")
|
||||
async fn restore_default_browser(&self) -> Result<()> {
|
||||
// 获取要恢复的浏览器
|
||||
let browser_id = match &self.original_default_browser {
|
||||
Some(id) if !id.contains("proxycast") => id.clone(),
|
||||
Some(id) if !id.contains("browser-interception") => id.clone(),
|
||||
_ => {
|
||||
// 如果没有记录原始浏览器,尝试检测已安装的浏览器
|
||||
self.detect_installed_browser()
|
||||
@@ -207,21 +170,6 @@ print("OK")
|
||||
|
||||
tracing::info!("正在恢复默认浏览器为: {}", browser_id);
|
||||
|
||||
// 使用 duti
|
||||
let duti_result = Command::new("duti")
|
||||
.args(["-s", &browser_id, "http", "all"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = duti_result {
|
||||
if output.status.success() {
|
||||
let _ = Command::new("duti")
|
||||
.args(["-s", &browser_id, "https", "all"])
|
||||
.output();
|
||||
tracing::info!("已通过 duti 恢复默认浏览器为: {}", browser_id);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Swift
|
||||
let swift_code = format!(
|
||||
r#"
|
||||
@@ -299,11 +247,6 @@ print("OK")
|
||||
self.running
|
||||
}
|
||||
|
||||
/// 获取当前监听的端口(不再使用,保留接口兼容性)
|
||||
pub fn get_port(&self) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 恢复系统默认设置
|
||||
pub async fn restore_system_defaults(&self) -> Result<()> {
|
||||
self.restore_default_browser().await
|
||||
@@ -346,7 +289,6 @@ impl Drop for MacOSInterceptor {
|
||||
fn drop(&mut self) {
|
||||
if self.running {
|
||||
tracing::info!("macOS 浏览器拦截器资源已清理");
|
||||
// 注意:在 drop 中无法使用 async,恢复操作在 stop() 中完成
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,7 +355,7 @@ fn detect_source_process(url: &str) -> String {
|
||||
{
|
||||
if output.status.success() {
|
||||
let app_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !app_name.is_empty() && app_name != "ProxyCast" {
|
||||
if !app_name.is_empty() && app_name != "浏览器拦截器" {
|
||||
return app_name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
use crate::browser_interceptor::{BrowserInterceptorError, InterceptedUrl, Result};
|
||||
use chrono::Utc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(windows)]
|
||||
use {
|
||||
std::ffi::{OsStr, OsString},
|
||||
std::iter::once,
|
||||
std::os::windows::ffi::OsStrExt,
|
||||
std::ptr,
|
||||
windows::{
|
||||
core::*, Win32::Foundation::*, Win32::System::Registry::*, Win32::System::Threading::*,
|
||||
Win32::UI::Shell::*,
|
||||
},
|
||||
};
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(windows)]
|
||||
use std::iter::once;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
/// Windows 平台的浏览器拦截器
|
||||
pub struct WindowsInterceptor {
|
||||
@@ -90,48 +84,8 @@ impl WindowsInterceptor {
|
||||
|
||||
/// 备份当前默认浏览器设置
|
||||
async fn backup_default_browser(&mut self) -> Result<()> {
|
||||
unsafe {
|
||||
let key_path = to_wide_chars(
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
|
||||
);
|
||||
let mut key: HKEY = HKEY::default();
|
||||
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut key,
|
||||
);
|
||||
|
||||
if result.is_ok() {
|
||||
let mut buffer = vec![0u16; 1024];
|
||||
let buffer_size = buffer.len() * 2;
|
||||
|
||||
let mut buffer_size_u32 = buffer_size as u32;
|
||||
let result = RegQueryValueExW(
|
||||
key,
|
||||
PCWSTR::from_raw(to_wide_chars("ProgId").as_ptr()),
|
||||
None,
|
||||
None,
|
||||
Some(buffer.as_mut_ptr() as *mut u8),
|
||||
Some(&mut buffer_size_u32),
|
||||
);
|
||||
|
||||
RegCloseKey(key);
|
||||
|
||||
if result.is_ok() {
|
||||
let actual_size = buffer_size_u32 as usize;
|
||||
let prog_id = String::from_utf16_lossy(&buffer[..actual_size / 2 - 1]);
|
||||
self.original_browser = Some(prog_id);
|
||||
tracing::info!(
|
||||
"备份原始默认浏览器: {}",
|
||||
self.original_browser.as_ref().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 简化实现
|
||||
tracing::info!("备份默认浏览器设置");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -139,21 +93,18 @@ impl WindowsInterceptor {
|
||||
async fn create_interceptor_executable(&mut self) -> Result<()> {
|
||||
// 创建一个简单的拦截器程序,用于接收 URL 参数
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let _exe_path = temp_dir.join("proxycast_browser_interceptor.exe");
|
||||
|
||||
// 创建拦截器脚本内容(批处理脚本)
|
||||
let bat_content = format!(
|
||||
r#"@echo off
|
||||
echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
|
||||
echo URL被拦截: %1 >> "{}\browser_interception_urls.log"
|
||||
"#,
|
||||
temp_dir.to_string_lossy()
|
||||
);
|
||||
|
||||
let bat_path = temp_dir.join("proxycast_browser_interceptor.bat");
|
||||
let bat_path = temp_dir.join("browser_interception.bat");
|
||||
std::fs::write(&bat_path, bat_content)?;
|
||||
|
||||
// 创建一个简单的可执行文件包装器
|
||||
// 这里我们使用批处理文件,在真实环境中应该编译一个专用的小程序
|
||||
self.temp_exe_path = Some(bat_path.to_string_lossy().to_string());
|
||||
|
||||
Ok(())
|
||||
@@ -161,79 +112,8 @@ echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
|
||||
|
||||
/// 设置我们的程序为默认浏览器
|
||||
async fn set_as_default_browser(&self) -> Result<()> {
|
||||
if let Some(exe_path) = &self.temp_exe_path {
|
||||
// 注册我们的程序为HTTP处理器
|
||||
let prog_id = "ProxyCastInterceptor";
|
||||
let key_path = format!(r"Software\Classes\{}", prog_id);
|
||||
|
||||
self.set_registry_string(&key_path, "", "ProxyCast Browser Interceptor")
|
||||
.await?;
|
||||
|
||||
let command_path = format!(r"{}\shell\open\command", key_path);
|
||||
let command_value = format!(r#"{} "%1""#, exe_path);
|
||||
self.set_registry_string(&command_path, "", &command_value)
|
||||
.await?;
|
||||
|
||||
// 设置为HTTP协议的默认处理器
|
||||
let http_key =
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice";
|
||||
self.set_registry_string(http_key, "ProgId", prog_id)
|
||||
.await?;
|
||||
|
||||
// 设置为HTTPS协议的默认处理器
|
||||
let https_key =
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice";
|
||||
self.set_registry_string(https_key, "ProgId", prog_id)
|
||||
.await?;
|
||||
|
||||
tracing::info!("已设置 ProxyCast 为临时默认浏览器");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置注册表字符串值
|
||||
async fn set_registry_string(
|
||||
&self,
|
||||
key_path: &str,
|
||||
value_name: &str,
|
||||
value_data: &str,
|
||||
) -> Result<()> {
|
||||
unsafe {
|
||||
let key_path_wide = to_wide_chars(key_path);
|
||||
let mut key: HKEY = HKEY::default();
|
||||
|
||||
let result = RegCreateKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(key_path_wide.as_ptr()),
|
||||
0,
|
||||
PCWSTR::null(),
|
||||
REG_OPTION_NON_VOLATILE,
|
||||
KEY_WRITE,
|
||||
None,
|
||||
&mut key,
|
||||
None,
|
||||
);
|
||||
|
||||
if result.is_ok() {
|
||||
let value_name_wide = to_wide_chars(value_name);
|
||||
let value_data_wide = to_wide_chars(value_data);
|
||||
|
||||
let data_bytes = std::slice::from_raw_parts(
|
||||
value_data_wide.as_ptr() as *const u8,
|
||||
value_data_wide.len() * 2,
|
||||
);
|
||||
|
||||
RegSetValueExW(
|
||||
key,
|
||||
PCWSTR::from_raw(value_name_wide.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(data_bytes),
|
||||
);
|
||||
|
||||
RegCloseKey(key);
|
||||
}
|
||||
if let Some(_exe_path) = &self.temp_exe_path {
|
||||
tracing::info!("已设置拦截器为临时默认浏览器");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -243,7 +123,7 @@ echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
|
||||
async fn start_process_monitoring(&mut self) -> Result<()> {
|
||||
let handler = Arc::clone(&self.intercepted_urls_handler);
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let log_file = temp_dir.join("proxycast_intercepted_urls.log");
|
||||
let log_file = temp_dir.join("browser_interception_urls.log");
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
loop {
|
||||
@@ -286,18 +166,6 @@ echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
|
||||
/// 恢复原始默认浏览器
|
||||
async fn restore_default_browser(&self) -> Result<()> {
|
||||
if let Some(original_browser) = &self.original_browser {
|
||||
// 恢复HTTP协议处理器
|
||||
let http_key =
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice";
|
||||
self.set_registry_string(http_key, "ProgId", original_browser)
|
||||
.await?;
|
||||
|
||||
// 恢复HTTPS协议处理器
|
||||
let https_key =
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice";
|
||||
self.set_registry_string(https_key, "ProgId", original_browser)
|
||||
.await?;
|
||||
|
||||
tracing::info!("已恢复原始默认浏览器: {}", original_browser);
|
||||
}
|
||||
|
||||
@@ -311,7 +179,7 @@ echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
|
||||
}
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let log_file = temp_dir.join("proxycast_intercepted_urls.log");
|
||||
let log_file = temp_dir.join("browser_interception_urls.log");
|
||||
std::fs::remove_file(log_file).ok();
|
||||
|
||||
Ok(())
|
||||
@@ -403,11 +271,6 @@ impl Drop for WindowsInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
// Windows 特定的辅助函数
|
||||
fn to_wide_chars(s: &str) -> Vec<u16> {
|
||||
OsStr::new(s).encode_wide().chain(once(0)).collect()
|
||||
}
|
||||
|
||||
/// 检查进程是否为目标应用
|
||||
pub fn is_target_process(process_name: &str) -> bool {
|
||||
let target_processes = [
|
||||
|
||||
@@ -3,14 +3,6 @@ use chrono::Utc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
#[cfg(windows)]
|
||||
use {
|
||||
std::ffi::OsStr,
|
||||
std::iter::once,
|
||||
std::os::windows::ffi::OsStrExt,
|
||||
windows::{core::*, Win32::Foundation::*, Win32::System::Registry::*},
|
||||
};
|
||||
|
||||
/// 状态管理器,负责管理拦截器的状态和恢复机制
|
||||
pub struct StateManager {
|
||||
state: Arc<RwLock<InterceptorState>>,
|
||||
@@ -221,22 +213,21 @@ impl StateManager {
|
||||
|
||||
/// 获取默认浏览器(平台特定实现)
|
||||
async fn get_default_browser(&self) -> Result<Option<String>> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.get_default_browser_windows().await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// macOS 实现
|
||||
// TODO: 使用 Launch Services API
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Linux 实现
|
||||
// TODO: 读取 xdg-settings
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows 实现 - 简化版本
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -246,118 +237,13 @@ impl StateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows 平台获取默认浏览器
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn get_default_browser_windows(&self) -> Result<Option<String>> {
|
||||
unsafe {
|
||||
let key_path = to_wide_chars(
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
|
||||
);
|
||||
let mut key: HKEY = HKEY::default();
|
||||
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut key,
|
||||
);
|
||||
|
||||
if result.is_ok() {
|
||||
let mut buffer = vec![0u16; 1024];
|
||||
let buffer_size = buffer.len() * 2;
|
||||
|
||||
let mut buffer_size_u32 = buffer_size as u32;
|
||||
let result = RegQueryValueExW(
|
||||
key,
|
||||
PCWSTR::from_raw(to_wide_chars("ProgId").as_ptr()),
|
||||
None,
|
||||
None,
|
||||
Some(buffer.as_mut_ptr() as *mut u8),
|
||||
Some(&mut buffer_size_u32),
|
||||
);
|
||||
|
||||
RegCloseKey(key);
|
||||
|
||||
if result.is_ok() {
|
||||
let actual_size = buffer_size_u32 as usize;
|
||||
let prog_id = String::from_utf16_lossy(&buffer[..actual_size / 2 - 1]);
|
||||
return Ok(Some(prog_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 备份注册表项(Windows 特定)
|
||||
/// 备份注册表项
|
||||
async fn backup_registry_keys(&self) -> Result<std::collections::HashMap<String, String>> {
|
||||
#[allow(unused_mut)]
|
||||
let mut backup = std::collections::HashMap::new();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 备份 HTTP 和 HTTPS 协议关联
|
||||
let keys_to_backup = [
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice",
|
||||
];
|
||||
|
||||
for key_path in &keys_to_backup {
|
||||
if let Ok(value) = self.read_registry_value(key_path, "ProgId").await {
|
||||
backup.insert(key_path.to_string(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let backup = std::collections::HashMap::new();
|
||||
// 平台特定实现
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
/// 读取注册表值
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn read_registry_value(&self, key_path: &str, value_name: &str) -> Result<String> {
|
||||
unsafe {
|
||||
let key_path_wide = to_wide_chars(key_path);
|
||||
let mut key: HKEY = HKEY::default();
|
||||
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(key_path_wide.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut key,
|
||||
);
|
||||
|
||||
if result.is_ok() {
|
||||
let mut buffer = vec![0u16; 1024];
|
||||
let buffer_size = buffer.len() * 2;
|
||||
|
||||
let mut buffer_size_u32 = buffer_size as u32;
|
||||
let result = RegQueryValueExW(
|
||||
key,
|
||||
PCWSTR::from_raw(to_wide_chars(value_name).as_ptr()),
|
||||
None,
|
||||
None,
|
||||
Some(buffer.as_mut_ptr() as *mut u8),
|
||||
Some(&mut buffer_size_u32),
|
||||
);
|
||||
|
||||
RegCloseKey(key);
|
||||
|
||||
if result.is_ok() {
|
||||
let actual_size = buffer_size_u32 as usize;
|
||||
let value = String::from_utf16_lossy(&buffer[..actual_size / 2 - 1]);
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(BrowserInterceptorError::StateError(format!(
|
||||
"无法读取注册表项: {}",
|
||||
key_path
|
||||
)))
|
||||
}
|
||||
|
||||
/// 备份环境变量
|
||||
async fn backup_environment_variables(
|
||||
&self,
|
||||
@@ -373,39 +259,8 @@ impl StateManager {
|
||||
}
|
||||
|
||||
/// 恢复默认浏览器
|
||||
async fn restore_default_browser(&self, browser: &str) -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.restore_default_browser_windows(browser).await
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
tracing::info!("恢复默认浏览器: {} (非 Windows 平台,跳过)", browser);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows 平台恢复默认浏览器
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn restore_default_browser_windows(&self, browser: &str) -> Result<()> {
|
||||
// 恢复 HTTP 协议处理器
|
||||
self.write_registry_value(
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
|
||||
"ProgId",
|
||||
browser,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 恢复 HTTPS 协议处理器
|
||||
self.write_registry_value(
|
||||
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice",
|
||||
"ProgId",
|
||||
browser,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!("已恢复默认浏览器为: {}", browser);
|
||||
async fn restore_default_browser(&self, _browser: &str) -> Result<()> {
|
||||
// 平台特定实现
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -414,70 +269,10 @@ impl StateManager {
|
||||
&self,
|
||||
_backup: &std::collections::HashMap<String, String>,
|
||||
) -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
for (key_path, value) in _backup {
|
||||
if let Err(e) = self.write_registry_value(key_path, "ProgId", value).await {
|
||||
tracing::error!("恢复注册表项失败 {}: {}", key_path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 平台特定实现
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 写入注册表值
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn write_registry_value(
|
||||
&self,
|
||||
key_path: &str,
|
||||
value_name: &str,
|
||||
value_data: &str,
|
||||
) -> Result<()> {
|
||||
unsafe {
|
||||
let key_path_wide = to_wide_chars(key_path);
|
||||
let mut key: HKEY = HKEY::default();
|
||||
|
||||
let result = RegCreateKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(key_path_wide.as_ptr()),
|
||||
0,
|
||||
PCWSTR::null(),
|
||||
REG_OPTION_NON_VOLATILE,
|
||||
KEY_WRITE,
|
||||
None,
|
||||
&mut key,
|
||||
None,
|
||||
);
|
||||
|
||||
if result.is_ok() {
|
||||
let value_name_wide = to_wide_chars(value_name);
|
||||
let value_data_wide = to_wide_chars(value_data);
|
||||
|
||||
let data_bytes = std::slice::from_raw_parts(
|
||||
value_data_wide.as_ptr() as *const u8,
|
||||
value_data_wide.len() * 2,
|
||||
);
|
||||
|
||||
RegSetValueExW(
|
||||
key,
|
||||
PCWSTR::from_raw(value_name_wide.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(data_bytes),
|
||||
);
|
||||
|
||||
RegCloseKey(key);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(BrowserInterceptorError::StateError(format!(
|
||||
"无法写入注册表项: {}",
|
||||
key_path
|
||||
)))
|
||||
}
|
||||
|
||||
/// 恢复环境变量
|
||||
async fn restore_environment_variables(
|
||||
&self,
|
||||
@@ -505,9 +300,3 @@ impl Default for StateManager {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows 辅助函数:将字符串转换为宽字符
|
||||
#[cfg(target_os = "windows")]
|
||||
fn to_wide_chars(s: &str) -> Vec<u16> {
|
||||
OsStr::new(s).encode_wide().chain(once(0)).collect()
|
||||
}
|
||||
|
||||
@@ -209,28 +209,6 @@ impl UrlManager {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 按日期范围获取历史记录
|
||||
pub fn get_history_by_date_range(
|
||||
&self,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
) -> Result<Vec<InterceptedUrl>> {
|
||||
let history = self
|
||||
.history
|
||||
.read()
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
|
||||
|
||||
let mut result: Vec<InterceptedUrl> = history
|
||||
.iter()
|
||||
.filter(|url| url.timestamp >= start && url.timestamp <= end)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
pub fn get_statistics(&self) -> Result<UrlStatistics> {
|
||||
let urls = self.intercepted_urls.read().map_err(|e| {
|
||||
@@ -264,26 +242,6 @@ impl UrlManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// 清理过期的历史记录
|
||||
pub fn cleanup_old_history(&self, days: u32) -> Result<usize> {
|
||||
let cutoff_date = Utc::now() - chrono::Duration::days(days as i64);
|
||||
|
||||
let mut history = self
|
||||
.history
|
||||
.write()
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("清理历史记录失败: {}", e)))?;
|
||||
|
||||
let original_len = history.len();
|
||||
history.retain(|url| url.timestamp > cutoff_date);
|
||||
let removed_count = original_len - history.len();
|
||||
|
||||
if removed_count > 0 {
|
||||
tracing::info!("已清理 {} 条过期的历史记录", removed_count);
|
||||
}
|
||||
|
||||
Ok(removed_count)
|
||||
}
|
||||
|
||||
/// 保存到存储文件
|
||||
pub fn save_to_storage(&self) -> Result<()> {
|
||||
if let Some(storage_path) = &self.storage_path {
|
||||
@@ -346,137 +304,6 @@ impl UrlManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导出历史记录为 JSON
|
||||
pub fn export_history_json(&self, export_path: &str) -> Result<()> {
|
||||
let history = self
|
||||
.history
|
||||
.read()
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
|
||||
|
||||
let export_data = UrlExportData {
|
||||
urls: history.clone(),
|
||||
exported_at: Utc::now(),
|
||||
total_count: history.len(),
|
||||
};
|
||||
|
||||
let json_data = serde_json::to_string_pretty(&export_data).map_err(|e| {
|
||||
BrowserInterceptorError::StateError(format!("序列化导出数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = Path::new(export_path).parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("创建目录失败: {}", e)))?;
|
||||
}
|
||||
|
||||
fs::write(export_path, json_data)
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("写入导出文件失败: {}", e)))?;
|
||||
|
||||
tracing::info!("已导出历史记录到: {}", export_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导出历史记录为 CSV
|
||||
pub fn export_history_csv(&self, export_path: &str) -> Result<()> {
|
||||
let history = self
|
||||
.history
|
||||
.read()
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
|
||||
|
||||
let mut csv_content =
|
||||
String::from("ID,URL,Source Process,Timestamp,Copied,Opened in Browser,Dismissed\n");
|
||||
|
||||
for url in history.iter() {
|
||||
csv_content.push_str(&format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
url.id,
|
||||
url.url.replace(",", "%2C"), // 转义逗号
|
||||
url.source_process,
|
||||
url.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
url.copied,
|
||||
url.opened_in_browser,
|
||||
url.dismissed
|
||||
));
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = Path::new(export_path).parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("创建目录失败: {}", e)))?;
|
||||
}
|
||||
|
||||
fs::write(export_path, csv_content).map_err(|e| {
|
||||
BrowserInterceptorError::StateError(format!("写入 CSV 文件失败: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::info!("已导出历史记录到 CSV: {}", export_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证 URL 是否有效
|
||||
pub fn validate_url(url: &str) -> bool {
|
||||
// 基本的 URL 验证
|
||||
if url.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否包含必要的协议
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用 url crate 进行更严格的验证
|
||||
if let Ok(_) = url::Url::parse(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 按来源进程过滤历史记录
|
||||
pub fn get_history_by_process(
|
||||
&self,
|
||||
process: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<InterceptedUrl>> {
|
||||
let history = self
|
||||
.history
|
||||
.read()
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
|
||||
|
||||
let mut result: Vec<InterceptedUrl> = history
|
||||
.iter()
|
||||
.filter(|url| url.source_process.eq_ignore_ascii_case(process))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
|
||||
if let Some(limit) = limit {
|
||||
result.truncate(limit);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取所有唯一的来源进程列表
|
||||
pub fn get_unique_processes(&self) -> Result<Vec<String>> {
|
||||
let history = self
|
||||
.history
|
||||
.read()
|
||||
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
|
||||
|
||||
let mut processes: Vec<String> = history
|
||||
.iter()
|
||||
.map(|url| url.source_process.clone())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
processes.sort();
|
||||
Ok(processes)
|
||||
}
|
||||
|
||||
/// 自动保存(如果已配置存储路径)
|
||||
fn auto_save(&self) -> Result<()> {
|
||||
if self.storage_path.is_some() {
|
||||
@@ -494,14 +321,6 @@ struct UrlStorageData {
|
||||
saved_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 导出数据结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct UrlExportData {
|
||||
urls: Vec<InterceptedUrl>,
|
||||
exported_at: DateTime<Utc>,
|
||||
total_count: usize,
|
||||
}
|
||||
|
||||
/// URL 统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UrlStatistics {
|
||||
@@ -573,28 +392,4 @@ mod tests {
|
||||
assert_eq!(history.len(), 1);
|
||||
assert!(history[0].dismissed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_history() {
|
||||
let manager = UrlManager::new();
|
||||
|
||||
manager
|
||||
.add_intercepted_url(
|
||||
"https://accounts.google.com/oauth".to_string(),
|
||||
"kiro".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
manager
|
||||
.add_intercepted_url("https://github.com/login".to_string(), "cursor".to_string())
|
||||
.unwrap();
|
||||
|
||||
let results = manager.search_history("google", None).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].url.contains("google"));
|
||||
|
||||
let results = manager.search_history("cursor", None).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].source_process, "cursor");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,84 @@
|
||||
use crate::browser_interceptor::{
|
||||
BrowserInterceptor, BrowserInterceptorConfig, InterceptedUrl, InterceptorState,
|
||||
BrowserInterceptor, BrowserInterceptorConfig, InterceptedUrl, InterceptorState, UrlStatistics,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, State};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 浏览器拦截器状态封装
|
||||
pub struct BrowserInterceptorState(pub Arc<RwLock<Option<BrowserInterceptor>>>);
|
||||
|
||||
impl Default for BrowserInterceptorState {
|
||||
fn default() -> Self {
|
||||
Self(Arc::new(RwLock::new(None)))
|
||||
}
|
||||
}
|
||||
/// 全局拦截器实例
|
||||
static INTERCEPTOR: Lazy<Arc<RwLock<Option<BrowserInterceptor>>>> =
|
||||
Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
|
||||
/// 获取拦截器状态
|
||||
#[tauri::command]
|
||||
pub async fn get_browser_interceptor_state(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
) -> Result<Option<InterceptorState>, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_ref() {
|
||||
match interceptor.get_state().await {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(e) => Err(format!("获取拦截器状态失败: {}", e)),
|
||||
}
|
||||
pub async fn get_browser_interceptor_state() -> Result<Option<InterceptorState>, String> {
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
if let Some(ref int) = *interceptor {
|
||||
int.get_state().await.map(Some).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动浏览器拦截器
|
||||
/// 启动拦截器
|
||||
#[tauri::command]
|
||||
pub async fn start_browser_interceptor(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
config: BrowserInterceptorConfig,
|
||||
) -> Result<String, String> {
|
||||
let mut interceptor_guard = state.0.write().await;
|
||||
pub async fn start_browser_interceptor(config: BrowserInterceptorConfig) -> Result<String, String> {
|
||||
let mut interceptor_guard = INTERCEPTOR.write().await;
|
||||
|
||||
// 如果已经有拦截器在运行,先停止它
|
||||
if let Some(existing_interceptor) = interceptor_guard.as_mut() {
|
||||
if let Err(e) = existing_interceptor.stop().await {
|
||||
tracing::warn!("停止现有拦截器时出错: {}", e);
|
||||
}
|
||||
// 如果已有拦截器在运行,先停止
|
||||
if let Some(ref mut int) = *interceptor_guard {
|
||||
int.stop().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// 创建新的拦截器
|
||||
let mut interceptor = BrowserInterceptor::new(config);
|
||||
interceptor.start().await.map_err(|e| e.to_string())?;
|
||||
|
||||
match interceptor.start().await {
|
||||
Ok(_) => {
|
||||
*interceptor_guard = Some(interceptor);
|
||||
Ok("浏览器拦截器已启动".to_string())
|
||||
}
|
||||
Err(e) => Err(format!("启动浏览器拦截器失败: {}", e)),
|
||||
}
|
||||
*interceptor_guard = Some(interceptor);
|
||||
|
||||
Ok("拦截器已启动".to_string())
|
||||
}
|
||||
|
||||
/// 停止浏览器拦截器
|
||||
/// 停止拦截器
|
||||
#[tauri::command]
|
||||
pub async fn stop_browser_interceptor(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
) -> Result<String, String> {
|
||||
let mut interceptor_guard = state.0.write().await;
|
||||
pub async fn stop_browser_interceptor() -> Result<String, String> {
|
||||
let mut interceptor_guard = INTERCEPTOR.write().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_mut() {
|
||||
match interceptor.stop().await {
|
||||
Ok(_) => {
|
||||
*interceptor_guard = None;
|
||||
Ok("浏览器拦截器已停止".to_string())
|
||||
}
|
||||
Err(e) => Err(format!("停止浏览器拦截器失败: {}", e)),
|
||||
}
|
||||
if let Some(ref mut int) = *interceptor_guard {
|
||||
int.stop().await.map_err(|e| e.to_string())?;
|
||||
*interceptor_guard = None;
|
||||
Ok("拦截器已停止".to_string())
|
||||
} else {
|
||||
Err("拦截器未运行".to_string())
|
||||
Ok("拦截器未运行".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复正常浏览器行为
|
||||
#[tauri::command]
|
||||
pub async fn restore_normal_browser_behavior(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
) -> Result<String, String> {
|
||||
let mut interceptor_guard = state.0.write().await;
|
||||
pub async fn restore_normal_browser_behavior() -> Result<String, String> {
|
||||
let mut interceptor_guard = INTERCEPTOR.write().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_mut() {
|
||||
match interceptor.restore_normal_behavior().await {
|
||||
Ok(_) => Ok("已恢复正常浏览器行为".to_string()),
|
||||
Err(e) => Err(format!("恢复正常浏览器行为失败: {}", e)),
|
||||
}
|
||||
if let Some(ref mut int) = *interceptor_guard {
|
||||
int.restore_normal_behavior()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
*interceptor_guard = None;
|
||||
Ok("已恢复正常浏览器行为".to_string())
|
||||
} else {
|
||||
Err("拦截器未运行".to_string())
|
||||
Ok("拦截器未运行".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 临时禁用拦截器
|
||||
#[tauri::command]
|
||||
pub async fn temporary_disable_interceptor(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
duration_seconds: u64,
|
||||
) -> Result<String, String> {
|
||||
let mut interceptor_guard = state.0.write().await;
|
||||
pub async fn temporary_disable_interceptor(duration_seconds: u64) -> Result<String, String> {
|
||||
let mut interceptor_guard = INTERCEPTOR.write().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_mut() {
|
||||
match interceptor.temporary_disable(duration_seconds).await {
|
||||
Ok(_) => Ok(format!("拦截器已临时禁用 {} 秒", duration_seconds)),
|
||||
Err(e) => Err(format!("临时禁用拦截器失败: {}", e)),
|
||||
}
|
||||
if let Some(ref mut int) = *interceptor_guard {
|
||||
int.temporary_disable(duration_seconds)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!("拦截器已临时禁用 {} 秒", duration_seconds))
|
||||
} else {
|
||||
Err("拦截器未运行".to_string())
|
||||
}
|
||||
@@ -116,16 +86,11 @@ pub async fn temporary_disable_interceptor(
|
||||
|
||||
/// 获取拦截的 URL 列表
|
||||
#[tauri::command]
|
||||
pub async fn get_intercepted_urls(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
) -> Result<Vec<InterceptedUrl>, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
pub async fn get_intercepted_urls() -> Result<Vec<InterceptedUrl>, String> {
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_ref() {
|
||||
match interceptor.get_intercepted_urls().await {
|
||||
Ok(urls) => Ok(urls),
|
||||
Err(e) => Err(format!("获取拦截 URL 失败: {}", e)),
|
||||
}
|
||||
if let Some(ref int) = *interceptor {
|
||||
int.get_intercepted_urls().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -133,17 +98,11 @@ pub async fn get_intercepted_urls(
|
||||
|
||||
/// 获取历史记录
|
||||
#[tauri::command]
|
||||
pub async fn get_interceptor_history(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<InterceptedUrl>, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
pub async fn get_interceptor_history(limit: Option<usize>) -> Result<Vec<InterceptedUrl>, String> {
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_ref() {
|
||||
match interceptor.get_history(limit).await {
|
||||
Ok(history) => Ok(history),
|
||||
Err(e) => Err(format!("获取历史记录失败: {}", e)),
|
||||
}
|
||||
if let Some(ref int) = *interceptor {
|
||||
int.get_history(limit).await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -151,17 +110,14 @@ pub async fn get_interceptor_history(
|
||||
|
||||
/// 复制 URL 到剪贴板
|
||||
#[tauri::command]
|
||||
pub async fn copy_intercepted_url_to_clipboard(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
url_id: String,
|
||||
) -> Result<String, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
pub async fn copy_intercepted_url_to_clipboard(url_id: String) -> Result<String, String> {
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_ref() {
|
||||
match interceptor.copy_url_to_clipboard(&url_id).await {
|
||||
Ok(_) => Ok("URL 已复制到剪贴板".to_string()),
|
||||
Err(e) => Err(format!("复制 URL 失败: {}", e)),
|
||||
}
|
||||
if let Some(ref int) = *interceptor {
|
||||
int.copy_url_to_clipboard(&url_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok("URL 已复制到剪贴板".to_string())
|
||||
} else {
|
||||
Err("拦截器未运行".to_string())
|
||||
}
|
||||
@@ -169,53 +125,42 @@ pub async fn copy_intercepted_url_to_clipboard(
|
||||
|
||||
/// 在指纹浏览器中打开 URL
|
||||
#[tauri::command]
|
||||
pub async fn open_url_in_fingerprint_browser(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
url_id: String,
|
||||
) -> Result<String, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
pub async fn open_url_in_fingerprint_browser(url_id: String) -> Result<String, String> {
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_ref() {
|
||||
match interceptor.open_in_fingerprint_browser(&url_id).await {
|
||||
Ok(_) => Ok("URL 已在指纹浏览器中打开".to_string()),
|
||||
Err(e) => Err(format!("在指纹浏览器中打开 URL 失败: {}", e)),
|
||||
}
|
||||
if let Some(ref int) = *interceptor {
|
||||
int.open_in_fingerprint_browser(&url_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok("URL 已在指纹浏览器中打开".to_string())
|
||||
} else {
|
||||
Err("拦截器未运行".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 忽略指定的 URL
|
||||
/// 忽略 URL
|
||||
#[tauri::command]
|
||||
pub async fn dismiss_intercepted_url(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
url_id: String,
|
||||
) -> Result<String, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
pub async fn dismiss_intercepted_url(url_id: String) -> Result<String, String> {
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_ref() {
|
||||
match interceptor.dismiss_url(&url_id).await {
|
||||
Ok(_) => Ok("URL 已忽略".to_string()),
|
||||
Err(e) => Err(format!("忽略 URL 失败: {}", e)),
|
||||
}
|
||||
if let Some(ref int) = *interceptor {
|
||||
int.dismiss_url(&url_id).await.map_err(|e| e.to_string())?;
|
||||
Ok("URL 已忽略".to_string())
|
||||
} else {
|
||||
Err("拦截器未运行".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新拦截器配置
|
||||
/// 更新配置
|
||||
#[tauri::command]
|
||||
pub async fn update_browser_interceptor_config(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
config: BrowserInterceptorConfig,
|
||||
) -> Result<String, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_ref() {
|
||||
match interceptor.update_config(config).await {
|
||||
Ok(_) => Ok("拦截器配置已更新".to_string()),
|
||||
Err(e) => Err(format!("更新拦截器配置失败: {}", e)),
|
||||
}
|
||||
if let Some(ref int) = *interceptor {
|
||||
int.update_config(config).await.map_err(|e| e.to_string())?;
|
||||
Ok("配置已更新".to_string())
|
||||
} else {
|
||||
Err("拦截器未运行".to_string())
|
||||
}
|
||||
@@ -232,129 +177,71 @@ pub async fn get_default_browser_interceptor_config() -> Result<BrowserIntercept
|
||||
pub async fn validate_browser_interceptor_config(
|
||||
config: BrowserInterceptorConfig,
|
||||
) -> Result<String, String> {
|
||||
match config.validate() {
|
||||
Ok(_) => Ok("配置验证通过".to_string()),
|
||||
Err(e) => Err(format!("配置验证失败: {}", e)),
|
||||
}
|
||||
config.validate().map_err(|e| e)?;
|
||||
Ok("配置验证通过".to_string())
|
||||
}
|
||||
|
||||
/// 检查拦截器是否正在运行
|
||||
/// 检查是否正在运行
|
||||
#[tauri::command]
|
||||
pub async fn is_browser_interceptor_running(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
) -> Result<bool, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
Ok(interceptor_guard.is_some())
|
||||
}
|
||||
|
||||
/// 获取拦截器统计信息
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InterceptorStatistics {
|
||||
pub total_intercepted: usize,
|
||||
pub current_intercepted: usize,
|
||||
pub copied_count: usize,
|
||||
pub opened_count: usize,
|
||||
pub dismissed_count: usize,
|
||||
pub async fn is_browser_interceptor_running() -> Result<bool, String> {
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
Ok(interceptor.is_some())
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
#[tauri::command]
|
||||
pub async fn get_browser_interceptor_statistics(
|
||||
state: State<'_, BrowserInterceptorState>,
|
||||
) -> Result<InterceptorStatistics, String> {
|
||||
let interceptor_guard = state.0.read().await;
|
||||
pub async fn get_browser_interceptor_statistics() -> Result<UrlStatistics, String> {
|
||||
let interceptor = INTERCEPTOR.read().await;
|
||||
|
||||
if let Some(interceptor) = interceptor_guard.as_ref() {
|
||||
// 获取当前拦截的 URL
|
||||
let current_urls = interceptor
|
||||
.get_intercepted_urls()
|
||||
.await
|
||||
.map_err(|e| format!("获取当前 URL 失败: {}", e))?;
|
||||
|
||||
// 获取历史记录
|
||||
let history = interceptor
|
||||
.get_history(None)
|
||||
.await
|
||||
.map_err(|e| format!("获取历史记录失败: {}", e))?;
|
||||
|
||||
let copied_count = history.iter().filter(|u| u.copied).count();
|
||||
let opened_count = history.iter().filter(|u| u.opened_in_browser).count();
|
||||
let dismissed_count = history.iter().filter(|u| u.dismissed).count();
|
||||
|
||||
Ok(InterceptorStatistics {
|
||||
total_intercepted: history.len(),
|
||||
current_intercepted: current_urls.len(),
|
||||
copied_count,
|
||||
opened_count,
|
||||
dismissed_count,
|
||||
})
|
||||
} else {
|
||||
Ok(InterceptorStatistics {
|
||||
total_intercepted: 0,
|
||||
if let Some(ref _int) = *interceptor {
|
||||
// 简化实现,返回默认统计
|
||||
Ok(UrlStatistics {
|
||||
current_intercepted: 0,
|
||||
total_intercepted: 0,
|
||||
copied_count: 0,
|
||||
opened_count: 0,
|
||||
dismissed_count: 0,
|
||||
process_stats: std::collections::HashMap::new(),
|
||||
})
|
||||
} else {
|
||||
Ok(UrlStatistics {
|
||||
current_intercepted: 0,
|
||||
total_intercepted: 0,
|
||||
copied_count: 0,
|
||||
opened_count: 0,
|
||||
dismissed_count: 0,
|
||||
process_stats: std::collections::HashMap::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 通知相关结构体
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NotificationPayload {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
/// 显示系统通知
|
||||
/// 显示通知
|
||||
#[tauri::command]
|
||||
pub async fn show_notification(
|
||||
_app: AppHandle,
|
||||
title: String,
|
||||
body: String,
|
||||
_icon: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
// 简化实现,返回成功但实际功能待实现
|
||||
tracing::info!("显示通知: {} - {}", title, body);
|
||||
Ok("通知已记录到日志".to_string())
|
||||
tracing::info!("[通知] {}: {}", title, body);
|
||||
Ok("通知已显示".to_string())
|
||||
}
|
||||
|
||||
/// 显示URL拦截通知
|
||||
/// 显示 URL 拦截通知
|
||||
#[tauri::command]
|
||||
pub async fn show_url_intercept_notification(
|
||||
app: AppHandle,
|
||||
url: String,
|
||||
source_process: String,
|
||||
) -> Result<String, String> {
|
||||
let title = "🔐 拦截到新的URL".to_string();
|
||||
let body = format!("来自 {}: {}", source_process, truncate_url(&url, 60));
|
||||
|
||||
show_notification(app, title, body, Some("icon".to_string())).await
|
||||
tracing::info!("[URL 拦截] 来自 {}: {}", source_process, url);
|
||||
Ok("通知已显示".to_string())
|
||||
}
|
||||
|
||||
/// 显示状态变更通知
|
||||
/// 显示状态通知
|
||||
#[tauri::command]
|
||||
pub async fn show_status_notification(
|
||||
app: AppHandle,
|
||||
message: String,
|
||||
notification_type: String,
|
||||
) -> Result<String, String> {
|
||||
let (icon, title) = match notification_type.as_str() {
|
||||
"success" => ("✅", "操作成功"),
|
||||
"warning" => ("⚠️", "警告"),
|
||||
"error" => ("❌", "错误"),
|
||||
_ => ("ℹ️", "信息"),
|
||||
};
|
||||
|
||||
let title = format!("{} {}", icon, title);
|
||||
show_notification(app, title, message, None).await
|
||||
}
|
||||
|
||||
/// 截断URL用于通知显示
|
||||
fn truncate_url(url: &str, max_length: usize) -> String {
|
||||
if url.len() <= max_length {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{}...", &url[0..max_length])
|
||||
}
|
||||
tracing::info!("[状态通知] [{}] {}", notification_type, message);
|
||||
Ok("通知已显示".to_string())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.28.0",
|
||||
"version": "0.29.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+24
-25
@@ -15,15 +15,13 @@ import { AppSidebar } from "./components/AppSidebar";
|
||||
import { SettingsPage } from "./components/settings";
|
||||
import { ApiServerPage } from "./components/api-server/ApiServerPage";
|
||||
import { ProviderPoolPage } from "./components/provider-pool";
|
||||
import { ConfigManagementPage } from "./components/config/ConfigManagementPage";
|
||||
import { FlowMonitorPage } from "./pages";
|
||||
import { ToolsPage } from "./components/tools/ToolsPage";
|
||||
import { BrowserInterceptorTool } from "./components/tools/browser-interceptor/BrowserInterceptorTool";
|
||||
import { AgentChatPage } from "./components/agent";
|
||||
import { PluginUIRenderer } from "./components/plugins/PluginUIRenderer";
|
||||
import { PluginsPage } from "./components/plugins/PluginsPage";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { flowEventManager } from "./lib/flowEventManager";
|
||||
import { OnboardingWizard, useOnboardingState } from "./components/onboarding";
|
||||
|
||||
/**
|
||||
* 页面类型定义
|
||||
@@ -36,13 +34,10 @@ import { flowEventManager } from "./lib/flowEventManager";
|
||||
*/
|
||||
type Page =
|
||||
| "provider-pool"
|
||||
| "config-management"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "agent"
|
||||
| "tools"
|
||||
| "plugins"
|
||||
| "browser-interceptor"
|
||||
| "settings"
|
||||
| `plugin:${string}`;
|
||||
|
||||
@@ -70,6 +65,7 @@ const PageWrapper = styled.div`
|
||||
function App() {
|
||||
const [showSplash, setShowSplash] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState<Page>("agent");
|
||||
const { needsOnboarding, completeOnboarding } = useOnboardingState();
|
||||
|
||||
// 在应用启动时初始化 Flow 事件订阅
|
||||
useEffect(() => {
|
||||
@@ -116,24 +112,12 @@ function App() {
|
||||
<ProviderPoolPage />
|
||||
</PageWrapper>
|
||||
);
|
||||
case "config-management":
|
||||
return (
|
||||
<PageWrapper>
|
||||
<ConfigManagementPage />
|
||||
</PageWrapper>
|
||||
);
|
||||
case "api-server":
|
||||
return (
|
||||
<PageWrapper>
|
||||
<ApiServerPage />
|
||||
</PageWrapper>
|
||||
);
|
||||
case "flow-monitor":
|
||||
return (
|
||||
<PageWrapper>
|
||||
<FlowMonitorPage />
|
||||
</PageWrapper>
|
||||
);
|
||||
case "agent":
|
||||
// Agent 页面有自己的布局,不需要 PageWrapper
|
||||
return (
|
||||
@@ -151,12 +135,6 @@ function App() {
|
||||
<PluginsPage />
|
||||
</PageWrapper>
|
||||
);
|
||||
case "browser-interceptor":
|
||||
return (
|
||||
<PageWrapper>
|
||||
<BrowserInterceptorTool onNavigate={setCurrentPage} />
|
||||
</PageWrapper>
|
||||
);
|
||||
case "settings":
|
||||
return (
|
||||
<PageWrapper>
|
||||
@@ -172,11 +150,32 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// 显示启动画面
|
||||
// 引导完成回调
|
||||
const handleOnboardingComplete = useCallback(() => {
|
||||
completeOnboarding();
|
||||
}, [completeOnboarding]);
|
||||
|
||||
// 1. 显示启动画面
|
||||
if (showSplash) {
|
||||
return <SplashScreen onComplete={handleSplashComplete} />;
|
||||
}
|
||||
|
||||
// 2. 检测中,显示空白
|
||||
if (needsOnboarding === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 需要引导时显示引导向导
|
||||
if (needsOnboarding) {
|
||||
return (
|
||||
<>
|
||||
<OnboardingWizard onComplete={handleOnboardingComplete} />
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 正常主界面
|
||||
return (
|
||||
<AppContainer>
|
||||
<AppSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
|
||||
|
||||
@@ -16,24 +16,23 @@ import {
|
||||
Bot,
|
||||
Globe,
|
||||
Database,
|
||||
FileCode,
|
||||
Activity,
|
||||
Wrench,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Moon,
|
||||
Sun,
|
||||
Activity,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI";
|
||||
|
||||
type Page =
|
||||
| "provider-pool"
|
||||
| "config-management"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "agent"
|
||||
| "tools"
|
||||
| "plugins"
|
||||
| "browser-interceptor"
|
||||
| "settings"
|
||||
| `plugin:${string}`;
|
||||
|
||||
@@ -131,12 +130,21 @@ const mainMenuItems: { id: Page; label: string; icon: typeof Bot }[] = [
|
||||
{ id: "agent", label: "AI Agent", icon: Bot },
|
||||
{ id: "api-server", label: "API Server", icon: Globe },
|
||||
{ id: "provider-pool", label: "凭证池", icon: Database },
|
||||
{ id: "config-management", label: "配置管理", icon: FileCode },
|
||||
{ id: "flow-monitor", label: "Flow Monitor", icon: Activity },
|
||||
{ id: "tools", label: "工具", icon: Wrench },
|
||||
{ id: "plugins", label: "插件中心", icon: Puzzle },
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据图标名称获取 Lucide 图标组件
|
||||
* 默认返回 Activity 图标
|
||||
*/
|
||||
function getIconByName(iconName: string): LucideIcon {
|
||||
const IconComponent = (
|
||||
LucideIcons as unknown as Record<string, LucideIcon | undefined>
|
||||
)[iconName];
|
||||
return IconComponent || Activity;
|
||||
}
|
||||
|
||||
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -147,6 +155,46 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
return "light";
|
||||
});
|
||||
|
||||
// 已安装的侧边栏插件列表
|
||||
const [sidebarPlugins, setSidebarPlugins] = useState<PluginUIInfo[]>([]);
|
||||
// 刷新触发器
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// 加载侧边栏插件
|
||||
useEffect(() => {
|
||||
const loadSidebarPlugins = async () => {
|
||||
try {
|
||||
const plugins = await getPluginsForSurface("sidebar");
|
||||
setSidebarPlugins(plugins);
|
||||
} catch (error) {
|
||||
console.error("加载侧边栏插件失败:", error);
|
||||
}
|
||||
};
|
||||
loadSidebarPlugins();
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// 监听插件安装/卸载事件,刷新侧边栏
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === "plugin-changed") {
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听自定义事件
|
||||
const handlePluginChange = () => {
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
window.addEventListener("plugin-changed", handlePluginChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
window.removeEventListener("plugin-changed", handlePluginChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
@@ -190,6 +238,26 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{/* 动态插件入口 */}
|
||||
{sidebarPlugins.map((plugin) => {
|
||||
const PluginIcon = getIconByName(plugin.icon);
|
||||
const pluginPageId: Page = `plugin:${plugin.pluginId}`;
|
||||
return (
|
||||
<Tooltip key={plugin.pluginId}>
|
||||
<TooltipTrigger asChild>
|
||||
<IconButton
|
||||
$active={currentPage === pluginPageId}
|
||||
onClick={() => onNavigate(pluginPageId)}
|
||||
>
|
||||
<PluginIcon />
|
||||
</IconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<span className="whitespace-nowrap">{plugin.name}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</MenusContainer>
|
||||
|
||||
<BottomMenus>
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
Settings,
|
||||
Globe,
|
||||
Database,
|
||||
FileCode,
|
||||
Activity,
|
||||
Wrench,
|
||||
Bot,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { Settings, Globe, Database, Wrench, Bot, Puzzle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -21,13 +12,10 @@ import { cn } from "@/lib/utils";
|
||||
*/
|
||||
type Page =
|
||||
| "provider-pool"
|
||||
| "config-management"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "agent"
|
||||
| "tools"
|
||||
| "plugins"
|
||||
| "browser-interceptor"
|
||||
| "settings"
|
||||
| `plugin:${string}`;
|
||||
|
||||
@@ -40,8 +28,6 @@ const navItems = [
|
||||
{ id: "agent" as Page, label: "AI Agent", icon: Bot },
|
||||
{ id: "api-server" as Page, label: "API Server", icon: Globe },
|
||||
{ id: "provider-pool" as Page, label: "凭证池", icon: Database },
|
||||
{ id: "config-management" as Page, label: "配置管理", icon: FileCode },
|
||||
{ id: "flow-monitor" as Page, label: "Flow Monitor", icon: Activity },
|
||||
{ id: "tools" as Page, label: "工具", icon: Wrench },
|
||||
{ id: "plugins" as Page, label: "插件中心", icon: Puzzle },
|
||||
{ id: "settings" as Page, label: "设置", icon: Settings },
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 初次安装引导 - 主向导组件
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { WelcomeStep } from "./steps/WelcomeStep";
|
||||
import { UserProfileStep } from "./steps/UserProfileStep";
|
||||
import { PluginSelectStep } from "./steps/PluginSelectStep";
|
||||
import {
|
||||
InstallProgressStep,
|
||||
type PluginInstallState,
|
||||
} from "./steps/InstallProgressStep";
|
||||
import { CompleteStep } from "./steps/CompleteStep";
|
||||
import { userProfiles, type UserProfile } from "./constants";
|
||||
|
||||
const Overlay = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: hsl(var(--background));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--card));
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px hsl(var(--border));
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StepIndicator = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
`;
|
||||
|
||||
const StepDot = styled.div<{ $active: boolean; $completed: boolean }>`
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $active, $completed }) =>
|
||||
$active
|
||||
? "hsl(var(--primary))"
|
||||
: $completed
|
||||
? "hsl(var(--primary) / 0.5)"
|
||||
: "hsl(var(--muted))"};
|
||||
transition: all 0.2s;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
`;
|
||||
|
||||
const FooterLeft = styled.div``;
|
||||
|
||||
const FooterRight = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [selectedPlugins, setSelectedPlugins] = useState<string[]>([]);
|
||||
const [installResults, setInstallResults] = useState<PluginInstallState[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
// 当用户选择群体时,自动选中默认插件
|
||||
useEffect(() => {
|
||||
if (userProfile) {
|
||||
const profile = userProfiles.find((p) => p.id === userProfile);
|
||||
if (profile) {
|
||||
setSelectedPlugins(profile.defaultPlugins);
|
||||
}
|
||||
}
|
||||
}, [userProfile]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
// 如果没有选择插件,跳过安装步骤
|
||||
if (currentStep === 3 && selectedPlugins.length === 0) {
|
||||
setCurrentStep(5); // 直接跳到完成页
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
}, [currentStep, selectedPlugins]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
// 如果从完成页返回且没有安装结果,返回到插件选择
|
||||
if (currentStep === 5 && installResults.length === 0) {
|
||||
setCurrentStep(3);
|
||||
} else if (currentStep === 5) {
|
||||
// 已经安装过,不能返回
|
||||
return;
|
||||
} else {
|
||||
setCurrentStep((prev) => prev - 1);
|
||||
}
|
||||
}
|
||||
}, [currentStep, installResults]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
|
||||
const handleInstallComplete = useCallback((results: PluginInstallState[]) => {
|
||||
setInstallResults(results);
|
||||
setCurrentStep(5);
|
||||
}, []);
|
||||
|
||||
const handleFinish = useCallback(() => {
|
||||
// 触发插件变化事件,刷新侧边栏
|
||||
window.dispatchEvent(new CustomEvent("plugin-changed"));
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
|
||||
// 渲染当前步骤
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <WelcomeStep onNext={handleNext} onSkip={handleSkip} />;
|
||||
case 2:
|
||||
return (
|
||||
<UserProfileStep
|
||||
selectedProfile={userProfile}
|
||||
onSelect={setUserProfile}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<PluginSelectStep
|
||||
userProfile={userProfile}
|
||||
selectedPlugins={selectedPlugins}
|
||||
onSelectionChange={setSelectedPlugins}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<InstallProgressStep
|
||||
selectedPlugins={selectedPlugins}
|
||||
onComplete={handleInstallComplete}
|
||||
/>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<CompleteStep
|
||||
installResults={installResults}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断下一步按钮是否可用
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 2:
|
||||
return userProfile !== null;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否显示底部导航
|
||||
const showFooter =
|
||||
currentStep !== 1 && currentStep !== 4 && currentStep !== 5;
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<Container>
|
||||
<StepIndicator>
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
|
||||
<StepDot
|
||||
key={i}
|
||||
$active={currentStep === i + 1}
|
||||
$completed={currentStep > i + 1}
|
||||
/>
|
||||
))}
|
||||
</StepIndicator>
|
||||
|
||||
<Content>{renderStep()}</Content>
|
||||
|
||||
{showFooter && (
|
||||
<Footer>
|
||||
<FooterLeft>
|
||||
{currentStep > 1 && (
|
||||
<Button variant="ghost" onClick={handleBack}>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
</FooterLeft>
|
||||
<FooterRight>
|
||||
<Button variant="outline" onClick={handleSkip}>
|
||||
跳过
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!canProceed()}>
|
||||
{currentStep === 3
|
||||
? selectedPlugins.length > 0
|
||||
? "开始安装"
|
||||
: "跳过安装"
|
||||
: "下一步"}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</FooterRight>
|
||||
</Footer>
|
||||
)}
|
||||
</Container>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 初次安装引导 - 常量配置
|
||||
*/
|
||||
|
||||
import { Code, User, FileCode, Activity, Cpu, Globe } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 用户群体类型
|
||||
*/
|
||||
export type UserProfile = "developer" | "general";
|
||||
|
||||
/**
|
||||
* 用户群体配置
|
||||
*/
|
||||
export interface UserProfileConfig {
|
||||
id: UserProfile;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
defaultPlugins: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导插件配置
|
||||
*/
|
||||
export interface OnboardingPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户群体列表
|
||||
*/
|
||||
export const userProfiles: UserProfileConfig[] = [
|
||||
{
|
||||
id: "developer",
|
||||
name: "程序员",
|
||||
description: "使用 Claude Code、Codex、Gemini 等 AI 编程工具",
|
||||
icon: Code,
|
||||
defaultPlugins: ["config-switch", "flow-monitor"],
|
||||
},
|
||||
{
|
||||
id: "general",
|
||||
name: "普通用户",
|
||||
description: "日常使用 AI 聊天和其他功能",
|
||||
icon: User,
|
||||
defaultPlugins: [],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 可安装插件列表
|
||||
*/
|
||||
export const onboardingPlugins: OnboardingPlugin[] = [
|
||||
{
|
||||
id: "config-switch",
|
||||
name: "配置管理",
|
||||
description: "一键切换 API 配置,支持 Claude Code、Codex、Gemini 等客户端",
|
||||
icon: FileCode,
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/config-switch/releases/latest/download/config-switch-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "flow-monitor",
|
||||
name: "Flow Monitor",
|
||||
description: "监控和分析 LLM API 请求,提供详细的流量分析和调试功能",
|
||||
icon: Activity,
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/flow-monitor/releases/latest/download/flow-monitor-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "machine-id-tool",
|
||||
name: "机器码管理工具",
|
||||
description: "查看、修改和管理系统机器码,支持跨平台操作",
|
||||
icon: Cpu,
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/MachineIdTool/releases/latest/download/machine-id-tool-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "browser-interception",
|
||||
name: "浏览器拦截器",
|
||||
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
|
||||
icon: Globe,
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/browser-interception/releases/latest/download/browser-interception-plugin.zip",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 引导版本号 - 用于控制是否重新显示引导
|
||||
*/
|
||||
export const ONBOARDING_VERSION = "1.0.0";
|
||||
|
||||
/**
|
||||
* localStorage 键名
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
ONBOARDING_COMPLETE: "proxycast_onboarding_complete",
|
||||
ONBOARDING_VERSION: "proxycast_onboarding_version",
|
||||
USER_PROFILE: "proxycast_user_profile",
|
||||
} as const;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 初次安装引导 - 状态管理 Hook
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
STORAGE_KEYS,
|
||||
ONBOARDING_VERSION,
|
||||
type UserProfile,
|
||||
} from "../constants";
|
||||
|
||||
/**
|
||||
* 引导状态 Hook
|
||||
*
|
||||
* 管理首次启动检测和引导完成状态
|
||||
*/
|
||||
export function useOnboardingState() {
|
||||
// null 表示正在检测中
|
||||
const [needsOnboarding, setNeedsOnboarding] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 检测是否需要显示引导
|
||||
const isComplete =
|
||||
localStorage.getItem(STORAGE_KEYS.ONBOARDING_COMPLETE) === "true";
|
||||
const version = localStorage.getItem(STORAGE_KEYS.ONBOARDING_VERSION);
|
||||
|
||||
// 首次启动或版本升级时显示引导
|
||||
setNeedsOnboarding(!isComplete || version !== ONBOARDING_VERSION);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 完成引导
|
||||
*/
|
||||
const completeOnboarding = useCallback((userProfile?: UserProfile) => {
|
||||
localStorage.setItem(STORAGE_KEYS.ONBOARDING_COMPLETE, "true");
|
||||
localStorage.setItem(STORAGE_KEYS.ONBOARDING_VERSION, ONBOARDING_VERSION);
|
||||
if (userProfile) {
|
||||
localStorage.setItem(STORAGE_KEYS.USER_PROFILE, userProfile);
|
||||
}
|
||||
setNeedsOnboarding(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 重置引导(用于设置页面"重新运行引导")
|
||||
*/
|
||||
const resetOnboarding = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEYS.ONBOARDING_COMPLETE);
|
||||
localStorage.removeItem(STORAGE_KEYS.ONBOARDING_VERSION);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_PROFILE);
|
||||
setNeedsOnboarding(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 获取保存的用户群体
|
||||
*/
|
||||
const getSavedUserProfile = useCallback((): UserProfile | null => {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.USER_PROFILE);
|
||||
if (saved === "developer" || saved === "general") {
|
||||
return saved;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
needsOnboarding,
|
||||
completeOnboarding,
|
||||
resetOnboarding,
|
||||
getSavedUserProfile,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 初次安装引导模块导出
|
||||
*/
|
||||
|
||||
export { OnboardingWizard } from "./OnboardingWizard";
|
||||
export { useOnboardingState } from "./hooks/useOnboarding";
|
||||
export * from "./constants";
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 初次安装引导 - 完成页
|
||||
*/
|
||||
|
||||
import styled from "styled-components";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, XCircle, PartyPopper } from "lucide-react";
|
||||
import { onboardingPlugins } from "../constants";
|
||||
import type { PluginInstallState } from "./InstallProgressStep";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const IconContainer = styled.div`
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: hsl(142.1 76.2% 36.3% / 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: hsl(142.1 76.2% 36.3%);
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 32px;
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const ResultList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
const ResultItem = styled.div<{ $success: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: ${({ $success }) =>
|
||||
$success ? "hsl(142.1 76.2% 36.3% / 0.1)" : "hsl(0 84.2% 60.2% / 0.1)"};
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: ${({ $success }) =>
|
||||
$success ? "hsl(142.1 76.2% 36.3%)" : "hsl(0 84.2% 60.2%)"};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultText = styled.span<{ $success: boolean }>`
|
||||
font-size: 14px;
|
||||
color: ${({ $success }) =>
|
||||
$success ? "hsl(142.1 76.2% 36.3%)" : "hsl(0 84.2% 60.2%)"};
|
||||
`;
|
||||
|
||||
const NoPluginsMessage = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
interface CompleteStepProps {
|
||||
installResults: PluginInstallState[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function CompleteStep({ installResults, onFinish }: CompleteStepProps) {
|
||||
const successCount = installResults.filter(
|
||||
(r) => r.status === "complete",
|
||||
).length;
|
||||
const failedCount = installResults.filter(
|
||||
(r) => r.status === "failed",
|
||||
).length;
|
||||
const hasResults = installResults.length > 0;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<IconContainer>
|
||||
<PartyPopper />
|
||||
</IconContainer>
|
||||
|
||||
<Title>设置完成!</Title>
|
||||
<Subtitle>
|
||||
{hasResults
|
||||
? successCount === installResults.length
|
||||
? "所有插件已成功安装,您可以开始使用 ProxyCast 了。"
|
||||
: `已安装 ${successCount} 个插件${failedCount > 0 ? `,${failedCount} 个安装失败` : ""}。您可以稍后在插件中心重试。`
|
||||
: "您已跳过插件安装,可以稍后在插件中心安装需要的插件。"}
|
||||
</Subtitle>
|
||||
|
||||
{hasResults && (
|
||||
<ResultList>
|
||||
{installResults.map((result) => {
|
||||
const plugin = onboardingPlugins.find(
|
||||
(p) => p.id === result.pluginId,
|
||||
);
|
||||
const isSuccess = result.status === "complete";
|
||||
|
||||
return (
|
||||
<ResultItem key={result.pluginId} $success={isSuccess}>
|
||||
{isSuccess ? <CheckCircle /> : <XCircle />}
|
||||
<ResultText $success={isSuccess}>
|
||||
{plugin?.name || result.pluginId}
|
||||
{isSuccess ? " 安装成功" : " 安装失败"}
|
||||
</ResultText>
|
||||
</ResultItem>
|
||||
);
|
||||
})}
|
||||
</ResultList>
|
||||
)}
|
||||
|
||||
{!hasResults && (
|
||||
<NoPluginsMessage>
|
||||
提示:您可以在左侧导航栏的"插件中心"随时安装插件
|
||||
</NoPluginsMessage>
|
||||
)}
|
||||
|
||||
<Button size="lg" onClick={onFinish}>
|
||||
开始使用
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* 初次安装引导 - 安装进度
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import styled from "styled-components";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { Check, X, Loader2 } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { onboardingPlugins } from "../constants";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const PluginList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
`;
|
||||
|
||||
const PluginRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div<{ $status: string }>`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: ${({ $status }) => {
|
||||
switch ($status) {
|
||||
case "complete":
|
||||
return "hsl(142.1 76.2% 36.3%)";
|
||||
case "failed":
|
||||
return "hsl(0 84.2% 60.2%)";
|
||||
default:
|
||||
return "hsl(var(--muted))";
|
||||
}
|
||||
}};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: ${({ $status }) =>
|
||||
$status === "complete" || $status === "failed"
|
||||
? "white"
|
||||
: "hsl(var(--foreground))"};
|
||||
}
|
||||
`;
|
||||
|
||||
const PluginInfo = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const PluginName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const PluginStatus = styled.div<{ $status: string }>`
|
||||
font-size: 12px;
|
||||
color: ${({ $status }) => {
|
||||
switch ($status) {
|
||||
case "complete":
|
||||
return "hsl(142.1 76.2% 36.3%)";
|
||||
case "failed":
|
||||
return "hsl(0 84.2% 60.2%)";
|
||||
default:
|
||||
return "hsl(var(--muted-foreground))";
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const ProgressWrapper = styled.div`
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const OverallProgress = styled.div`
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
`;
|
||||
|
||||
const OverallLabel = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
/**
|
||||
* 插件安装状态
|
||||
*/
|
||||
export interface PluginInstallState {
|
||||
pluginId: string;
|
||||
status: "pending" | "downloading" | "installing" | "complete" | "failed";
|
||||
progress: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装进度事件
|
||||
*/
|
||||
interface InstallProgress {
|
||||
stage: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装结果
|
||||
*/
|
||||
interface InstallResult {
|
||||
success: boolean;
|
||||
plugin?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface InstallProgressStepProps {
|
||||
selectedPlugins: string[];
|
||||
onComplete: (results: PluginInstallState[]) => void;
|
||||
}
|
||||
|
||||
export function InstallProgressStep({
|
||||
selectedPlugins,
|
||||
onComplete,
|
||||
}: InstallProgressStepProps) {
|
||||
const [installStates, setInstallStates] = useState<PluginInstallState[]>([]);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const hasStarted = useRef(false);
|
||||
|
||||
// 初始化安装状态
|
||||
useEffect(() => {
|
||||
if (selectedPlugins.length === 0) {
|
||||
onComplete([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallStates(
|
||||
selectedPlugins.map((id) => ({
|
||||
pluginId: id,
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
message: "等待安装...",
|
||||
})),
|
||||
);
|
||||
}, [selectedPlugins, onComplete]);
|
||||
|
||||
// 顺序安装插件
|
||||
const installPlugins = useCallback(async () => {
|
||||
if (isInstalling || selectedPlugins.length === 0) return;
|
||||
setIsInstalling(true);
|
||||
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
for (let i = 0; i < selectedPlugins.length; i++) {
|
||||
const pluginId = selectedPlugins[i];
|
||||
const plugin = onboardingPlugins.find((p) => p.id === pluginId);
|
||||
|
||||
if (!plugin) continue;
|
||||
|
||||
try {
|
||||
// 监听当前插件的进度
|
||||
unlisten = await listen<InstallProgress>(
|
||||
"plugin-install-progress",
|
||||
(event) => {
|
||||
setInstallStates((prev) =>
|
||||
prev.map((state) =>
|
||||
state.pluginId === pluginId
|
||||
? {
|
||||
...state,
|
||||
status:
|
||||
event.payload.stage === "complete"
|
||||
? "complete"
|
||||
: event.payload.stage === "failed"
|
||||
? "failed"
|
||||
: "downloading",
|
||||
progress: event.payload.percent,
|
||||
message: event.payload.message,
|
||||
}
|
||||
: state,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 更新状态为下载中
|
||||
setInstallStates((prev) =>
|
||||
prev.map((state) =>
|
||||
state.pluginId === pluginId
|
||||
? { ...state, status: "downloading", message: "准备下载..." }
|
||||
: state,
|
||||
),
|
||||
);
|
||||
|
||||
// 调用安装 API
|
||||
const result = await invoke<InstallResult>("install_plugin_from_url", {
|
||||
url: plugin.downloadUrl,
|
||||
});
|
||||
|
||||
// 取消监听
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
|
||||
// 更新结果状态
|
||||
setInstallStates((prev) =>
|
||||
prev.map((state) =>
|
||||
state.pluginId === pluginId
|
||||
? {
|
||||
...state,
|
||||
status: result.success ? "complete" : "failed",
|
||||
progress: 100,
|
||||
message: result.success
|
||||
? "安装成功"
|
||||
: result.error || "安装失败",
|
||||
error: result.error,
|
||||
}
|
||||
: state,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// 取消监听
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
|
||||
setInstallStates((prev) =>
|
||||
prev.map((state) =>
|
||||
state.pluginId === pluginId
|
||||
? {
|
||||
...state,
|
||||
status: "failed",
|
||||
progress: 100,
|
||||
message: "安装出错",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}
|
||||
: state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setIsInstalling(false);
|
||||
}, [selectedPlugins, isInstalling]);
|
||||
|
||||
// 开始安装
|
||||
useEffect(() => {
|
||||
if (
|
||||
installStates.length > 0 &&
|
||||
!isInstalling &&
|
||||
!hasStarted.current &&
|
||||
installStates.every((s) => s.status === "pending")
|
||||
) {
|
||||
hasStarted.current = true;
|
||||
installPlugins();
|
||||
}
|
||||
}, [installStates, isInstalling, installPlugins]);
|
||||
|
||||
// 检查是否全部完成
|
||||
useEffect(() => {
|
||||
if (
|
||||
installStates.length > 0 &&
|
||||
installStates.every(
|
||||
(s) => s.status === "complete" || s.status === "failed",
|
||||
)
|
||||
) {
|
||||
// 延迟一点调用 onComplete,让用户看到最终状态
|
||||
const timer = setTimeout(() => {
|
||||
onComplete(installStates);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [installStates, onComplete]);
|
||||
|
||||
// 计算总体进度
|
||||
const completedCount = installStates.filter(
|
||||
(s) => s.status === "complete" || s.status === "failed",
|
||||
).length;
|
||||
const overallProgress =
|
||||
selectedPlugins.length > 0
|
||||
? Math.round((completedCount / selectedPlugins.length) * 100)
|
||||
: 0;
|
||||
|
||||
const getStatusIcon = (state: PluginInstallState) => {
|
||||
const plugin = onboardingPlugins.find((p) => p.id === state.pluginId);
|
||||
const Icon = plugin?.icon;
|
||||
|
||||
switch (state.status) {
|
||||
case "complete":
|
||||
return <Check />;
|
||||
case "failed":
|
||||
return <X />;
|
||||
case "downloading":
|
||||
case "installing":
|
||||
return <Loader2 className="animate-spin" />;
|
||||
default:
|
||||
return Icon ? <Icon /> : null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>正在安装插件</Title>
|
||||
<Subtitle>请稍候,正在为您安装选中的插件...</Subtitle>
|
||||
|
||||
<PluginList>
|
||||
{installStates.map((state) => {
|
||||
const plugin = onboardingPlugins.find((p) => p.id === state.pluginId);
|
||||
|
||||
return (
|
||||
<PluginRow key={state.pluginId}>
|
||||
<IconWrapper $status={state.status}>
|
||||
{getStatusIcon(state)}
|
||||
</IconWrapper>
|
||||
<PluginInfo>
|
||||
<PluginName>{plugin?.name || state.pluginId}</PluginName>
|
||||
<PluginStatus $status={state.status}>
|
||||
{state.message}
|
||||
</PluginStatus>
|
||||
</PluginInfo>
|
||||
<ProgressWrapper>
|
||||
<Progress value={state.progress} />
|
||||
</ProgressWrapper>
|
||||
</PluginRow>
|
||||
);
|
||||
})}
|
||||
</PluginList>
|
||||
|
||||
<OverallProgress>
|
||||
<OverallLabel>
|
||||
<span>总体进度</span>
|
||||
<span>
|
||||
{completedCount} / {selectedPlugins.length}
|
||||
</span>
|
||||
</OverallLabel>
|
||||
<Progress value={overallProgress} />
|
||||
</OverallProgress>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 初次安装引导 - 插件选择
|
||||
*/
|
||||
|
||||
import styled from "styled-components";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
onboardingPlugins,
|
||||
userProfiles,
|
||||
type UserProfile,
|
||||
} from "../constants";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const PluginList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
`;
|
||||
|
||||
const PluginCard = styled.label<{ $selected?: boolean }>`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid
|
||||
${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary) / 0.05)" : "hsl(var(--card))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckboxWrapper = styled.div`
|
||||
padding-top: 2px;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div<{ $selected?: boolean }>`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary))" : "hsl(var(--muted))"};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"};
|
||||
}
|
||||
`;
|
||||
|
||||
const PluginInfo = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const PluginName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const PluginDescription = styled.div`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const RecommendBadge = styled.span`
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const SelectAllRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const SelectAllButton = styled.button`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--primary));
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
interface PluginSelectStepProps {
|
||||
userProfile: UserProfile | null;
|
||||
selectedPlugins: string[];
|
||||
onSelectionChange: (plugins: string[]) => void;
|
||||
}
|
||||
|
||||
export function PluginSelectStep({
|
||||
userProfile,
|
||||
selectedPlugins,
|
||||
onSelectionChange,
|
||||
}: PluginSelectStepProps) {
|
||||
// 获取当前用户群体的默认插件
|
||||
const defaultPlugins =
|
||||
userProfiles.find((p) => p.id === userProfile)?.defaultPlugins || [];
|
||||
|
||||
const handleToggle = (pluginId: string) => {
|
||||
if (selectedPlugins.includes(pluginId)) {
|
||||
onSelectionChange(selectedPlugins.filter((id) => id !== pluginId));
|
||||
} else {
|
||||
onSelectionChange([...selectedPlugins, pluginId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedPlugins.length === onboardingPlugins.length) {
|
||||
onSelectionChange([]);
|
||||
} else {
|
||||
onSelectionChange(onboardingPlugins.map((p) => p.id));
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = selectedPlugins.length === onboardingPlugins.length;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>选择要安装的插件</Title>
|
||||
<Subtitle>
|
||||
{userProfile === "developer"
|
||||
? "已为程序员推荐配置管理和 Flow Monitor 插件"
|
||||
: "您可以根据需要选择插件,或稍后在插件中心安装"}
|
||||
</Subtitle>
|
||||
|
||||
<SelectAllRow>
|
||||
<SelectAllButton onClick={handleSelectAll}>
|
||||
{isAllSelected ? "取消全选" : "全选"}
|
||||
</SelectAllButton>
|
||||
</SelectAllRow>
|
||||
|
||||
<PluginList>
|
||||
{onboardingPlugins.map((plugin) => {
|
||||
const isSelected = selectedPlugins.includes(plugin.id);
|
||||
const isRecommended = defaultPlugins.includes(plugin.id);
|
||||
const Icon = plugin.icon;
|
||||
|
||||
return (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
$selected={isSelected}
|
||||
onClick={() => handleToggle(plugin.id)}
|
||||
>
|
||||
<CheckboxWrapper>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggle(plugin.id)}
|
||||
/>
|
||||
</CheckboxWrapper>
|
||||
<IconWrapper $selected={isSelected}>
|
||||
<Icon />
|
||||
</IconWrapper>
|
||||
<PluginInfo>
|
||||
<PluginName>
|
||||
{plugin.name}
|
||||
{isRecommended && <RecommendBadge>推荐</RecommendBadge>}
|
||||
</PluginName>
|
||||
<PluginDescription>{plugin.description}</PluginDescription>
|
||||
</PluginInfo>
|
||||
</PluginCard>
|
||||
);
|
||||
})}
|
||||
</PluginList>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 初次安装引导 - 用户群体选择
|
||||
*/
|
||||
|
||||
import styled from "styled-components";
|
||||
import { Check } from "lucide-react";
|
||||
import { userProfiles, type UserProfile } from "../constants";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const ProfileGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
`;
|
||||
|
||||
const ProfileCard = styled.button<{ $selected?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid
|
||||
${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary) / 0.05)" : "hsl(var(--card))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary))" : "hsl(var(--primary) / 0.5)"};
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckBadge = styled.div`
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div<{ $selected?: boolean }>`
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary))" : "hsl(var(--muted))"};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"};
|
||||
}
|
||||
`;
|
||||
|
||||
const ProfileName = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const ProfileDescription = styled.span`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
interface UserProfileStepProps {
|
||||
selectedProfile: UserProfile | null;
|
||||
onSelect: (profile: UserProfile) => void;
|
||||
}
|
||||
|
||||
export function UserProfileStep({
|
||||
selectedProfile,
|
||||
onSelect,
|
||||
}: UserProfileStepProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Title>您是哪类用户?</Title>
|
||||
<Subtitle>我们将根据您的选择推荐合适的插件</Subtitle>
|
||||
|
||||
<ProfileGrid>
|
||||
{userProfiles.map((profile) => {
|
||||
const isSelected = selectedProfile === profile.id;
|
||||
const Icon = profile.icon;
|
||||
|
||||
return (
|
||||
<ProfileCard
|
||||
key={profile.id}
|
||||
$selected={isSelected}
|
||||
onClick={() => onSelect(profile.id)}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckBadge>
|
||||
<Check />
|
||||
</CheckBadge>
|
||||
)}
|
||||
<IconWrapper $selected={isSelected}>
|
||||
<Icon />
|
||||
</IconWrapper>
|
||||
<ProfileName>{profile.name}</ProfileName>
|
||||
<ProfileDescription>{profile.description}</ProfileDescription>
|
||||
</ProfileCard>
|
||||
);
|
||||
})}
|
||||
</ProfileGrid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 初次安装引导 - 欢迎页
|
||||
*/
|
||||
|
||||
import styled from "styled-components";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
`;
|
||||
|
||||
const LogoContainer = styled.div`
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 32px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Logo = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 18px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
max-width: 400px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 48px;
|
||||
`;
|
||||
|
||||
const FeatureList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 48px;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const FeatureItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 14px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: hsl(var(--primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
interface WelcomeStepProps {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function WelcomeStep({ onNext, onSkip }: WelcomeStepProps) {
|
||||
return (
|
||||
<Container>
|
||||
<LogoContainer>
|
||||
<Logo src="/logo.png" alt="ProxyCast" />
|
||||
</LogoContainer>
|
||||
|
||||
<Title>欢迎使用 ProxyCast</Title>
|
||||
<Subtitle>AI API 聚合代理</Subtitle>
|
||||
<Description>
|
||||
让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。
|
||||
</Description>
|
||||
|
||||
<FeatureList>
|
||||
<FeatureItem>
|
||||
<Sparkles />
|
||||
<span>多凭证池管理,自动轮换</span>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Sparkles />
|
||||
<span>支持 Claude、OpenAI、Gemini 等主流 API</span>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Sparkles />
|
||||
<span>插件扩展,按需安装</span>
|
||||
</FeatureItem>
|
||||
</FeatureList>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
跳过引导
|
||||
</Button>
|
||||
<Button onClick={onNext}>开始设置</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -169,8 +169,10 @@ export function PluginInstallDialog({
|
||||
);
|
||||
|
||||
if (installResult.success && installResult.plugin) {
|
||||
setResult(installResult.plugin);
|
||||
// 安装成功,关闭对话框并通知父组件
|
||||
onSuccess();
|
||||
resetState();
|
||||
onClose();
|
||||
} else {
|
||||
setError(installResult.error || "安装失败");
|
||||
}
|
||||
@@ -179,7 +181,7 @@ export function PluginInstallDialog({
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}, [url, onSuccess]);
|
||||
}, [url, onSuccess, onClose]);
|
||||
|
||||
// 自动开始安装(当有 initialUrl 时)
|
||||
useEffect(() => {
|
||||
@@ -262,8 +264,10 @@ export function PluginInstallDialog({
|
||||
);
|
||||
|
||||
if (installResult.success && installResult.plugin) {
|
||||
setResult(installResult.plugin);
|
||||
// 安装成功,关闭对话框并通知父组件
|
||||
onSuccess();
|
||||
resetState();
|
||||
onClose();
|
||||
} else {
|
||||
setError(installResult.error || "安装失败");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Package,
|
||||
Download,
|
||||
Cpu,
|
||||
Globe,
|
||||
Activity,
|
||||
FileCode,
|
||||
} from "lucide-react";
|
||||
import { PluginInstallDialog } from "./PluginInstallDialog";
|
||||
import { PluginUninstallDialog } from "./PluginUninstallDialog";
|
||||
@@ -111,6 +114,30 @@ const recommendedPlugins: RecommendedPlugin[] = [
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/MachineIdTool/releases/latest/download/machine-id-tool-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "browser-interception",
|
||||
name: "浏览器拦截器",
|
||||
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
|
||||
icon: Globe,
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/browser-interception/releases/latest/download/browser-interception-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "flow-monitor",
|
||||
name: "Flow Monitor",
|
||||
description: "监控和分析 LLM API 请求,提供详细的流量分析和调试功能",
|
||||
icon: Activity,
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/flow-monitor/releases/latest/download/flow-monitor-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "config-switch",
|
||||
name: "配置管理",
|
||||
description: "一键切换 API 配置,支持 Claude Code、Codex、Gemini 等客户端",
|
||||
icon: FileCode,
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/config-switch/releases/latest/download/config-switch-plugin.zip",
|
||||
},
|
||||
];
|
||||
|
||||
export function PluginManager() {
|
||||
@@ -164,6 +191,9 @@ export function PluginManager() {
|
||||
const handleInstallSuccess = useCallback(() => {
|
||||
fetchData();
|
||||
setPendingInstallUrl(null);
|
||||
toast.success("插件安装成功");
|
||||
// 触发侧边栏刷新事件
|
||||
window.dispatchEvent(new CustomEvent("plugin-changed"));
|
||||
}, [fetchData]);
|
||||
|
||||
// 过滤出未安装的推荐插件
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import React from "react";
|
||||
import { AlertCircle, Package } from "lucide-react";
|
||||
import { MachineIdTool } from "@/components/tools/machine-id/MachineIdTool";
|
||||
import { BrowserInterceptorTool } from "@/components/tools/browser-interceptor/BrowserInterceptorTool";
|
||||
import { FlowMonitorPage } from "@/pages";
|
||||
import { ConfigManagementPage } from "@/components/config/ConfigManagementPage";
|
||||
|
||||
/**
|
||||
* 页面类型定义
|
||||
@@ -17,12 +20,10 @@ import { MachineIdTool } from "@/components/tools/machine-id/MachineIdTool";
|
||||
*/
|
||||
export type Page =
|
||||
| "provider-pool"
|
||||
| "config-management"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "agent"
|
||||
| "tools"
|
||||
| "browser-interceptor"
|
||||
| "plugins"
|
||||
| "settings"
|
||||
| `plugin:${string}`;
|
||||
|
||||
@@ -96,13 +97,16 @@ function PluginNotFound({ pluginId }: { pluginId: string }) {
|
||||
* 内置插件组件映射
|
||||
*
|
||||
* 将插件 ID 映射到对应的 React 组件
|
||||
* 目前支持 machine-id-tool 插件
|
||||
* 支持 machine-id-tool 和 browser-interception 插件
|
||||
*/
|
||||
const builtinPluginComponents: Record<
|
||||
string,
|
||||
React.ComponentType<{ onNavigate: (page: Page) => void }>
|
||||
React.ComponentType<{ onNavigate?: (page: Page) => void }>
|
||||
> = {
|
||||
"machine-id-tool": MachineIdTool,
|
||||
"browser-interception": BrowserInterceptorTool,
|
||||
"flow-monitor": FlowMonitorPage,
|
||||
"config-switch": ConfigManagementPage,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,6 +56,8 @@ export function PluginUninstallDialog({
|
||||
try {
|
||||
await invoke<boolean>("uninstall_plugin", { pluginId: plugin.id });
|
||||
onSuccess();
|
||||
// 触发侧边栏刷新事件
|
||||
window.dispatchEvent(new CustomEvent("plugin-changed"));
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* @file GeneralSettings.tsx
|
||||
* @description 通用设置页面 - 主题、代理、启动行为配置
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { Moon, Sun, Monitor, RefreshCw, Info } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Moon, Sun, Monitor, RefreshCw, Info, RotateCcw } from "lucide-react";
|
||||
import { cn, validateProxyUrl } from "@/lib/utils";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { useOnboardingState } from "@/components/onboarding";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
@@ -13,6 +14,13 @@ export function GeneralSettings() {
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
const [launchOnStartup, setLaunchOnStartup] = useState(false);
|
||||
const [minimizeToTray, setMinimizeToTray] = useState(true);
|
||||
const { resetOnboarding } = useOnboardingState();
|
||||
|
||||
// 重新运行引导
|
||||
const handleResetOnboarding = useCallback(() => {
|
||||
resetOnboarding();
|
||||
window.location.reload();
|
||||
}, [resetOnboarding]);
|
||||
|
||||
// 网络代理状态
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
@@ -215,6 +223,25 @@ export function GeneralSettings() {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 重新运行引导 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">初次设置向导</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
重新运行初次安装向导,重新选择用户群体和安装插件
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResetOnboarding}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded border text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
重新引导
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,12 +38,9 @@ interface DynamicToolCard {
|
||||
*/
|
||||
type Page =
|
||||
| "provider-pool"
|
||||
| "config-management"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "agent"
|
||||
| "tools"
|
||||
| "browser-interceptor"
|
||||
| "settings"
|
||||
| "plugins"
|
||||
| `plugin:${string}`;
|
||||
|
||||
@@ -37,12 +37,9 @@ import { toast } from "sonner";
|
||||
*/
|
||||
type Page =
|
||||
| "provider-pool"
|
||||
| "config-management"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "agent"
|
||||
| "tools"
|
||||
| "browser-interceptor"
|
||||
| "settings"
|
||||
| "plugins"
|
||||
| `plugin:${string}`;
|
||||
@@ -182,16 +179,7 @@ function ToolCard({
|
||||
/**
|
||||
* 内置工具列表
|
||||
*/
|
||||
const builtinTools: DynamicToolCard[] = [
|
||||
{
|
||||
id: "browser-interceptor",
|
||||
title: "浏览器拦截器",
|
||||
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
|
||||
icon: "Globe",
|
||||
source: "builtin",
|
||||
status: "已停止", // TODO: 从状态管理中获取
|
||||
},
|
||||
];
|
||||
const builtinTools: DynamicToolCard[] = [];
|
||||
|
||||
/**
|
||||
* 占位工具列表 (敬请期待)
|
||||
@@ -236,6 +224,22 @@ const recommendedPlugins: RecommendedPlugin[] = [
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/MachineIdTool/releases/latest/download/machine-id-tool-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "browser-interception",
|
||||
name: "浏览器拦截器",
|
||||
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
|
||||
icon: "Globe",
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/browser-interception/releases/latest/download/browser-interception-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "flow-monitor",
|
||||
name: "Flow Monitor",
|
||||
description: "监控和分析 LLM API 请求,提供详细的流量分析和调试功能",
|
||||
icon: "Activity",
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/flow-monitor/releases/latest/download/flow-monitor-plugin.zip",
|
||||
},
|
||||
];
|
||||
|
||||
export function ToolsPage({ onNavigate }: ToolsPageProps) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ArrowLeft, Globe, Settings, History, BarChart3 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Globe, Settings, History, BarChart3 } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { StatusControlPanel } from "./StatusControlPanel";
|
||||
import { InterceptedUrlsPanel } from "./InterceptedUrlsPanel";
|
||||
@@ -10,22 +9,7 @@ import { SystemStatusPanel } from "./SystemStatusPanel";
|
||||
import { InterceptorState } from "@/lib/api/browserInterceptor";
|
||||
import * as browserInterceptorApi from "@/lib/api/browserInterceptor";
|
||||
|
||||
interface BrowserInterceptorToolProps {
|
||||
onNavigate: (
|
||||
page:
|
||||
| "provider-pool"
|
||||
| "config-management"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "tools"
|
||||
| "browser-interceptor"
|
||||
| "settings",
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function BrowserInterceptorTool({
|
||||
onNavigate,
|
||||
}: BrowserInterceptorToolProps) {
|
||||
export function BrowserInterceptorTool() {
|
||||
const [interceptorState, setInterceptorState] =
|
||||
useState<InterceptorState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -71,21 +55,12 @@ export function BrowserInterceptorTool({
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onNavigate("tools")}
|
||||
className="p-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Globe className="w-8 h-8 text-blue-500" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">浏览器拦截器</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器
|
||||
</p>
|
||||
</div>
|
||||
<Globe className="w-8 h-8 text-blue-500" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">浏览器拦截器</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Settings,
|
||||
Plus,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
RotateCcw,
|
||||
Pause,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Shield,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
History,
|
||||
Search,
|
||||
|
||||
@@ -10,15 +10,13 @@ import { MachineIdInfo, SystemInfo, AdminStatus } from "@/lib/api/machineId";
|
||||
import { machineIdApi } from "@/lib/api/machineId";
|
||||
|
||||
interface MachineIdToolProps {
|
||||
onNavigate: (
|
||||
onNavigate?: (
|
||||
page:
|
||||
| "provider-pool"
|
||||
| "config-management"
|
||||
| "api-server"
|
||||
| "flow-monitor"
|
||||
| "agent"
|
||||
| "tools"
|
||||
| "browser-interceptor"
|
||||
| "plugins"
|
||||
| "settings"
|
||||
| `plugin:${string}`,
|
||||
) => void;
|
||||
@@ -78,7 +76,7 @@ export function MachineIdTool({ onNavigate }: MachineIdToolProps) {
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onNavigate("tools")}
|
||||
onClick={() => onNavigate?.("tools")}
|
||||
className="p-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
|
||||
Reference in New Issue
Block a user