fix(common): 🐛 modify the location of the remote login window

fix packaging failure due to insufficient memory in js and node
This commit is contained in:
Dawn
2025-11-06 02:33:39 +08:00
parent 8d2c89b11f
commit c01304fb35
18 changed files with 250 additions and 606 deletions

View File

@@ -55,9 +55,13 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
env:
NODE_OPTIONS: --max-old-space-size=4096
run: pnpm install
- name: Generate component typings
env:
NODE_OPTIONS: --max-old-space-size=4096
run: pnpm vite build --emptyOutDir
- name: Build Vite + Tauri
@@ -81,5 +85,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# 增加 Node.js 内存限制,避免构建时内存溢出
NODE_OPTIONS: --max-old-space-size=4096
with:
releaseId: "debug-build"

View File

@@ -38,6 +38,8 @@ jobs:
run: pnpm install
- name: Generate frontend build (typings & assets)
env:
NODE_OPTIONS: --max-old-space-size=4096
run: pnpm vite build --emptyOutDir
- name: Upload generated component typings
@@ -109,6 +111,8 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
env:
NODE_OPTIONS: --max-old-space-size=4096
run: pnpm install
# 安装 Rust
@@ -131,6 +135,8 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
# 使用之前配置的私钥密码
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# 增加 Node.js 内存限制,避免构建时内存溢出
NODE_OPTIONS: --max-old-space-size=4096
with:
tagName: v__VERSION__ #这个动作会自动将\_\_VERSION\_\_替换为app version
releaseName: 'v__VERSION__'

View File

@@ -7,6 +7,9 @@
"tauri-apps.tauri-vscode",
"1yib.rust-bundle",
"ms-vscode.vscode-typescript-next",
"biomejs.biome"
"biomejs.biome",
"github.vscode-github-actions",
"fill-labs.dependi",
"usernamehw.errorlens"
]
}

View File

@@ -1,249 +0,0 @@
# AI 流式数据渲染完成
## ✅ 已完成的功能
### 1. 后端 SSE 数据接收 ✅
- Rust 后端成功接收 SSE 流式数据
- 正确解析服务器返回的 JSON 格式数据
- 通过 Tauri 事件系统发送到前端
### 2. 前端数据解析 ✅
- 前端成功接收 Tauri 事件
- 正确解析 JSON 数据,提取 `data.receive.content` 字段
- 实时更新消息内容
### 3. UI 实时渲染 ✅
- 添加消息列表显示
- 实时更新 AI 回复内容
- 流式光标动画效果
- 自动滚动到底部
## 📊 数据流程
```
服务器 SSE 流
data:{"success":true,"data":{"receive":{"content":"JavaScript"}}}
Rust 后端接收并解析
emit('ai-stream-event', { data: JSON字符串 })
前端 listen('ai-stream-event')
解析 JSON.parse(chunk)
提取 data.data.receive.content
更新 messageList[index].content
Vue 响应式更新 UI
实时显示在聊天界面
```
## 🎨 UI 功能
### 消息显示
- ✅ 用户消息:右对齐,绿色气泡
- ✅ AI 消息:左对齐,默认气泡
- ✅ 流式光标:闪烁的光标效果 `▋`
- ✅ 自动滚动:新消息自动滚动到底部
### 消息结构
```typescript
interface Message {
type: 'user' | 'assistant'
content: string
streaming?: boolean // 是否正在流式输出
timestamp?: number
}
```
## 🔧 关键代码修改
### Chat.vue 主要改动
1. **添加消息列表状态**
```typescript
const messageList = ref<Message[]>([])
const chatContainerRef = ref<HTMLElement | null>(null)
```
2. **实时更新消息内容**
```typescript
onChunk: (chunk: string) => {
try {
const data = JSON.parse(chunk)
if (data.success && data.data?.receive?.content) {
messageList.value[aiMessageIndex].content = data.data.receive.content
scrollToBottom()
}
} catch (e) {
console.error('解析JSON失败:', e)
}
}
```
3. **消息列表渲染**
```vue
<template v-for="(message, index) in messageList" :key="index">
<!-- 用户消息 -->
<n-flex v-if="message.type === 'user'" :size="6" justify="end">
...
</n-flex>
<!-- AI消息 -->
<n-flex v-else :size="6">
<div class="bubble">
<span v-if="message.streaming" class="streaming-cursor">
{{ message.content }}
</span>
<span v-else>{{ message.content }}</span>
</div>
</n-flex>
</template>
```
4. **流式光标动画**
```css
.streaming-cursor::after {
content: '▋';
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
```
## 📝 服务器数据格式
服务器返回的 SSE 数据格式:
```
data:{"success":true,"code":200,"data":{"send":{"id":"89799684200960","type":"user","content":"什么是js"},"receive":{"id":"89799684200961","type":"assistant","content":"JavaScript"}},"msg":"","path":null,"version":null,"timestamp":"1761736321008"}
data:{"success":true,"code":200,"data":{"send":{"id":"89799684200960","type":"user","content":"什么是js"},"receive":{"id":"89799684200961","type":"assistant","content":"非常适合"}},"msg":"","path":null,"version":null,"timestamp":"1761736321008"}
...
```
每个数据块包含:
- `success`: 是否成功
- `data.send`: 用户发送的消息
- `data.receive.content`: AI 回复的内容(逐步累积)
- `timestamp`: 时间戳
## 🎯 测试步骤
1. **启动应用**
```bash
pnpm tauri dev
```
2. **打开 ChatBot 窗口**
- 点击左侧 Robot 插件图标
3. **选择 AI 模型**
- 点击"当前模型"标签
- 选择一个可用的模型
4. **发送消息**
- 在输入框输入问题,例如:"什么是 JavaScript"
- 点击发送或按回车
5. **观察效果**
- ✅ 用户消息立即显示在右侧
- ✅ AI 消息占位符出现在左侧
- ✅ AI 回复内容逐字逐句实时更新
- ✅ 流式光标闪烁效果
- ✅ 自动滚动到最新消息
- ✅ 流结束后光标消失
## 🐛 调试信息
### 前端控制台输出
```
🎯 Chat页面收到AI发送请求: {内容: '什么是js', 当前模型: 'gpt-3.5-turbo', ...}
🚀 开始发送AI消息: {内容: '什么是js', 模型: 'gpt-3.5-turbo', ...}
📨 收到AI流式数据: JavaScript
📨 收到AI流式数据: 非常适合
📨 收到AI流式数据: 开发
...
✅ AI流式响应完成
✅ AI消息发送成功
```
### Rust 后端日志
```
[INFO] 🤖 开始发送 AI 流式消息请求, conversation_id: xxx, request_id: xxx
[INFO] 📡 SSE Request URL: http://192.168.1.37:18760/ai/chat/message/send-stream
[INFO] ✅ SSE 连接已建立,开始监听流式数据...
[INFO] 🔍 收到原始数据块 (长度: 365): "data:{...}"
[INFO] 📨 收到 SSE 数据 (无空格): {"success":true,...}
...
[INFO] ✅ SSE 流正常结束,总内容长度: xxx
[INFO] 🏁 SSE 流处理完成
```
## 🎉 功能演示
### 流式输出效果
```
用户: 什么是 JavaScript
AI: J▋ (0.1秒)
AI: JavaScript▋ (0.2秒)
AI: JavaScript 是▋ (0.3秒)
AI: JavaScript 是一种▋ (0.4秒)
...
AI: JavaScript 是一种非常适合开发单页应用,用户的操作不需要... (完成)
```
## 📋 文件清单
### 修改的文件
- ✅ `src/plugins/robot/views/Chat.vue` - 添加消息列表和实时渲染
- ✅ `src-tauri/src/command/ai_command.rs` - 添加调试日志
### 新增的文件
- ✅ `AI_STREAM_COMPLETE.md` - 本文档
## 🚀 后续优化建议
1. **消息持久化**
- 将消息保存到数据库
- 刷新后恢复历史消息
2. **Markdown 渲染**
- 支持代码高亮
- 支持表格、列表等格式
3. **消息操作**
- 复制消息
- 删除消息
- 重新生成
4. **性能优化**
- 虚拟滚动(消息过多时)
- 消息分页加载
5. **用户体验**
- 停止生成按钮
- 消息加载动画
- 错误重试机制
## ✨ 总结
AI 流式数据功能已经完全实现并可以正常工作!
- ✅ 后端成功接收 SSE 流式数据
- ✅ 前端成功解析 JSON 数据
- ✅ UI 实时渲染 AI 回复
- ✅ 流式光标动画效果
- ✅ 自动滚动到最新消息
- ✅ 完整的错误处理
现在你可以在聊天界面看到 AI 的回复像打字一样逐字逐句地出现,就像真人在输入一样!🎉

View File

@@ -1,214 +0,0 @@
# AI 流式数据接入实现文档
## 概述
本文档描述了 AI 流式数据接入功能的实现,该功能通过 HTTP 请求建立 SSE (Server-Sent Events) 连接,实时接收 AI 返回的流式数据。
## 实现架构
### 1. 后端 (Rust/Tauri)
#### 文件位置
- `src-tauri/src/command/ai_command.rs` - AI 命令处理
- `src-tauri/Cargo.toml` - 添加了 `reqwest-eventsource` 依赖
#### 核心功能
- **命令**: `ai_message_send_stream`
- **功能**:
1. 发送 HTTP POST 请求到 AI 服务器
2. 建立 SSE 连接
3. 监听流式数据
4. 通过 Tauri 事件系统将数据发送到前端
#### 事件类型
```rust
pub struct SseStreamEvent {
pub event_type: String, // "chunk" | "done" | "error"
pub data: Option<String>,
pub error: Option<String>,
pub request_id: String,
}
```
#### 工作流程
1. 接收前端请求参数conversationId, content, useContext
2. 使用 `reqwest` 发送 HTTP POST 请求
3. 使用 `reqwest-eventsource` 建立 SSE 连接
4. 在后台任务中监听 SSE 事件流
5. 每收到一个数据块,发送 `chunk` 事件到前端
6. 流结束时,发送 `done` 事件
7. 出错时,发送 `error` 事件
### 2. 前端 (Vue/TypeScript)
#### 文件位置
- `src/utils/ImRequestUtils.ts` - 流式 API 封装
- `src/plugins/robot/views/Chat.vue` - AI 聊天界面
- `src/enums/index.ts` - 添加了 `AI_MESSAGE_SEND_STREAM` 命令枚举
#### 核心功能
- **函数**: `messageSendStream`
- **特点**:
1. 使用 Promise 包装整个 SSE 流程
2. 监听 Tauri 的 `ai-stream-event` 事件
3. 提供回调函数支持onChunk, onDone, onError
4. 流结束后 resolve Promise
#### 使用示例
```typescript
const fullResponse = await messageSendStream(
{
conversationId: 'chat-123',
content: '你好AI',
useContext: true
},
{
onChunk: (chunk: string) => {
// 实时接收数据块
console.log('收到数据:', chunk)
},
onDone: (fullContent: string) => {
// 流结束
console.log('完整内容:', fullContent)
},
onError: (error: string) => {
// 错误处理
console.error('错误:', error)
}
}
)
```
## 数据流向
```
前端 (Chat.vue)
↓ 调用 messageSendStream()
前端 (ImRequestUtils.ts)
↓ invoke(AI_MESSAGE_SEND_STREAM)
后端 (ai_command.rs)
↓ 发送 HTTP POST 请求
AI 服务器
↓ 建立 SSE 连接
↓ 返回流式数据
后端 (ai_command.rs)
↓ emit('ai-stream-event', chunk)
前端 (ImRequestUtils.ts)
↓ listen('ai-stream-event')
↓ 触发回调 onChunk()
↓ 累积数据
↓ 流结束时 resolve(fullContent)
前端 (Chat.vue)
↓ 显示完整响应
```
## 关键特性
### 1. 同步监听
- HTTP 请求发送后,立即在 Rust 后端建立 SSE 连接
- 使用 `tokio::spawn` 在后台异步处理流式数据
- 不阻塞主线程
### 2. 事件驱动
- 使用 Tauri 的事件系统进行前后端通信
- 通过 `request_id` 区分不同的请求
- 支持多个并发请求
### 3. Promise 包装
- 前端使用 Promise 包装整个流程
- 流结束时自动 resolve
- 出错时自动 reject
- 支持 async/await 语法
### 4. 回调支持
- `onChunk`: 实时接收数据块,可用于 UI 更新
- `onDone`: 流结束时调用
- `onError`: 错误处理
## 测试步骤
### 1. 编译项目
```bash
# 安装依赖
cd src-tauri
cargo build
# 或者直接运行
npm run tauri dev
```
### 2. 测试流程
1. 打开应用
2. 进入 ChatBot 窗口robot 插件)
3. 选择一个 AI 模型
4. 输入消息并发送
5. 观察控制台输出:
- 应该看到 "🚀 开始发送AI消息"
- 应该看到 "📨 收到AI流式数据块" (多次)
- 应该看到 "✅ AI流式响应完成"
- 应该看到 "✅ AI消息发送成功"
### 3. 验证点
- [ ] HTTP 请求成功发送
- [ ] SSE 连接成功建立
- [ ] 能够接收流式数据块
- [ ] 数据块正确传递到前端
- [ ] Promise 在流结束后正确 resolve
- [ ] 错误情况下 Promise 正确 reject
- [ ] 多个并发请求互不干扰
## 注意事项
### 1. 窗口 Label
- AI 聊天窗口的 label 是 `robot`
- 事件会发送到所有窗口,通过 `request_id` 过滤
### 2. 错误处理
- 网络错误会触发 `error` 事件
- SSE 连接关闭会触发 `done` 事件
- 需要区分正常结束和异常结束
### 3. 内存管理
- 每个请求都会创建一个后台任务
- 任务在流结束后自动清理
- 前端监听器在 Promise resolve/reject 后自动取消
### 4. 并发请求
- 使用唯一的 `request_id` 区分不同请求
- 支持多个并发流式请求
- 每个请求独立处理,互不影响
## 后续优化建议
1. **UI 实时更新**: 在 `onChunk` 回调中实时更新聊天界面,显示 AI 正在输出的内容
2. **取消请求**: 添加取消流式请求的功能
3. **重试机制**: 添加自动重试机制
4. **进度指示**: 添加更详细的进度指示
5. **错误分类**: 区分不同类型的错误(网络错误、服务器错误等)
6. **性能优化**: 对于大量数据块,考虑批量处理
## 相关文件清单
### Rust 后端
- `src-tauri/src/command/ai_command.rs` (新建)
- `src-tauri/src/command/mod.rs` (修改)
- `src-tauri/src/lib.rs` (修改)
- `src-tauri/Cargo.toml` (修改)
### 前端
- `src/utils/ImRequestUtils.ts` (修改)
- `src/plugins/robot/views/Chat.vue` (修改)
- `src/enums/index.ts` (修改)
## 技术栈
- **后端**: Rust, Tauri, reqwest, reqwest-eventsource, tokio
- **前端**: Vue 3, TypeScript, Tauri API
- **通信**: Tauri Event System, SSE (Server-Sent Events)

4
package.json vendored
View File

@@ -20,7 +20,7 @@
"========= 启动vue(tauri项目会连带执行不需要单独执行) =========": "",
"dev": "vite",
"========= 打包vue(tauri项目会连带执行不需要单独执行) =========": "",
"build": "vue-tsc --noEmit && vite build",
"build": "NODE_OPTIONS=--max-old-space-size=4096 vue-tsc --noEmit && NODE_OPTIONS=--max-old-space-size=4096 vite build",
"========= 启动HuLa桌面应用程序 =========": "",
"tauri:dev": "tauri dev",
"========= 启动HuLa桌面应用程序(简化命令) =========": "",
@@ -140,7 +140,7 @@
"vue-virtual-scroller": "2.0.0-beta.8"
},
"devDependencies": {
"@biomejs/biome": "^2.2.7",
"@biomejs/biome": "^2.3.4",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@iconify/vue": "^5.0.0",

82
pnpm-lock.yaml generated vendored
View File

@@ -187,8 +187,8 @@ importers:
version: 2.0.0-beta.8(vue@3.5.22(typescript@5.9.3))
devDependencies:
'@biomejs/biome':
specifier: ^2.2.7
version: 2.2.7
specifier: ^2.3.4
version: 2.3.4
'@commitlint/cli':
specifier: ^19.8.1
version: 19.8.1(@types/node@24.6.2)(typescript@5.9.3)
@@ -480,59 +480,59 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.2.7':
resolution: {integrity: sha512-1a8j0UP1vXVUf3UzMZEJ/zS2VgAG6wU6Cuh/I764sUGI+MCnJs/9WaojHYBDCxCMLTgU60/WqnYof85emXmSBA==}
'@biomejs/biome@2.3.4':
resolution: {integrity: sha512-TU08LXjBHdy0mEY9APtEtZdNQQijXUDSXR7IK1i45wgoPD5R0muK7s61QcFir6FpOj/RP1+YkPx5QJlycXUU3w==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.2.7':
resolution: {integrity: sha512-xBUUsebnO2/Qj1v7eZmKUy2ZcFkZ4/jLUkxN02Qup1RPoRaiW9AKXHrqS3L7iX6PzofHY2xuZ+Pb9kAcpoe0qA==}
'@biomejs/cli-darwin-arm64@2.3.4':
resolution: {integrity: sha512-w40GvlNzLaqmuWYiDU6Ys9FNhJiclngKqcGld3iJIiy2bpJ0Q+8n3haiaC81uTPY/NA0d8Q/I3Z9+ajc14102Q==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.2.7':
resolution: {integrity: sha512-vsY4NhmxqgfLJufr9XUnC+yGUPJiXAc1mz6FcjaAmuIuLwfghN4uQO7hnW2AneGyoi2mNe9Jbvf6Qtq4AjzrFg==}
'@biomejs/cli-darwin-x64@2.3.4':
resolution: {integrity: sha512-3s7TLVtjJ7ni1xADXsS7x7GMUrLBZXg8SemXc3T0XLslzvqKj/dq1xGeBQ+pOWQzng9MaozfacIHdK2UlJ3jGA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.2.7':
resolution: {integrity: sha512-FrTwvKO/7t5HbVTvhlMOTOVQLAcR7r4O4iFQhEpZXUtBfosHqrX/JJlX7daPawoe14MDcCu9CDg0zLVpTuDvuQ==}
'@biomejs/cli-linux-arm64-musl@2.3.4':
resolution: {integrity: sha512-IruVGQRwMURivWazchiq7gKAqZSFs5so6gi0hJyxk7x6HR+iwZbO2IxNOqyLURBvL06qkIHs7Wffl6Bw30vCbQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.2.7':
resolution: {integrity: sha512-nUdco104rjV9dULi1VssQ5R/kX2jE/Z2sDjyqS+siV9sTQda0DwmEUixFNRCWvZJRRiZUWhgiDFJ4n7RowO8Mg==}
'@biomejs/cli-linux-arm64@2.3.4':
resolution: {integrity: sha512-y7efHyyM2gYmHy/AdWEip+VgTMe9973aP7XYKPzu/j8JxnPHuSUXftzmPhkVw0lfm4ECGbdBdGD6+rLmTgNZaA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.2.7':
resolution: {integrity: sha512-MnsysF5s/iLC5wnYvuMseOy+m8Pd4bWG1uwlVyy2AUbfjAVUgtbYbboc5wMXljFrDY7e6rLjLTR4S2xqDpGlQg==}
'@biomejs/cli-linux-x64-musl@2.3.4':
resolution: {integrity: sha512-mzKFFv/w66e4/jCobFmD3kymCqG+FuWE7sVa4Yjqd9v7qt2UhXo67MSZKY9Ih18V2IwPzRKQPCw6KwdZs6AXSA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.2.7':
resolution: {integrity: sha512-tPTcGAIEOOZrj2tQ7fdraWlaxNKApBw6l4In8wQQV1IyxnAexqi0hykHzKEX8hKKctf5gxGBfNCzyIvqpj4CFQ==}
'@biomejs/cli-linux-x64@2.3.4':
resolution: {integrity: sha512-gKfjWR/6/dfIxPJCw8REdEowiXCkIpl9jycpNVHux8aX2yhWPLjydOshkDL6Y/82PcQJHn95VCj7J+BRcE5o1Q==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.2.7':
resolution: {integrity: sha512-h5D1jhwA2b7cFXerYiJfXHSzzAMFFoEDL5Mc2BgiaEw0iaSgSso/3Nc6FbOR55aTQISql+IpB4PS7JoV26Gdbw==}
'@biomejs/cli-win32-arm64@2.3.4':
resolution: {integrity: sha512-5TJ6JfVez+yyupJ/iGUici2wzKf0RrSAxJhghQXtAEsc67OIpdwSKAQboemILrwKfHDi5s6mu7mX+VTCTUydkw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.2.7':
resolution: {integrity: sha512-URqAJi0kONyBKG4V9NVafHLDtm6IHmF4qPYi/b6x7MD6jxpWeJiTCO6R5+xDlWckX2T/OGv6Yq3nkz6s0M8Ykw==}
'@biomejs/cli-win32-x64@2.3.4':
resolution: {integrity: sha512-FGCijXecmC4IedQ0esdYNlMpx0Jxgf4zceCaMu6fkjWyjgn50ZQtMiqZZQ0Q/77yqPxvtkgZAvt5uGw0gAAjig==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -1289,8 +1289,8 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/source-map@0.3.11':
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
@@ -5643,39 +5643,39 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.2.7':
'@biomejs/biome@2.3.4':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.2.7
'@biomejs/cli-darwin-x64': 2.2.7
'@biomejs/cli-linux-arm64': 2.2.7
'@biomejs/cli-linux-arm64-musl': 2.2.7
'@biomejs/cli-linux-x64': 2.2.7
'@biomejs/cli-linux-x64-musl': 2.2.7
'@biomejs/cli-win32-arm64': 2.2.7
'@biomejs/cli-win32-x64': 2.2.7
'@biomejs/cli-darwin-arm64': 2.3.4
'@biomejs/cli-darwin-x64': 2.3.4
'@biomejs/cli-linux-arm64': 2.3.4
'@biomejs/cli-linux-arm64-musl': 2.3.4
'@biomejs/cli-linux-x64': 2.3.4
'@biomejs/cli-linux-x64-musl': 2.3.4
'@biomejs/cli-win32-arm64': 2.3.4
'@biomejs/cli-win32-x64': 2.3.4
'@biomejs/cli-darwin-arm64@2.2.7':
'@biomejs/cli-darwin-arm64@2.3.4':
optional: true
'@biomejs/cli-darwin-x64@2.2.7':
'@biomejs/cli-darwin-x64@2.3.4':
optional: true
'@biomejs/cli-linux-arm64-musl@2.2.7':
'@biomejs/cli-linux-arm64-musl@2.3.4':
optional: true
'@biomejs/cli-linux-arm64@2.2.7':
'@biomejs/cli-linux-arm64@2.3.4':
optional: true
'@biomejs/cli-linux-x64-musl@2.2.7':
'@biomejs/cli-linux-x64-musl@2.3.4':
optional: true
'@biomejs/cli-linux-x64@2.2.7':
'@biomejs/cli-linux-x64@2.3.4':
optional: true
'@biomejs/cli-win32-arm64@2.2.7':
'@biomejs/cli-win32-arm64@2.3.4':
optional: true
'@biomejs/cli-win32-x64@2.2.7':
'@biomejs/cli-win32-x64@2.3.4':
optional: true
'@braintree/sanitize-url@7.1.1': {}
@@ -6292,7 +6292,7 @@ snapshots:
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/source-map@0.3.6':
'@jridgewell/source-map@0.3.11':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
@@ -10058,7 +10058,7 @@ snapshots:
terser@5.37.0:
dependencies:
'@jridgewell/source-map': 0.3.6
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21

6
src-tauri/Cargo.lock generated
View File

@@ -2799,7 +2799,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hula"
version = "3.0.2"
version = "3.0.3"
dependencies = [
"anyhow",
"async-walkdir",
@@ -7152,9 +7152,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9871670c6711f50fddd4e20350be6b9dd6e6c2b5d77d8ee8900eb0d58cd837a"
checksum = "8bceb52453e507c505b330afe3398510e87f428ea42b6e76ecb6bd63b15965b5"
dependencies = [
"anyhow",
"bytes",

View File

@@ -1,6 +1,6 @@
[package]
name = "hula"
version = "3.0.2"
version = "3.0.3"
description = "hula"
authors = ["nongyehong"]
license = ""
@@ -36,7 +36,7 @@ cc = "1.0"
[dependencies]
# Tauri 官方依赖
tauri = { version = "2.9.1", features = [
tauri = { version = "2.9.2", features = [
"protocol-asset",
"macos-private-api",
"tray-icon",

View File

@@ -21,7 +21,6 @@ import {
ThemeEnum,
ChangeTypeEnum,
MittEnum,
ModalEnum,
OnlineEnum,
RoomTypeEnum
} from '@/enums'
@@ -34,7 +33,6 @@ import { useSettingStore } from '@/stores/setting.ts'
import { isDesktop, isIOS, isMobile, isWindows } from '@/utils/PlatformConstants'
import LockScreen from '@/views/LockScreen.vue'
import { unreadCountManager } from '@/utils/UnreadCountManager'
import {
type LoginSuccessResType,
type OnStatusChangeType,
@@ -49,6 +47,8 @@ import { useAnnouncementStore } from '@/stores/announcement'
import { useFeedStore } from '@/stores/feed'
import { useFeedNotificationStore } from '@/stores/feedNotification'
import type { MarkItemType, RevokedMsgType, UserItem } from '@/services/types.ts'
import * as ImRequestUtils from '@/utils/ImRequestUtils'
import { REMOTE_LOGIN_INFO_KEY } from '@/common/constants'
const mobileRtcCallFloatCell = isMobile()
? defineAsyncComponent(() => import('@/mobile/components/RtcCallFloatCell.vue'))
@@ -69,7 +69,7 @@ const router = useRouter()
// 只在桌面端初始化窗口管理功能
const { createWebviewWindow } = isDesktop() ? useWindow() : { createWebviewWindow: () => {} }
const settingStore = useSettingStore()
const { themes, lockScreen, page } = storeToRefs(settingStore)
const { themes, lockScreen, page, login } = storeToRefs(settingStore)
// 全局快捷键管理
const { initializeGlobalShortcut, cleanupGlobalShortcut } = useGlobalShortcut()
@@ -276,11 +276,9 @@ useMitt.on(WsResponseMessageType.ROOM_INFO_CHANGE, async (data: { roomId: string
useMitt.on(WsResponseMessageType.TOKEN_EXPIRED, async (wsTokenExpire: WsTokenExpire) => {
if (Number(userUid.value) === Number(wsTokenExpire.uid) && userStore.userInfo!.client === wsTokenExpire.client) {
const { useLogin } = await import('@/hooks/useLogin')
const { resetLoginState, logout } = useLogin()
if (isMobile()) {
// 移动端处理:立即清空登录数据并跳转到登录页,然后显示弹窗
const { useLogin } = await import('@/hooks/useLogin')
const { resetLoginState, logout } = useLogin()
try {
// 1. 先重置登录状态(不请求接口,只清理本地)
await resetLoginState()
@@ -314,13 +312,18 @@ useMitt.on(WsResponseMessageType.TOKEN_EXPIRED, async (wsTokenExpire: WsTokenExp
// 桌面端处理:聚焦主窗口并显示远程登录弹窗
const home = await WebviewWindow.getByLabel('home')
await home?.setFocus()
useMitt.emit(MittEnum.LEFT_MODAL_SHOW, {
type: ModalEnum.REMOTE_LOGIN,
props: {
ip: wsTokenExpire.ip
}
})
const remoteIp = wsTokenExpire.ip || '未知IP'
localStorage.setItem(
REMOTE_LOGIN_INFO_KEY,
JSON.stringify({
ip: remoteIp,
timestamp: Date.now()
})
)
await ImRequestUtils.logout({ autoLogin: login.value.autoLogin })
await resetLoginState()
await logout()
useMitt.emit(MittEnum.LOGIN_REMOTE_MODAL)
}
}
})

View File

@@ -6,3 +6,6 @@ export const MAX_FOOTER_HEIGHT = 390
export const MIN_FOOTER_HEIGHT = 200
/** 顶部选项栏高度 */
export const TOOLBAR_HEIGHT = 40
/** 异地登录信息存储键 */
export const REMOTE_LOGIN_INFO_KEY = 'REMOTE_LOGIN_INFO'

View File

@@ -82,6 +82,8 @@ export enum MittEnum {
OPEN_GROUP_NICKNAME_MODAL = 'openGroupNicknameModal',
/** 左边菜单弹窗 */
LEFT_MODAL_SHOW = 'leftModalShow',
/** 登录窗口异地登录弹窗 */
LOGIN_REMOTE_MODAL = 'loginRemoteModal',
/** 触发home窗口事件 */
HOME_WINDOW_RESIZE = 'homeWindowResize',
/** @ AT */

View File

@@ -187,7 +187,11 @@ export const useWindow = () => {
width: number,
height: number,
parent: string,
payload?: Record<string, any>
payload?: Record<string, any>,
options?: {
minWidth?: number
minHeight?: number
}
) => {
// 移动端不支持窗口管理
if (!isDesktop()) {
@@ -211,8 +215,8 @@ export const useWindow = () => {
height: height,
resizable: false,
center: true,
minWidth: 500,
minHeight: 500,
minWidth: options?.minWidth ?? 500,
minHeight: options?.minHeight ?? 500,
focus: true,
minimizable: false,
parent: parentWindow ? parentWindow : parent,

View File

@@ -1,4 +1,3 @@
import { emit } from '@tauri-apps/api/event'
import {
type FormInst,
NAvatar,
@@ -15,6 +14,8 @@ import {
NTimeline,
NTimelineItem
} from 'naive-ui'
import type { PropType } from 'vue'
import { emit } from '@tauri-apps/api/event'
import { EventEnum } from '@/enums'
import { handRelativeTime } from '@/utils/Day.ts'
import './style.scss'
@@ -22,33 +23,17 @@ import { getVersion } from '@tauri-apps/api/app'
import { confirm } from '@tauri-apps/plugin-dialog'
import { relaunch } from '@tauri-apps/plugin-process'
import { check } from '@tauri-apps/plugin-updater'
import { useLogin } from '@/hooks/useLogin'
import { useSettingStore } from '@/stores/setting.ts'
import { useUserStore } from '@/stores/user.ts'
import { AvatarUtils } from '@/utils/AvatarUtils'
import * as ImRequestUtils from '@/utils/ImRequestUtils'
import { isMac } from '@/utils/PlatformConstants'
import { REMOTE_LOGIN_INFO_KEY } from '@/common/constants'
const { logout: sysLogout, resetLoginState } = useLogin()
const formRef = ref<FormInst | null>()
const formValue = ref({
lockPassword: ''
})
export const modalShow = ref(false)
export const remotelogin = ref({
loading: false,
async logout() {
remotelogin.value.loading = true
const settingStore = useSettingStore()
const { login } = storeToRefs(settingStore)
// token已在后端清空只需要返回登录页
await ImRequestUtils.logout({ autoLogin: login.value.autoLogin })
await resetLoginState()
await sysLogout()
modalShow.value = false
remotelogin.value.loading = false
}
})
export const lock = ref({
loading: false,
rules: {
@@ -432,26 +417,41 @@ export const RemoteLogin = defineComponent({
ip: {
type: String,
default: '未知IP'
},
onConfirm: {
type: Function as PropType<() => void | Promise<void>>,
default: void 0
}
},
setup(props) {
const userStore = useUserStore()
const handleConfirm = async () => {
modalShow.value = false
if (props.onConfirm) {
// 关闭对应子窗口
await props.onConfirm()
}
}
setInterval(() => {
localStorage.removeItem(REMOTE_LOGIN_INFO_KEY)
}, 300)
return () => (
<NModal
v-model:show={modalShow.value}
maskClosable={false}
class="w-350px border-rd-8px select-none cursor-default">
<div class="bg-[--bg-popover] w-360px h-full p-6px box-border flex flex-col">
class="w-350px h-310px border-rd-8px select-none cursor-default">
<div class="bg-[--bg-popover] size-full p-6px box-border flex flex-col">
{isMac() ? (
<div
onClick={remotelogin.value.logout}
onClick={handleConfirm}
class="mac-close relative size-13px shadow-inner bg-#ed6a5eff rounded-50% select-none">
<svg class="hidden size-7px color-#000 select-none absolute top-3px left-3px">
<use href="#close"></use>
</svg>
</div>
) : (
<svg onClick={remotelogin.value.logout} class="w-12px h-12px ml-a cursor-pointer select-none">
<svg onClick={handleConfirm} class="w-12px h-12px ml-a cursor-pointer select-none text-[--text-color]">
<use href="#close"></use>
</svg>
)}
@@ -460,7 +460,7 @@ export const RemoteLogin = defineComponent({
<span class="text-(14px [--text-color])">线</span>
<div class="relative">
<img class="rounded-full size-72px" src={AvatarUtils.getAvatarUrl(userStore.userInfo!.avatar!)} />
<img class="rounded-full size-72px" src={AvatarUtils.getAvatarUrl(userStore.userInfo?.avatar ?? '')} />
<div class="absolute inset-0 bg-[--avatar-hover-bg] backdrop-blur-[2px] rounded-full flex items-center justify-center">
<svg class="size-34px text-white animate-pulse">
<use href="#cloudError"></use>
@@ -473,14 +473,8 @@ export const RemoteLogin = defineComponent({
</div>
</NFlex>
<NButton
disabled={remotelogin.value.loading}
loading={remotelogin.value.loading}
onClick={remotelogin.value.logout}
style={{ color: '#fff' }}
class="w-full"
color="#13987f">
<NButton onClick={handleConfirm} style={{ color: '#fff' }} class="w-full" color="#13987f">
</NButton>
</div>
</div>

View File

@@ -588,6 +588,11 @@ const getCommonRoutes = (): Array<RouteRecordRaw> => [
path: '/modal-privacyAgreement',
name: 'modal-privacyAgreement',
component: () => import('@/views/agreementWindow/Privacy.vue')
},
{
path: '/modal-remoteLogin',
name: 'modal-remoteLogin',
component: () => import('@/views/loginWindow/RemoteLoginModal.vue')
}
]

View File

@@ -192,8 +192,10 @@ import { useGuideStore } from '@/stores/guide'
import { useLoginHistoriesStore } from '@/stores/loginHistory.ts'
import { useSettingStore } from '@/stores/setting.ts'
import { useUserStore } from '@/stores/user.ts'
import { MittEnum } from '@/enums'
import { REMOTE_LOGIN_INFO_KEY } from '@/common/constants'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { isCompatibility, isMac } from '@/utils/PlatformConstants'
import { isCompatibility, isDesktop, isMac } from '@/utils/PlatformConstants'
import { clearListener } from '@/utils/ReadCountQueue'
import { useLogin } from '@/hooks/useLogin'
@@ -311,6 +313,44 @@ watch(
}
)
const openRemoteLoginModal = async (ip?: string) => {
if (!isDesktop()) {
return
}
const payloadIp = ip ?? '未知IP'
await createModalWindow(
'异地登录提醒',
'modal-remoteLogin',
350,
310,
'login',
{
ip: payloadIp
},
{
minWidth: 350,
minHeight: 310
}
)
}
const handleCachedRemoteLoginModal = () => {
const cached = localStorage.getItem(REMOTE_LOGIN_INFO_KEY)
if (!cached) {
return
}
try {
const parsed = JSON.parse(cached) as { ip?: string }
openRemoteLoginModal(parsed?.ip)
} catch (error) {
localStorage.removeItem(REMOTE_LOGIN_INFO_KEY)
}
}
useMitt.on(MittEnum.LOGIN_REMOTE_MODAL, (payload: { ip?: string }) => {
openRemoteLoginModal(payload?.ip)
})
/** 删除账号列表内容 */
const delAccount = (item: UserInfoType) => {
// 获取删除前账户列表的长度
@@ -373,6 +413,7 @@ const enterKey = (e: KeyboardEvent) => {
}
onBeforeMount(async () => {
handleCachedRemoteLoginModal()
// 始终初始化托盘菜单状态为false
isTrayMenuShow.value = false

View File

@@ -0,0 +1,83 @@
<template>
<n-config-provider :theme="lightTheme" class="remote-login-modal size-full select-none">
<RemoteLogin :ip="ip" :on-confirm="handleConfirm" />
</n-config-provider>
</template>
<script setup lang="ts">
import { getCurrentWebviewWindow, WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { lightTheme } from 'naive-ui'
import { RemoteLogin, modalShow } from '@/layout/left/model.tsx'
import { REMOTE_LOGIN_INFO_KEY } from '@/common/constants'
const ip = ref('未知IP')
let currentWindow: WebviewWindow | null = null
let parentWindow: WebviewWindow | null = null
let unlistenClose: (() => void) | undefined
const handleStorageChange = (event: StorageEvent) => {
if (event.key !== REMOTE_LOGIN_INFO_KEY || !event.newValue) {
modalShow.value = false
currentWindow?.close()
return
}
const parsed = JSON.parse(event.newValue) as { ip?: string }
if (parsed?.ip) {
ip.value = parsed.ip
}
}
const assignIpFromCache = () => {
const cached = localStorage.getItem(REMOTE_LOGIN_INFO_KEY)
if (!cached) return
const parsed = JSON.parse(cached) as { ip?: string }
if (parsed?.ip) {
ip.value = parsed.ip
}
}
const handleConfirm = async () => {
modalShow.value = false
localStorage.removeItem(REMOTE_LOGIN_INFO_KEY)
await parentWindow?.setEnabled(true)
await currentWindow?.close()
}
onMounted(async () => {
window.addEventListener('storage', handleStorageChange)
modalShow.value = true
currentWindow = await getCurrentWebviewWindow()
parentWindow = await WebviewWindow.getByLabel('login')
assignIpFromCache()
await currentWindow.show()
if (currentWindow) {
unlistenClose = await currentWindow.onCloseRequested(async () => {
modalShow.value = false
localStorage.removeItem(REMOTE_LOGIN_INFO_KEY)
await parentWindow?.setEnabled(true)
})
}
})
onUnmounted(async () => {
modalShow.value = false
window.removeEventListener('storage', handleStorageChange)
localStorage.removeItem(REMOTE_LOGIN_INFO_KEY)
if (unlistenClose) {
await unlistenClose()
unlistenClose = undefined
}
await parentWindow?.setEnabled(true)
currentWindow = null
parentWindow = null
})
</script>
<style scoped>
.remote-login-modal {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
</style>

View File

@@ -1,43 +0,0 @@
## Default Permission
Default permissions for the plugin
#### This default permission set includes the following:
- `allow-ping`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`hula:allow-ping`
</td>
<td>
Enables the ping command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`hula:deny-ping`
</td>
<td>
Denies the ping command without any pre-configured scope.
</td>
</tr>
</table>