flat: 暂存

This commit is contained in:
Apcallover
2025-11-16 18:11:30 +08:00
parent 120bad4abe
commit e9c90d759b
18 changed files with 9912 additions and 8113 deletions

249
FEATURES.md Normal file
View 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实现高效通信。

14815
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"ant-design-vue": "^4.2.6",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0", "pinia-plugin-persistedstate": "^4.5.0",
"sass": "^1.93.2", "sass": "^1.93.2",

96
src/main/Api/api.ts Normal file
View 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 };
}
}

View File

@@ -1,31 +1,11 @@
import { app, shell, BrowserWindow } from "electron"; 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 { setupLiveHandlers } from "./ipc/live";
import { setupPromptHandlers } from "./ipc/prompt"; import { setupPromptHandlers } from "./ipc/prompt";
import { setupWorkflowHandlers } from "./ipc/workflow"; import { setupWorkflowHandlers } from "./ipc/workflow";
import { preload, indexHtml, ELECTRON_RENDERER_URL } from "./config"; import { preload, indexHtml, ELECTRON_RENDERER_URL } from "./config";
// 开发环境下启用热重载 // 简单的开发环境检测
if (is.dev) { const isDev = process.env.NODE_ENV === 'development';
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);
}
}
/** /**
* 创建主窗口 * 创建主窗口
@@ -36,7 +16,6 @@ function createWindow(): void {
height: 670, height: 670,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
...(process.platform === "linux" ? { icon } : {}),
webPreferences: { webPreferences: {
preload, preload,
sandbox: false, sandbox: false,
@@ -47,14 +26,24 @@ function createWindow(): void {
mainWindow.show(); mainWindow.show();
}); });
// mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url); shell.openExternal(details.url);
return { action: "deny" }; 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); mainWindow.loadURL(ELECTRON_RENDERER_URL);
} else { } else {
mainWindow.loadFile(indexHtml); mainWindow.loadFile(indexHtml);
@@ -65,13 +54,9 @@ function createWindow(): void {
* 准备好后 * 准备好后
*/ */
app.whenReady().then(() => { app.whenReady().then(() => {
electronApp.setAppUserModelId("com.electron"); app.setAppUserModelId("com.electron");
app.on("browser-window-created", (_, window) => { // 直播相关处理
optimizer.watchWindowShortcuts(window);
});
// 直播相关处理
setupLiveHandlers(); setupLiveHandlers();
setupPromptHandlers(); setupPromptHandlers();
@@ -89,7 +74,27 @@ app.whenReady().then(() => {
* 退出应用 * 退出应用
*/ */
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
// 强制清理所有资源
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit(); 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();
});

View File

@@ -1,45 +1,124 @@
import { ipcMain, BrowserWindow } from "electron"; import { ipcMain, BrowserWindow } from "electron";
import { preload, indexHtml, ELECTRON_RENDERER_URL } from "../config"; import { preload, indexHtml, ELECTRON_RENDERER_URL } from "../config";
import { getSessionId, sendMessage } from "../Api/api";
import { showPrompt } from "../utils/tools"; 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() { 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,
},
};
});
// 切换摄像头插入状态 // 切换摄像头插入状态
ipcMain.on("toggle-camera-insert", async () => { ipcMain.on("toggle-camera-insert", async () => {
if (liveWindow) { if (liveState.liveWindow) {
isVideoInserted = !isVideoInserted; liveState.isVideoInserted = !liveState.isVideoInserted;
liveWindow.webContents.send( liveState.liveWindow.webContents.send(
"toggle-camera-insert", "toggle-camera-insert",
isVideoInserted, liveState.isVideoInserted,
); );
} }
}); });
// 插入摄像头视频 // 插入摄像头视频
ipcMain.on("insert-camera-video", async (_) => { ipcMain.on("insert-camera-video", async () => {
if (liveWindow) { if (liveState.liveWindow) {
liveWindow.webContents.send("insert-camera-video"); liveState.liveWindow.webContents.send("insert-camera-video");
liveState.cameraActive = true;
} else {
showPrompt("直播窗口未打开", "error");
} }
}); });
// 插入音频 // 插入音频
ipcMain.on("insert-video-audio", async (_) => { ipcMain.on("insert-video-audio", async () => {
if (liveWindow) { if (liveState.liveWindow) {
liveWindow.webContents.send("insert-video-audio"); 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 { try {
console.log("Starting live stream..."); console.log("Starting live stream...");
// TODO: 实现直播推流逻辑
return { success: true }; // 1. 设置用户ID
if (userId) {
liveState.userId = userId;
}
// 2. 创建或显示直播窗口
if (!liveState.liveWindow) {
await createLiveWindow();
}
// 3. 等待网页加载完成
// await waitForPageLoad();
// 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;
// 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) { } catch (error: any) {
console.error("Start live error:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
@@ -47,151 +126,273 @@ export function setupLiveHandlers() {
// 结束直播 // 结束直播
ipcMain.handle("stop-live", async () => { ipcMain.handle("stop-live", async () => {
try { 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 }; return { success: true };
} catch (error: any) { } catch (error: any) {
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("explain-position", async (_, content: string) => { // 强制关闭直播窗口
ipcMain.handle("force-close-live", async () => {
try { try {
let params = { console.log("Force closing live window...");
sessionid: LiveSessionId,
// 通知直播窗口清理所有媒体资源
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, text: content,
type: "echo", type: "echo",
interrupt: true, interrupt: true,
}; };
await sendMessage(params); await sendMessage(params);
// TODO: 实现结束直播逻辑
return { success: true }; return { success: true };
} catch (error: any) { } catch (error: any) {
console.error("Send message error:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// 打开直播窗口 // 创建直播窗口
ipcMain.handle("open-live-window", async (_, args) => { async function createLiveWindow() {
try { const width = 375;
if (liveWindow) { const height = 690;
liveWindow.focus(); let liveUrl = `${ELECTRON_RENDERER_URL}/#/live`;
showPrompt("直播窗口已打开", "info"); if (liveState.userId) {
return { success: true }; liveUrl += `?userId=${liveState.userId}`;
} }
const { width, height, path, userId } = args;
let liveUrl = `${ELECTRON_RENDERER_URL}/#/${path}`; liveState.liveWindow = new BrowserWindow({
if (userId) { title: "直播窗口",
liveUrl += `?userId=${userId}`; width,
} height,
// TODO: 实现打开直播窗口逻辑 minimizable: false,
liveWindow = new BrowserWindow({ maximizable: false,
title: "直播窗口", closable: true,
width, alwaysOnTop: true,
height, webPreferences: {
minimizable: false, // 是否可以最小化 preload,
maximizable: false, // 是否可以最小化 nodeIntegration: true,
closable: true, // 窗口是否可关闭 contextIsolation: false,
alwaysOnTop: true, // 窗口是否永远在别的窗口的上面 webSecurity: false, // 允许访问本地文件
webPreferences: { allowRunningInsecureContent: true, // 允许运行本地内容
preload, },
nodeIntegration: true, });
contextIsolation: false,
liveState.liveWindow.on("closed", () => {
liveState.liveWindow = null;
liveState.isLiveOn = false;
});
console.log(liveUrl);
if (ELECTRON_RENDERER_URL) {
await liveState.liveWindow.loadURL(liveUrl);
} else {
await liveState.liveWindow.loadFile(indexHtml, { hash: "/live" });
}
}
// 等待页面加载完成
// @ts-ignore - 暂时未使用但保留以备将来使用
async function waitForPageLoad() {
if (!liveState.liveWindow) {
throw new Error("直播窗口不存在");
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("页面加载超时"));
}, 30000); // 30秒超时
// 监听页面加载完成事件
liveState.liveWindow!.webContents.once("did-finish-load", () => {
clearTimeout(timeout);
console.log("页面加载完成");
// 额外等待一下确保iframe也加载完成
setTimeout(() => {
// 通知所有渲染进程状态已更新
const allWindows = BrowserWindow.getAllWindows();
allWindows.forEach((window: BrowserWindow) => {
if (window.webContents && !window.isDestroyed()) {
window.webContents.send("live-status-updated", {
hasLiveWindow: true,
windowLoaded: true,
});
}
});
resolve(true);
}, 2000);
});
// 监听加载失败事件
liveState.liveWindow!.webContents.once(
"did-fail-load",
(_, errorCode, errorDesc) => {
clearTimeout(timeout);
reject(
new Error(`页面加载失败: ${errorDesc} (${errorCode})`),
);
}, },
}); );
// liveWindow.webContents.openDevTools(); });
liveWindow.on("closed", () => { }
liveWindow = null;
}); // 文件管理处理器
if (ELECTRON_RENDERER_URL) { ipcMain.handle("show-files-in-live", async (_, { files }) => {
liveWindow.loadURL(liveUrl); console.log("收到文件", files);
setTimeout(async () => { try {
const res = await getSessionId({ userId: userId }); // 使用当前直播窗口的 webContents
if (res.success) { if (liveState.liveWindow && !liveState.liveWindow.isDestroyed()) {
LiveSessionId = res.sessionId; console.log("发送文件路径到直播窗口:", files.length, "个文件");
console.log(
"Session ID obtained successfully", // 直接传递文件路径信息,不读取文件内容
LiveSessionId, const processedFiles = files.map((file) => ({
); name: file.name,
} type: file.type,
}, 3000); size: file.size,
path: file.path,
// 为支持的文件类型生成 file:// URL
url: file.path
? `file://${file.path.replace(/\\/g, "/")}`
: null,
}));
console.log(
"处理后的文件数据:",
processedFiles.map((f) => ({
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 { } else {
liveWindow.loadFile(indexHtml, { hash: `/${path}` }); throw new Error("直播窗口未找到或已关闭");
} }
return { success: true };
} catch (error: any) { } 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 { export const getLiveState = () => ({ ...liveState });
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 };
}
}

View File

@@ -9,6 +9,9 @@ let InstallWindows: BrowserWindow | null = null;
export function setupWorkflowHandlers() { export function setupWorkflowHandlers() {
let lastJobSummary = "这是我们今天介绍的第一个岗位"; let lastJobSummary = "这是我们今天介绍的第一个岗位";
// 存储用户确认回调的Map
const modelDownloadCallbacks = new Map<string, { confirm: Function, reject: Function }>();
// 打开安装窗口 // 打开安装窗口
ipcMain.handle("open-install-window", async (_, args) => { ipcMain.handle("open-install-window", async (_, args) => {
try { try {
@@ -197,6 +200,259 @@ export function setupWorkflowHandlers() {
return await checkOllamaServer(); 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 (_, modelName = "qwen3:8b") => {
const webContents = BrowserWindow.getFocusedWindow()?.webContents;
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(() => {
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 () => { ipcMain.handle("ensure-ollama-running", async () => {
let isRunning = await checkOllamaServer(); let isRunning = await checkOllamaServer();

View File

@@ -12,3 +12,41 @@ export function showPrompt(
buttons: ["确定"], buttons: ["确定"],
}); });
} }
export 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;
}
// 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;
// }

View File

@@ -1,8 +1,22 @@
import { ElectronAPI } from '@electron-toolkit/preload' interface ElectronAPI {
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 { declare global {
interface Window { interface Window {
electron: ElectronAPI electron: ElectronAPI
api: unknown api: API
} }
} }

View File

@@ -1,5 +1,14 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
const electronAPI = {
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 = { const api = {
installOllama: () => ipcRenderer.invoke("install-ollama-and-model"), installOllama: () => ipcRenderer.invoke("install-ollama-and-model"),

View File

@@ -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;
});
}

View File

@@ -5,8 +5,10 @@ import App from "./App.vue";
import router from "./router"; import router from "./router";
import * as Pinia from "pinia"; import * as Pinia from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate"; import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
const pinia = Pinia.createPinia(); const pinia = Pinia.createPinia();
pinia.use(piniaPluginPersistedstate); pinia.use(piniaPluginPersistedstate);
createApp(App).use(router).use(pinia).mount("#app"); createApp(App).use(Antd).use(router).use(pinia).mount("#app");

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,42 @@
<audio :src="soundUrl" class="sound" ref="welcome" @ended="welcomeEnd"></audio> <audio :src="soundUrl" class="sound" ref="welcome" @ended="welcomeEnd"></audio>
<!-- 隐藏的麦克风播放元素用于放大麦克风声音 --> <!-- 隐藏的麦克风播放元素用于放大麦克风声音 -->
<audio ref="micAudio" autoplay style="display:none"></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> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from "vue"; import { ref, onMounted, onBeforeUnmount } from "vue";
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
// 不再需要音频和文档图标
const route = useRoute(); const route = useRoute();
const isCameraActive = ref(false) const isCameraActive = ref(false)
const isfullScreen = ref(false); const isfullScreen = ref(false);
// 文件展示状态
const displayedFiles = ref([]);
const userId = ref(null); const userId = ref(null);
const liveUrl = ref(""); const liveUrl = ref("");
const soundUrl = ref(""); const soundUrl = ref("");
@@ -49,26 +75,117 @@ 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(() => { onMounted(() => {
startLive() startLive()
window.electron.ipcRenderer.on('insert-camera-video', handleInsertCameraVideo) window.electron.ipcRenderer.on('insert-camera-video', handleInsertCameraVideo)
window.electron.ipcRenderer.on('insert-video-audio', handleInsertVideoAudio) window.electron.ipcRenderer.on('insert-video-audio', handleInsertVideoAudio)
window.electron.ipcRenderer.on('toggle-camera-insert', handleToggleCameraInsert) 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(() => { onBeforeUnmount(() => {
window.electron.ipcRenderer.off('insert-camera-video', handleInsertCameraVideo) window.electron.ipcRenderer.off('insert-camera-video', handleInsertCameraVideo)
window.electron.ipcRenderer.off('insert-video-audio', handleInsertVideoAudio) window.electron.ipcRenderer.off('insert-video-audio', handleInsertVideoAudio)
window.electron.ipcRenderer.off('toggle-camera-insert', handleToggleCameraInsert) window.electron.ipcRenderer.off('toggle-camera-insert', handleToggleCameraInsert)
// 清理麦克风流 window.electron.ipcRenderer.off('force-cleanup-media', handleForceCleanup)
if (micStream) {
try { micStream.getTracks().forEach((t) => t.stop()) } catch (e) { } // 清理文件展示相关事件
micStream = null window.electron.ipcRenderer.off('show-files-display', handleShowFilesDisplay)
} window.electron.ipcRenderer.off('remove-file-display', handleRemoveFileDisplay)
if (micAudio.value) { window.electron.ipcRenderer.off('clear-all-files-display', handleClearAllFilesDisplay)
try { micAudio.value.pause() } catch (e) { }
micAudio.value.srcObject = null // 清理媒体资源
} forceCleanupMedia()
}) })
function startLive() { function startLive() {
@@ -76,8 +193,11 @@ function startLive() {
console.log(paramsUserId) console.log(paramsUserId)
if (paramsUserId) { if (paramsUserId) {
userId.value = paramsUserId userId.value = paramsUserId
console.log(userId.value)
// 直播数字人 正式地址
liveUrl.value = `https://dmdemo.hx.cn/dashboard.html?userId=${userId.value}`; liveUrl.value = `https://dmdemo.hx.cn/dashboard.html?userId=${userId.value}`;
// 测试地址
// liveUrl.value = "https://www.baidu.com/"
} }
} }
@@ -205,6 +325,67 @@ async function startCamera() {
} catch (error) { } catch (error) {
console.error('Error accessing camera:', 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> }</script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -251,5 +432,126 @@ async function startCamera() {
height: 100vh; height: 100vh;
object-fit: cover; object-fit: cover;
} }
/* 文件悬浮展示样式 */
.files-display-overlay {
position: absolute;
top: 20px;
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;
}
} }
</style> </style>

4
test-files.txt Normal file
View File

@@ -0,0 +1,4 @@
这是一个测试文件
用于验证文件拖拽功能
内容Hello World!
时间2025-11-16

View File

@@ -1,8 +1,19 @@
{ {
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": { "compilerOptions": {
"composite": true, "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/**/*"]
} }

View File

@@ -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": { "compilerOptions": {
"composite": true, "composite": true,
"baseUrl": ".", "baseUrl": ".",
@@ -14,6 +7,27 @@
"src/renderer/src/*" "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"
]
} }