flat: 暂存
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
*.log*
|
||||
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
shamefully-hoist=true
|
||||
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
39
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Main Process",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
},
|
||||
"runtimeArgs": ["--sourcemap"],
|
||||
"env": {
|
||||
"REMOTE_DEBUGGING_PORT": "9222"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Renderer Process",
|
||||
"port": 9222,
|
||||
"request": "attach",
|
||||
"type": "chrome",
|
||||
"webRoot": "${workspaceFolder}/src/renderer",
|
||||
"timeout": 60000,
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug All",
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
76
README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
<h1 align="center">electron-app</h1>
|
||||
|
||||
<p align="center">An Electron application with Vue3 and TypeScript</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/electron" alt="electron-version">
|
||||
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/electron-vite" alt="electron-vite-version" />
|
||||
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/electron-builder" alt="electron-builder-version" />
|
||||
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/vite" alt="vite-version" />
|
||||
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/vue" alt="vue-version" />
|
||||
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/typescript" alt="typescript-version" />
|
||||
</p>
|
||||
|
||||
<p align='center'>
|
||||
<img src='./build/electron-vite-vue-ts.png'/>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- 💡 Optimize asset handling
|
||||
- 🚀 Fast HMR for renderer processes
|
||||
- 🔥 Hot reloading for main process and preload scripts
|
||||
- 🔌 Easy to debug
|
||||
- 🔒 Compile to v8 bytecode to protect source code
|
||||
|
||||
## Getting Started
|
||||
|
||||
Read [documentation](https://electron-vite.org/) for more details.
|
||||
|
||||
- [Configuring](https://electron-vite.org/config/)
|
||||
- [Development](https://electron-vite.org/guide/dev.html)
|
||||
- [Asset Handling](https://electron-vite.org/guide/assets.html)
|
||||
- [HMR](https://electron-vite.org/guide/hmr.html) & [Hot Reloading](https://electron-vite.org/guide/hot-reloading.html)
|
||||
- [Debugging](https://electron-vite.org/guide/debugging.html)
|
||||
- [Source code protection](https://electron-vite.org/guide/source-code-protection.html)
|
||||
- [Distribution](https://electron-vite.org/guide/distribution.html)
|
||||
- [Troubleshooting](https://electron-vite.org/guide/troubleshooting.html)
|
||||
|
||||
You can also use the [create-electron](https://github.com/alex8088/quick-start/tree/master/packages/create-electron) tool to scaffold your project for other frameworks (e.g. `React`, `Svelte` or `Solid`).
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin)
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
$ npm run build:win
|
||||
|
||||
# For macOS
|
||||
$ npm run build:mac
|
||||
|
||||
# For Linux
|
||||
$ npm run build:linux
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- [electron-vite-bytecode-example](https://github.com/alex8088/electron-vite-bytecode-example), source code protection
|
||||
- [electron-vite-decorator-example](https://github.com/alex8088/electron-vite-decorator-example), typescipt decorator
|
||||
- [electron-vite-worker-example](https://github.com/alex8088/electron-vite-worker-example), worker and fork
|
||||
BIN
build/electron-vite-vue-ts.png
Normal file
|
After Width: | Height: | Size: 611 KiB |
12
build/entitlements.mac.plist
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
build/icon.icns
Normal file
BIN
build/icon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
build/icon.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
37
electron.vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { resolve } from "path";
|
||||
import {
|
||||
defineConfig,
|
||||
externalizeDepsPlugin,
|
||||
bytecodePlugin,
|
||||
} from "electron-vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin(), bytecodePlugin()],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin(), bytecodePlugin()],
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@renderer": resolve("src/renderer/src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 51730,
|
||||
fs: {
|
||||
strict: true,
|
||||
},
|
||||
// "proxy": {
|
||||
// "/api": {
|
||||
// target: "http://localhost:3000",
|
||||
// changeOrigin: true,
|
||||
// rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
// },
|
||||
// },
|
||||
},
|
||||
plugins: [vue()],
|
||||
},
|
||||
});
|
||||
7288
package-lock.json
generated
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "electron-app",
|
||||
"version": "1.0.0",
|
||||
"description": "An Electron application with Vue and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
"homepage": "https://electron-vite.org",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"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",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"sass": "^1.93.2",
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-router": "^4.6.3",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config": "^1.0.2",
|
||||
"@electron-toolkit/eslint-config-ts": "^1.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@rushstack/eslint-patch": "^1.7.1",
|
||||
"@types/electron": "^1.4.38",
|
||||
"@types/node": "^18.19.9",
|
||||
"@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-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",
|
||||
"vite": "^5.0.12",
|
||||
"vue": "^3.4.15",
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
BIN
resources/icon.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
resources/icon.png.copy
Normal file
|
After Width: | Height: | Size: 35 KiB |
4
src/main/config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { join } from "path";
|
||||
export const preload = join(__dirname, "../preload/index.js");
|
||||
export const indexHtml = join(__dirname, "../renderer/index.html");
|
||||
export const ELECTRON_RENDERER_URL = process.env["ELECTRON_RENDERER_URL"];
|
||||
92
src/main/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建主窗口
|
||||
*/
|
||||
function createWindow(): void {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1080,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === "linux" ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.on("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// mainWindow.webContents.openDevTools();
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
if (is.dev && ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(ELECTRON_RENDERER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(indexHtml);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备好后
|
||||
*/
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId("com.electron");
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// 直播相关处理
|
||||
setupLiveHandlers();
|
||||
|
||||
setupPromptHandlers();
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on("activate", function () {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 退出应用
|
||||
*/
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
194
src/main/ipc/live.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { ipcMain, BrowserWindow } from "electron";
|
||||
import { preload, indexHtml, ELECTRON_RENDERER_URL } from "../config";
|
||||
import { showPrompt } from "../utils/tools";
|
||||
import { OllamaClient } from "../utils/ollama-client";
|
||||
let liveWindow: BrowserWindow | null = null;
|
||||
|
||||
const client = new OllamaClient({
|
||||
baseUrl: "http://127.0.0.1:11434", // 可选,默认值
|
||||
timeout: 30000, // 可选,默认30秒
|
||||
});
|
||||
|
||||
// 直播相关的主进程处理
|
||||
export function setupLiveHandlers() {
|
||||
let LiveSessionId = null;
|
||||
let isVideoInserted = false;
|
||||
|
||||
// 切换摄像头插入状态
|
||||
ipcMain.on("toggle-camera-insert", async () => {
|
||||
if (liveWindow) {
|
||||
isVideoInserted = !isVideoInserted;
|
||||
liveWindow.webContents.send(
|
||||
"toggle-camera-insert",
|
||||
isVideoInserted,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 插入摄像头视频
|
||||
ipcMain.on("insert-camera-video", async (_) => {
|
||||
if (liveWindow) {
|
||||
liveWindow.webContents.send("insert-camera-video");
|
||||
}
|
||||
});
|
||||
|
||||
// 插入音频
|
||||
ipcMain.on("insert-video-audio", async (_) => {
|
||||
if (liveWindow) {
|
||||
liveWindow.webContents.send("insert-video-audio");
|
||||
}
|
||||
});
|
||||
|
||||
// 开始直播
|
||||
ipcMain.handle("start-live", async (_) => {
|
||||
try {
|
||||
console.log("Starting live stream...");
|
||||
// TODO: 实现直播推流逻辑
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 结束直播
|
||||
ipcMain.handle("stop-live", async () => {
|
||||
try {
|
||||
// TODO: 实现结束直播逻辑
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("explain-position", async (_, content: string) => {
|
||||
try {
|
||||
let params = {
|
||||
sessionid: LiveSessionId,
|
||||
text: content,
|
||||
type: "echo",
|
||||
interrupt: true,
|
||||
};
|
||||
await sendMessage(params);
|
||||
// TODO: 实现结束直播逻辑
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 打开直播窗口
|
||||
ipcMain.handle("open-live-window", async (_, args) => {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}, 3000);
|
||||
} else {
|
||||
liveWindow.loadFile(indexHtml, { hash: `/${path}` });
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("ollama-test", async (_, jobInfo) => {
|
||||
try {
|
||||
const result = await client.generateText({
|
||||
model: "qwen:7b",
|
||||
prompt: `请根据提供的 json 数据:${jobInfo},直接生成一段用于吸引求职者投递的岗位介绍文案。文案需:
|
||||
1、简洁、有力,突出岗位核心价值和吸引力。
|
||||
2、不包含任何多余的开头、结尾、解释或废话。
|
||||
3、目标是立即抓住用户眼球并促使他们投递简历。
|
||||
4、不含任何废话或与岗位无关的内容
|
||||
**要求:**只输出生成的岗位介绍文案本身。`,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (error: any) {
|
||||
console.error("Ollama 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 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 };
|
||||
}
|
||||
}
|
||||
13
src/main/ipc/prompt.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ipcMain, dialog } from "electron";
|
||||
|
||||
export function setupPromptHandlers() {
|
||||
// 提示信息处理
|
||||
ipcMain.handle("show-prompt", async (event) => {
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
title: "提示",
|
||||
message: "这是一个提示信息",
|
||||
buttons: ["确定"],
|
||||
});
|
||||
});
|
||||
}
|
||||
271
src/main/utils/ollama-client.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { type ClientRequest, type IncomingMessage } from "http";
|
||||
import * as http from "http";
|
||||
|
||||
interface OllamaOptions {
|
||||
baseUrl?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface GenerateParams {
|
||||
model: string;
|
||||
prompt: string;
|
||||
system?: string;
|
||||
template?: string;
|
||||
context?: number[];
|
||||
stream?: boolean;
|
||||
format?: "json";
|
||||
options?: {
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
seed?: number;
|
||||
num_predict?: number;
|
||||
stop?: string[];
|
||||
num_ctx?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GenerateResponse {
|
||||
model: string;
|
||||
created_at: string;
|
||||
response: string;
|
||||
context: number[];
|
||||
done: boolean;
|
||||
total_duration: number;
|
||||
load_duration: number;
|
||||
prompt_eval_duration: number;
|
||||
eval_duration: number;
|
||||
prompt_eval_count: number;
|
||||
eval_count: number;
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
details: {
|
||||
format: string;
|
||||
family: string;
|
||||
families: string[];
|
||||
parameter_size: string;
|
||||
quantization_level: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ListModelsResponse {
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export class OllamaClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeout: number;
|
||||
|
||||
constructor(options: OllamaOptions = {}) {
|
||||
this.baseUrl = options.baseUrl || "http://localhost:11434";
|
||||
this.timeout = options.timeout || 30000;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
method = "GET",
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, this.baseUrl);
|
||||
const options = {
|
||||
method,
|
||||
hostname: url.hostname,
|
||||
port: url.port || "11434",
|
||||
path: url.pathname,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: this.timeout,
|
||||
};
|
||||
|
||||
const req: ClientRequest = http.request(
|
||||
options,
|
||||
(res: IncomingMessage) => {
|
||||
let data = "";
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
if (
|
||||
res.statusCode &&
|
||||
res.statusCode >= 200 &&
|
||||
res.statusCode < 300
|
||||
) {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(
|
||||
new Error("Failed to parse response data"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`HTTP Error: ${res.statusCode} ${res.statusMessage}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on("error", reject);
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async generate(params: GenerateParams): Promise<GenerateResponse> {
|
||||
const result = await this.request<GenerateResponse>(
|
||||
"/api/generate",
|
||||
"POST",
|
||||
{
|
||||
...params,
|
||||
stream: false,
|
||||
},
|
||||
);
|
||||
|
||||
// 如果响应是JSON格式,尝试解析
|
||||
if (params.format === "json" && result.response) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.response);
|
||||
result.response =
|
||||
parsed.content || parsed.text || result.response;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"Failed to parse JSON response, returning raw response",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文本响应,直接返回文本内容
|
||||
*/
|
||||
async generateText(
|
||||
params: Omit<GenerateParams, "format">,
|
||||
): Promise<string> {
|
||||
const result = await this.generate({
|
||||
...params,
|
||||
format: undefined, // 确保不使用 JSON 格式
|
||||
});
|
||||
return result.response;
|
||||
}
|
||||
|
||||
async streamGenerate(
|
||||
params: GenerateParams,
|
||||
onResponse: (response: GenerateResponse) => void,
|
||||
onError?: (error: Error) => void,
|
||||
): Promise<void> {
|
||||
const url = new URL("/api/generate", this.baseUrl);
|
||||
const options = {
|
||||
method: "POST",
|
||||
hostname: url.hostname,
|
||||
port: url.port || "11434",
|
||||
path: url.pathname,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const req = http.request(options, (res) => {
|
||||
let buffer = "";
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const response = JSON.parse(
|
||||
line,
|
||||
) as GenerateResponse;
|
||||
onResponse(response);
|
||||
} catch (e) {
|
||||
const error = new Error(
|
||||
"Failed to parse streaming response",
|
||||
);
|
||||
onError?.(error);
|
||||
console.error(error, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const response = JSON.parse(
|
||||
buffer,
|
||||
) as GenerateResponse;
|
||||
onResponse(response);
|
||||
} catch (e) {
|
||||
const error = new Error(
|
||||
"Failed to parse final streaming response",
|
||||
);
|
||||
onError?.(error);
|
||||
console.error(error, e);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
onError?.(error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.write(JSON.stringify({ ...params, stream: true }));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(): Promise<ListModelsResponse> {
|
||||
return this.request<ListModelsResponse>("/api/tags");
|
||||
}
|
||||
|
||||
async pullModel(modelName: string): Promise<void> {
|
||||
await this.request("/api/pull", "POST", { name: modelName });
|
||||
}
|
||||
|
||||
async deleteModel(modelName: string): Promise<void> {
|
||||
await this.request("/api/delete", "DELETE", { name: modelName });
|
||||
}
|
||||
|
||||
async copyModel(sourceModel: string, targetModel: string): Promise<void> {
|
||||
await this.request("/api/copy", "POST", {
|
||||
source: sourceModel,
|
||||
destination: targetModel,
|
||||
});
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
await this.request("/");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/main/utils/tools.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { dialog } from "electron";
|
||||
|
||||
export function showPrompt(
|
||||
content: string,
|
||||
type: "info" | "error" = "info",
|
||||
title: string = "提示",
|
||||
) {
|
||||
dialog.showMessageBox({
|
||||
type: type,
|
||||
title: title,
|
||||
message: content,
|
||||
buttons: ["确定"],
|
||||
});
|
||||
}
|
||||
8
src/preload/index.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: unknown
|
||||
}
|
||||
}
|
||||
22
src/preload/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { contextBridge } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
16
src/renderer/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>直播中控平台</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
9
src/renderer/src/App.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// import Versions from './components/Versions.vue'
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<!-- <Versions /> -->
|
||||
</template>
|
||||
45
src/renderer/src/api/home.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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 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 };
|
||||
}
|
||||
}
|
||||
33
src/renderer/src/api/jobs.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// 岗位接口类型定义
|
||||
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;
|
||||
});
|
||||
}
|
||||
67
src/renderer/src/assets/base.css
Normal file
@@ -0,0 +1,67 @@
|
||||
:root {
|
||||
--ev-c-white: #ffffff;
|
||||
--ev-c-white-soft: #f8f8f8;
|
||||
--ev-c-white-mute: #f2f2f2;
|
||||
|
||||
--ev-c-black: #1b1b1f;
|
||||
--ev-c-black-soft: #222222;
|
||||
--ev-c-black-mute: #282828;
|
||||
|
||||
--ev-c-gray-1: #515c67;
|
||||
--ev-c-gray-2: #414853;
|
||||
--ev-c-gray-3: #32363f;
|
||||
|
||||
--ev-c-text-1: rgba(255, 255, 245, 0.86);
|
||||
--ev-c-text-2: rgba(235, 235, 245, 0.6);
|
||||
--ev-c-text-3: rgba(235, 235, 245, 0.38);
|
||||
|
||||
--ev-button-alt-border: transparent;
|
||||
--ev-button-alt-text: var(--ev-c-text-1);
|
||||
--ev-button-alt-bg: var(--ev-c-gray-3);
|
||||
--ev-button-alt-hover-border: transparent;
|
||||
--ev-button-alt-hover-text: var(--ev-c-text-1);
|
||||
--ev-button-alt-hover-bg: var(--ev-c-gray-2);
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-background: var(--ev-c-black);
|
||||
--color-background-soft: var(--ev-c-black-soft);
|
||||
--color-background-mute: var(--ev-c-black-mute);
|
||||
|
||||
--color-text: var(--ev-c-text-1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
10
src/renderer/src/assets/electron.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="64" cy="64" r="64" fill="#2F3242"/>
|
||||
<ellipse cx="63.9835" cy="23.2036" rx="4.48794" ry="4.495" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.6153 43.5751L30.1748 44.4741L30.1748 44.4741L28.6153 43.5751ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM53.7489 81.7014L52.8478 83.2597L53.7489 81.7014ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM99.3169 43.6354L97.7574 44.5344L99.3169 43.6354ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM53.7836 46.3728L54.6847 47.931L53.7836 46.3728ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM84.3832 64.0673H82.5832H84.3832ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077V84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027V89.4027V89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.8501 68.0857C62.6341 68.5652 60.451 67.1547 59.9713 64.9353C59.4934 62.7159 60.9007 60.5293 63.1167 60.0489C65.3326 59.5693 67.5157 60.9798 67.9954 63.1992C68.4742 65.4186 67.066 67.6052 64.8501 68.0857Z" fill="#A2ECFB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
10
src/renderer/src/assets/main.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@import './reset.css';
|
||||
@import './base.css';
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
48
src/renderer/src/assets/reset.css
Normal file
@@ -0,0 +1,48 @@
|
||||
/* http://meyerweb.com/eric/tools/css/reset/
|
||||
v2.0 | 20110126
|
||||
License: none (public domain)
|
||||
*/
|
||||
|
||||
html, body, div, span, applet, object, iframe,
|
||||
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||
a, abbr, acronym, address, big, cite, code,
|
||||
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||
small, strike, strong, sub, sup, tt, var,
|
||||
b, u, i, center,
|
||||
dl, dt, dd, ol, ul, li,
|
||||
fieldset, form, label, legend,
|
||||
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||
article, aside, canvas, details, embed,
|
||||
figure, figcaption, footer, header, hgroup,
|
||||
menu, nav, output, ruby, section, summary,
|
||||
time, mark, audio, video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
font: inherit;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
/* HTML5 display-role reset for older browsers */
|
||||
article, aside, details, figcaption, figure,
|
||||
footer, header, hgroup, menu, nav, section {
|
||||
display: block;
|
||||
}
|
||||
body {
|
||||
line-height: 1;
|
||||
}
|
||||
ol, ul {
|
||||
list-style: none;
|
||||
}
|
||||
blockquote, q {
|
||||
quotes: none;
|
||||
}
|
||||
blockquote:before, blockquote:after,
|
||||
q:before, q:after {
|
||||
content: '';
|
||||
content: none;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
25
src/renderer/src/assets/wavy-lines.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1422 800" opacity="0.3">
|
||||
<defs>
|
||||
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="oooscillate-grad">
|
||||
<stop stop-color="hsl(206, 75%, 49%)" stop-opacity="1" offset="0%"></stop>
|
||||
<stop stop-color="hsl(331, 90%, 56%)" stop-opacity="1" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g stroke-width="1" stroke="url(#oooscillate-grad)" fill="none" stroke-linecap="round">
|
||||
<path d="M 0 448 Q 355.5 -100 711 400 Q 1066.5 900 1422 448" opacity="0.05"></path>
|
||||
<path d="M 0 420 Q 355.5 -100 711 400 Q 1066.5 900 1422 420" opacity="0.11"></path>
|
||||
<path d="M 0 392 Q 355.5 -100 711 400 Q 1066.5 900 1422 392" opacity="0.18"></path>
|
||||
<path d="M 0 364 Q 355.5 -100 711 400 Q 1066.5 900 1422 364" opacity="0.24"></path>
|
||||
<path d="M 0 336 Q 355.5 -100 711 400 Q 1066.5 900 1422 336" opacity="0.30"></path>
|
||||
<path d="M 0 308 Q 355.5 -100 711 400 Q 1066.5 900 1422 308" opacity="0.37"></path>
|
||||
<path d="M 0 280 Q 355.5 -100 711 400 Q 1066.5 900 1422 280" opacity="0.43"></path>
|
||||
<path d="M 0 252 Q 355.5 -100 711 400 Q 1066.5 900 1422 252" opacity="0.49"></path>
|
||||
<path d="M 0 224 Q 355.5 -100 711 400 Q 1066.5 900 1422 224" opacity="0.56"></path>
|
||||
<path d="M 0 196 Q 355.5 -100 711 400 Q 1066.5 900 1422 196" opacity="0.62"></path>
|
||||
<path d="M 0 168 Q 355.5 -100 711 400 Q 1066.5 900 1422 168" opacity="0.68"></path>
|
||||
<path d="M 0 140 Q 355.5 -100 711 400 Q 1066.5 900 1422 140" opacity="0.75"></path>
|
||||
<path d="M 0 112 Q 355.5 -100 711 400 Q 1066.5 900 1422 112" opacity="0.81"></path>
|
||||
<path d="M 0 84 Q 355.5 -100 711 400 Q 1066.5 900 1422 84" opacity="0.87"></path>
|
||||
<path d="M 0 56 Q 355.5 -100 711 400 Q 1066.5 900 1422 56" opacity="0.94"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
13
src/renderer/src/components/Versions.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
9
src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference path="../../preload/index.d.ts" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
0
src/renderer/src/index.css
Normal file
12
src/renderer/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import "./assets/main.css";
|
||||
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import * as Pinia from "pinia";
|
||||
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
|
||||
|
||||
const pinia = Pinia.createPinia();
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
|
||||
createApp(App).use(router).use(pinia).mount("#app");
|
||||
30
src/renderer/src/router/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { useUserStore } from "../stores/useUserStore";
|
||||
import routes from "./routes.js";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
//全局前置守卫
|
||||
router.beforeEach((to) => {
|
||||
// showLoadingToast({message: t('Loading'), forbidClick: true, duration: 0})
|
||||
const userStore = useUserStore();
|
||||
if (to.meta.no_login) {
|
||||
// 无需登录的页面直接进入
|
||||
return true;
|
||||
} else if (!userStore.token) {
|
||||
// 未登录或登录失效
|
||||
return { path: "/login", replace: true };
|
||||
} else {
|
||||
// 已经登陆
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach(() => {
|
||||
// closeToast()
|
||||
});
|
||||
|
||||
export default router;
|
||||
25
src/renderer/src/router/routes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
component: () => import("../views/Login/index.vue"),
|
||||
meta: { no_login: true },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "Home",
|
||||
component: () => import("../views/Home/index.vue"),
|
||||
meta: { no_login: false },
|
||||
},
|
||||
{
|
||||
path: "/live",
|
||||
name: "Live",
|
||||
component: () => import("../views/Live/index.vue"),
|
||||
meta: { no_login: true },
|
||||
},
|
||||
// Add more routes as needed
|
||||
];
|
||||
|
||||
export default routes;
|
||||
249
src/renderer/src/stores/useLiveStore.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
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;
|
||||
// TODO: 这里添加讲解岗位的具体实现
|
||||
// 可以调用 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: 692, 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
|
||||
) {
|
||||
console.log("Starting next position from positions watch");
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
31
src/renderer/src/stores/useUserStore.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { User } from "@renderer/types/user";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useUserStore = defineStore(
|
||||
"user",
|
||||
() => {
|
||||
const user = ref<User | null>(null);
|
||||
const token = ref<string>("");
|
||||
|
||||
function setUserInfo(userInfo: User) {
|
||||
user.value = userInfo;
|
||||
}
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken;
|
||||
// 通知主进程保存 token
|
||||
// window.electron.ipcRenderer.send("set-token", newToken);
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
setUserInfo,
|
||||
setToken,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
},
|
||||
);
|
||||
13
src/renderer/src/types/user.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface User {
|
||||
id?: string;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
roles?: string[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type NullableUser = User | null;
|
||||
320
src/renderer/src/views/Home/index.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div class="left-col">
|
||||
<section class="panel panel-list">
|
||||
<header class="panel-header">直播讲解岗位顺序</header>
|
||||
<div class="drag-container">
|
||||
<div class="drag-item back-blue" v-if="currentPosition" @click="onListClicked(currentPosition)">
|
||||
<span class="drag-handle">⋮⋮</span>
|
||||
<span class="item-name">{{ currentPosition.jobTitle }}</span>
|
||||
</div>
|
||||
<draggable v-model="positions" group="positions" :animation="200" item-key="id"
|
||||
class="draggable-list" @change="onListChange" ghost-class="ghost-class"
|
||||
chosen-class="chosen-class" drag-class="drag-class">
|
||||
<template #item="{ element }">
|
||||
<div class="drag-item" @click="onListClicked(element)">
|
||||
<span class="drag-handle">⋮⋮</span>
|
||||
<span class="item-name">{{ element.jobTitle }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel-details" v-if="seePosition">
|
||||
<header class="panel-header"> 岗位详情</header>
|
||||
<div class="details-body">
|
||||
<p class="empty-message">{{ seePosition.msg }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="right-col">
|
||||
<section class="panel-details">
|
||||
<header class="panel-header">讲解中:</header>
|
||||
<div class="details-body">
|
||||
<p class="empty-message" v-if="currentPosition">{{ currentPosition.msg }}</p>
|
||||
<p class="empty-message" v-else>加载数据...</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel panel-controls">
|
||||
<header class="panel-header">直播控制</header>
|
||||
<div class="controls-row">
|
||||
<button @click="handleOpenLiveWindow">打开直播窗口</button>
|
||||
<button :class="{ 'primary': !isLiveOn }" @click="handleStartLive" :disabled="isLiveOn">
|
||||
开始直播
|
||||
</button>
|
||||
<button :class="{ 'primary': isLiveOn }" @click="handleStopLive" :disabled="!isLiveOn">
|
||||
关闭直播
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="panel-header small">内容控制</header>
|
||||
<div class="controls-row">
|
||||
<button @click="toggleCameraInsert">全屏插播</button>
|
||||
<button @click="insertCameraVideo">窗口插播</button>
|
||||
<button @click="insertVideoAudio">音频插入</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import { useUserStore } from '@renderer/stores/useUserStore';
|
||||
import { useLiveStore } from '@renderer/stores/useLiveStore'
|
||||
const { positions, currentPosition, seePosition, isLiveOn } = storeToRefs(useLiveStore())
|
||||
const broadcastOrder = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
init();
|
||||
|
||||
})
|
||||
|
||||
async function init() {
|
||||
useLiveStore().fetchPositions()
|
||||
}
|
||||
|
||||
function onListChange(event) {
|
||||
console.log('Drag event:', event);
|
||||
if (event.moved) {
|
||||
const { oldIndex, newIndex } = event.moved;
|
||||
console.log(`Item moved from position ${oldIndex} to ${newIndex}`);
|
||||
}
|
||||
console.log('New order:', broadcastOrder.value);
|
||||
}
|
||||
|
||||
function onListClicked(element) {
|
||||
console.log(element)
|
||||
useLiveStore().setCurrentPosition(element)
|
||||
}
|
||||
|
||||
// 直播控制
|
||||
const handleOpenLiveWindow = async () => {
|
||||
try {
|
||||
await useLiveStore().openLiveWindow()
|
||||
} catch (error) {
|
||||
console.error('打开直播窗口失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartLive = async () => {
|
||||
try {
|
||||
await useLiveStore().startLive()
|
||||
} catch (error) {
|
||||
console.error('开始直播失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopLive = async () => {
|
||||
try {
|
||||
await useLiveStore().stopLive()
|
||||
} catch (error) {
|
||||
console.error('结束直播失败:', error)
|
||||
}
|
||||
}
|
||||
function toggleCameraInsert() {
|
||||
window.electron.ipcRenderer.send('toggle-camera-insert')
|
||||
}
|
||||
function insertCameraVideo() {
|
||||
window.electron.ipcRenderer.send('insert-camera-video')
|
||||
}
|
||||
function insertVideoAudio() {
|
||||
window.electron.ipcRenderer.send('insert-video-audio')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--bg);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.left-col {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
background-color: var(--card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
// border-radius: 8px;
|
||||
// box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1),
|
||||
// 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
// background-color: var(--card);
|
||||
}
|
||||
|
||||
.drag-container {
|
||||
min-height: 200px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drag-container {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.draggable-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.drag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #999;
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drag-item:hover {
|
||||
background: #f8f9fa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.ghost-class {
|
||||
opacity: 0.5;
|
||||
background: #f1f5f9;
|
||||
border: 2px dashed #94a3b8;
|
||||
}
|
||||
|
||||
.chosen-class {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.drag-class {
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--muted);
|
||||
}
|
||||
|
||||
.panel-details {
|
||||
min-height: 200px;
|
||||
max-height: 500px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.details-body {
|
||||
flex: 1;
|
||||
padding: 10px 16px 16px 16px;
|
||||
overflow-y: auto;
|
||||
|
||||
.empty-message {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-col {
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background-color: var(--card);
|
||||
|
||||
.panel-controls {
|
||||
padding: 16px;
|
||||
background-color: var(--card);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--accent);
|
||||
color: #333;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header.small {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.back-blue {
|
||||
background-color: #94a3b8 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
257
src/renderer/src/views/Live/index.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div class="livePage">
|
||||
<!-- 主画面 -->
|
||||
<iframe ref="liveIframe" :src="liveUrl" frameborder="0" allowfullscreen class="main-iframe"></iframe>
|
||||
<!-- 插播画面 -->
|
||||
<video ref="cameraVideo" :class="{ 'camera-video': true, 'full-screen': isfullScreen }" autoplay></video>
|
||||
<!-- 插播声音 -->
|
||||
<audio :src="soundUrl" class="sound" ref="welcome" @ended="welcomeEnd"></audio>
|
||||
<!-- 隐藏的麦克风播放元素,用于放大麦克风声音 -->
|
||||
<audio ref="micAudio" autoplay style="display:none"></audio>
|
||||
</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 userId = ref(null);
|
||||
const liveUrl = ref("");
|
||||
const soundUrl = ref("https://dmdemo.hx.cn/sound/welcome.mp3");
|
||||
const welcome = ref()
|
||||
const live = ref()
|
||||
let cameraStream = null // 用于存储摄像头媒体流
|
||||
let wasWelcomePlaying = false
|
||||
const cameraVideo = ref()
|
||||
const liveIframe = ref(null) // 对应模板中的 iframe
|
||||
const micAudio = ref(null) // 隐藏的麦克风播放元素
|
||||
let micStream = null
|
||||
let prevIframeVideoVolume = 1
|
||||
let micPlaying = false
|
||||
|
||||
|
||||
function welcomeEnd() {
|
||||
welcome.value.autoplay = false
|
||||
// 尝试恢复主视频(iframe 内的视频)音量到 1
|
||||
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) {
|
||||
// 可能跨域,忽略
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
function startLive() {
|
||||
// 从 URL 获取 userId
|
||||
const paramsUserId = route.query.userId;
|
||||
if (paramsUserId) {
|
||||
userId.value = paramsUserId
|
||||
liveUrl.value = `https://dmdemo.hx.cn/dashboard.html?userId=${userId.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInsertCameraVideo() {
|
||||
if (isCameraActive.value) {
|
||||
// 停止摄像头
|
||||
stopCamera()
|
||||
} else {
|
||||
// 启动摄像头
|
||||
await startCamera()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleCameraInsert() {
|
||||
if (isCameraActive.value) {
|
||||
// 停止摄像头
|
||||
stopCamera()
|
||||
if (wasWelcomePlaying) {
|
||||
welcome.value.play()
|
||||
}
|
||||
isfullScreen.value = false
|
||||
} else {
|
||||
// 暂停原视频
|
||||
// 暂停欢迎音频
|
||||
if (!welcome.value.paused) {
|
||||
welcome.value.pause()
|
||||
wasWelcomePlaying = true
|
||||
} else {
|
||||
wasWelcomePlaying = false
|
||||
}
|
||||
isfullScreen.value = true
|
||||
// 启动摄像头
|
||||
await startCamera()
|
||||
}
|
||||
}
|
||||
|
||||
// 插入/切换视频音频:把 iframe 内视频声音放小,并放大麦克风声音
|
||||
async function handleInsertVideoAudio(param = {}) {
|
||||
// param 可选字段:{ videoVolume: number (0-1), micVolume: number (0-2), restore: boolean }
|
||||
const videoVolume = typeof param.videoVolume === 'number' ? param.videoVolume : 0.2
|
||||
const micVolume = typeof param.micVolume === 'number' ? param.micVolume : 1.0
|
||||
const restore = !!param.restore
|
||||
|
||||
// 如果请求恢复或已在播放麦克风,则恢复原始音量并停止麦克风
|
||||
if (restore || micPlaying) {
|
||||
try {
|
||||
const iframeEl = liveIframe.value
|
||||
if (iframeEl && iframeEl.contentWindow) {
|
||||
const v = iframeEl.contentWindow.document.querySelector('video')
|
||||
if (v && typeof v.volume === 'number') {
|
||||
v.volume = prevIframeVideoVolume ?? 1
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('无法访问 iframe 内的视频(可能是跨域),无法恢复音量')
|
||||
}
|
||||
|
||||
// 停止麦克风流
|
||||
if (micStream) {
|
||||
micStream.getTracks().forEach(t => t.stop())
|
||||
micStream = null
|
||||
}
|
||||
if (micAudio.value) {
|
||||
try { micAudio.value.pause() } catch (e) { }
|
||||
micAudio.value.srcObject = null
|
||||
}
|
||||
micPlaying = false
|
||||
return
|
||||
}
|
||||
|
||||
// 降低 iframe 内视频音量(尝试)
|
||||
try {
|
||||
const iframeEl = liveIframe.value
|
||||
if (iframeEl && iframeEl.contentWindow) {
|
||||
const v = iframeEl.contentWindow.document.querySelector('video')
|
||||
if (v && typeof v.volume === 'number') {
|
||||
prevIframeVideoVolume = v.volume
|
||||
v.volume = videoVolume
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('无法访问 iframe 内的视频(可能是跨域),无法调整其音量')
|
||||
}
|
||||
|
||||
// 获取并播放麦克风流(如果已经有 cameraStream 的音频轨道则复用)
|
||||
try {
|
||||
if (cameraStream && cameraStream.getAudioTracks && cameraStream.getAudioTracks().length > 0) {
|
||||
// 如果摄像头流已经包含音频(少见),直接用 cameraVideo 元素来放大声音
|
||||
if (cameraVideo.value) {
|
||||
cameraVideo.value.volume = micVolume
|
||||
micPlaying = true
|
||||
}
|
||||
} else {
|
||||
// 请求麦克风权限并播放到隐藏的 audio 元素
|
||||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
if (micAudio.value) {
|
||||
micAudio.value.srcObject = micStream
|
||||
micAudio.value.volume = micVolume
|
||||
try { await micAudio.value.play() } catch (e) { console.warn('micAudio play failed', e) }
|
||||
micPlaying = true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取麦克风失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 停止摄像头
|
||||
function stopCamera() {
|
||||
if (cameraStream) {
|
||||
cameraStream.getTracks().forEach(track => track.stop())
|
||||
cameraStream = null
|
||||
}
|
||||
if (cameraVideo.value) {
|
||||
cameraVideo.value.srcObject = null
|
||||
}
|
||||
isCameraActive.value = false
|
||||
}
|
||||
|
||||
// 启动摄像头
|
||||
async function startCamera() {
|
||||
try {
|
||||
cameraStream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
cameraVideo.value.srcObject = cameraStream
|
||||
isCameraActive.value = true
|
||||
} catch (error) {
|
||||
console.error('Error accessing camera:', error)
|
||||
}
|
||||
}</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.livePage {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.sound {
|
||||
display: none;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
// object-fit: cover;
|
||||
}
|
||||
|
||||
.main-iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
// border: 2px solid #fff;
|
||||
z-index: 100;
|
||||
/* object-fit: cover; */
|
||||
}
|
||||
|
||||
.full-screen {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
145
src/renderer/src/views/Login/index.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1>欢迎登录直播中控平台</h1>
|
||||
<p class="subtitle">请登录以继续</p>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<input id="username" v-model="username" type="text" placeholder="Username" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input id="password" v-model="password" type="password" placeholder="Password" required />
|
||||
</div>
|
||||
<button type="submit" :disabled="!username || !password"
|
||||
:class="{ 'btn-active': username && password }">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useUserStore } from '@renderer/stores/useUserStore';
|
||||
import router from '@renderer/router';
|
||||
const userStore = useUserStore();
|
||||
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const handleLogin = () => {
|
||||
let userInfo = {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
userId: 'rs876543'
|
||||
};
|
||||
if (username.value === 'admin' && password.value === '123456') {
|
||||
userStore.setUserInfo(userInfo);
|
||||
userStore.setToken('token_abcdefg1234567');
|
||||
router.push('/home');
|
||||
} else {
|
||||
alert('用户名或密码错误!');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #764ba2;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
button.btn-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
8
tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
}
|
||||
18
tsconfig.web.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.vue",
|
||||
"src/preload/*.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": [
|
||||
"src/renderer/src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||