Compare commits
10 Commits
120bad4abe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd72faa9a6 | ||
|
|
a2808e47ac | ||
|
|
3cd7514066 | ||
|
|
6641e07b8a | ||
|
|
634610ca7e | ||
|
|
9f18b32c7e | ||
|
|
de65b813e1 | ||
|
|
9792c88e53 | ||
|
|
92ec6504f9 | ||
|
|
e9c90d759b |
9
.codebuddy/agents/直播带岗平台.md
Normal file
9
.codebuddy/agents/直播带岗平台.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: 直播带岗平台
|
||||
description: 一个 electron 的项目, 一个通过文本实时数字人直播推流软件,主要有三个页面,和一些 交互页面设计:1、一个登录页面 2、一个控制中台页面 (文本润色功能、图片上传功能、快捷回复功能、粘贴 json 或文本 岗位信息进行润色优化功能、岗位播放列表功能、打开直播窗口按钮、关闭直播按钮、摄像头全屏插播、摄像头窗口插播、文本阅读分段,正在播放的文本区域,显示已正在播放和已播放的特殊颜色表示,未开始的文本呈默认颜色、设置按钮、查看岗位详情、AI 模型管理、Ai 模型检测、爱模型下载、设置图标、查看岗位详情等 )3、一个直播窗口移动端窗口
|
||||
tools: list_files, search_file, search_content, read_file, read_lints, replace_in_file, write_to_file, execute_command, create_rule, delete_files, web_fetch, use_skill, web_search
|
||||
agentMode: agentic
|
||||
enabled: true
|
||||
enabledAutoRun: true
|
||||
---
|
||||
请不要过多废话,直接更改代码
|
||||
10
.codebuddy/rules/开发框架.mdc
Normal file
10
.codebuddy/rules/开发框架.mdc
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description:
|
||||
alwaysApply: true
|
||||
enabled: true
|
||||
updatedAt: 2025-12-20T10:05:42.556Z
|
||||
provider:
|
||||
---
|
||||
|
||||
1、本项目是 electron-builder + vue3 开发的 pc 端应用
|
||||
2、禁止编写说明文档,直接给出代码
|
||||
249
FEATURES.md
Normal file
249
FEATURES.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# 直播中台控制系统 - 功能使用说明
|
||||
|
||||
## 🎯 项目架构
|
||||
|
||||
### 系统组成
|
||||
- **主进程 (Main Process)**: 负责系统管理、数据中心、核心业务逻辑、多窗口协调
|
||||
- **中控平台 (Renderer)**: 渲染UI、处理用户输入,仅包含轻量级IPC通信代码
|
||||
- **数字人窗口 (Renderer)**: 渲染数字人界面和iframe,接收主进程数据
|
||||
|
||||
### 技术架构
|
||||
- **桌面端**: Electron 28 + TypeScript
|
||||
- **前端**: Vue 3 + Composition API + Ant Design Vue
|
||||
- **状态管理**: Pinia (仅UI状态)
|
||||
- **通信机制**: IPC (进程间通信)
|
||||
|
||||
## 🚀 核心功能实现
|
||||
|
||||
### 1. 开始直播
|
||||
**功能描述**: 完整的直播启动流程
|
||||
**实现流程**:
|
||||
1. 从 UserStore 获取用户ID (固定值: 'rs876543')
|
||||
2. 创建/显示直播窗口
|
||||
3. 等待页面和iframe加载完成 (30秒超时)
|
||||
4. 获取数字人平台sessionId
|
||||
5. 设置直播状态
|
||||
6. 发送欢迎消息
|
||||
|
||||
**前端调用**:
|
||||
```javascript
|
||||
// userId 自动从 userStore.user?.userId 获取 (固定值: 'rs876543')
|
||||
await window.electron.ipcRenderer.invoke('start-live', { userId })
|
||||
```
|
||||
|
||||
**主进程处理**: `[src/main/ipc/live.ts:80-118]`
|
||||
|
||||
### 2. 结束直播
|
||||
**功能描述**: 清理直播资源和状态
|
||||
**实现流程**:
|
||||
1. 重置直播状态
|
||||
2. 关闭直播窗口
|
||||
3. 清空sessionId
|
||||
4. 重置所有插播状态
|
||||
|
||||
**前端调用**:
|
||||
```javascript
|
||||
await window.electron.ipcRenderer.invoke('stop-live')
|
||||
```
|
||||
|
||||
**主进程处理**: `[src/main/ipc/live.ts:124-145]`
|
||||
|
||||
### 2.1. 强制关闭
|
||||
**功能描述**: 强制关闭直播窗口并清理所有资源(紧急情况使用)
|
||||
**实现流程**:
|
||||
1. 发送清理指令到直播窗口
|
||||
2. 强制清理所有媒体资源(摄像头、麦克风、音频、视频状态)
|
||||
3. 恢复iframe内视频音量到正常状态
|
||||
4. 强制销毁直播窗口(移除所有监听器)
|
||||
5. 重置所有直播状态和插播状态
|
||||
6. 不管当前直播状态,强制执行关闭
|
||||
|
||||
**前端调用**:
|
||||
```javascript
|
||||
await window.electron.ipcRenderer.invoke('force-close-live')
|
||||
```
|
||||
|
||||
**主进程处理**: `[src/main/ipc/live.ts:147-183]`
|
||||
|
||||
### 2.2. 媒体资源清理
|
||||
**功能描述**: 在强制关闭时彻底清理所有媒体资源
|
||||
**清理内容**:
|
||||
- **摄像头资源**: 停止摄像头视频流,清理video元素
|
||||
- **麦克风资源**: 停止麦克风流,清理audio元素
|
||||
- **音频资源**: 暂停所有播放中的音频,重置播放状态
|
||||
- **视频状态**: 恢复iframe内视频音量,清理插播状态
|
||||
- **应用状态**: 重置所有媒体相关的变量和标志位
|
||||
|
||||
**实现细节**:
|
||||
```javascript
|
||||
// 主进程发送清理指令
|
||||
liveState.liveWindow.webContents.send("force-cleanup-media");
|
||||
|
||||
// 直播窗口接收并处理
|
||||
window.electron.ipcRenderer.on('force-cleanup-media', handleForceCleanup);
|
||||
|
||||
// 清理函数会执行:
|
||||
forceCleanupMedia() // 停止所有媒体流、重置状态
|
||||
```
|
||||
|
||||
### 3. 全屏插播
|
||||
**功能描述**: 切换摄像头全屏显示
|
||||
**实现方式**: 切换视频插入状态,发送指令到直播窗口
|
||||
|
||||
**前端调用**:
|
||||
```javascript
|
||||
window.electron.ipcRenderer.send('toggle-camera-insert')
|
||||
```
|
||||
|
||||
**直播窗口响应**: `[src/renderer/src/views/Live/index.vue:92-113]`
|
||||
|
||||
### 4. 窗口插播
|
||||
**功能描述**: 开启摄像头画中画模式
|
||||
**实现方式**: 在直播窗口右下角显示小窗口摄像头
|
||||
|
||||
**前端调用**:
|
||||
```javascript
|
||||
window.electron.ipcRenderer.send('insert-camera-video')
|
||||
```
|
||||
|
||||
**直播窗口响应**: `[src/renderer/src/views/Live/index.vue:84-90]`
|
||||
|
||||
### 5. 音频插入
|
||||
**功能描述**: 插入音频文件播放
|
||||
**实现方式**: 降低主视频音量,播放插入音频
|
||||
|
||||
**前端调用**:
|
||||
```javascript
|
||||
window.electron.ipcRenderer.send('insert-video-audio')
|
||||
```
|
||||
|
||||
**直播窗口响应**: `[src/renderer/src/views/Live/index.vue:116-147]`
|
||||
|
||||
### 6. 镜头切换
|
||||
**功能描述**: 摄像头开启/关闭切换
|
||||
**实现方式**: 切换摄像头激活状态
|
||||
|
||||
**前端调用**:
|
||||
```javascript
|
||||
window.electron.ipcRenderer.send('toggle-camera-insert')
|
||||
```
|
||||
|
||||
### 7. 发送消息到数字人
|
||||
**功能描述**: 向数字人发送文本内容
|
||||
**实现流程**:
|
||||
1. 验证直播状态和sessionId
|
||||
2. 构建消息参数
|
||||
3. 调用数字人平台API
|
||||
4. 处理响应结果
|
||||
|
||||
**前端调用**:
|
||||
```javascript
|
||||
await window.electron.ipcRenderer.invoke('push-explain-position', '消息内容')
|
||||
```
|
||||
|
||||
**主进程处理**: `[src/main/ipc/live.ts:145-164]`
|
||||
|
||||
## 🔄 状态管理
|
||||
|
||||
### 主进程状态
|
||||
```typescript
|
||||
interface LiveState {
|
||||
sessionId: string | null; // 数字人会话ID
|
||||
userId: string | null; // 用户ID
|
||||
isVideoInserted: boolean; // 视频插入状态
|
||||
isLiveOn: boolean; // 直播状态
|
||||
jobList: any[]; // 岗位列表
|
||||
currentJob: any; // 当前岗位
|
||||
cameraActive: boolean; // 摄像头激活状态
|
||||
audioActive: boolean; // 音频激活状态
|
||||
liveWindow: BrowserWindow | null; // 直播窗口
|
||||
}
|
||||
```
|
||||
|
||||
### 状态同步
|
||||
- **获取直播状态**: `get-live-status` IPC接口
|
||||
- **UI状态自动同步**: 每2秒从主进程同步状态
|
||||
- **状态变化实时响应**: 按钮状态根据直播状态动态更新
|
||||
|
||||
## 🎮 用户界面
|
||||
|
||||
### 中控台布局
|
||||
- **顶部导航**: 应用标题和用户信息
|
||||
- **左侧边栏**: 岗位列表和详情信息
|
||||
- **主内容区**: 直播控制面板、AI模型管理
|
||||
- **底部输入区**: 消息输入和快捷指令
|
||||
|
||||
### 按钮状态管理
|
||||
- **开始直播**: loading状态,直播中时禁用
|
||||
- **结束直播**: 仅直播时可用,loading状态
|
||||
- **插播功能**: 仅直播时可用
|
||||
- **发送消息**: 仅直播时可用
|
||||
|
||||
### 快捷指令
|
||||
- 欢迎新观众
|
||||
- 感谢陪伴
|
||||
- 互动提问
|
||||
- 行业分享
|
||||
|
||||
## 🔗 外部服务集成
|
||||
|
||||
### 数字人平台
|
||||
- **API地址**: `http://ywpt.hx.cn/dmhx/`
|
||||
- **获取会话**: `get_sessionid` 接口
|
||||
- **发送消息**: `human` 接口
|
||||
- **参数格式**: `{ sessionid, text, type: "echo", interrupt: true }`
|
||||
|
||||
### 直播展示
|
||||
- **直播地址**: `https://dmdemo.hx.cn/dashboard.html`
|
||||
- **参数传递**: `?userId=${userId}`
|
||||
- **嵌入方式**: iframe 全屏显示
|
||||
|
||||
## 🛠️ 开发和调试
|
||||
|
||||
### 启动项目
|
||||
```bash
|
||||
npm run dev # 开发模式
|
||||
npm run build # 生产构建
|
||||
npm run typecheck # 类型检查
|
||||
```
|
||||
|
||||
### 调试接口
|
||||
```javascript
|
||||
// 获取主进程实时状态
|
||||
const state = getLiveState();
|
||||
|
||||
// 获取直播状态 (IPC)
|
||||
const status = await window.electron.ipcRenderer.invoke('get-live-status');
|
||||
```
|
||||
|
||||
### 日志输出
|
||||
- 主进程日志在Electron控制台查看
|
||||
- 渲染进程日志在浏览器开发者工具查看
|
||||
- API调用错误会在控制台详细显示
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
### 权限要求
|
||||
- 摄像头权限: 需要用户授权
|
||||
- 麦克风权限: 音频插入功能需要
|
||||
- 网络权限: 访问外部API服务
|
||||
|
||||
### 错误处理
|
||||
- 网络请求失败: 自动重试和错误提示
|
||||
- 权限被拒绝: 友好的权限请求提示
|
||||
- 状态不一致: 自动同步机制修复
|
||||
|
||||
### 性能优化
|
||||
- 状态同步频率适中 (2秒)
|
||||
- IPC通信异步处理
|
||||
- 资源清理及时 (窗口关闭时)
|
||||
|
||||
## 🚀 使用流程
|
||||
|
||||
1. **启动应用**: 运行 `npm run dev` 启动开发环境
|
||||
2. **开始直播**: 点击"开始直播"按钮,系统自动获取sessionId并创建直播窗口
|
||||
3. **发送消息**: 在输入框输入内容或使用快捷指令,数字人会实时播报
|
||||
4. **插播控制**: 使用插播功能切换摄像头或插入音频
|
||||
5. **结束直播**: 点击"结束直播"清理资源
|
||||
|
||||
系统已按照现代化架构设计,主进程负责核心逻辑,渲染进程专注于UI展示,通过IPC实现高效通信。
|
||||
BIN
build/icon.icns
BIN
build/icon.icns
Binary file not shown.
BIN
build/icon.ico
BIN
build/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 59 KiB |
BIN
build/icon.png
BIN
build/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 703 KiB |
BIN
build/icon3.ico
Normal file
BIN
build/icon3.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
12399
package-lock.json
generated
12399
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "electron-app",
|
||||
"productName": "直播控制台",
|
||||
"version": "1.0.0",
|
||||
"description": "An Electron application with Vue and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -17,12 +18,14 @@
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:win:x64": "npm run build && electron-builder --win --x64",
|
||||
"build:mac": "npm run build && electron-builder --mac",
|
||||
"build:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"sass": "^1.93.2",
|
||||
@@ -40,16 +43,16 @@
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"electron": "^28.2.0",
|
||||
"electron": "^28.3.3",
|
||||
"electron-builder": "^24.9.1",
|
||||
"electron-reloader": "^1.2.3",
|
||||
"electron-vite": "^2.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-vue": "^9.20.1",
|
||||
"prettier": "^3.2.4",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.0.12",
|
||||
"vue": "^3.4.15",
|
||||
"vue-tsc": "^1.8.27"
|
||||
"vue-tsc": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
96
src/main/Api/api.ts
Normal file
96
src/main/Api/api.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export async function getSessionId(requestBody: object) {
|
||||
try {
|
||||
const response = await fetch("http://ywpt.hx.cn/dmhx/get_sessionid", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
// 首先检查响应内容类型
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
// 如果不是JSON,读取原始文本进行调试
|
||||
const rawText = await response.text();
|
||||
console.warn("服务器返回非JSON响应:", rawText);
|
||||
|
||||
// 尝试解析可能的JSON响应(即使Content-Type不正确)
|
||||
try {
|
||||
const data = JSON.parse(rawText);
|
||||
if (response.ok && data.sessionid) {
|
||||
return { success: true, sessionId: data.sessionid };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || "服务器返回非JSON格式",
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `服务器响应格式错误: ${rawText.substring(0, 100)}...`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是JSON,正常解析
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.sessionid) {
|
||||
return { success: true, sessionId: data.sessionid };
|
||||
} else {
|
||||
return { success: false, error: data.message || "未知错误" };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error in getSessionId:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessage(requestBody: object) {
|
||||
try {
|
||||
const response = await fetch(`http://ywpt.hx.cn/dmhx/human`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.message || "未知错误" };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error in sendMessage:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getJob(requestBody: object) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://qd.zhaopinzao8dian.com/api/app/job/metaInfo`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
);
|
||||
const resData = await response.json();
|
||||
|
||||
if (resData.code === 200) {
|
||||
return { success: true, data: resData.data };
|
||||
} else {
|
||||
throw new Error(resData.msg);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error in sendMessage:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,18 @@
|
||||
import { app, shell, BrowserWindow } from "electron";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import icon from "../../resources/icon.png?asset";
|
||||
import { setupLiveHandlers } from "./ipc/live";
|
||||
import { setupPromptHandlers } from "./ipc/prompt";
|
||||
import { setupWorkflowHandlers } from "./ipc/workflow";
|
||||
import { preload, indexHtml, ELECTRON_RENDERER_URL } from "./config";
|
||||
|
||||
// 在开发环境下启用热重载
|
||||
if (is.dev) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require("electron-reloader")(module, {
|
||||
debug: true,
|
||||
watchRenderer: true, // 同时监视渲染进程文件
|
||||
ignore: [
|
||||
/node_modules/,
|
||||
/dist/,
|
||||
/release/,
|
||||
/\.[\/\\]\./, // 忽略点文件
|
||||
/package(-lock)?\.json/,
|
||||
],
|
||||
});
|
||||
console.log("Electron hot reload enabled");
|
||||
} catch (err) {
|
||||
console.error("Error enabling hot reload:", err);
|
||||
}
|
||||
}
|
||||
// 必须在 app ready 之前调用
|
||||
app.commandLine.appendSwitch("disable-site-isolation-trials");
|
||||
|
||||
app.commandLine.appendSwitch(
|
||||
"disable-features",
|
||||
"IsolateOrigins,site-per-process",
|
||||
);
|
||||
|
||||
// 简单的开发环境检测
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
/**
|
||||
* 创建主窗口
|
||||
@@ -35,8 +22,8 @@ function createWindow(): void {
|
||||
width: 1080,
|
||||
height: 670,
|
||||
show: false,
|
||||
titleBarStyle: "hidden",
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === "linux" ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload,
|
||||
sandbox: false,
|
||||
@@ -47,14 +34,24 @@ function createWindow(): void {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// mainWindow.webContents.openDevTools();
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
if (is.dev && ELECTRON_RENDERER_URL) {
|
||||
// 窗口关闭时强制清理资源并退出应用
|
||||
mainWindow.on("closed", () => {
|
||||
// 清理所有资源
|
||||
mainWindow.removeAllListeners();
|
||||
// 强制退出应用
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
if (isDev && ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(ELECTRON_RENDERER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(indexHtml);
|
||||
@@ -65,17 +62,11 @@ function createWindow(): void {
|
||||
* 准备好后
|
||||
*/
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId("com.electron");
|
||||
app.setAppUserModelId("com.electron");
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// 直播相关处理
|
||||
// 直播相关处理
|
||||
setupLiveHandlers();
|
||||
|
||||
setupPromptHandlers();
|
||||
|
||||
setupWorkflowHandlers();
|
||||
|
||||
createWindow();
|
||||
@@ -89,7 +80,27 @@ app.whenReady().then(() => {
|
||||
* 退出应用
|
||||
*/
|
||||
app.on("window-all-closed", () => {
|
||||
// 强制清理所有资源
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// 在开发环境中,确保进程正确退出
|
||||
app.on("before-quit", () => {
|
||||
// 清理所有资源
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows.forEach((window) => {
|
||||
window.removeAllListeners();
|
||||
window.close();
|
||||
});
|
||||
});
|
||||
|
||||
// 处理进程退出信号
|
||||
process.on("SIGINT", () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
@@ -1,45 +1,127 @@
|
||||
import { ipcMain, BrowserWindow } from "electron";
|
||||
import { preload, indexHtml, ELECTRON_RENDERER_URL } from "../config";
|
||||
import { getSessionId, sendMessage } from "../Api/api";
|
||||
import { showPrompt } from "../utils/tools";
|
||||
let liveWindow: BrowserWindow | null = null;
|
||||
|
||||
// 直播状态管理
|
||||
interface LiveState {
|
||||
sessionId: string | null;
|
||||
userId: string | null;
|
||||
isVideoInserted: boolean;
|
||||
isLiveOn: boolean;
|
||||
jobList: any[];
|
||||
currentJob: any;
|
||||
cameraActive: boolean;
|
||||
audioActive: boolean;
|
||||
liveWindow: BrowserWindow | null;
|
||||
}
|
||||
|
||||
// 全局直播状态
|
||||
const liveState: LiveState = {
|
||||
sessionId: null,
|
||||
userId: null,
|
||||
isVideoInserted: false,
|
||||
isLiveOn: false,
|
||||
jobList: [],
|
||||
currentJob: null,
|
||||
cameraActive: false,
|
||||
audioActive: false,
|
||||
liveWindow: null,
|
||||
};
|
||||
|
||||
// 直播相关的主进程处理
|
||||
export function setupLiveHandlers() {
|
||||
let LiveSessionId = null;
|
||||
let isVideoInserted = false;
|
||||
// 获取直播状态
|
||||
ipcMain.handle("get-live-status", async () => {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isLiveOn: liveState.isLiveOn,
|
||||
hasLiveWindow: !!liveState.liveWindow,
|
||||
isVideoInserted: liveState.isVideoInserted,
|
||||
cameraActive: liveState.cameraActive,
|
||||
sessionId: liveState.sessionId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 切换摄像头插入状态
|
||||
ipcMain.on("toggle-camera-insert", async () => {
|
||||
if (liveWindow) {
|
||||
isVideoInserted = !isVideoInserted;
|
||||
liveWindow.webContents.send(
|
||||
if (liveState.liveWindow) {
|
||||
liveState.isVideoInserted = !liveState.isVideoInserted;
|
||||
liveState.liveWindow.webContents.send(
|
||||
"toggle-camera-insert",
|
||||
isVideoInserted,
|
||||
liveState.isVideoInserted,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 插入摄像头视频
|
||||
ipcMain.on("insert-camera-video", async (_) => {
|
||||
if (liveWindow) {
|
||||
liveWindow.webContents.send("insert-camera-video");
|
||||
ipcMain.on("insert-camera-video", async () => {
|
||||
if (liveState.liveWindow) {
|
||||
liveState.liveWindow.webContents.send("insert-camera-video");
|
||||
liveState.cameraActive = true;
|
||||
} else {
|
||||
showPrompt("直播窗口未打开", "error");
|
||||
}
|
||||
});
|
||||
|
||||
// 插入音频
|
||||
ipcMain.on("insert-video-audio", async (_) => {
|
||||
if (liveWindow) {
|
||||
liveWindow.webContents.send("insert-video-audio");
|
||||
ipcMain.on("insert-video-audio", async () => {
|
||||
if (liveState.liveWindow) {
|
||||
liveState.liveWindow.webContents.send("insert-video-audio");
|
||||
liveState.audioActive = true;
|
||||
} else {
|
||||
showPrompt("直播窗口未打开", "error");
|
||||
}
|
||||
});
|
||||
|
||||
// 开始直播
|
||||
ipcMain.handle("start-live", async (_) => {
|
||||
// 开始直播 - 完整的直播启动流程
|
||||
ipcMain.handle("start-live", async (_, { userId } = {}) => {
|
||||
try {
|
||||
console.log("Starting live stream...");
|
||||
// TODO: 实现直播推流逻辑
|
||||
return { success: true };
|
||||
|
||||
// 1. 设置用户ID
|
||||
if (userId) {
|
||||
liveState.userId = userId;
|
||||
}
|
||||
|
||||
// 2. 创建或显示直播窗口
|
||||
if (!liveState.liveWindow) {
|
||||
await createLiveWindow();
|
||||
}
|
||||
|
||||
// 3. 等待网页加载完成
|
||||
await waitForIframeVideoPlaying(liveState.liveWindow!.webContents);
|
||||
|
||||
// 4. 获取 sessionId
|
||||
console.log("获取 sessionId");
|
||||
const sessionResult = await getSessionId({
|
||||
userId: liveState.userId,
|
||||
});
|
||||
console.log("sessionid", sessionResult);
|
||||
if (!sessionResult.success) {
|
||||
return { success: false, error: "获取会话ID失败" };
|
||||
}
|
||||
liveState.sessionId = sessionResult.sessionId;
|
||||
|
||||
// socket
|
||||
|
||||
// 5. 设置直播状态
|
||||
liveState.isLiveOn = true;
|
||||
|
||||
// 6. 发送欢迎消息
|
||||
await sendMessage({
|
||||
sessionid: liveState.sessionId,
|
||||
text: "大家好,欢迎来到我的直播间!",
|
||||
type: "echo",
|
||||
interrupt: true,
|
||||
});
|
||||
|
||||
// showPrompt("直播已开始", "info");
|
||||
return { success: true, sessionId: liveState.sessionId };
|
||||
} catch (error: any) {
|
||||
console.error("Start live error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
@@ -47,151 +129,293 @@ export function setupLiveHandlers() {
|
||||
// 结束直播
|
||||
ipcMain.handle("stop-live", async () => {
|
||||
try {
|
||||
// TODO: 实现结束直播逻辑
|
||||
liveState.isLiveOn = false;
|
||||
|
||||
// 关闭直播窗口
|
||||
if (liveState.liveWindow) {
|
||||
liveState.liveWindow.close();
|
||||
liveState.liveWindow = null;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
liveState.sessionId = null;
|
||||
liveState.isVideoInserted = false;
|
||||
liveState.cameraActive = false;
|
||||
liveState.audioActive = false;
|
||||
|
||||
// showPrompt("直播已结束", "info");
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("explain-position", async (_, content: string) => {
|
||||
// 强制关闭直播窗口
|
||||
ipcMain.handle("force-close-live", async () => {
|
||||
try {
|
||||
let params = {
|
||||
sessionid: LiveSessionId,
|
||||
console.log("Force closing live window...");
|
||||
|
||||
// 通知直播窗口清理所有媒体资源
|
||||
if (liveState.liveWindow) {
|
||||
try {
|
||||
// 发送清理指令到直播窗口
|
||||
liveState.liveWindow.webContents.send(
|
||||
"force-cleanup-media",
|
||||
);
|
||||
|
||||
// 等待一下确保清理指令被处理
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 强制关闭窗口
|
||||
liveState.liveWindow.removeAllListeners();
|
||||
liveState.liveWindow.destroy();
|
||||
} catch (error) {
|
||||
console.warn("Error destroying window:", error);
|
||||
}
|
||||
liveState.liveWindow = null;
|
||||
}
|
||||
|
||||
// 强制重置所有状态
|
||||
liveState.isLiveOn = false;
|
||||
liveState.sessionId = null;
|
||||
liveState.isVideoInserted = false;
|
||||
liveState.cameraActive = false;
|
||||
liveState.audioActive = false;
|
||||
|
||||
showPrompt("直播窗口已强制关闭", "info");
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error("Force close error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 提交消息给数字人平台
|
||||
ipcMain.handle("push-explain-position", async (_, content: string) => {
|
||||
try {
|
||||
if (!liveState.sessionId) {
|
||||
return { success: false, error: "直播未开始,无法发送消息" };
|
||||
}
|
||||
|
||||
const params = {
|
||||
sessionid: liveState.sessionId,
|
||||
text: content,
|
||||
type: "echo",
|
||||
interrupt: true,
|
||||
};
|
||||
await sendMessage(params);
|
||||
// TODO: 实现结束直播逻辑
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error("Send message error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 打开直播窗口
|
||||
ipcMain.handle("open-live-window", async (_, args) => {
|
||||
// 创建直播窗口
|
||||
async function createLiveWindow() {
|
||||
const width = 375;
|
||||
const height = 665;
|
||||
let liveUrl = `${ELECTRON_RENDERER_URL}/#/live`;
|
||||
if (liveState.userId) {
|
||||
liveUrl += `?userId=${liveState.userId}`;
|
||||
}
|
||||
|
||||
liveState.liveWindow = new BrowserWindow({
|
||||
title: "直播窗口",
|
||||
width,
|
||||
height,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
closable: true,
|
||||
frame: false,
|
||||
trafficLightPosition: { x: -100, y: -100 },
|
||||
alwaysOnTop: true,
|
||||
titleBarStyle: "hidden",
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
webSecurity: false, // 允许访问本地文件
|
||||
allowRunningInsecureContent: true, // 允许运行本地内容
|
||||
},
|
||||
});
|
||||
liveState.liveWindow.webContents.openDevTools();
|
||||
liveState.liveWindow.on("closed", () => {
|
||||
liveState.liveWindow = null;
|
||||
liveState.isLiveOn = false;
|
||||
});
|
||||
// if (ELECTRON_RENDERER_URL) {
|
||||
// await liveState.liveWindow.loadURL(liveUrl);
|
||||
// } else {
|
||||
// await liveState.liveWindow.loadFile(indexHtml, { hash: "/live" });
|
||||
// }
|
||||
if (ELECTRON_RENDERER_URL) {
|
||||
let liveUrl = `${ELECTRON_RENDERER_URL}/#/live`;
|
||||
if (liveState.userId) {
|
||||
liveUrl += `?userId=${liveState.userId}`;
|
||||
}
|
||||
await liveState.liveWindow.loadURL(liveUrl);
|
||||
} else {
|
||||
await liveState.liveWindow.loadFile(indexHtml, {
|
||||
hash: "live",
|
||||
search: `userId=${liveState.userId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 等待页面加载完成
|
||||
async function waitForIframeVideoPlaying(
|
||||
webContents: Electron.WebContents,
|
||||
timeoutMs = 120000, // 2 分钟超时
|
||||
) {
|
||||
console.log("开始检测 iframe 内视频的实际播放状态...");
|
||||
|
||||
try {
|
||||
if (liveWindow) {
|
||||
liveWindow.focus();
|
||||
showPrompt("直播窗口已打开", "info");
|
||||
return { success: true };
|
||||
}
|
||||
const { width, height, path, userId } = args;
|
||||
let liveUrl = `${ELECTRON_RENDERER_URL}/#/${path}`;
|
||||
if (userId) {
|
||||
liveUrl += `?userId=${userId}`;
|
||||
}
|
||||
// TODO: 实现打开直播窗口逻辑
|
||||
liveWindow = new BrowserWindow({
|
||||
title: "直播窗口",
|
||||
width,
|
||||
height,
|
||||
minimizable: false, // 是否可以最小化
|
||||
maximizable: false, // 是否可以最小化
|
||||
closable: true, // 窗口是否可关闭
|
||||
alwaysOnTop: true, // 窗口是否永远在别的窗口的上面
|
||||
webPreferences: {
|
||||
preload,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
// liveWindow.webContents.openDevTools();
|
||||
liveWindow.on("closed", () => {
|
||||
liveWindow = null;
|
||||
});
|
||||
if (ELECTRON_RENDERER_URL) {
|
||||
liveWindow.loadURL(liveUrl);
|
||||
setTimeout(async () => {
|
||||
const res = await getSessionId({ userId: userId });
|
||||
if (res.success) {
|
||||
LiveSessionId = res.sessionId;
|
||||
console.log(
|
||||
"Session ID obtained successfully",
|
||||
LiveSessionId,
|
||||
);
|
||||
await webContents.executeJavaScript(`
|
||||
new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
|
||||
const check = setInterval(() => {
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (!iframe) return;
|
||||
const iframeDoc = iframe.contentDocument;
|
||||
if (!iframeDoc) {
|
||||
return;
|
||||
}
|
||||
}, 3000);
|
||||
const video = iframeDoc.querySelector('video');
|
||||
if (video) {
|
||||
const isReady = video.readyState >= 3;
|
||||
const hasProgress = video.currentTime > 0;
|
||||
const isPlaying = !video.paused;
|
||||
const isVisible = video.videoWidth > 0 && video.videoHeight > 0;
|
||||
if (isReady && hasProgress && isPlaying && isVisible) {
|
||||
console.log("Success: Iframe 内视频已开始播放!当前进度:", video.currentTime);
|
||||
clearInterval(check);
|
||||
resolve(true);
|
||||
} else {
|
||||
// 可以打印状态方便调试 (可选)
|
||||
// console.log(\`等待播放: Ready=\${video.readyState}, Time=\${video.currentTime}, Paused=\${video.paused}\`);
|
||||
}
|
||||
}
|
||||
if (Date.now() - start > ${timeoutMs}) {
|
||||
clearInterval(check);
|
||||
console.warn("Iframe 视频播放检测超时");
|
||||
resolve(false);
|
||||
}
|
||||
}, 500); // 每 0.5 秒检查一次
|
||||
})
|
||||
`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Iframe 检测脚本执行出错 (请检查 webSecurity 是否关闭):",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件管理处理器
|
||||
ipcMain.handle("show-files-in-live", async (_, { files }) => {
|
||||
console.log("收到文件", files);
|
||||
try {
|
||||
// 使用当前直播窗口的 webContents
|
||||
if (liveState.liveWindow && !liveState.liveWindow.isDestroyed()) {
|
||||
console.log("发送文件路径到直播窗口:", files.length, "个文件");
|
||||
|
||||
// 直接传递文件路径信息,不读取文件内容
|
||||
const processedFiles = files.map((file: any) => ({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
// 为支持的文件类型生成 file:// URL
|
||||
url: file.path
|
||||
? `file://${file.path.replace(/\\/g, "/")}`
|
||||
: null,
|
||||
}));
|
||||
|
||||
console.log(
|
||||
"处理后的文件数据:",
|
||||
processedFiles.map((f: any) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
size: f.size,
|
||||
hasPath: !!f.path,
|
||||
hasUrl: !!f.url,
|
||||
})),
|
||||
);
|
||||
|
||||
// 发送文件数据到直播窗口
|
||||
liveState.liveWindow.webContents.send("show-files-display", {
|
||||
files: processedFiles,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `文件已发送到直播窗口 (${files.length}个文件)`,
|
||||
};
|
||||
} else {
|
||||
liveWindow.loadFile(indexHtml, { hash: `/${path}` });
|
||||
throw new Error("直播窗口未找到或已关闭");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
console.error("展示文件失败:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "展示文件失败",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("remove-file-from-live", async (_, { index, fileName }) => {
|
||||
try {
|
||||
if (liveState.liveWindow && !liveState.liveWindow.isDestroyed()) {
|
||||
liveState.liveWindow.webContents.send("remove-file-display", {
|
||||
index,
|
||||
fileName,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "已通知直播窗口移除文件",
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("移除文件展示失败:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "移除文件展示失败",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("clear-all-files-from-live", async () => {
|
||||
try {
|
||||
if (liveState.liveWindow && !liveState.liveWindow.isDestroyed()) {
|
||||
liveState.liveWindow.webContents.send(
|
||||
"clear-all-files-display",
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "已通知直播窗口清空所有文件",
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("清空文件展示失败:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "清空文件展示失败",
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getSessionId(requestBody: object) {
|
||||
try {
|
||||
const response = await fetch("http://ywpt.hx.cn/dmhx/get_sessionid", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
// 首先检查响应内容类型
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
// 如果不是JSON,读取原始文本进行调试
|
||||
const rawText = await response.text();
|
||||
console.warn("服务器返回非JSON响应:", rawText);
|
||||
|
||||
// 尝试解析可能的JSON响应(即使Content-Type不正确)
|
||||
try {
|
||||
const data = JSON.parse(rawText);
|
||||
if (response.ok && data.sessionid) {
|
||||
return { success: true, sessionId: data.sessionid };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || "服务器返回非JSON格式",
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `服务器响应格式错误: ${rawText.substring(0, 100)}...`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是JSON,正常解析
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.sessionid) {
|
||||
return { success: true, sessionId: data.sessionid };
|
||||
} else {
|
||||
return { success: false, error: data.message || "未知错误" };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error in getSessionId:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(requestBody: object) {
|
||||
try {
|
||||
const response = await fetch(`http://ywpt.hx.cn/dmhx/human`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.message || "未知错误" };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error in sendMessage:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
// 导出状态(用于调试)
|
||||
export const getLiveState = () => ({ ...liveState });
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ipcMain, dialog } from "electron";
|
||||
|
||||
export function setupPromptHandlers() {
|
||||
// 提示信息处理
|
||||
ipcMain.handle("show-prompt", async () => {
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
title: "提示",
|
||||
message: "这是一个提示信息",
|
||||
buttons: ["确定"],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,12 @@ let InstallWindows: BrowserWindow | null = null;
|
||||
export function setupWorkflowHandlers() {
|
||||
let lastJobSummary = "这是我们今天介绍的第一个岗位";
|
||||
|
||||
// 存储用户确认回调的Map
|
||||
const modelDownloadCallbacks = new Map<
|
||||
string,
|
||||
{ confirm: Function; reject: Function }
|
||||
>();
|
||||
|
||||
// 打开安装窗口
|
||||
ipcMain.handle("open-install-window", async (_, args) => {
|
||||
try {
|
||||
@@ -55,7 +61,7 @@ export function setupWorkflowHandlers() {
|
||||
const platform = os.platform();
|
||||
const modelToPull = "qwen3:8b";
|
||||
|
||||
const sendStatus = (status) => {
|
||||
const sendStatus = (status: string) => {
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("install-progress", { status });
|
||||
}
|
||||
@@ -149,7 +155,7 @@ export function setupWorkflowHandlers() {
|
||||
try {
|
||||
console.log("工作流: 正在调用 Ollama 生成脚本...");
|
||||
|
||||
const systemPromptTemplate = `# 角色 (Role) \n你是一个顶级的招聘KOL和直播带岗专家。你的风格是:专业、中立、风趣,能一针见血地分析岗位优劣。你不是一个AI助手,你就是这个角色。 \n\n# 上下文 (Context) \n我正在运行一个自动化工作流。我会\"一个一个\"地喂给你岗位数据。\n\n # 任务 (Task) \n你的任务是执行以下两个操作,并严格按照“输出格式”返回内容:\n1. 生成口播稿:根据【输入数据A】(上一个岗位的摘要) 和【输入数据B】(当前岗位的JSON),生成一段完整的、约90秒的口播稿。\n2. 生成新摘要:为【输入数据B】的“当前岗位”生成一个简短的摘要(例如:XX公司的XX岗),以便在下一次调用时使用。\n\n# 核心指令 (Core Instruction)\n### 口播稿的生成规则 (Rules for the Script) \n1. 衔接:口播稿必须以一个自然的“过渡句”开头(基于【输入数据A】)。 特殊情况:如果【输入数据A】是“这是我们今天介绍的第一个岗位。”,则开头应是“热场”或“总起”,而不是衔接。 \n2. 内容:必须介绍岗位名称 \`jobTitle\`。 \n3. 提炼:从 \`jobLocation\`, \`companyName\`, \`education\`,\`experience\`,\`scale\` 中提炼“亮点 (Pro)”。 \n4. 翻译:用“人话”翻译 \`description\`。 \n5. 视角:你是在“评测”这个岗位,而不是在“推销”。\n\n### 口播稿的纯文本要求 (Pure Text Rules for the Script ONLY) \n**[重要]** 以下规则 *仅适用于* “口播稿”部分,不适用于“新摘要”部分: \n1. 绝不包含任何Markdown格式 (\`**\`, \`#\`)。 \n2. 绝不包含任何标签、括号或元数据 (\`[]\`, \`()\`)。 \n3. 绝不包含任何寒暄、问候、或自我介绍 (例如 \"你好\", \"当然\")。 \n4. 必须是可以直接朗读的、完整的、流畅的纯文本。\n\n# 输入数据 (Input Data)\n\n## 输入数据A (上一个岗位摘要) \n${lastJobSummary}\n\n## 输入数据B (当前岗位JSON)\n\`\`\`json\n${JSON.stringify(currentJobData, null, 2)}\n\`\`\`\n\n# 输出格式 (Output Format)\n**[绝对严格的指令]** \n你必须严格按照下面这个“两部分”格式输出,使用 \`---NEXT_SUMMARY---\` 作为唯一的分隔符。 绝不在分隔符之外添加任何多余的文字、解释或Markdown。 \n\n[这里是AI生成的、符合上述所有“纯文本要求”的完整口播稿] \n---NEXT_SUMMARY--- \n[这里是AI为“当前岗位”生成的简短新摘要]`;
|
||||
const systemPromptTemplate = `# 角色 (Role) \n你是一个顶级的招聘KOL和直播带岗专家。你的风格是:专业、中立、风趣,能一针见血地分析岗位优劣。你不是一个AI助手,你就是这个角色。 \n\n# 上下文 (Context) \n我正在运行一个自动化工作流。我会\"一个一个\"地喂给你岗位数据。\n\n # 任务 (Task) \n你的任务是执行以下两个操作,并严格按照“输出格式”返回内容:\n1. 生成口播稿:根据【输入数据A】(上一个岗位的摘要) 和【输入数据B】(当前岗位的JSON),生成一段完整的、约90秒的口播稿。\n2. 生成新摘要:为【输入数据B】的“当前岗位”生成一个简短的摘要(例如:XX公司的XX岗),以便在下一次调用时使用。\n\n# 核心指令 (Core Instruction)\n### 口播稿的生成规则 (Rules for the Script) \n1. 衔接:口播稿必须以一个自然的“过渡句”开头(基于【输入数据A】)。 特殊情况:如果【输入数据A】是“这是我们今天介绍的第一个岗位。”,则开头应是“热场”或“总起”,而不是衔接。 \n2. 内容:必须介绍岗位名称 \`jobTitle\`。 \n3. 提炼:从 \`jobLocation\`, \`companyName\`, \`education\`,\`experience\`,\`scale\` 中提炼“亮点 (Pro)”。 \n4. 翻译:用“人话”翻译 \`description\`。 \n5. 视角:你是在“评测”这个岗位,而不是在“推销”。\n\n### 口播稿的纯文本要求 (Pure Text Rules for the Script ONLY) \n**[重要]** 以下规则 *仅适用于* “口播稿”部分,不适用于“新摘要”部分: \n1. 绝不包含任何Markdown格式 (\`**\`, \`#\`)。 \n2. 绝不包含任何标签、括号或元数据 (\`[]\`, \`()\`)。 \n3. 绝不包含任何寒暄、问候、或自我介绍 (例如 \"你好\", \"当然\")。 \n4. 必须是可以直接朗读的、完整的、流畅的纯文本。\n\n# 输入数据 (Input Data)\n\n## 输入数据A (上一个岗位摘要) \n${lastJobSummary}\n\n## 输入数据B (当前岗位JSON)\n\`\`\`json\n${JSON.stringify(currentJobData, null, 2)}\n\`\`\`\n\n# 输出格式 (Output Format)\n**[绝对严格的指令]** \n你必须严格按照下面这个“两部分”格式输出,使用 \`---NEXT_SUMMARY---\` 作为唯一的分隔符。 绝不在分隔符之外添加任何多余的文字、解释或Markdown。 \n\n[这里是AI生成的、符合上述所有“纯文本要求”的完整口播稿] \n---NEXT_SUMMARY--- \n[这里是AI为“当前岗位”生成的简短新摘要]`;
|
||||
|
||||
answerText = await runOllamaNonStream(
|
||||
systemPromptTemplate,
|
||||
@@ -197,6 +203,279 @@ export function setupWorkflowHandlers() {
|
||||
return await checkOllamaServer();
|
||||
});
|
||||
|
||||
// 检查指定模型是否存在
|
||||
ipcMain.handle("check-model-exists", async (_, modelName = "qwen3:8b") => {
|
||||
try {
|
||||
const response = await fetch("http://127.0.0.1:11434/api/tags", {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = data.models || [];
|
||||
|
||||
// 检查模型是否存在于本地
|
||||
const modelExists = models.some(
|
||||
(model: any) => model.name === modelName,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
exists: modelExists,
|
||||
models: models.map((m: any) => ({
|
||||
name: m.name,
|
||||
size: m.size,
|
||||
modified_at: m.modified_at,
|
||||
})),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Check model error:", error);
|
||||
return {
|
||||
success: false,
|
||||
exists: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 加载模型(检查ollama状<61><E78AB6><EFBFBD>,下载模型如果不存在)
|
||||
ipcMain.handle("load-model", async (event, modelName = "qwen3:8b") => {
|
||||
const webContents = event.sender;
|
||||
|
||||
const sendStatus = (status: string, type = "info") => {
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("model-load-progress", {
|
||||
status,
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 发送询问是否下载模型的消息
|
||||
const askUserToDownload = () => {
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("model-download-confirm", {
|
||||
modelName,
|
||||
message: `模型 ${modelName} 不存在,是否下载?`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
sendStatus("正在检查Ollama服务状态...", "info");
|
||||
|
||||
// 1. 检查Ollama是否运行
|
||||
const isOllamaRunning = await checkOllamaServer();
|
||||
if (!isOllamaRunning) {
|
||||
sendStatus("Ollama服务未运行,正在启动...", "warning");
|
||||
|
||||
try {
|
||||
// 尝试启动Ollama服务
|
||||
await runCommand("ollama", ["ps"]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const isRunningNow = await checkOllamaServer();
|
||||
if (!isRunningNow) {
|
||||
throw new Error("无法启动Ollama服务,请手动启动");
|
||||
}
|
||||
sendStatus("Ollama服务启动成功", "success");
|
||||
} catch (error: any) {
|
||||
sendStatus(`启动Ollama服务失败: ${error.message}`, "error");
|
||||
return {
|
||||
success: false,
|
||||
message: `Ollama服务启动失败: ${error.message}`,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
sendStatus("Ollama服务正在运行", "success");
|
||||
}
|
||||
|
||||
// 2. 检查模型是否存在
|
||||
sendStatus(`正在检查模型 ${modelName} 是否存在...`, "info");
|
||||
|
||||
const modelCheckResult = await new Promise<{
|
||||
exists: boolean;
|
||||
models: any[];
|
||||
}>((resolve, reject) => {
|
||||
fetch("http://127.0.0.1:11434/api/tags", {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
const models = data.models || [];
|
||||
const modelExists = models.some(
|
||||
(model: any) => model.name === modelName,
|
||||
);
|
||||
resolve({ exists: modelExists, models });
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
if (modelCheckResult.exists) {
|
||||
sendStatus(`模型 ${modelName} 已存在,无需下载`, "success");
|
||||
return {
|
||||
success: true,
|
||||
message: `模型 ${modelName} 已就绪`,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 模型不存在,询问用户是否下载
|
||||
askUserToDownload();
|
||||
|
||||
// 等待用户确认
|
||||
const userConfirmed = await new Promise<boolean>(
|
||||
(resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (modelDownloadCallbacks.has(modelName)) {
|
||||
modelDownloadCallbacks.delete(modelName);
|
||||
resolve(false); // 30秒超时自动取消
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
modelDownloadCallbacks.set(modelName, {
|
||||
confirm: () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
},
|
||||
reject: (error: any) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (!userConfirmed) {
|
||||
sendStatus("用户取消了模型下载", "info");
|
||||
return {
|
||||
success: false,
|
||||
message: `用户取消了 ${modelName} 模型的下载`,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 用户确认,开始下载模型
|
||||
sendStatus(
|
||||
`开始下载模型 ${modelName},这可能需要一些时间...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const process = spawn("ollama", ["pull", modelName], {
|
||||
shell: true,
|
||||
});
|
||||
|
||||
const sendProgress = (data: any) => {
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("model-load-progress", {
|
||||
status: data.toString().trim(),
|
||||
type: "download",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
process.stdout.on("data", sendProgress);
|
||||
process.stderr.on("data", sendProgress);
|
||||
|
||||
process.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`模型下载失败,退出码: ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
process.on("error", (err: any) => {
|
||||
reject(new Error(`启动下载进程失败: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
sendStatus(`模型 ${modelName} 下载完成!`, "success");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `模型 ${modelName} 下载并加载成功`,
|
||||
downloaded: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Load model error:", error);
|
||||
sendStatus(`加载模型失败: ${error.message}`, "error");
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 处理用户对模型下载的确认响应
|
||||
ipcMain.on("model-download-confirm-response", (_event, data) => {
|
||||
const { modelName, confirmed } = data;
|
||||
const callback = modelDownloadCallbacks.get(modelName);
|
||||
|
||||
if (callback) {
|
||||
modelDownloadCallbacks.delete(modelName);
|
||||
if (confirmed) {
|
||||
callback.confirm();
|
||||
} else {
|
||||
callback.reject(new Error("用户取消了模型下载"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 润色文本的处理器
|
||||
ipcMain.handle("polish-text", async (_, text) => {
|
||||
try {
|
||||
if (!text || typeof text !== "string" || text.trim() === "") {
|
||||
return {
|
||||
success: false,
|
||||
error: "输入文本不能为空",
|
||||
};
|
||||
}
|
||||
|
||||
const systemPrompt = `你是一个专业的文本润色专家。请将以下文本进行润色,使其更加流畅、自然、专业。要求:
|
||||
1. 保持原意不变
|
||||
2. 使语言更加流畅自然
|
||||
3. 提升表达的准确性
|
||||
4. 适合直播场合使用
|
||||
5. 保持简洁明了
|
||||
|
||||
请直接返回润色后的文本,不要添加任何其他说明或解释。
|
||||
|
||||
原文:${text.trim()}`;
|
||||
|
||||
const polishedText = await runOllamaNonStream(
|
||||
systemPrompt,
|
||||
"qwen3:8b",
|
||||
);
|
||||
|
||||
if (!polishedText) {
|
||||
throw new Error("AI模型返回为空");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: polishedText.trim(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("润色文本失败:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "润色服务出现错误",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 处理器:检查服务,如果没运行,就用一个轻量命令唤醒它
|
||||
ipcMain.handle("ensure-ollama-running", async () => {
|
||||
let isRunning = await checkOllamaServer();
|
||||
@@ -257,7 +536,7 @@ function checkOllamaServer() {
|
||||
}
|
||||
|
||||
// 辅助函数:运行一个简单的命令并等待它完成
|
||||
function runCommand(command, args) {
|
||||
function runCommand(command: string, args: string[]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn(command, args, { shell: true });
|
||||
process.on("close", (code) => {
|
||||
@@ -271,7 +550,7 @@ function runCommand(command, args) {
|
||||
});
|
||||
}
|
||||
// 这是一个非流式的 Ollama 助手函数
|
||||
async function runOllamaNonStream(prompt, model = "qwen3:8b") {
|
||||
async function runOllamaNonStream(prompt: string, model: string = "qwen3:8b") {
|
||||
try {
|
||||
const response = await fetch("http://127.0.0.1:11434/api/chat", {
|
||||
method: "POST",
|
||||
@@ -297,11 +576,16 @@ async function runOllamaNonStream(prompt, model = "qwen3:8b") {
|
||||
}
|
||||
}
|
||||
|
||||
function streamCommand(command, args, webContents, eventName) {
|
||||
function streamCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
webContents: any,
|
||||
eventName: string,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn(command, args, { shell: true });
|
||||
|
||||
const send = (channel, data) => {
|
||||
const send = (channel: string, data: any) => {
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send(channel, data);
|
||||
}
|
||||
|
||||
@@ -12,3 +12,41 @@ export function showPrompt(
|
||||
buttons: ["确定"],
|
||||
});
|
||||
}
|
||||
|
||||
export function convertJobData(originalData: any) {
|
||||
// 检查输入是否为对象
|
||||
if (typeof originalData !== "object" || originalData === null) {
|
||||
console.error("输入必须是一个有效的对象。");
|
||||
return {};
|
||||
}
|
||||
|
||||
const convertedData = {
|
||||
jobId: originalData.jobId,
|
||||
jobTitle: originalData.jobTitle,
|
||||
education: originalData.education,
|
||||
experience: originalData.experience,
|
||||
companyName: originalData.companyName,
|
||||
jobLocation: originalData.jobLocation,
|
||||
description: originalData.description,
|
||||
scale: originalData.scale,
|
||||
};
|
||||
|
||||
return convertedData;
|
||||
}
|
||||
|
||||
// async function jobTransformAIobj(job: any) {
|
||||
// // const val = await window.electron.ipcRenderer.invoke("ollama-test", job);
|
||||
// const val = await window.electron.ipcRenderer.invoke(
|
||||
// "run-job-workflow",
|
||||
// job,
|
||||
// );
|
||||
|
||||
// if (val.success === false) {
|
||||
// throw new Error(val.error);
|
||||
// }
|
||||
// let record = {
|
||||
// ...job,
|
||||
// msg: val.data,
|
||||
// };
|
||||
// return record;
|
||||
// }
|
||||
|
||||
28
src/preload/index.d.ts
vendored
28
src/preload/index.d.ts
vendored
@@ -1,8 +1,32 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
interface ProcessAPI {
|
||||
versions: {
|
||||
electron: string;
|
||||
chrome: string;
|
||||
node: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
process: ProcessAPI;
|
||||
ipcRenderer: {
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
on: (channel: string, listener: (...args: any[]) => void) => void;
|
||||
off: (channel: string, listener: (...args: any[]) => void) => void;
|
||||
send: (channel: string, ...args: any[]) => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface API {
|
||||
installOllama: () => Promise<any>;
|
||||
onInstallProgress: (callback: (value: any) => void) => void;
|
||||
removeInstallProgressListeners: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: unknown
|
||||
api: API
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
const electronAPI = {
|
||||
process: {
|
||||
versions: process.versions
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args),
|
||||
on: (channel: string, listener: (...args: any[]) => void) => ipcRenderer.on(channel, listener),
|
||||
off: (channel: string, listener: (...args: any[]) => void) => ipcRenderer.off(channel, listener),
|
||||
send: (channel: string, ...args: any[]) => ipcRenderer.send(channel, ...args),
|
||||
removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel),
|
||||
}
|
||||
};
|
||||
|
||||
const api = {
|
||||
installOllama: () => ipcRenderer.invoke("install-ollama-and-model"),
|
||||
|
||||
// 监听进度 (Send/On)
|
||||
onInstallProgress: (callback) => {
|
||||
onInstallProgress: (callback: any) => {
|
||||
ipcRenderer.on("install-progress", (_event, value) => callback(value));
|
||||
},
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// 岗位接口类型定义
|
||||
export interface Job {
|
||||
jobId: number;
|
||||
jobTitle: string;
|
||||
minSalary?: number;
|
||||
maxSalary?: number;
|
||||
education: string;
|
||||
experience: string;
|
||||
companyName: string;
|
||||
jobLocation: string;
|
||||
description: string;
|
||||
scale: string;
|
||||
createTime?: string;
|
||||
industry?: string;
|
||||
jobCategory?: string;
|
||||
companyNature?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: Job;
|
||||
}
|
||||
|
||||
// 模拟分页查询
|
||||
export async function getJob(): Promise<ApiResponse> {
|
||||
return fetch("https://qd.zhaopinzao8dian.com/api/app/job/metaInfo")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
return data;
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const versions = reactive({ ...window.electron.process.versions })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="versions">
|
||||
<li class="electron-version">Electron v{{ versions.electron }}</li>
|
||||
<li class="chrome-version">Chromium v{{ versions.chrome }}</li>
|
||||
<li class="node-version">Node v{{ versions.node }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -5,8 +5,10 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import * as Pinia from "pinia";
|
||||
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
|
||||
import Antd from "ant-design-vue";
|
||||
import "ant-design-vue/dist/reset.css";
|
||||
|
||||
const pinia = Pinia.createPinia();
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
|
||||
createApp(App).use(router).use(pinia).mount("#app");
|
||||
createApp(App).use(Antd).use(router).use(pinia).mount("#app");
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { ref, watch } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { getJob, type Job } from "@renderer/api/jobs";
|
||||
|
||||
export interface Position {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const useLiveStore = defineStore("live", () => {
|
||||
const positions = ref<Job[]>([]);
|
||||
const currentPosition = ref<Job | null>(null);
|
||||
const seePosition = ref<Job | null>(null);
|
||||
const isLiveOn = ref(false);
|
||||
const isLiveWindowOpen = ref(false);
|
||||
const isExplaining = ref(false); // 是否正在讲解中
|
||||
const isStartingLive = ref(false); // 是否正在开始直播中
|
||||
|
||||
// 获取岗位列表
|
||||
async function fetchPositions() {
|
||||
try {
|
||||
const resData = await getJob();
|
||||
if (resData.code === 200) {
|
||||
const job = convertJobData(resData.data);
|
||||
const val = await jobTransformAIobj(job);
|
||||
positions.value = [...positions.value, val];
|
||||
if (positions.value.length < 10) {
|
||||
fetchPositions();
|
||||
}
|
||||
} else {
|
||||
throw new Error(resData.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("获取岗位失败");
|
||||
console.error("获取岗位列表失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始直播
|
||||
async function startLive() {
|
||||
if (isStartingLive.value) return; // 防止重复启动
|
||||
try {
|
||||
isStartingLive.value = true;
|
||||
const result =
|
||||
await window.electron.ipcRenderer.invoke("start-live");
|
||||
|
||||
if (result.success) {
|
||||
isLiveOn.value = true;
|
||||
// 确保有岗位可以讲解,并触发 getNextPosition 来启动讲解
|
||||
if (positions.value.length > 0 && !currentPosition.value) {
|
||||
getNextPosition();
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("开始直播失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
isStartingLive.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentPosition(position: Job) {
|
||||
if (position.jobId === seePosition.value?.jobId) {
|
||||
seePosition.value = null;
|
||||
} else {
|
||||
seePosition.value = position;
|
||||
}
|
||||
}
|
||||
|
||||
// 开始讲解岗位
|
||||
async function startExplainingPosition(job: Job) {
|
||||
if (!isLiveOn.value) return;
|
||||
|
||||
try {
|
||||
isExplaining.value = true;
|
||||
// 这里添加讲解岗位的具体实现
|
||||
// 可以调用 AI 接口进行讲解
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
"explain-position",
|
||||
job.msg,
|
||||
);
|
||||
if (result.success) {
|
||||
// 讲解完成后,获取下一个岗位
|
||||
// await completeCurrentPosition();
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("岗位讲解失败:", error);
|
||||
isExplaining.value = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 结束直播
|
||||
async function stopLive() {
|
||||
try {
|
||||
const result =
|
||||
await window.electron.ipcRenderer.invoke("stop-live");
|
||||
if (result.success) {
|
||||
isLiveOn.value = false;
|
||||
isExplaining.value = false;
|
||||
// 只清空当前岗位,不移动列表中的岗位
|
||||
currentPosition.value = null;
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("结束直播失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 打开直播窗口
|
||||
async function openLiveWindow() {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
"open-live-window",
|
||||
{ path: "live", width: 375, height: 682, userId: "rs876543" },
|
||||
);
|
||||
if (result.success) {
|
||||
isLiveWindowOpen.value = true;
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("打开直播窗口失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取下一个要讲解的岗位
|
||||
function getNextPosition() {
|
||||
if (!isLiveOn.value) return; // 只在直播开启时切换岗位
|
||||
|
||||
if (positions.value.length > 0) {
|
||||
// 设置下一个要讲解的岗位
|
||||
currentPosition.value = positions.value[0];
|
||||
// 从列表中移除该岗位
|
||||
positions.value = positions.value.slice(1);
|
||||
|
||||
// 如果直播已经开启,自动开始讲解
|
||||
if (isLiveOn.value && !isExplaining.value) {
|
||||
startExplainingPosition(currentPosition.value).catch(
|
||||
(error) => {
|
||||
console.error("自动开始讲解失败:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 没有更多岗位时
|
||||
currentPosition.value = null;
|
||||
isExplaining.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 完成当前岗位讲解
|
||||
async function completeCurrentPosition() {
|
||||
if (currentPosition.value) {
|
||||
// 从列表中移除当前岗位
|
||||
positions.value = positions.value.filter(
|
||||
(job) => job.jobId !== currentPosition.value?.jobId,
|
||||
);
|
||||
isExplaining.value = false;
|
||||
// 自动获取下一个岗位
|
||||
getNextPosition();
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 currentPosition 的变化
|
||||
watch(currentPosition, (newVal) => {
|
||||
// 当 currentPosition 为空且不是正在讲解状态时,自动获取下一个岗位
|
||||
if (!newVal && !isExplaining.value && positions.value.length > 0) {
|
||||
getNextPosition();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 positions 的变化
|
||||
watch(
|
||||
positions,
|
||||
(newVal) => {
|
||||
console.log("positions changed:", newVal);
|
||||
if (
|
||||
newVal.length > 0 &&
|
||||
!currentPosition.value &&
|
||||
!isExplaining.value
|
||||
) {
|
||||
getNextPosition();
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true, // 深度监听数组内容的变化
|
||||
immediate: true, // 立即执行一次回调
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
positions,
|
||||
currentPosition,
|
||||
isLiveOn,
|
||||
isLiveWindowOpen,
|
||||
setCurrentPosition,
|
||||
seePosition,
|
||||
isExplaining,
|
||||
fetchPositions,
|
||||
completeCurrentPosition,
|
||||
getNextPosition,
|
||||
startLive,
|
||||
stopLive,
|
||||
openLiveWindow,
|
||||
};
|
||||
});
|
||||
|
||||
async function jobTransformAIobj(job: any) {
|
||||
// const val = await window.electron.ipcRenderer.invoke("ollama-test", job);
|
||||
const val = await window.electron.ipcRenderer.invoke(
|
||||
"run-job-workflow",
|
||||
job,
|
||||
);
|
||||
|
||||
if (val.success === false) {
|
||||
throw new Error(val.error);
|
||||
}
|
||||
let record = {
|
||||
...job,
|
||||
msg: val.data,
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
function convertJobData(originalData) {
|
||||
// 检查输入是否为对象
|
||||
if (typeof originalData !== "object" || originalData === null) {
|
||||
console.error("输入必须是一个有效的对象。");
|
||||
return {};
|
||||
}
|
||||
|
||||
const convertedData = {
|
||||
jobId: originalData.jobId,
|
||||
jobTitle: originalData.jobTitle,
|
||||
education: originalData.education,
|
||||
experience: originalData.experience,
|
||||
companyName: originalData.companyName,
|
||||
jobLocation: originalData.jobLocation,
|
||||
description: originalData.description,
|
||||
scale: originalData.scale,
|
||||
};
|
||||
|
||||
return convertedData;
|
||||
}
|
||||
38
src/renderer/src/types/socket.ts
Normal file
38
src/renderer/src/types/socket.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/types/socket.ts
|
||||
|
||||
/**
|
||||
* Socket 配置项接口
|
||||
*/
|
||||
export interface SocketOptions {
|
||||
/** 心跳间隔(ms),默认 30000 */
|
||||
heartbeatInterval?: number;
|
||||
/** 初始重连间隔(ms),默认 3000 */
|
||||
reconnectInterval?: number;
|
||||
/** 最大重连次数,默认 10 */
|
||||
maxReconnectCount?: number;
|
||||
/** 消息接收回调 */
|
||||
onMessage?: (data: string | ArrayBuffer | Blob) => void;
|
||||
/** 错误回调 */
|
||||
onError?: (error: Event) => void;
|
||||
/** 关闭回调 */
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
/** 连接成功回调 */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息记录类型
|
||||
*/
|
||||
export interface MessageItem {
|
||||
time: string;
|
||||
type: 'send' | 'receive' | 'system';
|
||||
content: string | object;
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳包类型(可根据后端格式调整)
|
||||
*/
|
||||
export interface HeartbeatData {
|
||||
type: 'ping' | 'pong';
|
||||
timestamp?: number;
|
||||
}
|
||||
175
src/renderer/src/utils/socket.ts
Normal file
175
src/renderer/src/utils/socket.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// src/utils/socket.ts
|
||||
import type { SocketOptions, HeartbeatData } from "@renderer/types/socket";
|
||||
|
||||
/**
|
||||
* WebSocket 长连接工具类(TS 版)
|
||||
* @class SocketClient
|
||||
* @param {string} url - WS/WSS 连接地址
|
||||
* @param {SocketOptions} options - 配置项
|
||||
*/
|
||||
class SocketClient {
|
||||
private url: string; // 连接地址(私有属性)
|
||||
private ws: WebSocket | null; // WebSocket 实例
|
||||
private isConnected: boolean; // 连接状态
|
||||
private heartbeatTimer: NodeJS.Timeout | null; // 心跳定时器
|
||||
private reconnectTimer: NodeJS.Timeout | null; // 重连定时器
|
||||
private reconnectCount: number; // 已重连次数
|
||||
private config: Required<SocketOptions>; // 完整配置(包含默认值)
|
||||
|
||||
constructor(url: string, options: SocketOptions = {}) {
|
||||
this.url = url;
|
||||
this.ws = null;
|
||||
this.isConnected = false;
|
||||
this.heartbeatTimer = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectCount = 0;
|
||||
|
||||
// 合并默认配置与用户配置(确保所有配置项有值)
|
||||
this.config = {
|
||||
heartbeatInterval: options.heartbeatInterval || 30000,
|
||||
reconnectInterval: options.reconnectInterval || 3000,
|
||||
maxReconnectCount: options.maxReconnectCount || 10,
|
||||
onMessage: options.onMessage || (() => {}),
|
||||
onError: options.onError || (() => {}),
|
||||
onClose: options.onClose || (() => {}),
|
||||
onOpen: options.onOpen || (() => {})
|
||||
};
|
||||
|
||||
// 初始化连接
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 WebSocket 连接
|
||||
*/
|
||||
private init(): void {
|
||||
// 关闭已有连接
|
||||
this.close();
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
// 连接成功
|
||||
this.ws.onopen = (): void => {
|
||||
this.isConnected = true;
|
||||
this.reconnectCount = 0;
|
||||
this.config.onOpen();
|
||||
this.startHeartbeat();
|
||||
};
|
||||
|
||||
// 接收消息
|
||||
this.ws.onmessage = (event: MessageEvent): void => {
|
||||
this.config.onMessage(event.data);
|
||||
};
|
||||
|
||||
// 错误回调
|
||||
this.ws.onerror = (error: Event): void => {
|
||||
this.isConnected = false;
|
||||
this.config.onError(error);
|
||||
};
|
||||
|
||||
// 关闭回调
|
||||
this.ws.onclose = (event: CloseEvent): void => {
|
||||
this.isConnected = false;
|
||||
this.config.onClose(event);
|
||||
this.stopHeartbeat();
|
||||
|
||||
// 自动重连(未超过最大次数)
|
||||
if (this.reconnectCount < this.config.maxReconnectCount) {
|
||||
this.reconnect();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
this.config.onError(error as Event);
|
||||
this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳保活
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.isConnected && this.ws) {
|
||||
// 发送心跳包(强类型)
|
||||
const heartbeatData: HeartbeatData = {
|
||||
type: 'ping',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
this.send(heartbeatData);
|
||||
}
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @param {string | object} data - 要发送的消息(字符串/对象)
|
||||
* @throws {Error} 连接未建立时抛出错误
|
||||
*/
|
||||
public send(data: string | object): void {
|
||||
if (!this.isConnected || !this.ws) {
|
||||
throw new Error('WebSocket 未连接,无法发送消息');
|
||||
}
|
||||
|
||||
// 类型判断:对象转为 JSON 字符串
|
||||
const sendData: string = typeof data === 'object'
|
||||
? JSON.stringify(data)
|
||||
: data;
|
||||
|
||||
this.ws.send(sendData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断线重连(阶梯式间隔)
|
||||
*/
|
||||
private reconnect(): void {
|
||||
this.reconnectCount++;
|
||||
const currentInterval = this.config.reconnectInterval * this.reconnectCount;
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log(`WebSocket 第 ${this.reconnectCount} 次重连...`);
|
||||
this.init();
|
||||
}, currentInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动关闭连接
|
||||
* @param {number} code - 关闭码(默认 1000:正常关闭)
|
||||
* @param {string} reason - 关闭原因
|
||||
*/
|
||||
public close(code: number = 1000, reason: string = '主动关闭连接'): void {
|
||||
if (this.ws) {
|
||||
this.ws.close(code, reason);
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
this.stopHeartbeat();
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前连接状态
|
||||
* @returns {boolean} 连接状态
|
||||
*/
|
||||
public getConnectedState(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
}
|
||||
|
||||
export default SocketClient;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="livePage">
|
||||
<div class="live-header"></div>
|
||||
<!-- 主画面 -->
|
||||
<iframe ref="liveIframe" :src="liveUrl" frameborder="0" allowfullscreen class="main-iframe"></iframe>
|
||||
<!-- 插播画面 -->
|
||||
@@ -8,16 +9,42 @@
|
||||
<audio :src="soundUrl" class="sound" ref="welcome" @ended="welcomeEnd"></audio>
|
||||
<!-- 隐藏的麦克风播放元素,用于放大麦克风声音 -->
|
||||
<audio ref="micAudio" autoplay style="display:none"></audio>
|
||||
|
||||
<!-- 文件悬浮展示区域 - 仅支持图片和视频 -->
|
||||
<div class="files-display-overlay" v-if="displayedFiles.length > 0">
|
||||
<div class="files-container">
|
||||
<div v-for="(file, index) in displayedFiles" :key="index" class="file-display-item">
|
||||
<!-- 关闭按钮 -->
|
||||
<!-- <button class="file-close-btn" @click="removeFileFromDisplay(index)">×</button> -->
|
||||
|
||||
<!-- 图片文件展示 -->
|
||||
<div v-if="file.type.startsWith('image/')" class="image-display">
|
||||
<img :src="file.data" :alt="file.name" />
|
||||
<!-- <div class="file-name-overlay">{{ file.name }}</div> -->
|
||||
</div>
|
||||
|
||||
<!-- 视频文件展示 -->
|
||||
<div v-else-if="file.type.startsWith('video/')" class="video-display">
|
||||
<video :src="file.data" controls autoplay muted />
|
||||
<!-- <div class="file-name-overlay">{{ file.name }}</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRoute } from 'vue-router';
|
||||
// 不再需要音频和文档图标
|
||||
|
||||
const route = useRoute();
|
||||
const isCameraActive = ref(false)
|
||||
const isfullScreen = ref(false);
|
||||
|
||||
// 文件展示状态
|
||||
const displayedFiles = ref([]);
|
||||
const userId = ref(null);
|
||||
const liveUrl = ref("");
|
||||
const soundUrl = ref("");
|
||||
@@ -49,34 +76,136 @@ function welcomeEnd() {
|
||||
}
|
||||
|
||||
|
||||
// 文件展示相关函数
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 处理展示文件的请求 - 仅支持图片和视频
|
||||
const handleShowFilesDisplay = (_event, data) => {
|
||||
console.log('收到文件展示请求:', data);
|
||||
const files = data.files || [];
|
||||
|
||||
// 过滤并处理文件数据,仅保留图片和视频文件
|
||||
const processedFiles = files
|
||||
.filter(file => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
console.log(`文件过滤: ${file.name} (${file.type}) - ${isImage || isVideo ? '保留' : '过滤掉'}`);
|
||||
return isImage || isVideo;
|
||||
})
|
||||
.map(file => {
|
||||
console.log(`处理文件: ${file.name}, 路径: ${file.path}, URL: ${file.url}`);
|
||||
|
||||
if (file.url || file.path) {
|
||||
// 如果有文件路径,直接使用路径
|
||||
const fileUrl = file.url || `file://${file.path.replace(/\\/g, '/')}`;
|
||||
console.log(`使用文件路径: ${fileUrl}`);
|
||||
return {
|
||||
...file,
|
||||
data: fileUrl
|
||||
};
|
||||
} else if (file.data && typeof file.data === 'string') {
|
||||
// 如果没有路径但有base64数据,确保格式正确
|
||||
const base64Data = file.data.includes('base64,') ? file.data : `data:${file.type};base64,${file.data}`;
|
||||
console.log(`使用base64数据: ${base64Data.substring(0, 100)}...`);
|
||||
return {
|
||||
...file,
|
||||
data: base64Data
|
||||
};
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
displayedFiles.value = processedFiles;
|
||||
console.log('处理后的文件数量:', displayedFiles.value.length);
|
||||
console.log('文件详情:', processedFiles.map(f => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
hasData: !!f.data,
|
||||
hasPath: !!f.path,
|
||||
hasUrl: !!f.url,
|
||||
data: f.data ? f.data.substring(0, 100) : 'no-data' // 显示前100个字符
|
||||
})));
|
||||
};
|
||||
|
||||
// 移除文件展示
|
||||
const removeFileFromDisplay = (index) => {
|
||||
const removedFile = displayedFiles.value[index];
|
||||
displayedFiles.value.splice(index, 1);
|
||||
|
||||
// 通知主进程文件已被移除
|
||||
window.electron.ipcRenderer.send('file-display-removed', {
|
||||
index,
|
||||
fileName: removedFile?.name,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
// 处理移除文件展示的请求
|
||||
const handleRemoveFileDisplay = (_event, data) => {
|
||||
console.log('收到移除文件展示请求:', data);
|
||||
const { index } = data;
|
||||
if (index >= 0 && index < displayedFiles.value.length) {
|
||||
displayedFiles.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理清空所有文件展示的请求
|
||||
const handleClearAllFilesDisplay = (_event, data) => {
|
||||
console.log('收到清空文件展示请求:', data);
|
||||
displayedFiles.value = [];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
startLive()
|
||||
window.electron.ipcRenderer.on('insert-camera-video', handleInsertCameraVideo)
|
||||
window.electron.ipcRenderer.on('insert-video-audio', handleInsertVideoAudio)
|
||||
window.electron.ipcRenderer.on('toggle-camera-insert', handleToggleCameraInsert)
|
||||
window.electron.ipcRenderer.on('force-cleanup-media', handleForceCleanup)
|
||||
|
||||
// 文件展示相关事件
|
||||
window.electron.ipcRenderer.on('show-files-display', handleShowFilesDisplay)
|
||||
window.electron.ipcRenderer.on('remove-file-display', handleRemoveFileDisplay)
|
||||
window.electron.ipcRenderer.on('clear-all-files-display', handleClearAllFilesDisplay)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.electron.ipcRenderer.off('insert-camera-video', handleInsertCameraVideo)
|
||||
window.electron.ipcRenderer.off('insert-video-audio', handleInsertVideoAudio)
|
||||
window.electron.ipcRenderer.off('toggle-camera-insert', handleToggleCameraInsert)
|
||||
// 清理麦克风流
|
||||
if (micStream) {
|
||||
try { micStream.getTracks().forEach((t) => t.stop()) } catch (e) { }
|
||||
micStream = null
|
||||
}
|
||||
if (micAudio.value) {
|
||||
try { micAudio.value.pause() } catch (e) { }
|
||||
micAudio.value.srcObject = null
|
||||
}
|
||||
window.electron.ipcRenderer.off('force-cleanup-media', handleForceCleanup)
|
||||
|
||||
// 清理文件展示相关事件
|
||||
window.electron.ipcRenderer.off('show-files-display', handleShowFilesDisplay)
|
||||
window.electron.ipcRenderer.off('remove-file-display', handleRemoveFileDisplay)
|
||||
window.electron.ipcRenderer.off('clear-all-files-display', handleClearAllFilesDisplay)
|
||||
|
||||
// 清理媒体资源
|
||||
forceCleanupMedia()
|
||||
})
|
||||
|
||||
function startLive() {
|
||||
const paramsUserId = route.query.userId;
|
||||
console.log(paramsUserId)
|
||||
if (paramsUserId) {
|
||||
userId.value = paramsUserId
|
||||
let paramsUserId = route.query.userId;
|
||||
|
||||
if (!paramsUserId) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
paramsUserId = urlParams.get('userId');
|
||||
}
|
||||
|
||||
if (!paramsUserId && window.location.hash.includes('?')) {
|
||||
const hashQuery = window.location.hash.split('?')[1];
|
||||
const searchParams = new URLSearchParams(hashQuery);
|
||||
paramsUserId = searchParams.get('userId');
|
||||
}
|
||||
|
||||
|
||||
if (paramsUserId) {
|
||||
userId.value = paramsUserId;
|
||||
liveUrl.value = `https://dmdemo.hx.cn/dashboard.html?userId=${userId.value}`;
|
||||
}
|
||||
}
|
||||
@@ -205,6 +334,67 @@ async function startCamera() {
|
||||
} catch (error) {
|
||||
console.error('Error accessing camera:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 强制清理所有媒体资源
|
||||
function forceCleanupMedia() {
|
||||
try {
|
||||
console.log('强制清理所有媒体资源...');
|
||||
|
||||
// 停止摄像头
|
||||
stopCamera();
|
||||
|
||||
// 停止麦克风
|
||||
if (micStream) {
|
||||
try {
|
||||
micStream.getTracks().forEach(track => track.stop());
|
||||
} catch (e) { console.warn('停止麦克风流失败:', e); }
|
||||
micStream = null;
|
||||
}
|
||||
|
||||
// 清理麦克风音频
|
||||
if (micAudio.value) {
|
||||
try {
|
||||
micAudio.value.pause();
|
||||
micAudio.value.srcObject = null;
|
||||
} catch (e) { console.warn('清理麦克风音频失败:', e); }
|
||||
}
|
||||
|
||||
// 清理欢迎音频
|
||||
if (welcome.value) {
|
||||
try {
|
||||
welcome.value.pause();
|
||||
welcome.value.currentTime = 0;
|
||||
} catch (e) { console.warn('清理欢迎音频失败:', e); }
|
||||
}
|
||||
|
||||
// 恢复iframe内视频音量
|
||||
try {
|
||||
const iframeEl = liveIframe.value;
|
||||
if (iframeEl && iframeEl.contentWindow) {
|
||||
const v = iframeEl.contentWindow.document.querySelector('video');
|
||||
if (v && typeof v.volume === 'number') {
|
||||
v.volume = 1;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('恢复iframe音量失败:', e); }
|
||||
|
||||
// 重置状态
|
||||
isCameraActive.value = false;
|
||||
isfullScreen.value = false;
|
||||
micPlaying = false;
|
||||
wasWelcomePlaying = false;
|
||||
|
||||
console.log('媒体资源清理完成');
|
||||
} catch (error) {
|
||||
console.error('强制清理媒体资源出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理强制清理指令
|
||||
function handleForceCleanup() {
|
||||
console.log('收到强制清理指令');
|
||||
forceCleanupMedia();
|
||||
}</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -251,5 +441,132 @@ async function startCamera() {
|
||||
height: 100vh;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 文件悬浮展示样式 */
|
||||
.files-display-overlay {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 20px;
|
||||
z-index: 1000;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.files-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.file-display-item {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.file-display-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.file-close-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
color: #ff4d4f;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.file-close-btn:hover {
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 图片展示 */
|
||||
.image-display {
|
||||
max-width: 300px;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.image-display img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 视频展示 */
|
||||
.video-display {
|
||||
max-width: 300px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.video-display video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 已移除音频、PDF和其他文档类型的展示样式 */
|
||||
|
||||
/* 文件名覆盖层 */
|
||||
.file-name-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.live-header {
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@ const handleLogin = () => {
|
||||
if (username.value === 'admin' && password.value === '123456') {
|
||||
userStore.setUserInfo(userInfo);
|
||||
userStore.setToken('token_abcdefg1234567');
|
||||
router.push('/home');
|
||||
router.push('/');
|
||||
} else {
|
||||
alert('用户名或密码错误!');
|
||||
}
|
||||
@@ -47,6 +47,7 @@ const handleLogin = () => {
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -64,6 +65,7 @@ const handleLogin = () => {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
||||
4
test-files.txt
Normal file
4
test-files.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
这是一个测试文件
|
||||
用于验证文件拖拽功能
|
||||
内容:Hello World!
|
||||
时间:2025-11-16
|
||||
@@ -1,8 +1,19 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
"types": ["electron-vite/node"],
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*.ts",
|
||||
"src/renderer/src/**/*.vue",
|
||||
"src/preload/*.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
@@ -14,6 +7,27 @@
|
||||
"src/renderer/src/*"
|
||||
]
|
||||
},
|
||||
"allowJs": true
|
||||
}
|
||||
"allowJs": true,
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*.ts",
|
||||
"src/renderer/src/**/*.vue",
|
||||
"src/preload/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user