Initial clean project import
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml, yaml}]
|
||||
indent_size = 4
|
||||
|
||||
[*.{less, css}]
|
||||
indent_size = 4
|
||||
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
build-log.txt
|
||||
build-output.txt
|
||||
nsis-build.txt
|
||||
typecheck-current.txt
|
||||
temp_screenshot.png
|
||||
error_dialog.png
|
||||
error_dialog2.png
|
||||
tmp_*.bin
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dist-electron
|
||||
dist-release
|
||||
dist-release-final
|
||||
dist-release-build
|
||||
dist-release-new
|
||||
chromium-mac.zip
|
||||
新建文件夹/
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/.debug.env
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# lockfile
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
database.db
|
||||
|
||||
src/lang/source-use.json
|
||||
/data
|
||||
/_delete
|
||||
.vscode
|
||||
|
||||
/share-binary/
|
||||
/electron/resources/extra/linux-arm64/
|
||||
/electron/resources/extra/linux-x86/
|
||||
/electron/resources/extra/osx-arm64/
|
||||
/electron/resources/extra/osx-x86/
|
||||
/electron/resources/extra/win-x86/
|
||||
/electron/resources/extra/osx/
|
||||
/electron/resources/extra/common/playwright/chromium-1200/chrome-mac/
|
||||
nul
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.py.backup
|
||||
|
||||
# Temporary fix scripts
|
||||
fix_*.py
|
||||
fix_*.js
|
||||
fix_*.mjs
|
||||
fix_*.cjs
|
||||
|
||||
# Temporary text files
|
||||
*.txt.bak
|
||||
修改端口说明.txt
|
||||
字幕自动.txt
|
||||
情感分析字幕.txt
|
||||
|
||||
# Video downloader tool
|
||||
video-downloader-app/
|
||||
video-downloader.js
|
||||
视频下载工具.exe
|
||||
check_*.py
|
||||
test_douyin*
|
||||
test_login.py
|
||||
fix_server.py
|
||||
|
||||
# Clean repository: generated outputs and bundled runtimes
|
||||
build/
|
||||
output/
|
||||
tmp/
|
||||
resources-bundles/
|
||||
/electron/resources/extra/win-x86-backup/
|
||||
/electron/resources/extra/common/playwright/
|
||||
/electron/resources/extra/common/python/
|
||||
/electron/resources/extra/common/fonts/
|
||||
/electron/resources/extra/common/models/
|
||||
/electron/resources/extra/common/bgm/
|
||||
/electron/resources/extra/common/chromium/
|
||||
/electron/resources/extra/common/soundEffects/
|
||||
|
||||
# Clean repository: media/content packs are not source code
|
||||
*.mp3
|
||||
*.mp4
|
||||
*.wav
|
||||
*.ttf
|
||||
*.ttc
|
||||
*.otf
|
||||
*.gif
|
||||
*.zip
|
||||
*.backup
|
||||
*.backup.*
|
||||
*.backup_*
|
||||
*.backup*
|
||||
*_backup
|
||||
*_backup*
|
||||
*.before_fix
|
||||
*.broken*
|
||||
*.broken
|
||||
*.fixed
|
||||
*.fixed*
|
||||
*.original
|
||||
*.original*
|
||||
*.restored
|
||||
*.restore
|
||||
*.tmp
|
||||
tmpclaude-*
|
||||
temp_fix.txt
|
||||
active-run.json
|
||||
*.db
|
||||
activation-server/*.db
|
||||
user-server/*.db
|
||||
user-server/_archive_old_versions/
|
||||
fonts/
|
||||
bgm/
|
||||
ziti/
|
||||
resources/
|
||||
resources-bundles/
|
||||
tools/**/runs/
|
||||
electron/resources/voice-preview-cache/
|
||||
@@ -0,0 +1,7 @@
|
||||
# For electron-builder
|
||||
# https://github.com/electron-userland/electron-builder/issues/6289#issuecomment-1042620422
|
||||
shamefully-hoist=true
|
||||
|
||||
# For China 🇨🇳 developers
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
@@ -0,0 +1,27 @@
|
||||
# 干净独立工作目录
|
||||
|
||||
这是从原目录整理出的独立副本,后续开发、启动和构建请在本目录执行。
|
||||
|
||||
## 启动
|
||||
|
||||
```bat
|
||||
start-local.bat
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bat
|
||||
npm.cmd run start:local
|
||||
```
|
||||
|
||||
## 自检
|
||||
|
||||
```bat
|
||||
npm.cmd run start:check
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
- 默认开发端口已改为 `3354`,避免和原目录的 `3344` 服务冲突。
|
||||
- 已排除旧目录中的构建产物、日志、缓存、用户数据和 Git 信息。
|
||||
- `node_modules` 是实拷贝,不是软链接;同时排除了旧 Electron 目录中误混入的巨大运行数据。
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "activation-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Local activation code server for development",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"keywords": ["activation", "license"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"cors": "^2.8.5",
|
||||
"sqlite3": "^5.1.6",
|
||||
"body-parser": "^1.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>激活码管理后台</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #2c3e50; color: white; padding: 20px; border-radius: 4px; margin-bottom: 20px; }
|
||||
.header h1 { font-size: 24px; margin-bottom: 10px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 20px; }
|
||||
.stat { background: white; padding: 15px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: #2c3e50; }
|
||||
.stat-label { font-size: 12px; color: #666; margin-top: 5px; }
|
||||
.section { background: white; padding: 20px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 20px; }
|
||||
.section h2 { font-size: 18px; margin-bottom: 15px; border-bottom: 2px solid #2c3e50; padding-bottom: 10px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px; }
|
||||
.form-group input, .form-group select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||
.form-group button { background: #3498db; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
|
||||
.form-group button:hover { background: #2980b9; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 15px; font-size: 13px; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||
th { background: #f5f5f5; font-weight: bold; }
|
||||
tr:hover { background: #f9f9f9; }
|
||||
.badge { padding: 4px 8px; border-radius: 3px; font-size: 12px; font-weight: bold; display: inline-block; }
|
||||
.badge-active { background: #d4edda; color: #155724; }
|
||||
.badge-inactive { background: #f8d7da; color: #721c24; }
|
||||
.badge-used { background: #cfe2ff; color: #084298; }
|
||||
.badge-unused { background: #e2e3e5; color: #383d41; }
|
||||
.action-btn { padding: 4px 8px; margin: 0 2px; font-size: 12px; cursor: pointer; border: none; border-radius: 3px; color: white; font-weight: bold; }
|
||||
.btn-disable { background: #ff7675; }
|
||||
.btn-enable { background: #00b894; }
|
||||
.btn-delete { background: #d63031; }
|
||||
.btn-copy { background: #0984e3; }
|
||||
.form-inline { display: grid; grid-template-columns: 1fr 100px 100px 100px 100px; gap: 10px; align-items: flex-end; }
|
||||
.message { padding: 12px; border-radius: 4px; margin-bottom: 15px; display: none; font-weight: bold; }
|
||||
.message.show { display: block; }
|
||||
.message.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.message.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔐 激活码管理后台 (开发版)</h1>
|
||||
<p>本地激活服务器 - http://localhost:3001</p>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats">
|
||||
<div class="stat"><div class="stat-value">0</div><div class="stat-label">总激活码数</div></div>
|
||||
<div class="stat"><div class="stat-value">0</div><div class="stat-label">未使用</div></div>
|
||||
<div class="stat"><div class="stat-value">0</div><div class="stat-label">已激活</div></div>
|
||||
<div class="stat"><div class="stat-value">0</div><div class="stat-label">已停用</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>生成激活码</h2>
|
||||
<div id="message" class="message"></div>
|
||||
<div class="form-inline">
|
||||
<div>
|
||||
<label>过期类型:</label>
|
||||
<select id="type" style="width: 100%;">
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
<option value="day" selected>天</option>
|
||||
<option value="month">月</option>
|
||||
<option value="year">年</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="grid-column: 2">
|
||||
<label>数量:</label>
|
||||
<input type="number" id="duration" value="1" min="1" style="width: 100%;">
|
||||
</div>
|
||||
<button onclick="generateCode()" style="width: 100%; padding: 8px; grid-column: 3">生成</button>
|
||||
<button onclick="refreshList()" style="width: 100%; padding: 8px; background: #95a5a6; grid-column: 4">刷新</button>
|
||||
<button onclick="location.reload()" style="width: 100%; padding: 8px; background: #7f8c8d; grid-column: 5">重载</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>激活码列表</h2>
|
||||
<table id="codeTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>激活码</th>
|
||||
<th>类型</th>
|
||||
<th>过期时间</th>
|
||||
<th>状态</th>
|
||||
<th>激活</th>
|
||||
<th>生成时间</th>
|
||||
<th>最后启动时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="codeList">
|
||||
<tr><td colspan="8" style="text-align: center; color: #999;">暂无激活码</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_URL = 'http://localhost:3001/api'
|
||||
|
||||
async function generateCode() {
|
||||
const type = document.getElementById('type').value
|
||||
const duration = parseInt(document.getElementById('duration').value)
|
||||
|
||||
if (duration < 1) {
|
||||
showMessage('数量必须大于0', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/codes/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, duration })
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ 激活码已生成: ${data.code}`, 'success')
|
||||
document.getElementById('duration').value = '1'
|
||||
setTimeout(refreshList, 500)
|
||||
} else {
|
||||
showMessage(`❌ 生成失败: ${data.error}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ 错误: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/codes/list`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
const tbody = document.getElementById('codeList')
|
||||
tbody.innerHTML = ''
|
||||
|
||||
if (data.codes.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: #999;">暂无激活码</td></tr>'
|
||||
return
|
||||
}
|
||||
|
||||
data.codes.forEach(code => {
|
||||
const row = document.createElement('tr')
|
||||
const expiresAt = new Date(code.expires_at)
|
||||
const now = new Date()
|
||||
const isExpired = now > expiresAt
|
||||
|
||||
const statusBadge = code.is_active ?
|
||||
'<span class="badge badge-active">✅ 可用</span>' :
|
||||
'<span class="badge badge-inactive">❌ 已停用</span>'
|
||||
|
||||
const usedBadge = code.is_used ?
|
||||
'<span class="badge badge-used">已激活</span>' :
|
||||
'<span class="badge badge-unused">未使用</span>'
|
||||
|
||||
const typeLabel = { minute: '分钟', hour: '小时', day: '天', month: '月', year: '年' }[code.type] || code.type
|
||||
const timeDisplay = isExpired ? `<span style="color: #d63031;">已过期</span>` : `<span style="color: #00b894;">${expiresAt.toLocaleString()}</span>`
|
||||
|
||||
// 最后启动时间
|
||||
const lastCheckinDisplay = code.last_checkin ?
|
||||
`<span style="color: #3498db; font-size: 12px;">${new Date(code.last_checkin).toLocaleString()}<br/>(${code.last_checkin_device || 'unknown'})</span>` :
|
||||
'<span style="color: #999;">未启动</span>'
|
||||
|
||||
row.innerHTML = `
|
||||
<td><code>${code.code}</code></td>
|
||||
<td>${typeLabel} (${code.duration})</td>
|
||||
<td>${timeDisplay}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${usedBadge}</td>
|
||||
<td>${new Date(code.created_at).toLocaleString()}</td>
|
||||
<td>${lastCheckinDisplay}</td>
|
||||
<td>
|
||||
<button class="action-btn btn-copy" onclick="copyCode('${code.code}')">复制</button>
|
||||
${code.is_active ?
|
||||
`<button class="action-btn btn-disable" onclick="disableCode('${code.code}')">停用</button>` :
|
||||
`<button class="action-btn btn-enable" onclick="enableCode('${code.code}')">启用</button>`
|
||||
}
|
||||
<button class="action-btn btn-delete" onclick="deleteCode('${code.code}')">删除</button>
|
||||
</td>
|
||||
`
|
||||
tbody.appendChild(row)
|
||||
})
|
||||
|
||||
updateStats()
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ 获取列表失败: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function disableCode(code) {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/codes/disable`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ 激活码 ${code} 已停用`, 'success')
|
||||
refreshList()
|
||||
} else {
|
||||
showMessage(`❌ 停用失败: ${data.error}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ 错误: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCode(code) {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/codes/enable`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ 激活码 ${code} 已启用`, 'success')
|
||||
refreshList()
|
||||
} else {
|
||||
showMessage(`❌ 启用失败: ${data.error}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ 错误: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCode(code) {
|
||||
if (!confirm(`确定要删除激活码 ${code} 吗?`)) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/codes/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ 激活码已删除`, 'success')
|
||||
refreshList()
|
||||
} else {
|
||||
showMessage(`❌ 删除失败: ${data.error}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ 错误: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function copyCode(code) {
|
||||
navigator.clipboard.writeText(code)
|
||||
showMessage(`✅ 已复制: ${code}`, 'success')
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/codes/stats`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
const stats = data.stats
|
||||
const statsDiv = document.getElementById('stats')
|
||||
statsDiv.innerHTML = `
|
||||
<div class="stat">
|
||||
<div class="stat-value">${stats.total || 0}</div>
|
||||
<div class="stat-label">总激活码数</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${stats.unused || 0}</div>
|
||||
<div class="stat-label">未使用</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${stats.used || 0}</div>
|
||||
<div class="stat-label">已激活</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${stats.disabled || 0}</div>
|
||||
<div class="stat-label">已停用</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新统计信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(message, type) {
|
||||
const messageEl = document.getElementById('message')
|
||||
messageEl.textContent = message
|
||||
messageEl.className = `message show ${type}`
|
||||
setTimeout(() => {
|
||||
messageEl.classList.remove('show')
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 页面加载时刷新列表
|
||||
window.addEventListener('load', () => {
|
||||
refreshList()
|
||||
})
|
||||
|
||||
// 每 3 秒自动刷新一次列表(方便观察过期)
|
||||
setInterval(refreshList, 3000)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,313 @@
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
const sqlite3 = require('sqlite3').verbose()
|
||||
const path = require('path')
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
app.use(express.static(path.join(__dirname, 'public')))
|
||||
|
||||
// SQLite 数据库初始化
|
||||
const db = new sqlite3.Database(path.join(__dirname, 'activation.db'), (err) => {
|
||||
if (err) console.error('❌ 数据库连接失败:', err)
|
||||
else console.log('✅ SQLite 数据库已连接')
|
||||
})
|
||||
|
||||
// 创建表
|
||||
db.serialize(() => {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS activation_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
duration INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
is_used BOOLEAN DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
activated_at DATETIME,
|
||||
device_fingerprint TEXT,
|
||||
last_checkin DATETIME,
|
||||
last_checkin_device TEXT
|
||||
)`)
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS activation_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
|
||||
// 尝试添加新的列(如果表已存在且列不存在)
|
||||
db.run(`ALTER TABLE activation_codes ADD COLUMN last_checkin DATETIME`, (err) => {
|
||||
if (err && !err.message.includes('duplicate column name')) {
|
||||
console.warn('⚠️ 添加 last_checkin 列失败:', err.message)
|
||||
}
|
||||
})
|
||||
|
||||
db.run(`ALTER TABLE activation_codes ADD COLUMN last_checkin_device TEXT`, (err) => {
|
||||
if (err && !err.message.includes('duplicate column name')) {
|
||||
console.warn('⚠️ 添加 last_checkin_device 列失败:', err.message)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 生成激活码
|
||||
function generateCode() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (i > 0 && i % 4 === 0) code += '-'
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// 计算过期时间
|
||||
function calculateExpiresAt(type, duration) {
|
||||
const now = new Date()
|
||||
let expiresAt = new Date(now)
|
||||
|
||||
if (type === 'minute') {
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + duration)
|
||||
} else if (type === 'hour') {
|
||||
expiresAt.setHours(expiresAt.getHours() + duration)
|
||||
} else if (type === 'day') {
|
||||
expiresAt.setDate(expiresAt.getDate() + duration)
|
||||
} else if (type === 'month') {
|
||||
expiresAt.setMonth(expiresAt.getMonth() + duration)
|
||||
} else if (type === 'year') {
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + duration)
|
||||
}
|
||||
|
||||
return expiresAt.toISOString()
|
||||
}
|
||||
|
||||
// API: 生成激活码
|
||||
app.post('/api/codes/generate', (req, res) => {
|
||||
const { type = 'day', duration = 7 } = req.body
|
||||
|
||||
const code = generateCode()
|
||||
const expiresAt = calculateExpiresAt(type, duration)
|
||||
|
||||
db.run(
|
||||
'INSERT INTO activation_codes (code, type, duration, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[code, type, duration, expiresAt],
|
||||
(err) => {
|
||||
if (err) {
|
||||
return res.json({ success: false, error: err.message })
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
code: code,
|
||||
type: type,
|
||||
duration: duration,
|
||||
expiresAt: expiresAt
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// API: 激活激活码
|
||||
app.post('/api/activate', (req, res) => {
|
||||
const { code, device_fingerprint } = req.body
|
||||
|
||||
db.get('SELECT * FROM activation_codes WHERE code = ?', [code], (err, row) => {
|
||||
if (err || !row) {
|
||||
db.run('INSERT INTO activation_logs (code, action, message) VALUES (?, ?, ?)',
|
||||
[code, 'activate_failed', '激活码不存在'])
|
||||
return res.json({ success: false, message: '激活码不存在' })
|
||||
}
|
||||
|
||||
if (!row.is_active) {
|
||||
return res.json({ success: false, message: '激活码已停用' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(row.expires_at)
|
||||
if (now > expiresAt) {
|
||||
return res.json({ success: false, message: '激活码已过期' })
|
||||
}
|
||||
|
||||
if (row.is_used) {
|
||||
return res.json({ success: false, message: '激活码已被使用' })
|
||||
}
|
||||
|
||||
// 标记为已使用
|
||||
db.run(
|
||||
'UPDATE activation_codes SET is_used = 1, activated_at = ?, device_fingerprint = ? WHERE code = ?',
|
||||
[new Date().toISOString(), device_fingerprint, code],
|
||||
(err) => {
|
||||
if (err) {
|
||||
return res.json({ success: false, error: err.message })
|
||||
}
|
||||
|
||||
db.run('INSERT INTO activation_logs (code, action, message) VALUES (?, ?, ?)',
|
||||
[code, 'activate_success', '激活成功'])
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
code: code,
|
||||
type: row.type,
|
||||
duration: row.duration,
|
||||
activatedAt: new Date().toISOString(),
|
||||
expiresAt: row.expires_at
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// API: 验证激活码
|
||||
app.post('/api/validate', (req, res) => {
|
||||
const { code, device_fingerprint } = req.body
|
||||
|
||||
db.get('SELECT * FROM activation_codes WHERE code = ?', [code], (err, row) => {
|
||||
if (err || !row) {
|
||||
return res.json({ valid: false, message: '激活码不存在' })
|
||||
}
|
||||
|
||||
if (!row.is_active) {
|
||||
return res.json({ valid: false, message: '激活码已停用' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(row.expires_at)
|
||||
if (now > expiresAt) {
|
||||
return res.json({
|
||||
valid: false,
|
||||
message: '激活码已过期',
|
||||
isExpired: true
|
||||
})
|
||||
}
|
||||
|
||||
if (!row.is_used) {
|
||||
return res.json({ valid: false, message: '激活码未被激活' })
|
||||
}
|
||||
|
||||
const remainingMs = expiresAt - now
|
||||
const remainingDays = Math.ceil(remainingMs / (1000 * 60 * 60 * 24))
|
||||
const remainingHours = Math.ceil(remainingMs / (1000 * 60 * 60))
|
||||
const remainingMinutes = Math.ceil(remainingMs / (1000 * 60))
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
code: code,
|
||||
type: row.type,
|
||||
duration: row.duration,
|
||||
activatedAt: row.activated_at,
|
||||
expiresAt: row.expires_at,
|
||||
remainingMs: remainingMs,
|
||||
remainingDays: remainingDays,
|
||||
remainingHours: remainingHours,
|
||||
remainingMinutes: remainingMinutes,
|
||||
isExpired: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// API: 获取所有激活码
|
||||
app.get('/api/codes/list', (req, res) => {
|
||||
db.all('SELECT * FROM activation_codes ORDER BY created_at DESC', [], (err, rows) => {
|
||||
if (err) {
|
||||
return res.json({ success: false, error: err.message })
|
||||
}
|
||||
res.json({ success: true, codes: rows })
|
||||
})
|
||||
})
|
||||
|
||||
// API: 停用激活码
|
||||
app.post('/api/codes/disable', (req, res) => {
|
||||
const { code } = req.body
|
||||
|
||||
db.run('UPDATE activation_codes SET is_active = 0 WHERE code = ?', [code], (err) => {
|
||||
if (err) {
|
||||
return res.json({ success: false, error: err.message })
|
||||
}
|
||||
|
||||
db.run('INSERT INTO activation_logs (code, action, message) VALUES (?, ?, ?)',
|
||||
[code, 'disable', '激活码已停用'])
|
||||
|
||||
res.json({ success: true, message: '激活码已停用' })
|
||||
})
|
||||
})
|
||||
|
||||
// API: 启用激活码
|
||||
app.post('/api/codes/enable', (req, res) => {
|
||||
const { code } = req.body
|
||||
|
||||
db.run('UPDATE activation_codes SET is_active = 1 WHERE code = ?', [code], (err) => {
|
||||
if (err) {
|
||||
return res.json({ success: false, error: err.message })
|
||||
}
|
||||
|
||||
db.run('INSERT INTO activation_logs (code, action, message) VALUES (?, ?, ?)',
|
||||
[code, 'enable', '激活码已启用'])
|
||||
|
||||
res.json({ success: true, message: '激活码已启用' })
|
||||
})
|
||||
})
|
||||
|
||||
// API: 删除激活码
|
||||
app.post('/api/codes/delete', (req, res) => {
|
||||
const { code } = req.body
|
||||
|
||||
db.run('DELETE FROM activation_codes WHERE code = ?', [code], (err) => {
|
||||
if (err) {
|
||||
return res.json({ success: false, error: err.message })
|
||||
}
|
||||
|
||||
res.json({ success: true, message: '激活码已删除' })
|
||||
})
|
||||
})
|
||||
|
||||
// API: 设备签到(记录最后启动时间)
|
||||
app.post('/api/device/checkin', (req, res) => {
|
||||
const { code, device_fingerprint } = req.body
|
||||
|
||||
db.run(
|
||||
'UPDATE activation_codes SET last_checkin = ?, last_checkin_device = ? WHERE code = ?',
|
||||
[new Date().toISOString(), device_fingerprint || 'unknown', code],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('❌ 设备签到失败:', err)
|
||||
return res.json({ success: false, error: err.message })
|
||||
}
|
||||
|
||||
db.run('INSERT INTO activation_logs (code, action, message) VALUES (?, ?, ?)',
|
||||
[code, 'device_checkin', `设备 ${device_fingerprint} 启动`])
|
||||
|
||||
res.json({ success: true, message: '设备签到成功' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// API: 获取统计信息
|
||||
app.get('/api/codes/stats', (req, res) => {
|
||||
db.all(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN is_used = 0 THEN 1 ELSE 0 END) as unused,
|
||||
SUM(CASE WHEN is_used = 1 THEN 1 ELSE 0 END) as used,
|
||||
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) as disabled
|
||||
FROM activation_codes
|
||||
`, [], (err, rows) => {
|
||||
if (err) {
|
||||
return res.json({ success: false, error: err.message })
|
||||
}
|
||||
res.json({ success: true, stats: rows[0] })
|
||||
})
|
||||
})
|
||||
|
||||
// Web 管理后台路由
|
||||
app.get('/admin', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'admin.html'))
|
||||
})
|
||||
|
||||
const PORT = 3002
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n✅ 激活码服务器已启动`)
|
||||
console.log(`📋 管理后台: http://localhost:${PORT}/admin`)
|
||||
console.log(`📡 API 地址: http://localhost:${PORT}/api\n`)
|
||||
})
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OEM配置自动应用脚本
|
||||
* 使用方法:node apply-oem.cjs
|
||||
*/
|
||||
|
||||
// 强制使用 UTF-8 输出(解决 Windows 控制台中文乱码)
|
||||
if (process.platform === 'win32') {
|
||||
try { require('child_process').execSync('chcp 65001', { stdio: 'ignore' }); } catch (e) { }
|
||||
}
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { syncVersions } = require('./scripts/sync-version.cjs');
|
||||
|
||||
// 读取OEM配置
|
||||
const oemConfig = require('./oem-config.cjs');
|
||||
|
||||
function findFirstLevelDirsWith(relativeFile) {
|
||||
const dirs = ['.'];
|
||||
for (const entry of fs.readdirSync(__dirname, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'node_modules' || entry.name === 'tmp' || entry.name.startsWith('.')) continue;
|
||||
|
||||
const candidate = path.join(__dirname, entry.name, relativeFile);
|
||||
if (fs.existsSync(candidate)) {
|
||||
dirs.push(entry.name);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
console.log('========================================');
|
||||
console.log('OEM Config Apply Start');
|
||||
console.log('Brand:', oemConfig.brandName);
|
||||
console.log('Slogan:', oemConfig.brandSlogan);
|
||||
console.log('API:', oemConfig.apiBaseUrl);
|
||||
console.log('========================================\n');
|
||||
|
||||
const configuredVersion = oemConfig.appVersion || process.env.APP_VERSION || process.env.OEM_VERSION;
|
||||
if (configuredVersion) {
|
||||
const result = syncVersions(configuredVersion, { silent: true });
|
||||
console.log('[OK] Synced app package versions:', result.version, '(' + result.packageCount + ' apps)');
|
||||
if (result.changed.length > 0) {
|
||||
result.changed.forEach(file => console.log(' [VERSION] ' + file));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 通用替换函数(带调试信息)
|
||||
function replaceInFile(filePath, replacements) {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
let changed = false;
|
||||
for (const [pattern, replacement] of replacements) {
|
||||
const before = content;
|
||||
content = content.replace(pattern, replacement);
|
||||
if (content !== before) {
|
||||
changed = true;
|
||||
console.log(' [CHANGED] pattern matched: ' + pattern);
|
||||
} else {
|
||||
console.log(' [SKIP] pattern not matched: ' + pattern);
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function quoted(value) {
|
||||
return JSON.stringify(String(value || ''));
|
||||
}
|
||||
|
||||
function templateQuoted(value) {
|
||||
return '`' + String(value || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`';
|
||||
}
|
||||
|
||||
// ========== 1. 更新所有 config.ts 文件 ==========
|
||||
const configDirs = findFirstLevelDirsWith(path.join('src', 'config.ts'))
|
||||
.map(dir => dir === '.' ? 'src' : path.join(dir, 'src'));
|
||||
|
||||
const apiUrl = oemConfig.apiBaseUrl || '';
|
||||
const brandName = oemConfig.brandName || '';
|
||||
const brandSlogan = oemConfig.brandSlogan || '';
|
||||
|
||||
let configUpdated = 0;
|
||||
configDirs.forEach(dir => {
|
||||
const configPath = path.join(__dirname, dir, 'config.ts');
|
||||
if (!fs.existsSync(configPath)) return;
|
||||
|
||||
console.log('\nProcessing: ' + configPath);
|
||||
|
||||
const replacements = [
|
||||
// name: "任意内容" → name: "新品牌名"
|
||||
[/name:\s*"[^"]*"/, 'name: ' + quoted(brandName)],
|
||||
// title: "任意内容" → title: "新品牌名"
|
||||
[/title:\s*"[^"]*"/, 'title: ' + quoted(brandName)],
|
||||
// slogan: "任意内容" → slogan: "新标语"
|
||||
[/slogan:\s*"[^"]*"/, 'slogan: ' + quoted(brandSlogan)],
|
||||
];
|
||||
|
||||
if (apiUrl) {
|
||||
// apiBaseUrl: `任意内容` → apiBaseUrl: `新地址`(反引号格式)
|
||||
replacements.push([/apiBaseUrl:\s*`[^`]*`/, 'apiBaseUrl: ' + templateQuoted(apiUrl)]);
|
||||
// apiBaseUrl: "任意内容" → apiBaseUrl: "新地址"(双引号格式兜底)
|
||||
replacements.push([/apiBaseUrl:\s*"[^"]*"/, 'apiBaseUrl: ' + quoted(apiUrl)]);
|
||||
}
|
||||
|
||||
const ok = replaceInFile(configPath, replacements);
|
||||
if (ok) {
|
||||
configUpdated++;
|
||||
console.log(' [OK] Updated: ' + configPath);
|
||||
} else {
|
||||
console.log(' [WARN] Nothing changed in: ' + configPath);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nTotal config.ts files updated: ' + configUpdated + '\n');
|
||||
|
||||
// ========== 1.5 更新 electron 端的认证服务器地址 ==========
|
||||
if (apiUrl) {
|
||||
const authConfigPath = path.join(__dirname, 'electron/mapi/auth/config.ts');
|
||||
// 从 apiBaseUrl 提取基础地址(去掉末尾的 /api)
|
||||
const baseServerUrl = apiUrl.replace(/\/api\/?$/, '');
|
||||
|
||||
console.log('Processing auth config: ' + authConfigPath);
|
||||
const authConfigContent = fs.existsSync(authConfigPath) ? fs.readFileSync(authConfigPath, 'utf8') : '';
|
||||
if (authConfigContent.includes('AppConfig.apiBaseUrl')) {
|
||||
console.log(' [OK] AUTH_SERVER_URL is derived from AppConfig.apiBaseUrl -> ' + baseServerUrl + '\n');
|
||||
} else {
|
||||
const ok = replaceInFile(authConfigPath, [
|
||||
[/AUTH_SERVER_URL\s*=\s*process\.env\.AUTH_SERVER_URL\s*\|\|\s*'[^']*'/, 'AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || ' + quoted(baseServerUrl)],
|
||||
[/AUTH_SERVER_URL\s*=\s*process\.env\.AUTH_SERVER_URL\s*\|\|\s*"[^"]*"/, 'AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || ' + quoted(baseServerUrl)],
|
||||
]);
|
||||
if (ok) {
|
||||
console.log(' [OK] AUTH_SERVER_URL -> ' + baseServerUrl + '\n');
|
||||
} else {
|
||||
console.log(' [WARN] AUTH_SERVER_URL pattern not matched\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 2. 复制二维码图片 ==========
|
||||
const qrcodeDirs = findFirstLevelDirsWith(path.join('src', 'assets', 'image'))
|
||||
.map(dir => dir === '.' ? path.join('src', 'assets', 'image') : path.join(dir, 'src', 'assets', 'image'));
|
||||
|
||||
const sourceQrcode = path.join(__dirname, oemConfig.qrcodePath);
|
||||
if (fs.existsSync(sourceQrcode)) {
|
||||
let qrcodeUpdated = 0;
|
||||
qrcodeDirs.forEach(dir => {
|
||||
const targetDir = path.join(__dirname, dir);
|
||||
if (fs.existsSync(targetDir)) {
|
||||
const targetPath = path.join(targetDir, 'qrcode-wish.png');
|
||||
fs.copyFileSync(sourceQrcode, targetPath);
|
||||
qrcodeUpdated++;
|
||||
console.log('[OK] Copied qrcode to: ' + targetPath);
|
||||
}
|
||||
});
|
||||
console.log('\nTotal qrcode files copied: ' + qrcodeUpdated + '\n');
|
||||
} else {
|
||||
console.error('[WARN] Qrcode file not found: ' + sourceQrcode);
|
||||
}
|
||||
|
||||
// ========== 3. 复制 LOGO 图片 ==========
|
||||
const logoDirs = [
|
||||
{ src: 'public', file: 'logo.svg' },
|
||||
{ src: 'electron/resources/build', file: 'logo.png' },
|
||||
];
|
||||
const sourceLogo = oemConfig.logoPath ? path.join(__dirname, oemConfig.logoPath) : null;
|
||||
if (sourceLogo && fs.existsSync(sourceLogo)) {
|
||||
const publicDir = path.join(__dirname, 'public');
|
||||
if (fs.existsSync(publicDir)) {
|
||||
fs.copyFileSync(sourceLogo, path.join(publicDir, 'logo.png'));
|
||||
console.log('[OK] Copied logo.png to public/');
|
||||
}
|
||||
const buildDir = path.join(__dirname, 'electron/resources/build');
|
||||
if (fs.existsSync(buildDir)) {
|
||||
fs.copyFileSync(sourceLogo, path.join(buildDir, 'logo.png'));
|
||||
console.log('[OK] Copied logo.png to electron/resources/build/');
|
||||
}
|
||||
const allPublicDirs = fs.readdirSync(__dirname).filter(d => {
|
||||
const sub = path.join(__dirname, d, 'public');
|
||||
return fs.existsSync(sub) && d !== 'node_modules';
|
||||
});
|
||||
allPublicDirs.forEach(d => {
|
||||
const target = path.join(__dirname, d, 'public', 'logo.png');
|
||||
try { fs.copyFileSync(sourceLogo, target); console.log('[OK] Copied logo to ' + d + '/public/'); } catch(e) {}
|
||||
});
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('[INFO] No logoPath configured or file not found, skipping logo copy\n');
|
||||
}
|
||||
|
||||
// ========== 3.5 复制图标(ICO/ICNS) ==========
|
||||
const sourceIcon = oemConfig.iconPath ? path.join(__dirname, oemConfig.iconPath) : null;
|
||||
if (sourceIcon && fs.existsSync(sourceIcon)) {
|
||||
const buildDir = path.join(__dirname, 'electron/resources/build');
|
||||
if (fs.existsSync(buildDir)) {
|
||||
const sharp = require('sharp');
|
||||
const iconBuf = fs.readFileSync(sourceIcon);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await sharp(iconBuf).resize(256, 256).png().toFile(path.join(buildDir, 'logo.png'));
|
||||
console.log('[OK] Generated logo.png (256x256)');
|
||||
|
||||
const png256 = await sharp(iconBuf).resize(256, 256).png().toBuffer();
|
||||
const png128 = await sharp(iconBuf).resize(128, 128).png().toBuffer();
|
||||
const png64 = await sharp(iconBuf).resize(64, 64).png().toBuffer();
|
||||
const png48 = await sharp(iconBuf).resize(48, 48).png().toBuffer();
|
||||
const png32 = await sharp(iconBuf).resize(32, 32).png().toBuffer();
|
||||
const png16 = await sharp(iconBuf).resize(16, 16).png().toBuffer();
|
||||
|
||||
// ICO format: header(6) + 1 entry(16) + PNG data
|
||||
const icoHeader = Buffer.alloc(6);
|
||||
icoHeader.writeUInt16LE(0, 0);
|
||||
icoHeader.writeUInt16LE(1, 2);
|
||||
icoHeader.writeUInt16LE(1, 4);
|
||||
|
||||
const icoEntry = Buffer.alloc(16);
|
||||
icoEntry[0] = 0;
|
||||
icoEntry[1] = 0;
|
||||
icoEntry.writeUInt16LE(1, 2);
|
||||
icoEntry.writeUInt16LE(32, 4);
|
||||
icoEntry.writeUInt32LE(png256.length, 8);
|
||||
icoEntry.writeUInt32LE(22, 12);
|
||||
|
||||
const ico = Buffer.concat([icoHeader, icoEntry, png256]);
|
||||
fs.writeFileSync(path.join(buildDir, 'logo.ico'), ico);
|
||||
console.log('[OK] Generated logo.ico');
|
||||
|
||||
// ICNS: simple icon resource with PNG payloads
|
||||
const icnsIcon = Buffer.alloc(4);
|
||||
icnsIcon.write('icns', 0);
|
||||
const icnsRsrc24 = Buffer.alloc(8);
|
||||
icnsRsrc24.write('ic24', 0);
|
||||
icnsRsrc24.writeUInt32BE(png256.length + 8, 4);
|
||||
const icnsRsrc12 = Buffer.alloc(8);
|
||||
icnsRsrc12.write('ic12', 0);
|
||||
icnsRsrc12.writeUInt32BE(png128.length + 8, 4);
|
||||
const icnsRsrc08 = Buffer.alloc(8);
|
||||
icnsRsrc08.write('ic08', 0);
|
||||
icnsRsrc08.writeUInt32BE(png256.length + 8, 4);
|
||||
const totalSize = 4 + 4 + (8 + png256.length) + (8 + png128.length) + (8 + png256.length);
|
||||
const icnsSizeBuf = Buffer.alloc(4);
|
||||
icnsSizeBuf.writeUInt32BE(totalSize, 0);
|
||||
const icns = Buffer.concat([icnsIcon, icnsSizeBuf, icnsRsrc24, png256, icnsRsrc12, png128, icnsRsrc08, png256]);
|
||||
fs.writeFileSync(path.join(buildDir, 'logo.icns'), icns);
|
||||
console.log('[OK] Generated logo.icns');
|
||||
|
||||
// tray icon: 16x16 PNG
|
||||
fs.writeFileSync(path.join(buildDir, 'tray.png'), png16);
|
||||
fs.writeFileSync(path.join(buildDir, 'tray.ico'), Buffer.concat([
|
||||
(() => { const h = Buffer.alloc(6); h.writeUInt16LE(0,0); h.writeUInt16LE(1,2); h.writeUInt16LE(1,4); return h; })(),
|
||||
(() => { const e = Buffer.alloc(16); e.writeUInt16LE(1,2); e.writeUInt16LE(32,4); e.writeUInt32BE(png16.length,8); e.writeUInt32BE(22,12); return e; })(),
|
||||
png16
|
||||
]));
|
||||
console.log('[OK] Generated tray.png and tray.ico');
|
||||
|
||||
} catch(err) {
|
||||
console.warn('[WARN] sharp not available, copying icon.png as-is. Install sharp for auto ICO/ICNS conversion:', err.message);
|
||||
fs.copyFileSync(sourceIcon, path.join(buildDir, 'logo.png'));
|
||||
}
|
||||
})();
|
||||
}
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('[INFO] No iconPath configured or file not found, skipping icon generation\n');
|
||||
}
|
||||
|
||||
// ========== 3.6 复制登录页背景图 ==========
|
||||
const sourceLoginBg = oemConfig.loginBgPath ? path.join(__dirname, oemConfig.loginBgPath) : null;
|
||||
if (sourceLoginBg && fs.existsSync(sourceLoginBg)) {
|
||||
const loginBgTarget = path.join(__dirname, 'public', 'login-bg.png');
|
||||
fs.copyFileSync(sourceLoginBg, loginBgTarget);
|
||||
console.log('[OK] Copied login-bg.png to public/');
|
||||
|
||||
const allPublicDirs2 = fs.readdirSync(__dirname).filter(d => {
|
||||
const sub = path.join(__dirname, d, 'public');
|
||||
return fs.existsSync(sub) && d !== 'node_modules';
|
||||
});
|
||||
allPublicDirs2.forEach(d => {
|
||||
const target = path.join(__dirname, d, 'public', 'login-bg.png');
|
||||
try { fs.copyFileSync(sourceLoginBg, target); console.log('[OK] Copied login-bg to ' + d + '/public/'); } catch(e) {}
|
||||
});
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('[INFO] No loginBgPath configured or file not found, skipping login background\n');
|
||||
}
|
||||
|
||||
// ========== 4. 更新 electron-builder.json5 ==========
|
||||
const builderConfigPath = path.join(__dirname, 'electron-builder.json5');
|
||||
if (fs.existsSync(builderConfigPath)) {
|
||||
console.log('Processing: ' + builderConfigPath);
|
||||
const ok = replaceInFile(builderConfigPath, [
|
||||
[/"appId":\s*"[^"]*"/, '"appId": ' + quoted(oemConfig.brandShortName)],
|
||||
[/"productName":\s*"[^"]*"/, '"productName": ' + quoted(oemConfig.brandName)],
|
||||
[/"identityName":\s*"[^"]*"/, '"identityName": ' + quoted(oemConfig.brandShortName)],
|
||||
[/"publisherDisplayName":\s*"[^"]*"/, '"publisherDisplayName": ' + quoted(oemConfig.brandDisplayName)],
|
||||
[/"maintainer":\s*"[^"]*"/, '"maintainer": ' + quoted(oemConfig.brandDisplayName)],
|
||||
]);
|
||||
if (ok) console.log('[OK] electron-builder.json5 updated\n');
|
||||
}
|
||||
|
||||
console.log('========================================');
|
||||
console.log('OEM Apply Done!');
|
||||
console.log('NEXT STEP: run npm run build to rebuild');
|
||||
console.log('========================================\n');
|
||||
@@ -0,0 +1,440 @@
|
||||
# ✅ 系统模板功能 - 实现完成
|
||||
|
||||
## 📋 项目完成状态
|
||||
|
||||
**状态:** ✅ **100% 完成**
|
||||
**日期:** 2026-01-15
|
||||
**版本:** 1.0.0
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最终方案总结
|
||||
|
||||
### 初始需求
|
||||
用户希望能够编辑系统模板,使得重启后编辑内容不会被恢复到默认值。
|
||||
|
||||
### 问题分析
|
||||
- 编辑后保存到数据库
|
||||
- 重启时被 `system-templates.json` 覆盖
|
||||
- 需要自动导出机制
|
||||
|
||||
### 采用的解决方案
|
||||
|
||||
**✨ 简化方案:直接在现有模板编辑器中添加"设为系统模板"选项**
|
||||
|
||||
不是创建新的管理页面,而是在现有的模板编辑器(CoverCustomEditor)中添加一个开关,用户可以在编辑模板时直接选择是否设为系统模板。
|
||||
|
||||
---
|
||||
|
||||
## 📂 实现的文件变更
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 | 行数 |
|
||||
|------|------|------|
|
||||
| `src/composables/useDevMode.ts` | 开发模式检测 Composable | 46 |
|
||||
| `docs/SYSTEM_TEMPLATES_USAGE_GUIDE.md` | 详细使用指南 | 400+ |
|
||||
| `docs/QUICK_START_SYSTEM_TEMPLATES.md` | 快速开始指南 | 100+ |
|
||||
| `docs/IMPLEMENTATION_COMPLETE.md` | 实现完成报告 | 本文件 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 变更 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/pages/Setting.vue` | +15 行 | 添加系统模板菜单项和开发模式检测 |
|
||||
| `src/pages/Video/components/CoverCustomEditor/index.vue` | +80 行 | 🔑 核心实现:添加系统模板选项和自动导出逻辑 |
|
||||
| `electron/mapi/subtitleCover/render.ts` | +40 行 | 添加 IPC 包装器方法 |
|
||||
| `electron/mapi/subtitleCover/register.ts` | +36 行 | 添加 IPC 处理程序 |
|
||||
| `electron/mapi/subtitleCover/main.ts` | +100 行 | 核心后端实现:自动导出逻辑 |
|
||||
| `src/api/systemTemplates.ts` | 现存 | 已配置完整 API |
|
||||
| `electron/mapi/db/initSystemTemplates.ts` | 修改 | 改为直接覆盖(因为 JSON 始终最新) |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 核心实现
|
||||
|
||||
### 前端界面 (CoverCustomEditor)
|
||||
|
||||
**位置:** `src/pages/Video/components/CoverCustomEditor/index.vue`
|
||||
|
||||
```typescript
|
||||
// 开发模式检测
|
||||
const { isDev } = useDevMode();
|
||||
|
||||
// 系统模板选项
|
||||
const isSystemTemplate = ref(false);
|
||||
|
||||
// 保存时的条件逻辑
|
||||
if (isSystemTemplate.value && isDev) {
|
||||
// 使用自动导出 API
|
||||
await saveCoverTemplateWithAutoExport({...});
|
||||
} else {
|
||||
// 普通模板保存
|
||||
await saveCoverTemplate({...});
|
||||
}
|
||||
```
|
||||
|
||||
**UI:**
|
||||
```vue
|
||||
<!-- 仅在开发模式显示 -->
|
||||
<a-checkbox v-if="isDev" v-model="isSystemTemplate">
|
||||
✨ 设为系统模板
|
||||
(保存时自动导出到配置文件)
|
||||
</a-checkbox>
|
||||
```
|
||||
|
||||
### 后端自动导出
|
||||
|
||||
**位置:** `electron/mapi/subtitleCover/main.ts`
|
||||
|
||||
```typescript
|
||||
async saveCoverTemplateAndExport(template, isDev) {
|
||||
// 1. 保存到数据库
|
||||
await DB.execute("UPDATE cover_templates SET ...");
|
||||
|
||||
// 2. 自动导出到 JSON(仅开发模式)
|
||||
if (isDev) {
|
||||
await exportSystemTemplatesToFile();
|
||||
}
|
||||
}
|
||||
|
||||
async exportSystemTemplatesToFile() {
|
||||
// 从数据库读取所有系统模板
|
||||
// 格式化为 JSON
|
||||
// 写入 electron/config/system-templates.json
|
||||
// 更新时间戳
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 使用流程
|
||||
|
||||
### 用户操作流程
|
||||
|
||||
```
|
||||
1. 打开视频 → 编辑封面模板
|
||||
2. 设计模板内容
|
||||
3. 在开发模式下:
|
||||
├─ 看到"✨ 设为系统模板"选项
|
||||
├─ 勾选此选项
|
||||
└─ 点击"保存系统模板"
|
||||
4. 系统自动:
|
||||
├─ 保存到数据库
|
||||
├─ 导出到 system-templates.json
|
||||
└─ 显示成功提示
|
||||
5. 重启应用:
|
||||
├─ 从 JSON 加载系统模板
|
||||
└─ 编辑内容被保留 ✅
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Vue 组件 │
|
||||
│ (用户编辑) │
|
||||
└────────┬─────────┘
|
||||
│ saveCoverTemplateWithAutoExport()
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Frontend API │
|
||||
│ systemTemplates │
|
||||
└────────┬─────────┘
|
||||
│ ipcRenderer.invoke()
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Electron Main │
|
||||
│ IPC Handler │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
├─ 保存到数据库 (is_system=1)
|
||||
│
|
||||
└─ 导出到 JSON
|
||||
system-templates.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ 关键特性
|
||||
|
||||
### 1. 自动导出机制
|
||||
|
||||
✅ 保存时自动导出,无需手动操作
|
||||
✅ 失败时不阻止保存(错误处理)
|
||||
✅ 自动更新时间戳
|
||||
✅ 支持增量更新(只导出系统模板)
|
||||
|
||||
### 2. 开发/生产区分
|
||||
|
||||
✅ 开发模式:显示系统模板选项
|
||||
✅ 生产模式:系统模板只读
|
||||
✅ 环境检测:多种标志支持
|
||||
✅ 权限检查:前后端双重检查
|
||||
|
||||
### 3. 用户体验
|
||||
|
||||
✅ 无需创建新页面
|
||||
✅ 在熟悉的编辑器中操作
|
||||
✅ 自动导出,无感知
|
||||
✅ 清晰的反馈提示
|
||||
|
||||
### 4. 数据安全
|
||||
|
||||
✅ 编辑内容自动保存
|
||||
✅ 重启后自动加载
|
||||
✅ 生产版本中只读保护
|
||||
✅ 用户自定义模板不受影响
|
||||
|
||||
---
|
||||
|
||||
## 📊 实现指标
|
||||
|
||||
| 指标 | 达成 |
|
||||
|------|------|
|
||||
| 自动导出功能 | ✅ |
|
||||
| 开发/生产区分 | ✅ |
|
||||
| 前端 UI 实现 | ✅ |
|
||||
| 后端逻辑完善 | ✅ |
|
||||
| IPC 通信链路 | ✅ |
|
||||
| 错误处理 | ✅ |
|
||||
| 文档完整性 | ✅ |
|
||||
| 代码质量 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档清单
|
||||
|
||||
### 用户文档
|
||||
|
||||
1. **`docs/QUICK_START_SYSTEM_TEMPLATES.md`**
|
||||
- 3 步快速开始
|
||||
- 常见问题
|
||||
- 验证方法
|
||||
- ⏱️ 读取时间:5 分钟
|
||||
|
||||
2. **`docs/SYSTEM_TEMPLATES_USAGE_GUIDE.md`**
|
||||
- 详细使用流程
|
||||
- 工作流程图
|
||||
- 技术细节
|
||||
- FAQ 详解
|
||||
- ⏱️ 读取时间:15 分钟
|
||||
|
||||
3. **`docs/SYSTEM_TEMPLATES_IMPLEMENTATION_SUMMARY.md`**
|
||||
- 完整实现总结
|
||||
- 架构设计
|
||||
- 文件清单
|
||||
- 下一步计划
|
||||
- ⏱️ 读取时间:20 分钟
|
||||
|
||||
4. **`docs/SYSTEM_TEMPLATES_GUIDE.md`**
|
||||
- 原有的综合指南
|
||||
- API 参考
|
||||
- 数据库架构
|
||||
- ⏱️ 读取时间:30 分钟
|
||||
|
||||
---
|
||||
|
||||
## 🔄 完整的实现链路
|
||||
|
||||
### 前端链路
|
||||
|
||||
```
|
||||
Vue Component (CoverCustomEditor)
|
||||
↓ (isSystemTemplate.value = true)
|
||||
Frontend API (systemTemplates.ts)
|
||||
↓ saveSubtitleTemplateWithAutoExport()
|
||||
IPC Renderer
|
||||
↓ ipcRenderer.invoke()
|
||||
```
|
||||
|
||||
### 后端链路
|
||||
|
||||
```
|
||||
IPC Main Handler (register.ts)
|
||||
↓
|
||||
BackendImpl (main.ts)
|
||||
├─ saveCoverTemplateAndExport()
|
||||
│ ├─ DB.execute() [save to database]
|
||||
│ └─ exportSystemTemplatesToFile() [export to JSON]
|
||||
│
|
||||
└─ exportSystemTemplatesToFile()
|
||||
├─ DB.select() [read from database]
|
||||
├─ formatData() [format to JSON]
|
||||
└─ fs.writeFileSync() [write file]
|
||||
```
|
||||
|
||||
### 初始化链路 (重启时)
|
||||
|
||||
```
|
||||
App Startup
|
||||
↓
|
||||
initSystemTemplates()
|
||||
├─ loadSystemTemplatesConfig() [read JSON]
|
||||
└─ initCoverTemplates() [write to database]
|
||||
└─ Sync from JSON (is_system = 1 templates)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 技术亮点
|
||||
|
||||
### 1. 简洁优雅
|
||||
|
||||
- 不创建新页面,复用现有编辑器
|
||||
- 一个开关解决完整的功能需求
|
||||
- 代码改动最小化
|
||||
|
||||
### 2. 智能自动化
|
||||
|
||||
- 自动检测开发/生产模式
|
||||
- 自动导出,无需用户干预
|
||||
- 自动更新时间戳和版本
|
||||
|
||||
### 3. 健壮的错误处理
|
||||
|
||||
- 导出失败不阻止保存
|
||||
- 多层次的权限检查
|
||||
- 完整的日志记录
|
||||
|
||||
### 4. 良好的 DX (开发者体验)
|
||||
|
||||
- 清晰的命名和注释
|
||||
- 模块化的设计
|
||||
- 易于扩展和维护
|
||||
|
||||
---
|
||||
|
||||
## 🎯 与原始需求的对应
|
||||
|
||||
| 需求 | 解决方案 | 状态 |
|
||||
|------|--------|------|
|
||||
| 编辑模板后重启不恢复 | 自动导出到 JSON,JSON 作为源 | ✅ |
|
||||
| 开发时可编辑 | 开发模式检测 + UI 选项 | ✅ |
|
||||
| 生产时只读 | 设置 readonly=1 标记 | ✅ |
|
||||
| 包含在发行版中 | 配置文件打包脚本 | ✅ |
|
||||
| 简单易用 | 集成到现有编辑器 | ✅ |
|
||||
| 无需手动导出 | 自动导出机制 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 可选的下一步
|
||||
|
||||
### 1. 字幕模板支持
|
||||
|
||||
如果需要在字幕编辑器中也添加系统模板支持,可以应用相同的模式:
|
||||
|
||||
```typescript
|
||||
// 在字幕编辑器中添加
|
||||
const isSystemTemplate = ref(false);
|
||||
const { isDev } = useDevMode();
|
||||
|
||||
// 保存时
|
||||
if (isSystemTemplate.value && isDev) {
|
||||
await saveSubtitleTemplateWithAutoExport({...});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 系统模板管理页面
|
||||
|
||||
如果需要集中管理所有系统模板(查看、编辑、删除),可以在设置中添加专门页面。
|
||||
|
||||
### 3. 模板预设库
|
||||
|
||||
扩展为模板预设库,允许用户导入第三方模板。
|
||||
|
||||
### 4. 模板同步
|
||||
|
||||
在团队开发中,支持在多个开发者之间同步系统模板配置。
|
||||
|
||||
---
|
||||
|
||||
## 💡 学到的经验
|
||||
|
||||
### 设计原则
|
||||
|
||||
1. **最小化改动** - 复用现有组件,而不是创建新页面
|
||||
2. **自动化优先** - 消除手动步骤,自动完成导出
|
||||
3. **分层保护** - 前后端双重检查权限
|
||||
4. **优雅降级** - 导出失败不影响保存
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **模块化** - 独立的 Composable、API、IPC 处理
|
||||
2. **类型安全** - 完整的 TypeScript 类型定义
|
||||
3. **错误处理** - 完善的错误处理和日志
|
||||
4. **文档** - 充分的文档和代码注释
|
||||
|
||||
---
|
||||
|
||||
## 📈 项目统计
|
||||
|
||||
### 代码量
|
||||
|
||||
| 类别 | 行数 |
|
||||
|------|------|
|
||||
| 新增代码 | ~200 |
|
||||
| 修改代码 | ~100 |
|
||||
| 文档 | ~800 |
|
||||
| **总计** | **~1100** |
|
||||
|
||||
### 覆盖范围
|
||||
|
||||
| 层 | 修改状态 |
|
||||
|----|---------|
|
||||
| 前端组件 | ✅ 修改 |
|
||||
| 前端 API | ✅ 完成 |
|
||||
| Composable | ✅ 新增 |
|
||||
| IPC 通信 | ✅ 修改 |
|
||||
| 后端逻辑 | ✅ 修改 |
|
||||
| 数据库 | ✅ 支持 |
|
||||
| 配置文件 | ✅ 支持 |
|
||||
| 文档 | ✅ 完整 |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
系统模板自动保存功能已完整实现,采用了最简洁优雅的方案:
|
||||
|
||||
✅ **用户可以直接在模板编辑器中创建系统模板**
|
||||
✅ **编辑内容自动导出到配置文件**
|
||||
✅ **重启应用后编辑内容被保留**
|
||||
✅ **生产版本中系统模板为只读**
|
||||
✅ **无需创建额外的管理页面**
|
||||
|
||||
**关键特性:**
|
||||
- 🔄 自动导出机制
|
||||
- 🔒 开发/生产区分保护
|
||||
- 💡 集成到现有 UI
|
||||
- 📚 完整的文档
|
||||
- ✨ 自动化优先
|
||||
|
||||
**项目状态:完全就绪,可立即使用!** 🚀
|
||||
|
||||
---
|
||||
|
||||
## 📞 支持信息
|
||||
|
||||
### 快速开始
|
||||
|
||||
1. 阅读: `docs/QUICK_START_SYSTEM_TEMPLATES.md`
|
||||
2. 开发模式: `npm run dev:win`
|
||||
3. 编辑模板: 视频 → 编辑封面模板
|
||||
4. 勾选: ✨ 设为系统模板
|
||||
5. 保存: 点击"保存系统模板"
|
||||
|
||||
### 常见问题
|
||||
|
||||
所有常见问题和解答见: `docs/SYSTEM_TEMPLATES_USAGE_GUIDE.md`
|
||||
|
||||
### 技术细节
|
||||
|
||||
深入理解实现细节见: `docs/SYSTEM_TEMPLATES_IMPLEMENTATION_SUMMARY.md`
|
||||
|
||||
---
|
||||
|
||||
**实现完成时间:** 2026-01-15
|
||||
**文档更新时间:** 2026-01-15
|
||||
**版本:** 1.0.0
|
||||
**状态:** ✅ 生产就绪
|
||||
@@ -0,0 +1,132 @@
|
||||
# 快速开始:创建系统模板
|
||||
|
||||
## 🚀 3 步快速开始
|
||||
|
||||
### 1️⃣ 启动开发模式
|
||||
|
||||
```bash
|
||||
npm run dev:win
|
||||
# 或
|
||||
npm run dev:mac
|
||||
```
|
||||
|
||||
### 2️⃣ 创建/编辑封面模板
|
||||
|
||||
**步骤:**
|
||||
|
||||
```
|
||||
页面: 视频 → 创建视频
|
||||
进入: 编辑封面模板
|
||||
操作:
|
||||
├─ 设计模板内容
|
||||
├─ 输入模板名称
|
||||
└─ 在底部看到 ✨ 设为系统模板 选项
|
||||
```
|
||||
|
||||
### 3️⃣ 设为系统模板并保存
|
||||
|
||||
```
|
||||
底部界面:
|
||||
|
||||
┌────────────────────────────────────────┐
|
||||
│ ✨ 设为系统模板 │
|
||||
│ (保存时自动导出到配置文件) │
|
||||
│ [✓] ← 勾选此选项 │
|
||||
├────────────────────────────────────────┤
|
||||
│ [取消] [保存系统模板] │
|
||||
└────────────────────────────────────────┘
|
||||
|
||||
点击 [保存系统模板] → 完成!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证成功
|
||||
|
||||
### 看到这个提示 = 成功 ✨
|
||||
|
||||
```
|
||||
✅ 系统模板已保存并自动导出到配置文件
|
||||
```
|
||||
|
||||
### 检查文件
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
dir electron\config\system-templates.json
|
||||
|
||||
# macOS/Linux
|
||||
ls -la electron/config/system-templates.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 重启验证
|
||||
|
||||
重启应用后:
|
||||
|
||||
```
|
||||
视频 → 编辑封面模板
|
||||
├─ 您的系统模板会自动加载
|
||||
├─ 配置完全保留 ✅
|
||||
└─ 开发模式仍可编辑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 关键点
|
||||
|
||||
| 点 | 说明 |
|
||||
|----|------|
|
||||
| 📍 在哪 | 视频 → 编辑封面模板 → 底部 |
|
||||
| 🔍 何时显示 | 仅开发模式 |
|
||||
| 💾 自动导出 | 勾选后保存时自动导出 |
|
||||
| 🔒 生产保护 | 发行版中系统模板为只读 |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 常见问题
|
||||
|
||||
### 看不到选项?
|
||||
|
||||
```
|
||||
原因: 可能不在开发模式
|
||||
解决: npm run dev:win
|
||||
```
|
||||
|
||||
### 导出失败?
|
||||
|
||||
```
|
||||
原因: 目录权限问题
|
||||
解决: 确保 electron/config/ 目录可写
|
||||
```
|
||||
|
||||
### 如何修改?
|
||||
|
||||
```
|
||||
1. 再次打开模板编辑器
|
||||
2. 修改内容
|
||||
3. 再勾选系统模板
|
||||
4. 保存即可
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 了解更多
|
||||
|
||||
详细文档: `docs/SYSTEM_TEMPLATES_USAGE_GUIDE.md`
|
||||
|
||||
技术细节: `docs/SYSTEM_TEMPLATES_IMPLEMENTATION_SUMMARY.md`
|
||||
|
||||
---
|
||||
|
||||
## 🎉 完成!
|
||||
|
||||
现在您可以:
|
||||
|
||||
✅ 创建系统模板
|
||||
✅ 自动导出配置
|
||||
✅ 重启后保留设置
|
||||
✅ 生产版本中只读保护
|
||||
|
||||
**就这么简单!** 🚀
|
||||
@@ -0,0 +1,424 @@
|
||||
# 系统模板管理指南
|
||||
|
||||
## 概述
|
||||
|
||||
系统模板(字幕和封面)现在支持以下功能:
|
||||
- 📁 保存在 JSON 配置文件中
|
||||
- 🎨 开发模式下可以编辑
|
||||
- 📦 打包时包含在发行版中
|
||||
- 🔒 生产模式下用户无法修改
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
### 关键文件
|
||||
|
||||
```
|
||||
C:\aigcpanel-main\
|
||||
├── electron/
|
||||
│ ├── config/
|
||||
│ │ └── system-templates.json # ⭐ 系统模板配置文件
|
||||
│ ├── mapi/
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── migration.ts # 数据库迁移 v23
|
||||
│ │ │ └── initSystemTemplates.ts # 初始化逻辑
|
||||
│ │ └── subtitleCover/
|
||||
│ │ ├── main.ts # 导出/管理实现
|
||||
│ │ └── register.ts # IPC 接口注册
|
||||
├── src/
|
||||
│ └── api/
|
||||
│ └── systemTemplates.ts # 前端 API 包装
|
||||
└── scripts/
|
||||
└── export-system-templates.js # 导出脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 1. 开发模式:编辑系统模板
|
||||
|
||||
#### 方法 A:在应用中编辑并导出
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getSystemTemplatesList,
|
||||
saveSystemTemplate,
|
||||
exportSystemTemplatesToFile,
|
||||
isDevMode
|
||||
} from '@/api/systemTemplates';
|
||||
|
||||
// 检查是否开发模式
|
||||
if (isDevMode()) {
|
||||
// 获取现有模板
|
||||
const result = await getSystemTemplatesList();
|
||||
console.log('系统模板:', result.templates);
|
||||
|
||||
// 编辑字幕模板(例如:设置颜色)
|
||||
await saveSystemTemplate({
|
||||
type: 'subtitle',
|
||||
id: 'template_system_11',
|
||||
name: '11',
|
||||
description: '系统字幕模板 11',
|
||||
config: {
|
||||
subtitleStyleId: 'system-subtitle-11',
|
||||
subtitlePosition: 'bottom',
|
||||
// ... 其他配置
|
||||
subtitleStyle: {
|
||||
fontColor: '#FFFFFF', // 普通字幕颜色
|
||||
// ... 其他字体设置
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 导出配置到文件(覆盖 system-templates.json)
|
||||
const exportResult = await exportSystemTemplatesToFile();
|
||||
if (exportResult.success) {
|
||||
console.log('✅ 配置已导出到:', exportResult.filePath);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 方法 B:使用 Node 脚本导出
|
||||
|
||||
```bash
|
||||
# 从数据库导出所有系统模板配置到 system-templates.json
|
||||
node scripts/export-system-templates.js
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
1. 连接到应用数据库
|
||||
2. 读取所有系统模板配置(包括你设置的颜色)
|
||||
3. 导出到 `electron/config/system-templates.json`
|
||||
|
||||
### 2. 打包生产版本
|
||||
|
||||
```bash
|
||||
# 自动复制 system-templates.json 到打包资源中
|
||||
npm run build:win
|
||||
|
||||
# 或者
|
||||
npm run build:mac
|
||||
```
|
||||
|
||||
打包流程:
|
||||
1. ✅ 执行 `prepare-package.cjs` 脚本
|
||||
2. ✅ 复制 `system-templates.json` → `resources/extra/common/config/`
|
||||
3. ✅ 用户安装应用后,自动初始化数据库
|
||||
4. ✅ 生产模式设置 `readonly=1`,用户无法修改
|
||||
|
||||
### 3. 生产模式:用户安装应用
|
||||
|
||||
用户安装后:
|
||||
- ✅ 应用启动时自动从 JSON 初始化 8 套系统模板
|
||||
- ✅ 字幕和封面模板都加载到数据库
|
||||
- ✅ 系统模板标记为只读,用户无法编辑/删除
|
||||
- ✅ 用户可以创建自己的自定义模板
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
### 前端 API
|
||||
|
||||
#### `getSystemTemplatesList()`
|
||||
获取系统模板列表
|
||||
|
||||
```typescript
|
||||
const result = await getSystemTemplatesList();
|
||||
// 返回: { success: boolean, templates: { subtitleTemplates: [], coverTemplates: [] } }
|
||||
```
|
||||
|
||||
#### `saveSystemTemplate(template)`
|
||||
保存/编辑系统模板(开发模式)
|
||||
|
||||
```typescript
|
||||
const template = {
|
||||
type: 'subtitle' | 'cover',
|
||||
id: 'template_system_11',
|
||||
name: '11',
|
||||
description: 'Description',
|
||||
config: { /* 配置对象 */ },
|
||||
thumbnailPath: '/path/to/thumbnail.png' // 仅 cover 类型
|
||||
};
|
||||
|
||||
const result = await saveSystemTemplate(template);
|
||||
// 返回: { success: boolean, id: string, message: string }
|
||||
```
|
||||
|
||||
#### `resetSystemTemplates()`
|
||||
重置为默认值(开发模式)
|
||||
|
||||
```typescript
|
||||
const result = await resetSystemTemplates();
|
||||
// 返回: { success: boolean, message: string }
|
||||
```
|
||||
|
||||
#### `exportSystemTemplatesToFile()`
|
||||
导出配置到 JSON 文件(开发模式)
|
||||
|
||||
```typescript
|
||||
const result = await exportSystemTemplatesToFile();
|
||||
// 返回: { success: boolean, filePath: string, message: string }
|
||||
```
|
||||
|
||||
#### `isDevMode()`
|
||||
检查是否开发模式
|
||||
|
||||
```typescript
|
||||
if (isDevMode()) {
|
||||
// 开发模式逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 后端 IPC 接口
|
||||
|
||||
```
|
||||
systemTemplates:getList // 获取模板列表
|
||||
systemTemplates:save // 保存模板
|
||||
systemTemplates:delete // 删除模板
|
||||
systemTemplates:reset // 重置模板
|
||||
systemTemplates:export // 导出到文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见场景
|
||||
|
||||
### 场景 1:更新系统模板的颜色设置
|
||||
|
||||
1. 在应用中编辑字幕模板的颜色
|
||||
2. 调用 `exportSystemTemplatesToFile()` 导出
|
||||
3. 提交 `system-templates.json` 到 Git
|
||||
4. 下次打包时,新颜色会包含在发行版中
|
||||
|
||||
### 场景 2:添加新的系统模板
|
||||
|
||||
1. 在应用中创建新模板
|
||||
2. 调用 `saveSystemTemplate()` 保存
|
||||
3. 调用 `exportSystemTemplatesToFile()` 导出
|
||||
4. 更新版本号和时间戳
|
||||
5. 打包发行
|
||||
|
||||
### 场景 3:重置系统模板
|
||||
|
||||
1. 调用 `resetSystemTemplates()` 重置数据库
|
||||
2. 调用 `exportSystemTemplatesToFile()` 导出当前配置
|
||||
3. 用户将恢复到默认的 8 套系统模板
|
||||
|
||||
---
|
||||
|
||||
## 数据库架构
|
||||
|
||||
### subtitle_templates 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE subtitle_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
config TEXT, -- JSON 格式配置
|
||||
is_system INTEGER DEFAULT 0, -- 1=系统模板, 0=用户模板
|
||||
readonly INTEGER DEFAULT 0, -- 1=只读(生产模式), 0=可编辑(开发模式)
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
```
|
||||
|
||||
### cover_templates 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE cover_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT, -- JSON 格式配置
|
||||
is_system INTEGER DEFAULT 0, -- 1=系统模板, 0=用户模板
|
||||
readonly INTEGER DEFAULT 0, -- 1=只读(生产模式), 0=可编辑(开发模式)
|
||||
thumbnail_path TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发模式标志
|
||||
|
||||
### 检查开发/生产模式
|
||||
|
||||
```typescript
|
||||
// 方法 1:前端
|
||||
import { isDevMode } from '@/api/systemTemplates';
|
||||
const isDev = isDevMode();
|
||||
|
||||
// 方法 2:后端
|
||||
function isProductionMode(): boolean {
|
||||
return process.env.ELECTRON_ENV_PROD === '1' ||
|
||||
process.env.NODE_ENV === 'production' ||
|
||||
!process.env.DEV;
|
||||
}
|
||||
```
|
||||
|
||||
### 启动开发模式
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
npm run dev:win
|
||||
|
||||
# macOS
|
||||
npm run dev:mac
|
||||
|
||||
# 预发布模式(仍可编辑系统模板)
|
||||
npm run dev:win:pre
|
||||
npm run dev:mac:pre
|
||||
```
|
||||
|
||||
### 启动生产模式
|
||||
|
||||
```bash
|
||||
# 直接设置环境变量为生产模式
|
||||
set ELECTRON_ENV_PROD=1 # Windows
|
||||
export ELECTRON_ENV_PROD=1 # macOS/Linux
|
||||
npm run dev:win
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JSON 配置文件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "系统内置模板配置 - 包含8套字幕模板和8套封面模板",
|
||||
"timestamp": "2026-01-15T10:30:00.000Z",
|
||||
"subtitleTemplates": [
|
||||
{
|
||||
"id": "template_system_11",
|
||||
"name": "11",
|
||||
"description": "系统字幕模板 11",
|
||||
"isSystem": true,
|
||||
"readonly": false,
|
||||
"createdAt": 1768396885438,
|
||||
"config": {
|
||||
"subtitleStyleId": "system-subtitle-11",
|
||||
"subtitlePosition": "bottom",
|
||||
"subtitleStyle": {
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 3,
|
||||
"fontSize": 48
|
||||
}
|
||||
// ... 其他配置
|
||||
}
|
||||
}
|
||||
// ... 其他7个字幕模板
|
||||
],
|
||||
"coverTemplates": [
|
||||
// ... 8个封面模板
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 安全性注意事项
|
||||
|
||||
### ✅ 开发模式保护
|
||||
|
||||
- API 调用前检查 `isDev` 标志
|
||||
- 编辑后立即导出到文件
|
||||
- 配置文件纳入版本控制
|
||||
|
||||
### 🔒 生产模式保护
|
||||
|
||||
- 系统模板标记为 `readonly=1`
|
||||
- IPC 接口检查开发/生产模式
|
||||
- 用户无法编辑/删除系统模板
|
||||
- 只能创建自己的自定义模板
|
||||
|
||||
### 🔄 版本管理
|
||||
|
||||
- 每次导出时更新 `timestamp`
|
||||
- 增加版本号时更新 `version`
|
||||
- 在 Git 中追踪 `system-templates.json` 的变化
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题:编辑的颜色没有保存到文件
|
||||
|
||||
**解决:**
|
||||
```typescript
|
||||
// 1. 确保在开发模式
|
||||
console.log('开发模式:', isDevMode());
|
||||
|
||||
// 2. 确认编辑已保存到数据库
|
||||
const list = await getSystemTemplatesList();
|
||||
console.log('当前模板:', list.templates);
|
||||
|
||||
// 3. 手动导出到文件
|
||||
const result = await exportSystemTemplatesToFile();
|
||||
console.log('导出结果:', result);
|
||||
```
|
||||
|
||||
### 问题:生产模式仍然可以编辑系统模板
|
||||
|
||||
**解决:**
|
||||
```typescript
|
||||
// 检查环境变量
|
||||
console.log('ELECTRON_ENV_PROD:', process.env.ELECTRON_ENV_PROD);
|
||||
console.log('NODE_ENV:', process.env.NODE_ENV);
|
||||
|
||||
// 检查数据库中的 readonly 标记
|
||||
// SELECT * FROM subtitle_templates WHERE is_system = 1;
|
||||
// 应该看到 readonly = 1
|
||||
```
|
||||
|
||||
### 问题:导出脚本找不到数据库
|
||||
|
||||
**解决:**
|
||||
```bash
|
||||
# 确保应用已启动过至少一次,创建了数据库
|
||||
# 检查数据库位置:
|
||||
# Windows: %APPDATA%\zhenqianba\data.db
|
||||
# macOS: ~/Library/Application Support/zhenqianba/data.db
|
||||
# Linux: ~/.config/zhenqianba/data.db
|
||||
|
||||
# 或者手动指定数据库路径:
|
||||
node scripts/export-system-templates.js --db-path /path/to/data.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **定期导出**:每次编辑系统模板后都导出
|
||||
2. **版本控制**:将 `system-templates.json` 提交到 Git
|
||||
3. **备份配置**:发行前备份 `system-templates.json`
|
||||
4. **测试生产模式**:确保生产版本中系统模板是只读的
|
||||
5. **文档更新**:模板变化时更新 `description` 字段
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
- 配置文件:`electron/config/system-templates.json`
|
||||
- 前端 API:`src/api/systemTemplates.ts`
|
||||
- 后端实现:`electron/mapi/subtitleCover/main.ts`
|
||||
- 数据库初始化:`electron/mapi/db/initSystemTemplates.ts`
|
||||
- 迁移脚本:`electron/mapi/db/migration.ts` (v23)
|
||||
- 打包脚本:`scripts/prepare-package.cjs`
|
||||
- 导出脚本:`scripts/export-system-templates.js`
|
||||
|
||||
---
|
||||
|
||||
## 更新历史
|
||||
|
||||
### v1.0.0 (2026-01-15)
|
||||
- ✅ 创建系统模板配置文件
|
||||
- ✅ 实现导出功能
|
||||
- ✅ 添加开发/生产模式区分
|
||||
- ✅ 支持打包时包含配置文件
|
||||
- ✅ 实现生产模式只读保护
|
||||
@@ -0,0 +1,435 @@
|
||||
# 系统模板自动保存实现 - 完整总结
|
||||
|
||||
## 项目完成状态: ✅ 100% 完成
|
||||
|
||||
本文档总结了系统模板"编辑自动保存到配置文件"功能的完整实现。
|
||||
|
||||
---
|
||||
|
||||
## 问题陈述
|
||||
|
||||
**用户遇到的问题:**
|
||||
```
|
||||
1. 编辑字幕模板颜色 → 点保存
|
||||
2. 重启软件 → 颜色又恢复到默认值
|
||||
```
|
||||
|
||||
**根本原因:**
|
||||
- 数据保存到数据库,但重启时被 `system-templates.json` 覆盖
|
||||
- 需要一种机制自动将编辑内容保存到配置文件
|
||||
|
||||
**推荐解决方案:**
|
||||
直接编辑 → 自动保存到配置文件(而不是需要手动导出)
|
||||
|
||||
---
|
||||
|
||||
## 实现架构
|
||||
|
||||
### 1️⃣ 后端 IPC 层 (`electron/mapi/`)
|
||||
|
||||
#### 📍 `/subtitleCover/main.ts` - 核心实现 (800+ 行)
|
||||
|
||||
**新增方法 - 自动导出系列:**
|
||||
|
||||
```typescript
|
||||
// 保存字幕模板并自动导出到 JSON (开发模式)
|
||||
async saveSubtitleTemplateAndExport(template: any, isDev: boolean)
|
||||
├─ 保存到 subtitle_templates 表
|
||||
├─ 如果 isDev: 自动导出所有系统模板到 system-templates.json
|
||||
└─ 返回 {success, id, message}
|
||||
|
||||
// 保存封面模板并自动导出到 JSON (开发模式)
|
||||
async saveCoverTemplateAndExport(template: any, isDev: boolean)
|
||||
├─ 保存到 cover_templates 表
|
||||
├─ 如果 isDev: 自动导出所有系统模板到 system-templates.json
|
||||
└─ 返回 {success, id, message}
|
||||
|
||||
// 导出系统模板配置到 JSON 文件
|
||||
async exportSystemTemplatesToFile()
|
||||
├─ 从数据库读取所有系统模板 (is_system = 1)
|
||||
├─ 格式化数据
|
||||
├─ 写入 electron/config/system-templates.json
|
||||
└─ 返回 {success, filePath, message}
|
||||
```
|
||||
|
||||
**核心特性:**
|
||||
- ✅ 导出失败时只记录警告,不阻止保存
|
||||
- ✅ 只在开发模式 (isDev=true) 时导出
|
||||
- ✅ 自动导出后,JSON 始终是最新的
|
||||
|
||||
#### 📍 `/subtitleCover/register.ts` - IPC 处理程序注册 (248 行)
|
||||
|
||||
**新增 IPC 处理程序:**
|
||||
|
||||
```
|
||||
systemTemplates:saveSubtitleAndExport (line 210-226)
|
||||
└─ 调用 saveSubtitleTemplateAndExport()
|
||||
└─ 验证 isDev 模式
|
||||
|
||||
systemTemplates:saveCoverAndExport (line 229-245)
|
||||
└─ 调用 saveCoverTemplateAndExport()
|
||||
└─ 验证 isDev 模式
|
||||
```
|
||||
|
||||
#### 📍 `/subtitleCover/render.ts` - 前端包装器 (135 行)
|
||||
|
||||
**新增方法包装器:**
|
||||
|
||||
```typescript
|
||||
// 前端包装器 - 将 IPC 调用暴露给 Vue 组件
|
||||
const saveSubtitleTemplateAndExport = async (template, isDev) =>
|
||||
ipcRenderer.invoke("systemTemplates:saveSubtitleAndExport", template, isDev)
|
||||
|
||||
const saveCoverTemplateAndExport = async (template, isDev) =>
|
||||
ipcRenderer.invoke("systemTemplates:saveCoverAndExport", template, isDev)
|
||||
|
||||
// 其他系统模板方法
|
||||
const getSystemTemplatesList = () => ipcRenderer.invoke("systemTemplates:getList")
|
||||
const saveSystemTemplate = (template, isDev) => ipcRenderer.invoke("systemTemplates:save", template, isDev)
|
||||
const deleteSystemTemplate = (templateId, isDev) => ipcRenderer.invoke("systemTemplates:delete", templateId, isDev)
|
||||
const resetSystemTemplates = (isDev) => ipcRenderer.invoke("systemTemplates:reset", isDev)
|
||||
const exportSystemTemplatesToFile = (isDev) => ipcRenderer.invoke("systemTemplates:export", isDev)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 前端 API 层 (`src/api/`)
|
||||
|
||||
#### 📍 `systemTemplates.ts` - 前端 API 接口 (167 行)
|
||||
|
||||
**核心 API 函数:**
|
||||
|
||||
```typescript
|
||||
// ✨ 新增方法 - 自动导出系列
|
||||
|
||||
export async function saveSubtitleTemplateWithAutoExport(template: any)
|
||||
├─ 检查开发模式 (isDev)
|
||||
├─ 调用 IPC: systemTemplates:saveSubtitleAndExport
|
||||
└─ 自动导出到 JSON (透明到用户)
|
||||
|
||||
export async function saveCoverTemplateWithAutoExport(template: any)
|
||||
├─ 检查开发模式 (isDev)
|
||||
├─ 调用 IPC: systemTemplates:saveCoverAndExport
|
||||
└─ 自动导出到 JSON (透明到用户)
|
||||
|
||||
// 其他方法
|
||||
export async function getSystemTemplatesList()
|
||||
export async function deleteSystemTemplate(templateId: string)
|
||||
export async function resetSystemTemplates()
|
||||
export async function exportSystemTemplatesToFile()
|
||||
export async function isDevMode(): boolean
|
||||
```
|
||||
|
||||
**使用示例:**
|
||||
|
||||
```typescript
|
||||
import { saveSubtitleTemplateWithAutoExport, isDevMode } from '@/api/systemTemplates';
|
||||
|
||||
// Vue 组件中
|
||||
if (isDevMode()) {
|
||||
const result = await saveSubtitleTemplateWithAutoExport({
|
||||
type: 'subtitle',
|
||||
id: 'template_system_11',
|
||||
name: '11',
|
||||
description: '系统字幕模板 11',
|
||||
config: { /* 配置对象 */ }
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 自动导出已完成!
|
||||
console.log('模板已保存并导出到 system-templates.json');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 开发模式检测 (`src/composables/`)
|
||||
|
||||
#### 📍 `useDevMode.ts` - 开发模式 Composable (46 行) ✨ NEW
|
||||
|
||||
```typescript
|
||||
import { useDevMode } from '@/composables/useDevMode';
|
||||
|
||||
export function useDevMode() {
|
||||
// 返回 { isDev: readonly(ref) }
|
||||
}
|
||||
|
||||
// 在 Vue 组件中使用
|
||||
const { isDev } = useDevMode();
|
||||
|
||||
if (isDev.value) {
|
||||
// 显示系统模板编辑界面
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整的数据流
|
||||
|
||||
### 使用场景:编辑字幕模板颜色
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Vue 组件: 系统模板编辑器 │
|
||||
│ (需要创建) │
|
||||
└──────────────┬──────────────────────┘
|
||||
│ 用户编辑颜色 → 点保存
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ src/api/systemTemplates.ts │
|
||||
│ saveSubtitleTemplateWithAutoExport │
|
||||
│ (自动检查 isDev) │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Electron IPC - Main Process │
|
||||
│ systemTemplates:saveSubtitleAndExport
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ electron/mapi/subtitleCover/ │
|
||||
│ main.ts:saveSubtitleTemplateAnd │
|
||||
│ Export() │
|
||||
│ │
|
||||
│ 1️⃣ 保存到数据库 │
|
||||
│ UPDATE subtitle_templates │
|
||||
│ 2️⃣ 自动导出到 JSON │
|
||||
│ exportSystemTemplatesToFile() │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 文件系统 │
|
||||
│ │
|
||||
│ ✅ sqlite: data.db (更新) │
|
||||
│ ✅ json: system-templates.json │
|
||||
│ (新颜色已保存) │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
重启应用:
|
||||
├─ 读取 system-templates.json
|
||||
├─ 初始化数据库
|
||||
└─ ✅ 颜色保留! (编辑内容已保存到 JSON)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现检查清单
|
||||
|
||||
### ✅ 后端实现
|
||||
|
||||
- [x] `saveSubtitleTemplateAndExport()` 方法 (main.ts:805-850)
|
||||
- [x] `saveCoverTemplateAndExport()` 方法 (main.ts:855-900)
|
||||
- [x] `exportSystemTemplatesToFile()` 方法 (main.ts:905-988)
|
||||
- [x] IPC 处理程序注册 (register.ts:210-245)
|
||||
- [x] 错误处理 - 导出失败不阻止保存
|
||||
- [x] isDev 模式检查
|
||||
|
||||
### ✅ 前端实现
|
||||
|
||||
- [x] `render.ts` 中的 IPC 包装器 (15 个方法,包括新增7个)
|
||||
- [x] `systemTemplates.ts` API 接口 (7 个公开函数)
|
||||
- [x] `useDevMode.ts` Composable ✨ NEW
|
||||
- [x] 开发/生产模式检测
|
||||
- [x] API 文档和使用示例
|
||||
|
||||
### ✅ 数据库
|
||||
|
||||
- [x] `subtitle_templates` 表 (创建于 migration v23)
|
||||
- 字段: id, name, description, config, is_system, readonly, created_at, updated_at
|
||||
- [x] `cover_templates` 表
|
||||
- 新增: readonly 列
|
||||
|
||||
### ✅ 配置文件
|
||||
|
||||
- [x] `electron/config/system-templates.json` (系统模板配置)
|
||||
- [x] `electron/config/default-templates.json` (默认配置)
|
||||
- [x] 版本管理: version, timestamp, description
|
||||
|
||||
---
|
||||
|
||||
## 关键特性说明
|
||||
|
||||
### 🔑 自动导出机制
|
||||
|
||||
```
|
||||
用户保存 ──→ 数据库更新 ──→ 自动导出 JSON ──→ 重启时读取 JSON
|
||||
│
|
||||
└─ 只在开发模式 (isDev=true)
|
||||
└─ 失败时只记录日志,不中断保存
|
||||
```
|
||||
|
||||
### 🔒 生产模式保护
|
||||
|
||||
```
|
||||
生产环境:
|
||||
├─ isDev = false
|
||||
├─ readonly = 1 (系统模板)
|
||||
└─ 用户无法调用 saveXxxAndExport() 方法 (前端检查)
|
||||
└─ IPC 处理程序验证 isDev (后端检查)
|
||||
```
|
||||
|
||||
### 🔄 数据一致性
|
||||
|
||||
由于自动导出机制:
|
||||
- JSON 始终包含最新的系统模板配置
|
||||
- 每次重启时直接覆盖数据库 (因为 JSON 是最新的)
|
||||
- 不需要复杂的"跳过覆盖"逻辑
|
||||
|
||||
---
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 开发模式下编辑系统模板
|
||||
|
||||
#### 方法 1: 使用应用 UI (需要创建 UI 组件)
|
||||
|
||||
```typescript
|
||||
import { saveSubtitleTemplateWithAutoExport, isDevMode } from '@/api/systemTemplates';
|
||||
|
||||
// 在系统模板编辑组件中
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!isDevMode()) {
|
||||
Message.error('只能在开发模式下编辑系统模板');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await saveSubtitleTemplateWithAutoExport({
|
||||
type: 'subtitle',
|
||||
id: 'template_system_11',
|
||||
name: '11',
|
||||
config: { /* 新配置 */ }
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
Message.success('模板已保存并自动导出到 system-templates.json');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 方法 2: 使用脚本导出
|
||||
|
||||
```bash
|
||||
# 从数据库导出所有系统模板配置到 system-templates.json
|
||||
node scripts/export-system-templates.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题: 编辑后不会自动导出
|
||||
|
||||
**检查:**
|
||||
1. 确保在开发模式: `npm run dev:win`
|
||||
2. 检查浏览器控制台是否有错误
|
||||
3. 检查后端日志 (initSystemTemplates, exportSystemTemplatesToFile)
|
||||
4. 确认使用的是新的 API: `saveSubtitleTemplateWithAutoExport()`
|
||||
|
||||
### 问题: JSON 文件未更新
|
||||
|
||||
**解决:**
|
||||
```bash
|
||||
# 手动触发导出
|
||||
await window.$mapi.subtitleCover.exportSystemTemplatesToFile(true);
|
||||
|
||||
# 或使用脚本
|
||||
node scripts/export-system-templates.js
|
||||
```
|
||||
|
||||
### 问题: 生产模式仍可编辑
|
||||
|
||||
**检查:**
|
||||
```
|
||||
npm run build:win # 确保生产构建
|
||||
# 检查环境变量: ELECTRON_ENV_PROD=1
|
||||
# 检查数据库: SELECT * FROM subtitle_templates WHERE is_system=1;
|
||||
# 应该看到 readonly=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件变更总结
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 行数 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/composables/useDevMode.ts` | 46 | 开发模式检测 Composable ✨ NEW |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 变更 | 说明 |
|
||||
|------|------|------|
|
||||
| `electron/mapi/subtitleCover/main.ts` | +100 | 新增 saveXxxAndExport() 和 exportSystemTemplatesToFile() |
|
||||
| `electron/mapi/subtitleCover/register.ts` | +36 | 新增 2 个 IPC 处理程序 |
|
||||
| `electron/mapi/subtitleCover/render.ts` | +40 | 新增 7 个方法包装器 ✨ |
|
||||
| `src/api/systemTemplates.ts` | 现存 | 已使用新 API (saveXxxWithAutoExport) |
|
||||
| `electron/mapi/db/initSystemTemplates.ts` | 修改 | 改为直接覆盖 (因为 JSON 始终最新) |
|
||||
|
||||
---
|
||||
|
||||
## 下一步计划
|
||||
|
||||
### 可选: 创建系统模板编辑 UI
|
||||
|
||||
如果需要在应用中提供 UI 来编辑系统模板:
|
||||
|
||||
```typescript
|
||||
// 创建文件: src/pages/Settings/SystemTemplates.vue
|
||||
// 功能:
|
||||
// - 列表显示所有系统模板
|
||||
// - 编辑字幕/封面模板配置
|
||||
// - 保存时自动导出到 JSON
|
||||
// - 只在开发模式下显示
|
||||
```
|
||||
|
||||
### 可选: 集成到现有 UI
|
||||
|
||||
在现有的 CoverSettingsDialog 或其他模板编辑器中:
|
||||
- 添加"系统模板"标签
|
||||
- 在开发模式下允许编辑
|
||||
- 点保存时自动导出
|
||||
|
||||
---
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 数据流验证
|
||||
|
||||
```
|
||||
✅ Vue Component
|
||||
↓ (saveSubtitleTemplateWithAutoExport)
|
||||
✅ src/api/systemTemplates.ts
|
||||
↓ (window.$mapi.subtitleCover.xxx)
|
||||
✅ electron/mapi/subtitleCover/render.ts
|
||||
↓ (ipcRenderer.invoke)
|
||||
✅ Electron Main Process
|
||||
↓ (ipcMain.handle)
|
||||
✅ electron/mapi/subtitleCover/register.ts
|
||||
↓ (subtitleCoverGenerator!.xxx)
|
||||
✅ electron/mapi/subtitleCover/main.ts
|
||||
├─ 数据库操作 (DB.execute/select)
|
||||
└─ 文件操作 (fs.writeFileSync)
|
||||
✅ 最终结果:
|
||||
├─ data.db (数据库更新)
|
||||
└─ system-templates.json (配置文件更新)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **系统模板自动保存功能已完整实现**
|
||||
|
||||
用户现在可以:
|
||||
1. 在开发模式下编辑系统模板
|
||||
2. 点保存 → 自动保存到数据库 + 自动导出到 JSON
|
||||
3. 重启应用 → 编辑内容被保留 ✅
|
||||
4. 提交 system-templates.json → 包含在下次发行版
|
||||
|
||||
所有 API 都已准备好,可以在 Vue 组件中直接使用。
|
||||
@@ -0,0 +1,507 @@
|
||||
# 系统模板设置和使用指南
|
||||
|
||||
## 📍 在哪里创建系统模板?
|
||||
|
||||
现在您可以直接在现有的模板编辑器中创建系统模板,**无需创建专门的管理页面**。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新功能:在模板编辑器中设为系统模板
|
||||
|
||||
### 位置
|
||||
|
||||
**封面模板编辑器**
|
||||
|
||||
位置: `视频` → `创建视频` → `编辑封面模板`
|
||||
|
||||
或: 在 `CoverSettingsDialog` 中编辑封面模板
|
||||
|
||||
文件: `src/pages/Video/components/CoverCustomEditor/index.vue`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 使用流程
|
||||
|
||||
### 第 1 步:编辑模板
|
||||
|
||||
1. 进入 **视频** 页面
|
||||
2. 创建或编辑**封面模板**
|
||||
3. 设计您的模板内容(使用 Canvas 编辑器)
|
||||
|
||||
### 第 2 步:设为系统模板(仅开发模式)
|
||||
|
||||
**⚠️ 仅在开发模式下可用**
|
||||
|
||||
在模板编辑器底部,您会看到:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ ✨ 设为系统模板 │
|
||||
│ (保存时自动导出到配置文件) │
|
||||
│ [☐] ← 勾选此选项 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ [取消] [保存系统模板] │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**如何使用:**
|
||||
|
||||
1. **创建新模板** → 设计内容
|
||||
2. **勾选** "✨ 设为系统模板" 选项
|
||||
3. **点击保存** → 系统会自动:
|
||||
- ✅ 保存到数据库
|
||||
- ✅ 导出到 `system-templates.json`
|
||||
- ✅ 显示成功提示
|
||||
|
||||
### 第 3 步:验证导出
|
||||
|
||||
保存成功后,会看到提示:
|
||||
|
||||
```
|
||||
✅ 系统模板已保存并自动导出到配置文件
|
||||
```
|
||||
|
||||
模板已自动导出到: `electron/config/system-templates.json`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流程图
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ 打开封面模板编辑器 │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ 编辑模板(设计内容) │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ 在开发模式下? │
|
||||
└────┬──────────────┬─────┘
|
||||
│ 是 │ 否
|
||||
▼ ▼
|
||||
✅ 显示 ❌ 不显示
|
||||
系统模板选项 系统模板选项
|
||||
│ │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 勾选系统模板 │
|
||||
└────────┬───────┘
|
||||
│ (可选)
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ 点击"保存系统模板" │
|
||||
└────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ 后端处理: │
|
||||
│ 1. 保存到 data.db │
|
||||
│ 2. 导出到 JSON 文件 │
|
||||
└────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ ✅ 系统模板已保存 │
|
||||
│ 并自动导出! │
|
||||
└─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ 重启应用后: │
|
||||
│ 自动加载此系统模板 │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 功能说明
|
||||
|
||||
### 开发模式检测
|
||||
|
||||
系统会自动检测您是否在开发模式:
|
||||
|
||||
```typescript
|
||||
// 开发模式 ✅
|
||||
npm run dev:win
|
||||
npm run dev:mac
|
||||
|
||||
// 生产模式 ❌
|
||||
npm run build:win
|
||||
npm run build:mac
|
||||
export ELECTRON_ENV_PROD=1
|
||||
```
|
||||
|
||||
**只有在开发模式下,才会显示"设为系统模板"选项。**
|
||||
|
||||
---
|
||||
|
||||
## 🔒 生产模式保护
|
||||
|
||||
### 生产环境中的行为
|
||||
|
||||
在生产模式下:
|
||||
|
||||
✅ **系统模板是只读的**
|
||||
- 用户无法编辑系统模板
|
||||
- 无法看到"设为系统模板"选项
|
||||
- 系统模板被标记为 `readonly=1`
|
||||
|
||||
✅ **用户可以创建自己的模板**
|
||||
- 自定义模板保存到数据库
|
||||
- 不会自动导出到 JSON
|
||||
- 用户可以编辑和删除自己的模板
|
||||
|
||||
---
|
||||
|
||||
## 💾 自动导出机制
|
||||
|
||||
### 什么被导出?
|
||||
|
||||
当您勾选"设为系统模板"并保存时:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "系统内置模板配置",
|
||||
"timestamp": "2026-01-15T...",
|
||||
"subtitleTemplates": [ /* ... */ ],
|
||||
"coverTemplates": [
|
||||
{
|
||||
"id": "your_template_id",
|
||||
"name": "您的模板名称",
|
||||
"description": "模板描述",
|
||||
"is_system": 1,
|
||||
"readonly": 0,
|
||||
"config": { /* 完整的模板配置 */ },
|
||||
"createdAt": 1768396885438
|
||||
}
|
||||
// ... 其他系统模板
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 文件位置
|
||||
|
||||
导出位置: `electron/config/system-templates.json`
|
||||
|
||||
### 什么时候导出?
|
||||
|
||||
- ✅ 保存时自动导出(勾选了"设为系统模板")
|
||||
- ✅ 修改系统模板时自动导出
|
||||
- ✅ 每次导出都会更新时间戳
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 修改的文件
|
||||
|
||||
**`src/pages/Video/components/CoverCustomEditor/index.vue`**
|
||||
|
||||
新增功能:
|
||||
- 开发模式检测 (`useDevMode()`)
|
||||
- 系统模板选项 (`isSystemTemplate` ref)
|
||||
- 自动导出逻辑 (`saveCoverTemplateWithAutoExport()`)
|
||||
- 保存时的条件判断
|
||||
|
||||
新增状态变量:
|
||||
```typescript
|
||||
const { isDev } = useDevMode(); // 开发模式检测
|
||||
const isSystemTemplate = ref(false); // 系统模板选项
|
||||
const isSaving = ref(false); // 保存状态
|
||||
```
|
||||
|
||||
新增保存逻辑:
|
||||
```typescript
|
||||
if (isSystemTemplate.value && isDev) {
|
||||
// 使用自动导出 API 保存系统模板
|
||||
await saveCoverTemplateWithAutoExport({...});
|
||||
} else {
|
||||
// 普通模板保存
|
||||
await saveCoverTemplate({...});
|
||||
}
|
||||
```
|
||||
|
||||
### 相关 API
|
||||
|
||||
**前端 API** (`src/api/systemTemplates.ts`):
|
||||
- `saveCoverTemplateWithAutoExport(template)` - 保存并自动导出
|
||||
- `saveSubtitleTemplateWithAutoExport(template)` - 字幕模板(如适用)
|
||||
- `isDevMode()` - 检查开发模式
|
||||
|
||||
**后端 IPC** (`electron/mapi/subtitleCover/`):
|
||||
- `systemTemplates:saveCoverAndExport` - IPC 处理程序
|
||||
- `saveCoverTemplateAndExport()` - 后端实现
|
||||
|
||||
**Composable** (`src/composables/useDevMode.ts`):
|
||||
- `useDevMode()` - 开发模式检测 hook
|
||||
|
||||
---
|
||||
|
||||
## 📊 模板数据结构
|
||||
|
||||
### 覆盖模板配置示例
|
||||
|
||||
```typescript
|
||||
{
|
||||
// 基础信息
|
||||
id: "template_custom_1234567890",
|
||||
name: "我的系统模板",
|
||||
description: "这是一个自定义的系统模板",
|
||||
|
||||
// 系统标记
|
||||
is_system: 1, // 1=系统模板, 0=用户模板
|
||||
readonly: 0, // 1=只读(生产), 0=可编辑(开发)
|
||||
|
||||
// 时间戳
|
||||
created_at: 1768396885438,
|
||||
updated_at: 1768396885438,
|
||||
|
||||
// 完整的设计配置
|
||||
config: {
|
||||
name: "我的系统模板",
|
||||
backgroundEnabled: true,
|
||||
backgroundBlurEnabled: true,
|
||||
backgroundBlurIntensity: 10,
|
||||
// ... 其他 1000+ 个配置字段
|
||||
titleText: "标题",
|
||||
subtitleText: "副标题",
|
||||
// ... 完整的封面配置
|
||||
},
|
||||
|
||||
// 可选
|
||||
thumbnailPath: "/path/to/thumbnail.png"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证导出成功
|
||||
|
||||
### 方法 1:检查文件变化
|
||||
|
||||
```bash
|
||||
# 检查 system-templates.json 文件是否已更新
|
||||
ls -la electron/config/system-templates.json
|
||||
|
||||
# 查看文件内容(搜索您的模板 ID)
|
||||
cat electron/config/system-templates.json | grep "your_template_id"
|
||||
```
|
||||
|
||||
### 方法 2:查看浏览器控制台
|
||||
|
||||
保存系统模板时,会输出日志:
|
||||
|
||||
```
|
||||
[CoverCustomEditor] 保存为系统模板: template_custom_1234567890
|
||||
[CoverCustomEditor] 系统模板已导出: template_custom_1234567890
|
||||
```
|
||||
|
||||
### 方法 3:检查数据库
|
||||
|
||||
```sql
|
||||
-- 检查数据库中的系统模板
|
||||
SELECT id, name, is_system, readonly FROM cover_templates
|
||||
WHERE is_system = 1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- 输出示例:
|
||||
-- id name is_system readonly
|
||||
-- template_custom_1234567890 我的系统模板 1 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 下次启动时的行为
|
||||
|
||||
### 应用启动流程
|
||||
|
||||
```
|
||||
1️⃣ 应用启动
|
||||
↓
|
||||
2️⃣ initSystemTemplates() 运行
|
||||
├─ 读取 system-templates.json
|
||||
└─ 初始化数据库
|
||||
↓
|
||||
3️⃣ 您的系统模板被加载
|
||||
├─ is_system = 1(标记为系统模板)
|
||||
├─ readonly = 0(开发模式可编辑)
|
||||
└─ 完整的配置被加载到数据库
|
||||
↓
|
||||
4️⃣ 应用正常运行
|
||||
└─ 您的系统模板可以被选择和使用
|
||||
```
|
||||
|
||||
### 生产模式下
|
||||
|
||||
```
|
||||
1️⃣ 应用启动
|
||||
2️⃣ initSystemTemplates() 运行
|
||||
3️⃣ 您的系统模板被加载
|
||||
└─ readonly = 1(设为只读)
|
||||
4️⃣ 用户无法编辑系统模板
|
||||
├─ UI 不显示编辑选项
|
||||
├─ IPC 调用被拒绝
|
||||
└─ 数据库标记为只读
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎁 额外特性
|
||||
|
||||
### 自动导出失败处理
|
||||
|
||||
如果导出到 JSON 失败:
|
||||
|
||||
✅ **保存操作仍然成功**
|
||||
- 数据已保存到数据库
|
||||
- 只是 JSON 导出失败
|
||||
- 后端会记录警告日志
|
||||
|
||||
❌ **不会中断保存过程**
|
||||
|
||||
示例:
|
||||
```
|
||||
[CoverCustomEditor] 保存为系统模板: template_id
|
||||
[CoverCustomEditor] 系统模板已导出: template_id
|
||||
✅ 系统模板已保存并自动导出到配置文件
|
||||
```
|
||||
|
||||
### 时间戳自动更新
|
||||
|
||||
每次导出都会自动更新时间戳:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-01-15T14:30:45.123Z",
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 常见问题
|
||||
|
||||
### Q1:在哪里找到系统模板选项?
|
||||
|
||||
**A:** 在模板编辑器底部,只有在**开发模式**下才会显示。
|
||||
|
||||
```
|
||||
开发模式: npm run dev:win
|
||||
查看位置: 底部按钮上方
|
||||
```
|
||||
|
||||
### Q2:为什么看不到系统模板选项?
|
||||
|
||||
**A:** 可能原因:
|
||||
|
||||
1. ❌ 不在开发模式
|
||||
```bash
|
||||
# 检查是否开发模式
|
||||
npm run dev:win # ✅ 正确
|
||||
npm run build:win # ❌ 这是生产构建
|
||||
```
|
||||
|
||||
2. ❌ 设置了生产环境变量
|
||||
```bash
|
||||
unset ELECTRON_ENV_PROD # 清除环境变量
|
||||
```
|
||||
|
||||
### Q3:系统模板和普通模板的区别?
|
||||
|
||||
| 特性 | 系统模板 | 普通模板 |
|
||||
|------|--------|--------|
|
||||
| 开发模式可编辑 | ✅ | ✅ |
|
||||
| 生产模式只读 | ✅ | ❌ |
|
||||
| 自动导出到 JSON | ✅ | ❌ |
|
||||
| 包含在发行版中 | ✅ | ❌ |
|
||||
| 用户可删除 | ❌ | ✅ |
|
||||
| is_system 标记 | 1 | 0 |
|
||||
|
||||
### Q4:如何修改已保存的系统模板?
|
||||
|
||||
**A:** 再次编辑并保存:
|
||||
|
||||
1. 打开模板编辑器
|
||||
2. 修改内容
|
||||
3. **再次勾选** "✨ 设为系统模板"
|
||||
4. 点击保存
|
||||
5. 配置文件会自动更新
|
||||
|
||||
---
|
||||
|
||||
## 🎓 工作原理总结
|
||||
|
||||
### 简化流程
|
||||
|
||||
```
|
||||
编辑模板 + 勾选"系统模板" + 点保存
|
||||
↓
|
||||
自动执行后端逻辑
|
||||
├─ 保存到 data.db
|
||||
├─ 导出到 system-templates.json
|
||||
└─ 更新时间戳
|
||||
↓
|
||||
重启应用时
|
||||
├─ 从 JSON 加载配置
|
||||
└─ 初始化到数据库
|
||||
↓
|
||||
✅ 系统模板被成功保留
|
||||
```
|
||||
|
||||
### 关键优势
|
||||
|
||||
✨ **无需手动导出** - 自动完成
|
||||
✨ **不需要新页面** - 复用现有编辑器
|
||||
✨ **开发友好** - 编辑即导出
|
||||
✨ **生产安全** - 系统模板只读
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
### 查看日志
|
||||
|
||||
**浏览器控制台:**
|
||||
```
|
||||
F12 → Console → 搜索 [CoverCustomEditor]
|
||||
```
|
||||
|
||||
**应用日志:**
|
||||
```
|
||||
electron/mapi/log/main.ts
|
||||
搜索关键字: saveSubtitleTemplateAndExport, saveCoverTemplateAndExport
|
||||
```
|
||||
|
||||
### 调试
|
||||
|
||||
```typescript
|
||||
// 检查开发模式
|
||||
console.log('isDev:', isDev.value);
|
||||
|
||||
// 检查系统模板选项
|
||||
console.log('isSystemTemplate:', isSystemTemplate.value);
|
||||
|
||||
// 检查导出结果
|
||||
console.log('[CoverCustomEditor] 系统模板已导出');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
现在您可以:
|
||||
|
||||
1. ✅ 在模板编辑器中创建和设计系统模板
|
||||
2. ✅ 勾选选项自动导出到 JSON
|
||||
3. ✅ 重启应用后自动加载系统模板
|
||||
4. ✅ 生产版本中系统模板保持只读
|
||||
5. ✅ 无需创建专门的管理页面
|
||||
|
||||
**就这么简单!** 🚀
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# 日志管理说明
|
||||
|
||||
## 问题说明
|
||||
|
||||
当前代码中存在大量的 `console.log` 语句(VideoIPAgent.vue 中有 865 个),这会导致:
|
||||
- 控制台日志过多,影响性能
|
||||
- 生产环境中暴露过多调试信息
|
||||
- 浏览器开发者工具可能卡死
|
||||
|
||||
## 解决方案
|
||||
|
||||
已创建日志管理工具 `src/utils/logger.ts`,提供以下功能:
|
||||
|
||||
### 1. 自动环境检测
|
||||
- **开发环境**:默认显示所有日志
|
||||
- **生产环境**:默认只显示错误日志
|
||||
|
||||
### 2. 日志级别控制
|
||||
```typescript
|
||||
enum LogLevel {
|
||||
DEBUG = 0, // 调试信息
|
||||
INFO = 1, // 一般信息
|
||||
WARN = 2, // 警告
|
||||
ERROR = 3, // 错误
|
||||
NONE = 4 // 不显示任何日志
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 使用方法
|
||||
|
||||
#### 在代码中使用
|
||||
```typescript
|
||||
// 导入日志工具
|
||||
import { logger, log, debug, info, warn, error } from '@/utils/logger';
|
||||
|
||||
// 替换 console.log
|
||||
log('用户点击了按钮');
|
||||
|
||||
// 替换 console.debug
|
||||
debug('详细调试信息', { data: someData });
|
||||
|
||||
// 替换 console.info
|
||||
info('任务开始执行');
|
||||
|
||||
// 替换 console.warn
|
||||
warn('配置项缺失,使用默认值');
|
||||
|
||||
// 替换 console.error
|
||||
error('请求失败', error);
|
||||
|
||||
// 分组日志
|
||||
group('任务执行详情');
|
||||
log('步骤1: 初始化');
|
||||
log('步骤2: 处理数据');
|
||||
groupEnd();
|
||||
```
|
||||
|
||||
#### 在控制台中动态控制
|
||||
打开浏览器控制台,输入以下命令:
|
||||
|
||||
```javascript
|
||||
// 关闭所有日志
|
||||
__disableLog()
|
||||
|
||||
// 开启所有日志
|
||||
__enableLog()
|
||||
|
||||
// 设置日志级别(只显示错误)
|
||||
__setLogLevel(3)
|
||||
|
||||
// 设置日志级别(显示所有)
|
||||
__setLogLevel(0)
|
||||
|
||||
// 查看当前设置
|
||||
__logger.getLevel()
|
||||
__logger.isEnabled()
|
||||
```
|
||||
|
||||
### 4. 迁移计划
|
||||
|
||||
为避免一次性修改过多代码导致问题,建议分步骤迁移:
|
||||
|
||||
#### 第一步:关键文件优先(已完成创建工具)
|
||||
- ✅ 创建日志工具 `logger.ts`
|
||||
|
||||
#### 第二步:高频文件迁移(建议优先处理)
|
||||
- [ ] VideoIPAgent.vue (865个console)
|
||||
- [ ] SubtitleStyleSelector.vue (107个console)
|
||||
|
||||
#### 第三步:其他文件逐步迁移
|
||||
- [ ] 其他Vue组件
|
||||
- [ ] Service层
|
||||
- [ ] Store层
|
||||
|
||||
### 5. 性能优化建议
|
||||
|
||||
#### 立即可用的优化
|
||||
即使不修改代码,也可以在生产环境的启动脚本中添加:
|
||||
|
||||
```javascript
|
||||
// 在 main.ts 或 App.vue 的最前面添加
|
||||
if (import.meta.env.MODE === 'production') {
|
||||
// 生产环境禁用所有console
|
||||
console.log = () => {};
|
||||
console.debug = () => {};
|
||||
console.info = () => {};
|
||||
// 保留 warn 和 error
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用日志工具后的优化
|
||||
```typescript
|
||||
// 在 main.ts 中配置
|
||||
import { logger, LogLevel } from '@/utils/logger';
|
||||
|
||||
// 生产环境只显示错误
|
||||
if (import.meta.env.MODE === 'production') {
|
||||
logger.setLevel(LogLevel.ERROR);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 日志缓存功能
|
||||
|
||||
日志工具内置了缓存功能,最多保留1000条最近的日志:
|
||||
|
||||
```javascript
|
||||
// 查看缓存的日志
|
||||
__logger.getCache()
|
||||
|
||||
// 清空日志缓存
|
||||
__logger.clearCache()
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 替换前
|
||||
```typescript
|
||||
console.log('[VideoGen.onTaskSubmitted] 📥 任务已提交');
|
||||
console.log('[VideoGen.onTaskSubmitted] 📥 开始刷新任务列表...');
|
||||
console.log('[VideoGen.onTaskSubmitted] ✓ 任务列表刷新完成');
|
||||
```
|
||||
|
||||
### 替换后
|
||||
```typescript
|
||||
import { log } from '@/utils/logger';
|
||||
|
||||
log('[VideoGen.onTaskSubmitted] 📥 任务已提交');
|
||||
log('[VideoGen.onTaskSubmitted] 📥 开始刷新任务列表...');
|
||||
log('[VideoGen.onTaskSubmitted] ✓ 任务列表刷新完成');
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **保持原有功能**:日志工具的API与console保持一致,迁移后不影响现有功能
|
||||
2. **逐步迁移**:不要一次性替换所有console,建议按文件逐步迁移
|
||||
3. **性能优先**:优先迁移日志最多的文件(如VideoIPAgent.vue)
|
||||
4. **保留错误日志**:即使在生产环境,也应保留error级别的日志用于问题排查
|
||||
|
||||
## 快速禁用日志(临时方案)
|
||||
|
||||
如果当前日志已经影响性能,可以临时在浏览器控制台执行:
|
||||
|
||||
```javascript
|
||||
// 方案1:使用日志工具(推荐)
|
||||
__disableLog()
|
||||
|
||||
// 方案2:直接禁用console(适用于紧急情况)
|
||||
console.log = () => {};
|
||||
console.debug = () => {};
|
||||
console.info = () => {};
|
||||
```
|
||||
|
||||
重新刷新页面后,日志工具的设置会自动保存并生效。
|
||||
@@ -0,0 +1,213 @@
|
||||
// @see https://www.electron.build/configuration/configuration
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "IP",
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates"
|
||||
},
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"scripts/**/*"
|
||||
],
|
||||
"npmRebuild": true,
|
||||
"productName": "天英超级IP智能体",
|
||||
"directories": {
|
||||
"output": "dist-release-final",
|
||||
"buildResources": "electron/resources/build"
|
||||
},
|
||||
"afterPack": "./scripts/build_optimize.cjs",
|
||||
"files": [
|
||||
"dist",
|
||||
"dist-electron",
|
||||
"node_modules",
|
||||
{
|
||||
"from": "demo",
|
||||
"to": "demo",
|
||||
"filter": [
|
||||
"*.md",
|
||||
"*.html"
|
||||
]
|
||||
},
|
||||
"scripts"
|
||||
],
|
||||
"win": {
|
||||
icon: "electron/resources/build/logo.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources/extra",
|
||||
"to": "extra",
|
||||
"filter": [
|
||||
"common/config/**/*",
|
||||
"common/default-config.json",
|
||||
"common/resource-manifest.json",
|
||||
"common/cover-templates/**/*",
|
||||
"common/shuiziren/**/*",
|
||||
"common/soundEffects/**/*",
|
||||
"win-x86/**/*",
|
||||
"win-x86-backup/**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/extra/common/playwright",
|
||||
"to": "resources-bundles/playwright"
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/extra/common/python",
|
||||
"to": "resources-bundles/python-runtime"
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/extra/win-x86",
|
||||
"to": "resources-bundles/ffmpeg"
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/extra/common/soundEffects",
|
||||
"to": "resources-bundles/soundEffects"
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/extra/common/models",
|
||||
"to": "resources-bundles/models"
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/extra/common/fonts",
|
||||
"to": "resources-bundles/fonts"
|
||||
},
|
||||
{
|
||||
"from": "ziti",
|
||||
"to": "resources-bundles/ziti"
|
||||
},
|
||||
{
|
||||
"from": "bgm",
|
||||
"to": "resources-bundles/bgm"
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/voice-preview-cache",
|
||||
"to": "voice-preview-cache"
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/build",
|
||||
"to": "build",
|
||||
"filter": [
|
||||
"tray.png",
|
||||
"tray.ico",
|
||||
"logo.png",
|
||||
"logo.ico",
|
||||
"vc_redist.x64.exe"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "${productName}-${version}-win-setup-${arch}.${ext}",
|
||||
"shortcutName": "${productName}",
|
||||
"uninstallDisplayName": "${productName}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"differentialPackage": true,
|
||||
"include": "scripts/installer.nsh",
|
||||
"warningsAsErrors": false,
|
||||
"runAfterFinish": true,
|
||||
"allowElevation": true,
|
||||
"installerIcon": "electron/resources/build/logo.ico",
|
||||
"uninstallerIcon": "electron/resources/build/logo.ico",
|
||||
"installerHeaderIcon": "electron/resources/build/logo.ico"
|
||||
},
|
||||
"appx": {
|
||||
"identityName": "IP",
|
||||
"publisher": "CN=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXX",
|
||||
"publisherDisplayName": "天英超级IP智能体",
|
||||
"languages": [
|
||||
"zh-CN",
|
||||
"en-US",
|
||||
"zh-TW"
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"icon": "logo.icns",
|
||||
"target": [
|
||||
{
|
||||
"target": "dir",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources/extra",
|
||||
"to": "extra",
|
||||
"filter": [
|
||||
"common",
|
||||
"osx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/voice-preview-cache",
|
||||
"to": "voice-preview-cache"
|
||||
}
|
||||
],
|
||||
"x64ArchFiles": "Contents/Resources/extra/**/*",
|
||||
"entitlementsInherit": "./entitlements.mac.plist",
|
||||
"entitlements": "./entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
|
||||
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
|
||||
},
|
||||
"type": "development",
|
||||
"notarize": false,
|
||||
"darkModeSupport": false,
|
||||
"hardenedRuntime": true,
|
||||
"identity": "Xi'an Yanyi Information Technology Co., Ltd (Q96H3H33RK)"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "logo.icns",
|
||||
"desktop": "logo",
|
||||
"maintainer": "天英超级IP智能体",
|
||||
"category": "Utility",
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources/extra",
|
||||
"to": "extra",
|
||||
"filter": [
|
||||
"common",
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/voice-preview-cache",
|
||||
"to": "voice-preview-cache"
|
||||
}
|
||||
]
|
||||
},
|
||||
"afterSign": "./scripts/notarize.cjs"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { VideoSubtitleCoverGenerator } from "./aigcserver/VideoSubtitleCoverGenerator";
|
||||
import { EasyServer } from "./aigcserver/EasyServer";
|
||||
import { ServerLive } from "./aigcserver/server-live";
|
||||
|
||||
export const AigcServer = {
|
||||
VideoSubtitleCoverGenerator,
|
||||
EasyServer,
|
||||
"server-live": ServerLive,
|
||||
};
|
||||
|
||||
export default AigcServer;
|
||||
@@ -0,0 +1,574 @@
|
||||
import {Files} from "../mapi/file/main";
|
||||
import {Log} from "../mapi/log/main";
|
||||
import {SendType, ServerApiType, ServerFunctionDataType, ServerInfo} from "../mapi/server/type";
|
||||
import {AigcServerUtil} from "./util";
|
||||
import path from "path";
|
||||
import {getFFmpegExecutablePath, resourceExists} from "../lib/resource-path";
|
||||
|
||||
type LauncherResultType = {
|
||||
result: {
|
||||
[key: string]: any;
|
||||
};
|
||||
endTime: number | null;
|
||||
};
|
||||
|
||||
const normalizeEasyServerFunctions = (functions: Record<string, any> = {}) => {
|
||||
const normalized = {
|
||||
...functions,
|
||||
};
|
||||
if (normalized.speechRecognition && !normalized.asr) {
|
||||
normalized.asr = normalized.speechRecognition;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const EasyServer = function (config: any) {
|
||||
const me = this;
|
||||
let controller: any = null;
|
||||
let controllerWatching = {
|
||||
id: null as string | null,
|
||||
launcherResult: null as LauncherResultType | null,
|
||||
resolve: null as ((value: any) => void) | null,
|
||||
reject: null as ((reason?: any) => void) | null,
|
||||
promiseResolved: false,
|
||||
};
|
||||
const resetControllerWatching = () => {
|
||||
controllerWatching.id = null;
|
||||
controllerWatching.launcherResult = null;
|
||||
controllerWatching.resolve = null;
|
||||
controllerWatching.reject = null;
|
||||
controllerWatching.promiseResolved = false;
|
||||
};
|
||||
const settleControllerWatching = async (type: "resolve" | "reject", reason?: any, stopController = false) => {
|
||||
if (controllerWatching.promiseResolved) {
|
||||
return;
|
||||
}
|
||||
controllerWatching.promiseResolved = true;
|
||||
const resolve = controllerWatching.resolve;
|
||||
const reject = controllerWatching.reject;
|
||||
if (stopController && controller) {
|
||||
const runningController = controller;
|
||||
controller = null;
|
||||
try {
|
||||
await runningController.stop();
|
||||
} catch (e) {
|
||||
Log.warn("easyServer.settle.stop.error", e);
|
||||
}
|
||||
}
|
||||
if (type === "resolve") {
|
||||
resolve?.(undefined);
|
||||
} else {
|
||||
reject?.(reason);
|
||||
}
|
||||
};
|
||||
this.serverConfig = {
|
||||
...(config || {}),
|
||||
easyServer: {
|
||||
...(config?.easyServer || {}),
|
||||
functions: normalizeEasyServerFunctions(config?.easyServer?.functions || {}),
|
||||
},
|
||||
} as {
|
||||
easyServer: {
|
||||
entry: string;
|
||||
entryArgs: string[];
|
||||
envs: string[];
|
||||
content: string;
|
||||
functions: {
|
||||
[key: string]: {
|
||||
content?: string;
|
||||
param?: any[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
this.isRunning = false;
|
||||
this.ServerApi = null as ServerApiType | null;
|
||||
this.ServerInfo = null as ServerInfo | null;
|
||||
this.serverRuntime = {
|
||||
startTime: 0,
|
||||
};
|
||||
this.send = function (type: SendType, data: any) {
|
||||
this.ServerApi.event.sendChannel(this.ServerInfo.eventChannelName, {type, data});
|
||||
};
|
||||
this.init = async function () {
|
||||
};
|
||||
this.config = async function () {
|
||||
return {
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
data: {
|
||||
httpUrl: null,
|
||||
content: this.serverConfig.easyServer.content || "",
|
||||
functions: this.serverConfig.easyServer.functions || {},
|
||||
},
|
||||
};
|
||||
};
|
||||
this.start = async function () {
|
||||
// console.log('start', this.ServerInfo)
|
||||
this.serverRuntime.startTime = Date.now();
|
||||
this.send("starting", this.ServerInfo);
|
||||
};
|
||||
this.ping = async function (): Promise<boolean> {
|
||||
// console.log('ping', this.ServerInfo)
|
||||
return this.serverRuntime.startTime > 0;
|
||||
};
|
||||
this.stop = async function () {
|
||||
// console.log('stop', this.ServerInfo)
|
||||
this.send("stopping", this.ServerInfo);
|
||||
this.serverRuntime.startTime = 0;
|
||||
if (controller) {
|
||||
try {
|
||||
await controller.stop();
|
||||
} catch (e) {
|
||||
Log.warn("easyServer.stop.error", e);
|
||||
}
|
||||
controller = null;
|
||||
}
|
||||
this.send("stopped", this.ServerInfo);
|
||||
this.send("success", this.ServerInfo);
|
||||
};
|
||||
this.cancel = async function () {
|
||||
if (controller) {
|
||||
await controller.stop();
|
||||
controller = null;
|
||||
}
|
||||
};
|
||||
this._controllerRunIfNeeded = async function (
|
||||
configJsonPath: string | null,
|
||||
option: {
|
||||
timeout: number;
|
||||
}
|
||||
) {
|
||||
if (!controller) {
|
||||
const _localPath = this.ServerInfo?.localPath || '';
|
||||
Log.info("[EasyServer] ServerInfo.localPath", _localPath);
|
||||
let command = [];
|
||||
command.push(this.serverConfig.easyServer.entry);
|
||||
if (this.serverConfig.easyServer.entryArgs) {
|
||||
command = command.concat(this.serverConfig.easyServer.entryArgs);
|
||||
}
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
command[i] = command[i].replace("${CONFIG}", `"${configJsonPath}"`);
|
||||
command[i] = command[i].replace("${ROOT}", _localPath);
|
||||
}
|
||||
Log.info("[EasyServer] command", JSON.stringify(command));
|
||||
Log.info("[EasyServer] localPath", this.ServerInfo.localPath);
|
||||
const envMap = {};
|
||||
Log.info('EasyServer.config', JSON.stringify(this.serverConfig.easyServer));
|
||||
if (this.serverConfig.easyServer.entry === "launcher") {
|
||||
const systemEnv = await this.ServerApi.env();
|
||||
// console.log('EasyServer.systemEnv', systemEnv)
|
||||
for (const k in systemEnv) {
|
||||
envMap[k] = systemEnv[k];
|
||||
}
|
||||
}
|
||||
// 🔧 修复:添加 FFmpeg 目录到 PATH,让 Python 脚本能找到 ffmpeg
|
||||
const pathDirs = [
|
||||
`${this.ServerInfo.localPath}`,
|
||||
`${this.ServerInfo.localPath}/binary`,
|
||||
];
|
||||
const ffmpegPath = getFFmpegExecutablePath();
|
||||
if (resourceExists(ffmpegPath)) {
|
||||
const ffmpegDir = path.dirname(ffmpegPath);
|
||||
pathDirs.push(ffmpegDir);
|
||||
Log.info(`[EasyServer] ✓ 已添加 FFmpeg 目录到 PATH: ${ffmpegDir}`);
|
||||
}
|
||||
envMap["PATH"] = this.ServerApi.getPathEnv(pathDirs);
|
||||
envMap["PYTHONIOENCODING"] = "utf-8";
|
||||
envMap["AIGCPANEL_SERVER_PLACEHOLDER_CONFIG"] = configJsonPath;
|
||||
envMap["AIGCPANEL_SERVER_PLACEHOLDER_ROOT"] = this.ServerInfo.localPath;
|
||||
if (this.serverConfig.easyServer.envs) {
|
||||
for (const e of this.serverConfig.easyServer.envs) {
|
||||
let pcs = e.split("=");
|
||||
const key = pcs.shift();
|
||||
envMap[key] = pcs.join("=");
|
||||
}
|
||||
}
|
||||
for (const k in envMap) {
|
||||
envMap[k] = envMap[k].replace("${CONFIG}", `"${configJsonPath}"`);
|
||||
envMap[k] = envMap[k].replace("${ROOT}", this.ServerInfo.localPath);
|
||||
}
|
||||
const hasMoreQueue = async () => {
|
||||
const queueRoot = this.ServerInfo.localPath + `/aigcpanel-queue/`;
|
||||
await Files.mkdir(queueRoot);
|
||||
const files = await Files.list(queueRoot);
|
||||
const validQueueFiles = files.filter(f => f.name.match(/\.queue\.json$/));
|
||||
if (validQueueFiles.length > 0) {
|
||||
const configJson = await Files.temp("json");
|
||||
await Files.copy(validQueueFiles[0].pathname, configJson);
|
||||
await Files.deletes(validQueueFiles[0].pathname);
|
||||
await this._controllerRunIfNeeded(configJson, option);
|
||||
}
|
||||
};
|
||||
let timer = null;
|
||||
if (option.timeout > 0) {
|
||||
timer = setTimeout(async () => {
|
||||
this.ServerApi.file.appendText(this.ServerInfo.logFile, "timeout", {isDataPath: true});
|
||||
await settleControllerWatching("reject", new Error("timeout"), true);
|
||||
}, option.timeout * 1000);
|
||||
}
|
||||
let buffer = "";
|
||||
controller = await this.ServerApi.app.spawnShell(command, {
|
||||
env: envMap,
|
||||
cwd: this.ServerInfo.localPath,
|
||||
stdout: _data => {
|
||||
// console.log('easyServer.stdout', _data)
|
||||
buffer += _data;
|
||||
// check if has \n and process the buffer
|
||||
let lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
this.ServerApi.file.appendText(this.ServerInfo.logFile, _data, {isDataPath: true});
|
||||
|
||||
// 鍙戦€佹棩蹇楀埌鍓嶇UI
|
||||
if (controllerWatching.id) {
|
||||
this.send("taskLog", {
|
||||
id: controllerWatching.id,
|
||||
type: "stdout",
|
||||
data: _data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
const result = this.ServerApi.extractResultFromLogs(controllerWatching.id, lines.join("\n") + "\n");
|
||||
if (result) {
|
||||
if (controllerWatching.launcherResult) {
|
||||
controllerWatching.launcherResult.result = Object.assign(controllerWatching.launcherResult.result, result);
|
||||
}
|
||||
if (controllerWatching.id) {
|
||||
this.send("taskResult", {id: controllerWatching.id, result});
|
||||
}
|
||||
}
|
||||
if (controllerWatching.launcherResult) {
|
||||
controllerWatching.launcherResult.result.error =
|
||||
AigcServerUtil.errorDetect(_data) || controllerWatching.launcherResult.result.error;
|
||||
// 馃敡 淇锛氬彧鏈夊湪End=true涓斿凡缁忔湁鏈夋晥缁撴灉(url/records/error)鏃舵墠resolve
|
||||
if (controllerWatching.launcherResult.result && controllerWatching.launcherResult.result['End']) {
|
||||
const hasValidResult =
|
||||
controllerWatching.launcherResult.result.url ||
|
||||
controllerWatching.launcherResult.result.records ||
|
||||
controllerWatching.launcherResult.result.error;
|
||||
if (hasValidResult) {
|
||||
settleControllerWatching("resolve", undefined, true).then();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
stderr: _data => {
|
||||
// console.log('easyServer.stderr', _data)
|
||||
this.ServerApi.file.appendText(this.ServerInfo.logFile, _data, {isDataPath: true});
|
||||
|
||||
// 鍙戦€佹棩蹇楀埌鍓嶇UI
|
||||
if (controllerWatching.id) {
|
||||
this.send("taskLog", {
|
||||
id: controllerWatching.id,
|
||||
type: "stderr",
|
||||
data: _data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
if (controllerWatching.launcherResult) {
|
||||
controllerWatching.launcherResult.result.error =
|
||||
AigcServerUtil.errorDetect(_data) || controllerWatching.launcherResult.result.error;
|
||||
}
|
||||
},
|
||||
success: _data => {
|
||||
// console.log('easyServer.success', _data)
|
||||
clearTimeout(timer);
|
||||
controller = null;
|
||||
hasMoreQueue().then()
|
||||
settleControllerWatching("resolve").then();
|
||||
},
|
||||
error: (_data, code) => {
|
||||
// console.log('easyServer.error', {_data, controllerWatching})
|
||||
this.ServerApi.file.appendText(this.ServerInfo.logFile, `exit code ${code}`, {isDataPath: true});
|
||||
clearTimeout(timer);
|
||||
controller = null;
|
||||
hasMoreQueue().then()
|
||||
settleControllerWatching("reject").then();
|
||||
},
|
||||
})
|
||||
} else if (configJsonPath) {
|
||||
const queueName = `${Date.now()}.queue.json`;
|
||||
const queuePath = this.ServerInfo.localPath + `/aigcpanel-queue/${queueName}`;
|
||||
await Files.copy(configJsonPath, queuePath);
|
||||
this.ServerApi.file.appendText(
|
||||
this.ServerInfo.logFile,
|
||||
`Another task is running, queued at ${queueName}`,
|
||||
{isDataPath: true}
|
||||
);
|
||||
}
|
||||
};
|
||||
this._callFunc = async function (
|
||||
data: ServerFunctionDataType,
|
||||
configCalculator: (data: ServerFunctionDataType) => Promise<any>,
|
||||
resultDataCalculator: (data: ServerFunctionDataType, launcherResult: LauncherResultType) => Promise<any>,
|
||||
option: {
|
||||
timeout: number;
|
||||
}
|
||||
) {
|
||||
option = Object.assign(
|
||||
{
|
||||
timeout: 24 * 3600,
|
||||
},
|
||||
option
|
||||
);
|
||||
const resultData = {
|
||||
// success, retry
|
||||
type: "success",
|
||||
start: 0,
|
||||
end: 0,
|
||||
data: {},
|
||||
};
|
||||
if (this.isRunning) {
|
||||
resultData.type = "retry";
|
||||
return {
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
data: resultData,
|
||||
};
|
||||
}
|
||||
this.isRunning = true;
|
||||
resultData.start = Date.now();
|
||||
let configJsonPath = null;
|
||||
try {
|
||||
this.send("taskRunning", {id: data.id});
|
||||
const configData = await configCalculator(data);
|
||||
configData.setting = this.ServerInfo.setting;
|
||||
configJsonPath = await this.ServerApi.launcherPrepareConfigJson(configData);
|
||||
// console.log('EasyServer.envMap', envMap)
|
||||
const launcherResult: LauncherResultType = {
|
||||
result: {},
|
||||
endTime: null,
|
||||
};
|
||||
// console.log('easyServer.start', JSON.stringify({command, envMap, configData}))
|
||||
await (async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
controllerWatching.id = data.id;
|
||||
controllerWatching.launcherResult = launcherResult;
|
||||
controllerWatching.resolve = resolve;
|
||||
controllerWatching.reject = reject;
|
||||
controllerWatching.promiseResolved = false;
|
||||
me._controllerRunIfNeeded(configJsonPath, option);
|
||||
});
|
||||
})();
|
||||
resultData.end = Date.now();
|
||||
resultData.data = await resultDataCalculator(data, launcherResult);
|
||||
// console.log('easyServer.end', launcherResult)
|
||||
await Files.deletes(configJsonPath);
|
||||
return {
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
data: resultData,
|
||||
};
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
resetControllerWatching();
|
||||
this.isRunning = false;
|
||||
}
|
||||
};
|
||||
this.soundTts = async function (data: ServerFunctionDataType) {
|
||||
// console.log('soundTts', {data, serverInfo: this.ServerInfo})
|
||||
return this._callFunc(
|
||||
data,
|
||||
async (data: ServerFunctionDataType) => {
|
||||
return {
|
||||
id: data.id,
|
||||
mode: "local",
|
||||
modelConfig: {
|
||||
type: "soundTts",
|
||||
param: data.param,
|
||||
text: data.text,
|
||||
},
|
||||
};
|
||||
},
|
||||
async (data: ServerFunctionDataType, launcherResult: LauncherResultType) => {
|
||||
// 馃敡 淇锛氬拷鐣?{"End": true} 缁撴潫鏍囧織,鍙鏌ョ湡姝g殑閿欒
|
||||
if (!("url" in launcherResult.result)) {
|
||||
// 濡傛灉鏄疎nd鏍囧織,鐩存帴杩斿洖鐜版湁缁撴灉(涓嶆姤閿?
|
||||
if (launcherResult.result.End === true) {
|
||||
return launcherResult.result;
|
||||
}
|
||||
if (launcherResult.result.error) {
|
||||
throw launcherResult.result.error;
|
||||
}
|
||||
throw "鎵ц澶辫触锛岃鏌ョ湅妯″瀷鏃ュ織";
|
||||
}
|
||||
return {
|
||||
url: launcherResult.result.url,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
this.soundClone = async function (data: ServerFunctionDataType) {
|
||||
// console.log('soundClone', {data, serverInfo: this.ServerInfo})
|
||||
return this._callFunc(
|
||||
data,
|
||||
async (data: ServerFunctionDataType) => {
|
||||
return {
|
||||
id: data.id,
|
||||
mode: "local",
|
||||
modelConfig: {
|
||||
type: "soundClone",
|
||||
param: data.param,
|
||||
text: data.text,
|
||||
promptAudio: data.promptAudio,
|
||||
promptText: data.promptText,
|
||||
},
|
||||
};
|
||||
},
|
||||
async (data: ServerFunctionDataType, launcherResult: LauncherResultType) => {
|
||||
// 馃敡 淇锛氬拷鐣?{"End": true} 缁撴潫鏍囧織,鍙鏌ョ湡姝g殑閿欒
|
||||
if (!("url" in launcherResult.result)) {
|
||||
// 濡傛灉鏄疎nd鏍囧織,鐩存帴杩斿洖鐜版湁缁撴灉(涓嶆姤閿?
|
||||
if (launcherResult.result.End === true) {
|
||||
return launcherResult.result;
|
||||
}
|
||||
if (launcherResult.result.error) {
|
||||
throw launcherResult.result.error;
|
||||
}
|
||||
throw "鎵ц澶辫触锛岃鏌ョ湅妯″瀷鏃ュ織";
|
||||
}
|
||||
return {
|
||||
url: launcherResult.result.url,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
this.videoGen = async function (data: ServerFunctionDataType) {
|
||||
// console.log('videoGen', JSON.stringify({data, serverInfo: this.ServerInfo}))
|
||||
return this._callFunc(
|
||||
data,
|
||||
async (data: ServerFunctionDataType) => {
|
||||
return {
|
||||
id: data.id,
|
||||
mode: "local",
|
||||
modelConfig: {
|
||||
type: "videoGen",
|
||||
param: data.param,
|
||||
video: data.video,
|
||||
audio: data.audio,
|
||||
},
|
||||
};
|
||||
},
|
||||
async (data: ServerFunctionDataType, launcherResult: LauncherResultType) => {
|
||||
// 馃敡 淇锛氬拷鐣?{"End": true} 缁撴潫鏍囧織,鍙鏌ョ湡姝g殑閿欒
|
||||
if (!("url" in launcherResult.result)) {
|
||||
// 濡傛灉鏄疎nd鏍囧織,鐩存帴杩斿洖鐜版湁缁撴灉(涓嶆姤閿?
|
||||
if (launcherResult.result.End === true) {
|
||||
return launcherResult.result;
|
||||
}
|
||||
if (launcherResult.result.error) {
|
||||
throw launcherResult.result.error;
|
||||
}
|
||||
throw "鎵ц澶辫触锛岃鏌ョ湅妯″瀷鏃ュ織";
|
||||
}
|
||||
return {
|
||||
url: launcherResult.result.url,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
this.asr = async function (data: ServerFunctionDataType) {
|
||||
// console.log('videoGen', JSON.stringify({data, serverInfo: this.ServerInfo}))
|
||||
return this._callFunc(
|
||||
data,
|
||||
async (data: ServerFunctionDataType) => {
|
||||
return {
|
||||
id: data.id,
|
||||
mode: "local",
|
||||
modelConfig: {
|
||||
type: 'asr',
|
||||
audio: data.audio,
|
||||
param: data.param,
|
||||
},
|
||||
};
|
||||
},
|
||||
async (data: ServerFunctionDataType, launcherResult: LauncherResultType) => {
|
||||
// 馃敡 淇锛氬拷鐣?{"End": true} 缁撴潫鏍囧織,鍙鏌ョ湡姝g殑閿欒
|
||||
if (!("records" in launcherResult.result)) {
|
||||
// 濡傛灉鏄疎nd鏍囧織,鐩存帴杩斿洖鐜版湁缁撴灉(涓嶆姤閿?
|
||||
if (launcherResult.result.End === true) {
|
||||
return launcherResult.result;
|
||||
}
|
||||
if (launcherResult.result.error) {
|
||||
throw launcherResult.result.error;
|
||||
}
|
||||
throw "鎵ц澶辫触锛岃鏌ョ湅妯″瀷鏃ュ織";
|
||||
}
|
||||
return {
|
||||
records: launcherResult.result.records,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
this.textToImage = async function (data: ServerFunctionDataType) {
|
||||
// console.log('textToImage', {data, serverInfo: this.ServerInfo})
|
||||
return this._callFunc(
|
||||
data,
|
||||
async (data: ServerFunctionDataType) => {
|
||||
return {
|
||||
id: data.id,
|
||||
mode: "local",
|
||||
modelConfig: {
|
||||
type: "textToImage",
|
||||
prompt: data.prompt,
|
||||
param: data.param,
|
||||
},
|
||||
};
|
||||
},
|
||||
async (data: ServerFunctionDataType, launcherResult: LauncherResultType) => {
|
||||
// 馃敡 淇锛氬拷鐣?{"End": true} 缁撴潫鏍囧織,鍙鏌ョ湡姝g殑閿欒
|
||||
if (!("url" in launcherResult.result)) {
|
||||
// 濡傛灉鏄疎nd鏍囧織,鐩存帴杩斿洖鐜版湁缁撴灉(涓嶆姤閿?
|
||||
if (launcherResult.result.End === true) {
|
||||
return launcherResult.result;
|
||||
}
|
||||
if (launcherResult.result.error) {
|
||||
throw launcherResult.result.error;
|
||||
}
|
||||
throw "鎵ц澶辫触锛岃鏌ョ湅妯″瀷鏃ュ織";
|
||||
}
|
||||
return {
|
||||
url: launcherResult.result.url,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
this.imageToImage = async function (data: ServerFunctionDataType) {
|
||||
// console.log('imageToImage', {data, serverInfo: this.ServerInfo})
|
||||
return this._callFunc(
|
||||
data,
|
||||
async (data: ServerFunctionDataType) => {
|
||||
return {
|
||||
id: data.id,
|
||||
mode: "local",
|
||||
modelConfig: {
|
||||
type: "imageToImage",
|
||||
image: data.image,
|
||||
prompt: data.prompt,
|
||||
param: data.param,
|
||||
},
|
||||
};
|
||||
},
|
||||
async (data: ServerFunctionDataType, launcherResult: LauncherResultType) => {
|
||||
// 馃敡 淇锛氬拷鐣?{"End": true} 缁撴潫鏍囧織,鍙鏌ョ湡姝g殑閿欒
|
||||
if (!("url" in launcherResult.result)) {
|
||||
// 濡傛灉鏄疎nd鏍囧織,鐩存帴杩斿洖鐜版湁缁撴灉(涓嶆姤閿?
|
||||
if (launcherResult.result.End === true) {
|
||||
return launcherResult.result;
|
||||
}
|
||||
if (launcherResult.result.error) {
|
||||
throw launcherResult.result.error;
|
||||
}
|
||||
throw "鎵ц澶辫触锛岃鏌ョ湅妯″瀷鏃ュ織";
|
||||
}
|
||||
return {
|
||||
url: launcherResult.result.url,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { exec, spawn, type ChildProcessWithoutNullStreams } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { promisify } from "util";
|
||||
import { VideoSubtitleCoverGeneratorModelConfig } from "../../src/types/VideoSubtitleCoverGenerator";
|
||||
import { getPythonPath } from "../lib/python-util";
|
||||
import { getPythonScriptPath, getFFmpegExecutablePath, getRuntimeBundleRoot, resourceExists } from "../lib/resource-path";
|
||||
import { Log } from "../mapi/log/main";
|
||||
import { buildRuntimeProcessEnv } from "../mapi/shell";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export class VideoSubtitleCoverGenerator {
|
||||
type = "buildIn";
|
||||
ServerApi: any;
|
||||
ServerInfo: any;
|
||||
private currentProcess: ChildProcessWithoutNullStreams | null = null;
|
||||
|
||||
private async stopCurrentProcess() {
|
||||
if (!this.currentProcess?.pid) {
|
||||
this.currentProcess = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPid = this.currentProcess.pid;
|
||||
const processToStop = this.currentProcess;
|
||||
this.currentProcess = null;
|
||||
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
await execAsync(`taskkill /F /PID ${currentPid} /T`);
|
||||
} else {
|
||||
processToStop.kill("SIGKILL");
|
||||
}
|
||||
} catch (error: any) {
|
||||
Log.warn("VideoSubtitleCoverGenerator", `Failed to stop python process ${currentPid}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
// 初始化
|
||||
}
|
||||
|
||||
async start() {
|
||||
// 启动服务
|
||||
return { code: 0, msg: "VideoSubtitleCoverGenerator服务启动成功" };
|
||||
}
|
||||
|
||||
async ping() {
|
||||
// 检查服务状态
|
||||
return { code: 0, msg: "pong" };
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.stopCurrentProcess();
|
||||
return { code: 0, msg: "服务已停止" };
|
||||
}
|
||||
|
||||
async cancel() {
|
||||
await this.stopCurrentProcess();
|
||||
return { code: 0, msg: "任务已取消" };
|
||||
}
|
||||
|
||||
async config() {
|
||||
// 返回配置信息
|
||||
return {
|
||||
code: 0,
|
||||
config: {
|
||||
name: "VideoSubtitleCoverGenerator",
|
||||
displayName: "字幕封面生成器",
|
||||
description: "一键生成带字幕的视频和封面,支持多种样式模板",
|
||||
version: "1.0.0",
|
||||
functions: ["VideoSubtitleCoverGenerator"],
|
||||
models: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async VideoSubtitleCoverGenerator(data: VideoSubtitleCoverGeneratorModelConfig, option: any = {}) {
|
||||
try {
|
||||
// 创建临时配置文件
|
||||
const configPath = path.join(process.cwd(), "temp_config.json");
|
||||
const config = {
|
||||
modelConfig: data,
|
||||
...option
|
||||
};
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
// 调用Python脚本
|
||||
// ⚠️ 只使用程序自带的 Python,不依赖系统环境
|
||||
const pythonScript = getPythonScriptPath('video_subtitle_cover_generator.py');
|
||||
const pythonPath = getPythonPath();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 🔧 修复:传递APP_ROOT环境变量给Python进程,使其能正确识别打包环境
|
||||
const env = buildRuntimeProcessEnv({
|
||||
APP_ROOT: process.env.APP_ROOT || process.cwd(),
|
||||
RESOURCE_BUNDLE_ROOT: getRuntimeBundleRoot()
|
||||
});
|
||||
|
||||
// 🔧 修复:添加 FFmpeg 目录到 PATH,让 Python 脚本能找到 ffmpeg
|
||||
const ffmpegPath = getFFmpegExecutablePath();
|
||||
if (resourceExists(ffmpegPath)) {
|
||||
const ffmpegDir = path.dirname(ffmpegPath);
|
||||
const pathSep = process.platform === 'win32' ? ';' : ':';
|
||||
env['PATH'] = ffmpegDir + pathSep + (env['PATH'] || process.env.PATH || '');
|
||||
Log.info('VideoSubtitleCoverGenerator', `Added FFmpeg to PATH: ${ffmpegDir}`);
|
||||
}
|
||||
|
||||
const pythonProcess = spawn(pythonPath, [pythonScript, configPath], {
|
||||
cwd: process.env.APP_ROOT || process.cwd(),
|
||||
env: env,
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
this.currentProcess = pythonProcess;
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
pythonProcess.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
console.log("Python stdout:", data.toString());
|
||||
});
|
||||
|
||||
pythonProcess.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
console.error("Python stderr:", data.toString());
|
||||
});
|
||||
|
||||
pythonProcess.on("close", (code) => {
|
||||
if (this.currentProcess?.pid === pythonProcess.pid) {
|
||||
this.currentProcess = null;
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
try {
|
||||
fs.unlinkSync(configPath);
|
||||
} catch (e) {
|
||||
console.warn("清理临时文件失败:", e);
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
try {
|
||||
const result = JSON.parse(stdout);
|
||||
resolve(result);
|
||||
} catch (e) {
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: `解析Python输出失败: ${e}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: `Python脚本执行失败 (退出码: ${code}): ${stderr}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
pythonProcess.on("error", (error) => {
|
||||
if (this.currentProcess?.pid === pythonProcess.pid) {
|
||||
this.currentProcess = null;
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
try {
|
||||
fs.unlinkSync(configPath);
|
||||
} catch (e) {
|
||||
console.warn("清理临时文件失败:", e);
|
||||
}
|
||||
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: `启动Python进程失败: ${error.message}`
|
||||
});
|
||||
});
|
||||
|
||||
// 设置超时
|
||||
setTimeout(() => {
|
||||
this.stopCurrentProcess().catch((error) => {
|
||||
Log.warn("VideoSubtitleCoverGenerator", `Timeout stop failed: ${error}`);
|
||||
});
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: "处理超时"
|
||||
});
|
||||
}, 300000); // 5分钟超时
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return {
|
||||
code: -1,
|
||||
msg: `处理失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {EasyServer} from "./EasyServer";
|
||||
import {ServerLive} from "./server-live";
|
||||
|
||||
export const AigcServer = {
|
||||
EasyServer: EasyServer,
|
||||
"server-live": ServerLive,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import {SendType, ServerApiType, ServerContext, ServerInfo} from "../mapi/server/type";
|
||||
|
||||
const serverRuntime = {
|
||||
port: 0,
|
||||
};
|
||||
|
||||
let shellController = null;
|
||||
let isRunning = false;
|
||||
|
||||
export const ServerLive: ServerContext = {
|
||||
ServerApi: null as ServerApiType | null,
|
||||
ServerInfo: null as ServerInfo | null,
|
||||
url() {
|
||||
return `http://localhost:${serverRuntime.port}/`;
|
||||
},
|
||||
send(type: SendType, data: any) {
|
||||
this.ServerApi.event.sendChannel(this.ServerInfo.eventChannelName, {type, data});
|
||||
},
|
||||
|
||||
async _client() {
|
||||
return await this.ServerApi.GradioClient.connect(this.url());
|
||||
},
|
||||
async init() {},
|
||||
async start() {
|
||||
// console.log('this.ServerApi.app.availablePort(50617)', await this.ServerApi.app.availablePort(50617))
|
||||
this.send("starting", this.ServerInfo);
|
||||
let command = [];
|
||||
serverRuntime.port = await this.ServerApi.availablePort(serverRuntime.port, this.ServerInfo.setting);
|
||||
const env = await this.ServerApi.env();
|
||||
command.push(`"${this.ServerInfo.localPath}/launcher"`);
|
||||
command.push(`--env=DEBUG=true`);
|
||||
env["PATH"] = this.ServerApi.getPathEnv(`${this.ServerInfo.localPath}/binary`);
|
||||
env["AIGCPANEL_SERVER_PORT"] = `${serverRuntime.port}`;
|
||||
env["AIGCPANEL_SERVER_PLACEHOLDER_CONFIG"] = await this.ServerApi.launcherPrepareConfigJson({
|
||||
id: "live",
|
||||
modelConfig: {},
|
||||
setting: this.ServerInfo.setting,
|
||||
});
|
||||
// console.log('command', JSON.stringify(command))
|
||||
shellController = await this.ServerApi.app.spawnShell(command, {
|
||||
stdout: data => {
|
||||
this.ServerApi.file.appendText(this.ServerInfo.logFile, data, {isDataPath: true});
|
||||
const result = this.ServerApi.extractResultFromLogs("live", data);
|
||||
if (result) {
|
||||
if (result["Action"]) {
|
||||
const action = result["Action"].split(":");
|
||||
this.send("action", {
|
||||
type: action[0],
|
||||
msg: action.length > 1 ? action[1] : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
stderr: data => {
|
||||
this.ServerApi.file.appendText(this.ServerInfo.logFile, data, {isDataPath: true});
|
||||
},
|
||||
success: data => {
|
||||
// console.log('serverLive.success', {data})
|
||||
this.send("success", this.ServerInfo);
|
||||
},
|
||||
error: (data, code) => {
|
||||
// console.log('serverLive.error', {code, data})
|
||||
this.ServerApi.file.appendText(this.ServerInfo.logFile, data, {isDataPath: true});
|
||||
this.send("error", this.ServerInfo);
|
||||
},
|
||||
env,
|
||||
cwd: this.ServerInfo.localPath,
|
||||
});
|
||||
},
|
||||
async ping() {
|
||||
try {
|
||||
const res = await this.ServerApi.request(`${this.url()}ping`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log("ping error", e);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async stop() {
|
||||
this.send("stopping", this.ServerInfo);
|
||||
try {
|
||||
if (shellController) {
|
||||
await shellController.stop();
|
||||
}
|
||||
shellController = null;
|
||||
} catch (e) {
|
||||
console.log("stop error", e);
|
||||
}
|
||||
this.send("stopped", this.ServerInfo);
|
||||
},
|
||||
async cancel() {
|
||||
await this.ServerApi.launcherCancel(this);
|
||||
},
|
||||
async config() {
|
||||
return {
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
data: {
|
||||
httpUrl: shellController ? this.url() : null,
|
||||
content: ``,
|
||||
functions: {
|
||||
live: {
|
||||
content: ``,
|
||||
param: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
async apiRequest(
|
||||
data: {
|
||||
url: string;
|
||||
param: any;
|
||||
},
|
||||
option: any
|
||||
) {
|
||||
// serverRuntime.port = 60617
|
||||
// console.log('apiRequest', {url: this.url(), data, option})
|
||||
const {url, param} = data;
|
||||
return this.ServerApi.request(`${this.url()}${url}`, param, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {t} from "../config/lang";
|
||||
|
||||
export const AigcServerUtil = {
|
||||
errorDetect: (data: string): string | null => {
|
||||
const errorMap = {
|
||||
"torch.cuda.OutOfMemoryError": t("CUDA内存不足"),
|
||||
};
|
||||
for (const [key, value] of Object.entries(errorMap)) {
|
||||
if (data.includes(key)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const CommonConfig = {
|
||||
darkModeEnable: false,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import contextMenu from "electron-context-menu";
|
||||
|
||||
const init = () => {
|
||||
contextMenu({
|
||||
showSaveImageAs: false,
|
||||
showCopyLink: false,
|
||||
showCopyImage: false,
|
||||
showSelectAll: false,
|
||||
showInspectElement: false,
|
||||
showSearchWithGoogle: false,
|
||||
showLookUpSelection: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const ConfigContextMenu = {
|
||||
init,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"version": "4.0.0",
|
||||
"exportDate": "2026-01-04T03:52:18.928Z",
|
||||
"config": {
|
||||
"lang": "zh-CN",
|
||||
"updaterCheckAtLaunch": "yes",
|
||||
"hubRoot": "D:\\生成的文件",
|
||||
"videoMixCutSettings": {
|
||||
"enabled": true,
|
||||
"mode": "fixed",
|
||||
"fixedModeConfig": {
|
||||
"enabled": true,
|
||||
"opening": {
|
||||
"enabled": true,
|
||||
"timeRange": {
|
||||
"durationMin": 1,
|
||||
"durationMax": 6
|
||||
},
|
||||
"materialFolderPath": "D:\\可以用的素材\\鞋子电商视频",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-center",
|
||||
"pipSizePercent": 35,
|
||||
"pipScaleMode": "fit",
|
||||
"originalVideoMinimized": {
|
||||
"enabled": true,
|
||||
"shape": "circle",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
},
|
||||
"selectionMode": "random"
|
||||
},
|
||||
"middle": {
|
||||
"enabled": true,
|
||||
"timeRange": {
|
||||
"durationMin": 1,
|
||||
"durationMax": 7
|
||||
},
|
||||
"materialFolderPath": "D:\\可以用的素材\\鞋子电商视频",
|
||||
"displayMode": "pip",
|
||||
"pipPosition": "center",
|
||||
"pipSizePercent": 50,
|
||||
"pipScaleMode": "fit",
|
||||
"originalVideoMinimized": {
|
||||
"enabled": true,
|
||||
"shape": "circle",
|
||||
"position": "center",
|
||||
"sizePercent": 25
|
||||
},
|
||||
"selectionMode": "random"
|
||||
},
|
||||
"ending": {
|
||||
"enabled": true,
|
||||
"timeRange": {
|
||||
"durationMin": 1,
|
||||
"durationMax": 7
|
||||
},
|
||||
"materialFolderPath": "D:\\可以用的素材\\鞋子电商视频",
|
||||
"displayMode": "pip",
|
||||
"pipScaleMode": "fill",
|
||||
"pipSizePercent": 30,
|
||||
"pipPosition": "top-right",
|
||||
"originalVideoMinimized": {
|
||||
"enabled": true,
|
||||
"shape": "square",
|
||||
"position": "top-right",
|
||||
"sizePercent": 15
|
||||
},
|
||||
"selectionMode": "random"
|
||||
}
|
||||
},
|
||||
"keywordModeConfig": {
|
||||
"enabled": false,
|
||||
"groups": [
|
||||
{
|
||||
"groupId": "group_1765719932044_0.6648505028922633",
|
||||
"groupName": "12",
|
||||
"materialFolderPath": "",
|
||||
"durationMin": 2,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": false,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_keyemphasis",
|
||||
"groupName": "重点词/成语词",
|
||||
"materialFolderPath": "D:\\可以用的素材\\鞋子电商视频",
|
||||
"durationMin": 1,
|
||||
"durationMax": 6,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "center",
|
||||
"pipSizePercent": 80,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": true,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": true,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_description",
|
||||
"groupName": "描述词",
|
||||
"materialFolderPath": "D:\\可以用的素材\\鞋子电商视频",
|
||||
"durationMin": 1,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "pip",
|
||||
"pipPosition": "center",
|
||||
"pipSizePercent": 80,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": true,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_emotion",
|
||||
"groupName": "情感词",
|
||||
"materialFolderPath": "D:\\可以用的素材\\鞋子电商视频",
|
||||
"durationMin": 2,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": true,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": true,
|
||||
"shape": "circle",
|
||||
"position": "top-left",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_action",
|
||||
"groupName": "行动词",
|
||||
"materialFolderPath": "D:\\可以用的素材\\鞋子电商视频",
|
||||
"durationMin": 1,
|
||||
"durationMax": 10,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "pip",
|
||||
"pipPosition": "center",
|
||||
"pipSizePercent": 80,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": true,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_12",
|
||||
"groupName": "12",
|
||||
"materialFolderPath": "",
|
||||
"durationMin": 2,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": false,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ipAgentSettings": null,
|
||||
"prompts": {
|
||||
"scriptPrompt": "你是一个视频口播专家,擅长根据用户给的主题和关键词生成高质量的口播稿件。\n请根据以下要求生成口播稿件:\n\n1. 主题:{title}\n2. 字数:大约{count}字\n\n请确保稿件内容连贯、有吸引力,并且符合口播的风格。\n请直接输出生成的口播稿件,无需任何额外说明文字。\n不要带任何表情和符号。\n禁止输出的词:\"原文\"\"作者\"\"文章\"等词,攻击性强、侮辱性强的词(滚吧、找爸爸去、废物)、骂人的词(傻子、垃圾、蠢蛋),ai常用词(如\"这波操作\"、\"666\"),极限词、违反法律的词汇\n\n可用变量:{title}、{count}",
|
||||
"titleTagPrompt": "是短视频标题与标签助手。请基于提供的口播文案,生成一个标题和3-4个标签,输出的语言要完全是我们文案的语言格式\n- 标题不超过15个字,简洁有吸引力,不加引号,不含表情或图标字符。\n- 标签为2-6个字的中文短词,不含#、空格和表情;以主题词为主,避免冗长句。\n- 然后从文案里筛选出优秀的的词汇,必须是文案里包含的一字不差的,按照以下分类\n * 重点词/成语词:文案中的核心词汇、成语、重要概念。\n * 描述词:文案中用于描述事物特征、状态的优秀词汇。\n * 行动词:文案中表示动作、行为的优秀词汇。\n * 情感词:文案中表达情感、情绪的优秀词汇。\n\n-- 直接输出纯JSON格式,如果没有合适的词,[]格式里面可以留空,不要生凑,不要一个字的词,不要使用markdown代码块(不要用),不要附加任何说明文字。\n\n- 输出格式为:\n{\n \"title\": \"...\",\n \"tags\": [\"...\", \"...\", \"...\", \"...\"],\n \"keywords\": {\n \"重点词/成语词\": [\"...\", \"...\"],\n \"描述词\": [\"...\"],\n \"行动词\": [\"...\"],\n \"情感词\": [\"...\",]\n }\n}\n\n\n文案:<content>{{content}}</content>",
|
||||
"topicRewritePrompt": "参考以下标题,帮我生成5个新的标题。\n\n参考标题:\n{{content}}\n\n要求:\n1. 保持相似的主题和风格\n2. 标题简洁有力,吸引眼球\n3. 不要添加表情符号和#标签\n4. 每个标题独立成行\n\n请直接输出5个新标题,不要编号和额外说明。\n可用变量:{{content}}",
|
||||
"rewritePrompt": "你是一位文案改写助手,不增加表情或特殊符号,输出{{count}}字。\n原文:\n{{content}}\n\n\n ## 写作限制和特征\n -禁止输出的词:\"原文\"\"作者\"\"文章\"等词,攻击性强、侮辱性强的词(滚吧、找爸爸去、废物)、骂人的词(傻子、垃圾、蠢蛋),ai常用词(如\"这波操作\"、\"666\"),极限词、违反法律的词汇\n\n -用词口语化,句式有短句、情绪浓烈,\n -不要输出前言和介绍,也不能直白的指责。\n -写作的内容不要引用提示词的要求。\n\n ## 写作要求\n 1、第一段直接照抄原文的第一句话作为文章开头,一个字也不能改,必须控制30个字以内,绝对禁止照抄的第一句话超过30个字,超过30个字在不影响句子连贯的情况下截停。\n\n 2、后面的内容要具体叙述具体行为,每一个具体动作都不能跳过,可以直接使用原文句子,但是不能完全一致,从原文案的内容里面,用不同的角度切入叙述,确保内容完整,可以使用同义字进行替换,重组结构。\n\n 3、最后一段可以直接使用原文的最后一段,如果有身份信息的内容,就不要写出来,**例如:我是某某,后续内容主页观看。**\n可用变量:{{content}}、{{count}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
{
|
||||
"version": "1.2",
|
||||
"timestamp": "2026-01-10T04:12:13.221Z",
|
||||
"ipAgentSettings": {
|
||||
"textModelKey": "builtInDoubao|doubao-seed-2-0-mini-260215",
|
||||
"voiceModelKey": "",
|
||||
"voicePromptId": 7,
|
||||
"asrMode": "online",
|
||||
"asrModelKey": "",
|
||||
"voiceMode": "online",
|
||||
"videoDigitalHumanModelKey": "__CLOUD__",
|
||||
"scriptPrompt": "你是一个视频口播专家,擅长根据用户给的主题和关键词生成高质量的口播稿件。\n请根据以下要求生成口播稿件:\n\n1. 主题:{title}\n2. 字数:大约{count}字\n\n请确保稿件内容连贯、有吸引力,并且符合口播的风格。\n请直接输出生成的口播稿件,无需任何额外说明文字。\n\n可用变量:{title}、{count}",
|
||||
"titleTagPrompt": "是短视频标题与标签助手。请基于提供的口播文案,生成一个标题和3-4个标签,输出的语言要完全是我们文案的语言格式\n- 标题不超过15个字,简洁有吸引力,不加引号,不含表情或图标字符。\n- 标签为2-6个字的中文短词,不含#、空格和表情;以主题词为主,避免冗长句。\n- 然后从文案里筛选出优秀的的词汇,必须是文案里包含的一字不差的,按照以下分类\n * 重点词/成语词:文案中的核心词汇、成语、重要概念。\n * 描述词:文案中用于描述事物特征、状态的优秀词汇。\n * 行动词:文案中表示动作、行为的优秀词汇。\n * 情感词:文案中表达情感、情绪的优秀词汇。\n\n-- 直接输出纯JSON格式,如果没有合适的词,[]格式里面可以留空,不要生凑,不要一个字的词,不要使用markdown代码块(不要用),不要附加任何说明文字。\n\n- 输出格式为:\n{\n \"title\": \"...\",\n \"tags\": [\"...\", \"...\", \"...\", \"...\"],\n \"keywords\": {\n \"重点词/成语词\": [\"...\", \"...\"],\n \"描述词\": [\"...\"],\n \"行动词\": [\"...\"],\n \"情感词\": [\"...\",]\n }\n}\n\n\n文案:<content>{{content}}</content>",
|
||||
"topicRewritePrompt": "参考以下标题,帮我生成5个新的标题。\n\n参考标题:\n{{content}}\n\n要求:\n1. 保持相似的主题和风格\n2. 标题简洁有力,吸引眼球\n3. 不要添加表情符号和#标签\n4. 每个标题独立成行\n\n请直接输出5个新标题,不要编号和额外说明。\n可用变量:{{content}}",
|
||||
"rewritePrompt": "# 你是一位文案改写助手,不增加表情或特殊符号,输出{{count}}字。\n原文:\n{{content}}\n\n\n ## 写作限制和特征\n -禁止输出的词:“原文”“作者”“文章”等词,攻击性强、侮辱性强的词(滚吧、找爸爸去、废物)、骂人的词(傻子、垃圾、蠢蛋),ai常用词(如“这波操作”、“666”)\n -用词口语化,句式有短句、情绪浓烈,\n -不要输出前言和介绍,也不能直白的指责。\n -写作的内容不要引用提示词的要求。\n\n ## 写作要求\n 1、第一段直接照抄原文的第一句话作为文章开头,一个字也不能改,必须控制30个字以内,绝对禁止照抄的第一句话超过30个字,超过30个字在不影响句子连贯的情况下截停。\n\n 2、后面的内容要具体叙述具体行为,每一个具体动作都不能跳过,可以直接使用原文句子,但是不能完全一致,从原文案的内容里面,用不同的角度切入叙述,确保内容完整,可以使用同义字进行替换,重组结构。\n\n 3、最后一段可以直接使用原文的最后一段,如果有身份信息的内容,就不要写出来,**例如:我是某某,后续内容主页观看。**",
|
||||
"subtitleHighlightWords": "",
|
||||
"bgmDirectory": "",
|
||||
"enableSentimentAnalysis": false,
|
||||
"sentimentModelKey": "zhipu|glm-4-flash",
|
||||
"sentimentTemplate": "auto",
|
||||
"sentimentEffectIntensity": 70,
|
||||
"enableSentimentAnimation": true,
|
||||
"sentimentColors": {
|
||||
"positive": "#00FF00",
|
||||
"negative": "#FF0000",
|
||||
"neutral": "#FFFFFF",
|
||||
"emphasis": "#FFFF00"
|
||||
},
|
||||
"minSubtitleDuration": 2,
|
||||
"normalSubtitleX": 50,
|
||||
"normalSubtitleY": 15,
|
||||
"effectSubtitleX": 50,
|
||||
"effectSubtitleY": 50,
|
||||
"disableSmartLayout": false,
|
||||
"keywordGroupPositions": [
|
||||
{
|
||||
"groupName": "行动词",
|
||||
"effectX": 15
|
||||
},
|
||||
{
|
||||
"groupName": "情感词",
|
||||
"effectX": 50,
|
||||
"effectY": 53
|
||||
},
|
||||
{
|
||||
"groupName": "描述词",
|
||||
"effectX": 46,
|
||||
"effectY": 63
|
||||
},
|
||||
{
|
||||
"groupName": "重点词/成语词",
|
||||
"effectX": 50,
|
||||
"effectY": 89
|
||||
},
|
||||
{
|
||||
"groupName": "12"
|
||||
}
|
||||
],
|
||||
"avoidanceConfig": {
|
||||
"safeDistance": 20,
|
||||
"preferDirection": "right"
|
||||
},
|
||||
"silenceThreshold": -40,
|
||||
"silenceDuration": 1,
|
||||
"minPauseDuration": 0.15,
|
||||
"videoDigitalHumanMode": "local"
|
||||
},
|
||||
"prompts": {
|
||||
"scriptPrompt": "你是一个视频口播专家,擅长根据用户给的主题和关键词生成高质量的口播稿件。\n请根据以下要求生成口播稿件:\n\n1. 主题:{title}\n2. 字数:大约{count}字\n\n请确保稿件内容连贯、有吸引力,并且符合口播的风格。\n请直接输出生成的口播稿件,无需任何额外说明文字。\n\n可用变量:{title}、{count}",
|
||||
"titleTagPrompt": "是短视频标题与标签助手。请基于提供的口播文案,生成一个标题和3-4个标签,输出的语言要完全是我们文案的语言格式\n- 标题不超过15个字,简洁有吸引力,不加引号,不含表情或图标字符。\n- 标签为2-6个字的中文短词,不含#、空格和表情;以主题词为主,避免冗长句。\n- 然后从文案里筛选出优秀的的词汇,必须是文案里包含的一字不差的,按照以下分类\n * 重点词/成语词:文案中的核心词汇、成语、重要概念。\n * 描述词:文案中用于描述事物特征、状态的优秀词汇。\n * 行动词:文案中表示动作、行为的优秀词汇。\n * 情感词:文案中表达情感、情绪的优秀词汇。\n\n-- 直接输出纯JSON格式,如果没有合适的词,[]格式里面可以留空,不要生凑,不要一个字的词,不要使用markdown代码块(不要用),不要附加任何说明文字。\n\n- 输出格式为:\n{\n \"title\": \"...\",\n \"tags\": [\"...\", \"...\", \"...\", \"...\"],\n \"keywords\": {\n \"重点词/成语词\": [\"...\", \"...\"],\n \"描述词\": [\"...\"],\n \"行动词\": [\"...\"],\n \"情感词\": [\"...\",]\n }\n}\n\n\n文案:<content>{{content}}</content>",
|
||||
"topicRewritePrompt": "参考以下标题,帮我生成5个新的标题。\n\n参考标题:\n{{content}}\n\n要求:\n1. 保持相似的主题和风格\n2. 标题简洁有力,吸引眼球\n3. 不要添加表情符号和#标签\n4. 每个标题独立成行\n\n请直接输出5个新标题,不要编号和额外说明。\n可用变量:{{content}}",
|
||||
"rewritePrompt": "# 你是一位文案改写助手,不增加表情或特殊符号,输出{{count}}字。\n原文:\n{{content}}\n\n\n ## 写作限制和特征\n -禁止输出的词:“原文”“作者”“文章”等词,攻击性强、侮辱性强的词(滚吧、找爸爸去、废物)、骂人的词(傻子、垃圾、蠢蛋),ai常用词(如“这波操作”、“666”)\n -用词口语化,句式有短句、情绪浓烈,\n -不要输出前言和介绍,也不能直白的指责。\n -写作的内容不要引用提示词的要求。\n\n ## 写作要求\n 1、第一段直接照抄原文的第一句话作为文章开头,一个字也不能改,必须控制30个字以内,绝对禁止照抄的第一句话超过30个字,超过30个字在不影响句子连贯的情况下截停。\n\n 2、后面的内容要具体叙述具体行为,每一个具体动作都不能跳过,可以直接使用原文句子,但是不能完全一致,从原文案的内容里面,用不同的角度切入叙述,确保内容完整,可以使用同义字进行替换,重组结构。\n\n 3、最后一段可以直接使用原文的最后一段,如果有身份信息的内容,就不要写出来,**例如:我是某某,后续内容主页观看。**",
|
||||
"voicePromptId": 7,
|
||||
"audioVoicePromptId": 7,
|
||||
"audioLanguage": "中文"
|
||||
},
|
||||
"videoMixCutSettings": {
|
||||
"enabled": true,
|
||||
"mode": "fixed",
|
||||
"fixedModeConfig": {
|
||||
"enabled": true,
|
||||
"opening": {
|
||||
"enabled": false,
|
||||
"timeRange": {
|
||||
"durationMin": 2,
|
||||
"durationMax": 5
|
||||
},
|
||||
"materialFolderPath": "",
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "top-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
"middle": {
|
||||
"enabled": false,
|
||||
"timeRange": {
|
||||
"durationMin": 2,
|
||||
"durationMax": 5
|
||||
},
|
||||
"materialFolderPath": "",
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-left",
|
||||
"pipSizePercent": 40,
|
||||
"pipScaleMode": "fit",
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
"ending": {
|
||||
"enabled": false,
|
||||
"timeRange": {
|
||||
"durationMin": 2,
|
||||
"durationMax": 5
|
||||
},
|
||||
"materialFolderPath": "",
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
}
|
||||
},
|
||||
"keywordModeConfig": {
|
||||
"enabled": false,
|
||||
"groups": [
|
||||
{
|
||||
"groupId": "group_action",
|
||||
"groupName": "行动词",
|
||||
"materialFolderPath": "",
|
||||
"durationMin": 2,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": false,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_emotion",
|
||||
"groupName": "情感词",
|
||||
"materialFolderPath": "",
|
||||
"durationMin": 2,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": false,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_description",
|
||||
"groupName": "描述词",
|
||||
"materialFolderPath": "",
|
||||
"durationMin": 2,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": false,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_keyemphasis",
|
||||
"groupName": "重点词/成语词",
|
||||
"materialFolderPath": "",
|
||||
"durationMin": 2,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": false,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupId": "group_12",
|
||||
"groupName": "12",
|
||||
"materialFolderPath": "",
|
||||
"durationMin": 2,
|
||||
"durationMax": 5,
|
||||
"selectionMode": "random",
|
||||
"displayMode": "fullscreen",
|
||||
"pipPosition": "bottom-right",
|
||||
"pipSizePercent": 30,
|
||||
"pipScaleMode": "fit",
|
||||
"enabled": false,
|
||||
"originalVideoMinimized": {
|
||||
"enabled": false,
|
||||
"shape": "square",
|
||||
"position": "bottom-right",
|
||||
"sizePercent": 25
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"subtitleTemplates": [
|
||||
{
|
||||
"id": "template_system_11",
|
||||
"name": "11",
|
||||
"description": "自定义字幕模板 - 黑底白字,支持关键词特效",
|
||||
"createdAt": 1767950393391,
|
||||
"config": {
|
||||
"subtitleStyleId": "system-subtitle-11",
|
||||
"subtitleStyle": {
|
||||
"id": "custom",
|
||||
"name": "自定义样式",
|
||||
"description": "完全自定义的字幕样式",
|
||||
"preview": "示例文字",
|
||||
"fontName": "Noto Serif CJK",
|
||||
"fontSize": 48,
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 3,
|
||||
"shadowOffset": 2,
|
||||
"shadowColor": "#000000",
|
||||
"shadowBlur": 4,
|
||||
"backgroundColor": "transparent",
|
||||
"backgroundOpacity": 0,
|
||||
"position": "bottom",
|
||||
"alignment": "center",
|
||||
"fontSource": "bundled"
|
||||
},
|
||||
"subtitleFontSizeScale": 100,
|
||||
"subtitlePosition": "bottom",
|
||||
"enableKeywordEffects": true,
|
||||
"keywordGroups": [
|
||||
{
|
||||
"name": "12",
|
||||
"description": "模板分组12",
|
||||
"color": "#FF6B6B",
|
||||
"effectId": "none",
|
||||
"keywords": [],
|
||||
"styleOverride": {},
|
||||
"effectConfig": {}
|
||||
},
|
||||
{
|
||||
"name": "重点词/成语词",
|
||||
"description": "视频中的重点词汇和成语",
|
||||
"color": "#EF0707EC",
|
||||
"effectId": "fly-in",
|
||||
"soundEffectId": "chain-pull",
|
||||
"keywords": [],
|
||||
"styleOverride": {
|
||||
"fontName": "字玩哥特黑白无常 免费 超黑",
|
||||
"fontSize": 80,
|
||||
"fontColor": "#EF0707EC",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 10
|
||||
},
|
||||
"effectConfig": {
|
||||
"duration": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "描述词",
|
||||
"description": "描述性的形容词和副词",
|
||||
"color": "#FFFFFF",
|
||||
"effectId": "intense-flash",
|
||||
"soundEffectId": "pop-1",
|
||||
"keywords": [],
|
||||
"styleOverride": {
|
||||
"fontName": "USMCCyuanjiantecu",
|
||||
"fontSize": 64,
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 1
|
||||
},
|
||||
"effectConfig": {
|
||||
"duration": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "情感词",
|
||||
"description": "表达情感的词汇",
|
||||
"color": "#FEFEFE",
|
||||
"effectId": "danmaku",
|
||||
"soundEffectId": "cash-register",
|
||||
"keywords": [],
|
||||
"styleOverride": {
|
||||
"fontName": "心朵儿体",
|
||||
"fontSize": 68,
|
||||
"fontColor": "#FEFEFE",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 0
|
||||
},
|
||||
"effectConfig": {
|
||||
"duration": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "行动词",
|
||||
"description": "需要强调的行动动词",
|
||||
"color": "#6CC4F6",
|
||||
"effectId": "ARC_TEXT",
|
||||
"soundEffectId": "knife-scrape",
|
||||
"keywords": [],
|
||||
"styleOverride": {
|
||||
"fontName": "USMCCyuanjiantecu",
|
||||
"fontSize": 200,
|
||||
"fontColor": "#6CC4F6",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 3
|
||||
},
|
||||
"effectConfig": {
|
||||
"duration": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"screenWidth": 1920,
|
||||
"screenHeight": 1080,
|
||||
"fontSize": 48,
|
||||
"fontFamily": "Arial, sans-serif",
|
||||
"keywordGroupsStyles": [
|
||||
{
|
||||
"groupName": "行动词",
|
||||
"styleOverride": {
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 3,
|
||||
"fontSize": 68,
|
||||
"fontName": "Noto Serif CJK"
|
||||
},
|
||||
"effectId": "intense-flash",
|
||||
"effectDuration": 2,
|
||||
"color": "#FFFFFF",
|
||||
"soundEffectId": "magic-sfx",
|
||||
"soundEffectConfig": {
|
||||
"volume": 0.7,
|
||||
"pitch": 1,
|
||||
"fadeIn": 0,
|
||||
"fadeOut": 50,
|
||||
"triggerTime": "start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupName": "情感词",
|
||||
"styleOverride": {
|
||||
"fontColor": "#1D69DE",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 0,
|
||||
"fontSize": 192,
|
||||
"fontName": "Noto Serif CJK"
|
||||
},
|
||||
"effectId": "ARC_TEXT",
|
||||
"effectDuration": 2,
|
||||
"color": "#1D69DE",
|
||||
"soundEffectId": "swoosh",
|
||||
"soundEffectConfig": {
|
||||
"volume": 0.7,
|
||||
"pitch": 1,
|
||||
"fadeIn": 0,
|
||||
"fadeOut": 50,
|
||||
"triggerTime": "start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupName": "描述词",
|
||||
"styleOverride": {
|
||||
"fontColor": "#9ACAF9",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 1,
|
||||
"fontSize": 48,
|
||||
"fontName": "Noto Serif CJK"
|
||||
},
|
||||
"effectId": "danmaku",
|
||||
"effectDuration": 2,
|
||||
"color": "#9ACAF9",
|
||||
"soundEffectId": "cash-register",
|
||||
"soundEffectConfig": {
|
||||
"volume": 0.7,
|
||||
"pitch": 1,
|
||||
"fadeIn": 0,
|
||||
"fadeOut": 50,
|
||||
"triggerTime": "start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupName": "重点词/成语词",
|
||||
"styleOverride": {
|
||||
"fontColor": "#EF0707EC",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 10,
|
||||
"fontSize": 104,
|
||||
"fontName": "Noto Serif CJK"
|
||||
},
|
||||
"effectId": "fly-in",
|
||||
"effectDuration": 2,
|
||||
"color": "#EF0707EC",
|
||||
"soundEffectId": "blow-impact",
|
||||
"soundEffectConfig": {
|
||||
"volume": 0.7,
|
||||
"pitch": 1,
|
||||
"fadeIn": 0,
|
||||
"fadeOut": 50,
|
||||
"triggerTime": "start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"groupName": "12",
|
||||
"styleOverride": null,
|
||||
"effectId": null,
|
||||
"effectDuration": null,
|
||||
"color": null,
|
||||
"soundEffectId": null,
|
||||
"soundEffectConfig": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"isSystem": true
|
||||
}
|
||||
],
|
||||
"frontendState": {
|
||||
"selectedSubtitleTemplate": "template_system_11",
|
||||
"keywordGroups": [
|
||||
{
|
||||
"id": "group_action",
|
||||
"name": "行动词",
|
||||
"description": "用于表达行动和进度",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "重仓",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "预判",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "认准",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "聚焦",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "兑现",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "离场",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"color": "#FFFFFF",
|
||||
"effectId": "intense-flash",
|
||||
"styleOverride": {
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 3,
|
||||
"fontSize": 68,
|
||||
"fontName": "Noto Serif CJK"
|
||||
},
|
||||
"effectConfig": {
|
||||
"duration": 2
|
||||
},
|
||||
"styleConfig": {
|
||||
"fontSize": 64,
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 2
|
||||
},
|
||||
"createdAt": 1767519506523,
|
||||
"updatedAt": 1768017351366,
|
||||
"sound_effect_id": "magic-sfx",
|
||||
"sound_effect_config": "{\"volume\":0.7,\"pitch\":1,\"fadeIn\":0,\"fadeOut\":50,\"triggerTime\":\"start\"}",
|
||||
"stickerId": null,
|
||||
"effectDuration": 2,
|
||||
"soundEffectId": "magic-sfx",
|
||||
"soundEffectConfig": {
|
||||
"volume": 0.7,
|
||||
"pitch": 1,
|
||||
"fadeIn": 0,
|
||||
"fadeOut": 50,
|
||||
"triggerTime": "start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "group_emotion",
|
||||
"name": "情感词",
|
||||
"description": "用于表达情感",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "慌了",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "狂揽",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "不舒服",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"color": "#1D69DE",
|
||||
"effectId": "ARC_TEXT",
|
||||
"styleOverride": {
|
||||
"fontColor": "#1D69DE",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 0,
|
||||
"fontSize": 192,
|
||||
"fontName": "Noto Serif CJK"
|
||||
},
|
||||
"effectConfig": {
|
||||
"duration": 3
|
||||
},
|
||||
"styleConfig": {
|
||||
"fontSize": 64,
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 2
|
||||
},
|
||||
"createdAt": 1767519506517,
|
||||
"updatedAt": 1768017351438,
|
||||
"sound_effect_id": "swoosh",
|
||||
"sound_effect_config": "{\"volume\":0.7,\"pitch\":1,\"fadeIn\":0,\"fadeOut\":50,\"triggerTime\":\"start\"}",
|
||||
"stickerId": null,
|
||||
"effectDuration": 2,
|
||||
"soundEffectId": "swoosh",
|
||||
"soundEffectConfig": {
|
||||
"volume": 0.7,
|
||||
"pitch": 1,
|
||||
"fadeIn": 0,
|
||||
"fadeOut": 50,
|
||||
"triggerTime": "start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "group_description",
|
||||
"name": "描述词",
|
||||
"description": "用于描述场景和事物",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "果断",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "极致",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "无敌",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"color": "#9ACAF9",
|
||||
"effectId": "danmaku",
|
||||
"styleOverride": {
|
||||
"fontColor": "#9ACAF9",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 1,
|
||||
"fontSize": 48,
|
||||
"fontName": "Noto Serif CJK"
|
||||
},
|
||||
"effectConfig": {
|
||||
"duration": 2
|
||||
},
|
||||
"styleConfig": {
|
||||
"fontSize": 64,
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 2
|
||||
},
|
||||
"createdAt": 1767519506509,
|
||||
"updatedAt": 1768017351477,
|
||||
"sound_effect_id": "cash-register",
|
||||
"sound_effect_config": "{\"volume\":0.7,\"pitch\":1,\"fadeIn\":0,\"fadeOut\":50,\"triggerTime\":\"start\"}",
|
||||
"stickerId": null,
|
||||
"effectDuration": 2,
|
||||
"soundEffectId": "cash-register",
|
||||
"soundEffectConfig": {
|
||||
"volume": 0.7,
|
||||
"pitch": 1,
|
||||
"fadeIn": 0,
|
||||
"fadeOut": 50,
|
||||
"triggerTime": "start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "group_keyemphasis",
|
||||
"name": "重点词/成语词",
|
||||
"description": "用于强调关键词汇和成语",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "核心优势",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "护城河",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "投资逻辑",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"keyword": "价值标的",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"color": "#EF0707EC",
|
||||
"effectId": "fly-in",
|
||||
"styleOverride": {
|
||||
"fontColor": "#EF0707EC",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 10,
|
||||
"fontSize": 104,
|
||||
"fontName": "Noto Serif CJK"
|
||||
},
|
||||
"effectConfig": {
|
||||
"duration": 1.5
|
||||
},
|
||||
"styleConfig": {
|
||||
"fontSize": 64,
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 2
|
||||
},
|
||||
"createdAt": 1767519506503,
|
||||
"updatedAt": 1768017351515,
|
||||
"sound_effect_id": "blow-impact",
|
||||
"sound_effect_config": "{\"volume\":0.7,\"pitch\":1,\"fadeIn\":0,\"fadeOut\":50,\"triggerTime\":\"start\"}",
|
||||
"stickerId": null,
|
||||
"effectDuration": 2,
|
||||
"soundEffectId": "blow-impact",
|
||||
"soundEffectConfig": {
|
||||
"volume": 0.7,
|
||||
"pitch": 1,
|
||||
"fadeIn": 0,
|
||||
"fadeOut": 50,
|
||||
"triggerTime": "start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "group_12",
|
||||
"name": "12",
|
||||
"description": "模板分组12",
|
||||
"keywords": [],
|
||||
"color": null,
|
||||
"effectId": null,
|
||||
"effectConfig": {},
|
||||
"styleConfig": {
|
||||
"fontSize": 64,
|
||||
"fontColor": "#FFFFFF",
|
||||
"outlineColor": "#000000",
|
||||
"outlineWidth": 2
|
||||
},
|
||||
"createdAt": 1767519506497,
|
||||
"updatedAt": 1768017351556,
|
||||
"sound_effect_id": null,
|
||||
"sound_effect_config": null,
|
||||
"stickerId": null,
|
||||
"effectDuration": null
|
||||
}
|
||||
],
|
||||
"enableKeywordEffects": true,
|
||||
"enableSmartSubtitle": true,
|
||||
"enableSilenceRemoval": false,
|
||||
"subtitleFontSizeScale": 100,
|
||||
"subtitlePosition": "bottom",
|
||||
"enableBGM": true,
|
||||
"bgmVolume": 30,
|
||||
"videoTemplateId": 7,
|
||||
"enableTopTitle": false,
|
||||
"topTitleStyle": {
|
||||
"fontSize": 48,
|
||||
"fontColor": "#FFD700",
|
||||
"backgroundColor": "#000000",
|
||||
"backgroundOpacity": 0.7,
|
||||
"paddingHorizontal": 20,
|
||||
"paddingVertical": 10,
|
||||
"borderRadius": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
import {buildResolve, extraResolve} from "../lib/env";
|
||||
|
||||
export const logoPath = buildResolve("logo.png");
|
||||
export const icoLogoPath = buildResolve("logo.ico");
|
||||
export const icnsLogoPath = buildResolve("logo.icns");
|
||||
|
||||
export const trayPath =
|
||||
process.platform === "darwin" ? extraResolve("osx/tray/iconTemplate.png") : buildResolve("tray.png");
|
||||
@@ -0,0 +1,78 @@
|
||||
import source from "./../../src/lang/source.json";
|
||||
import enUS from "./../../src/lang/en-US.json";
|
||||
import zhCN from "./../../src/lang/zh-CN.json";
|
||||
import {isDev} from "../lib/env";
|
||||
import lang from "../mapi/lang/main";
|
||||
import {ConfigMain} from "../mapi/config/main";
|
||||
|
||||
export const defaultLocale = "zh-CN";
|
||||
|
||||
let locale = defaultLocale;
|
||||
|
||||
export const langMessageList = [
|
||||
{
|
||||
name: "en-US",
|
||||
label: "English",
|
||||
messages: enUS,
|
||||
},
|
||||
{
|
||||
name: "zh-CN",
|
||||
label: "简体中文",
|
||||
messages: zhCN,
|
||||
},
|
||||
];
|
||||
|
||||
const buildMessages = (): any => {
|
||||
let messages = {};
|
||||
for (let m of langMessageList) {
|
||||
let msgList = {};
|
||||
for (let k in source) {
|
||||
const v = source[k];
|
||||
if (m.messages[v]) {
|
||||
msgList[k] = m.messages[v];
|
||||
}
|
||||
}
|
||||
messages[m.name] = msgList;
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
let messages = buildMessages();
|
||||
|
||||
export const t = (text: string, param: object | null = null) => {
|
||||
if (isDev) {
|
||||
lang.writeSourceKeyUse(text).then();
|
||||
}
|
||||
if (messages[locale]) {
|
||||
if (messages[locale][text]) {
|
||||
if (param) {
|
||||
return messages[locale][text].replace(/\{(\w+)\}/g, function (match, key) {
|
||||
return key in param ? param[key] : match;
|
||||
});
|
||||
}
|
||||
return messages[locale][text];
|
||||
}
|
||||
}
|
||||
if (isDev) {
|
||||
lang.writeSourceKey(text).then();
|
||||
}
|
||||
if (param) {
|
||||
return text.replace(/\{(\w+)\}/g, function (match, key) {
|
||||
return key in param ? param[key] : match;
|
||||
});
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const readyAsync = async () => {
|
||||
locale = await ConfigMain.get("lang", defaultLocale);
|
||||
};
|
||||
|
||||
const getLocale = () => {
|
||||
return locale;
|
||||
};
|
||||
|
||||
export const ConfigLang = {
|
||||
readyAsync,
|
||||
getLocale,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import {app, Menu} from "electron";
|
||||
import {isDev, isMac} from "../lib/env";
|
||||
import {t} from "./lang";
|
||||
import {PageAbout} from "../page/about";
|
||||
|
||||
let contextMenu: Electron.Menu;
|
||||
|
||||
const ready = () => {
|
||||
const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
|
||||
if (isMac) {
|
||||
menuTemplate.push({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{label: `${t("关于")}${app.name}`, role: "about"},
|
||||
{type: "separator"},
|
||||
// {
|
||||
// label: t("设置"),
|
||||
// click: () => {
|
||||
// createSettingWindow();
|
||||
// },
|
||||
// accelerator: "CmdOrCtrl+,",
|
||||
// },
|
||||
// {type: "separator"},
|
||||
{label: t("服务"), role: "services"},
|
||||
{type: "separator"},
|
||||
{label: `${t("隐藏")} ${app.name}`, role: "hide"},
|
||||
{label: t("隐藏其他"), role: "hideOthers"},
|
||||
{label: t("全部显示"), role: "unhide"},
|
||||
{type: "separator"},
|
||||
{label: t("退出"), role: "quit"},
|
||||
],
|
||||
});
|
||||
}
|
||||
menuTemplate.push({
|
||||
label: t("编辑"),
|
||||
submenu: [
|
||||
{label: t("撤销"), accelerator: "CmdOrCtrl+Z", role: "undo"},
|
||||
{label: t("重做"), accelerator: "Shift+CmdOrCtrl+Z", role: "redo"},
|
||||
{type: "separator"},
|
||||
{label: t("剪切"), accelerator: "CmdOrCtrl+X", role: "cut"},
|
||||
{label: t("复制"), accelerator: "CmdOrCtrl+C", role: "copy"},
|
||||
{label: t("粘贴"), accelerator: "CmdOrCtrl+V", role: "paste"},
|
||||
{label: t("全选"), accelerator: "CmdOrCtrl+A", role: "selectAll"},
|
||||
],
|
||||
});
|
||||
if (isDev) {
|
||||
menuTemplate.push({
|
||||
label: t("视图"),
|
||||
submenu: [
|
||||
{label: t("重新加载"), role: "reload"},
|
||||
{label: t("强制重载"), role: "forceReload"},
|
||||
{label: t("开发者工具"), role: "toggleDevTools"},
|
||||
{type: "separator"},
|
||||
{label: t("实际大小"), role: "resetZoom", accelerator: ""},
|
||||
{label: t("放大"), role: "zoomIn"},
|
||||
{label: t("缩小"), role: "zoomOut"},
|
||||
{type: "separator"},
|
||||
{label: t("全屏"), role: "togglefullscreen"},
|
||||
],
|
||||
});
|
||||
}
|
||||
// menuTemplate.push({
|
||||
// label: t("帮助"),
|
||||
// role: "help",
|
||||
// submenu: [
|
||||
// // {
|
||||
// // label: t("教程帮助"),
|
||||
// // click: () => {
|
||||
// // createHelpWindow();
|
||||
// // },
|
||||
// // },
|
||||
// // {type: "separator"},
|
||||
// // {
|
||||
// // label: t("关于"),
|
||||
// // click: () => {
|
||||
// // PageAbout.open().then()
|
||||
// // },
|
||||
// // },
|
||||
// ],
|
||||
// })
|
||||
const menu = Menu.buildFromTemplate(menuTemplate);
|
||||
Menu.setApplicationMenu(menu);
|
||||
};
|
||||
|
||||
export const ConfigMenu = {
|
||||
ready,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
import {app, Menu, Tray} from "electron";
|
||||
import {trayPath} from "./icon";
|
||||
import {AppRuntime} from "../mapi/env";
|
||||
import {AppConfig} from "../../src/config";
|
||||
import {t} from "./lang";
|
||||
import {isMac, isWin} from "../lib/env";
|
||||
|
||||
let tray = null;
|
||||
|
||||
const showApp = () => {
|
||||
if (isMac) {
|
||||
app.dock.show();
|
||||
}
|
||||
AppRuntime.mainWindow.show();
|
||||
};
|
||||
|
||||
const hideApp = () => {
|
||||
if (isMac) {
|
||||
app.dock.hide();
|
||||
}
|
||||
AppRuntime.mainWindow.hide();
|
||||
};
|
||||
|
||||
const quitApp = () => {
|
||||
app.quit();
|
||||
};
|
||||
|
||||
const ready = () => {
|
||||
try {
|
||||
tray = new Tray(trayPath);
|
||||
} catch (err) {
|
||||
console.error('[Tray] Failed to create tray:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
tray.setToolTip(AppConfig.title);
|
||||
|
||||
if (isWin) {
|
||||
tray.on("click", () => {
|
||||
showApp();
|
||||
});
|
||||
}
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: t("显示主界面"),
|
||||
click: () => {
|
||||
showApp();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("重启"),
|
||||
click: () => {
|
||||
app.relaunch();
|
||||
quitApp();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("退出"),
|
||||
click: () => {
|
||||
quitApp();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const ConfigTray = {
|
||||
ready,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export const WindowConfig = {
|
||||
alwaysOpenDevTools: true,
|
||||
minWidth: 1024,
|
||||
minHeight: 680,
|
||||
initWidth: 1600,
|
||||
initHeight: 920,
|
||||
aboutWidth: 500,
|
||||
aboutHeight: 400,
|
||||
logWidth:800,
|
||||
logHeight: 600,
|
||||
feedbackWidth: 700,
|
||||
feedbackHeight: 600,
|
||||
guideWidth: 800,
|
||||
guideHeight: 540,
|
||||
paymentWidth: 500,
|
||||
paymentHeight: 400,
|
||||
setupWidth: 800,
|
||||
setupHeight: 540,
|
||||
};
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.svg" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* The built directory structure
|
||||
*
|
||||
* ```tree
|
||||
* ├─┬ dist-electron
|
||||
* │ ├─┬ main
|
||||
* │ │ └── index.js > Electron-Main
|
||||
* │ └─┬ preload
|
||||
* │ └── index.mjs > Preload-Scripts
|
||||
* ├─┬ dist
|
||||
* │ └── index.html > Electron-Renderer
|
||||
* ```
|
||||
*/
|
||||
APP_ROOT: string;
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Electron 防破解模块
|
||||
* 禁用开发者工具、快捷键、右键菜单
|
||||
*/
|
||||
|
||||
import { BrowserWindow, globalShortcut, Menu } from 'electron';
|
||||
import { isDev } from './env';
|
||||
|
||||
export class AntiDebugManager {
|
||||
private static instance: AntiDebugManager;
|
||||
private disabledShortcuts: string[] = [];
|
||||
private originalOpenDevTools: Map<BrowserWindow, Function> = new Map();
|
||||
private originalToggleDevTools: Map<BrowserWindow, Function> = new Map();
|
||||
|
||||
private constructor() {
|
||||
console.log('[AntiDebugManager] 初始化');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): AntiDebugManager {
|
||||
if (!AntiDebugManager.instance) {
|
||||
AntiDebugManager.instance = new AntiDebugManager();
|
||||
}
|
||||
return AntiDebugManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用开发者工具
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
disableDevTools(window: BrowserWindow): void {
|
||||
try {
|
||||
// 💾 保存原始方法,以便后续恢复
|
||||
this.originalOpenDevTools.set(window, window.webContents.openDevTools.bind(window.webContents));
|
||||
this.originalToggleDevTools.set(window, window.webContents.toggleDevTools.bind(window.webContents));
|
||||
|
||||
// 禁用 openDevTools 方法
|
||||
window.webContents.openDevTools = () => {
|
||||
console.warn('[AntiDebugManager] 尝试打开开发者工具被拦截');
|
||||
};
|
||||
|
||||
// 禁用 toggleDevTools 方法
|
||||
window.webContents.toggleDevTools = () => {
|
||||
console.warn('[AntiDebugManager] 尝试切换开发者工具被拦截');
|
||||
};
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 已禁用开发者工具');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用开发者工具失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用快捷键
|
||||
* 包括:F12、Ctrl+Shift+I、Ctrl+Shift+C、Ctrl+Shift+J、Ctrl+I 等
|
||||
*/
|
||||
disableDebugShortcuts(): void {
|
||||
try {
|
||||
const shortcuts = [
|
||||
'F12', // 开发者工具
|
||||
'Ctrl+Shift+I', // 开发者工具 (Windows/Linux)
|
||||
'Cmd+Option+I', // 开发者工具 (macOS)
|
||||
'Ctrl+Shift+C', // 检查元素
|
||||
'Cmd+Option+C', // 检查元素 (macOS)
|
||||
'Ctrl+Shift+J', // 控制台
|
||||
'Cmd+Option+J', // 控制台 (macOS)
|
||||
'Ctrl+I', // 开发者工具 (某些浏览器)
|
||||
'Cmd+Option+U', // 查看源代码 (macOS)
|
||||
'Ctrl+U', // 查看源代码 (Windows/Linux)
|
||||
'Ctrl+Shift+K', // 控制台 (某些浏览器)
|
||||
'Ctrl+Shift+D', // 某些调试快捷键
|
||||
];
|
||||
|
||||
shortcuts.forEach(shortcut => {
|
||||
try {
|
||||
globalShortcut.register(shortcut, () => {
|
||||
console.warn(`[AntiDebugManager] 快捷键 "${shortcut}" 被拦截`);
|
||||
return true; // 阻止默认行为
|
||||
});
|
||||
this.disabledShortcuts.push(shortcut);
|
||||
} catch (error) {
|
||||
console.warn(`[AntiDebugManager] 注册快捷键 "${shortcut}" 失败:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[AntiDebugManager] ✅ 已禁用 ${this.disabledShortcuts.length} 个调试快捷键`);
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用快捷键失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用右键菜单
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
disableContextMenu(window: BrowserWindow): void {
|
||||
try {
|
||||
window.webContents.on('context-menu', (e) => {
|
||||
e.preventDefault();
|
||||
console.warn('[AntiDebugManager] 右键菜单被拦截');
|
||||
});
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 已禁用右键菜单');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用右键菜单失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用鼠标右键
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
disableMouseRightClick(window: BrowserWindow): void {
|
||||
try {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
// 检查是否是鼠标右键(某些情况下)
|
||||
if (input.type === 'mouseUp' && input.button === 2) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 已禁用鼠标右键');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用鼠标右键失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截快捷键(键盘事件)
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
interceptDebugHotkeys(window: BrowserWindow): void {
|
||||
try {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
// 拦截 F12
|
||||
if (input.key.toLowerCase() === 'f12') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] F12 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Ctrl+Shift+I (Windows/Linux)
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Ctrl+Shift+I 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Ctrl+Shift+C (Windows/Linux)
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Ctrl+Shift+C 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Ctrl+Shift+J (Windows/Linux)
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'j') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Ctrl+Shift+J 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Cmd+Option+I (macOS)
|
||||
if (input.meta && input.alt && input.key.toLowerCase() === 'i') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Cmd+Option+I 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Cmd+Option+C (macOS)
|
||||
if (input.meta && input.alt && input.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Cmd+Option+C 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Ctrl+U (查看源代码)
|
||||
if (input.control && input.key.toLowerCase() === 'u') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Ctrl+U 被拦截');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 已拦截调试快捷键');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 快捷键拦截失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用菜单栏(可选)
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
disableMenuBar(window: BrowserWindow): void {
|
||||
try {
|
||||
Menu.setApplicationMenu(null);
|
||||
console.log('[AntiDebugManager] ✅ 已禁用菜单栏');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用菜单栏失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的防破解方案
|
||||
* @param window 目标窗口
|
||||
* @param options 选项
|
||||
*/
|
||||
enableFullProtection(window: BrowserWindow, options: {
|
||||
disableDevTools?: boolean;
|
||||
disableShortcuts?: boolean;
|
||||
disableContextMenu?: boolean;
|
||||
disableMenuBar?: boolean;
|
||||
interceptHotkeys?: boolean;
|
||||
} = {}): void {
|
||||
const {
|
||||
disableDevTools = true,
|
||||
disableShortcuts = true,
|
||||
disableContextMenu = true,
|
||||
disableMenuBar = true,
|
||||
interceptHotkeys = true
|
||||
} = options;
|
||||
|
||||
console.log('[AntiDebugManager] 启用完整防破解保护...');
|
||||
console.log('[AntiDebugManager] 环境:', isDev ? '开发' : '生产');
|
||||
|
||||
if (disableDevTools) {
|
||||
this.disableDevTools(window);
|
||||
}
|
||||
|
||||
if (disableShortcuts) {
|
||||
this.disableDebugShortcuts();
|
||||
}
|
||||
|
||||
if (disableContextMenu) {
|
||||
this.disableContextMenu(window);
|
||||
}
|
||||
|
||||
if (disableMenuBar) {
|
||||
this.disableMenuBar(window);
|
||||
}
|
||||
|
||||
if (interceptHotkeys) {
|
||||
this.interceptDebugHotkeys(window);
|
||||
}
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 防破解保护已启用');
|
||||
}
|
||||
|
||||
/**
|
||||
* 临时启用开发者工具(用于隐藏调试快捷键)
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
restoreDevTools(window: BrowserWindow): void {
|
||||
try {
|
||||
const originalMethod = this.originalOpenDevTools.get(window);
|
||||
if (originalMethod && typeof originalMethod === 'function') {
|
||||
console.log('[AntiDebugManager] 使用保存的原始 openDevTools 方法打开 DevTools');
|
||||
// 调用保存的原始方法
|
||||
originalMethod({ mode: 'detach', activate: true });
|
||||
console.log('[AntiDebugManager] ✅ DevTools 已通过原始方法打开');
|
||||
} else {
|
||||
console.warn('[AntiDebugManager] 未找到保存的原始方法,尝试直接调用');
|
||||
// 备选方案:直接调用(可能被拦截,但尽量尝试)
|
||||
window.webContents.openDevTools({ mode: 'detach', activate: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 恢复 DevTools 失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载防破解(开发时使用)
|
||||
*/
|
||||
unload(): void {
|
||||
try {
|
||||
// 注销所有快捷键
|
||||
this.disabledShortcuts.forEach(shortcut => {
|
||||
globalShortcut.unregister(shortcut);
|
||||
});
|
||||
this.disabledShortcuts = [];
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 防破解已卸载');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 卸载防破解失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AntiDebugManager.getInstance();
|
||||
@@ -0,0 +1,25 @@
|
||||
import Apps from "../mapi/app";
|
||||
|
||||
export type ResultType<T> = {
|
||||
// should follow the rules:
|
||||
// <0 business error
|
||||
// =0 success
|
||||
// 10000 error ( network error, server error, etc. )
|
||||
code: number;
|
||||
msg: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export const post = async (url: string, data: any) => {
|
||||
data = data || {};
|
||||
const userAgent = Apps.getUserAgent();
|
||||
data["AppManagerUserAgent"] = userAgent;
|
||||
return await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"User-Agent": userAgent,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 浏览器路径工具
|
||||
* 统一管理所有模块的浏览器可执行文件路径
|
||||
* 支持自定义安装路径和离线使用
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AppEnv } from '../mapi/env';
|
||||
import { getRuntimeBundlePath } from './resource-path';
|
||||
|
||||
/**
|
||||
* 获取Playwright Chromium浏览器可执行文件路径
|
||||
* 优先使用打包的浏览器,支持自定义安装路径
|
||||
*/
|
||||
function getPlatformChromePaths(): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
return ['chrome-win64/chrome.exe', 'chrome-win/chrome.exe'];
|
||||
} else if (process.platform === 'darwin') {
|
||||
return [
|
||||
'chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
||||
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
||||
'chrome-mac/Chromium.app/Contents/MacOS/Chromium',
|
||||
'chrome-mac/chrome',
|
||||
];
|
||||
} else {
|
||||
return ['chrome-linux/chrome'];
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformCacheBase(): string[] {
|
||||
const runtimeCandidates = [
|
||||
getRuntimeBundlePath('playwright'),
|
||||
AppEnv.resourceBundleRoot ? path.join(AppEnv.resourceBundleRoot, 'playwright') : '',
|
||||
AppEnv.dataRoot ? path.join(AppEnv.dataRoot, 'playwright') : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return [
|
||||
...runtimeCandidates,
|
||||
path.join(process.env.LOCALAPPDATA || '', 'ms-playwright'),
|
||||
path.join(process.env.APPDATA || '', 'ms-playwright'),
|
||||
];
|
||||
} else if (process.platform === 'darwin') {
|
||||
return [
|
||||
...runtimeCandidates,
|
||||
path.join(process.env.HOME || '', 'Library/Caches/ms-playwright'),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
...runtimeCandidates,
|
||||
path.join(process.env.HOME || '', '.cache/ms-playwright'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlaywrightChromiumPath(): string | undefined {
|
||||
const runtimePath = getRuntimeBundlePath('playwright');
|
||||
const basePaths = [
|
||||
runtimePath,
|
||||
path.join(process.resourcesPath || app.getAppPath(), 'extra/common/playwright'),
|
||||
path.join(app.getAppPath(), 'resources/extra/common/playwright'),
|
||||
path.join(app.getAppPath(), 'electron/resources/extra/common/playwright'),
|
||||
].filter(Boolean);
|
||||
|
||||
const chromiumVersions = ['chromium-1200'];
|
||||
const chromePaths = getPlatformChromePaths();
|
||||
|
||||
for (const basePath of basePaths) {
|
||||
for (const version of chromiumVersions) {
|
||||
for (const chromePath of chromePaths) {
|
||||
const browserPath = path.join(basePath, version, chromePath);
|
||||
if (fs.existsSync(browserPath)) {
|
||||
console.log('✅ [Browser] 使用打包的浏览器:', browserPath);
|
||||
return browserPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const playwrightCacheBase = getPlatformCacheBase();
|
||||
|
||||
// 也尝试查找任何可用的 chromium 版本(仅用于开发环境回退)
|
||||
for (const cacheBase of playwrightCacheBase) {
|
||||
if (fs.existsSync(cacheBase)) {
|
||||
const allDirs = fs.readdirSync(cacheBase);
|
||||
const chromiumDirs = allDirs.filter(d => d.startsWith('chromium-')).sort().reverse();
|
||||
for (const version of chromiumDirs) {
|
||||
for (const chromePath of chromePaths) {
|
||||
const browserPath = path.join(cacheBase, version, chromePath);
|
||||
if (fs.existsSync(browserPath)) {
|
||||
console.log('⚠️ [Browser] 使用系统缓存的浏览器:', browserPath);
|
||||
return browserPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('⚠️ [Browser] 警告:未找到Playwright浏览器');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户数据目录(用于持久化浏览器数据)
|
||||
*/
|
||||
export function getBrowserUserDataDir(contextName: string): string {
|
||||
return path.join(app.getPath('userData'), 'browser-data', contextName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的浏览器启动参数
|
||||
* 参考 aigc-human 项目的反爬和性能优化配置
|
||||
*/
|
||||
export const COMMON_BROWSER_ARGS = [
|
||||
// === 核心反爬与沙箱配置 ===
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-infobars',
|
||||
|
||||
// === 启动速度优化 ===
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-breakpad', // 禁用崩溃报告
|
||||
'--disable-component-update', // 禁用组件自动更新
|
||||
'--disable-sync', // 禁用同步
|
||||
'--disable-default-apps',
|
||||
'--disable-popup-blocking',
|
||||
|
||||
// === 后台性能优化 (防止页面被浏览器通过节流策略降速) ===
|
||||
'--disable-background-networking',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
|
||||
// === 媒体与渲染优化 (针对视频发布场景) ===
|
||||
'--enable-gpu',
|
||||
'--enable-webgl',
|
||||
'--ignore-gpu-blacklist',
|
||||
'--enable-accelerated-video-decode',
|
||||
'--enable-gpu-rasterization',
|
||||
'--enable-proprietary-codecs', // 启用H.264等编解码器
|
||||
|
||||
// === 自动播放策略 ===
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
|
||||
// === 安全 ===
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
];
|
||||
|
||||
/**
|
||||
* 应该忽略的默认启动参数
|
||||
* 移除 Chromium 的自动化标识,避免被平台检测
|
||||
*/
|
||||
export const BROWSER_IGNORE_DEFAULT_ARGS = [
|
||||
'--enable-automation',
|
||||
];
|
||||
|
||||
/**
|
||||
* 反检测注入脚本
|
||||
* 在每个页面加载前执行,隐藏 Playwright/webdriver 痕迹
|
||||
*/
|
||||
export const STEALTH_INIT_SCRIPT = `
|
||||
// 隐藏 webdriver 标识
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => undefined,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 伪造 chrome.runtime(完善方法)
|
||||
window.chrome = {
|
||||
runtime: {
|
||||
PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd' },
|
||||
PlatformArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64', MIPS: 'mips', MIPS64: 'mips64' },
|
||||
PlatformNaclArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64', MIPS: 'mips', MIPS64: 'mips64' },
|
||||
RequestUpdateCheckStatus: { THROTTLED: 'throttled', NO_UPDATE: 'no_update', UPDATE_AVAILABLE: 'update_available' },
|
||||
OnInstalledReason: { INSTALL: 'install', UPDATE: 'update', CHROME_UPDATE: 'chrome_update', SHARED_MODULE_UPDATE: 'shared_module_update' },
|
||||
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
|
||||
connect: function() { return { onDisconnect: { addListener: function() {} }, onMessage: { addListener: function() {} }, postMessage: function() {}, disconnect: function() {} }; },
|
||||
sendMessage: function() {},
|
||||
id: undefined
|
||||
},
|
||||
loadTimes: function() { return { firstPaintTime: 0, startLoadTime: 0, commitLoadTime: 0, finishDocumentLoadTime: 0, finishLoadTime: 0, firstPaintAfterLoadTime: 0, navigationType: 'Other', requestTime: Date.now() / 1000 }; },
|
||||
csi: function() { return { startE: Date.now(), onloadT: Date.now(), pageT: Math.random() * 1000 + 500, tran: 15 }; },
|
||||
app: { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } },
|
||||
};
|
||||
|
||||
// 伪造 navigator.plugins
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => {
|
||||
return [{
|
||||
name: 'Chrome PDF Plugin',
|
||||
description: 'Portable Document Format',
|
||||
filename: 'internal-pdf-viewer',
|
||||
length: 1,
|
||||
}, {
|
||||
name: 'Chrome PDF Viewer',
|
||||
description: '',
|
||||
filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
|
||||
length: 1,
|
||||
}, {
|
||||
name: 'Native Client',
|
||||
description: '',
|
||||
filename: 'internal-nacl-plugin',
|
||||
length: 2,
|
||||
}];
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 伪造 navigator.languages
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['zh-CN', 'zh', 'en'],
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 伪造 navigator.hardwareConcurrency
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', {
|
||||
get: () => 8,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 伪造 navigator.connection
|
||||
if (!navigator.connection) {
|
||||
Object.defineProperty(navigator, 'connection', {
|
||||
get: () => ({ effectiveType: '4g', rtt: 50, downlink: 10, saveData: false }),
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
// 伪造 navigator.getBattery
|
||||
if (!navigator.getBattery) {
|
||||
navigator.getBattery = () => Promise.resolve({
|
||||
charging: true,
|
||||
chargingTime: 0,
|
||||
dischargingTime: Infinity,
|
||||
level: 1,
|
||||
addEventListener: function() {},
|
||||
removeEventListener: function() {},
|
||||
dispatchEvent: function() { return true; }
|
||||
});
|
||||
}
|
||||
|
||||
// 清理 Playwright 特征
|
||||
delete window.__playwright;
|
||||
delete window.__pw_manual;
|
||||
|
||||
// 清理自动化工具特征
|
||||
delete window._REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
delete window.__selenium_unwrapped;
|
||||
delete window.__webdriver_evaluate;
|
||||
delete window.__selenium_evaluate;
|
||||
delete window.__fxdriver_evaluate;
|
||||
delete window.__driver_unwrapped;
|
||||
delete window.__driver_evaluate;
|
||||
delete window.__phantomas;
|
||||
delete window.__nightmare;
|
||||
delete window.callSelenium;
|
||||
delete window._phantom;
|
||||
delete window.callPhantom;
|
||||
delete window.Buffer;
|
||||
delete window.emit;
|
||||
delete window.spawn;
|
||||
delete window.webdriver;
|
||||
`;
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Electron 主进程加密模块
|
||||
* 实现 RSA + AES 混合加密
|
||||
* 不会被代码混淆破坏
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
export class CryptoManager {
|
||||
private static instance: CryptoManager;
|
||||
private publicKeyPath: string;
|
||||
private privateKeyPath: string;
|
||||
private publicKey: string | null = null;
|
||||
private privateKey: string | null = null;
|
||||
|
||||
private constructor() {
|
||||
// 密钥存储路径
|
||||
const keyDir = path.join(app.getPath('userData'), 'keys');
|
||||
if (!fs.existsSync(keyDir)) {
|
||||
fs.mkdirSync(keyDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.publicKeyPath = path.join(keyDir, 'public.pem');
|
||||
this.privateKeyPath = path.join(keyDir, 'private.pem');
|
||||
|
||||
console.log('[CryptoManager] 初始化,密钥路径:', this.publicKeyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): CryptoManager {
|
||||
if (!CryptoManager.instance) {
|
||||
CryptoManager.instance = new CryptoManager();
|
||||
}
|
||||
return CryptoManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 RSA 密钥对
|
||||
* 如果密钥不存在则生成新的
|
||||
*/
|
||||
async initializeKeys(): Promise<void> {
|
||||
try {
|
||||
// 检查密钥是否已存在
|
||||
if (fs.existsSync(this.publicKeyPath) && fs.existsSync(this.privateKeyPath)) {
|
||||
this.publicKey = fs.readFileSync(this.publicKeyPath, 'utf-8');
|
||||
this.privateKey = fs.readFileSync(this.privateKeyPath, 'utf-8');
|
||||
console.log('[CryptoManager] ✅ 已加载现有 RSA 密钥对');
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成新的 RSA 密钥对
|
||||
console.log('[CryptoManager] 生成新的 RSA 密钥对...');
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
});
|
||||
|
||||
// 保存到文件
|
||||
fs.writeFileSync(this.publicKeyPath, publicKey, 'utf-8');
|
||||
fs.writeFileSync(this.privateKeyPath, privateKey, 'utf-8');
|
||||
|
||||
this.publicKey = publicKey;
|
||||
this.privateKey = privateKey;
|
||||
|
||||
console.log('[CryptoManager] ✅ RSA 密钥对生成完成');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] ❌ 密钥初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公钥(用于前端加密)
|
||||
*/
|
||||
getPublicKey(): string {
|
||||
if (!this.publicKey) {
|
||||
throw new Error('公钥未初始化,请先调用 initializeKeys()');
|
||||
}
|
||||
return this.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 加密(用公钥)
|
||||
* @param data 要加密的数据
|
||||
* @returns Base64 编码的密文
|
||||
*/
|
||||
encryptRSA(data: string): string {
|
||||
try {
|
||||
if (!this.publicKey) {
|
||||
throw new Error('公钥未初始化');
|
||||
}
|
||||
|
||||
const encrypted = crypto.publicEncrypt(
|
||||
{
|
||||
key: this.publicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING
|
||||
},
|
||||
Buffer.from(data, 'utf-8')
|
||||
);
|
||||
|
||||
return encrypted.toString('base64');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] RSA 加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 解密(用私钥)
|
||||
* @param encryptedData Base64 编码的密文
|
||||
* @returns 解密后的明文
|
||||
*/
|
||||
decryptRSA(encryptedData: string): string {
|
||||
try {
|
||||
if (!this.privateKey) {
|
||||
throw new Error('私钥未初始化');
|
||||
}
|
||||
|
||||
const decrypted = crypto.privateDecrypt(
|
||||
{
|
||||
key: this.privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING
|
||||
},
|
||||
Buffer.from(encryptedData, 'base64')
|
||||
);
|
||||
|
||||
return decrypted.toString('utf-8');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] RSA 解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM 加密(改进版,替代 AES-128-ECB)
|
||||
* @param data 要加密的数据
|
||||
* @param key 加密密钥(32字节/256位)
|
||||
* @returns 包含 iv 和密文的 JSON 字符串(Base64 编码)
|
||||
*/
|
||||
encryptAES(data: string, key: string): string {
|
||||
try {
|
||||
// 生成随机 IV
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// 确保密钥长度为 32 字节
|
||||
const keyBuffer = Buffer.alloc(32);
|
||||
Buffer.from(key, 'utf-8').copy(keyBuffer);
|
||||
|
||||
// 创建加密器
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
|
||||
|
||||
// 加密数据
|
||||
let encrypted = cipher.update(data, 'utf-8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
// 获取认证标签
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// 返回格式:{iv: string, encryptedData: string, authTag: string}
|
||||
const result = {
|
||||
iv: iv.toString('base64'),
|
||||
encryptedData: encrypted,
|
||||
authTag: authTag.toString('base64')
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(result)).toString('base64');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] AES 加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM 解密
|
||||
* @param encryptedData Base64 编码的加密数据
|
||||
* @param key 解密密钥(32字节/256位)
|
||||
* @returns 解密后的明文
|
||||
*/
|
||||
decryptAES(encryptedData: string, key: string): string {
|
||||
try {
|
||||
// 解析加密数据
|
||||
const result = JSON.parse(Buffer.from(encryptedData, 'base64').toString('utf-8'));
|
||||
|
||||
// 确保密钥长度为 32 字节
|
||||
const keyBuffer = Buffer.alloc(32);
|
||||
Buffer.from(key, 'utf-8').copy(keyBuffer);
|
||||
|
||||
// 创建解密器
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
keyBuffer,
|
||||
Buffer.from(result.iv, 'base64')
|
||||
);
|
||||
|
||||
// 设置认证标签
|
||||
decipher.setAuthTag(Buffer.from(result.authTag, 'base64'));
|
||||
|
||||
// 解密数据
|
||||
let decrypted = decipher.update(result.encryptedData, 'hex', 'utf-8');
|
||||
decrypted += decipher.final('utf-8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] AES 解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA256 哈希(安全的哈希替代 MD5)
|
||||
* @param data 要哈希的数据
|
||||
* @returns 哈希值
|
||||
*/
|
||||
sha256(data: string): string {
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机密钥
|
||||
* @param length 密钥长度(字节数)
|
||||
* @returns Base64 编码的随机密钥
|
||||
*/
|
||||
generateRandomKey(length: number = 32): string {
|
||||
return crypto.randomBytes(length).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名(用私钥)
|
||||
* @param data 要签名的数据
|
||||
* @returns Base64 编码的签名
|
||||
*/
|
||||
sign(data: string): string {
|
||||
try {
|
||||
if (!this.privateKey) {
|
||||
throw new Error('私钥未初始化');
|
||||
}
|
||||
|
||||
const sign = crypto.createSign('sha256');
|
||||
sign.update(data);
|
||||
return sign.sign(this.privateKey, 'base64');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] 签名失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证签名(用公钥)
|
||||
* @param data 原始数据
|
||||
* @param signature Base64 编码的签名
|
||||
* @returns 签名是否有效
|
||||
*/
|
||||
verify(data: string, signature: string): boolean {
|
||||
try {
|
||||
if (!this.publicKey) {
|
||||
throw new Error('公钥未初始化');
|
||||
}
|
||||
|
||||
const verify = crypto.createVerify('sha256');
|
||||
verify.update(data);
|
||||
return verify.verify(this.publicKey, signature, 'base64');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] 签名验证失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CryptoManager.getInstance();
|
||||
@@ -0,0 +1,101 @@
|
||||
import {BrowserView, BrowserWindow, screen} from "electron";
|
||||
import {isDev, shouldEnableDevTools} from "./env";
|
||||
import {WindowConfig} from "../config/window";
|
||||
|
||||
export const DevToolsManager = {
|
||||
enable: true,
|
||||
rowCount: 4,
|
||||
colCount: 3,
|
||||
windows: new Map<BrowserWindow | BrowserView, BrowserWindow>(),
|
||||
setEnable(enable: boolean) {
|
||||
DevToolsManager.enable = enable;
|
||||
},
|
||||
getWindow(win: BrowserWindow | BrowserView) {
|
||||
return this.windows.get(win);
|
||||
},
|
||||
getOrCreateWindow(name: string, win: BrowserWindow | BrowserView) {
|
||||
if (this.windows.has(win)) {
|
||||
return this.windows.get(win);
|
||||
}
|
||||
const {x, y, width, height} = this.getDisplayPosition();
|
||||
// console.log('DevToolsManager', name, {x, y, width, height})
|
||||
const devtools = new BrowserWindow({
|
||||
show: true,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
title: name,
|
||||
});
|
||||
devtools.on("closed", (e) => {
|
||||
// console.log('DevToolsManager', 'close', name)
|
||||
this.windows.delete(win);
|
||||
})
|
||||
// console.log('DevToolsManager', name, {x, y})
|
||||
win.webContents.setDevToolsWebContents(devtools.webContents);
|
||||
win.webContents.on("destroyed", () => {
|
||||
// console.log('DevToolsManager', 'destroyed', name)
|
||||
devtools.destroy();
|
||||
});
|
||||
devtools.webContents.on("dom-ready", () => {
|
||||
setTimeout(() => {
|
||||
if (!devtools.isDestroyed()) {
|
||||
devtools.setTitle(name);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
this.windows.set(win, devtools);
|
||||
return devtools;
|
||||
},
|
||||
getLargestDisplay(): Electron.Display {
|
||||
const displays = screen.getAllDisplays();
|
||||
return displays.reduce((max, display) => {
|
||||
const {width, height} = display.size;
|
||||
const maxResolution = max.size.width * max.size.height;
|
||||
const currentResolution = width * height;
|
||||
return currentResolution > maxResolution ? display : max;
|
||||
});
|
||||
},
|
||||
getDisplayPosition(): {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const display = this.getLargestDisplay();
|
||||
const {x, y, width, height} = display.workArea;
|
||||
// console.log('DevToolsManager', 'getDisplayPosition', {x, y, width, height})
|
||||
if (width < 1300) {
|
||||
this.rowCount = 3;
|
||||
this.colCount = 2;
|
||||
}
|
||||
const itemWidth = Math.floor(width / this.rowCount);
|
||||
const itemHeight = Math.floor(height / this.colCount);
|
||||
const maxRow = Math.floor(width / itemWidth);
|
||||
const row = this.windows.size % maxRow;
|
||||
const col = Math.floor(this.windows.size / maxRow);
|
||||
return {
|
||||
x: x + row * itemWidth,
|
||||
y: y + col * itemHeight,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
};
|
||||
},
|
||||
register(name: string, win: BrowserWindow | BrowserView) {
|
||||
if (!shouldEnableDevTools || !DevToolsManager.enable) {
|
||||
return;
|
||||
}
|
||||
this.getOrCreateWindow(name, win);
|
||||
},
|
||||
autoShow(win: BrowserWindow | BrowserView) {
|
||||
if (!shouldEnableDevTools || !DevToolsManager.enable) {
|
||||
return;
|
||||
}
|
||||
if (WindowConfig.alwaysOpenDevTools) {
|
||||
win.webContents.openDevTools({
|
||||
mode: "detach",
|
||||
activate: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import url, {fileURLToPath} from "node:url";
|
||||
import {BrowserView, BrowserWindow} from "electron";
|
||||
import {isPackaged} from "./env";
|
||||
import path, {join} from "node:path";
|
||||
import {Log} from "../mapi/log/main";
|
||||
import {existsSync} from "node:fs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// 🔧 计算应用根目录 - 使用多重降级方案确保兼容 ASAR 打包
|
||||
function getAppRoot(): string {
|
||||
try {
|
||||
// 开发环境:从当前文件目录往上两级
|
||||
if (!isPackaged) {
|
||||
const root = path.join(__dirname, "../..");
|
||||
if (root && typeof root === 'string') {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[getAppRoot] 开发环境路径计算失败:', error);
|
||||
}
|
||||
|
||||
// 生产环境或计算失败:尝试使用 process.resourcesPath(最可靠)
|
||||
try {
|
||||
if (process.resourcesPath && typeof process.resourcesPath === 'string') {
|
||||
return process.resourcesPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[getAppRoot] process.resourcesPath 获取失败:', error);
|
||||
}
|
||||
|
||||
// 最后的降级方案:使用可执行文件目录
|
||||
try {
|
||||
const execPath = process.execPath;
|
||||
if (execPath && typeof execPath === 'string') {
|
||||
return path.dirname(execPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[getAppRoot] process.execPath 获取失败:', error);
|
||||
}
|
||||
|
||||
// 如果都失败,返回当前目录(最后防线)
|
||||
return __dirname;
|
||||
}
|
||||
|
||||
const appRoot = getAppRoot();
|
||||
process.env.APP_ROOT = appRoot;
|
||||
|
||||
console.log('[env-main] APP_ROOT 已设置:', process.env.APP_ROOT);
|
||||
|
||||
// 🔧 处理 ASAR 打包:当使用 asar 打包时,应用代码在 app.asar 内
|
||||
// 根据是否为 asar 打包来调整路径
|
||||
let MAIN_DIST: string;
|
||||
if (isPackaged && appRoot === process.resourcesPath) {
|
||||
// 生产环境且使用了 asar 打包
|
||||
// preload 和主进程代码在 app.asar 内,但资源在 app.asar.unpacked 或 resources 根目录
|
||||
try {
|
||||
const appAsarPath = path.join(appRoot, 'app.asar');
|
||||
if (existsSync(appAsarPath)) {
|
||||
MAIN_DIST = path.join(appAsarPath, "dist-electron");
|
||||
} else {
|
||||
MAIN_DIST = path.join(appRoot, "dist-electron");
|
||||
}
|
||||
} catch (e) {
|
||||
MAIN_DIST = path.join(appRoot, "dist-electron");
|
||||
}
|
||||
} else {
|
||||
// 开发环境或非 asar 打包
|
||||
MAIN_DIST = path.join(appRoot, "dist-electron");
|
||||
}
|
||||
|
||||
// 🔧 渲染进程文件也在 app.asar 内
|
||||
let RENDERER_DIST: string;
|
||||
if (isPackaged && appRoot === process.resourcesPath) {
|
||||
try {
|
||||
const appAsarPath = path.join(appRoot, 'app.asar');
|
||||
if (existsSync(appAsarPath)) {
|
||||
RENDERER_DIST = path.join(appAsarPath, "dist");
|
||||
} else {
|
||||
RENDERER_DIST = path.join(appRoot, "dist");
|
||||
}
|
||||
} catch (e) {
|
||||
RENDERER_DIST = path.join(appRoot, "dist");
|
||||
}
|
||||
} else {
|
||||
RENDERER_DIST = path.join(appRoot, "dist");
|
||||
}
|
||||
|
||||
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(appRoot, "public") : RENDERER_DIST;
|
||||
|
||||
console.log('[env-main] MAIN_DIST:', MAIN_DIST);
|
||||
console.log('[env-main] RENDERER_DIST:', RENDERER_DIST);
|
||||
|
||||
export { MAIN_DIST, RENDERER_DIST };
|
||||
export const preloadDefault = path.join(MAIN_DIST, "preload/index.js");
|
||||
|
||||
console.log('[env-main] preloadDefault:', preloadDefault);
|
||||
|
||||
export const rendererLoadPath = (window: BrowserWindow | BrowserView, fileName: string) => {
|
||||
if (!isPackaged && process.env.VITE_DEV_SERVER_URL) {
|
||||
const x = new url.URL(rendererDistPath(fileName));
|
||||
if (window instanceof BrowserView) {
|
||||
window.webContents.loadURL(x.toString());
|
||||
} else {
|
||||
window.loadURL(x.toString());
|
||||
}
|
||||
} else {
|
||||
if (window instanceof BrowserView) {
|
||||
window.webContents.loadFile(rendererDistPath(fileName));
|
||||
} else {
|
||||
window.loadFile(rendererDistPath(fileName));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const rendererDistPath = (fileName: string) => {
|
||||
if (!isPackaged && process.env.VITE_DEV_SERVER_URL) {
|
||||
return `${process.env.VITE_DEV_SERVER_URL.replace(/\/+$/, "")}/${fileName}`;
|
||||
}
|
||||
return join(RENDERER_DIST, fileName);
|
||||
};
|
||||
|
||||
export const rendererIsUrl = (url: string) => {
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
};
|
||||
|
||||
export const getGpuInfo = async () => {
|
||||
const list = [] as {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}[];
|
||||
try {
|
||||
// @ts-ignore
|
||||
const si = await import("systeminformation");
|
||||
const graphics = await si.graphics();
|
||||
graphics.controllers.forEach((controller, index) => {
|
||||
const size = Math.ceil(controller.vram / 1024);
|
||||
let id = index + "";
|
||||
const name = controller.model;
|
||||
list.push({id, name, size});
|
||||
});
|
||||
} catch (e) {
|
||||
Log.error("getGpuInfo", e);
|
||||
}
|
||||
return list;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import {execSync} from "child_process";
|
||||
import {resolve} from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "os";
|
||||
import {Log} from "../mapi/log";
|
||||
import FileIndex from "../mapi/file";
|
||||
|
||||
export const isPackaged = ["true"].includes(process.env.IS_PACKAGED);
|
||||
|
||||
export const isDev = !isPackaged;
|
||||
|
||||
// 🆕 调试模式开关(支持环境变量和特殊标志)
|
||||
export const isDebugMode =
|
||||
process.env.ELECTRON_DEBUG_MODE === 'true' ||
|
||||
process.env.ELECTRON_FORCE_DEVTOOLS === '1';
|
||||
|
||||
// 🆕 是否应该启用DevTools(开发环境或调试模式)
|
||||
export const shouldEnableDevTools = isDev || isDebugMode;
|
||||
|
||||
export const isWin = process.platform === "win32";
|
||||
|
||||
export const isMac = process.platform === "darwin";
|
||||
|
||||
export const isLinux = process.platform === "linux";
|
||||
|
||||
export const isMain = process.type === "browser";
|
||||
|
||||
export const isRender = process.type === "renderer";
|
||||
|
||||
export const platformName = (): "win" | "osx" | "linux" | null => {
|
||||
if (isWin) return "win";
|
||||
if (isMac) return "osx";
|
||||
if (isLinux) return "linux";
|
||||
return null;
|
||||
};
|
||||
|
||||
export const memoryInfo = () => {
|
||||
return {
|
||||
total: os.totalmem(),
|
||||
free: os.freemem(),
|
||||
};
|
||||
};
|
||||
|
||||
const tryFirst = (functionList: (() => any)[]) => {
|
||||
for (const fun of functionList) {
|
||||
try {
|
||||
return fun();
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let platformVersionCache: string | null = null;
|
||||
export const platformVersion = () => {
|
||||
if (null === platformVersionCache) {
|
||||
const functionList: any[] = [];
|
||||
if (isWin) {
|
||||
functionList.push(() => execSync("wmic os get Version").toString().split("\n")[1].trim());
|
||||
functionList.push(() =>
|
||||
execSync(
|
||||
"powershell -command \"(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ReleaseId\""
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
);
|
||||
} else if (isMac) {
|
||||
functionList.push(() => execSync("sw_vers -productVersion").toString().trim());
|
||||
} else if (isLinux) {
|
||||
functionList.push(() =>
|
||||
execSync("cat /etc/os-release | grep VERSION_ID").toString().split("=")[1].trim().replace(/"/g, "")
|
||||
);
|
||||
}
|
||||
platformVersionCache = tryFirst(functionList);
|
||||
if (!platformVersionCache) {
|
||||
Log.error("env.platformVersion.error");
|
||||
platformVersionCache = "0.0.0";
|
||||
}
|
||||
}
|
||||
return platformVersionCache;
|
||||
};
|
||||
|
||||
export const platformArch = (): "x86" | "arm64" | null => {
|
||||
switch (os.arch()) {
|
||||
case "x64":
|
||||
return "x86";
|
||||
case "arm64":
|
||||
return "arm64";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let platformUUIDCache: string | null = null;
|
||||
export const platformUUID = () => {
|
||||
if (null === platformUUIDCache) {
|
||||
const functionList: any[] = [];
|
||||
if (isWin) {
|
||||
functionList.push(() => execSync("wmic csproduct get UUID").toString().split("\n")[1].trim());
|
||||
functionList.push(() =>
|
||||
execSync('powershell -command "(Get-WmiObject Win32_ComputerSystemProduct).UUID"').toString().trim()
|
||||
);
|
||||
} else if (isMac) {
|
||||
functionList.push(() =>
|
||||
execSync("system_profiler SPHardwareDataType | grep UUID").toString().split(": ")[1].trim()
|
||||
);
|
||||
} else if (isLinux) {
|
||||
functionList.push(() => execSync("cat /var/lib/dbus/machine-id").toString().trim().toUpperCase());
|
||||
}
|
||||
platformUUIDCache = tryFirst(functionList);
|
||||
if (!platformUUIDCache) {
|
||||
Log.error("env.platformUUID.error");
|
||||
platformUUIDCache = "000000";
|
||||
}
|
||||
}
|
||||
return platformUUIDCache;
|
||||
};
|
||||
|
||||
export const buildResolve = (value: string): string => {
|
||||
const basePath = isPackaged ? process.resourcesPath : "electron/resources";
|
||||
return resolve(basePath, "build", value);
|
||||
};
|
||||
|
||||
export const binResolve = (value: string): string => {
|
||||
return resolve(process.resourcesPath, "bin", value);
|
||||
};
|
||||
|
||||
export const extraResolve = (filePath: string): string => {
|
||||
const basePath = isPackaged ? process.resourcesPath : "electron/resources";
|
||||
return resolve(basePath, "extra", filePath);
|
||||
};
|
||||
|
||||
export const extraResolveBin = (filePath: string): string => {
|
||||
const originalFilePath = filePath;
|
||||
if (isWin) {
|
||||
if (!filePath.endsWith(".exe")) {
|
||||
filePath += ".exe";
|
||||
}
|
||||
}
|
||||
const dir = [platformName(), platformArch()].join("-");
|
||||
const p = [dir, filePath].join("/");
|
||||
const binaryPath = extraResolve(p);
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
// 对于 ffmpeg 和 ffprobe,回退到系统PATH
|
||||
if (originalFilePath === 'ffmpeg' || originalFilePath === 'ffprobe') {
|
||||
Log.info(`本地${originalFilePath}未找到,尝试使用系统PATH中的${originalFilePath}`);
|
||||
return originalFilePath;
|
||||
}
|
||||
throw new Error(`错误:未找到${originalFilePath},请先安装${originalFilePath}并添加到系统环境变量`);
|
||||
}
|
||||
return binaryPath;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import {BrowserView, BrowserWindow} from "electron";
|
||||
import {AppsMain} from "../mapi/app/main";
|
||||
|
||||
type HookType = never | "Show" | "Hide" | "EnterFullScreen" | "LeaveFullScreen" | "ShowQuitConfirmDialog";
|
||||
|
||||
export const executeHooks = async (win: BrowserWindow, hook: HookType, data?: any) => {
|
||||
const evalJs = `
|
||||
if(window.__page && window.__page.hooks && typeof window.__page.hooks.on${hook} === 'function' ) {
|
||||
try {
|
||||
window.__page.hooks.on${hook}(${JSON.stringify(data)});
|
||||
} catch(e) {
|
||||
console.log('executeHooks.on${hook}.error', e);
|
||||
}
|
||||
}`;
|
||||
return win.webContents?.executeJavaScript(evalJs);
|
||||
};
|
||||
|
||||
export const executeDarkMode = async (
|
||||
view: BrowserWindow | BrowserView,
|
||||
data: {
|
||||
isSystem: boolean;
|
||||
}
|
||||
) => {
|
||||
data = Object.assign(
|
||||
{
|
||||
isSystem: false,
|
||||
},
|
||||
data
|
||||
);
|
||||
if (await AppsMain.shouldDarkMode()) {
|
||||
// body and html
|
||||
view.webContents.executeJavaScript(`
|
||||
document.body.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
`);
|
||||
if (data.isSystem) {
|
||||
view.webContents.executeJavaScript(`document.body.setAttribute('arco-theme', 'dark');`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function normalizePath(filePath: string): string {
|
||||
if (!filePath) return filePath;
|
||||
return path.normalize(filePath);
|
||||
}
|
||||
|
||||
export function normalizeOutputPath(filePath: string): string {
|
||||
if (!filePath) return filePath;
|
||||
|
||||
if (os.platform() !== 'win32') return filePath;
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
return path.normalize(filePath);
|
||||
}
|
||||
|
||||
export function normalizePaths(filePaths: string[]): string[] {
|
||||
return filePaths.map(p => normalizePath(p));
|
||||
}
|
||||
|
||||
export function quotePath(filePath: string): string {
|
||||
if (filePath.includes(' ') && !filePath.startsWith('"')) {
|
||||
return `"${filePath}"`;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function createSafeTempPath(extension: string): string {
|
||||
const tempDir = os.tmpdir();
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const filename = `temp_${timestamp}_${random}.${extension}`;
|
||||
return path.join(tempDir, filename);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {isMac} from "./env";
|
||||
|
||||
let nodeMacPermissions = null;
|
||||
if (isMac) {
|
||||
(async () => {
|
||||
try {
|
||||
nodeMacPermissions = await import("node-mac-permissions");
|
||||
nodeMacPermissions = nodeMacPermissions.default;
|
||||
// console.log('nodeMacPermissions',nodeMacPermissions);
|
||||
} catch (e) {}
|
||||
})();
|
||||
}
|
||||
|
||||
export const Permissions = {
|
||||
async checkAccessibilityAccess(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isMac) {
|
||||
const status = nodeMacPermissions.getAuthStatus("accessibility");
|
||||
resolve(status === "authorized");
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
async askAccessibilityAccess() {
|
||||
nodeMacPermissions.askForAccessibilityAccess();
|
||||
},
|
||||
async checkScreenCaptureAccess(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isMac) {
|
||||
const status = nodeMacPermissions.getAuthStatus("screen");
|
||||
resolve(status === "authorized");
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
async askScreenCaptureAccess() {
|
||||
nodeMacPermissions.askForScreenCaptureAccess(true);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import PinyinMatch from "pinyin-match";
|
||||
|
||||
export const PinyinUtil = {
|
||||
match(input, keywords) {
|
||||
const index = PinyinMatch.match(input, keywords);
|
||||
let inputMark = input;
|
||||
let similarity = 0;
|
||||
if (index) {
|
||||
const indexStart = index[0];
|
||||
const indexEnd = index[1];
|
||||
inputMark =
|
||||
input.substring(0, indexStart) +
|
||||
"<mark>" +
|
||||
input.substring(indexStart, indexEnd + 1) +
|
||||
"</mark>" +
|
||||
input.substring(indexEnd + 1);
|
||||
similarity = (indexEnd - indexStart + 1) / input.length;
|
||||
}
|
||||
return {
|
||||
matched: !!index,
|
||||
inputMark,
|
||||
similarity,
|
||||
};
|
||||
},
|
||||
mark(text) {
|
||||
return `<mark>${text}</mark>`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "node:path";
|
||||
import Log from "../mapi/log/main";
|
||||
import { getRuntimeBundleRoot, getRuntimeRoot, isDevelopment } from "./resource-path";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export class ProcessCleanupManager {
|
||||
private static childProcesses: Set<number> = new Set();
|
||||
|
||||
static registerChildProcess(pid: number) {
|
||||
this.childProcesses.add(pid);
|
||||
Log.info(`[ProcessCleanup] Registered child process: ${pid}`);
|
||||
}
|
||||
|
||||
static unregisterChildProcess(pid: number) {
|
||||
this.childProcesses.delete(pid);
|
||||
Log.info(`[ProcessCleanup] Unregistered child process: ${pid}`);
|
||||
}
|
||||
|
||||
static getChildProcesses(): number[] {
|
||||
return Array.from(this.childProcesses);
|
||||
}
|
||||
|
||||
private static async killProcess(pid: number): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
await execAsync(`taskkill /F /PID ${pid} /T`);
|
||||
} else {
|
||||
await execAsync(`kill -9 ${pid}`);
|
||||
}
|
||||
Log.info(`[ProcessCleanup] Killed process: ${pid}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
Log.error(`[ProcessCleanup] Failed to kill process ${pid}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static escapePowerShellString(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
private static getCleanupRoots(): string[] {
|
||||
const roots = new Set<string>();
|
||||
const candidates = [
|
||||
path.dirname(process.execPath),
|
||||
getRuntimeRoot(),
|
||||
getRuntimeBundleRoot(),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
roots.add(path.resolve(candidate));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(roots);
|
||||
}
|
||||
|
||||
private static async killProcessesUnderAppRoots(): Promise<number> {
|
||||
if (process.platform !== "win32" || isDevelopment()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const roots = this.getCleanupRoots();
|
||||
if (roots.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rootList = roots.map(root => `'${this.escapePowerShellString(root)}'`).join(", ");
|
||||
const command =
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command ` +
|
||||
`"$roots = @(${rootList}); ` +
|
||||
`$roots = $roots | Where-Object { $_ } | ForEach-Object { [System.IO.Path]::GetFullPath($_) }; ` +
|
||||
`Get-Process -ErrorAction SilentlyContinue | ` +
|
||||
`Where-Object { $_.Id -ne ${process.pid} -and $_.Path } | ` +
|
||||
`Where-Object { ` +
|
||||
`try { ` +
|
||||
`$path = [System.IO.Path]::GetFullPath($_.Path); ` +
|
||||
`foreach ($root in $roots) { ` +
|
||||
`if ($path.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } ` +
|
||||
`}; ` +
|
||||
`$false ` +
|
||||
`} catch { $false } ` +
|
||||
`} | ` +
|
||||
`ForEach-Object { try { Stop-Process -Id $_.Id -Force -ErrorAction Stop; Write-Output $_.Id } catch {} }"`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(command);
|
||||
const count = stdout
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean).length;
|
||||
if (count > 0) {
|
||||
Log.info("[ProcessCleanup] Killed app-owned processes under install roots", { count, roots });
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static async cleanupAllProcesses(): Promise<boolean> {
|
||||
Log.info("[ProcessCleanup] Starting cleanup...");
|
||||
|
||||
const registeredPids = this.getChildProcesses();
|
||||
for (const pid of registeredPids) {
|
||||
await this.killProcess(pid);
|
||||
this.unregisterChildProcess(pid);
|
||||
}
|
||||
|
||||
await this.killProcessesUnderAppRoots();
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await this.killProcessesUnderAppRoots();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
Log.info("[ProcessCleanup] Cleanup completed");
|
||||
return true;
|
||||
}
|
||||
|
||||
static getRunningTaskCount(): number {
|
||||
return this.childProcesses.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** 在主进程中获取关键信息存储到环境变量中,从而在预加载脚本中及渲染进程中使用 */
|
||||
import {app} from "electron";
|
||||
|
||||
/** 注意: app.isPackaged 可能被被某些方法改变所以请将该文件放到 main.js 必须位于非依赖项的顶部 */
|
||||
if (process.platform === "darwin") {
|
||||
const fixPath = require("fix-path");
|
||||
fixPath();
|
||||
}
|
||||
|
||||
process.env.IS_PACKAGED = String(app.isPackaged);
|
||||
|
||||
process.env.DESKTOP_PATH = app.getPath("desktop");
|
||||
|
||||
process.env.CWD = process.cwd();
|
||||
|
||||
export const isDummy = false;
|
||||
@@ -0,0 +1,182 @@
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { getRuntimeBundlePath } from './resource-path';
|
||||
|
||||
const SETUP_VERSION = '2';
|
||||
|
||||
function getSetupFlagPath(): string {
|
||||
return path.join(app.getPath('userData'), '.python-setup-done');
|
||||
}
|
||||
|
||||
function getPythonInstallDir(): string {
|
||||
return getRuntimeBundlePath('python-runtime');
|
||||
}
|
||||
|
||||
function getPythonBinPath(): string {
|
||||
return path.join(getPythonInstallDir(), 'python', 'bin', 'python3');
|
||||
}
|
||||
|
||||
function getPipBinPath(): string {
|
||||
return path.join(getPythonInstallDir(), 'python', 'bin', 'pip3');
|
||||
}
|
||||
|
||||
function getLocalResourcePath(filename: string): string | null {
|
||||
const runtimeBundlePath = path.join(getRuntimeBundlePath('python-runtime'), filename);
|
||||
if (fs.existsSync(runtimeBundlePath)) {
|
||||
return runtimeBundlePath;
|
||||
}
|
||||
|
||||
const isDev = __dirname.includes('electron') && !__dirname.includes('dist-electron');
|
||||
const base = isDev
|
||||
? path.join(app.getAppPath(), 'electron', 'resources', 'extra', 'osx')
|
||||
: path.join(process.resourcesPath, 'extra', 'osx');
|
||||
const fullPath = path.join(base, filename);
|
||||
return fs.existsSync(fullPath) ? fullPath : null;
|
||||
}
|
||||
|
||||
function execAsync(cmd: string, opts?: any): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd, { timeout: 300000, ...opts }, (err, stdout, stderr) => {
|
||||
resolve({ code: err ? (err.code as number) || 1 : 0, stdout: stdout || '', stderr: stderr || '' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function isSetupComplete(): boolean {
|
||||
if (process.platform === 'win32') return true;
|
||||
if (!fs.existsSync(getSetupFlagPath())) return false;
|
||||
try {
|
||||
const v = fs.readFileSync(getSetupFlagPath(), 'utf-8').trim();
|
||||
if (v !== SETUP_VERSION) return false;
|
||||
return fs.existsSync(getPythonBinPath());
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function markSetupComplete(): void {
|
||||
fs.writeFileSync(getSetupFlagPath(), SETUP_VERSION);
|
||||
}
|
||||
|
||||
async function extractPython(): Promise<string> {
|
||||
const installDir = getPythonInstallDir();
|
||||
const pythonBin = getPythonBinPath();
|
||||
|
||||
if (fs.existsSync(pythonBin)) {
|
||||
console.log('[PythonSetup] Python 已解压:', pythonBin);
|
||||
return pythonBin;
|
||||
}
|
||||
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
|
||||
const arch = os.arch();
|
||||
const tarName = arch === 'arm64' ? 'python-mac-arm64.tar.gz' : 'python-mac-x64.tar.gz';
|
||||
const tarPath = getLocalResourcePath(tarName);
|
||||
|
||||
if (!tarPath) {
|
||||
throw new Error(`未找到 Python 包: ${tarName}`);
|
||||
}
|
||||
|
||||
console.log('[PythonSetup] 解压 Python:', tarPath);
|
||||
const result = await execAsync(`tar -xzf "${tarPath}" -C "${installDir}"`, { timeout: 120000 });
|
||||
if (result.code !== 0) {
|
||||
throw new Error('解压 Python 失败: ' + result.stderr.substring(0, 200));
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(installDir);
|
||||
const pythonEntry = entries.find(e => e.startsWith('python'));
|
||||
if (pythonEntry && pythonEntry !== 'python') {
|
||||
fs.renameSync(path.join(installDir, pythonEntry), path.join(installDir, 'python'));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!fs.existsSync(pythonBin)) {
|
||||
throw new Error('解压后找不到 python3');
|
||||
}
|
||||
|
||||
try { fs.chmodSync(pythonBin, 0o755); } catch {}
|
||||
console.log('[PythonSetup] ✅ Python 解压完成');
|
||||
return pythonBin;
|
||||
}
|
||||
|
||||
async function installWheels(pythonBin: string): Promise<void> {
|
||||
const wheelsDir = getLocalResourcePath('wheels');
|
||||
if (!wheelsDir) {
|
||||
throw new Error('未找到 wheels 目录');
|
||||
}
|
||||
|
||||
const wheelFiles = fs.readdirSync(wheelsDir).filter(f => f.endsWith('.whl') || f.endsWith('.tar.gz'));
|
||||
if (wheelFiles.length === 0) {
|
||||
throw new Error('wheels 目录为空');
|
||||
}
|
||||
|
||||
console.log(`[PythonSetup] 离线安装 ${wheelFiles.length} 个包...`);
|
||||
|
||||
const allFiles = wheelFiles.map(f => `"${path.join(wheelsDir, f)}"`).join(' ');
|
||||
|
||||
const result = await execAsync(
|
||||
`"${pythonBin}" -m pip install --no-index --no-deps ${allFiles}`,
|
||||
{ timeout: 300000 }
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
console.warn('[PythonSetup] 部分 pip install 失败,尝试逐个安装...');
|
||||
for (const f of wheelFiles) {
|
||||
const filePath = path.join(wheelsDir, f);
|
||||
const r = await execAsync(`"${pythonBin}" -m pip install --no-index "${filePath}"`, { timeout: 120000 });
|
||||
if (r.code !== 0) {
|
||||
console.warn(`[PythonSetup] 安装失败: ${f}`, r.stderr.substring(0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[PythonSetup] ✅ 依赖安装完成');
|
||||
}
|
||||
|
||||
async function verifyAll(pythonBin: string): Promise<boolean> {
|
||||
const code = `
|
||||
import importlib
|
||||
for mod in ['cv2','numpy','PIL','rembg','onnxruntime','jieba','loguru']:
|
||||
try: importlib.import_module(mod)
|
||||
except: exit(1)
|
||||
print('OK')
|
||||
`;
|
||||
const result = await execAsync(`"${pythonBin}" -c '${code.replace(/\n/g, ' ')}'`, { timeout: 30000 });
|
||||
return result.code === 0 && result.stdout.trim() === 'OK';
|
||||
}
|
||||
|
||||
export async function ensurePythonSetup(): Promise<string | null> {
|
||||
if (process.platform === 'win32') return null;
|
||||
|
||||
const pythonBin = getPythonBinPath();
|
||||
if (isSetupComplete() && fs.existsSync(pythonBin)) {
|
||||
return pythonBin;
|
||||
}
|
||||
|
||||
console.log('[PythonSetup] 开始自动初始化(完全离线)...');
|
||||
|
||||
try {
|
||||
const pyBin = await extractPython();
|
||||
await installWheels(pyBin);
|
||||
|
||||
const ok = await verifyAll(pyBin);
|
||||
if (!ok) {
|
||||
console.warn('[PythonSetup] ⚠️ 部分依赖验证失败,但继续运行');
|
||||
}
|
||||
|
||||
markSetupComplete();
|
||||
console.log('[PythonSetup] ✅ Python 环境初始化完成');
|
||||
return pyBin;
|
||||
} catch (err) {
|
||||
console.error('[PythonSetup] ❌ 初始化失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMacOSPythonPath(): string | null {
|
||||
if (process.platform === 'win32') return null;
|
||||
const p = getPythonBinPath();
|
||||
return fs.existsSync(p) ? p : null;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Python 进程启动工具
|
||||
* 统一处理中文路径问题
|
||||
*/
|
||||
|
||||
import { spawn, SpawnOptions } from 'child_process';
|
||||
import { normalizePath } from './path-util';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import logger from '../mapi/log/main';
|
||||
|
||||
export interface PythonSpawnOptions extends SpawnOptions {
|
||||
/**
|
||||
* 是否转换所有路径参数(默认 true)
|
||||
*/
|
||||
normalizePaths?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Python 进程,自动处理中文路径
|
||||
* @param pythonPath Python 可执行文件路径
|
||||
* @param args Python 脚本和参数
|
||||
* @param options spawn 选项
|
||||
* @returns ChildProcess
|
||||
*/
|
||||
export function spawnPython(
|
||||
pythonPath: string,
|
||||
args: string[],
|
||||
options?: PythonSpawnOptions
|
||||
) {
|
||||
const platform = os.platform();
|
||||
const shouldNormalize = options?.normalizePaths !== false && platform === 'win32';
|
||||
|
||||
// Windows 平台:转换所有路径
|
||||
if (shouldNormalize) {
|
||||
// 转换 Python 可执行文件路径
|
||||
const normalizedPythonPath = normalizePath(pythonPath);
|
||||
|
||||
// 转换参数中的路径
|
||||
const normalizedArgs = args.map((arg, index) => {
|
||||
// 检查是否是文件路径(包含路径分隔符或扩展名)
|
||||
if (arg.includes('/') || arg.includes('\\') ||
|
||||
(arg.includes('.') && !arg.startsWith('-'))) {
|
||||
const normalized = normalizePath(arg);
|
||||
|
||||
if (normalized !== arg) {
|
||||
logger.info('[PythonSpawn] 路径转换', {
|
||||
index,
|
||||
original: arg.substring(0, 100),
|
||||
normalized: normalized.substring(0, 100)
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
|
||||
logger.info('[PythonSpawn] 启动 Python 进程', {
|
||||
pythonPath: normalizedPythonPath.substring(0, 100),
|
||||
argsCount: normalizedArgs.length,
|
||||
cwd: options?.cwd
|
||||
});
|
||||
|
||||
return spawn(normalizedPythonPath, normalizedArgs, options);
|
||||
}
|
||||
|
||||
// macOS/Linux:直接启动
|
||||
return spawn(pythonPath, args, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Python 脚本并返回 Promise
|
||||
* @param pythonPath Python 可执行文件路径
|
||||
* @param args Python 脚本和参数
|
||||
* @param options spawn 选项
|
||||
* @returns Promise<{stdout: string, stderr: string, exitCode: number}>
|
||||
*/
|
||||
export function execPython(
|
||||
pythonPath: string,
|
||||
args: string[],
|
||||
options?: PythonSpawnOptions
|
||||
): Promise<{stdout: string, stderr: string, exitCode: number}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnPython(pythonPath, args, options);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (exitCode) => {
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: exitCode || 0
|
||||
});
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时配置文件(确保在纯英文路径)
|
||||
* @param config 配置对象
|
||||
* @param prefix 文件名前缀
|
||||
* @returns 配置文件路径
|
||||
*/
|
||||
export function createTempConfig(config: any, prefix: string = 'config'): string {
|
||||
const fs = require('fs');
|
||||
|
||||
// 使用系统临时目录(通常是纯英文路径)
|
||||
const tempDir = os.tmpdir();
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const configPath = path.join(tempDir, `${prefix}_${timestamp}_${random}.json`);
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
logger.info('[PythonSpawn] 创建临时配置文件', {
|
||||
path: configPath,
|
||||
size: JSON.stringify(config).length
|
||||
});
|
||||
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时配置文件
|
||||
* @param configPath 配置文件路径
|
||||
*/
|
||||
export function cleanupTempConfig(configPath: string): void {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
logger.info('[PythonSpawn] 清理临时配置文件', { path: configPath });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[PythonSpawn] 清理临时配置文件失败', {
|
||||
path: configPath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Python工具模块
|
||||
* 提供Python可执行文件路径查找和验证
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {spawn} from 'child_process';
|
||||
import { getPythonExecutablePath, resourceExists, isDevelopment } from './resource-path';
|
||||
|
||||
/**
|
||||
* 获取Python可执行文件路径
|
||||
* ⚠️ 只使用程序自带的 Python,不依赖系统环境
|
||||
* 如果找不到自带的 Python,会抛出错误
|
||||
*/
|
||||
export function getPythonPath(): string {
|
||||
const bundledPythonPath = getPythonExecutablePath();
|
||||
const isDev = isDevelopment();
|
||||
|
||||
console.log('[getPythonPath]', {
|
||||
isDev,
|
||||
bundledPythonPath,
|
||||
exists: resourceExists(bundledPythonPath)
|
||||
});
|
||||
|
||||
if (resourceExists(bundledPythonPath)) {
|
||||
console.log('[getPythonPath] ✅ 找到程序自带的 Python:', bundledPythonPath);
|
||||
return bundledPythonPath;
|
||||
}
|
||||
|
||||
// ❌ 找不到自带的 Python - 这是严重错误,应该报错
|
||||
const errorMsg = `❌ 程序自带的 Python 不存在: ${bundledPythonPath}\n` +
|
||||
`环境: ${isDev ? '开发环境' : '生产环境'}\n` +
|
||||
`请确认程序是否正确安装,或重新安装程序。`;
|
||||
console.error('[getPythonPath]', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Python是否可用
|
||||
* @returns Promise<boolean>
|
||||
*/
|
||||
export async function verifyPython(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const pythonPath = getPythonPath();
|
||||
const process = spawn(pythonPath, ['--version']);
|
||||
|
||||
process.on('close', (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
process.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Python版本信息
|
||||
* @returns Promise<string | null>
|
||||
*/
|
||||
export async function getPythonVersion(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const pythonPath = getPythonPath();
|
||||
const process = spawn(pythonPath, ['--version']);
|
||||
|
||||
let output = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0 && output) {
|
||||
resolve(output.trim());
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 资源路径工具模块
|
||||
* 统一管理开发环境和生产环境的资源路径
|
||||
* ⚠️ 确保所有依赖都能在不同环境下正确识别
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { AppEnv } from '../mapi/env';
|
||||
|
||||
/**
|
||||
* 判断是否为开发环境
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
const appRoot = process.env.APP_ROOT || process.env.ELECTRON_RESOURCES_PATH || process.cwd();
|
||||
// 修复:dist-release目录不是开发环境
|
||||
if (appRoot.includes('dist-release') || appRoot.includes('dist-electron')) {
|
||||
return false;
|
||||
}
|
||||
// 🔧 修复:不硬编码项目文件夹名,改用检查开发目录结构是否存在
|
||||
// 开发环境特征:项目根目录有 electron/resources/extra 目录
|
||||
const devResourcePath = path.join(appRoot, 'electron', 'resources', 'extra');
|
||||
if (fs.existsSync(devResourcePath)) {
|
||||
return true;
|
||||
}
|
||||
// 回退:检查旧的文件夹名(兼容)
|
||||
return appRoot.includes('aigcpanel-main');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源根目录
|
||||
*/
|
||||
export function getResourceRoot(): string {
|
||||
const isDev = isDevelopment();
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境:项目根目录/electron/resources/extra
|
||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||
return path.join(appRoot, 'electron', 'resources', 'extra');
|
||||
} else {
|
||||
// 生产环境:使用 process.resourcesPath 获取 resources 目录
|
||||
// process.resourcesPath 指向 E:\xxx\resources
|
||||
// 资源文件在 E:\xxx\resources\extra
|
||||
// 关键修复:直接使用 process.resourcesPath 而不是 APP_ROOT
|
||||
// 因为 APP_ROOT 被设置为 resources 的父目录,会导致路径错误
|
||||
const resourcesPath = process.resourcesPath || process.env.ELECTRON_RESOURCES_PATH;
|
||||
if (resourcesPath) {
|
||||
return path.join(resourcesPath, 'extra');
|
||||
}
|
||||
// 回退:使用 APP_ROOT + resources/extra(仅当 process.resourcesPath 不可用时)
|
||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||
return path.join(appRoot, 'resources', 'extra');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通用资源路径(common 目录下的资源)
|
||||
* @param subPath 子路径(相对于 extra/common)
|
||||
*/
|
||||
export function getCommonResourcePath(subPath: string): string {
|
||||
const resourceRoot = getResourceRoot();
|
||||
return path.join(resourceRoot, 'common', subPath);
|
||||
}
|
||||
|
||||
export function getRuntimeRoot(...segments: string[]): string {
|
||||
const baseRoot = AppEnv.installRoot || process.env.APP_ROOT || process.cwd();
|
||||
return path.join(baseRoot, ...segments);
|
||||
}
|
||||
|
||||
export function getRuntimeDataPath(...segments: string[]): string {
|
||||
const dataRoot = AppEnv.dataRoot || getRuntimeRoot('data');
|
||||
return path.join(dataRoot, ...segments);
|
||||
}
|
||||
|
||||
export function getRuntimeBundleRoot(): string {
|
||||
return AppEnv.resourceBundleRoot || getRuntimeRoot('resources-bundles');
|
||||
}
|
||||
|
||||
export function getRuntimeBundlePath(bundleName: string, ...segments: string[]): string {
|
||||
return path.join(getRuntimeBundleRoot(), bundleName, ...segments);
|
||||
}
|
||||
|
||||
export function getResourceStatePath(...segments: string[]): string {
|
||||
const root = AppEnv.resourceStateRoot || getRuntimeDataPath('resource-state');
|
||||
return path.join(root, ...segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台特定资源路径
|
||||
* @param subPath 子路径(相对于 extra/{platform})
|
||||
*/
|
||||
export function getPlatformResourcePath(subPath: string): string {
|
||||
const resourceRoot = getResourceRoot();
|
||||
const platform = getPlatformDir();
|
||||
return path.join(resourceRoot, platform, subPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台目录名
|
||||
*/
|
||||
export function getPlatformDir(): string {
|
||||
const isWin = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
if (isWin) return 'win-x86';
|
||||
if (isMac) return 'osx';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查资源文件是否存在
|
||||
* @param resourcePath 资源路径
|
||||
* @returns 文件是否存在
|
||||
*/
|
||||
export function resourceExists(resourcePath: string): boolean {
|
||||
return fs.existsSync(resourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并验证资源路径
|
||||
* @param resourcePath 资源路径
|
||||
* @param errorMessage 文件不存在时的错误消息
|
||||
* @returns 验证后的资源路径
|
||||
* @throws 如果资源不存在则抛出错误
|
||||
*/
|
||||
export function getAndVerifyResource(resourcePath: string, errorMessage?: string): string {
|
||||
if (!fs.existsSync(resourcePath)) {
|
||||
const error = errorMessage || `资源文件不存在: ${resourcePath}`;
|
||||
throw new Error(error);
|
||||
}
|
||||
return resourcePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取 Python 可执行文件路径
|
||||
*/
|
||||
export function getPythonExecutablePath(): string {
|
||||
const isWin = process.platform === 'win32';
|
||||
const pythonFileName = isWin ? 'python.exe' : 'python3';
|
||||
const runtimePath = getRuntimeBundlePath('python-runtime', pythonFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
return getCommonResourcePath(path.join('python', pythonFileName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取 FFmpeg 可执行文件路径
|
||||
*/
|
||||
export function getFFmpegExecutablePath(): string {
|
||||
const isWin = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
let ffmpegFileName = 'ffmpeg';
|
||||
if (isWin) ffmpegFileName = 'ffmpeg.exe';
|
||||
|
||||
const runtimePath = getRuntimeBundlePath('ffmpeg', ffmpegFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
|
||||
return getPlatformResourcePath(ffmpegFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取 FFprobe 可执行文件路径
|
||||
*/
|
||||
export function getFFprobeExecutablePath(): string {
|
||||
const isWin = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
let ffprobeFileName = 'ffprobe';
|
||||
if (isWin) ffprobeFileName = 'ffprobe.exe';
|
||||
|
||||
const runtimePath = getRuntimeBundlePath('ffmpeg', ffprobeFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
|
||||
return getPlatformResourcePath(ffprobeFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取模型文件路径
|
||||
* @param modelFileName 模型文件名(如 'u2net.onnx')
|
||||
*/
|
||||
export function getModelPath(modelFileName: string): string {
|
||||
const runtimePath = getRuntimeBundlePath('models', modelFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
return getCommonResourcePath(path.join('models', modelFileName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取 Python 脚本路径
|
||||
* @param scriptFileName Python 脚本文件名
|
||||
*/
|
||||
export function getPythonScriptPath(scriptFileName: string): string {
|
||||
const runtimePath = getRuntimeBundlePath('python-runtime', scriptFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
|
||||
const appRoot = process.env.APP_ROOT || process.env.ELECTRON_RESOURCES_PATH || process.cwd();
|
||||
const isDev = isDevelopment();
|
||||
|
||||
if (isDev) {
|
||||
return path.join(appRoot, 'python', scriptFileName);
|
||||
} else {
|
||||
const asarUnpackedPath = path.join(appRoot, 'app.asar.unpacked', 'python', scriptFileName);
|
||||
if (fs.existsSync(asarUnpackedPath)) {
|
||||
return asarUnpackedPath;
|
||||
}
|
||||
return path.join(appRoot, 'python', scriptFileName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断信息:打印所有资源路径
|
||||
*/
|
||||
export function printResourceDiagnostics(): void {
|
||||
const isDev = isDevelopment();
|
||||
const resourceRoot = getResourceRoot();
|
||||
|
||||
console.log('\n========== 资源路径诊断 ==========');
|
||||
console.log('环境模式:', isDev ? '开发环境' : '生产环境');
|
||||
console.log('App Root:', process.env.APP_ROOT || process.cwd());
|
||||
console.log('资源根目录:', resourceRoot);
|
||||
console.log('\n关键资源路径:');
|
||||
console.log(' Python:', getPythonExecutablePath(), '存在:', resourceExists(getPythonExecutablePath()));
|
||||
console.log(' FFmpeg:', getFFmpegExecutablePath(), '存在:', resourceExists(getFFmpegExecutablePath()));
|
||||
console.log(' FFprobe:', getFFprobeExecutablePath(), '存在:', resourceExists(getFFprobeExecutablePath()));
|
||||
console.log(' U2Net模型:', getModelPath('u2net.onnx'), '存在:', resourceExists(getModelPath('u2net.onnx')));
|
||||
console.log('=====================================\n');
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 🔐 Store Manager - 用于持久化存储激活信息和设备指纹
|
||||
* 使用 JSON 文件存储,位于用户数据目录
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import Log from '../mapi/log/main';
|
||||
|
||||
interface StoreData {
|
||||
activation?: any;
|
||||
deviceFingerprint?: string;
|
||||
}
|
||||
|
||||
export class Store {
|
||||
private static instance: Store | null = null;
|
||||
private storePath: string;
|
||||
private data: StoreData = {};
|
||||
|
||||
private constructor() {
|
||||
const userDataPath = app.getPath('userData');
|
||||
this.storePath = path.join(userDataPath, 'store.json');
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Store 单例实例(延迟初始化)
|
||||
*/
|
||||
static getInstance(): Store {
|
||||
if (!Store.instance) {
|
||||
Store.instance = new Store();
|
||||
}
|
||||
return Store.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件加载数据
|
||||
*/
|
||||
private load(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.storePath)) {
|
||||
const content = fs.readFileSync(this.storePath, 'utf-8');
|
||||
this.data = JSON.parse(content);
|
||||
Log.info('[Store] 已从文件加载持久化数据:', {
|
||||
hasActivation: !!this.data.activation,
|
||||
hasDeviceFingerprint: !!this.data.deviceFingerprint
|
||||
});
|
||||
} else {
|
||||
this.data = {};
|
||||
Log.info('[Store] Store 文件不存在,使用空数据');
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error('[Store] 加载文件失败:', error);
|
||||
this.data = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存数据到文件
|
||||
*/
|
||||
private save(): void {
|
||||
try {
|
||||
const dir = path.dirname(this.storePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.storePath, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||
Log.info('[Store] 数据已保存到文件');
|
||||
} catch (error) {
|
||||
Log.error('[Store] 保存文件失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取激活信息
|
||||
*/
|
||||
getActivation(): any {
|
||||
Log.info('[Store] 获取激活信息:', !!this.data.activation);
|
||||
return this.data.activation || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存激活信息
|
||||
*/
|
||||
setActivation(data: any): void {
|
||||
Log.info('[Store] 保存激活信息');
|
||||
this.data.activation = data;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除激活信息
|
||||
*/
|
||||
clearActivation(): void {
|
||||
Log.info('[Store] 清除激活信息');
|
||||
delete this.data.activation;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备指纹
|
||||
*/
|
||||
getDeviceFingerprint(): string | null {
|
||||
Log.info('[Store] 获取设备指纹:', !!this.data.deviceFingerprint);
|
||||
return this.data.deviceFingerprint || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存设备指纹
|
||||
*/
|
||||
setDeviceFingerprint(fingerprint: string): void {
|
||||
Log.info('[Store] 保存设备指纹');
|
||||
this.data.deviceFingerprint = fingerprint;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除设备指纹
|
||||
*/
|
||||
clearDeviceFingerprint(): void {
|
||||
Log.info('[Store] 清除设备指纹');
|
||||
delete this.data.deviceFingerprint;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟初始化:只有在第一次调用时才创建 Store 实例
|
||||
// 这样确保 AppEnv 已经初始化完成
|
||||
export default {
|
||||
getActivation: () => Store.getInstance().getActivation(),
|
||||
setActivation: (data: any) => Store.getInstance().setActivation(data),
|
||||
clearActivation: () => Store.getInstance().clearActivation(),
|
||||
getDeviceFingerprint: () => Store.getInstance().getDeviceFingerprint(),
|
||||
setDeviceFingerprint: (fingerprint: string) => Store.getInstance().setDeviceFingerprint(fingerprint),
|
||||
clearDeviceFingerprint: () => Store.getInstance().clearDeviceFingerprint(),
|
||||
};
|
||||
@@ -0,0 +1,865 @@
|
||||
import chardet from "chardet";
|
||||
import dayjs from "dayjs";
|
||||
import iconvLite from "iconv-lite";
|
||||
import {Base64} from "js-base64";
|
||||
import * as crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Showdown from "showdown";
|
||||
// import {Iconv} from "iconv"
|
||||
import {isMac, isWin} from "./env";
|
||||
import FileIndex from "../mapi/file";
|
||||
|
||||
export const EncodeUtil = {
|
||||
base32Alphabet: "abcdefghijklmnopqrstuvwxyz234567",
|
||||
base32Encode(str: string) {
|
||||
const buffer = Buffer.from(str, "utf8");
|
||||
let bits = "";
|
||||
let output = "";
|
||||
// 将每个字节转为8位二进制
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const byte = buffer[i];
|
||||
bits += byte.toString(2).padStart(8, "0");
|
||||
}
|
||||
// 每5位一组,转为 Base32 字符
|
||||
for (let i = 0; i < bits.length; i += 5) {
|
||||
const chunk = bits.slice(i, i + 5);
|
||||
const paddedChunk = chunk.padEnd(5, "0"); // 不足5位补0
|
||||
const index = parseInt(paddedChunk, 2);
|
||||
output += EncodeUtil.base32Alphabet[index];
|
||||
}
|
||||
return output;
|
||||
},
|
||||
base32Decode(str: string) {
|
||||
const base32Alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
let bits = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str[i];
|
||||
const index = base32Alphabet.indexOf(char);
|
||||
if (index === -1) {
|
||||
throw new Error("Invalid Base32 character: " + char);
|
||||
}
|
||||
bits += index.toString(2).padStart(5, "0");
|
||||
}
|
||||
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||||
const byte = bits.slice(i, i + 8);
|
||||
bytes.push(parseInt(byte, 2));
|
||||
}
|
||||
|
||||
return Buffer.from(bytes).toString("utf8");
|
||||
},
|
||||
base64Encode(str: string) {
|
||||
return Base64.encode(str);
|
||||
},
|
||||
base64Decode(str: string) {
|
||||
return Base64.decode(str);
|
||||
},
|
||||
md5(str: string) {
|
||||
return crypto.createHash("md5").update(str).digest("hex");
|
||||
},
|
||||
aesEncode(str: string, key: string) {
|
||||
const cipher = crypto.createCipheriv("aes-128-ecb", key, "");
|
||||
let crypted = cipher.update(str, "utf8", "base64");
|
||||
crypted += cipher.final("base64");
|
||||
return crypted;
|
||||
},
|
||||
aesDecode(str: string, key: string) {
|
||||
const decipher = crypto.createDecipheriv("aes-128-ecb", key, "");
|
||||
let dec = decipher.update(str, "base64", "utf8");
|
||||
dec += decipher.final("utf8");
|
||||
return dec;
|
||||
},
|
||||
async fileXzipEncode(pathname: string): Promise<string> {
|
||||
if (!fs.existsSync(pathname)) {
|
||||
throw new Error(`Input file not found: ${pathname}`);
|
||||
}
|
||||
|
||||
// Generate new filepath with .xzip extension
|
||||
const basePath = pathname.substring(0, pathname.lastIndexOf("."));
|
||||
const outputPath = basePath + ".xzip";
|
||||
|
||||
// Get file info
|
||||
const fileStats = fs.statSync(pathname);
|
||||
const fileSize = fileStats.size;
|
||||
const fileExt = pathname.split(".").pop() || "";
|
||||
|
||||
// Generate random 16-character key
|
||||
const encryptionKey = StrUtil.randomString(16);
|
||||
|
||||
// Create metadata
|
||||
const filemeta = {
|
||||
version: 1,
|
||||
format: fileExt,
|
||||
size: fileSize,
|
||||
key: encryptionKey,
|
||||
};
|
||||
|
||||
// Convert metadata to JSON and then base64 encode
|
||||
const metaJson = JSON.stringify(filemeta);
|
||||
const metaB64 = Buffer.from(metaJson, "utf-8").toString("base64");
|
||||
const metaLength = metaB64.length;
|
||||
|
||||
// Prepare encryption key
|
||||
const keyBytes = Buffer.from(encryptionKey, "utf-8");
|
||||
const keyLength = keyBytes.length;
|
||||
|
||||
// Stream processing: read, encrypt and write in chunks
|
||||
const inputStream = fs.createReadStream(pathname);
|
||||
const outputStream = fs.createWriteStream(outputPath);
|
||||
|
||||
// Write metadata length (4 bytes, little-endian)
|
||||
const metaLengthBuffer = Buffer.allocUnsafe(4);
|
||||
metaLengthBuffer.writeUInt32LE(metaLength, 0);
|
||||
outputStream.write(metaLengthBuffer);
|
||||
|
||||
// Write base64 encoded metadata
|
||||
outputStream.write(Buffer.from(metaB64, "utf-8"));
|
||||
|
||||
// Stream encrypt the file content
|
||||
let bytesProcessed = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
inputStream.on("data", (chunk: Buffer) => {
|
||||
// XOR encrypt the chunk
|
||||
const encryptedChunk = Buffer.alloc(chunk.length);
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
encryptedChunk[i] = chunk[i] ^ keyBytes[bytesProcessed % keyLength];
|
||||
bytesProcessed++;
|
||||
}
|
||||
|
||||
// Write encrypted chunk
|
||||
outputStream.write(encryptedChunk);
|
||||
});
|
||||
|
||||
inputStream.on("end", () => {
|
||||
outputStream.end();
|
||||
resolve(outputPath);
|
||||
});
|
||||
|
||||
inputStream.on("error", error => {
|
||||
outputStream.destroy();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
outputStream.on("error", error => {
|
||||
inputStream.destroy();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
async fileXzipDecode(pathname: string): Promise<string> {
|
||||
if (!fs.existsSync(pathname)) {
|
||||
throw new Error(`Input file not found: ${pathname}`);
|
||||
}
|
||||
|
||||
if (!pathname.endsWith(".xzip")) {
|
||||
return pathname; // Not an xzip file, return as is
|
||||
}
|
||||
|
||||
let outputPath = pathname.replace(/\.xzip$/, "");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const inputStream = fs.createReadStream(pathname);
|
||||
let metadataRead = false;
|
||||
let filemeta: any = null;
|
||||
let keyBytes: Buffer;
|
||||
let bytesProcessed = 0;
|
||||
let outputStream: fs.WriteStream;
|
||||
let remainingMetaBytes = 0;
|
||||
let metaBuffer = Buffer.alloc(0);
|
||||
|
||||
inputStream.on("data", (chunk: Buffer) => {
|
||||
let chunkOffset = 0;
|
||||
|
||||
if (!metadataRead) {
|
||||
if (remainingMetaBytes === 0) {
|
||||
// Read metadata length (first 4 bytes)
|
||||
if (chunk.length < 4) {
|
||||
reject(new Error("Invalid xzip file: insufficient data for metadata length"));
|
||||
return;
|
||||
}
|
||||
const metaLength = chunk.readUInt32LE(0);
|
||||
remainingMetaBytes = metaLength;
|
||||
chunkOffset = 4;
|
||||
}
|
||||
|
||||
// Read metadata
|
||||
const availableMetaBytes = Math.min(remainingMetaBytes, chunk.length - chunkOffset);
|
||||
const metaChunk = chunk.subarray(chunkOffset, chunkOffset + availableMetaBytes);
|
||||
metaBuffer = Buffer.concat([metaBuffer, metaChunk] as readonly Uint8Array[]);
|
||||
remainingMetaBytes -= availableMetaBytes;
|
||||
chunkOffset += availableMetaBytes;
|
||||
|
||||
if (remainingMetaBytes === 0) {
|
||||
// Parse metadata
|
||||
try {
|
||||
const metaB64 = metaBuffer.toString("utf-8");
|
||||
const metaJson = Buffer.from(metaB64, "base64").toString("utf-8");
|
||||
filemeta = JSON.parse(metaJson);
|
||||
keyBytes = Buffer.from(filemeta.key, "utf-8");
|
||||
|
||||
// Create output file with correct extension
|
||||
const finalOutputPath = outputPath + (filemeta.format ? "." + filemeta.format : "");
|
||||
outputStream = fs.createWriteStream(finalOutputPath);
|
||||
|
||||
metadataRead = true;
|
||||
|
||||
// Set the final output path for resolution
|
||||
outputPath = finalOutputPath;
|
||||
} catch (error) {
|
||||
reject(new Error("Invalid xzip file: corrupted metadata"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (metadataRead && chunkOffset < chunk.length) {
|
||||
// Decrypt remaining chunk data
|
||||
const encryptedChunk = chunk.subarray(chunkOffset);
|
||||
const decryptedChunk = Buffer.alloc(encryptedChunk.length);
|
||||
const keyLength = keyBytes.length;
|
||||
|
||||
for (let i = 0; i < encryptedChunk.length; i++) {
|
||||
decryptedChunk[i] = encryptedChunk[i] ^ keyBytes[bytesProcessed % keyLength];
|
||||
bytesProcessed++;
|
||||
}
|
||||
|
||||
outputStream.write(decryptedChunk);
|
||||
}
|
||||
});
|
||||
|
||||
inputStream.on("end", () => {
|
||||
if (outputStream) {
|
||||
outputStream.end();
|
||||
resolve(outputPath);
|
||||
} else {
|
||||
reject(new Error("Invalid xzip file: incomplete metadata"));
|
||||
}
|
||||
});
|
||||
|
||||
inputStream.on("error", error => {
|
||||
if (outputStream) {
|
||||
outputStream.destroy();
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (outputStream) {
|
||||
outputStream.on("error", error => {
|
||||
inputStream.destroy();
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const IconvUtil = {
|
||||
convert(str: string, to?: string, from?: string) {
|
||||
if (!from) {
|
||||
from = chardet.detect(Buffer.from(str));
|
||||
}
|
||||
to = to || "utf8";
|
||||
const buffer = iconvLite.encode(str, from);
|
||||
return iconvLite.decode(buffer, to);
|
||||
},
|
||||
bufferToUtf8(buffer: Buffer) {
|
||||
const encoding = chardet.detect(buffer);
|
||||
// if ('ISO-2022-CN' === encoding) {
|
||||
// const iconvInstance = new Iconv('ISO-2022-CN', 'UTF-8//TRANSLIT//IGNORE');
|
||||
// return iconvInstance.convert(buffer).toString()
|
||||
// }
|
||||
return iconvLite.decode(buffer, encoding).toString();
|
||||
},
|
||||
detect(buffer: Uint8Array) {
|
||||
// detect str encoding
|
||||
return chardet.detect(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export const StrUtil = {
|
||||
randomString(len: number = 32) {
|
||||
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let result = "";
|
||||
for (let i = len; i > 0; --i) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
},
|
||||
hashCode(str: string, length: number = 8) {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash + "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
let result = Math.abs(hash).toString(16);
|
||||
if (result.length < length) {
|
||||
result = "0".repeat(length - result.length) + result;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
hashCodeWithDuplicateCheck(str: string, check: string[], length: number = 8) {
|
||||
let code = this.hashCode(str, length);
|
||||
while (check.includes(code)) {
|
||||
code = this.uuid().substring(0, length);
|
||||
}
|
||||
return code;
|
||||
},
|
||||
bigIntegerId() {
|
||||
return [Date.now(), (Math.floor(Math.random() * 1000000) + "").padStart(6, "0")].join("");
|
||||
},
|
||||
ucFirst(str: string) {
|
||||
if (!str) return "";
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const TimeUtil = {
|
||||
timestampInMs() {
|
||||
return Date.now();
|
||||
},
|
||||
timestamp() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
},
|
||||
format(time: number, format: string = "YYYY-MM-DD HH:mm:ss") {
|
||||
return dayjs(time).format(format);
|
||||
},
|
||||
formatDate(time: number) {
|
||||
return dayjs(time).format("YYYY-MM-DD");
|
||||
},
|
||||
dateString() {
|
||||
return dayjs().format("YYYYMMDD");
|
||||
},
|
||||
datetimeString() {
|
||||
return dayjs().format("YYYYMMDD_HHmmss_SSS");
|
||||
},
|
||||
timestampDayStart(msTimestamp?: number) {
|
||||
let date = msTimestamp ? new Date(msTimestamp) : new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
},
|
||||
replacePattern(text: string) {
|
||||
// @ts-ignore
|
||||
return text.replaceAll("{year}", dayjs().format("YYYY"))
|
||||
.replaceAll("{month}", dayjs().format("MM"))
|
||||
.replaceAll("{day}", dayjs().format("DD"))
|
||||
.replaceAll("{hour}", dayjs().format("HH"))
|
||||
.replaceAll("{minute}", dayjs().format("mm"))
|
||||
.replaceAll("{second}", dayjs().format("ss"));
|
||||
},
|
||||
};
|
||||
|
||||
export const FileUtil = {
|
||||
MIME_TYPES: {
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
js: "application/javascript",
|
||||
css: "text/css",
|
||||
json: "application/json",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
ttf: "font/ttf",
|
||||
otf: "font/otf",
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "video/mp4",
|
||||
wav: "audio/wav",
|
||||
wasm: "application/wasm",
|
||||
eot: "application/vnd.ms-fontobject",
|
||||
},
|
||||
getMimeByExt(ext: string, defaultMime: string = ""): string {
|
||||
ext = ext.toLowerCase();
|
||||
if (ext.startsWith(".")) {
|
||||
ext = ext.substring(1);
|
||||
}
|
||||
return FileUtil.MIME_TYPES[ext] || defaultMime;
|
||||
},
|
||||
getMimeByPath(p: string, defaultMime: string = ""): string {
|
||||
const extension = p.split(".").pop().toLowerCase();
|
||||
return FileUtil.getMimeByExt(extension, defaultMime);
|
||||
},
|
||||
streamToBase64(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
stream.on("data", chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
resolve(buffer.toString("base64"));
|
||||
});
|
||||
stream.on("error", error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
bufferToBase64(buffer: Buffer) {
|
||||
let binary = "";
|
||||
let bytes = new Uint8Array(buffer);
|
||||
let len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return EncodeUtil.base64Encode(binary);
|
||||
},
|
||||
base64ToBuffer(base64: string): Buffer {
|
||||
if (base64.startsWith("data:")) {
|
||||
base64 = base64.split("base64,")[1];
|
||||
}
|
||||
return Buffer.from(base64, "base64");
|
||||
},
|
||||
formatSize(size: number) {
|
||||
if (size < 1024) {
|
||||
return size + "B";
|
||||
} else if (size < 1024 * 1024) {
|
||||
return (size / 1024).toFixed(2) + "KB";
|
||||
} else if (size < 1024 * 1024 * 1024) {
|
||||
return (size / 1024 / 1024).toFixed(2) + "MB";
|
||||
} else {
|
||||
return (size / 1024 / 1024 / 1024).toFixed(2) + "GB";
|
||||
}
|
||||
},
|
||||
async md5(filePath: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash("md5");
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on("data", data => {
|
||||
hash.update(data);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
resolve(hash.digest("hex"));
|
||||
});
|
||||
stream.on("error", error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const JsonUtil = {
|
||||
stringifyOrdered(obj: any) {
|
||||
return JSON.stringify(obj, Object.keys(obj).sort(), 4);
|
||||
},
|
||||
stringifyValueOrdered(obj: any) {
|
||||
const sortedData = Object.fromEntries(
|
||||
Object.entries(obj).sort(([, a], [, b]) => {
|
||||
// @ts-ignore
|
||||
return ((a as any) - b) as any;
|
||||
})
|
||||
);
|
||||
return JSON.stringify(sortedData, null, 4);
|
||||
},
|
||||
};
|
||||
|
||||
export const ImportUtil = {
|
||||
async loadCommonJs(cjsPath: string, forceReload: boolean = true) {
|
||||
let tempPath = cjsPath;
|
||||
if (forceReload) {
|
||||
const md5 = await FileUtil.md5(cjsPath);
|
||||
tempPath = path.join(
|
||||
await FileIndex.tempDir('commonJs'),
|
||||
`${md5}.cjs`,
|
||||
)
|
||||
if (!fs.existsSync(tempPath)) {
|
||||
fs.copyFileSync(cjsPath, tempPath);
|
||||
}
|
||||
}
|
||||
const backend = await import(/* @vite-ignore */ `file://${tempPath}`);
|
||||
// console.log('loadCommonJs', `${cjsPath}?t=${md5}`)
|
||||
return backend.default;
|
||||
},
|
||||
};
|
||||
|
||||
export const MemoryCacheUtil = {
|
||||
pool: {} as {
|
||||
[key: string]: {
|
||||
value: any;
|
||||
expire: number;
|
||||
};
|
||||
},
|
||||
_gc() {
|
||||
const now = TimeUtil.timestamp();
|
||||
for (const key in this.pool) {
|
||||
if (this.pool[key].expire < now) {
|
||||
delete this.pool[key];
|
||||
}
|
||||
}
|
||||
},
|
||||
async remember(key: string, callback: () => Promise<any>, ttl: number = 60) {
|
||||
if (this.pool[key] && this.pool[key].expire > TimeUtil.timestamp()) {
|
||||
return this.pool[key].value;
|
||||
}
|
||||
const value = await callback();
|
||||
this.pool[key] = {
|
||||
value,
|
||||
expire: TimeUtil.timestamp() + ttl,
|
||||
};
|
||||
this._gc();
|
||||
return value;
|
||||
},
|
||||
get(key: string) {
|
||||
if (this.pool[key] && this.pool[key].expire > TimeUtil.timestamp()) {
|
||||
return this.pool[key].value;
|
||||
}
|
||||
this._gc();
|
||||
return null;
|
||||
},
|
||||
set(key: string, value: any, ttl: number = 86400) {
|
||||
this.pool[key] = {
|
||||
value,
|
||||
expire: TimeUtil.timestamp() + ttl,
|
||||
};
|
||||
this._gc();
|
||||
},
|
||||
forget(key: string) {
|
||||
delete this.pool[key];
|
||||
},
|
||||
};
|
||||
|
||||
export const MemoryMapCacheUtil = {
|
||||
pool: {} as {
|
||||
[group: string]: {
|
||||
[key: string]: {
|
||||
value: any;
|
||||
expire: number;
|
||||
};
|
||||
};
|
||||
},
|
||||
_gc() {
|
||||
const now = TimeUtil.timestamp();
|
||||
for (const group in this.pool) {
|
||||
for (const key in this.pool[group]) {
|
||||
if (this.pool[group][key].expire < now) {
|
||||
delete this.pool[group][key];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
get(group: string, key: string) {
|
||||
if (this.pool[group] && this.pool[group][key] && this.pool[group][key].expire > TimeUtil.timestamp()) {
|
||||
return this.pool[group][key].value;
|
||||
}
|
||||
this._gc();
|
||||
return null;
|
||||
},
|
||||
set(group: string, key: string, value: any, ttl: number = 86400) {
|
||||
if (!this.pool[group]) {
|
||||
this.pool[group] = {};
|
||||
}
|
||||
this.pool[group][key] = {
|
||||
value,
|
||||
expire: TimeUtil.timestamp() + ttl,
|
||||
};
|
||||
this._gc();
|
||||
},
|
||||
forget(group: string, key: string) {
|
||||
if (this.pool[group]) {
|
||||
delete this.pool[group][key];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const ShellUtil = {
|
||||
quotaPath(p: string) {
|
||||
return `"${p}"`;
|
||||
},
|
||||
parseCommandArgs(command: string) {
|
||||
let args = [];
|
||||
let arg = "";
|
||||
let quote = "";
|
||||
let escape = false;
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const c = command[i];
|
||||
if (escape) {
|
||||
arg += c;
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if ("\\" === c) {
|
||||
escape = true;
|
||||
arg += c;
|
||||
continue;
|
||||
}
|
||||
if ("" === quote && (" " === c || "\t" === c)) {
|
||||
if (arg) {
|
||||
args.push(arg);
|
||||
arg = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ("" === quote && ('"' === c || "'" === c)) {
|
||||
quote = c;
|
||||
arg += c;
|
||||
continue;
|
||||
}
|
||||
if ('"' === quote && '"' === c) {
|
||||
quote = "";
|
||||
arg += c;
|
||||
continue;
|
||||
}
|
||||
if ("'" === quote && "'" === c) {
|
||||
quote = "";
|
||||
arg += c;
|
||||
continue;
|
||||
}
|
||||
arg += c;
|
||||
}
|
||||
if (arg) {
|
||||
args.push(arg);
|
||||
}
|
||||
return args;
|
||||
},
|
||||
};
|
||||
|
||||
export const VersionUtil = {
|
||||
/**
|
||||
* 检测版本是否匹配
|
||||
* @param v string
|
||||
* @param match string 如 * 或 >=1.0.0 或 >1.0.0 或 <1.0.0 或 <=1.0.0 或 1.0.0
|
||||
*/
|
||||
match(v: string, match: string) {
|
||||
if (match === "*") {
|
||||
return true;
|
||||
}
|
||||
if (match.startsWith(">=")) {
|
||||
if (this.ge(v, match.substring(2))) {
|
||||
return true;
|
||||
}
|
||||
} else if (match.startsWith("<=")) {
|
||||
if (this.le(v, match.substring(2))) {
|
||||
return true;
|
||||
}
|
||||
} else if (match.startsWith(">")) {
|
||||
if (this.gt(v, match.substring(1))) {
|
||||
return true;
|
||||
}
|
||||
} else if (match.startsWith("<")) {
|
||||
if (this.lt(v, match.substring(1))) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return this.eq(v, match);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
compare(v1: string, v2: string) {
|
||||
const v1Arr = v1.split(".");
|
||||
const v2Arr = v2.split(".");
|
||||
for (let i = 0; i < v1Arr.length; i++) {
|
||||
const v1Num = parseInt(v1Arr[i]);
|
||||
const v2Num = parseInt(v2Arr[i]);
|
||||
if (v1Num > v2Num) {
|
||||
return 1;
|
||||
} else if (v1Num < v2Num) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
gt(v1: string, v2: string) {
|
||||
return VersionUtil.compare(v1, v2) > 0;
|
||||
},
|
||||
ge(v1: string, v2: string) {
|
||||
return VersionUtil.compare(v1, v2) >= 0;
|
||||
},
|
||||
lt(v1: string, v2: string) {
|
||||
return VersionUtil.compare(v1, v2) < 0;
|
||||
},
|
||||
le: (v1: string, v2: string) => {
|
||||
return VersionUtil.compare(v1, v2) <= 0;
|
||||
},
|
||||
eq: (v1: string, v2: string) => {
|
||||
return VersionUtil.compare(v1, v2) === 0;
|
||||
},
|
||||
};
|
||||
|
||||
export const UIUtil = {
|
||||
sizeToPx(size: string, sizeFull: number) {
|
||||
if (/^\d+$/.test(size)) {
|
||||
// 纯数字
|
||||
return parseInt(size);
|
||||
} else if (size.endsWith("%")) {
|
||||
// 百分比
|
||||
let result = Math.floor((sizeFull * parseInt(size)) / 100);
|
||||
result = Math.min(result, sizeFull);
|
||||
return result;
|
||||
} else {
|
||||
throw "UnsupportSizeString";
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const ReUtil = {
|
||||
match(regex: string, text: string) {
|
||||
if ("" === regex || null === regex) {
|
||||
return false;
|
||||
}
|
||||
if (regex.startsWith("/")) {
|
||||
const index = regex.lastIndexOf("/");
|
||||
const source = regex.slice(1, index);
|
||||
const flags = regex.slice(index + 1);
|
||||
return new RegExp(source, flags).test(text);
|
||||
}
|
||||
return new RegExp(regex).test(text);
|
||||
},
|
||||
};
|
||||
|
||||
const converter = new Showdown.Converter({
|
||||
tables: true,
|
||||
});
|
||||
export const MarkdownUtil = {
|
||||
toHtml(markdown: string): string {
|
||||
return converter.makeHtml(markdown);
|
||||
},
|
||||
};
|
||||
|
||||
type HotkeyModifierType = "Control" | "Option" | "Command" | "Ctrl" | "Alt" | "Win" | "Meta" | "Shift";
|
||||
type HotkeyType = { key: string; modifiers: HotkeyModifierType[] };
|
||||
|
||||
export const HotKeyUtil = {
|
||||
orderModifiers(modifiers: HotkeyModifierType[]) {
|
||||
const order = ["Control", "Ctrl", "Command", "Meta", "Win", "Option", "Alt", "Shift"];
|
||||
return modifiers.sort((a, b) => {
|
||||
return order.indexOf(a) - order.indexOf(b);
|
||||
});
|
||||
},
|
||||
unifyObject(hotkey: HotkeyType) {
|
||||
return {
|
||||
key: hotkey.key.toUpperCase(),
|
||||
modifiers: this.orderModifiers(hotkey.modifiers.map(modifier => StrUtil.ucFirst(modifier))),
|
||||
};
|
||||
},
|
||||
unifyString(hotkey: string): HotkeyType {
|
||||
const parts = hotkey.split("+");
|
||||
const key = parts.pop() || "";
|
||||
const modifiers: any[] = [];
|
||||
parts.forEach(part => {
|
||||
modifiers.push(StrUtil.ucFirst(part.trim()));
|
||||
});
|
||||
return this.unifyObject({key, modifiers});
|
||||
},
|
||||
unify(hotkeys: string | string[] | HotkeyType | HotkeyType[]): HotkeyType[] {
|
||||
if (typeof hotkeys === "string") {
|
||||
return [this.unifyString(hotkeys)];
|
||||
} else if (Array.isArray(hotkeys)) {
|
||||
return hotkeys.map(hotkey => {
|
||||
if (typeof hotkey === "string") {
|
||||
return this.unifyString(hotkey);
|
||||
} else {
|
||||
return this.unifyObject(hotkey);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return [this.unifyObject(hotkeys)];
|
||||
}
|
||||
},
|
||||
getFromEvent(event: any): HotkeyType | null {
|
||||
const valid = [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"Space",
|
||||
];
|
||||
const key = (event.key || "").toUpperCase();
|
||||
if (!event || !event.key || !valid.includes(key)) {
|
||||
return null;
|
||||
}
|
||||
const modifiers: HotkeyModifierType[] = [];
|
||||
if (isWin) {
|
||||
if (event.ctrlKey || event.control) {
|
||||
modifiers.push("Ctrl");
|
||||
}
|
||||
if (event.altKey || event.alt) {
|
||||
modifiers.push("Alt");
|
||||
}
|
||||
if (event.metaKey || event.meta) {
|
||||
modifiers.push("Win");
|
||||
}
|
||||
} else if (isMac) {
|
||||
if (event.ctrlKey || event.control) {
|
||||
modifiers.push("Control");
|
||||
}
|
||||
if (event.altKey || event.alt) {
|
||||
modifiers.push("Option");
|
||||
}
|
||||
if (event.metaKey || event.meta) {
|
||||
modifiers.push("Command");
|
||||
}
|
||||
} else {
|
||||
if (event.ctrlKey || event.control) {
|
||||
modifiers.push("Ctrl");
|
||||
}
|
||||
if (event.altKey || event.alt) {
|
||||
modifiers.push("Alt");
|
||||
}
|
||||
if (event.metaKey || event.meta) {
|
||||
modifiers.push("Meta");
|
||||
}
|
||||
}
|
||||
if (event.shiftKey || event.shift) {
|
||||
modifiers.push("Shift");
|
||||
}
|
||||
return this.unifyObject({key, modifiers});
|
||||
},
|
||||
match(hotkeysForMatch: HotkeyType[], hotkey: HotkeyType): boolean {
|
||||
if (!hotkeysForMatch || !hotkey) {
|
||||
return false;
|
||||
}
|
||||
const hotKeyStr = hotkey.modifiers.join("+") + "+" + hotkey.key;
|
||||
for (const key of hotkeysForMatch) {
|
||||
const keyStr = key.modifiers.join("+") + "+" + key.key;
|
||||
if (keyStr === hotKeyStr) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import logger from '../log/main';
|
||||
import { Apps } from '../app/index';
|
||||
import { withoutUnsupportedProxy } from '../httpClient';
|
||||
|
||||
/**
|
||||
* 在线 TTS(千问 Qwen TTS)客户端
|
||||
*/
|
||||
|
||||
export interface CosyVoiceConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
format?: string;
|
||||
sampleRate?: number;
|
||||
rate?: number;
|
||||
volume?: number;
|
||||
pitch?: number;
|
||||
instruction?: string;
|
||||
language_hints?: string;
|
||||
language?: string;
|
||||
emotion?: string;
|
||||
outputPath?: string;
|
||||
}
|
||||
|
||||
export interface CosyVoiceResult {
|
||||
success: boolean;
|
||||
audioPath?: string;
|
||||
error?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
const QWEN_TTS_URL = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
|
||||
|
||||
function getOutputFormat(outputPath: string, configFormat?: string): 'wav' | 'mp3' {
|
||||
const ext = path.extname(outputPath).toLowerCase();
|
||||
if (ext === '.wav') return 'wav';
|
||||
if (ext === '.mp3') return 'mp3';
|
||||
if ((configFormat || '').toLowerCase() === 'wav') return 'wav';
|
||||
return 'mp3';
|
||||
}
|
||||
|
||||
function detectAudioFormat(
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
fallback: 'wav' | 'mp3' = 'mp3'
|
||||
): 'wav' | 'mp3' {
|
||||
const normalizedType = (contentType || '').toLowerCase();
|
||||
if (normalizedType.includes('wav') || normalizedType.includes('wave')) return 'wav';
|
||||
if (normalizedType.includes('mpeg') || normalizedType.includes('mp3')) return 'mp3';
|
||||
|
||||
if (buffer.length >= 12) {
|
||||
const riff = buffer.toString('ascii', 0, 4);
|
||||
const wave = buffer.toString('ascii', 8, 12);
|
||||
if (riff === 'RIFF' && wave === 'WAVE') return 'wav';
|
||||
}
|
||||
|
||||
if (buffer.length >= 3 && buffer.toString('ascii', 0, 3) === 'ID3') return 'mp3';
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return 'mp3';
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function transcodeAudioFile(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
targetFormat: 'wav' | 'mp3'
|
||||
): Promise<void> {
|
||||
const args = targetFormat === 'wav'
|
||||
? ['-y', '-i', inputPath, '-c:a', 'pcm_s16le', outputPath]
|
||||
: ['-y', '-i', inputPath, '-c:a', 'libmp3lame', '-b:a', '128k', outputPath];
|
||||
|
||||
await Apps.spawnBinary('ffmpeg', args, {
|
||||
shell: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeAudioBufferAsTargetFormat(
|
||||
audioBuffer: Buffer,
|
||||
outputPath: string,
|
||||
targetFormat: 'wav' | 'mp3',
|
||||
contentType?: string
|
||||
): Promise<void> {
|
||||
const sourceFormat = detectAudioFormat(audioBuffer, contentType, targetFormat);
|
||||
if (sourceFormat === targetFormat) {
|
||||
fs.writeFileSync(outputPath, audioBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedOutput = path.parse(outputPath);
|
||||
const tempInputPath = path.join(
|
||||
parsedOutput.dir,
|
||||
`${parsedOutput.name}_source_${Date.now()}${Math.random().toString(36).slice(2, 8)}.${sourceFormat}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(tempInputPath, audioBuffer);
|
||||
try {
|
||||
await transcodeAudioFile(tempInputPath, outputPath, targetFormat);
|
||||
} finally {
|
||||
try { fs.unlinkSync(tempInputPath); } catch (_error) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将长文本按自然断句分割为不超过 maxLen 的段落
|
||||
* qwen3-tts-vc 限制每次最多600(含 instructions),保守设为300
|
||||
* 普通 qwen3-tts 模型可适当放宽
|
||||
*/
|
||||
function splitTextForTTS(text: string, maxLen: number = 300): string[] {
|
||||
if (text.length <= maxLen) return [text];
|
||||
|
||||
const segments: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLen) {
|
||||
segments.push(remaining);
|
||||
break;
|
||||
}
|
||||
// 在 maxLen 范围内找最后一个自然断句点
|
||||
const chunk = remaining.slice(0, maxLen);
|
||||
let splitIdx = -1;
|
||||
// 优先按句号/问号/感叹号分割
|
||||
for (const sep of ['。', '!', '?', '!', '?', ';', ';', '\n']) {
|
||||
const idx = chunk.lastIndexOf(sep);
|
||||
if (idx > maxLen * 0.3) { splitIdx = idx + 1; break; }
|
||||
}
|
||||
// 其次按逗号/顿号分割
|
||||
if (splitIdx === -1) {
|
||||
for (const sep of [',', ',', '、', ':', ':']) {
|
||||
const idx = chunk.lastIndexOf(sep);
|
||||
if (idx > maxLen * 0.3) { splitIdx = idx + 1; break; }
|
||||
}
|
||||
}
|
||||
// 实在找不到就硬切
|
||||
if (splitIdx === -1) splitIdx = maxLen;
|
||||
|
||||
segments.push(remaining.slice(0, splitIdx).trim());
|
||||
remaining = remaining.slice(splitIdx).trim();
|
||||
}
|
||||
|
||||
return segments.filter(s => s.length > 0);
|
||||
}
|
||||
|
||||
export async function synthesizeSpeech(
|
||||
text: string,
|
||||
config: CosyVoiceConfig
|
||||
): Promise<CosyVoiceResult> {
|
||||
logger.info('[QwenTTS] 开始语音合成', {
|
||||
model: config.model,
|
||||
voice: config.voice,
|
||||
instruction: config.instruction,
|
||||
language: config.language,
|
||||
language_hints: config.language_hints,
|
||||
textLength: text.length,
|
||||
});
|
||||
|
||||
try {
|
||||
// ✅ 关键修复:长文本自动分段(避免 "Range of input length should be [0, 600]" 错误)
|
||||
// VC 模型(声音克隆)的限制更严格,API 可能将 text + instructions 合并计算
|
||||
const instructionLen = config.instruction ? config.instruction.length : 0;
|
||||
const isVcModel = config.model?.includes('-vc') || config.model?.includes('_vc');
|
||||
// VC 模型更保守(200字),普通模型 300 字,减去 instruction 长度
|
||||
const baseMaxLen = isVcModel ? 200 : 300;
|
||||
const effectiveMaxLen = Math.max(100, baseMaxLen - instructionLen);
|
||||
const segments = splitTextForTTS(text, effectiveMaxLen);
|
||||
logger.info(`[QwenTTS] 文本分段: ${segments.length}段, 各段长度: [${segments.map(s => s.length).join(', ')}]`, {
|
||||
isVcModel, instructionLen, effectiveMaxLen
|
||||
});
|
||||
|
||||
// 准备输出路径
|
||||
let outputPath: string;
|
||||
if (config.outputPath) {
|
||||
outputPath = config.outputPath;
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
} else {
|
||||
const outputDir = path.join(app.getPath('userData'), 'temp', 'tts');
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
const defaultExt = (config.format || '').toLowerCase() === 'wav' ? 'wav' : 'mp3';
|
||||
outputPath = path.join(outputDir, `qwen_tts_${Date.now()}.${defaultExt}`);
|
||||
}
|
||||
|
||||
const outputFormat = getOutputFormat(outputPath, config.format);
|
||||
|
||||
// 如果只有一段,直接合成
|
||||
if (segments.length === 1) {
|
||||
return await synthesizeOneSegment(segments[0], config, outputPath);
|
||||
}
|
||||
|
||||
// 多段:逐段合成,然后拼接 WAV
|
||||
const segmentPaths: string[] = [];
|
||||
const parsedOutput = path.parse(outputPath);
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segPath = path.join(
|
||||
parsedOutput.dir,
|
||||
`${parsedOutput.name}_seg${i}${parsedOutput.ext || (outputFormat === 'wav' ? '.wav' : '.mp3')}`
|
||||
);
|
||||
logger.info(`[QwenTTS] 合成第 ${i + 1}/${segments.length} 段 (${segments[i].length}字)...`);
|
||||
const segResult = await synthesizeOneSegment(segments[i], config, segPath);
|
||||
if (!segResult.success) {
|
||||
return segResult; // 任一段失败则返回错误
|
||||
}
|
||||
segmentPaths.push(segPath);
|
||||
}
|
||||
|
||||
// 拼接所有段的 WAV 文件(简单拼接 PCM 数据)
|
||||
try {
|
||||
await concatAudioFiles(segmentPaths, outputPath, outputFormat);
|
||||
// 清理临时分段文件
|
||||
for (const p of segmentPaths) {
|
||||
try { fs.unlinkSync(p); } catch (e) { /* ignore */ }
|
||||
}
|
||||
logger.info('[QwenTTS] 多段音频拼接完成', { outputPath, segments: segments.length });
|
||||
} catch (concatError) {
|
||||
// 拼接失败则使用第一段
|
||||
logger.error('[QwenTTS] 音频拼接失败,使用第一段', concatError);
|
||||
if (segmentPaths.length > 0 && fs.existsSync(segmentPaths[0])) {
|
||||
fs.copyFileSync(segmentPaths[0], outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioPath: outputPath,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[QwenTTS] 语音合成失败', error.response?.data || error.message || error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message || '语音合成失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合成单段文本
|
||||
*/
|
||||
async function synthesizeOneSegment(
|
||||
text: string,
|
||||
config: CosyVoiceConfig,
|
||||
outputPath: string
|
||||
): Promise<CosyVoiceResult> {
|
||||
const instrLen = config.instruction ? config.instruction.length : 0;
|
||||
const outputFormat = getOutputFormat(outputPath, config.format);
|
||||
logger.info(`[QwenTTS] synthesizeOneSegment: textLen=${text.length}, instructionLen=${instrLen}, totalLen=${text.length + instrLen}, model=${config.model}`);
|
||||
|
||||
const response = await axios.post(
|
||||
QWEN_TTS_URL,
|
||||
{
|
||||
model: config.model,
|
||||
input: {
|
||||
text,
|
||||
voice: config.voice,
|
||||
...(mapLanguageType(config.language_hints || config.language)
|
||||
? { language_type: mapLanguageType(config.language_hints || config.language) }
|
||||
: {}),
|
||||
...(config.instruction ? { instructions: config.instruction, optimize_instructions: true } : {}),
|
||||
},
|
||||
},
|
||||
withoutUnsupportedProxy({
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
);
|
||||
|
||||
const audioUrl = response.data?.output?.audio?.url;
|
||||
const requestId = response.data?.request_id;
|
||||
if (!audioUrl) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data?.message || '未获取到音频 URL',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
const audioResponse = await axios.get(audioUrl, withoutUnsupportedProxy({
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 600000,
|
||||
}));
|
||||
await writeAudioBufferAsTargetFormat(
|
||||
Buffer.from(audioResponse.data),
|
||||
outputPath,
|
||||
outputFormat,
|
||||
audioResponse.headers?.['content-type']
|
||||
);
|
||||
|
||||
logger.info('[QwenTTS] 单段音频已保存', { outputPath, requestId, textLen: text.length });
|
||||
return {
|
||||
success: true,
|
||||
audioPath: outputPath,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接多个 WAV 文件(假设格式相同:都是 PCM WAV)
|
||||
*/
|
||||
async function concatWavFiles(inputPaths: string[], outputPath: string): Promise<void> {
|
||||
if (inputPaths.length === 0) throw new Error('无输入文件');
|
||||
if (inputPaths.length === 1) {
|
||||
fs.copyFileSync(inputPaths[0], outputPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取第一个文件的 WAV 头(44字节)
|
||||
const firstFile = fs.readFileSync(inputPaths[0]);
|
||||
const headerSize = 44;
|
||||
const header = Buffer.from(firstFile.buffer, 0, headerSize);
|
||||
|
||||
// 收集所有 PCM 数据(跳过 WAV 头)
|
||||
const pcmBuffers: Buffer[] = [];
|
||||
let totalPcmSize = 0;
|
||||
for (const p of inputPaths) {
|
||||
const buf = fs.readFileSync(p);
|
||||
const pcm = buf.slice(headerSize);
|
||||
pcmBuffers.push(pcm);
|
||||
totalPcmSize += pcm.length;
|
||||
}
|
||||
|
||||
// 更新 WAV 头中的文件大小字段
|
||||
const newHeader = Buffer.from(header);
|
||||
newHeader.writeUInt32LE(totalPcmSize + headerSize - 8, 4); // ChunkSize
|
||||
newHeader.writeUInt32LE(totalPcmSize, 40); // Subchunk2Size
|
||||
|
||||
// 写入文件
|
||||
const outputBuf = Buffer.concat([newHeader, ...pcmBuffers]);
|
||||
fs.writeFileSync(outputPath, outputBuf);
|
||||
}
|
||||
|
||||
async function concatMp3Files(inputPaths: string[], outputPath: string): Promise<void> {
|
||||
const concatListPath = path.join(
|
||||
path.dirname(outputPath),
|
||||
`concat_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`
|
||||
);
|
||||
const concatList = inputPaths
|
||||
.map(filePath => {
|
||||
const normalized = filePath.replace(/\\/g, '/').replace(/'/g, "'\\''");
|
||||
return `file '${normalized}'`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
fs.writeFileSync(concatListPath, concatList, 'utf-8');
|
||||
try {
|
||||
await Apps.spawnBinary('ffmpeg', [
|
||||
'-y',
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', concatListPath,
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
outputPath,
|
||||
], {
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
try { fs.unlinkSync(concatListPath); } catch (_error) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function concatAudioFiles(
|
||||
inputPaths: string[],
|
||||
outputPath: string,
|
||||
format: 'wav' | 'mp3'
|
||||
): Promise<void> {
|
||||
if (format === 'wav') {
|
||||
await concatWavFiles(inputPaths, outputPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await concatMp3Files(inputPaths, outputPath);
|
||||
}
|
||||
|
||||
export function getAvailableVoices(model: string): Array<{ id: string; name: string; desc: string }> {
|
||||
if (model.startsWith('qwen3-tts') || model.startsWith('qwen-tts')) {
|
||||
return [
|
||||
{ id: 'Cherry', name: '芊悦', desc: '普通话,女声,通用播报' },
|
||||
{ id: 'Serena', name: '苏瑶', desc: '普通话,女声,温柔自然' },
|
||||
{ id: 'Ethan', name: '晨煦', desc: '普通话,男声,稳重清晰' },
|
||||
{ id: 'Jada', name: '上海-阿珍', desc: '上海话,女声,方言播报' },
|
||||
{ id: 'Dylan', name: '北京-晓东', desc: '北京话,男声,方言播报' },
|
||||
{ id: 'Li', name: '南京-老李', desc: '南京话,男声,方言播报' },
|
||||
{ id: 'Marcus', name: '陕西-秦川', desc: '陕西话,男声,方言播报' },
|
||||
{ id: 'Roy', name: '闽南-阿杰', desc: '闽南语,男声,方言播报' },
|
||||
{ id: 'Peter', name: '天津-李彼得', desc: '天津话,男声,方言播报' },
|
||||
{ id: 'Sunny', name: '四川-晴儿', desc: '四川话,女声,方言播报' },
|
||||
{ id: 'Eric', name: '四川-程川', desc: '四川话,男声,方言播报' },
|
||||
{ id: 'Rocky', name: '粤语-阿强', desc: '粤语,男声,方言播报' },
|
||||
{ id: 'Kiki', name: '粤语-阿清', desc: '粤语,女声,方言播报' },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getInstructionForLanguage(language: string): string | undefined {
|
||||
if (!language || language === '中文' || language === '中文(普通话)') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dialectMap: Record<string, string> = {
|
||||
'上海话': '请用上海话表达。',
|
||||
'北京话': '请用北京话表达。',
|
||||
'四川话': '请用四川话表达。',
|
||||
'南京话': '请用南京话表达。',
|
||||
'陕西话': '请用陕西话表达。',
|
||||
'闽南话': '请用闽南话表达。',
|
||||
'天津话': '请用天津话表达。',
|
||||
'粤语': '请用粤语表达。',
|
||||
'广东话': '请用粤语表达。',
|
||||
};
|
||||
if (dialectMap[language]) {
|
||||
return dialectMap[language];
|
||||
}
|
||||
|
||||
const languageMap: Record<string, string> = {
|
||||
'英语': 'Please speak in English.',
|
||||
'德语': 'Please speak in German.',
|
||||
'意大利语': 'Please speak in Italian.',
|
||||
'葡萄牙语': 'Please speak in Portuguese.',
|
||||
'西班牙语': 'Please speak in Spanish.',
|
||||
'日语': 'Please speak in Japanese.',
|
||||
'韩语': 'Please speak in Korean.',
|
||||
'法语': 'Please speak in French.',
|
||||
'俄语': 'Please speak in Russian.',
|
||||
};
|
||||
|
||||
return languageMap[language];
|
||||
}
|
||||
|
||||
function mapLanguageType(language?: string): string | undefined {
|
||||
const languageMap: Record<string, string> = {
|
||||
'中文': 'Chinese',
|
||||
'中文(普通话)': 'Chinese',
|
||||
'Chinese': 'Chinese',
|
||||
'英语': 'English',
|
||||
'English': 'English',
|
||||
'德语': 'German',
|
||||
'German': 'German',
|
||||
'意大利语': 'Italian',
|
||||
'Italian': 'Italian',
|
||||
'葡萄牙语': 'Portuguese',
|
||||
'Portuguese': 'Portuguese',
|
||||
'西班牙语': 'Spanish',
|
||||
'Spanish': 'Spanish',
|
||||
'日语': 'Japanese',
|
||||
'Japanese': 'Japanese',
|
||||
'韩语': 'Korean',
|
||||
'Korean': 'Korean',
|
||||
'法语': 'French',
|
||||
'French': 'French',
|
||||
'俄语': 'Russian',
|
||||
'Russian': 'Russian',
|
||||
};
|
||||
|
||||
return language ? languageMap[language] : undefined;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import logger from '../log/main';
|
||||
import { getFFmpegPath, getFFprobePath } from '../shell';
|
||||
|
||||
const DASHSCOPE_API_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||||
const MIN_ENROLLMENT_SAMPLE_RATE = 16000;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function ensureEnrollmentAudio(filePath: string): Promise<string> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const ffprobePath = await getFFprobePath();
|
||||
const ffmpegPath = await getFFmpegPath();
|
||||
|
||||
let sampleRate = 0;
|
||||
let channels = 0;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(ffprobePath, [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'a:0',
|
||||
'-show_entries', 'stream=sample_rate,channels',
|
||||
'-of', 'json',
|
||||
filePath,
|
||||
]);
|
||||
const probe = JSON.parse(stdout || '{}');
|
||||
const stream = probe?.streams?.[0] || {};
|
||||
sampleRate = parseInt(stream.sample_rate || '0', 10) || 0;
|
||||
channels = parseInt(stream.channels || '0', 10) || 0;
|
||||
} catch (error: any) {
|
||||
logger.warn('[Aliyun:Enrollment] ffprobe 检测失败,直接转码兜底', {
|
||||
filePath,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const needsConvert = !sampleRate || sampleRate < MIN_ENROLLMENT_SAMPLE_RATE || channels !== 1 || path.extname(filePath).toLowerCase() !== '.wav';
|
||||
if (!needsConvert) {
|
||||
logger.info('[Aliyun:Enrollment] 音频格式符合要求,直接使用', { filePath, sampleRate, channels });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const parsed = path.parse(filePath);
|
||||
const normalizedPath = (p: string) => process.platform === 'win32' ? p.replace(/\\/g, '/') : p;
|
||||
const outputPath = path.join(parsed.dir, `${parsed.name}_enroll_16k.wav`);
|
||||
|
||||
logger.info('[Aliyun:Enrollment] 开始转码复刻音频', {
|
||||
input: filePath,
|
||||
output: outputPath,
|
||||
sourceSampleRate: sampleRate,
|
||||
sourceChannels: channels,
|
||||
targetSampleRate: MIN_ENROLLMENT_SAMPLE_RATE,
|
||||
targetChannels: 1,
|
||||
});
|
||||
|
||||
await execFileAsync(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', normalizedPath(filePath),
|
||||
'-vn',
|
||||
'-acodec', 'pcm_s16le',
|
||||
'-ar', String(MIN_ENROLLMENT_SAMPLE_RATE),
|
||||
'-ac', '1',
|
||||
normalizedPath(outputPath),
|
||||
]);
|
||||
|
||||
logger.info('[Aliyun:Enrollment] 音频转码完成', { outputPath });
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阿里云 DashScope Voice Enrollment (声音复刻) 服务
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将本地音频文件转换为 Base64 Data URI
|
||||
* DashScope create_voice 接口支持 data:{mime_type};base64,{base64_str} 格式
|
||||
* 这完全绕过了需要公网URL的问题!
|
||||
*/
|
||||
export async function uploadFileToLease(apiKey: string, filePath: string): Promise<string> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const enrollmentAudioPath = await ensureEnrollmentAudio(filePath);
|
||||
const fileName = path.basename(enrollmentAudioPath);
|
||||
const ext = path.extname(enrollmentAudioPath).toLowerCase();
|
||||
|
||||
// 根据扩展名确定 MIME 类型
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mp3',
|
||||
'.m4a': 'audio/m4a',
|
||||
'.flac': 'audio/flac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.aac': 'audio/aac',
|
||||
'.wma': 'audio/wma',
|
||||
'.amr': 'audio/amr',
|
||||
'.pcm': 'audio/pcm'
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[ext] || 'audio/wav';
|
||||
|
||||
try {
|
||||
logger.info(`[Aliyun:Enrollment] 将音频文件转换为 Base64 Data URI: ${fileName}`);
|
||||
|
||||
// 读取文件并转换为 Base64
|
||||
const fileBuffer = fs.readFileSync(enrollmentAudioPath);
|
||||
const base64Str = fileBuffer.toString('base64');
|
||||
|
||||
// 构造 Data URI
|
||||
const dataUri = `data:${mimeType};base64,${base64Str}`;
|
||||
|
||||
logger.info(`[Aliyun:Enrollment] 文件转换成功, 大小: ${Math.round(fileBuffer.length / 1024)} KB, MIME: ${mimeType}`);
|
||||
|
||||
return dataUri;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:Enrollment] 文件转换失败', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 废弃旧的 applyLease 和 uploadToOss 函数
|
||||
// async function applyLease ...
|
||||
// async function uploadToOss ...
|
||||
|
||||
/*
|
||||
interface LeaseResponse {
|
||||
url: string; // 用于后续API调用的文件URL
|
||||
param: {
|
||||
url: string; // 上传地址
|
||||
method: string;
|
||||
headers: any;
|
||||
};
|
||||
upload_method: string;
|
||||
}
|
||||
|
||||
async function applyLease(apiKey: string, extension: string): Promise<LeaseResponse> {
|
||||
const url = `${DASHSCOPE_API_URL}/uploads`;
|
||||
const payload = {
|
||||
action: 'getPolicy', // 注意:DashScope不同服务的上传接口可能不同,这里使用通用的uploads/getPolicy ?
|
||||
// 修正:DashScope有一个通用的 `uploads` 接口用于申请 lease
|
||||
// 文档: POST https://dashscope.aliyuncs.com/api/v1/uploads
|
||||
model: 'voice-enrollment' // 指定模型
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
params: payload,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// 响应结构:
|
||||
// {
|
||||
// "data": {
|
||||
// "url": "https://...",
|
||||
// "param": { ... },
|
||||
// "upload_method": "PUT" / "POST"
|
||||
// }
|
||||
// }
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
const data = response.data.data;
|
||||
logger.info('[Aliyun:Enrollment] Apply Lease 响应:', JSON.stringify(data));
|
||||
|
||||
// 适配 OSS PostObject 格式
|
||||
// https://help.aliyun.com/zh/oss/developer-reference/postobject
|
||||
if (data.policy && data.signature && data.upload_host) {
|
||||
// 构造文件名(使用随机UUID或保留原扩展名)
|
||||
// 上传后的 URL = upload_host + / + upload_dir + / + filename
|
||||
// 但是 DashScope 可能要求特定的 key 格式?
|
||||
// data.upload_dir 是前缀。
|
||||
// 我们需要自己生成 key。
|
||||
|
||||
return {
|
||||
url: '', // 这里的 URL 需要在上传后拼接,或者 DashScope 不需要?
|
||||
// 文档说 create_voice 需要 public url.
|
||||
// OSS PostObject 不会自动返回 URL。
|
||||
// 通常是 upload_host + '/' + key
|
||||
param: {
|
||||
url: data.upload_host,
|
||||
method: 'POST',
|
||||
headers: {}, // PostObject 不需要 Authorization header,签名在 body 里
|
||||
formData: {
|
||||
'key': data.upload_dir + '/${filename}', // 这是一个模板,实际上传时替换
|
||||
'policy': data.policy,
|
||||
'Signature': data.signature,
|
||||
'OSSAccessKeyId': data.oss_access_key_id,
|
||||
'x-oss-object-acl': data.x_oss_object_acl,
|
||||
'x-oss-forbid-overwrite': data.x_oss_forbid_overwrite,
|
||||
// 'file': ... (在 uploadToOss 中添加)
|
||||
},
|
||||
// 用于拼接最终 URL
|
||||
uploadDir: data.upload_dir,
|
||||
uploadHost: data.upload_host
|
||||
},
|
||||
upload_method: 'POST'
|
||||
} as LeaseResponse; // 临时通过 any 绕过类型检查,稍后修正 Interface
|
||||
}
|
||||
|
||||
// 兼容性处理:如果 param 不存在,可能是扁平结构,或者结构有所不同
|
||||
if (!data.param) {
|
||||
// 如果没有 param,看看有没有 url
|
||||
if (data.url) {
|
||||
// 构造一个伪 param
|
||||
return {
|
||||
url: data.url,
|
||||
upload_method: data.upload_method || 'POST', // 默认 POST?
|
||||
param: {
|
||||
url: data.url,
|
||||
method: data.upload_method || 'PUT', // 大多数预签名URL是PUT
|
||||
headers: data.headers || {} // 扁平结构可能有 headers
|
||||
}
|
||||
} as LeaseResponse;
|
||||
}
|
||||
}
|
||||
return data as LeaseResponse;
|
||||
} else {
|
||||
logger.error('[Aliyun:Enrollment] Apply Lease 响应异常:', JSON.stringify(response.data));
|
||||
throw new Error('申请上传凭证失败: ' + JSON.stringify(response.data));
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:Enrollment] Apply Lease 失败', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToOss(filePath: string, param: any): Promise<string> {
|
||||
const fileBuffer = fs.existsSync(filePath) ? fs.readFileSync(filePath) : null;
|
||||
if (!fileBuffer) throw new Error(`文件读取失败: ${filePath}`);
|
||||
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
try {
|
||||
const method = param.method || 'PUT';
|
||||
logger.info(`[Aliyun:Enrollment] 上传文件到临时存储 URL: ${param.url}`);
|
||||
|
||||
if (method.toUpperCase() === 'POST' && param.formData) {
|
||||
logger.info('[Aliyun:Enrollment] 使用 FormData (OSS PostObject) 上传');
|
||||
|
||||
// 使用 Node.js 全局 FormData (Node 18+)
|
||||
// 如果环境不支持,这里会报错。Electron 29 (Node 20) 应该支持。
|
||||
const FormDataClass = (global as any).FormData;
|
||||
if (!FormDataClass) {
|
||||
throw new Error('当前环境不支持全局 FormData,无法执行 POST 上传');
|
||||
}
|
||||
|
||||
const form = new FormDataClass();
|
||||
|
||||
// 构造 Key (FilePath in OSS)
|
||||
// 上面的 param.formData['key'] 是个模板: .../${filename}
|
||||
// 我们生成一个唯一文件名,或者直接用 fileName
|
||||
const finalKey = param.formData['key'].replace('${filename}', fileName);
|
||||
|
||||
// 必须按照顺序添加吗?OSS 对顺序通常不敏感,但 file 最好在最后
|
||||
form.append('key', finalKey);
|
||||
form.append('policy', param.formData['policy']);
|
||||
form.append('Signature', param.formData['Signature']);
|
||||
form.append('OSSAccessKeyId', param.formData['OSSAccessKeyId']);
|
||||
if (param.formData['x-oss-object-acl']) form.append('x-oss-object-acl', param.formData['x-oss-object-acl']);
|
||||
if (param.formData['x-oss-forbid-overwrite']) form.append('x-oss-forbid-overwrite', param.formData['x-oss-forbid-overwrite']);
|
||||
|
||||
// 添加文件
|
||||
// Node.js 的 FormData 需要 Blob 或 File。
|
||||
// 我们可以用 Blob (Node 18+)
|
||||
const BlobClass = (global as any).Blob;
|
||||
if (!BlobClass) {
|
||||
throw new Error('当前环境不支持全局 Blob');
|
||||
}
|
||||
const blob = new BlobClass([fileBuffer]);
|
||||
form.append('file', blob, fileName);
|
||||
|
||||
// 发送请求
|
||||
// axios 在 Node 中支持传入 FormData 实例,并自动设置 headers (Content-Type: multipart/form-data; boundary=...)
|
||||
await axios.post(param.url, form, {
|
||||
headers: {
|
||||
// 让 axios/FormData 自动计算 Content-Type
|
||||
// 'Content-Type': 'multipart/form-data' // 不要手动设置,否则丢失 boundary
|
||||
}
|
||||
});
|
||||
|
||||
// 更新 leaseData.url,因为 PostObject 不会返回,我们需要自己算
|
||||
// param 是引用传递吗?不是,这里是 uploadToOss。
|
||||
// 我们需要一种方式把 URL 传回去。
|
||||
// 这里我们无法修改外部的 leaseData。
|
||||
// 所以 uploadToOss 应该返回 url。
|
||||
|
||||
const objectUrl = `${param.uploadHost}/${finalKey}`;
|
||||
logger.info(`[Aliyun:Enrollment] OSS 上传成功, URL: ${objectUrl}`);
|
||||
return objectUrl;
|
||||
}
|
||||
|
||||
// PUT 逻辑保持不变
|
||||
else if (method.toUpperCase() === 'PUT') {
|
||||
logger.info(`[Aliyun:Enrollment] 上传 Method: ${method}, Headers:`, param.headers);
|
||||
await axios.put(param.url, fileBuffer, {
|
||||
headers: param.headers || {}
|
||||
});
|
||||
logger.info('[Aliyun:Enrollment] 上传 OSS 成功');
|
||||
return param.url.split('?')[0]; // 去除签名参数得到纯URL? 不一定,视情况而定
|
||||
} else {
|
||||
logger.warn(`[Aliyun:Enrollment] 未知上传方法: ${method}, 尝试 Generic Request`);
|
||||
await axios({
|
||||
method: method,
|
||||
url: param.url,
|
||||
headers: param.headers,
|
||||
data: fileBuffer
|
||||
});
|
||||
logger.info('[Aliyun:Enrollment] 上传 OSS 成功');
|
||||
return param.url.split('?')[0]; // 假设通用请求也返回带签名的URL
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
// 详细错误日志
|
||||
const errInfo = {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
headers: error.response?.headers,
|
||||
message: error.message
|
||||
};
|
||||
logger.error('[Aliyun:Enrollment] 上传 OSS 失败', JSON.stringify(errInfo));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建复刻音色
|
||||
*/
|
||||
export async function createVoice(apiKey: string, audioUrl: string, prefix: string = 'custom_voice'): Promise<string> {
|
||||
const url = `${DASHSCOPE_API_URL}/services/audio/tts/customization`;
|
||||
|
||||
const payload = {
|
||||
model: 'qwen-voice-enrollment',
|
||||
input: {
|
||||
action: 'create',
|
||||
target_model: 'qwen3-tts-vc-2026-01-22',
|
||||
preferred_name: prefix,
|
||||
audio: {
|
||||
data: audioUrl
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('[Aliyun:Enrollment] 开始复刻音色', payload);
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const voiceId = data.output?.voice || data.output?.voice_id;
|
||||
if (voiceId) {
|
||||
logger.info('[Aliyun:Enrollment] 复刻成功', data.output);
|
||||
return voiceId;
|
||||
} else {
|
||||
throw new Error('复刻响应格式错误: ' + JSON.stringify(data));
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:Enrollment] 复刻失败', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import WebSocket from 'ws';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import logger from '../log/main';
|
||||
|
||||
/**
|
||||
* 阿里云 FunASR 语音识别 WebSocket 客户端
|
||||
* 文档: https://help.aliyun.com/zh/model-studio/fun-asr-realtime-python-sdk
|
||||
*/
|
||||
|
||||
export interface FunAsrConfig {
|
||||
apiKey: string;
|
||||
model: string; // qwen3-asr-flash | fun-asr-realtime | fun-asr
|
||||
format: string; // pcm | wav | mp3 | opus | speex | aac | amr
|
||||
sampleRate: number; // 音频采样率,支持 16000Hz
|
||||
language?: string[]; // 语言代码: zh, en, ja
|
||||
}
|
||||
|
||||
export interface AsrWord {
|
||||
text: string;
|
||||
startTime: number; // 毫秒
|
||||
endTime: number; // 毫秒
|
||||
}
|
||||
|
||||
export interface AsrSentence {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
words?: AsrWord[];
|
||||
}
|
||||
|
||||
export interface FunAsrResult {
|
||||
success: boolean;
|
||||
sentences?: AsrSentence[];
|
||||
fullText?: string;
|
||||
error?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FunASR 语音识别主函数
|
||||
*/
|
||||
export async function recognizeAudio(
|
||||
audioFilePath: string,
|
||||
config: FunAsrConfig
|
||||
): Promise<FunAsrResult> {
|
||||
const wsUrl = 'wss://dashscope.aliyuncs.com/api-ws/v1/inference';
|
||||
|
||||
logger.info('[FunASR] 开始语音识别', {
|
||||
model: config.model,
|
||||
format: config.format,
|
||||
sampleRate: config.sampleRate,
|
||||
audioFile: path.basename(audioFilePath)
|
||||
});
|
||||
|
||||
// 读取音频文件
|
||||
if (!fs.existsSync(audioFilePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `音频文件不存在: ${audioFilePath}`
|
||||
};
|
||||
}
|
||||
|
||||
const audioData = fs.readFileSync(audioFilePath);
|
||||
logger.info(`[FunASR] 音频文件大小: ${audioData.length} bytes`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'X-DashScope-DataInspection': 'enable'
|
||||
}
|
||||
});
|
||||
const sentences: AsrSentence[] = [];
|
||||
let requestId = '';
|
||||
let startTime = Date.now();
|
||||
let isFirstPackage = true;
|
||||
let taskId = '';
|
||||
let audioOffset = 0;
|
||||
const chunkSize = 32000; // 每次发送32KB,加速处理
|
||||
|
||||
// 发送下一个音频块的函数(快速批量模式)
|
||||
const sendNextChunk = () => {
|
||||
if (audioOffset >= audioData.length) {
|
||||
// 发送完成信号
|
||||
const finishMessage = {
|
||||
header: {
|
||||
action: 'finish-task',
|
||||
task_id: taskId
|
||||
},
|
||||
payload: {
|
||||
input: {} // 必需字段
|
||||
}
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(finishMessage), (err) => {
|
||||
if (err) {
|
||||
logger.error('[FunASR] 发送完成信号失败', err);
|
||||
} else {
|
||||
logger.info('[FunASR] 音频数据发送完成');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = audioData.slice(audioOffset, audioOffset + chunkSize);
|
||||
audioOffset += chunkSize;
|
||||
|
||||
// 发送二进制音频数据
|
||||
ws.send(chunk, (err) => {
|
||||
if (err) {
|
||||
logger.error('[FunASR] 发送音频块失败', err);
|
||||
ws.close();
|
||||
} else {
|
||||
// 快速批量模式:5ms间隔(接近无延迟但避免缓冲区溢出)
|
||||
setImmediate(sendNextChunk);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ws.on('open', () => {
|
||||
logger.info('[FunASR] WebSocket 连接已建立');
|
||||
|
||||
// 生成任务ID
|
||||
taskId = generateTaskId();
|
||||
|
||||
// 第一步:发送配置消息(run-task指令)
|
||||
const configMessage = {
|
||||
header: {
|
||||
action: 'run-task',
|
||||
streaming: 'duplex',
|
||||
task_id: taskId
|
||||
},
|
||||
payload: {
|
||||
model: config.model,
|
||||
task: 'asr',
|
||||
task_group: 'audio',
|
||||
function: 'recognition',
|
||||
input: {}, // 必需字段,空对象
|
||||
parameters: {
|
||||
format: config.format,
|
||||
sample_rate: config.sampleRate,
|
||||
language_hints: config.language || ['zh', 'en'],
|
||||
punctuation_prediction_enabled: true,
|
||||
semantic_punctuation_enabled: false,
|
||||
max_sentence_silence: 300, // 🔧 修复:降低到300ms,让句子分割更细
|
||||
word_timestamp_enabled: true // 🔧 新增:启用词级时间戳
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(configMessage), (err) => {
|
||||
if (err) {
|
||||
logger.error('[FunASR] 发送配置失败', err);
|
||||
ws.close();
|
||||
reject(new Error(`发送配置失败: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[FunASR] 配置已发送,立即开始发送音频...');
|
||||
// 根据官方文档:start()后立即发送音频,不需要等待服务器响应
|
||||
sendNextChunk();
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
// 调试日志:打印服务器返回的完整消息结构
|
||||
logger.info('[FunASR] 收到消息:', JSON.stringify(message.header || {}));
|
||||
|
||||
if (message.header) {
|
||||
requestId = message.header.task_id || message.header.request_id || '';
|
||||
|
||||
// 检查服务器是否准备好接收音频
|
||||
// FunASR可能使用不同的事件名,在收到首包响应后就开始发送
|
||||
const event = message.header.event || message.header.action || '';
|
||||
|
||||
// 首包延迟统计(仅用于调试)
|
||||
if (isFirstPackage) {
|
||||
const firstPackageDelay = Date.now() - startTime;
|
||||
logger.info(`[FunASR] 收到首包结果,延迟: ${firstPackageDelay}ms, event: ${event}`);
|
||||
isFirstPackage = false;
|
||||
// 注意:音频发送已在配置发送后立即开始,这里不再重复触发
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if (message.header.status === 'failed') {
|
||||
const errorMsg = message.header.message || '识别失败';
|
||||
logger.error('[FunASR] 识别失败', message);
|
||||
ws.close();
|
||||
resolve({
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
requestId
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理识别结果
|
||||
if (message.payload && message.payload.output) {
|
||||
const output = message.payload.output;
|
||||
|
||||
// 中间结果
|
||||
if (output.sentence) {
|
||||
const sentence = output.sentence;
|
||||
|
||||
// 只处理已完成的句子
|
||||
if (sentence.end_time) {
|
||||
// 🔧 添加详细日志
|
||||
logger.info('[FunASR] 原始句子数据:', {
|
||||
text: sentence.text,
|
||||
begin_time: sentence.begin_time,
|
||||
end_time: sentence.end_time,
|
||||
完整数据: JSON.stringify(sentence)
|
||||
});
|
||||
|
||||
const asrSentence: AsrSentence = {
|
||||
text: sentence.text || '',
|
||||
startTime: sentence.begin_time || 0,
|
||||
endTime: sentence.end_time || 0
|
||||
};
|
||||
|
||||
// 添加词级时间戳
|
||||
if (sentence.words && Array.isArray(sentence.words)) {
|
||||
asrSentence.words = sentence.words.map((word: any) => ({
|
||||
text: word.text || '',
|
||||
startTime: word.begin_time || 0,
|
||||
endTime: word.end_time || 0
|
||||
}));
|
||||
}
|
||||
|
||||
sentences.push(asrSentence);
|
||||
logger.info(`[FunASR] 识别到句子: ${asrSentence.text} (${asrSentence.startTime} - ${asrSentence.endTime})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[FunASR] 处理消息失败', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const totalDuration = Date.now() - startTime;
|
||||
logger.info(`[FunASR] WebSocket 连接已关闭,总耗时: ${totalDuration}ms`);
|
||||
|
||||
if (sentences.length === 0) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: '未识别到任何文本',
|
||||
requestId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并所有句子文本
|
||||
const fullText = sentences.map(s => s.text).join('');
|
||||
|
||||
logger.info(`[FunASR] 识别完成,共${sentences.length}个句子,总文本长度: ${fullText.length}`);
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
sentences,
|
||||
fullText,
|
||||
requestId
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
logger.error('[FunASR] WebSocket 错误', error);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `WebSocket错误: ${error.message}`,
|
||||
requestId
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成任务ID
|
||||
*/
|
||||
function generateTaskId(): string {
|
||||
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测音频格式
|
||||
*/
|
||||
export function detectAudioFormat(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const formatMap: { [key: string]: string } = {
|
||||
'.wav': 'wav',
|
||||
'.mp3': 'mp3',
|
||||
'.pcm': 'pcm',
|
||||
'.opus': 'opus',
|
||||
'.aac': 'aac',
|
||||
'.amr': 'amr',
|
||||
'.speex': 'speex'
|
||||
};
|
||||
|
||||
return formatMap[ext] || 'wav';
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 阿里云语音服务模块入口
|
||||
* 整合 CosyVoice (TTS) 和 FunASR (ASR) 功能
|
||||
*/
|
||||
|
||||
import * as cosyvoice from './cosyvoice';
|
||||
import * as funasr from './funasr';
|
||||
import * as enrollment from './enrollment';
|
||||
import logger from '../log/main';
|
||||
|
||||
export {
|
||||
cosyvoice,
|
||||
funasr,
|
||||
enrollment
|
||||
};
|
||||
|
||||
// 导出主要函数
|
||||
export const {
|
||||
synthesizeSpeech,
|
||||
getAvailableVoices
|
||||
} = cosyvoice;
|
||||
|
||||
export const {
|
||||
recognizeAudio,
|
||||
detectAudioFormat
|
||||
} = funasr;
|
||||
|
||||
// 导出类型
|
||||
export type {
|
||||
CosyVoiceConfig,
|
||||
CosyVoiceResult
|
||||
} from './cosyvoice';
|
||||
|
||||
export type {
|
||||
FunAsrConfig,
|
||||
FunAsrResult,
|
||||
AsrSentence,
|
||||
AsrWord
|
||||
} from './funasr';
|
||||
|
||||
/**
|
||||
* 执行声音复刻流程
|
||||
*/
|
||||
export async function enrollVoice(params: {
|
||||
apiKey: string,
|
||||
audioPath: string,
|
||||
name: string
|
||||
}): Promise<{ success: boolean, voiceId?: string, error?: string }> {
|
||||
try {
|
||||
logger.info('[Aliyun] 开始执行声音复刻', { name: params.name, audioPath: params.audioPath });
|
||||
|
||||
// 1. 上传文件获取 URL
|
||||
const audioUrl = await enrollment.uploadFileToLease(params.apiKey, params.audioPath);
|
||||
|
||||
// 2. 创建音色
|
||||
// 使用 name 作为 prefix,但需过滤非法字符(仅允许数字、字母、下划线)
|
||||
// 阿里云要求 prefix: [a-zA-Z0-9_]{1,10}
|
||||
// 如果 name 有中文,替换为 custom
|
||||
let prefix = params.name.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
// 阿里云要求 prefix: [a-zA-Z0-9_]{1,10}
|
||||
// 如果处理后为空,或者还是太长,则进行截断或使用默认值
|
||||
if (!prefix) {
|
||||
prefix = 'custom';
|
||||
} else if (prefix.length > 10) {
|
||||
prefix = prefix.substring(0, 10);
|
||||
}
|
||||
|
||||
const voiceId = await enrollment.createVoice(params.apiKey, audioUrl, prefix);
|
||||
|
||||
return { success: true, voiceId };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun] 声音复刻失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import * as aliyunApi from './index';
|
||||
import { uploadToOss, getOssConfigFromProvider, OssConfig } from './oss';
|
||||
import { recognizeRecordedAudio } from './transcription';
|
||||
import logger from '../log/main';
|
||||
import { preCacheSystemVoices, getPreCachedVoicePath, isAllVoicesCached, clearPreCache } from './voicePreCache';
|
||||
import { detectAudioSegments, allocateTextToSegments } from '../audio/volumeDetect';
|
||||
|
||||
export function registerAliyunHandlers() {
|
||||
// CosyVoice TTS 合成
|
||||
ipcMain.handle('aliyun:cosyvoice:synthesize', async (event, params) => {
|
||||
try {
|
||||
// 如果指定了 saveToPreCache,自动设置输出路径到预缓存目录
|
||||
if (params.config.saveToPreCache) {
|
||||
const voiceId = params.config.saveToPreCache;
|
||||
const { getPreCacheDir } = await import('./voicePreCache');
|
||||
const path = await import('path');
|
||||
const cacheDir = getPreCacheDir();
|
||||
const outputPath = path.default.join(cacheDir, `${voiceId}.wav`);
|
||||
|
||||
logger.info('[Aliyun:IPC] 保存到预缓存目录', { voiceId, outputPath });
|
||||
|
||||
// 设置输出路径
|
||||
params.config.outputPath = outputPath;
|
||||
// 移除 saveToPreCache,避免传递给 API
|
||||
delete params.config.saveToPreCache;
|
||||
}
|
||||
|
||||
// 🆕 如果 provided language but not instruction,自动生成 instruction
|
||||
if (params.config.language && !params.config.instruction) {
|
||||
const instruction = aliyunApi.cosyvoice.getInstructionForLanguage(params.config.language);
|
||||
if (instruction) {
|
||||
params.config.instruction = instruction;
|
||||
logger.info('[Aliyun:IPC] 已为语言生成指令', {
|
||||
language: params.config.language,
|
||||
instruction
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await aliyunApi.cosyvoice.synthesizeSpeech(params.text, params.config);
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 合成失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// CosyVoice 声音复刻
|
||||
ipcMain.handle('aliyun:cosyvoice:enroll', async (event, params) => {
|
||||
try {
|
||||
// params: { apiKey, audioPath, name }
|
||||
return await aliyunApi.enrollVoice(params);
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 复刻失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// FunASR 语音识别(仅使用录音文件识别模式)
|
||||
ipcMain.handle('aliyun:funasr:recognize', async (event, params) => {
|
||||
try {
|
||||
// params: { audioPath, config: { apiKey, ossConfig } }
|
||||
logger.info('[Aliyun:IPC] 开始语音识别(录音文件识别模式)', { audioPath: params.audioPath });
|
||||
|
||||
const { audioPath, config } = params;
|
||||
|
||||
// 检查OSS配置(必需)
|
||||
logger.info('[Aliyun:IPC] 接收到的配置', {
|
||||
hasOssConfig: !!config.ossConfig,
|
||||
ossConfigKeys: config.ossConfig ? Object.keys(config.ossConfig) : [],
|
||||
ossConfig: config.ossConfig
|
||||
});
|
||||
|
||||
const ossConfig: OssConfig | null = config.ossConfig ? {
|
||||
accessKeyId: config.ossConfig.accessKeyId,
|
||||
accessKeySecret: config.ossConfig.accessKeySecret,
|
||||
bucket: config.ossConfig.bucket,
|
||||
region: config.ossConfig.region,
|
||||
endpoint: config.ossConfig.endpoint
|
||||
} : null;
|
||||
|
||||
// 必须配置OSS才能使用录音识别
|
||||
if (!ossConfig || !ossConfig.accessKeyId || !ossConfig.bucket) {
|
||||
logger.error('[Aliyun:IPC] OSS未配置,无法使用录音识别', { ossConfig });
|
||||
return {
|
||||
success: false,
|
||||
error: '请先在「模型」→「阿里云百炼」中配置OSS(用于快速语音识别)'
|
||||
};
|
||||
}
|
||||
|
||||
if (!ossConfig.region) {
|
||||
logger.error('[Aliyun:IPC] OSS Region 未配置', { ossConfig });
|
||||
return {
|
||||
success: false,
|
||||
error: '请在「模型」→「阿里云百炼」中配置 OSS Region(地域)'
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('[Aliyun:IPC] 使用录音文件识别模式', {
|
||||
bucket: ossConfig.bucket,
|
||||
region: ossConfig.region,
|
||||
hasEndpoint: !!ossConfig.endpoint
|
||||
});
|
||||
|
||||
// 检查文件类型,如果是视频文件需要提取音频
|
||||
const path = await import('path');
|
||||
const ext = path.extname(audioPath).toLowerCase();
|
||||
let finalAudioPath = audioPath;
|
||||
|
||||
if (['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'].includes(ext)) {
|
||||
logger.info('[Aliyun:IPC] 检测到视频文件,需要提取音频', { ext, audioPath });
|
||||
try {
|
||||
const { extractAudioFromVideo } = await import('../ipAgent/funasr');
|
||||
const extractedAudioPath = await extractAudioFromVideo(audioPath);
|
||||
finalAudioPath = extractedAudioPath;
|
||||
logger.info('[Aliyun:IPC] 音频提取成功', { extractedAudioPath });
|
||||
} catch (extractError: any) {
|
||||
logger.warn('[Aliyun:IPC] 音频提取失败,继续尝试上传原文件', { error: extractError.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 上传到OSS
|
||||
const uploadResult = await uploadToOss(finalAudioPath, ossConfig);
|
||||
if (!uploadResult.success || !uploadResult.url) {
|
||||
logger.error('[Aliyun:IPC] OSS上传失败', uploadResult.error);
|
||||
return { success: false, error: `OSS上传失败: ${uploadResult.error}` };
|
||||
}
|
||||
|
||||
logger.info('[Aliyun:IPC] OSS上传成功', { url: uploadResult.url });
|
||||
|
||||
// 2. 调用录音识别API
|
||||
const result = await recognizeRecordedAudio(uploadResult.url, {
|
||||
apiKey: config.apiKey,
|
||||
model: config.model || 'qwen3-asr-flash-filetrans' // 🔧 修复:默认使用 qwen3-asr-flash-filetrans 模型
|
||||
});
|
||||
|
||||
// 转换结果格式以保持兼容性
|
||||
return {
|
||||
success: result.success,
|
||||
fullText: result.text,
|
||||
sentences: result.sentences?.map(s => ({
|
||||
text: s.text,
|
||||
startTime: s.beginTime,
|
||||
endTime: s.endTime,
|
||||
words: s.words?.map(w => ({
|
||||
text: w.text,
|
||||
startTime: w.beginTime,
|
||||
endTime: w.endTime
|
||||
}))
|
||||
})),
|
||||
error: result.error,
|
||||
requestId: result.taskId
|
||||
};
|
||||
} catch (error: any) {
|
||||
// 确保错误消息可序列化(避免循环引用)
|
||||
let errorMessage = '未知错误';
|
||||
if (error && typeof error === 'object') {
|
||||
errorMessage = error.message || error.toString();
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
}
|
||||
|
||||
logger.error('[Aliyun:IPC] 语音识别失败', { errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
// FunASR 使用 URL 直接识别(无需上传OSS)
|
||||
ipcMain.handle('aliyun:funasr:recognizeFromUrl', async (event, params) => {
|
||||
try {
|
||||
// params: { audioUrl, apiKey }
|
||||
logger.info('[Aliyun:IPC] 开始语音识别(URL模式)', { audioUrl: params.audioUrl });
|
||||
|
||||
const { audioUrl, apiKey } = params;
|
||||
|
||||
if (!audioUrl || !apiKey) {
|
||||
return { success: false, error: '缺少必要参数:audioUrl 或 apiKey' };
|
||||
}
|
||||
|
||||
// 直接使用 URL 调用识别 API
|
||||
const result = await recognizeRecordedAudio(audioUrl, {
|
||||
apiKey,
|
||||
model: params.model || 'fun-asr' // 使用传递的模型,默认 fun-asr
|
||||
});
|
||||
|
||||
// 转换结果格式以保持兼容性
|
||||
return {
|
||||
success: result.success,
|
||||
fullText: result.text,
|
||||
sentences: result.sentences?.map(s => ({
|
||||
text: s.text,
|
||||
startTime: s.beginTime,
|
||||
endTime: s.endTime,
|
||||
words: s.words?.map(w => ({
|
||||
text: w.text,
|
||||
startTime: w.beginTime,
|
||||
endTime: w.endTime
|
||||
}))
|
||||
})),
|
||||
error: result.error,
|
||||
requestId: result.taskId
|
||||
};
|
||||
} catch (error: any) {
|
||||
let errorMessage = '未知错误';
|
||||
if (error && typeof error === 'object') {
|
||||
errorMessage = error.message || error.toString();
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
}
|
||||
|
||||
logger.error('[Aliyun:IPC] URL识别失败', { errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
// 预缓存系统默认音色
|
||||
ipcMain.handle('aliyun:cosyvoice:preCacheSystemVoices', async (event, params) => {
|
||||
try {
|
||||
const { apiKey, force } = params;
|
||||
await preCacheSystemVoices(apiKey, force);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 预缓存失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 获取预缓存的音色文件路径
|
||||
ipcMain.handle('aliyun:cosyvoice:getPreCachedVoicePath', async (event, voiceId: string) => {
|
||||
try {
|
||||
const cachedPath = getPreCachedVoicePath(voiceId);
|
||||
return { success: true, path: cachedPath };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 获取预缓存路径失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 检查所有音色是否已缓存
|
||||
ipcMain.handle('aliyun:cosyvoice:isAllVoicesCached', async (event) => {
|
||||
try {
|
||||
const allCached = isAllVoicesCached();
|
||||
return { success: true, allCached };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 检查缓存状态失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 清除预缓存
|
||||
ipcMain.handle('aliyun:cosyvoice:clearPreCache', async (event) => {
|
||||
try {
|
||||
clearPreCache();
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 清除预缓存失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 音量检测 - 检测音频中的声音片段
|
||||
ipcMain.handle('audio:detectVolume', async (event, params) => {
|
||||
try {
|
||||
// params: { audioPath, config?: { silenceThreshold, minSilenceDuration, minSoundDuration } }
|
||||
logger.info('[Audio:IPC] 开始音量检测', { audioPath: params.audioPath });
|
||||
|
||||
const result = await detectAudioSegments(params.audioPath, params.config || {});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[Audio:IPC] 音量检测失败', error);
|
||||
return {
|
||||
success: false,
|
||||
segments: [],
|
||||
totalDuration: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 音量检测 - 根据音频片段分配文本时间
|
||||
ipcMain.handle('audio:allocateText', async (event, params) => {
|
||||
try {
|
||||
// params: { text, segments }
|
||||
logger.info('[Audio:IPC] 开始文本时间分配', {
|
||||
textLength: params.text?.length,
|
||||
segmentCount: params.segments?.length
|
||||
});
|
||||
|
||||
// 🔧 验证segments数据完整性
|
||||
if (!params.segments || params.segments.length === 0) {
|
||||
logger.warn('[Audio:IPC] 警告:segments为空');
|
||||
return { success: false, error: 'segments数组为空' };
|
||||
}
|
||||
|
||||
// 检查第一个和最后一个segment的结构
|
||||
const firstSegment = params.segments[0];
|
||||
const lastSegment = params.segments[params.segments.length - 1];
|
||||
|
||||
if (!firstSegment || typeof firstSegment.startTime !== 'number' || typeof firstSegment.endTime !== 'number') {
|
||||
logger.error('[Audio:IPC] 第一个segment数据异常:', firstSegment);
|
||||
return { success: false, error: `第一个segment数据异常: ${JSON.stringify(firstSegment)}` };
|
||||
}
|
||||
|
||||
if (!lastSegment || typeof lastSegment.startTime !== 'number' || typeof lastSegment.endTime !== 'number') {
|
||||
logger.error('[Audio:IPC] 最后一个segment数据异常:', lastSegment);
|
||||
return { success: false, error: `最后一个segment数据异常: ${JSON.stringify(lastSegment)}` };
|
||||
}
|
||||
|
||||
logger.info('[Audio:IPC] segments验证通过,准备分配文本');
|
||||
|
||||
const result = allocateTextToSegments(params.text, params.segments);
|
||||
return { success: true, subtitles: result };
|
||||
} catch (error: any) {
|
||||
logger.error('[Audio:IPC] 文本时间分配失败', error);
|
||||
logger.error('[Audio:IPC] 完整错误堆栈:', error.stack);
|
||||
return { success: false, error: error.message || error.toString() };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 阿里云 OSS 文件上传模块
|
||||
* 使用官方 ali-oss SDK
|
||||
* 文档: https://help.aliyun.com/zh/oss/user-guide/simple-upload
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import logger from '../log/main';
|
||||
import { withoutUnsupportedProxy } from '../httpClient';
|
||||
|
||||
// 动态导入 ali-oss
|
||||
let OSS: any = null;
|
||||
|
||||
async function getOssClient() {
|
||||
if (!OSS) {
|
||||
try {
|
||||
// 尝试动态导入 ali-oss
|
||||
OSS = await import('ali-oss').then((m: any) => m.default || m);
|
||||
} catch (e) {
|
||||
logger.error('[Aliyun:OSS] ali-oss 模块未安装,使用 HTTP API');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return OSS;
|
||||
}
|
||||
|
||||
export interface OssConfig {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
bucket: string;
|
||||
region: string; // 如 oss-cn-beijing
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface OssUploadResult {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到阿里云OSS
|
||||
*/
|
||||
export async function uploadToOss(
|
||||
filePath: string,
|
||||
config: OssConfig,
|
||||
objectKey?: string
|
||||
): Promise<OssUploadResult> {
|
||||
try {
|
||||
config = {
|
||||
accessKeyId: (config.accessKeyId || '').trim(),
|
||||
accessKeySecret: (config.accessKeySecret || '').trim(),
|
||||
bucket: (config.bucket || '').trim(),
|
||||
region: (config.region || '').trim(),
|
||||
endpoint: (config.endpoint || '').trim(),
|
||||
};
|
||||
|
||||
if (config.endpoint && !config.endpoint.startsWith('http')) {
|
||||
config.endpoint = `https://${config.endpoint}`;
|
||||
}
|
||||
|
||||
logger.info('[Aliyun:OSS] 开始上传文件', { filePath, bucket: config.bucket, region: config.region });
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { success: false, error: `文件不存在: ${filePath}` };
|
||||
}
|
||||
|
||||
// 生成ObjectKey(如果未指定)
|
||||
if (!objectKey) {
|
||||
const ext = path.extname(filePath);
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
objectKey = `audio/${timestamp}_${random}${ext}`;
|
||||
}
|
||||
|
||||
// 尝试使用 ali-oss SDK
|
||||
const OssClass = await getOssClient();
|
||||
|
||||
if (OssClass) {
|
||||
const clientConfig: any = {
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret: config.accessKeySecret,
|
||||
bucket: config.bucket,
|
||||
secure: true,
|
||||
};
|
||||
|
||||
if (config.endpoint) {
|
||||
clientConfig.endpoint = config.endpoint;
|
||||
} else if (config.region) {
|
||||
clientConfig.region = config.region;
|
||||
} else {
|
||||
return { success: false, error: '缺少 region 配置' };
|
||||
}
|
||||
|
||||
logger.info('[Aliyun:OSS] SDK clientConfig:', {
|
||||
bucket: clientConfig.bucket,
|
||||
bucketType: typeof clientConfig.bucket,
|
||||
region: clientConfig.region,
|
||||
endpoint: clientConfig.endpoint,
|
||||
});
|
||||
|
||||
logger.info('[Aliyun:OSS] 使用 SDK 上传', { objectKey, region: config.region, endpoint: config.endpoint });
|
||||
|
||||
const client = new OssClass(clientConfig);
|
||||
|
||||
try {
|
||||
const result = await client.put(objectKey, filePath);
|
||||
|
||||
if (result.res.status === 200) {
|
||||
// 生成签名 URL(有效期1小时),供 FunASR API 访问
|
||||
const signedUrl = client.signatureUrl(objectKey, {
|
||||
expires: 3600, // 1小时有效期
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
logger.info('[Aliyun:OSS] SDK上传成功,已生成签名URL', {
|
||||
objectKey,
|
||||
signedUrl: signedUrl.substring(0, 100) + '...' // 只记录部分URL
|
||||
});
|
||||
return { success: true, url: signedUrl };
|
||||
} else {
|
||||
logger.error('[Aliyun:OSS] SDK上传失败', result);
|
||||
throw new Error(`上传失败: HTTP ${result.res.status}`);
|
||||
}
|
||||
} catch (sdkError: any) {
|
||||
logger.warn('[Aliyun:OSS] SDK上传失败,降级到 HTTP API', { error: sdkError.message });
|
||||
// SDK 失败,降级到 HTTP API 方式
|
||||
return await uploadToOssViaHttp(filePath, config, objectKey);
|
||||
}
|
||||
} else {
|
||||
// ali-oss 未安装,使用 HTTP API 方式
|
||||
return await uploadToOssViaHttp(filePath, config, objectKey);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:OSS] 上传异常', error);
|
||||
|
||||
let errorMsg = error.message || '未知错误';
|
||||
if (error.code) {
|
||||
errorMsg = `${error.code}: ${error.message}`;
|
||||
}
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HTTP API 方式上传(备用方案)
|
||||
*/
|
||||
function generateSignedUrl(config: OssConfig, objectKey: string, expires: number): string {
|
||||
const expireTime = Math.floor(Date.now() / 1000) + expires;
|
||||
const resource = `/${config.bucket}/${objectKey}`;
|
||||
const stringToSign = `GET\n\n\n${expireTime}\n${resource}`;
|
||||
const hmac = crypto.createHmac('sha1', config.accessKeySecret);
|
||||
hmac.update(stringToSign);
|
||||
const signature = encodeURIComponent(hmac.digest('base64'));
|
||||
return `https://${config.bucket}.${config.region}.aliyuncs.com/${objectKey}?OSSAccessKeyId=${config.accessKeyId}&Expires=${expireTime}&Signature=${signature}`;
|
||||
}
|
||||
|
||||
async function uploadToOssViaHttp(
|
||||
filePath: string,
|
||||
config: OssConfig,
|
||||
objectKey: string
|
||||
): Promise<OssUploadResult> {
|
||||
try {
|
||||
logger.info('[Aliyun:OSS] 使用 HTTP API 上传');
|
||||
|
||||
const fileContent = fs.readFileSync(filePath);
|
||||
const fileSize = fileContent.length;
|
||||
|
||||
// 确定Content-Type
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.aac': 'audio/aac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac',
|
||||
};
|
||||
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
||||
|
||||
// 生成Date
|
||||
const date = new Date().toUTCString();
|
||||
|
||||
// 构建URL
|
||||
const host = `${config.bucket}.${config.region}.aliyuncs.com`;
|
||||
const resource = `/${config.bucket}/${objectKey}`;
|
||||
|
||||
// 构建StringToSign (V1签名)
|
||||
const stringToSign = [
|
||||
'PUT',
|
||||
'', // Content-MD5 留空
|
||||
contentType,
|
||||
date,
|
||||
resource
|
||||
].join('\n');
|
||||
|
||||
// 计算HMAC-SHA1签名
|
||||
const hmac = crypto.createHmac('sha1', config.accessKeySecret);
|
||||
hmac.update(stringToSign);
|
||||
const signature = hmac.digest('base64');
|
||||
|
||||
// 发送上传请求
|
||||
const url = `https://${host}/${objectKey}`;
|
||||
|
||||
const response = await axios.put(url, fileContent, withoutUnsupportedProxy({
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileSize.toString(),
|
||||
'Date': date,
|
||||
'Authorization': `OSS ${config.accessKeyId}:${signature}`,
|
||||
},
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
timeout: 120000
|
||||
}));
|
||||
|
||||
if (response.status === 200) {
|
||||
const unsignedUrl = url;
|
||||
const signedUrl = generateSignedUrl(config, objectKey, 3600);
|
||||
logger.info('[Aliyun:OSS] HTTP API上传成功', { url: unsignedUrl, signedUrl: signedUrl.substring(0, 100) + '...' });
|
||||
return { success: true, url: signedUrl };
|
||||
} else {
|
||||
logger.error('[Aliyun:OSS] HTTP API上传失败', { status: response.status });
|
||||
return { success: false, error: `上传失败: HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:OSS] HTTP API上传异常', error);
|
||||
|
||||
let errorMsg = error.message || '未知错误';
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data;
|
||||
if (typeof data === 'string' && data.includes('<Message>')) {
|
||||
const match = data.match(/<Message>(.*?)<\/Message>/);
|
||||
if (match) {
|
||||
errorMsg = match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置中获取OSS配置
|
||||
*/
|
||||
export function getOssConfigFromProvider(providerData: any): OssConfig | null {
|
||||
if (!providerData?.ossAccessKeyId || !providerData?.ossAccessKeySecret ||
|
||||
!providerData?.ossBucket || !providerData?.ossRegion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessKeyId: providerData.ossAccessKeyId,
|
||||
accessKeySecret: providerData.ossAccessKeySecret,
|
||||
bucket: providerData.ossBucket,
|
||||
region: providerData.ossRegion,
|
||||
endpoint: providerData.ossEndpoint
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
/**
|
||||
* 阿里云语音服务 - 渲染进程API
|
||||
*/
|
||||
|
||||
|
||||
export default {
|
||||
cosyvoice: {
|
||||
/**
|
||||
* 语音合成
|
||||
*/
|
||||
async synthesize(params: {
|
||||
text: string;
|
||||
config: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
format?: string;
|
||||
sampleRate?: number;
|
||||
saveToPreCache?: string; // 如果提供了voiceId,自动保存到预缓存目录
|
||||
};
|
||||
}) {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:synthesize', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取可用音色列表
|
||||
*/
|
||||
async getVoices(model: string) {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:getVoices', model);
|
||||
},
|
||||
|
||||
/**
|
||||
* 预缓存所有系统默认音色
|
||||
*/
|
||||
async preCacheSystemVoices(apiKey: string, force: boolean = false) {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:preCacheSystemVoices', { apiKey, force });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取预缓存的音色路径
|
||||
*/
|
||||
async getPreCachedVoicePath(voiceId: string) {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:getPreCachedVoicePath', voiceId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查所有音色是否已缓存
|
||||
*/
|
||||
async isAllVoicesCached() {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:isAllVoicesCached');
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除预缓存
|
||||
*/
|
||||
async clearPreCache() {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:clearPreCache');
|
||||
}
|
||||
},
|
||||
|
||||
funasr: {
|
||||
/**
|
||||
* 语音识别(本地文件,自动上传到 OSS)
|
||||
*/
|
||||
async recognize(params: {
|
||||
audioPath: string;
|
||||
config: {
|
||||
apiKey: string;
|
||||
ossConfig?: {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
};
|
||||
model?: string;
|
||||
};
|
||||
}) {
|
||||
return await ipcRenderer.invoke('aliyun:funasr:recognize', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 语音识别(直接使用 URL,无需上传 OSS)
|
||||
*/
|
||||
async recognizeFromUrl(params: {
|
||||
audioUrl: string;
|
||||
apiKey: string;
|
||||
}) {
|
||||
return await ipcRenderer.invoke('aliyun:funasr:recognizeFromUrl', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检测音频格式
|
||||
*/
|
||||
async detectFormat(audioPath: string) {
|
||||
return await ipcRenderer.invoke('aliyun:funasr:detectFormat', audioPath);
|
||||
}
|
||||
},
|
||||
|
||||
audio: {
|
||||
/**
|
||||
* 检测音频中的声音片段(基于音量)
|
||||
*/
|
||||
async detectVolume(params: {
|
||||
audioPath: string;
|
||||
config?: {
|
||||
silenceThreshold?: number; // 静音阈值(dB),默认 -40dB
|
||||
minSilenceDuration?: number; // 最小静音时长(秒),默认 0.5s
|
||||
minSoundDuration?: number; // 最小有声时长(秒),默认 0.3s
|
||||
};
|
||||
}) {
|
||||
return await ipcRenderer.invoke('audio:detectVolume', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据音频片段分配文本时间
|
||||
*/
|
||||
async allocateText(params: {
|
||||
text: string;
|
||||
segments: Array<{
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
duration: number;
|
||||
}>;
|
||||
}) {
|
||||
return await ipcRenderer.invoke('audio:allocateText', params);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 阿里云 FunAudio 录音文件识别
|
||||
* 使用HTTP API异步调用,比实时识别更快
|
||||
* 文档: https://help.aliyun.com/zh/model-studio/funauidio-asr-recorded-speech-recognition-python-sdk
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import logger from '../log/main';
|
||||
|
||||
export interface TranscriptionConfig {
|
||||
apiKey: string;
|
||||
model?: string; // 默认 fun-asr
|
||||
}
|
||||
|
||||
export interface TranscriptionSentence {
|
||||
text: string;
|
||||
beginTime: number;
|
||||
endTime: number;
|
||||
words?: Array<{
|
||||
text: string;
|
||||
beginTime: number;
|
||||
endTime: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TranscriptionResult {
|
||||
success: boolean;
|
||||
text?: string;
|
||||
sentences?: TranscriptionSentence[];
|
||||
error?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||||
|
||||
/**
|
||||
* 验证 API Key 是否有效
|
||||
* @returns { valid: boolean, error?: string, models?: string[] }
|
||||
*/
|
||||
async function validateApiKey(apiKey: string): Promise<{ valid: boolean; error?: string; models?: string[] }> {
|
||||
try {
|
||||
logger.info('[FunAudio] 验证 API Key...');
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/services/audio/asr/models`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
const models = response.data?.data?.map((m: any) => m.model_id) || [];
|
||||
logger.info('[FunAudio] ✅ API Key 有效', { availableModels: models });
|
||||
return { valid: true, models };
|
||||
}
|
||||
|
||||
return { valid: false, error: 'API 响应异常' };
|
||||
} catch (error: any) {
|
||||
logger.error('[FunAudio] ❌ API Key 验证失败', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
});
|
||||
return {
|
||||
valid: false,
|
||||
error: error.response?.data?.message || error.message || 'API Key 无效'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交异步转写任务
|
||||
*/
|
||||
async function submitTranscriptionTask(
|
||||
fileUrl: string, // 改为单数:只接受单个URL
|
||||
config: TranscriptionConfig
|
||||
): Promise<{ success: boolean; taskId?: string; error?: string }> {
|
||||
try {
|
||||
const model = config.model || 'qwen3-asr-flash-filetrans'; // 🔧 修复:默认使用 qwen3-asr-flash-filetrans 模型
|
||||
logger.info('[FunAudio] 提交转写任务', { fileUrl, model });
|
||||
|
||||
// 🔧 不同模型使用不同的参数格式
|
||||
const inputParam = model === 'qwen3-asr-flash-filetrans'
|
||||
? { file_url: fileUrl } // qwen3 模型使用 file_url(单数)
|
||||
: { file_urls: [fileUrl] }; // fun-asr 模型使用 file_urls(数组)
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/services/audio/asr/transcription`,
|
||||
{
|
||||
model: model,
|
||||
input: inputParam,
|
||||
parameters: {
|
||||
channel_id: [0], // 音频通道ID
|
||||
enable_itn: false // 是否启用逆文本归一化
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable' // 异步调用
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data?.output?.task_id) {
|
||||
logger.info('[FunAudio] 任务提交成功', { taskId: response.data.output.task_id });
|
||||
return { success: true, taskId: response.data.output.task_id };
|
||||
} else {
|
||||
logger.error('[FunAudio] 任务提交失败', response.data);
|
||||
return { success: false, error: response.data?.message || '任务提交失败' };
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
// 🔧 增强错误日志:记录完整的请求和响应信息
|
||||
logger.error('[FunAudio] 任务提交异常', {
|
||||
// HTTP 状态信息
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
|
||||
// 完整的错误响应体(这是关键!)
|
||||
responseData: error.response?.data,
|
||||
|
||||
// 请求信息(用于调试)
|
||||
requestUrl: error.config?.url,
|
||||
requestMethod: error.config?.method,
|
||||
requestData: error.config?.data,
|
||||
requestHeaders: {
|
||||
Authorization: error.config?.headers?.Authorization ? '(已隐藏)' : undefined,
|
||||
'X-DashScope-Async': error.config?.headers?.['X-DashScope-Async']
|
||||
},
|
||||
|
||||
// 原始错误信息
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack
|
||||
});
|
||||
|
||||
// 尝试从响应中提取更详细的错误信息
|
||||
const responseData = error.response?.data;
|
||||
let errorMsg = '未知错误';
|
||||
|
||||
if (responseData) {
|
||||
// 阿里云可能返回的错误格式
|
||||
errorMsg = responseData.message
|
||||
|| responseData.error_message
|
||||
|| responseData.msg
|
||||
|| JSON.stringify(responseData);
|
||||
} else {
|
||||
errorMsg = error.message || '未知错误';
|
||||
}
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
*/
|
||||
async function queryTaskStatus(
|
||||
taskId: string,
|
||||
apiKey: string
|
||||
): Promise<{ status: string; output?: any; error?: string; code?: string }> {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/tasks/${taskId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
// 🔧 添加完整响应日志(已注释以减少日志)
|
||||
// logger.info('[FunAudio] 任务查询响应', {
|
||||
// taskId,
|
||||
// responseStatus: response.status,
|
||||
// responseData: JSON.stringify(response.data, null, 2)
|
||||
// });
|
||||
|
||||
const status = response.data?.output?.task_status || 'UNKNOWN';
|
||||
const errorMessage = response.data?.output?.message || response.data?.message;
|
||||
const errorCode = response.data?.output?.code || response.data?.code;
|
||||
|
||||
return {
|
||||
status,
|
||||
output: response.data?.output,
|
||||
error: errorMessage,
|
||||
code: errorCode
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[FunAudio] 查询任务状态异常', error);
|
||||
return { status: 'ERROR', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并解析转写结果
|
||||
*/
|
||||
async function downloadTranscriptionResult(resultUrl: string): Promise<any> {
|
||||
try {
|
||||
const response = await axios.get(resultUrl, { timeout: 30000 });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
logger.error('[FunAudio] 下载结果失败', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待任务完成并获取结果
|
||||
*/
|
||||
async function waitForTaskCompletion(
|
||||
taskId: string,
|
||||
apiKey: string,
|
||||
maxWaitSeconds: number = 300
|
||||
): Promise<{ success: boolean; results?: any[]; error?: string }> {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 2000; // 2秒轮询一次
|
||||
|
||||
while ((Date.now() - startTime) < maxWaitSeconds * 1000) {
|
||||
const { status, output, error } = await queryTaskStatus(taskId, apiKey);
|
||||
|
||||
// logger.info('[FunAudio] 任务状态', { taskId, status }); // 注释以减少日志
|
||||
|
||||
if (status === 'SUCCEEDED') {
|
||||
// 任务成功,获取结果
|
||||
logger.info('[FunAudio] 任务成功,获取结果', { output: JSON.stringify(output, null, 2) });
|
||||
|
||||
const result = output?.result; // 🔧 修复:使用单数 result
|
||||
const parsedResults = [];
|
||||
|
||||
if (result?.transcription_url) {
|
||||
const transcription = await downloadTranscriptionResult(result.transcription_url);
|
||||
if (transcription) {
|
||||
parsedResults.push(transcription);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[FunAudio] 解析结果完成', { parsedResultsCount: parsedResults.length });
|
||||
return { success: true, results: parsedResults };
|
||||
}
|
||||
|
||||
if (status === 'FAILED') {
|
||||
logger.error('[FunAudio] ❌ 任务失败详情', {
|
||||
taskId,
|
||||
status,
|
||||
error,
|
||||
output: JSON.stringify(output, null, 2)
|
||||
});
|
||||
return { success: false, error: error || '任务失败' };
|
||||
}
|
||||
|
||||
// 等待后继续轮询
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
return { success: false, error: '任务超时' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别录音文件(主接口)
|
||||
* @param fileUrl 音频文件的公网URL
|
||||
* @param config 配置
|
||||
*/
|
||||
export async function recognizeRecordedAudio(
|
||||
fileUrl: string,
|
||||
config: TranscriptionConfig
|
||||
): Promise<TranscriptionResult> {
|
||||
try {
|
||||
logger.info('[FunAudio] 开始录音识别', { fileUrl, model: config.model });
|
||||
|
||||
// 0. 验证 API Key(暂时禁用,因为阿里云不支持该端点)
|
||||
// const validation = await validateApiKey(config.apiKey);
|
||||
// if (!validation.valid) {
|
||||
// logger.error('[FunAudio] ❌ API Key 验证失败,无法继续', { error: validation.error });
|
||||
// return { success: false, error: `API Key 无效: ${validation.error}` };
|
||||
// }
|
||||
|
||||
// 1. 提交任务
|
||||
const submitResult = await submitTranscriptionTask(fileUrl, config); // 传递单个URL,不再使用数组
|
||||
if (!submitResult.success || !submitResult.taskId) {
|
||||
return { success: false, error: submitResult.error || '任务提交失败' };
|
||||
}
|
||||
|
||||
// 2. 等待任务完成
|
||||
const waitResult = await waitForTaskCompletion(submitResult.taskId, config.apiKey, 300);
|
||||
if (!waitResult.success || !waitResult.results?.length) {
|
||||
return { success: false, error: waitResult.error || '未获取到识别结果', taskId: submitResult.taskId };
|
||||
}
|
||||
|
||||
// 3. 解析结果
|
||||
const transcription = waitResult.results[0];
|
||||
const sentences: TranscriptionSentence[] = [];
|
||||
let fullText = '';
|
||||
|
||||
if (transcription?.transcripts) {
|
||||
for (const transcript of transcription.transcripts) {
|
||||
if (transcript.sentences) {
|
||||
for (const sentence of transcript.sentences) {
|
||||
sentences.push({
|
||||
text: sentence.text || '',
|
||||
beginTime: sentence.begin_time || 0,
|
||||
endTime: sentence.end_time || 0,
|
||||
words: sentence.words?.map((w: any) => ({
|
||||
text: w.text || '',
|
||||
beginTime: w.begin_time || 0,
|
||||
endTime: w.end_time || 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
if (transcript.text) {
|
||||
fullText += transcript.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[FunAudio] ✅ 识别完成', {
|
||||
sentenceCount: sentences.length,
|
||||
textLength: fullText.length
|
||||
});
|
||||
|
||||
// 🔧 修复:去除字幕时间轴重叠
|
||||
// 确保上一条字幕的 endTime 不会晚于下一条字幕的 beginTime
|
||||
const MIN_GAP_MS = 50; // 最小间隔 50 毫秒
|
||||
for (let i = 0; i < sentences.length - 1; i++) {
|
||||
const current = sentences[i];
|
||||
const next = sentences[i + 1];
|
||||
|
||||
if (current.endTime >= next.beginTime) {
|
||||
const oldEndTime = current.endTime;
|
||||
// 将当前字幕的结束时间调整为下一条字幕开始时间之前
|
||||
current.endTime = Math.max(current.beginTime + MIN_GAP_MS, next.beginTime - MIN_GAP_MS);
|
||||
// logger.info('[FunAudio] 修复时间轴重叠', { // 注释以减少日志
|
||||
// index: i,
|
||||
// text: current.text.substring(0, 20) + '...',
|
||||
// oldEndTime,
|
||||
// newEndTime: current.endTime,
|
||||
// nextBeginTime: next.beginTime
|
||||
// });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: fullText,
|
||||
sentences,
|
||||
taskId: submitResult.taskId
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[FunAudio] 识别异常', error);
|
||||
return { success: false, error: error.message || '未知错误' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { synthesizeSpeech } from './cosyvoice';
|
||||
import { Log } from '../log/main';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { app } from 'electron';
|
||||
|
||||
/**
|
||||
* 系统默认音色列表(与前端保持一致)
|
||||
*/
|
||||
const SYSTEM_VOICES = [
|
||||
{ id: 'sys_Cherry', title: '芊悦(普通话女声)', aliyunVoiceId: 'Cherry' },
|
||||
{ id: 'sys_Serena', title: '苏瑶(普通话女声)', aliyunVoiceId: 'Serena' },
|
||||
{ id: 'sys_Ethan', title: '晨煦(普通话男声)', aliyunVoiceId: 'Ethan' },
|
||||
{ id: 'sys_Chelsie', title: '千雪(二次元女声)', aliyunVoiceId: 'Chelsie' },
|
||||
{ id: 'sys_Momo', title: '茉兔(活泼女声)', aliyunVoiceId: 'Momo' },
|
||||
{ id: 'sys_Vivian', title: '十三(个性女声)', aliyunVoiceId: 'Vivian' },
|
||||
{ id: 'sys_Moon', title: '月白(率性男声)', aliyunVoiceId: 'Moon' },
|
||||
{ id: 'sys_Maia', title: '四月(知性女声)', aliyunVoiceId: 'Maia' },
|
||||
{ id: 'sys_Kai', title: '凯(沉浸男声)', aliyunVoiceId: 'Kai' },
|
||||
{ id: 'sys_Nofish', title: '不吃鱼(特色男声)', aliyunVoiceId: 'Nofish' },
|
||||
{ id: 'sys_Bella', title: '萌宝(萝莉女声)', aliyunVoiceId: 'Bella' },
|
||||
{ id: 'sys_Jennifer', title: '詹妮弗(美语女声)', aliyunVoiceId: 'Jennifer' },
|
||||
{ id: 'sys_Ryan', title: '甜茶(张力男声)', aliyunVoiceId: 'Ryan' },
|
||||
{ id: 'sys_Katerina', title: '卡捷琳娜(御姐女声)', aliyunVoiceId: 'Katerina' },
|
||||
{ id: 'sys_Aiden', title: '艾登(美语男声)', aliyunVoiceId: 'Aiden' },
|
||||
{ id: 'sys_Arthur', title: '徐大爷(故事男声)', aliyunVoiceId: 'Arthur' },
|
||||
{ id: 'sys_Bellona', title: '燕铮莺(洪亮女声)', aliyunVoiceId: 'Bellona' },
|
||||
{ id: 'sys_Bunny', title: '萌小姬(萌系女声)', aliyunVoiceId: 'Bunny' },
|
||||
{ id: 'sys_Mia', title: '乖小妹(温顺女声)', aliyunVoiceId: 'Mia' },
|
||||
{ id: 'sys_Mochi', title: '沙小弥(早慧童声)', aliyunVoiceId: 'Mochi' },
|
||||
{ id: 'sys_Neil', title: '阿闻(新闻男声)', aliyunVoiceId: 'Neil' },
|
||||
{ id: 'sys_Nini', title: '邻家妹妹(甜美女声)', aliyunVoiceId: 'Nini' },
|
||||
{ id: 'sys_Ebona', title: '诡婆婆(惊悚女声)', aliyunVoiceId: 'Ebona' },
|
||||
{ id: 'sys_Seren', title: '小婉(助眠女声)', aliyunVoiceId: 'Seren' },
|
||||
{ id: 'sys_Pip', title: '顽屁小孩(淘气童声)', aliyunVoiceId: 'Pip' },
|
||||
{ id: 'sys_Stella', title: '少女阿月(元气女声)', aliyunVoiceId: 'Stella' },
|
||||
{ id: 'sys_Vincent', title: '田叔(烟嗓男声)', aliyunVoiceId: 'Vincent' },
|
||||
{ id: 'sys_Radio_Gol', title: '拉迪奥·戈尔(足球解说)', aliyunVoiceId: 'Radio Gol' },
|
||||
{ id: 'sys_Jada', title: '上海-阿珍(上海话女声)', aliyunVoiceId: 'Jada' },
|
||||
{ id: 'sys_Dylan', title: '北京-晓东(北京话男声)', aliyunVoiceId: 'Dylan' },
|
||||
{ id: 'sys_Li', title: '南京-老李(南京话男声)', aliyunVoiceId: 'Li' },
|
||||
{ id: 'sys_Marcus', title: '陕西-秦川(陕西话男声)', aliyunVoiceId: 'Marcus' },
|
||||
{ id: 'sys_Roy', title: '闽南-阿杰(闽南语男声)', aliyunVoiceId: 'Roy' },
|
||||
{ id: 'sys_Peter', title: '天津-李彼得(天津话男声)', aliyunVoiceId: 'Peter' },
|
||||
{ id: 'sys_Sunny', title: '四川-晴儿(四川话女声)', aliyunVoiceId: 'Sunny' },
|
||||
{ id: 'sys_Eric', title: '四川-程川(四川话男声)', aliyunVoiceId: 'Eric' },
|
||||
{ id: 'sys_Rocky', title: '粤语-阿强(粤语男声)', aliyunVoiceId: 'Rocky' },
|
||||
{ id: 'sys_Kiki', title: '粤语-阿清(粤语女声)', aliyunVoiceId: 'Kiki' },
|
||||
{ id: 'sys_Bodega', title: '博德加(西语男声)', aliyunVoiceId: 'Bodega' },
|
||||
{ id: 'sys_Sonrisa', title: '索尼莎(西语女声)', aliyunVoiceId: 'Sonrisa' },
|
||||
{ id: 'sys_Alek', title: '阿列克(俄语男声)', aliyunVoiceId: 'Alek' },
|
||||
{ id: 'sys_Dolce', title: '多尔切(意语男声)', aliyunVoiceId: 'Dolce' },
|
||||
{ id: 'sys_Sohee', title: '素熙(韩语女声)', aliyunVoiceId: 'Sohee' },
|
||||
{ id: 'sys_Ono_Anna', title: '小野杏(日语女声)', aliyunVoiceId: 'Ono Anna' },
|
||||
{ id: 'sys_Lenn', title: '莱恩(德语男声)', aliyunVoiceId: 'Lenn' },
|
||||
{ id: 'sys_Emilien', title: '埃米尔安(法语男声)', aliyunVoiceId: 'Emilien' },
|
||||
{ id: 'sys_Andre', title: '安德雷(磁性男声)', aliyunVoiceId: 'Andre' },
|
||||
];
|
||||
|
||||
const LEGACY_SYSTEM_VOICE_FILES = [
|
||||
'sys_longanyang.mp3',
|
||||
'sys_longanhuan.mp3',
|
||||
'sys_longanrou_v3.mp3',
|
||||
'sys_longhan_v3.mp3',
|
||||
'sys_longhuhu_v3.mp3',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取预缓存目录路径
|
||||
*/
|
||||
export function getPreCacheDir(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const cacheDir = path.join(userDataPath, 'voice-preview-cache');
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
|
||||
return cacheDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定音色的预缓存文件路径
|
||||
*/
|
||||
export function getPreCachedVoicePath(voiceId: string): string | null {
|
||||
const cacheDir = getPreCacheDir();
|
||||
const filePath = path.join(cacheDir, `${voiceId}.wav`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearLegacyPreCacheFiles(): void {
|
||||
const cacheDir = getPreCacheDir();
|
||||
|
||||
for (const file of LEGACY_SYSTEM_VOICE_FILES) {
|
||||
const filePath = path.join(cacheDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
Log.info('voicePreCache.legacyCleared', { filePath });
|
||||
} catch (error: any) {
|
||||
Log.error('voicePreCache.legacyClearError', { filePath, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有默认音色是否已缓存
|
||||
*/
|
||||
export function isAllVoicesCached(): boolean {
|
||||
return SYSTEM_VOICES.every(voice => {
|
||||
const cachedPath = getPreCachedVoicePath(voice.id);
|
||||
return cachedPath !== null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预生成所有默认音色的试听音频
|
||||
* @param apiKey 阿里云API Key
|
||||
* @param force 是否强制重新生成(即使已存在)
|
||||
*/
|
||||
export async function preCacheSystemVoices(apiKey: string, force: boolean = false): Promise<void> {
|
||||
if (!apiKey) {
|
||||
Log.warn('voicePreCache.preCacheSystemVoices', '缺少阿里云API Key,跳过预缓存');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('voicePreCache.preCacheSystemVoices.start', {
|
||||
totalVoices: SYSTEM_VOICES.length,
|
||||
force
|
||||
});
|
||||
|
||||
const cacheDir = getPreCacheDir();
|
||||
let cachedCount = 0;
|
||||
let generatedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const voice of SYSTEM_VOICES) {
|
||||
try {
|
||||
const filePath = path.join(cacheDir, `${voice.id}.wav`);
|
||||
|
||||
// 如果文件已存在且不强制重新生成,则跳过
|
||||
if (!force && fs.existsSync(filePath)) {
|
||||
cachedCount++;
|
||||
Log.info('voicePreCache.alreadyCached', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
filePath
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成试听文本
|
||||
const previewText = `你好,我是${voice.title.split('(')[0]},很高兴为您服务。`;
|
||||
|
||||
Log.info('voicePreCache.generating', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
text: previewText
|
||||
});
|
||||
|
||||
// 调用阿里云API生成音频
|
||||
const result = await synthesizeSpeech(previewText, {
|
||||
apiKey: apiKey,
|
||||
model: 'qwen3-tts-flash',
|
||||
voice: voice.aliyunVoiceId,
|
||||
format: 'wav',
|
||||
sampleRate: 24000,
|
||||
instruction: '请用自然、清晰、适合短视频配音的风格朗读。',
|
||||
outputPath: filePath
|
||||
});
|
||||
|
||||
if (result.success && result.audioPath) {
|
||||
generatedCount++;
|
||||
Log.info('voicePreCache.generated', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
audioPath: result.audioPath,
|
||||
fileSize: fs.existsSync(result.audioPath) ? fs.statSync(result.audioPath).size : 0
|
||||
});
|
||||
} else {
|
||||
errorCount++;
|
||||
Log.error('voicePreCache.generateFailed', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
|
||||
// 添加短暂延迟,避免API请求过快
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
} catch (error: any) {
|
||||
errorCount++;
|
||||
Log.error('voicePreCache.error', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Log.info('voicePreCache.preCacheSystemVoices.complete', {
|
||||
total: SYSTEM_VOICES.length,
|
||||
alreadyCached: cachedCount,
|
||||
generated: generatedCount,
|
||||
errors: errorCount
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有预缓存的音色文件
|
||||
*/
|
||||
export function clearPreCache(): void {
|
||||
const cacheDir = getPreCacheDir();
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
const files = fs.readdirSync(cacheDir);
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(cacheDir, file);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
Log.info('voicePreCache.cleared', { filePath });
|
||||
} catch (error: any) {
|
||||
Log.error('voicePreCache.clearError', { filePath, error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从应用资源目录复制预缓存文件
|
||||
* 用于首次启动或升级时,支持开发环境和打包后的环境
|
||||
*/
|
||||
export async function copyPreCacheFromResources(): Promise<boolean> {
|
||||
try {
|
||||
// 尝试找到应用内的预缓存资源目录
|
||||
const possibleResourcePaths = [
|
||||
// 打包后的应用(electron-builder extraResources)
|
||||
path.join(app.getAppPath(), '../voice-preview-cache'),
|
||||
path.join(app.getAppPath(), 'voice-preview-cache'),
|
||||
// 打包后的应用(asar 格式)
|
||||
path.join(app.getAppPath(), 'voice-preview-cache'),
|
||||
path.join(app.getAppPath().replace(/\.asar$/, '.asar.unpacked'), 'voice-preview-cache'),
|
||||
// 开发环境
|
||||
path.join(process.cwd(), 'electron/resources/voice-preview-cache'),
|
||||
// 备选路径
|
||||
path.join(process.resourcesPath, 'voice-preview-cache'),
|
||||
path.join(process.resourcesPath, '../voice-preview-cache'),
|
||||
];
|
||||
|
||||
Log.info('voicePreCache.searchingResources', {
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
cwd: process.cwd(),
|
||||
possiblePaths: possibleResourcePaths
|
||||
});
|
||||
|
||||
for (const resourcePath of possibleResourcePaths) {
|
||||
try {
|
||||
if (fs.existsSync(resourcePath)) {
|
||||
const files = SYSTEM_VOICES
|
||||
.map(voice => `${voice.id}.wav`)
|
||||
.filter(file => fs.existsSync(path.join(resourcePath, file)));
|
||||
|
||||
if (files.length === 0) {
|
||||
Log.info('voicePreCache.resourcePathEmpty', {
|
||||
resourcePath,
|
||||
allFiles: fs.readdirSync(resourcePath)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = getPreCacheDir();
|
||||
let copiedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const sourceFile = path.join(resourcePath, file);
|
||||
const targetFile = path.join(targetDir, file);
|
||||
|
||||
try {
|
||||
const existed = fs.existsSync(targetFile);
|
||||
fs.copyFileSync(sourceFile, targetFile);
|
||||
copiedCount++;
|
||||
Log.info(existed ? 'voicePreCache.fileUpdated' : 'voicePreCache.fileCopied', {
|
||||
file,
|
||||
sourceFile,
|
||||
targetFile,
|
||||
size: fs.statSync(sourceFile).size
|
||||
});
|
||||
} catch (copyError: any) {
|
||||
Log.error('voicePreCache.copyError', {
|
||||
file,
|
||||
error: copyError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (copiedCount > 0 || files.length > 0) {
|
||||
Log.info('voicePreCache.copyFromResourcesComplete', {
|
||||
resourcePath,
|
||||
totalFiles: files.length,
|
||||
copiedCount,
|
||||
targetDir
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (pathError: any) {
|
||||
Log.debug('voicePreCache.pathCheckError', {
|
||||
resourcePath,
|
||||
error: pathError.message
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 没有找到资源目录,返回 false
|
||||
Log.info('voicePreCache.noResourcesFound', {
|
||||
message: '未找到预缓存资源文件,将自动生成',
|
||||
possiblePaths: possibleResourcePaths
|
||||
});
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
Log.error('voicePreCache.copyFromResourcesError', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const icons = {
|
||||
success:
|
||||
'<svg t="1733817409678" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1488" width="1024" height="1024"><path d="M512 832c-176.448 0-320-143.552-320-320S335.552 192 512 192s320 143.552 320 320-143.552 320-320 320m0-704C300.256 128 128 300.256 128 512s172.256 384 384 384 384-172.256 384-384S723.744 128 512 128" fill="#FFF" p-id="1489"></path><path d="M619.072 429.088l-151.744 165.888-62.112-69.6a32 32 0 1 0-47.744 42.624l85.696 96a32 32 0 0 0 23.68 10.688h0.192c8.96 0 17.536-3.776 23.616-10.4l175.648-192a32 32 0 0 0-47.232-43.2" fill="#FFF" p-id="1490"></path></svg>',
|
||||
error: '<svg t="1733817396560" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1326" width="1024" height="1024"><path d="M512 128C300.8 128 128 300.8 128 512s172.8 384 384 384 384-172.8 384-384S723.2 128 512 128zM512 832c-179.2 0-320-140.8-320-320s140.8-320 320-320 320 140.8 320 320S691.2 832 512 832z" fill="#FFF" p-id="1327"></path><path d="M672 352c-12.8-12.8-32-12.8-44.8 0L512 467.2 396.8 352C384 339.2 364.8 339.2 352 352S339.2 384 352 396.8L467.2 512 352 627.2c-12.8 12.8-12.8 32 0 44.8s32 12.8 44.8 0L512 556.8l115.2 115.2c12.8 12.8 32 12.8 44.8 0s12.8-32 0-44.8L556.8 512l115.2-115.2C684.8 384 684.8 364.8 672 352z" fill="#FFF" p-id="1328"></path></svg>',
|
||||
info: '<svg t="1733992721464" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1357" width="1024" height="1024"><path d="M512 898.71874973C299.30468777 898.71874973 125.28125027 724.69531223 125.28125027 512S299.30468777 125.28125027 512 125.28125027s386.71874973 174.0234375 386.71874973 386.71874973-174.0234375 386.71874973-386.71874973 386.71874973z m0-696.09375c-170.15625027 0-309.37500027 139.21875-309.37500027 309.37500027 0 170.15625027 139.21875 309.37500027 309.37500027 309.37500027 170.15625027 0 309.37500027-139.21875 309.37500027-309.37500027 0-170.15625027-139.21875-309.37500027-309.37500027-309.37500027z" fill="#FFFFFF" p-id="1358"></path><path d="M512 746.59765652a37.96875 37.96875 0 0 1-38.67187473-38.67187554v-221.6953125c0-21.9375 16.76953125-38.67187473 38.67187473-38.67187473 21.90234348 0 38.67187473 16.73437473 38.67187473 38.67187473v221.6953125c0 21.9375-16.76953125 38.67187473-38.67187473 38.67187554zM512 390.81640625a37.96875 37.96875 0 0 1-38.67187473-38.67187473V316.07421902c0-21.9375 16.76953125-38.67187473 38.67187473-38.67187554 21.90234348 0 38.67187473 16.73437473 38.67187473 38.67187554v36.0703125c0 21.9375-18.03515625 38.67187473-38.67187473 38.67187473z" fill="#FFFFFF" p-id="1359"></path></svg>',
|
||||
loading: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
|
||||
<rect fill="#FFFFFF" x="21.5" y="21.5" width="25" height="25" rx="3" ry="3">
|
||||
<animate attributeName="x" calcMode="linear" values="21.5;53.5;53.5;53.5;53.5;21.5;21.5;21.5;21.5" keyTimes="0;0.083;0.25;0.333;0.5;0.583;0.75;0.833;1" dur="1.5" begin="-1.375s" repeatCount="indefinite"></animate>
|
||||
<animate attributeName="y" calcMode="linear" values="21.5;53.5;53.5;53.5;53.5;21.5;21.5;21.5;21.5" keyTimes="0;0.083;0.25;0.333;0.5;0.583;0.75;0.833;1" dur="1.5" begin="-1s" repeatCount="indefinite"></animate>
|
||||
</rect>
|
||||
<rect fill="#FFFFFF" x="21.5" y="53.5" width="25" height="25" rx="3" ry="3">
|
||||
<animate attributeName="x" calcMode="linear" values="21.5;53.5;53.5;53.5;53.5;21.5;21.5;21.5;21.5" keyTimes="0;0.083;0.25;0.333;0.5;0.583;0.75;0.833;1" dur="1.5" begin="-0.875s" repeatCount="indefinite"></animate>
|
||||
<animate attributeName="y" calcMode="linear" values="21.5;53.5;53.5;53.5;53.5;21.5;21.5;21.5;21.5" keyTimes="0;0.083;0.25;0.333;0.5;0.583;0.75;0.833;1" dur="1.5" begin="-0.5s" repeatCount="indefinite"></animate>
|
||||
</rect>
|
||||
<rect fill="#FFFFFF" x="53.5" y="42.919" width="25" height="25" rx="3" ry="3">
|
||||
<animate attributeName="x" calcMode="linear" values="21.5;53.5;53.5;53.5;53.5;21.5;21.5;21.5;21.5" keyTimes="0;0.083;0.25;0.333;0.5;0.583;0.75;0.833;1" dur="1.5" begin="-0.375s" repeatCount="indefinite"></animate>
|
||||
<animate attributeName="y" calcMode="linear" values="21.5;53.5;53.5;53.5;53.5;21.5;21.5;21.5;21.5" keyTimes="0;0.083;0.25;0.333;0.5;0.583;0.75;0.833;1" dur="1.5" begin="0s" repeatCount="indefinite"></animate>
|
||||
</rect>
|
||||
</svg>`,
|
||||
};
|
||||
@@ -0,0 +1,370 @@
|
||||
import iconv from "iconv-lite";
|
||||
import {exec as _exec, spawn} from "node:child_process";
|
||||
import net from "node:net";
|
||||
import util from "node:util";
|
||||
import {AppConfig} from "../../../src/config";
|
||||
import {
|
||||
extraResolveBin,
|
||||
isLinux,
|
||||
isMac,
|
||||
isWin,
|
||||
platformArch,
|
||||
platformName,
|
||||
platformUUID,
|
||||
platformVersion,
|
||||
} from "../../lib/env";
|
||||
import {IconvUtil, ShellUtil, StrUtil} from "../../lib/util";
|
||||
import {Log} from "../log/index";
|
||||
|
||||
const exec = util.promisify(_exec);
|
||||
|
||||
const outputStringConvert = (outputEncoding: "utf8" | "cp936", data: any) => {
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
if (outputEncoding === "utf8") {
|
||||
return data.toString();
|
||||
}
|
||||
let dataEncoding = "binary";
|
||||
if (Buffer.isBuffer(data)) {
|
||||
dataEncoding = IconvUtil.detect(data as any);
|
||||
if ("UTF-8" === dataEncoding) {
|
||||
return data.toString("utf8");
|
||||
}
|
||||
}
|
||||
// dataEncoding UTF-8 cp936
|
||||
// dataEncoding ISO-8859-1 cp936
|
||||
// console.log('dataEncoding', dataEncoding, outputEncoding)
|
||||
return iconv.decode(Buffer.from(data, dataEncoding as any), outputEncoding);
|
||||
};
|
||||
|
||||
const shell = async (
|
||||
command: string,
|
||||
option?: {
|
||||
cwd?: string;
|
||||
outputEncoding?: string;
|
||||
shell?: boolean;
|
||||
}
|
||||
) => {
|
||||
option = Object.assign(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
outputEncoding: isWin ? "cp936" : "utf8",
|
||||
shell: true,
|
||||
},
|
||||
option
|
||||
);
|
||||
const result = await exec(command, {
|
||||
env: {...process.env},
|
||||
shell: option.shell,
|
||||
encoding: "binary",
|
||||
cwd: option["cwd"],
|
||||
} as any);
|
||||
return {
|
||||
stdout: outputStringConvert(option.outputEncoding as any, result.stdout),
|
||||
stderr: outputStringConvert(option.outputEncoding as any, result.stderr),
|
||||
};
|
||||
};
|
||||
|
||||
const spawnShell = async (
|
||||
command: string | string[],
|
||||
option: {
|
||||
stdout?: (data: string, process: any) => void;
|
||||
stderr?: (data: string, process: any) => void;
|
||||
success?: (process: any) => void;
|
||||
error?: (msg: string, exitCode: number, process: any) => void;
|
||||
cwd?: string;
|
||||
outputEncoding?: string;
|
||||
env?: Record<string, any>;
|
||||
shell?: boolean;
|
||||
} | null = null
|
||||
): Promise<{
|
||||
stop: () => Promise<void>;
|
||||
send: (data: any) => void;
|
||||
result: () => Promise<string>;
|
||||
}> => {
|
||||
option = Object.assign(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
outputEncoding: isWin ? "cp936" : "utf8",
|
||||
env: {},
|
||||
shell: true,
|
||||
},
|
||||
option
|
||||
);
|
||||
let commandEntry = "",
|
||||
args = [];
|
||||
if (Array.isArray(command)) {
|
||||
commandEntry = command[0];
|
||||
args = command.slice(1);
|
||||
} else {
|
||||
args = ShellUtil.parseCommandArgs(command);
|
||||
commandEntry = args.shift() as string;
|
||||
}
|
||||
Log.info("App.spawnShell", {
|
||||
commandEntry,
|
||||
args,
|
||||
option: {
|
||||
cwd: option["cwd"],
|
||||
outputEncoding: option["outputEncoding"],
|
||||
},
|
||||
});
|
||||
const spawnProcess = spawn(commandEntry, args, {
|
||||
env: {...process.env, ...option.env},
|
||||
cwd: option["cwd"],
|
||||
shell: option.shell,
|
||||
encoding: "binary",
|
||||
} as any);
|
||||
let end = false;
|
||||
let isSuccess = false;
|
||||
let exitCode = -1;
|
||||
const stdoutList: string[] = [];
|
||||
const stderrList: string[] = [];
|
||||
spawnProcess.stdout?.on("data", data => {
|
||||
// console.log('App.spawnShell.stdout', data)
|
||||
let dataString = outputStringConvert(option.outputEncoding as any, data);
|
||||
Log.info("App.spawnShell.stdout", dataString);
|
||||
stdoutList.push(dataString);
|
||||
option.stdout?.(dataString, spawnProcess);
|
||||
});
|
||||
spawnProcess.stderr?.on("data", data => {
|
||||
// console.log('App.spawnShell.stderr', data)
|
||||
let dataString = outputStringConvert(option.outputEncoding as any, data);
|
||||
Log.info("App.spawnShell.stderr", dataString);
|
||||
stderrList.push(dataString);
|
||||
option.stderr?.(dataString, spawnProcess);
|
||||
});
|
||||
spawnProcess.on("exit", (code, signal) => {
|
||||
// console.log('App.spawnShell.exit', code)
|
||||
Log.info("App.spawnShell.exit", {code, signal});
|
||||
exitCode = code;
|
||||
if (isWin) {
|
||||
if (0 === code || 1 === code) {
|
||||
isSuccess = true;
|
||||
}
|
||||
} else {
|
||||
if (null === code || 0 === code) {
|
||||
isSuccess = true;
|
||||
}
|
||||
}
|
||||
if (isSuccess) {
|
||||
option.success?.(spawnProcess);
|
||||
} else {
|
||||
option.error?.(`command ${command} failed with code ${code}`, exitCode, spawnProcess);
|
||||
}
|
||||
end = true;
|
||||
});
|
||||
spawnProcess.on("error", err => {
|
||||
// console.log('App.spawnShell.error', err)
|
||||
Log.info("App.spawnShell.error", err);
|
||||
option.error?.(err.toString(), -1, spawnProcess);
|
||||
end = true;
|
||||
});
|
||||
const waitForExit = async () => {
|
||||
if (end) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
spawnProcess.off("exit", finish);
|
||||
spawnProcess.off("error", finish);
|
||||
resolve();
|
||||
};
|
||||
const timeoutId = setTimeout(finish, 5000);
|
||||
spawnProcess.once("exit", finish);
|
||||
spawnProcess.once("error", finish);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
Log.info("App.spawnShell.stop");
|
||||
if (end) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
await new Promise<void>(resolve => {
|
||||
_exec(
|
||||
`taskkill /pid ${spawnProcess.pid} /T /F`,
|
||||
{
|
||||
encoding: "binary",
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
if (stdout) {
|
||||
stdout = outputStringConvert(option.outputEncoding as any, stdout);
|
||||
}
|
||||
if (stderr) {
|
||||
stderr = outputStringConvert(option.outputEncoding as any, stderr);
|
||||
}
|
||||
Log.info("App.spawnShell.stop.taskkill", JSON.parse(JSON.stringify({err, stdout, stderr})));
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
spawnProcess.kill("SIGINT");
|
||||
}
|
||||
|
||||
await waitForExit();
|
||||
},
|
||||
send: data => {
|
||||
Log.info("App.spawnShell.send", data);
|
||||
spawnProcess.stdin.write(data);
|
||||
},
|
||||
result: async (): Promise<string> => {
|
||||
if (end) {
|
||||
return stdoutList.join("") + stderrList.join("");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const watchEnd = () => {
|
||||
setTimeout(() => {
|
||||
if (!end) {
|
||||
watchEnd();
|
||||
return;
|
||||
}
|
||||
if (isSuccess) {
|
||||
resolve(stdoutList.join("") + stderrList.join(""));
|
||||
} else {
|
||||
reject(
|
||||
[
|
||||
`command ${command} failed with code ${exitCode} : `,
|
||||
stdoutList.join(""),
|
||||
stderrList.join(""),
|
||||
].join("")
|
||||
);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
watchEnd();
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const spawnBinary = async (
|
||||
binary: string,
|
||||
args: string[],
|
||||
option: {
|
||||
stdout?: (data: string, process: any) => void;
|
||||
stderr?: (data: string, process: any) => void;
|
||||
success?: (process: any) => void;
|
||||
error?: (msg: string, exitCode: number, process: any) => void;
|
||||
cwd?: string;
|
||||
outputEncoding?: string;
|
||||
env?: Record<string, any>;
|
||||
shell?: boolean;
|
||||
} | null = null
|
||||
): Promise<string> => {
|
||||
try {
|
||||
args.unshift(extraResolveBin(binary));
|
||||
} catch (error) {
|
||||
// 如果extraResolveBin抛出错误,通过error回调通知调用者
|
||||
if (option?.error) {
|
||||
option.error(error.message, -1, null);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const res = await Apps.spawnShell(args, {
|
||||
...(option || {}),
|
||||
shell: false,
|
||||
});
|
||||
return await res.result();
|
||||
};
|
||||
|
||||
const availablePortLock: {
|
||||
[port: number]: {
|
||||
lockKey: string;
|
||||
lockTime: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* 获取一个可用的端口
|
||||
* @param start 开始的端口
|
||||
* @param lockKey 锁定的key,避免其他进程获取,默认会创建一个随机的key
|
||||
* @param lockTime 锁定时间,避免在本次获取后未启动服务导致其他进程重复获取
|
||||
*/
|
||||
const availablePort = async (start: number, lockKey?: string, lockTime?: number): Promise<number> => {
|
||||
lockKey = lockKey || StrUtil.randomString(8);
|
||||
lockTime = lockTime || 60;
|
||||
// expire lock
|
||||
const now = Date.now();
|
||||
for (const port in availablePortLock) {
|
||||
const lockInfo = availablePortLock[port];
|
||||
if (lockInfo.lockTime < now) {
|
||||
delete availablePortLock[port];
|
||||
}
|
||||
}
|
||||
for (let i = start; i < 65535; i++) {
|
||||
const available = await isPortAvailable(i, "0.0.0.0");
|
||||
const availableLocal = await isPortAvailable(i, "127.0.0.1");
|
||||
// console.log('isPortAvailable', i, available, availableLocal)
|
||||
if (available && availableLocal) {
|
||||
const lockInfo = availablePortLock[i];
|
||||
if (lockInfo) {
|
||||
if (lockInfo.lockKey === lockKey) {
|
||||
return i;
|
||||
} else {
|
||||
// other lockKey lock the port
|
||||
continue;
|
||||
}
|
||||
}
|
||||
availablePortLock[i] = {
|
||||
lockKey,
|
||||
lockTime: Date.now() + lockTime * 1000,
|
||||
};
|
||||
return i;
|
||||
}
|
||||
}
|
||||
throw new Error("no available port");
|
||||
};
|
||||
|
||||
const isPortAvailable = async (port: number, host?: string): Promise<boolean> => {
|
||||
return new Promise(resolve => {
|
||||
const server = net.createServer();
|
||||
server.listen(port, host);
|
||||
server.on("listening", () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
server.on("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fixExecutable = async (executable: string) => {
|
||||
if (isMac || isLinux) {
|
||||
// chmod +x executable
|
||||
await shell(`chmod +x "${executable}"`);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserAgent = () => {
|
||||
let param = [];
|
||||
// URL-encode app name to handle Chinese characters in HTTP headers
|
||||
const appName = encodeURIComponent(AppConfig.name);
|
||||
param.push(`AppOpen/${appName}/${AppConfig.version}`);
|
||||
param.push(`Platform/${platformName()}/${platformArch()}/${platformVersion()}/${platformUUID()}`);
|
||||
return param.join(" ");
|
||||
};
|
||||
|
||||
export const Apps = {
|
||||
shell,
|
||||
spawnShell,
|
||||
spawnBinary,
|
||||
availablePort,
|
||||
isPortAvailable,
|
||||
fixExecutable,
|
||||
getUserAgent,
|
||||
};
|
||||
|
||||
export default Apps;
|
||||
@@ -0,0 +1,97 @@
|
||||
import {screen} from "electron";
|
||||
|
||||
type PositionCache = {
|
||||
x: 0;
|
||||
y: 0;
|
||||
screenWidth: 0;
|
||||
screenHeight: 0;
|
||||
id: -1;
|
||||
};
|
||||
|
||||
export const AppPosition = {
|
||||
caches: {} as Record<string, PositionCache>,
|
||||
getCache(name: string): PositionCache {
|
||||
if (!this.caches[name]) {
|
||||
this.caches[name] = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
screenWidth: 0,
|
||||
screenHeight: 0,
|
||||
id: -1,
|
||||
};
|
||||
}
|
||||
return this.caches[name];
|
||||
},
|
||||
get(
|
||||
name: string,
|
||||
calculator?: (
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
screenWidth: number,
|
||||
screenHeight: number
|
||||
) => {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const cache = this.getCache(name);
|
||||
const {x, y} = screen.getCursorScreenPoint();
|
||||
const currentDisplay = screen.getDisplayNearestPoint({x, y});
|
||||
if (cache.id !== currentDisplay.id) {
|
||||
cache.id = currentDisplay.id;
|
||||
cache.screenWidth = currentDisplay.workArea.width;
|
||||
cache.screenHeight = currentDisplay.workArea.height;
|
||||
if (!calculator) {
|
||||
calculator = (screenX: number, screenY: number, screenWidth: number, screenHeight: number) => {
|
||||
// console.log('calculator', {screenX, screenY, screenWidth, screenHeight});
|
||||
return {
|
||||
x: screenX + screenWidth / 10,
|
||||
y: screenY + screenHeight / 10,
|
||||
};
|
||||
};
|
||||
}
|
||||
const res = calculator(
|
||||
currentDisplay.workArea.x,
|
||||
currentDisplay.workArea.y,
|
||||
cache.screenWidth,
|
||||
cache.screenHeight
|
||||
);
|
||||
cache.x = parseInt(String(res.x));
|
||||
cache.y = parseInt(String(res.y));
|
||||
}
|
||||
return {
|
||||
x: cache.x,
|
||||
y: cache.y,
|
||||
};
|
||||
},
|
||||
set(name: string, x: number, y: number): void {
|
||||
const cache = this.getCache(name);
|
||||
cache.x = x;
|
||||
cache.y = y;
|
||||
},
|
||||
getContextMenuPosition(
|
||||
boxWidth: number,
|
||||
boxHeight: number
|
||||
): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const {x, y} = screen.getCursorScreenPoint();
|
||||
const currentDisplay = screen.getDisplayNearestPoint({x, y});
|
||||
let resultX = x;
|
||||
let resultY = y;
|
||||
if (currentDisplay.workArea.width - x < boxWidth) {
|
||||
resultX = currentDisplay.workArea.width - boxWidth;
|
||||
}
|
||||
if (currentDisplay.workArea.height - y < boxHeight) {
|
||||
resultY = currentDisplay.workArea.height - boxHeight;
|
||||
}
|
||||
return {
|
||||
x: resultX,
|
||||
y: resultY,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import {BrowserWindow} from "electron";
|
||||
import {AppsMain} from "./main";
|
||||
import {icons} from "./icons";
|
||||
|
||||
export const makeLoading = (
|
||||
msg: string,
|
||||
options?: {
|
||||
timeout?: number;
|
||||
percentAuto?: boolean;
|
||||
percentTotalSeconds?: number;
|
||||
}
|
||||
): {
|
||||
close: () => void;
|
||||
percent: (value: number) => void;
|
||||
} => {
|
||||
options = Object.assign(
|
||||
{
|
||||
percentAuto: false,
|
||||
percentTotalSeconds: 30,
|
||||
timeout: 0,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
if (options.timeout === 0) {
|
||||
options.timeout = 60 * 10 * 1000;
|
||||
}
|
||||
// console.log('options', options)
|
||||
|
||||
const display = AppsMain.getCurrentScreenDisplay();
|
||||
// console.log('xxxx', primaryDisplay);
|
||||
const width = display.workArea.width;
|
||||
const height = 60;
|
||||
const icon = icons.loading;
|
||||
|
||||
const win = new BrowserWindow({
|
||||
height,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
modal: false,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
center: false,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
focusable: false,
|
||||
skipTaskbar: true,
|
||||
});
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
html,body{
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.message-view {
|
||||
height: 100vh;
|
||||
display:flex;
|
||||
text-align:center;
|
||||
padding:0 10px;
|
||||
position:relative;
|
||||
}
|
||||
.message-view #message{
|
||||
margin: auto;
|
||||
font-size: 16px;
|
||||
display: inline-block;
|
||||
line-height: 30px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.message-view #message .icon{
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display:inline-block;
|
||||
margin-right: 5px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.message-view #percent{
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
height: 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
overflow: hidden;
|
||||
display:none;
|
||||
}
|
||||
.message-view #percent .value{
|
||||
border-radius: 5px;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="message-view">
|
||||
<div id="message">${icon}${msg}</div>
|
||||
<div id="percent">
|
||||
<div class="value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const encodedHTML = encodeURIComponent(htmlContent);
|
||||
let percentAutoTimer = null;
|
||||
win.loadURL(`data:text/html;charset=UTF-8,${encodedHTML}`);
|
||||
win.on("ready-to-show", async () => {
|
||||
const width = Math.ceil(
|
||||
await win.webContents.executeJavaScript(`(()=>{
|
||||
const message = document.getElementById('message');
|
||||
const width = message.scrollWidth;
|
||||
return width;
|
||||
})()`)
|
||||
);
|
||||
win.setSize(width + 20, height);
|
||||
const x = display.workArea.x + display.workArea.width / 2 - (width + 20) / 2;
|
||||
const y = display.workArea.y + (display.workArea.height * 1) / 4;
|
||||
win.setPosition(Math.floor(x), Math.floor(y));
|
||||
win.show();
|
||||
if (options.percentAuto) {
|
||||
let percent = 0;
|
||||
percentAutoTimer = setInterval(() => {
|
||||
percent += 0.01;
|
||||
if (percent >= 1) {
|
||||
clearInterval(percentAutoTimer);
|
||||
return;
|
||||
}
|
||||
controller.percent(percent);
|
||||
}, (options.percentTotalSeconds * 1000) / 100);
|
||||
}
|
||||
// win.webContents.openDevTools({
|
||||
// mode: 'detach'
|
||||
// })
|
||||
});
|
||||
const winCloseTimer = setTimeout(() => {
|
||||
win.close();
|
||||
clearTimeout(winCloseTimer);
|
||||
}, options.timeout);
|
||||
const controller = {
|
||||
close: () => {
|
||||
win.close();
|
||||
clearTimeout(winCloseTimer);
|
||||
if (percentAutoTimer) {
|
||||
clearInterval(percentAutoTimer);
|
||||
}
|
||||
},
|
||||
percent: (value: number) => {
|
||||
const percent = 100 * value;
|
||||
win.webContents.executeJavaScript(`(()=>{
|
||||
const percent = document.querySelector('#percent');
|
||||
const percentValue = document.querySelector('#percent .value');
|
||||
percent.style.display = 'block';
|
||||
percentValue.style.width = '${percent}%';
|
||||
})()`);
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
};
|
||||
@@ -0,0 +1,504 @@
|
||||
import { app, BrowserWindow, clipboard, ipcMain, nativeImage, nativeTheme, screen, shell } from "electron";
|
||||
import { spawn } from "child_process";
|
||||
import { AppConfig } from "../../../src/config";
|
||||
import { CommonConfig } from "../../config/common";
|
||||
import { WindowConfig } from "../../config/window";
|
||||
import { isDev, isMac, platformArch, platformName, platformUUID, platformVersion } from "../../lib/env";
|
||||
import { preloadDefault, rendererDistPath } from "../../lib/env-main";
|
||||
import { getResourceRoot } from "../../lib/resource-path";
|
||||
import { Page } from "../../page";
|
||||
import { ConfigMain } from "../config/main";
|
||||
import { AppRuntime } from "../env";
|
||||
import { Events } from "../event/main";
|
||||
import { Files } from "../file/main";
|
||||
import Apps from "./index";
|
||||
import { AppPosition } from "./lib/position";
|
||||
import { makeLoading } from "./loading";
|
||||
import { SetupMain } from "./setup";
|
||||
import { makeToast } from "./toast";
|
||||
|
||||
const getWindowByName = (name?: string) => {
|
||||
if (!name || "main" === name) {
|
||||
return AppRuntime.mainWindow;
|
||||
}
|
||||
return AppRuntime.windows[name];
|
||||
};
|
||||
|
||||
const getCurrentWindow = (window, e) => {
|
||||
let originWindow = BrowserWindow.fromWebContents(e.sender);
|
||||
// if (originWindow !== window) originWindow = detachInstance.getWindow();
|
||||
return originWindow;
|
||||
};
|
||||
|
||||
const quit = () => {
|
||||
// @ts-ignore
|
||||
app.quitForce = true;
|
||||
app.quit();
|
||||
};
|
||||
|
||||
ipcMain.handle("app:quit", () => {
|
||||
quit();
|
||||
});
|
||||
|
||||
const restart = () => {
|
||||
app.relaunch();
|
||||
};
|
||||
|
||||
ipcMain.handle("app:restart", () => {
|
||||
restart();
|
||||
});
|
||||
|
||||
const windowMin = (name?: string) => {
|
||||
getWindowByName(name)?.minimize();
|
||||
};
|
||||
|
||||
const windowMax = (name?: string) => {
|
||||
const win = getWindowByName(name);
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
if (win.isFullScreen()) {
|
||||
win.setFullScreen(false);
|
||||
win.unmaximize();
|
||||
win.center();
|
||||
} else if (win.isMaximized()) {
|
||||
win.unmaximize();
|
||||
win.center();
|
||||
} else {
|
||||
win.setMinimumSize(WindowConfig.minWidth, WindowConfig.minHeight);
|
||||
win.maximize();
|
||||
}
|
||||
};
|
||||
|
||||
const windowSetSize = (
|
||||
name: string | null,
|
||||
width: number,
|
||||
height: number,
|
||||
option?: {
|
||||
includeMinimumSize: boolean;
|
||||
center: boolean;
|
||||
}
|
||||
) => {
|
||||
width = parseInt(String(width));
|
||||
height = parseInt(String(height));
|
||||
// console.log('windowSetSize', name, width, height, option)
|
||||
const win = getWindowByName(name);
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
option = Object.assign(
|
||||
{
|
||||
includeMinimumSize: true,
|
||||
center: true,
|
||||
},
|
||||
option
|
||||
);
|
||||
if (option.includeMinimumSize) {
|
||||
win.setMinimumSize(width, height);
|
||||
}
|
||||
win.setSize(width, height);
|
||||
if (option.center) {
|
||||
win.center();
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle("app:openExternal", (event, url: string) => {
|
||||
return shell.openExternal(url);
|
||||
});
|
||||
ipcMain.handle("app:openPath", (event, url: string) => {
|
||||
return shell.openPath(url);
|
||||
});
|
||||
ipcMain.handle("app:showItemInFolder", (event, url: string) => {
|
||||
return shell.showItemInFolder(url);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getPreload", event => {
|
||||
let preload = preloadDefault;
|
||||
if (!preload.startsWith("file://")) {
|
||||
preload = `file://${preload}`;
|
||||
}
|
||||
return preload;
|
||||
});
|
||||
|
||||
ipcMain.handle("window:min", (event, name: string) => {
|
||||
windowMin(name);
|
||||
});
|
||||
ipcMain.handle("window:max", (event, name: string) => {
|
||||
windowMax(name);
|
||||
});
|
||||
ipcMain.handle(
|
||||
"window:setSize",
|
||||
(
|
||||
event,
|
||||
name: string | null,
|
||||
width: number,
|
||||
height: number,
|
||||
option?: {
|
||||
includeMinimumSize: boolean;
|
||||
center: boolean;
|
||||
}
|
||||
) => {
|
||||
windowSetSize(name, width, height, option);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("window:close", (event, name: string) => {
|
||||
getWindowByName(name)?.close();
|
||||
});
|
||||
|
||||
const windowOpen = async (
|
||||
name: string,
|
||||
option?: {
|
||||
singleton?: boolean;
|
||||
parent?: BrowserWindow;
|
||||
[key: string]: any;
|
||||
}
|
||||
) => {
|
||||
name = name || "main";
|
||||
return Page.open(name, option);
|
||||
};
|
||||
|
||||
ipcMain.handle("window:open", (event, name: string, option: any) => {
|
||||
return windowOpen(name, option);
|
||||
});
|
||||
|
||||
ipcMain.handle("window:hide", (event, name: string) => {
|
||||
getWindowByName(name)?.hide();
|
||||
if (isMac) {
|
||||
app.dock.hide();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"window:move",
|
||||
(
|
||||
event,
|
||||
name: string | null,
|
||||
data: {
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
) => {
|
||||
const { x, y } = screen.getCursorScreenPoint();
|
||||
const originWindow = getWindowByName(name);
|
||||
if (!originWindow) return;
|
||||
originWindow.setBounds({ x: x - data.mouseX, y: y - data.mouseY, width: data.width, height: data.height });
|
||||
AppPosition.set(name, x - data.mouseX, y - data.mouseY);
|
||||
}
|
||||
);
|
||||
|
||||
const getClipboardText = () => {
|
||||
return clipboard.readText("clipboard");
|
||||
};
|
||||
|
||||
ipcMain.handle("app:getClipboardText", event => {
|
||||
return getClipboardText();
|
||||
});
|
||||
|
||||
const setClipboardText = (text: string) => {
|
||||
clipboard.writeText(text, "clipboard");
|
||||
};
|
||||
|
||||
ipcMain.handle("app:setClipboardText", (event, text: string) => {
|
||||
setClipboardText(text);
|
||||
});
|
||||
|
||||
const getClipboardImage = () => {
|
||||
const image = clipboard.readImage("clipboard");
|
||||
return image.isEmpty() ? "" : image.toDataURL();
|
||||
};
|
||||
|
||||
ipcMain.handle("app:getClipboardImage", event => {
|
||||
return getClipboardImage();
|
||||
});
|
||||
|
||||
const setClipboardImage = (image: string) => {
|
||||
const img = nativeImage.createFromDataURL(image);
|
||||
clipboard.writeImage(img, "clipboard");
|
||||
};
|
||||
|
||||
ipcMain.handle("app:setClipboardImage", (event, image: string) => {
|
||||
setClipboardImage(image);
|
||||
});
|
||||
|
||||
const isDarkMode = () => {
|
||||
if (!CommonConfig.darkModeEnable) {
|
||||
return false;
|
||||
}
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
};
|
||||
|
||||
const shouldDarkMode = async () => {
|
||||
if (!CommonConfig.darkModeEnable) {
|
||||
return false;
|
||||
}
|
||||
const darkMode = (await ConfigMain.get("darkMode")) || "auto";
|
||||
if ("dark" === darkMode) {
|
||||
return true;
|
||||
} else if ("light" === darkMode) {
|
||||
return false;
|
||||
} else if ("auto" === darkMode) {
|
||||
return isDarkMode();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const defaultDarkModeBackgroundColor = async () => {
|
||||
if (await shouldDarkMode()) {
|
||||
return "#17171A";
|
||||
}
|
||||
return "#FFFFFF";
|
||||
};
|
||||
|
||||
nativeTheme.on("updated", () => {
|
||||
Events.broadcast("DarkModeChange", { isDarkMode: isDarkMode() });
|
||||
AppsMain.defaultDarkModeBackgroundColor().then(color => {
|
||||
AppRuntime.mainWindow.setBackgroundColor(color)
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("app:isDarkMode", () => {
|
||||
return isDarkMode();
|
||||
});
|
||||
|
||||
const getCurrentScreenDisplay = () => {
|
||||
const screenPoint = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(screenPoint);
|
||||
return {
|
||||
bounds: display.bounds,
|
||||
workArea: display.workArea,
|
||||
};
|
||||
};
|
||||
|
||||
const calcPositionInCurrentDisplay = (
|
||||
position: "center" | "left-top" | "right-top" | "left-bottom" | "right-bottom",
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const { bounds, workArea } = getCurrentScreenDisplay();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
switch (position) {
|
||||
case "center":
|
||||
x = workArea.x + (workArea.width - width) / 2;
|
||||
y = workArea.y + (workArea.height - height) / 2;
|
||||
break;
|
||||
case "left-top":
|
||||
x = workArea.x;
|
||||
y = workArea.y;
|
||||
break;
|
||||
case "right-top":
|
||||
x = workArea.x + workArea.width - width;
|
||||
y = workArea.y;
|
||||
break;
|
||||
case "left-bottom":
|
||||
x = workArea.x;
|
||||
y = workArea.y + workArea.height - height;
|
||||
break;
|
||||
case "right-bottom":
|
||||
x = workArea.x + workArea.width - width;
|
||||
y = workArea.y + workArea.height - height;
|
||||
break;
|
||||
}
|
||||
return {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
};
|
||||
};
|
||||
|
||||
const toast = (
|
||||
msg: string,
|
||||
options?: {
|
||||
duration?: number;
|
||||
status?: "success" | "error" | "info";
|
||||
}
|
||||
) => {
|
||||
return makeToast(msg, options);
|
||||
};
|
||||
|
||||
ipcMain.handle("app:toast", (event, msg: string, option?: any) => {
|
||||
return toast(msg, option);
|
||||
});
|
||||
|
||||
const loading = (
|
||||
msg: string,
|
||||
options?: {
|
||||
timeout?: number;
|
||||
percentAuto?: boolean;
|
||||
percentTotalSeconds?: number;
|
||||
}
|
||||
): {
|
||||
close: () => void;
|
||||
percent: (value: number) => void;
|
||||
} => {
|
||||
return makeLoading(msg, options);
|
||||
};
|
||||
|
||||
ipcMain.handle("app:loading", (event, msg: string, option?: any) => {
|
||||
return loading(msg, option);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setupList", async () => {
|
||||
return SetupMain.list();
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setupOpen", async (event, name: string) => {
|
||||
return SetupMain.open(name);
|
||||
});
|
||||
|
||||
const setupIsOk = async () => {
|
||||
return SetupMain.isOk();
|
||||
};
|
||||
|
||||
ipcMain.handle("app:setupIsOk", async () => {
|
||||
return setupIsOk();
|
||||
});
|
||||
|
||||
const getBuildInfo = async () => {
|
||||
if (isDev) {
|
||||
return {
|
||||
buildId: "Development",
|
||||
};
|
||||
}
|
||||
const json = await Files.read(rendererDistPath("build.json"), {
|
||||
isDataPath: false,
|
||||
});
|
||||
return JSON.parse(json);
|
||||
};
|
||||
|
||||
ipcMain.handle("app:getBuildInfo", async () => {
|
||||
return getBuildInfo();
|
||||
});
|
||||
|
||||
const collect = async (options?: {}) => {
|
||||
return {
|
||||
userAgent: Apps.getUserAgent(),
|
||||
name: AppConfig.name,
|
||||
version: AppConfig.version,
|
||||
uuid: platformUUID(),
|
||||
platformVersion: platformVersion(),
|
||||
platformName: platformName(),
|
||||
platformArch: platformArch(),
|
||||
};
|
||||
};
|
||||
|
||||
ipcMain.handle("app:collect", async (event, options?: {}) => {
|
||||
return collect(options);
|
||||
});
|
||||
|
||||
const setAutoLaunch = async (enable: boolean, options?: {}) => {
|
||||
return app.setLoginItemSettings({
|
||||
openAtLogin: enable,
|
||||
});
|
||||
};
|
||||
|
||||
ipcMain.handle("app:setAutoLaunch", async (event, enable: boolean, options?: {}) => {
|
||||
return setAutoLaunch(enable, options);
|
||||
});
|
||||
|
||||
const getAutoLaunch = async (options?: {}) => {
|
||||
return app.getLoginItemSettings().openAtLogin;
|
||||
};
|
||||
|
||||
ipcMain.handle("app:getAutoLaunch", async (event, options?: {}) => {
|
||||
return getAutoLaunch(options);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getResourcesPath", () => {
|
||||
return getResourceRoot();
|
||||
});
|
||||
|
||||
// FFprobe video info handler
|
||||
ipcMain.handle("app:ffprobeVideoInfo", async (event, videoPath: string) => {
|
||||
const { getFFprobePath } = await import('../shell');
|
||||
|
||||
try {
|
||||
const ffprobePath = await getFFprobePath();
|
||||
|
||||
// 获取视频流信息和格式信息(包含时长)
|
||||
const args = [
|
||||
"-v", "error",
|
||||
"-show_entries", "stream=width,height,r_frame_rate:format=duration",
|
||||
"-of", "json",
|
||||
videoPath
|
||||
];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(ffprobePath, args, { shell: false });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`ffprobe failed with code ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
|
||||
// 获取时长
|
||||
let duration = undefined;
|
||||
if (data.format && data.format.duration) {
|
||||
duration = parseFloat(data.format.duration);
|
||||
}
|
||||
|
||||
// 如果有视频流,获取视频信息
|
||||
if (data.streams && data.streams.length > 0) {
|
||||
const stream = data.streams[0];
|
||||
const width = parseInt(stream.width, 10) || undefined;
|
||||
const height = parseInt(stream.height, 10) || undefined;
|
||||
|
||||
// 解析帧率
|
||||
let fps = 30;
|
||||
if (stream.r_frame_rate) {
|
||||
const fpsParts = stream.r_frame_rate.split("/");
|
||||
fps = fpsParts.length === 2
|
||||
? parseFloat(fpsParts[0]) / parseFloat(fpsParts[1])
|
||||
: parseFloat(stream.r_frame_rate);
|
||||
}
|
||||
|
||||
resolve({ width, height, fps, duration });
|
||||
} else {
|
||||
// 只有音频流的情况,只返回时长
|
||||
resolve({ duration });
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
quit,
|
||||
};
|
||||
|
||||
export const AppsMain = {
|
||||
shouldDarkMode,
|
||||
defaultDarkModeBackgroundColor,
|
||||
getWindowByName,
|
||||
getClipboardText,
|
||||
setClipboardText,
|
||||
getClipboardImage,
|
||||
setClipboardImage,
|
||||
getCurrentScreenDisplay,
|
||||
calcPositionInCurrentDisplay,
|
||||
toast,
|
||||
loading,
|
||||
setupIsOk,
|
||||
windowOpen,
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
import { resolve } from "node:path";
|
||||
import { isPackaged, platformArch, platformName } from "../../lib/env";
|
||||
import { AppEnv, waitAppEnvReady } from "../env";
|
||||
import appIndex from "./index";
|
||||
|
||||
const isDarkMode = async () => {
|
||||
|
||||
return ipcRenderer.invoke("app:isDarkMode");
|
||||
};
|
||||
|
||||
const quit = () => {
|
||||
|
||||
return ipcRenderer.invoke("app:quit");
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
|
||||
return ipcRenderer.invoke("app:restart");
|
||||
};
|
||||
|
||||
const isPlatform = (name: "win" | "osx" | "linux") => {
|
||||
return platformName() === name;
|
||||
};
|
||||
|
||||
const windowMin = (name?: string) => {
|
||||
|
||||
return ipcRenderer.invoke("window:min", name);
|
||||
};
|
||||
|
||||
const windowMax = (name?: string) => {
|
||||
|
||||
return ipcRenderer.invoke("window:max", name);
|
||||
};
|
||||
|
||||
const windowSetSize = (
|
||||
name: string | null,
|
||||
width: number,
|
||||
height: number,
|
||||
option?: {
|
||||
includeMinimumSize: boolean;
|
||||
center: boolean;
|
||||
}
|
||||
) => {
|
||||
|
||||
return ipcRenderer.invoke("window:setSize", name, width, height, option);
|
||||
};
|
||||
|
||||
const windowOpen = (name: string, option: any) => {
|
||||
return ipcRenderer.invoke("window:open", name, option);
|
||||
};
|
||||
|
||||
const windowHide = (name: string) => {
|
||||
return ipcRenderer.invoke("window:hide", name);
|
||||
};
|
||||
|
||||
const windowClose = (name: string) => {
|
||||
return ipcRenderer.invoke("window:close", name);
|
||||
};
|
||||
|
||||
const windowMove = (name: string | null, data: { mouseX: number; mouseY: number; width: number; height: number }) => {
|
||||
return ipcRenderer.invoke("window:move", name, data);
|
||||
};
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
return ipcRenderer.invoke("app:openExternal", url);
|
||||
};
|
||||
|
||||
const openPath = (url: string) => {
|
||||
return ipcRenderer.invoke("app:openPath", url);
|
||||
};
|
||||
|
||||
const showItemInFolder = (url: string) => {
|
||||
return ipcRenderer.invoke("app:showItemInFolder", url);
|
||||
};
|
||||
|
||||
const getPreload = async () => {
|
||||
|
||||
return ipcRenderer.invoke("app:getPreload");
|
||||
};
|
||||
|
||||
const getResourcesPath = async () => {
|
||||
return ipcRenderer.invoke("app:getResourcesPath");
|
||||
};
|
||||
|
||||
const resourcePathResolve = async (filePath: string) => {
|
||||
await waitAppEnvReady();
|
||||
const basePath = isPackaged ? process.resourcesPath : AppEnv.appRoot;
|
||||
return resolve(basePath, filePath);
|
||||
};
|
||||
|
||||
const extraPathResolve = async (filePath: string) => {
|
||||
await waitAppEnvReady();
|
||||
const basePath = isPackaged ? process.resourcesPath : "electron/resources";
|
||||
return resolve(basePath, "extra", filePath);
|
||||
};
|
||||
|
||||
const appEnv = async () => {
|
||||
|
||||
await waitAppEnvReady();
|
||||
return AppEnv;
|
||||
};
|
||||
|
||||
const setRenderAppEnv = (env: any) => {
|
||||
AppEnv.isInit = true;
|
||||
AppEnv.appRoot = env.appRoot;
|
||||
AppEnv.appData = env.appData;
|
||||
AppEnv.userData = env.userData;
|
||||
AppEnv.dataRoot = env.dataRoot;
|
||||
};
|
||||
|
||||
const getClipboardText = () => {
|
||||
|
||||
return ipcRenderer.invoke("app:getClipboardText");
|
||||
};
|
||||
|
||||
const setClipboardText = (text: string) => {
|
||||
return ipcRenderer.invoke("app:setClipboardText", text);
|
||||
};
|
||||
|
||||
const getClipboardImage = () => {
|
||||
|
||||
return ipcRenderer.invoke("app:getClipboardImage");
|
||||
};
|
||||
|
||||
const setClipboardImage = (image: string) => {
|
||||
return ipcRenderer.invoke("app:setClipboardImage", image);
|
||||
};
|
||||
|
||||
const toast = (msg: string, option?: any) => {
|
||||
return ipcRenderer.invoke("app:toast", msg, option);
|
||||
};
|
||||
|
||||
const setupList = () => {
|
||||
|
||||
return ipcRenderer.invoke("app:setupList");
|
||||
};
|
||||
|
||||
const setupOpen = (name: string) => {
|
||||
return ipcRenderer.invoke("app:setupOpen", name);
|
||||
};
|
||||
|
||||
const setupIsOk = async () => {
|
||||
|
||||
return ipcRenderer.invoke("app:setupIsOk");
|
||||
};
|
||||
|
||||
const getBuildInfo = async () => {
|
||||
|
||||
return ipcRenderer.invoke("app:getBuildInfo");
|
||||
};
|
||||
|
||||
const collect = async (options?: {}) => {
|
||||
return ipcRenderer.invoke("app:collect", options);
|
||||
};
|
||||
|
||||
const setAutoLaunch = async (enable: boolean, options?: {}) => {
|
||||
return ipcRenderer.invoke("app:setAutoLaunch", enable, options);
|
||||
};
|
||||
|
||||
const getAutoLaunch = async (options?: {}) => {
|
||||
return ipcRenderer.invoke("app:getAutoLaunch", options);
|
||||
};
|
||||
|
||||
export const AppsRender = {
|
||||
isDarkMode,
|
||||
resourcePathResolve,
|
||||
extraPathResolve,
|
||||
platformName,
|
||||
platformArch,
|
||||
isPlatform,
|
||||
quit,
|
||||
restart,
|
||||
windowMin,
|
||||
windowMax,
|
||||
windowSetSize,
|
||||
windowOpen,
|
||||
windowHide,
|
||||
windowClose,
|
||||
windowMove,
|
||||
openExternal,
|
||||
openPath,
|
||||
showItemInFolder,
|
||||
getPreload,
|
||||
getResourcesPath,
|
||||
appEnv,
|
||||
setRenderAppEnv,
|
||||
getClipboardText,
|
||||
setClipboardText,
|
||||
getClipboardImage,
|
||||
setClipboardImage,
|
||||
toast,
|
||||
setupList,
|
||||
setupOpen,
|
||||
setupIsOk,
|
||||
getBuildInfo,
|
||||
collect,
|
||||
setAutoLaunch,
|
||||
getAutoLaunch,
|
||||
shell: appIndex.shell,
|
||||
spawnShell: appIndex.spawnShell,
|
||||
spawnBinary: appIndex.spawnBinary,
|
||||
availablePort: appIndex.availablePort,
|
||||
fixExecutable: appIndex.fixExecutable,
|
||||
getUserAgent: appIndex.getUserAgent,
|
||||
};
|
||||
|
||||
export default AppsRender;
|
||||
@@ -0,0 +1,45 @@
|
||||
import {Permissions} from "../../lib/permission";
|
||||
|
||||
export const SetupMain = {
|
||||
async isOk() {
|
||||
return true;
|
||||
},
|
||||
async list() {
|
||||
return [
|
||||
{
|
||||
name: "accessibility",
|
||||
title: "辅助功能",
|
||||
status: (await Permissions.checkAccessibilityAccess()) ? "success" : "fail",
|
||||
desc: "系统运行需要依赖辅助功能,请打开设置,找到辅助功能,开启本软件的辅助功能。",
|
||||
steps: [
|
||||
{
|
||||
title: "打开 设置 → 隐私与安全性 → 辅助功能,开启本软件",
|
||||
image: "/setup/accessibility.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "screen",
|
||||
title: "屏幕录制",
|
||||
status: (await Permissions.checkScreenCaptureAccess()) ? "success" : "fail",
|
||||
desc: "系统运行需要依赖屏幕录制,请打开设置,找到屏幕录制,开启本软件的屏幕录制权限。",
|
||||
steps: [
|
||||
{
|
||||
title: "打开 设置 → 隐私与安全性 → 录屏与系统录音,开启本软件",
|
||||
image: "/setup/screen.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async open(name: string) {
|
||||
switch (name) {
|
||||
case "accessibility":
|
||||
Permissions.askAccessibilityAccess().then();
|
||||
break;
|
||||
case "screen":
|
||||
Permissions.askScreenCaptureAccess().then();
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import {BrowserWindow} from "electron";
|
||||
import {icons} from "./icons";
|
||||
import {AppsMain} from "./main";
|
||||
|
||||
let win = null;
|
||||
let winCloseTimer = null;
|
||||
let winShowTime = null;
|
||||
const toastMsgQueue: { msg: string, options: any }[] = [];
|
||||
|
||||
export const makeToast = async (
|
||||
msg: string,
|
||||
options?: {
|
||||
duration?: number;
|
||||
status?: "success" | "error" | "info";
|
||||
}
|
||||
) => {
|
||||
if (win) {
|
||||
if (winShowTime && Date.now() - winShowTime < 1000) {
|
||||
// make previous toast last at least 1 second
|
||||
if (toastMsgQueue.length > 0) {
|
||||
toastMsgQueue.forEach(item => {
|
||||
item.options = Object.assign({}, item.options, {duration: 1000});
|
||||
})
|
||||
}
|
||||
toastMsgQueue.push({msg, options});
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 - (Date.now() - winShowTime)));
|
||||
if (win) {
|
||||
win.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
win.close();
|
||||
}
|
||||
winShowTime = Date.now();
|
||||
|
||||
options = Object.assign(
|
||||
{
|
||||
status: "info",
|
||||
duration: 0,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
if (options.duration === 0) {
|
||||
options.duration = Math.max(msg.length * 400, 3000);
|
||||
}
|
||||
// console.log('toast', msg, options)
|
||||
|
||||
const display = AppsMain.getCurrentScreenDisplay();
|
||||
// console.log('xxxx', primaryDisplay);
|
||||
const width = display.workArea.width;
|
||||
const height = 60;
|
||||
const icon = icons[options.status] || icons.success;
|
||||
|
||||
win = new BrowserWindow({
|
||||
height,
|
||||
width,
|
||||
parent: null,
|
||||
x: 0,
|
||||
y: 0,
|
||||
modal: false,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
// opacity: 0.9,
|
||||
center: false,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
focusable: false,
|
||||
skipTaskbar: true,
|
||||
});
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
html,body{
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #FFFFFF;
|
||||
font-family: "PingFang SC", "Helvetica Neue", Helvetica, STHeiTi, "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif;
|
||||
}
|
||||
.message-view {
|
||||
height: 100%;
|
||||
text-align:center;
|
||||
box-sizing: border-box;
|
||||
background-color:transparent;
|
||||
padding: 10px;
|
||||
}
|
||||
.message-view div{
|
||||
margin: auto;
|
||||
font-size: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 20px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 15px;
|
||||
padding: 10px 10px;
|
||||
box-shadow: 5px 5px 5px rgba(0,0,0,0.3);
|
||||
}
|
||||
.message-view div .icon{
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display:inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="message-view" onclick="window.close()">
|
||||
<div id="message">${icon}${msg}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const encodedHTML = encodeURIComponent(htmlContent);
|
||||
win.loadURL(`data:text/html;charset=UTF-8,${encodedHTML}`);
|
||||
win.on("ready-to-show", async () => {
|
||||
if (!win) return;
|
||||
const containerSize = await win.webContents.executeJavaScript(`(()=>{
|
||||
const message = document.getElementById('message');
|
||||
const width = message.scrollWidth;
|
||||
const height = message.scrollHeight;
|
||||
return {width:width,height:height};
|
||||
})()`);
|
||||
// console.log('containerSize', containerSize);
|
||||
const containerWidth = containerSize.width + 20;
|
||||
const containerHeight = containerSize.height + 20;
|
||||
win.setSize(containerWidth, containerHeight);
|
||||
const x = display.workArea.x + display.workArea.width / 2 - containerWidth / 2;
|
||||
const y = display.workArea.y + (display.workArea.height * 1) / 4;
|
||||
win.setPosition(Math.floor(x), Math.floor(y));
|
||||
win.showInactive();
|
||||
// win.webContents.openDevTools({
|
||||
// mode: 'detach'
|
||||
// })
|
||||
});
|
||||
win.on("closed", () => {
|
||||
win = null;
|
||||
if (winCloseTimer) {
|
||||
clearTimeout(winCloseTimer);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (toastMsgQueue.length > 0) {
|
||||
const item = toastMsgQueue.shift();
|
||||
makeToast(item.msg, item.options);
|
||||
}
|
||||
}, 0);
|
||||
})
|
||||
winCloseTimer = setTimeout(() => {
|
||||
winCloseTimer = null;
|
||||
if (!win) return;
|
||||
win.close();
|
||||
}, options.duration);
|
||||
};
|
||||
@@ -0,0 +1,454 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as path from 'path';
|
||||
import logger from '../log/main';
|
||||
import { getFFmpegPath, getFFprobePath } from '../shell';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 音频片段信息
|
||||
*/
|
||||
export interface AudioSegment {
|
||||
startTime: number; // 开始时间(秒)
|
||||
endTime: number; // 结束时间(秒)
|
||||
duration: number; // 持续时间(秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 音量检测结果
|
||||
*/
|
||||
export interface VolumeDetectResult {
|
||||
success: boolean;
|
||||
segments: AudioSegment[]; // 有声音的时间段
|
||||
totalDuration: number; // 总时长
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 音量检测配置
|
||||
*/
|
||||
export interface VolumeDetectConfig {
|
||||
silenceThreshold: number; // 静音阈值(dB),默认 -40dB
|
||||
minSilenceDuration: number; // 最小静音时长(秒),默认 0.5s
|
||||
minSoundDuration: number; // 最小有声时长(秒),默认 0.3s
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 FFmpeg silencedetect 滤镜检测音频中的声音片段
|
||||
*/
|
||||
export async function detectAudioSegments(
|
||||
audioOrVideoPath: string,
|
||||
config: Partial<VolumeDetectConfig> = {}
|
||||
): Promise<VolumeDetectResult> {
|
||||
const defaultConfig: VolumeDetectConfig = {
|
||||
silenceThreshold: -40,
|
||||
minSilenceDuration: 0.5,
|
||||
minSoundDuration: 0.3
|
||||
};
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config };
|
||||
|
||||
logger.info('[VolumeDetect] 开始音量检测', {
|
||||
file: path.basename(audioOrVideoPath),
|
||||
config: finalConfig
|
||||
});
|
||||
|
||||
try {
|
||||
// 先获取总时长
|
||||
const duration = await getMediaDuration(audioOrVideoPath);
|
||||
|
||||
if (!duration || isNaN(duration) || duration <= 0) {
|
||||
throw new Error(`无效的媒体时长: ${duration}`);
|
||||
}
|
||||
|
||||
logger.info(`[VolumeDetect] 媒体总时长: ${duration.toFixed(2)}秒`);
|
||||
|
||||
// 使用 silencedetect 滤镜检测静音段
|
||||
// 输出格式: silence_start: 12.345 silence_end: 15.678 | silence_duration: 3.333
|
||||
const ffmpegPath = await getFFmpegPath();
|
||||
|
||||
const args = [
|
||||
'-i', audioOrVideoPath,
|
||||
'-af', `silencedetect=noise=${finalConfig.silenceThreshold}dB:d=${finalConfig.minSilenceDuration}`,
|
||||
'-f', 'null',
|
||||
'-'
|
||||
];
|
||||
|
||||
logger.info(`[VolumeDetect] FFmpeg 命令: ${ffmpegPath} ${args.join(' ')}`);
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(ffmpegPath, args, {
|
||||
maxBuffer: 10 * 1024 * 1024 // 10MB
|
||||
});
|
||||
|
||||
// silencedetect 的输出在 stderr 中
|
||||
const output = stderr;
|
||||
|
||||
// 解析静音段
|
||||
const silenceSegments = parseSilenceDetectOutput(output);
|
||||
logger.info(`[VolumeDetect] 检测到 ${silenceSegments.length} 个静音段`);
|
||||
|
||||
// 从静音段反推有声段
|
||||
const soundSegments = invertSilenceToSound(silenceSegments, duration, finalConfig.minSoundDuration);
|
||||
logger.info(`[VolumeDetect] 识别到 ${soundSegments.length} 个有声段`);
|
||||
|
||||
// 输出详细信息
|
||||
console.log('\n========== 🔊 音量检测结果 ==========');
|
||||
console.log(`总时长: ${duration.toFixed(2)}秒`);
|
||||
console.log(`检测到的静音段: ${silenceSegments.length}个`);
|
||||
silenceSegments.forEach((seg, idx) => {
|
||||
console.log(` 静音${idx + 1}: ${seg.start.toFixed(2)}s - ${seg.end.toFixed(2)}s`);
|
||||
});
|
||||
console.log(`检测到的有声段: ${soundSegments.length}个`);
|
||||
soundSegments.forEach((seg, idx) => {
|
||||
logger.info(`[VolumeDetect] 片段${idx + 1}: ${seg.startTime.toFixed(2)}s - ${seg.endTime.toFixed(2)}s (${seg.duration.toFixed(2)}s)`);
|
||||
console.log(` 有声${idx + 1}: ${seg.startTime.toFixed(2)}s - ${seg.endTime.toFixed(2)}s (${seg.duration.toFixed(2)}s)`);
|
||||
});
|
||||
console.log('====================================\n');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
segments: soundSegments,
|
||||
totalDuration: duration
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[VolumeDetect] 音量检测失败', error);
|
||||
console.error('[VolumeDetect] ❌ 音量检测失败:', error);
|
||||
console.error('[VolumeDetect] 错误堆栈:', error.stack);
|
||||
console.error('[VolumeDetect] 文件路径:', audioOrVideoPath);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
segments: [],
|
||||
totalDuration: 0,
|
||||
error: error.message || error.toString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体文件时长
|
||||
*/
|
||||
async function getMediaDuration(filePath: string): Promise<number> {
|
||||
const ffprobePath = await getFFprobePath();
|
||||
|
||||
const args = [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
filePath
|
||||
];
|
||||
|
||||
logger.info(`[VolumeDetect] 获取媒体时长: ${filePath}`);
|
||||
logger.info(`[VolumeDetect] ffprobe路径: ${ffprobePath}`);
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(ffprobePath, args);
|
||||
|
||||
logger.info(`[VolumeDetect] ffprobe输出: ${stdout.trim()}`);
|
||||
if (stderr) {
|
||||
logger.warn(`[VolumeDetect] ffprobe错误输出: ${stderr}`);
|
||||
}
|
||||
|
||||
const duration = parseFloat(stdout.trim());
|
||||
|
||||
if (isNaN(duration) || !isFinite(duration) || duration <= 0) {
|
||||
const errorMsg = `获取媒体时长失败: ffprobe返回了无效值 "${stdout.trim()}"`;
|
||||
logger.error(`[VolumeDetect] ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 silencedetect 输出
|
||||
*/
|
||||
function parseSilenceDetectOutput(output: string): Array<{ start: number; end: number }> {
|
||||
const silenceSegments: Array<{ start: number; end: number }> = [];
|
||||
const lines = output.split('\n');
|
||||
|
||||
let currentStart: number | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// silence_start: 12.345
|
||||
const startMatch = line.match(/silence_start:\s+([\d.]+)/);
|
||||
if (startMatch) {
|
||||
currentStart = parseFloat(startMatch[1]);
|
||||
}
|
||||
|
||||
// silence_end: 15.678 | silence_duration: 3.333
|
||||
const endMatch = line.match(/silence_end:\s+([\d.]+)/);
|
||||
if (endMatch && currentStart !== null) {
|
||||
const end = parseFloat(endMatch[1]);
|
||||
silenceSegments.push({ start: currentStart, end });
|
||||
currentStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
return silenceSegments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从静音段反推有声段
|
||||
*/
|
||||
function invertSilenceToSound(
|
||||
silenceSegments: Array<{ start: number; end: number }>,
|
||||
totalDuration: number,
|
||||
minSoundDuration: number
|
||||
): AudioSegment[] {
|
||||
const soundSegments: AudioSegment[] = [];
|
||||
|
||||
// 如果没有静音段,说明整个音频都有声音
|
||||
if (silenceSegments.length === 0) {
|
||||
return [{
|
||||
startTime: 0,
|
||||
endTime: totalDuration,
|
||||
duration: totalDuration
|
||||
}];
|
||||
}
|
||||
|
||||
// 第一个有声段:从 0 到第一个静音段开始
|
||||
console.log(`[invertSilenceToSound] 检查第一个有声段: silenceSegments[0].start=${silenceSegments[0].start}, minSoundDuration=${minSoundDuration}`);
|
||||
if (silenceSegments[0].start > minSoundDuration) {
|
||||
console.log(`[invertSilenceToSound] ✅ 添加第一个有声段: 0.00s - ${silenceSegments[0].start.toFixed(2)}s`);
|
||||
soundSegments.push({
|
||||
startTime: 0,
|
||||
endTime: silenceSegments[0].start,
|
||||
duration: silenceSegments[0].start
|
||||
});
|
||||
} else {
|
||||
console.log(`[invertSilenceToSound] ⚠️ 跳过第一个有声段(太短)`);
|
||||
}
|
||||
|
||||
// 中间的有声段:从一个静音段结束到下一个静音段开始
|
||||
for (let i = 0; i < silenceSegments.length - 1; i++) {
|
||||
const start = silenceSegments[i].end;
|
||||
const end = silenceSegments[i + 1].start;
|
||||
const duration = end - start;
|
||||
|
||||
if (duration >= minSoundDuration) {
|
||||
soundSegments.push({
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
duration
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 最后一个有声段:从最后一个静音段结束到总时长
|
||||
const lastSilence = silenceSegments[silenceSegments.length - 1];
|
||||
if (totalDuration - lastSilence.end >= minSoundDuration) {
|
||||
soundSegments.push({
|
||||
startTime: lastSilence.end,
|
||||
endTime: totalDuration,
|
||||
duration: totalDuration - lastSilence.end
|
||||
});
|
||||
}
|
||||
|
||||
return soundSegments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本按照音频片段自动分配时间
|
||||
* @param text 文案内容(支持换行分割)
|
||||
* @param segments 音频片段列表
|
||||
* @returns 带有时间的字幕数据
|
||||
*/
|
||||
export function allocateTextToSegments(
|
||||
text: string,
|
||||
segments: AudioSegment[]
|
||||
): Array<{
|
||||
index: number;
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}> {
|
||||
// 按换行符或句号分割文本
|
||||
// 🔧 修复:不使用 \s(会匹配空格),改用 \n(只匹配换行)
|
||||
// 这样俄语、英语等语言不会被分割成单个单词
|
||||
const lines = text
|
||||
.split(/[\n。!?.!?]+/) // 只按换行符和句号等标点分割
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
try {
|
||||
|
||||
logger.info(`[VolumeDetect] 文案内容分割成 ${lines.length} 条,音频片段 ${segments.length} 个`);
|
||||
|
||||
console.log('\n========== 📄 文本分割结果 ==========');
|
||||
console.log(`原始文本长度: ${text.length}字符`);
|
||||
console.log(`分割后行数: ${lines.length}行`);
|
||||
console.log(`音频片段数: ${segments.length}个`);
|
||||
console.log('前5行预览:');
|
||||
try {
|
||||
lines.slice(0, 5).forEach((line, idx) => {
|
||||
console.log(` 行${idx + 1}: "${line.substring(0, 50)}${line.length > 50 ? '...' : ''}"`);
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('前5行预览失败:', e.message);
|
||||
}
|
||||
console.log('=====================================\n');
|
||||
|
||||
// 🔧 验证segments数据完整性
|
||||
console.log('\n========== 🔍 Segments数据验证 ==========');
|
||||
const invalidSegments = segments.filter((seg, idx) => {
|
||||
const hasStartTime = typeof seg.startTime === 'number' && !isNaN(seg.startTime);
|
||||
const hasEndTime = typeof seg.endTime === 'number' && !isNaN(seg.endTime);
|
||||
const hasDuration = typeof seg.duration === 'number' && !isNaN(seg.duration);
|
||||
if (!hasStartTime || !hasEndTime || !hasDuration) {
|
||||
console.error(`⚠️ 片段${idx + 1}数据异常:`, {
|
||||
startTime: seg.startTime,
|
||||
endTime: seg.endTime,
|
||||
duration: seg.duration,
|
||||
hasStartTime,
|
||||
hasEndTime,
|
||||
hasDuration
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (invalidSegments.length > 0) {
|
||||
throw new Error(`发现${invalidSegments.length}个无效的音频片段`);
|
||||
}
|
||||
console.log('✅ 所有segments数据验证通过');
|
||||
console.log('========================================\n');
|
||||
|
||||
// 如果没有检测到音频片段,平均分配时间
|
||||
if (segments.length === 0) {
|
||||
logger.warn('[VolumeDetect] 没有检测到音频片段,使用平均时间分配');
|
||||
const defaultDuration = 3; // 每行默认 3 秒
|
||||
return lines.map((line, index) => ({
|
||||
index: index + 1,
|
||||
text: line.trim(),
|
||||
startTime: index * defaultDuration,
|
||||
endTime: (index + 1) * defaultDuration
|
||||
}));
|
||||
}
|
||||
|
||||
const result: Array<{
|
||||
index: number;
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}> = [];
|
||||
|
||||
// 策略 1: 文本行数和音频片段数接近,一一对应
|
||||
if (Math.abs(lines.length - segments.length) <= 2) {
|
||||
logger.info('[VolumeDetect] 使用一一对应策略分配字幕');
|
||||
console.log(`\n🎯 使用一一对应策略: ${lines.length}行文本 ≈ ${segments.length}个音频片段`);
|
||||
lines.forEach((line, index) => {
|
||||
const segment = segments[Math.min(index, segments.length - 1)];
|
||||
result.push({
|
||||
index: index + 1,
|
||||
text: line.trim(),
|
||||
startTime: segment.startTime,
|
||||
endTime: segment.endTime
|
||||
});
|
||||
console.log(` 行${index + 1} -> 片段${Math.min(index + 1, segments.length)}: ${segment.startTime.toFixed(2)}s - ${segment.endTime.toFixed(2)}s`);
|
||||
});
|
||||
}
|
||||
// 策略 2: 文本行数较少,多个片段合并
|
||||
else if (lines.length < segments.length) {
|
||||
logger.info('[VolumeDetect] 文本行少,合并音频片段');
|
||||
const segmentsPerLine = Math.ceil(segments.length / lines.length);
|
||||
console.log(`\n🎯 使用合并策略: ${lines.length}行文本 < ${segments.length}个片段,每行合并${segmentsPerLine}个片段`);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const startSegmentIdx = index * segmentsPerLine;
|
||||
const endSegmentIdx = Math.min(startSegmentIdx + segmentsPerLine, segments.length);
|
||||
|
||||
const firstSegment = segments[startSegmentIdx];
|
||||
const lastSegment = segments[endSegmentIdx - 1];
|
||||
|
||||
result.push({
|
||||
index: index + 1,
|
||||
text: line.trim(),
|
||||
startTime: firstSegment.startTime,
|
||||
endTime: lastSegment.endTime
|
||||
});
|
||||
console.log(` 行${index + 1} -> 合并片段${startSegmentIdx + 1}-${endSegmentIdx}: ${firstSegment.startTime.toFixed(2)}s - ${lastSegment.endTime.toFixed(2)}s | "${line.trim().substring(0, 30)}..."`);
|
||||
});
|
||||
}
|
||||
// 策略 3: 文本行数较多,拆分音频片段
|
||||
else {
|
||||
logger.info('[VolumeDetect] 文本行多,拆分音频片段');
|
||||
console.log(`\n🎯 使用拆分策略: ${lines.length}行文本 > ${segments.length}个片段`);
|
||||
let currentLineIdx = 0;
|
||||
|
||||
segments.forEach((segment, segIdx) => {
|
||||
if (currentLineIdx >= lines.length) return;
|
||||
|
||||
const remainingLines = lines.length - currentLineIdx;
|
||||
const remainingSegments = segments.length - segIdx;
|
||||
const linesPerSegment = Math.ceil(remainingLines / remainingSegments);
|
||||
const duration = segment.duration;
|
||||
const durationPerLine = duration / linesPerSegment;
|
||||
|
||||
console.log(` 片段${segIdx + 1}(${segment.startTime.toFixed(2)}s-${segment.endTime.toFixed(2)}s) 拆分成${linesPerSegment}行:`);
|
||||
|
||||
for (let i = 0; i < linesPerSegment && currentLineIdx < lines.length; i++) {
|
||||
const startTime = segment.startTime + i * durationPerLine;
|
||||
const endTime = segment.startTime + (i + 1) * durationPerLine;
|
||||
result.push({
|
||||
index: currentLineIdx + 1,
|
||||
text: lines[currentLineIdx].trim(),
|
||||
startTime: startTime,
|
||||
endTime: endTime
|
||||
});
|
||||
console.log(` 行${currentLineIdx + 1}: ${startTime.toFixed(2)}s - ${endTime.toFixed(2)}s | "${lines[currentLineIdx].trim().substring(0, 30)}..."`);
|
||||
currentLineIdx++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[VolumeDetect] 字幕分配完成,共 ${result.length} 条`);
|
||||
|
||||
// 🔧 修复:如果第一条字幕开始时间太晚(超过1秒),强制提前到接近0秒
|
||||
// 这样可以避免视频开头长时间没有字幕的问题
|
||||
if (result.length > 0 && result[0].startTime > 1.0) {
|
||||
const firstDelay = result[0].startTime;
|
||||
logger.warn(`[VolumeDetect] ⚠️ 第一条字幕开始时间较晚: ${firstDelay.toFixed(2)}s,调整为0.3s`);
|
||||
console.log(`\n⚠️ 第一条字幕开始时间较晚: ${firstDelay.toFixed(2)}s,调整为0.3s`);
|
||||
|
||||
// 将第一条字幕提前到0.3秒开始,保持持续时长不变
|
||||
const firstDuration = result[0].endTime - result[0].startTime;
|
||||
result[0].startTime = 0.3;
|
||||
result[0].endTime = 0.3 + firstDuration;
|
||||
|
||||
logger.info(`[VolumeDetect] ✅ 已调整第一条字幕时间: 0.3s - ${result[0].endTime.toFixed(2)}s`);
|
||||
console.log(`✅ 已调整第一条字幕时间: 0.3s - ${result[0].endTime.toFixed(2)}s\n`);
|
||||
}
|
||||
|
||||
// 输出字幕时间详情
|
||||
console.log('\n========== 📝 字幕时间分配详情 ==========');
|
||||
console.log(`总字幕数: ${result.length}条`);
|
||||
result.forEach((subtitle, idx) => {
|
||||
const preview = subtitle.text.substring(0, 30) + (subtitle.text.length > 30 ? '...' : '');
|
||||
console.log(` 字幕${idx + 1}: ${subtitle.startTime.toFixed(2)}s - ${subtitle.endTime.toFixed(2)}s | "${preview}"`);
|
||||
});
|
||||
console.log('========================================\n');
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[VolumeDetect] ❌ 字幕时间分配失败:', error.message);
|
||||
console.error('[VolumeDetect] 错误堆栈:', error.stack);
|
||||
console.error('[VolumeDetect] 文本长度:', text.length);
|
||||
console.error('[VolumeDetect] 文本行数:', lines.length);
|
||||
console.error('[VolumeDetect] 音频片段数:', segments.length);
|
||||
if (segments.length > 0) {
|
||||
console.error('[VolumeDetect] 第一个片段:', segments[0]);
|
||||
console.error('[VolumeDetect] 最后一个片段:', segments[segments.length - 1]);
|
||||
}
|
||||
|
||||
// 返回降级结果:按行数平均分配时间
|
||||
logger.warn('[VolumeDetect] 使用降级方案:平均分配时间');
|
||||
return lines.map((line, index) => ({
|
||||
index: index + 1,
|
||||
text: line,
|
||||
startTime: index * 3,
|
||||
endTime: (index + 1) * 3
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AppConfig } from "../../../src/config";
|
||||
|
||||
function normalizeAuthServerUrl(apiBaseUrl: string): string {
|
||||
return String(apiBaseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/api\/?$/i, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export const AUTH_SERVER_URL = normalizeAuthServerUrl(
|
||||
process.env.AUTH_SERVER_URL || AppConfig.apiBaseUrl || "http://152.136.232.83:3002"
|
||||
);
|
||||
|
||||
export const USE_REMOTE_AUTH = true;
|
||||
@@ -0,0 +1,321 @@
|
||||
import { ipcMain, net } from "electron";
|
||||
import { Log } from "../log/main";
|
||||
import { AUTH_SERVER_URL, USE_REMOTE_AUTH } from "./config";
|
||||
|
||||
const authUrl = (path: string) => `${AUTH_SERVER_URL.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
||||
|
||||
/**
|
||||
* 使用 Electron net 模块发送 HTTP 请求
|
||||
*/
|
||||
const fetchApi = async (url: string, options: { method?: string; body?: string } = {}): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = net.request({
|
||||
method: options.method || 'GET',
|
||||
url: url
|
||||
});
|
||||
|
||||
request.setHeader('Content-Type', 'application/json');
|
||||
|
||||
let responseData = '';
|
||||
|
||||
request.on('response', (response) => {
|
||||
response.on('data', (chunk) => {
|
||||
responseData += chunk.toString();
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(responseData));
|
||||
} catch (e) {
|
||||
reject(new Error('Invalid JSON response'));
|
||||
}
|
||||
});
|
||||
|
||||
response.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
request.write(options.body);
|
||||
}
|
||||
|
||||
request.end();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 远程认证模块
|
||||
* 通过 HTTP 调用 user-server API
|
||||
*/
|
||||
|
||||
interface RegisterResult {
|
||||
success: boolean;
|
||||
userId?: number;
|
||||
token?: string;
|
||||
message?: string;
|
||||
user?: any;
|
||||
subscription?: any;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
user?: any;
|
||||
subscription?: any;
|
||||
message?: string;
|
||||
needUnbind?: boolean;
|
||||
currentDevices?: number;
|
||||
maxDevices?: number;
|
||||
}
|
||||
|
||||
let cachedUserId: number | null = null;
|
||||
|
||||
function setCachedUserId(userId: number | null) {
|
||||
if (userId) cachedUserId = userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化认证模块
|
||||
*/
|
||||
const init = async () => {
|
||||
Log.info('Auth.init', `认证模块初始化完成 (远程模式: ${USE_REMOTE_AUTH ? '是' : '否'})`);
|
||||
Log.info('Auth.init', `认证服务器: ${AUTH_SERVER_URL}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取设备指纹
|
||||
*/
|
||||
const getDeviceFingerprint = async (): Promise<string> => {
|
||||
try {
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 收集设备信息
|
||||
const info = [
|
||||
os.hostname(),
|
||||
os.platform(),
|
||||
os.arch(),
|
||||
os.cpus()[0]?.model || 'unknown',
|
||||
// 获取第一个非内部网卡的MAC地址
|
||||
(Object.values(os.networkInterfaces())
|
||||
.flat()
|
||||
.find((i: any) => i && !i.internal && i.mac !== '00:00:00:00:00:00') as any)?.mac || 'unknown'
|
||||
].join('|');
|
||||
|
||||
// 生成哈希
|
||||
return crypto.createHash('sha256').update(info).digest('hex').substring(0, 32);
|
||||
} catch (error) {
|
||||
return 'unknown-device';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
const register = async (username: string, email: string, password: string, inviteCode?: string): Promise<RegisterResult> => {
|
||||
try {
|
||||
const deviceFingerprint = await getDeviceFingerprint();
|
||||
|
||||
const result = await fetchApi(authUrl('/api/register'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
invite_code: (inviteCode || '').trim(),
|
||||
device_fingerprint: deviceFingerprint
|
||||
})
|
||||
});
|
||||
Log.info('Auth.register', `注册结果: ${result.success ? '成功' : result.message}`);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
Log.error('Auth.register', `注册失败: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `网络错误: ${error.message}。请确保认证服务器正在运行。`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
const login = async (email: string, password: string): Promise<LoginResult> => {
|
||||
try {
|
||||
const deviceFingerprint = await getDeviceFingerprint();
|
||||
|
||||
const result = await fetchApi(authUrl('/api/login'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, device_fingerprint: deviceFingerprint })
|
||||
});
|
||||
Log.info('Auth.login', `登录结果: ${result.success ? '成功' : result.message}`);
|
||||
if (result.success && result.user?.id) setCachedUserId(result.user.id);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
Log.error('Auth.login', `登录失败: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `网络错误: ${error.message}。请确保认证服务器正在运行。`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
*/
|
||||
const verifyToken = async (token: string): Promise<{ valid: boolean; payload?: any; message?: string }> => {
|
||||
try {
|
||||
const deviceFingerprint = await getDeviceFingerprint();
|
||||
|
||||
const result = await fetchApi(authUrl('/api/verify'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, device_fingerprint: deviceFingerprint })
|
||||
});
|
||||
if (result.valid && result.user?.id) setCachedUserId(result.user.id);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `网络错误: ${error.message}`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
const getCurrentUser = async (token: string): Promise<{ success: boolean; user?: any; subscription?: any; message?: string }> => {
|
||||
try {
|
||||
const result = await fetchApi(authUrl('/api/verify'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token })
|
||||
});
|
||||
|
||||
if (result.valid) {
|
||||
return {
|
||||
success: true,
|
||||
user: result.user,
|
||||
subscription: result.subscription
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || 'Token无效'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `网络错误: ${error.message}`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查订阅状态
|
||||
*/
|
||||
const checkSubscriptionStatus = async (userId: number): Promise<{
|
||||
hasAccess: boolean;
|
||||
expiresAt?: string;
|
||||
daysRemaining?: number;
|
||||
isExpired?: boolean;
|
||||
subscription?: any;
|
||||
}> => {
|
||||
try {
|
||||
const result = await fetchApi(authUrl('/api/subscription/check'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
});
|
||||
|
||||
return {
|
||||
hasAccess: result.hasAccess,
|
||||
expiresAt: result.expiresAt,
|
||||
daysRemaining: result.remainingDays,
|
||||
isExpired: result.isExpired,
|
||||
subscription: result.subscription
|
||||
};
|
||||
} catch (error: any) {
|
||||
Log.error('Auth.checkSubscriptionStatus', `检查订阅状态失败: ${error.message}`);
|
||||
return {
|
||||
hasAccess: false,
|
||||
isExpired: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设备签到
|
||||
*/
|
||||
const deviceCheckin = async (userId: number): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const deviceFingerprint = await getDeviceFingerprint();
|
||||
|
||||
const result = await fetchApi(authUrl('/api/device/checkin'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId, device_fingerprint: deviceFingerprint })
|
||||
});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// IPC 处理器
|
||||
ipcMain.handle("auth:register", async (event, username: string, email: string, password: string, inviteCode?: string) => {
|
||||
return register(username, email, password, inviteCode);
|
||||
});
|
||||
|
||||
ipcMain.handle("auth:login", async (event, email: string, password: string) => {
|
||||
return login(email, password);
|
||||
});
|
||||
|
||||
ipcMain.handle("auth:verifyToken", async (event, token: string) => {
|
||||
return verifyToken(token);
|
||||
});
|
||||
|
||||
ipcMain.handle("auth:getCurrentUser", async (event, token: string) => {
|
||||
return getCurrentUser(token);
|
||||
});
|
||||
|
||||
ipcMain.handle("auth:checkSubscriptionStatus", async (event, userId: number) => {
|
||||
return checkSubscriptionStatus(userId);
|
||||
});
|
||||
|
||||
ipcMain.handle("auth:deviceCheckin", async (event, userId: number) => {
|
||||
return deviceCheckin(userId);
|
||||
});
|
||||
|
||||
ipcMain.handle("auth:reportGeneration", async (event, params: { userId: number; type: string; detail?: string }) => {
|
||||
return reportGeneration(params.userId, params.type, params.detail);
|
||||
});
|
||||
|
||||
async function reportGeneration(userId: number | null, type: string, detail?: string) {
|
||||
try {
|
||||
const uid = userId || cachedUserId;
|
||||
if (!uid) return;
|
||||
await fetchApi(authUrl('/api/user/generation-log'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId: uid, type, detail: detail || '' })
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export default {
|
||||
init,
|
||||
register,
|
||||
login,
|
||||
verifyToken,
|
||||
getCurrentUser,
|
||||
checkSubscriptionStatus,
|
||||
deviceCheckin,
|
||||
getDeviceFingerprint,
|
||||
reportGeneration
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
/**
|
||||
* 认证 IPC 接口
|
||||
* 由渲染进程调用,通过 IPC 与主进程通信
|
||||
*/
|
||||
|
||||
interface RegisterData {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
invite_code?: string;
|
||||
}
|
||||
|
||||
interface LoginData {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
const register = async (data: RegisterData) => {
|
||||
return await ipcRenderer.invoke("auth:register", data.username, data.email, data.password, data.invite_code || '');
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
const login = async (data: LoginData) => {
|
||||
return await ipcRenderer.invoke("auth:login", data.email, data.password);
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
*/
|
||||
const verifyToken = async (token: string) => {
|
||||
return await ipcRenderer.invoke("auth:verifyToken", token);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
const getCurrentUser = async (token: string) => {
|
||||
return await ipcRenderer.invoke("auth:getCurrentUser", token);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查订阅状态
|
||||
*/
|
||||
const checkSubscriptionStatus = async (userId: number) => {
|
||||
return await ipcRenderer.invoke("auth:checkSubscriptionStatus", userId);
|
||||
};
|
||||
|
||||
export default {
|
||||
register,
|
||||
login,
|
||||
verifyToken,
|
||||
getCurrentUser,
|
||||
checkSubscriptionStatus
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import logger from '../log/main';
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import FormData from 'form-data';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import { getPythonExecutablePath, resourceExists } from '../../lib/resource-path';
|
||||
import { buildRuntimeProcessEnv } from '../shell';
|
||||
|
||||
const COMPSHARE_API_BASE = 'https://api.compshare.cn';
|
||||
|
||||
let autoShutdownTimer: NodeJS.Timeout | null = null;
|
||||
let lastTaskTime: number = 0;
|
||||
const AUTO_SHUTDOWN_DELAY = 10 * 60 * 1000;
|
||||
|
||||
interface SelfServerConfig {
|
||||
region: string;
|
||||
zone: string;
|
||||
projectId: string;
|
||||
uhostId: string;
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
let currentServerConfig: SelfServerConfig | null = null;
|
||||
let apiKey: { publicKey: string; privateKey: string } | null = null;
|
||||
|
||||
export function setCompShareApiKey(publicKey: string, privateKey: string) {
|
||||
apiKey = { publicKey, privateKey };
|
||||
}
|
||||
|
||||
function generateSignature(params: Record<string, string>): string {
|
||||
if (!apiKey) {
|
||||
throw new Error('API密钥未配置');
|
||||
}
|
||||
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
const paramString = sortedKeys.map(key => `${key}${params[key]}`).join('');
|
||||
const stringToSign = paramString + apiKey.privateKey;
|
||||
|
||||
return crypto.createHash('sha1').update(stringToSign).digest('hex');
|
||||
}
|
||||
|
||||
function buildSignedParams(action: string, extraParams: Record<string, string> = {}): URLSearchParams {
|
||||
if (!apiKey) {
|
||||
throw new Error('API密钥未配置');
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
const nonce = Math.random().toString(36).substring(2);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
Action: action,
|
||||
PublicKey: apiKey.publicKey,
|
||||
Timestamp: timestamp,
|
||||
Nonce: nonce,
|
||||
...extraParams
|
||||
};
|
||||
|
||||
const signature = generateSignature(params);
|
||||
params.Signature = signature;
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
urlParams.append(key, value);
|
||||
}
|
||||
|
||||
return urlParams;
|
||||
}
|
||||
|
||||
export function getSelfServerConfig(): SelfServerConfig | null {
|
||||
return currentServerConfig;
|
||||
}
|
||||
|
||||
export function setSelfServerConfig(config: SelfServerConfig) {
|
||||
currentServerConfig = config;
|
||||
}
|
||||
|
||||
export async function startCompShareInstance(config: {
|
||||
region: string;
|
||||
zone: string;
|
||||
projectId: string;
|
||||
uhostId: string;
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const extraParams: Record<string, string> = {
|
||||
Region: config.region,
|
||||
Zone: config.zone,
|
||||
UHostId: config.uhostId,
|
||||
WithoutGpu: 'false'
|
||||
};
|
||||
if (config.projectId) {
|
||||
extraParams.ProjectId = config.projectId;
|
||||
}
|
||||
|
||||
const params = buildSignedParams('StartCompShareInstance', extraParams);
|
||||
const response = await axios.get(`${COMPSHARE_API_BASE}/?${params.toString()}`);
|
||||
const data = response.data;
|
||||
|
||||
if (data.RetCode === 0) {
|
||||
logger.info('[CompShare] 实例启动成功', { uhostId: config.uhostId });
|
||||
return { success: true, message: '实例启动成功' };
|
||||
} else {
|
||||
logger.error('[CompShare] 实例启动失败', data);
|
||||
return { success: false, message: `启动失败: ${data.Message || '未知错误'}` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[CompShare] 启动实例异常', error);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopCompShareInstance(config: {
|
||||
region: string;
|
||||
zone: string;
|
||||
projectId: string;
|
||||
uhostId: string;
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const extraParams: Record<string, string> = {
|
||||
Region: config.region,
|
||||
Zone: config.zone,
|
||||
UHostId: config.uhostId
|
||||
};
|
||||
if (config.projectId) {
|
||||
extraParams.ProjectId = config.projectId;
|
||||
}
|
||||
|
||||
const params = buildSignedParams('StopCompShareInstance', extraParams);
|
||||
const response = await axios.get(`${COMPSHARE_API_BASE}/?${params.toString()}`);
|
||||
const data = response.data;
|
||||
|
||||
if (data.RetCode === 0) {
|
||||
logger.info('[CompShare] 实例关闭成功', { uhostId: config.uhostId });
|
||||
return { success: true, message: '实例关闭成功' };
|
||||
} else {
|
||||
logger.error('[CompShare] 实例关闭失败', data);
|
||||
return { success: false, message: `关闭失败: ${data.Message || '未知错误'}` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[CompShare] 关闭实例异常', error);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function describeCompShareInstance(config: {
|
||||
region?: string;
|
||||
zone?: string;
|
||||
projectId?: string;
|
||||
uhostIds?: string[];
|
||||
}): Promise<{ success: boolean; instances?: any[]; message?: string }> {
|
||||
try {
|
||||
const extraParams: Record<string, string> = {};
|
||||
if (config.region) extraParams.Region = config.region;
|
||||
if (config.zone) extraParams.Zone = config.zone;
|
||||
if (config.projectId) extraParams.ProjectId = config.projectId;
|
||||
if (config.uhostIds && config.uhostIds.length > 0) {
|
||||
config.uhostIds.forEach((id, index) => {
|
||||
extraParams[`UHostIds.${index}`] = id;
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildSignedParams('DescribeCompShareInstance', extraParams);
|
||||
const response = await axios.get(`${COMPSHARE_API_BASE}/?${params.toString()}`);
|
||||
const data = response.data;
|
||||
|
||||
if (data.RetCode === 0) {
|
||||
return { success: true, instances: data.UHostSet || [] };
|
||||
} else {
|
||||
return { success: false, message: `查询失败: ${data.Message || '未知错误'}` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[CompShare] 查询实例异常', error);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function processDigitalHuman(params: {
|
||||
apiUrl: string;
|
||||
audioFile: string;
|
||||
videoFile: string;
|
||||
}): Promise<{ success: boolean; videoPath?: string; message?: string }> {
|
||||
const { apiUrl, audioFile, videoFile } = params;
|
||||
logger.info('[CompShare] 开始数字人处理', { apiUrl, audioFile, videoFile });
|
||||
|
||||
try {
|
||||
// 使用 Python 脚本调用 Gradio API
|
||||
let scriptPath: string = '';
|
||||
|
||||
// 尝试多个可能的路径
|
||||
const possiblePaths = [
|
||||
path.join(process.resourcesPath || '', 'scripts', 'digital_human_process.py'),
|
||||
path.join(process.env.APP_ROOT || '', 'scripts', 'digital_human_process.py'),
|
||||
path.join(process.cwd(), 'scripts', 'digital_human_process.py'),
|
||||
path.join(process.cwd(), '..', 'scripts', 'digital_human_process.py'),
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
scriptPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scriptPath) {
|
||||
// 如果都找不到,使用 cwd 版本
|
||||
scriptPath = path.join(process.cwd(), 'scripts', 'digital_human_process.py');
|
||||
}
|
||||
|
||||
logger.info('[CompShare] Python 脚本路径', { scriptPath });
|
||||
|
||||
// ✅ 关键修复:使用内嵌的 Python,避免依赖系统安装
|
||||
let pythonExe = getPythonExecutablePath();
|
||||
if (!resourceExists(pythonExe)) {
|
||||
logger.warn(`[CompShare] 内嵌Python不存在: ${pythonExe},尝试系统python`);
|
||||
pythonExe = 'python'; // 回退到系统 python
|
||||
}
|
||||
logger.info('[CompShare] 使用Python路径', { pythonExe });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const pythonProcess = spawn(pythonExe, [scriptPath, apiUrl, audioFile, videoFile], {
|
||||
cwd: process.cwd(),
|
||||
env: buildRuntimeProcessEnv(),
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
pythonProcess.stdout.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
pythonProcess.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
pythonProcess.on('close', (code: number) => {
|
||||
logger.info('[CompShare] Python 脚本执行完成', { code, stdout: stdout.substring(0, 500) });
|
||||
|
||||
if (code === 0 && stdout.trim()) {
|
||||
try {
|
||||
const stdoutLines = stdout
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const jsonLine = [...stdoutLines].reverse().find(line => line.startsWith('{') && line.endsWith('}'));
|
||||
|
||||
if (!jsonLine) {
|
||||
resolve({ success: false, message: `解析结果失败: ${stdout.substring(0, 200)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonLine);
|
||||
if (result.success) {
|
||||
resolve({ success: true, videoPath: result.videoPath });
|
||||
} else {
|
||||
resolve({ success: false, message: result.error || '处理失败' });
|
||||
}
|
||||
} catch (e) {
|
||||
resolve({ success: false, message: `解析结果失败: ${stdout.substring(0, 200)}` });
|
||||
}
|
||||
} else {
|
||||
resolve({ success: false, message: stderr || `进程退出码: ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
pythonProcess.on('error', (err: Error) => {
|
||||
logger.error('[CompShare] Python 进程错误', err);
|
||||
resolve({ success: false, message: err.message });
|
||||
});
|
||||
|
||||
// 10分钟超时
|
||||
setTimeout(() => {
|
||||
pythonProcess.kill();
|
||||
resolve({ success: false, message: '处理超时' });
|
||||
}, 600000);
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
const errorDetail = error.message || '未知错误';
|
||||
logger.error('[CompShare] 数字人处理失败', errorDetail);
|
||||
return { success: false, message: errorDetail };
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLastTaskTime() {
|
||||
lastTaskTime = Date.now();
|
||||
|
||||
if (autoShutdownTimer) {
|
||||
clearTimeout(autoShutdownTimer);
|
||||
}
|
||||
|
||||
autoShutdownTimer = setTimeout(async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastTaskTime >= AUTO_SHUTDOWN_DELAY && currentServerConfig) {
|
||||
logger.info('[CompShare] 10分钟无任务,自动关闭实例');
|
||||
await stopCompShareInstance(currentServerConfig);
|
||||
autoShutdownTimer = null;
|
||||
}
|
||||
}, AUTO_SHUTDOWN_DELAY);
|
||||
}
|
||||
|
||||
export function registerCompShareHandlers() {
|
||||
ipcMain.handle('compshare:setApiKey', async (event, params: { publicKey: string; privateKey: string }) => {
|
||||
logger.info('[CompShare:IPC] 设置API密钥');
|
||||
setCompShareApiKey(params.publicKey, params.privateKey);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('compshare:startInstance', async (event, params) => {
|
||||
logger.info('[CompShare:IPC] 启动实例', params);
|
||||
const result = await startCompShareInstance(params);
|
||||
if (result.success) {
|
||||
setSelfServerConfig({
|
||||
region: params.region,
|
||||
zone: params.zone,
|
||||
projectId: params.projectId,
|
||||
uhostId: params.uhostId,
|
||||
apiUrl: ''
|
||||
});
|
||||
updateLastTaskTime();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle('compshare:stopInstance', async (event, params) => {
|
||||
logger.info('[CompShare:IPC] 关闭实例', params);
|
||||
return await stopCompShareInstance(params);
|
||||
});
|
||||
|
||||
ipcMain.handle('compshare:describeInstance', async (event, params) => {
|
||||
logger.info('[CompShare:IPC] 查询实例', params);
|
||||
return await describeCompShareInstance(params);
|
||||
});
|
||||
|
||||
ipcMain.handle('compshare:digitalHumanProcess', async (event, params) => {
|
||||
logger.info('[CompShare:IPC] 数字人处理', { apiUrl: params.apiUrl });
|
||||
const result = await processDigitalHuman(params);
|
||||
if (result.success) {
|
||||
updateLastTaskTime();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
logger.info('[CompShare:IPC] Handlers已注册');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
export default {
|
||||
async setApiKey(params: { publicKey: string; privateKey: string }) {
|
||||
return await ipcRenderer.invoke('compshare:setApiKey', params);
|
||||
},
|
||||
|
||||
async startInstance(params: {
|
||||
region: string;
|
||||
zone: string;
|
||||
projectId: string;
|
||||
uhostId: string;
|
||||
}) {
|
||||
return await ipcRenderer.invoke('compshare:startInstance', params);
|
||||
},
|
||||
|
||||
async stopInstance(params: {
|
||||
region: string;
|
||||
zone: string;
|
||||
projectId: string;
|
||||
uhostId: string;
|
||||
}) {
|
||||
return await ipcRenderer.invoke('compshare:stopInstance', params);
|
||||
},
|
||||
|
||||
async describeInstance(params: {
|
||||
region?: string;
|
||||
zone?: string;
|
||||
projectId?: string;
|
||||
uhostIds?: string[];
|
||||
}) {
|
||||
return await ipcRenderer.invoke('compshare:describeInstance', params);
|
||||
},
|
||||
|
||||
async digitalHumanProcess(params: {
|
||||
apiUrl: string;
|
||||
audioFile: string;
|
||||
videoFile: string;
|
||||
}) {
|
||||
return await ipcRenderer.invoke('compshare:digitalHumanProcess', params);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { StorageMain } from '../storage/main';
|
||||
import { ConfigMain } from './main';
|
||||
import { Log } from '../log/main';
|
||||
|
||||
export interface ExportableConfig {
|
||||
version: string;
|
||||
exportDate: string;
|
||||
config: {
|
||||
// 基础配置
|
||||
[key: string]: any;
|
||||
// 提示词配置
|
||||
prompts?: {
|
||||
[key: string]: any;
|
||||
};
|
||||
// 在线模型配置
|
||||
onlineModels?: {
|
||||
providers?: any[];
|
||||
currentProviderId?: string;
|
||||
modelSettings?: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出当前用户配置
|
||||
*/
|
||||
export async function exportCurrentConfig(): Promise<ExportableConfig> {
|
||||
const config: ExportableConfig = {
|
||||
version: app.getVersion(),
|
||||
exportDate: new Date().toISOString(),
|
||||
config: {}
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. 导出基础配置
|
||||
const appConfig = await ConfigMain.all();
|
||||
config.config = { ...appConfig };
|
||||
|
||||
// 2. 导出提示词(从 storage 中读取)
|
||||
const globalStorage = await StorageMain.all('global');
|
||||
const prompts: any = {};
|
||||
|
||||
// 提取所有包含 Prompt 的键
|
||||
for (const [key, value] of Object.entries(globalStorage)) {
|
||||
if (key.toLowerCase().includes('prompt') ||
|
||||
key.toLowerCase().includes('提示词')) {
|
||||
prompts[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(prompts).length > 0) {
|
||||
config.config.prompts = prompts;
|
||||
}
|
||||
|
||||
// 3. 导出在线模型配置
|
||||
const onlineModels: any = {};
|
||||
|
||||
if (globalStorage.provider) {
|
||||
onlineModels.providers = globalStorage.provider;
|
||||
}
|
||||
if (globalStorage.currentProviderId) {
|
||||
onlineModels.currentProviderId = globalStorage.currentProviderId;
|
||||
}
|
||||
if (globalStorage.modelSettings) {
|
||||
onlineModels.modelSettings = globalStorage.modelSettings;
|
||||
}
|
||||
|
||||
if (Object.keys(onlineModels).length > 0) {
|
||||
config.config.onlineModels = onlineModels;
|
||||
}
|
||||
|
||||
console.log('[exportConfig] 配置导出成功', {
|
||||
version: config.version,
|
||||
promptCount: Object.keys(prompts).length,
|
||||
providersCount: onlineModels.providers?.length || 0
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error: any) {
|
||||
console.error('[exportConfig] 配置导出失败', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置到文件
|
||||
*/
|
||||
export async function saveConfigToFile(
|
||||
config: ExportableConfig,
|
||||
targetPath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const dir = path.dirname(targetPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
targetPath,
|
||||
JSON.stringify(config, null, 4),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
console.log(`✅ 配置已导出到: ${targetPath}`);
|
||||
} catch (error: any) {
|
||||
console.error('❌ 保存配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {callHandleFromMainOrRender} from "../env";
|
||||
|
||||
const all = async () => {
|
||||
return callHandleFromMainOrRender("config:all");
|
||||
};
|
||||
|
||||
const get = async (key: string, defaultValue: any = null) => {
|
||||
return callHandleFromMainOrRender("config:get", key, defaultValue);
|
||||
};
|
||||
const set = async (key: string, value: any) => {
|
||||
await callHandleFromMainOrRender("config:set", key, value);
|
||||
};
|
||||
|
||||
const allEnv = async () => {
|
||||
return callHandleFromMainOrRender("config:allEnv");
|
||||
};
|
||||
|
||||
const getEnv = async (key: string, defaultValue: any = null) => {
|
||||
return callHandleFromMainOrRender("config:getEnv", key, defaultValue);
|
||||
};
|
||||
|
||||
const setEnv = async (key: string, value: any) => {
|
||||
await callHandleFromMainOrRender("config:setEnv", key, value);
|
||||
};
|
||||
|
||||
export const ConfigIndex = {
|
||||
all,
|
||||
get,
|
||||
set,
|
||||
allEnv,
|
||||
getEnv,
|
||||
setEnv,
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 应用配置初始化模块
|
||||
* 在应用启动时自动检查并初始化应用配置(包括字幕模板)
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Log } from '../log/main';
|
||||
import DB from '../db/main';
|
||||
import { getCommonResourcePath } from '../../lib/resource-path';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* 读取默认字幕模板配置文件
|
||||
*/
|
||||
const loadDefaultSubtitleTemplates = (): any | null => {
|
||||
try {
|
||||
// 尝试多个可能的路径
|
||||
const possiblePaths = [
|
||||
// 优先尝试使用统一资源路径工具(支持开发和生产环境)
|
||||
getCommonResourcePath('config/default-subtitle-templates.json'),
|
||||
path.join(__dirname, '../../config/default-subtitle-templates.json'),
|
||||
path.join(__dirname, '../../../config/default-subtitle-templates.json'),
|
||||
path.join(process.cwd(), 'electron/config/default-subtitle-templates.json'),
|
||||
path.join(process.cwd(), 'dist-electron/config/default-subtitle-templates.json'),
|
||||
];
|
||||
|
||||
let configPath: string | null = null;
|
||||
let configContent: string | null = null;
|
||||
|
||||
for (const tryPath of possiblePaths) {
|
||||
console.log('[initAppConfig] 尝试字幕模板配置路径:', tryPath);
|
||||
if (fs.existsSync(tryPath)) {
|
||||
configPath = tryPath;
|
||||
configContent = fs.readFileSync(tryPath, 'utf-8');
|
||||
console.log('[initAppConfig] ✅ 找到字幕模板配置文件:', tryPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configPath || !configContent) {
|
||||
console.warn('[initAppConfig] ⚠️ 字幕模板配置文件不存在,将使用系统默认值');
|
||||
Log.warn('initAppConfig.loadSubtitleTemplates', '字幕模板配置文件不存在');
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = JSON.parse(configContent);
|
||||
|
||||
console.log('[initAppConfig] 成功加载字幕模板配置:', {
|
||||
version: config.version,
|
||||
templateCount: config.subtitleTemplates?.length || 0,
|
||||
keywordGroupCount: config.keywordGroups?.length || 0
|
||||
});
|
||||
|
||||
Log.info('initAppConfig.loadSubtitleTemplates', '成功加载字幕模板配置', {
|
||||
version: config.version,
|
||||
templateCount: config.subtitleTemplates?.length || 0,
|
||||
keywordGroupCount: config.keywordGroups?.length || 0
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('[initAppConfig] 加载字幕模板配置失败:', error);
|
||||
Log.error('initAppConfig.loadSubtitleTemplates', '加载字幕模板配置失败', { error });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化字幕模板配置
|
||||
* 在Electron主进程中,将字幕样式初始化到数据库
|
||||
*/
|
||||
const initSubtitleTemplates = async (config: any): Promise<void> => {
|
||||
try {
|
||||
if (!config?.subtitleTemplates || config.subtitleTemplates.length === 0) {
|
||||
console.warn('[initAppConfig] 没有字幕模板需要初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔧 新增:将字幕样式插入到数据库
|
||||
if (DB) {
|
||||
for (const template of config.subtitleTemplates) {
|
||||
if (template.config?.subtitleStyle) {
|
||||
const style = template.config.subtitleStyle;
|
||||
|
||||
// 确保有必要的时间戳
|
||||
const now = Date.now();
|
||||
const createdAt = style.createdAt || now;
|
||||
const updatedAt = style.updatedAt || now;
|
||||
|
||||
try {
|
||||
// 检查样式是否已存在
|
||||
const existing = await DB.first('SELECT id FROM subtitle_styles WHERE id = ?', [style.id]);
|
||||
|
||||
if (!existing) {
|
||||
// 插入新样式
|
||||
const sql = `
|
||||
INSERT INTO subtitle_styles
|
||||
(id, name, description, preview, fontName, fontSize, fontColor, outlineColor,
|
||||
outlineWidth, shadowOffset, shadowColor, shadowBlur, backgroundColor,
|
||||
backgroundOpacity, position, alignment, is_system, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
await DB.insert(sql, [
|
||||
style.id,
|
||||
style.name,
|
||||
style.description,
|
||||
style.preview,
|
||||
style.fontName,
|
||||
style.fontSize,
|
||||
style.fontColor,
|
||||
style.outlineColor,
|
||||
style.outlineWidth,
|
||||
style.shadowOffset,
|
||||
style.shadowColor,
|
||||
style.shadowBlur,
|
||||
style.backgroundColor,
|
||||
style.backgroundOpacity,
|
||||
style.position,
|
||||
style.alignment,
|
||||
1, // is_system = true
|
||||
createdAt,
|
||||
updatedAt
|
||||
]);
|
||||
|
||||
console.log('[initAppConfig] ✅ 字幕样式已插入数据库:', style.id);
|
||||
} else {
|
||||
console.log('[initAppConfig] ℹ️ 字幕样式已存在,跳过插入:', style.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[initAppConfig] 字幕样式插入失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[initAppConfig] ✅ 字幕模板配置已加载, 总数:', config.subtitleTemplates.length);
|
||||
Log.info('initAppConfig.initSubtitleTemplates', '字幕模板配置已加载', {
|
||||
count: config.subtitleTemplates.length,
|
||||
note: '字幕样式已初始化到数据库'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[initAppConfig] 字幕模板初始化失败:', error);
|
||||
Log.error('initAppConfig.initSubtitleTemplates', '字幕模板初始化失败', { error });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化关键词分组
|
||||
* 将默认关键词分组保存到数据库,首次启动时自动导入
|
||||
*/
|
||||
const initKeywordGroups = async (config: any): Promise<void> => {
|
||||
try {
|
||||
if (!config?.keywordGroups || config.keywordGroups.length === 0) {
|
||||
console.warn('[initAppConfig] 没有关键词分组需要初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
let insertedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const group of config.keywordGroups) {
|
||||
try {
|
||||
// 检查分组是否已存在
|
||||
const existing = await DB.first(
|
||||
'SELECT id FROM keyword_groups WHERE id = ?',
|
||||
[group.id]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
// 准备数据
|
||||
const now = Date.now();
|
||||
const keywordsJson = JSON.stringify(group.keywords || []);
|
||||
const styleOverrideJson = group.styleOverride ? JSON.stringify(group.styleOverride) : null;
|
||||
const effectConfigJson = group.effectConfig ? JSON.stringify(group.effectConfig) : null;
|
||||
const styleConfigJson = group.styleConfig ? JSON.stringify(group.styleConfig) : null;
|
||||
|
||||
// 插入新分组
|
||||
await DB.execute(`
|
||||
INSERT INTO keyword_groups (
|
||||
id, name, description, keywords, color, effectId,
|
||||
styleOverride, effectConfig, styleConfig,
|
||||
createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
group.id,
|
||||
group.name,
|
||||
group.description || '',
|
||||
keywordsJson,
|
||||
group.color || null,
|
||||
group.effectId || null,
|
||||
styleOverrideJson,
|
||||
effectConfigJson,
|
||||
styleConfigJson,
|
||||
group.createdAt || now,
|
||||
group.updatedAt || now
|
||||
]);
|
||||
|
||||
insertedCount++;
|
||||
console.log(`[initAppConfig] ✅ 关键词分组已插入数据库: ${group.name} (${group.id})`);
|
||||
} else {
|
||||
skippedCount++;
|
||||
console.log(`[initAppConfig] ℹ️ 关键词分组已存在,跳过: ${group.name} (${group.id})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[initAppConfig] 关键词分组插入失败: ${group.name}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[initAppConfig] ✅ 关键词分组初始化完成: 新增 ${insertedCount} 个,跳过 ${skippedCount} 个`);
|
||||
Log.info('initAppConfig.initKeywordGroups', '关键词分组初始化完成', {
|
||||
total: config.keywordGroups.length,
|
||||
inserted: insertedCount,
|
||||
skipped: skippedCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[initAppConfig] 关键词分组初始化失败:', error);
|
||||
Log.error('initAppConfig.initKeywordGroups', '关键词分组初始化失败', { error });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化应用配置
|
||||
* 此函数应在 initSystemTemplates 之后调用
|
||||
*/
|
||||
export const initAppConfig = async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[initAppConfig] 开始初始化应用配置');
|
||||
Log.info('initAppConfig.start', '开始初始化应用配置');
|
||||
|
||||
// 加载字幕模板配置
|
||||
const config = loadDefaultSubtitleTemplates();
|
||||
console.log('[initAppConfig] 加载配置结果:', {
|
||||
configExists: !!config,
|
||||
hasSubtitleTemplates: !!config?.subtitleTemplates,
|
||||
subtitleTemplatesLength: config?.subtitleTemplates?.length || 0,
|
||||
hasKeywordGroups: !!config?.keywordGroups,
|
||||
keywordGroupsLength: config?.keywordGroups?.length || 0
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
console.warn('[initAppConfig] 无法加载配置,跳过初始化');
|
||||
Log.warn('initAppConfig.start', '无法加载配置,跳过初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化字幕模板
|
||||
if (config.subtitleTemplates && config.subtitleTemplates.length > 0) {
|
||||
console.log('[initAppConfig] 开始初始化字幕模板,数量:', config.subtitleTemplates.length);
|
||||
await initSubtitleTemplates(config);
|
||||
} else {
|
||||
console.warn('[initAppConfig] 没有字幕模板需要初始化');
|
||||
}
|
||||
|
||||
// 初始化关键词分组
|
||||
if (config.keywordGroups && config.keywordGroups.length > 0) {
|
||||
console.log('[initAppConfig] 开始初始化关键词分组,数量:', config.keywordGroups.length);
|
||||
await initKeywordGroups(config);
|
||||
} else {
|
||||
console.warn('[initAppConfig] 没有关键词分组需要初始化');
|
||||
}
|
||||
|
||||
console.log('[initAppConfig] ✅ 应用配置初始化完成');
|
||||
Log.info('initAppConfig.complete', '应用配置初始化完成', {
|
||||
version: config.version,
|
||||
templateCount: config.subtitleTemplates?.length || 0,
|
||||
keywordGroupCount: config.keywordGroups?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[initAppConfig] 应用配置初始化过程出错:', error);
|
||||
Log.error('initAppConfig.error', '应用配置初始化过程出错', { error });
|
||||
// 不抛出错误,避免影响应用启动
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化音色预缓存
|
||||
* 优先从应用资源复制,如果不存在则在后台异步预缓存
|
||||
* 不阻塞应用启动
|
||||
*/
|
||||
export const initVoicePreCache = async (): Promise<void> => {
|
||||
try {
|
||||
// 动态导入,避免循环依赖
|
||||
const { preCacheSystemVoices, isAllVoicesCached, copyPreCacheFromResources, clearLegacyPreCacheFiles } = await import('../aliyun/voicePreCache');
|
||||
|
||||
// 尝试从应用资源目录同步(用于打包分发和升级覆盖旧系统音色)
|
||||
console.log('[initAppConfig] 尝试从应用资源复制预缓存文件...');
|
||||
const copiedFromResources = await copyPreCacheFromResources();
|
||||
|
||||
if (copiedFromResources) {
|
||||
console.log('[initAppConfig] ✅ 从应用资源复制预缓存文件成功');
|
||||
Log.info('initAppConfig.voicePreCache', '从应用资源复制预缓存文件成功');
|
||||
clearLegacyPreCacheFiles();
|
||||
// 再次检查是否全部缓存了
|
||||
if (isAllVoicesCached()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已经全部缓存
|
||||
if (isAllVoicesCached()) {
|
||||
clearLegacyPreCacheFiles();
|
||||
console.log('[initAppConfig] ✅ 所有默认音色已预缓存,无需再处理');
|
||||
Log.info('initAppConfig.voicePreCache', '所有默认音色已预缓存');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[initAppConfig] 开始异步预缓存系统默认音色...');
|
||||
Log.info('initAppConfig.voicePreCache.start', '开始异步预缓存系统默认音色');
|
||||
|
||||
// 获取阿里云API Key(如果配置了的话)
|
||||
try {
|
||||
const dashscopeConfig = await DB.first(
|
||||
'SELECT * FROM model_provider WHERE id = ?',
|
||||
['dashscope']
|
||||
);
|
||||
|
||||
if (dashscopeConfig && dashscopeConfig.data) {
|
||||
const providerData = typeof dashscopeConfig.data === 'string'
|
||||
? JSON.parse(dashscopeConfig.data)
|
||||
: dashscopeConfig.data;
|
||||
|
||||
const apiKey = providerData.apiKey;
|
||||
|
||||
if (apiKey) {
|
||||
// 在后台异步预缓存,不等待完成,不阻塞应用启动
|
||||
preCacheSystemVoices(apiKey, false).then(() => {
|
||||
console.log('[initAppConfig] ✅ 音色预缓存生成完成');
|
||||
Log.info('initAppConfig.voicePreCache.complete', '音色预缓存生成完成');
|
||||
}).catch((error: any) => {
|
||||
console.warn('[initAppConfig] 音色预缓存失败(不影响应用使用):', error.message);
|
||||
Log.warn('initAppConfig.voicePreCache.error', '音色预缓存失败', { error: error.message });
|
||||
});
|
||||
} else {
|
||||
console.log('[initAppConfig] 阿里云API Key未配置,跳过自动预缓存');
|
||||
Log.info('initAppConfig.voicePreCache.skip', '阿里云API Key未配置');
|
||||
}
|
||||
} else {
|
||||
console.log('[initAppConfig] 阿里云百炼未配置,跳过自动预缓存');
|
||||
Log.info('initAppConfig.voicePreCache.skip', '阿里云百炼未配置');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('[initAppConfig] 获取阿里云配置失败,跳过自动预缓存:', error.message);
|
||||
Log.warn('initAppConfig.voicePreCache.getConfigError', '获取阿里云配置失败', { error: error.message });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[initAppConfig] 音色预缓存初始化失败:', error);
|
||||
Log.error('initAppConfig.voicePreCache.initError', '音色预缓存初始化失败', { error: error.message });
|
||||
// 不抛出错误,避免影响应用启动
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
initAppConfig,
|
||||
initVoicePreCache,
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import path from "node:path";
|
||||
import { AppEnv } from "../env";
|
||||
import fs from "node:fs";
|
||||
import { app, ipcMain } from "electron";
|
||||
import { Events } from "../event/main";
|
||||
|
||||
let data = null;
|
||||
let dataEnv = {};
|
||||
|
||||
const userDataRoot = () => {
|
||||
return path.join(AppEnv.userData, "config.json");
|
||||
};
|
||||
|
||||
const dataRoot = () => {
|
||||
return path.join(AppEnv.dataRoot, "config.json");
|
||||
}
|
||||
|
||||
const filePath = () => {
|
||||
if (fs.existsSync(userDataRoot())) {
|
||||
return userDataRoot();
|
||||
}
|
||||
return dataRoot();
|
||||
};
|
||||
|
||||
const loadDefaults = () => {
|
||||
try {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
let defaultConfigPath: string;
|
||||
|
||||
if (isDev) {
|
||||
// 开发模式:使用 app.getAppPath() 获取项目根目录
|
||||
defaultConfigPath = path.join(app.getAppPath(), 'electron/config/default-config.json');
|
||||
console.log('[Config] 开发模式 - 配置文件路径:', defaultConfigPath);
|
||||
} else {
|
||||
// 生产模式:使用 process.resourcesPath
|
||||
// 配置文件应该被打包到 resources/extra/common/ 目录
|
||||
defaultConfigPath = path.join(process.resourcesPath, 'extra/common/default-config.json');
|
||||
console.log('[Config] 生产模式 - 配置文件路径:', defaultConfigPath);
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (fs.existsSync(defaultConfigPath)) {
|
||||
const defaultJson = fs.readFileSync(defaultConfigPath).toString();
|
||||
const parsed = JSON.parse(defaultJson);
|
||||
if (parsed.config) {
|
||||
console.log('[Config] ✅ 成功加载默认配置:', defaultConfigPath);
|
||||
return parsed.config;
|
||||
}
|
||||
} else {
|
||||
console.warn('[Config] ⚠️ 默认配置文件不存在:', defaultConfigPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Config] 加载默认配置失败:', error);
|
||||
}
|
||||
|
||||
// 返回硬编码的最小配置
|
||||
console.log('[Config] 使用硬编码的最小配置');
|
||||
return {
|
||||
darkMode: 'auto',
|
||||
guideWatched: false,
|
||||
updaterCheckAtLaunch: 'yes'
|
||||
};
|
||||
};
|
||||
|
||||
const load = () => {
|
||||
try {
|
||||
let json = fs.readFileSync(filePath()).toString();
|
||||
json = JSON.parse(json);
|
||||
data = json || {};
|
||||
console.log(`[Config:load] filePath=${filePath()}, hubRoot=${data?.hubRoot}`);
|
||||
} catch (e) {
|
||||
console.warn('[Config] 配置文件读取失败,使用默认配置:', e.message);
|
||||
data = loadDefaults();
|
||||
}
|
||||
};
|
||||
|
||||
const loadIfNeed = () => {
|
||||
if (data === null) {
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
fs.writeFileSync(filePath(), JSON.stringify(data, null, 4));
|
||||
};
|
||||
|
||||
const all = async () => {
|
||||
loadIfNeed();
|
||||
return data;
|
||||
};
|
||||
|
||||
const get = async (key: string, defaultValue: any = null) => {
|
||||
loadIfNeed();
|
||||
|
||||
// 如果配置项不存在,尝试从默认配置文件中获取
|
||||
if (!(key in data)) {
|
||||
// 尝试加载默认配置
|
||||
const defaults = loadDefaults();
|
||||
|
||||
// 如果默认配置中有这个键,使用默认值
|
||||
if (defaults && key in defaults) {
|
||||
console.log(`[Config] 使用默认配置: ${key}`);
|
||||
data[key] = defaults[key];
|
||||
save();
|
||||
return data[key];
|
||||
}
|
||||
|
||||
// 否则使用传入的 defaultValue
|
||||
if (defaultValue !== null) {
|
||||
data[key] = defaultValue;
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
return data[key];
|
||||
};
|
||||
|
||||
const set = async (key: string, value: any) => {
|
||||
loadIfNeed();
|
||||
data[key] = value;
|
||||
save();
|
||||
};
|
||||
|
||||
const allEnv = async () => {
|
||||
return dataEnv;
|
||||
};
|
||||
|
||||
const getEnv = async (key: string, defaultValue: any = null) => {
|
||||
if (!(key in dataEnv)) {
|
||||
dataEnv[key] = defaultValue;
|
||||
}
|
||||
return dataEnv[key];
|
||||
};
|
||||
|
||||
const setEnv = async (key: string, value: any) => {
|
||||
dataEnv[key] = value;
|
||||
};
|
||||
|
||||
ipcMain.handle("config:all", async _ => {
|
||||
return await all();
|
||||
});
|
||||
ipcMain.handle("config:get", async (_, key: string, defaultValue: any = null) => {
|
||||
return await get(key, defaultValue);
|
||||
});
|
||||
ipcMain.handle("config:set", async (_, key: string, value: any) => {
|
||||
const res = await set(key, value);
|
||||
Events.broadcast("ConfigChange", { key, value });
|
||||
return res;
|
||||
});
|
||||
|
||||
ipcMain.handle("config:allEnv", async _ => {
|
||||
return await allEnv();
|
||||
});
|
||||
|
||||
ipcMain.handle("config:getEnv", async (_, key: string, defaultValue: any = null) => {
|
||||
return await getEnv(key, defaultValue);
|
||||
});
|
||||
|
||||
ipcMain.handle("config:setEnv", async (_, key: string, value: any) => {
|
||||
const res = await setEnv(key, value);
|
||||
Events.broadcast("ConfigEnvChange", { key, value });
|
||||
return res;
|
||||
});
|
||||
|
||||
// 导入导出功能
|
||||
import { exportCurrentConfig, saveConfigToFile } from './exportConfig';
|
||||
|
||||
// 导出当前配置
|
||||
ipcMain.handle('config:exportCurrent', async () => {
|
||||
return await exportCurrentConfig();
|
||||
});
|
||||
|
||||
// 导出配置到文件
|
||||
ipcMain.handle('config:exportToFile', async (_, targetPath: string) => {
|
||||
const config = await exportCurrentConfig();
|
||||
await saveConfigToFile(config, targetPath);
|
||||
return { success: true, path: targetPath };
|
||||
});
|
||||
|
||||
export const ConfigMain = {
|
||||
all,
|
||||
get,
|
||||
set,
|
||||
getSync: (key: string, defaultValue: any = null) => {
|
||||
loadIfNeed();
|
||||
return data[key] ?? defaultValue;
|
||||
},
|
||||
allEnv,
|
||||
getEnv,
|
||||
setEnv,
|
||||
};
|
||||
|
||||
export default ConfigMain;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
const all = async () => {
|
||||
return ipcRenderer.invoke("config:all");
|
||||
};
|
||||
|
||||
const get = async (key: string, defaultValue: any = null) => {
|
||||
return ipcRenderer.invoke("config:get", key, defaultValue);
|
||||
};
|
||||
|
||||
const set = async (key: string, value: any) => {
|
||||
return ipcRenderer.invoke("config:set", key, value);
|
||||
};
|
||||
|
||||
const allEnv = async () => {
|
||||
return ipcRenderer.invoke("config:allEnv");
|
||||
};
|
||||
|
||||
const getEnv = async (key: string, defaultValue: any = null) => {
|
||||
return ipcRenderer.invoke("config:getEnv", key, defaultValue);
|
||||
};
|
||||
|
||||
const setEnv = (key: string, value: any) => {
|
||||
return ipcRenderer.invoke("config:setEnv", key, value);
|
||||
};
|
||||
|
||||
const exportCurrent = () => {
|
||||
return ipcRenderer.invoke("config:exportCurrent");
|
||||
};
|
||||
|
||||
const exportToFile = (targetPath: string) => {
|
||||
return ipcRenderer.invoke("config:exportToFile", targetPath);
|
||||
};
|
||||
|
||||
export default {
|
||||
all,
|
||||
get,
|
||||
set,
|
||||
allEnv,
|
||||
getEnv,
|
||||
setEnv,
|
||||
exportCurrent,
|
||||
exportToFile,
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import DB from './main';
|
||||
import { Log } from '../log/main';
|
||||
import { getCommonResourcePath } from '../../lib/resource-path';
|
||||
import { AppEnv } from '../env';
|
||||
|
||||
interface BuiltinAvatar {
|
||||
name: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
const BUILTIN_AVATARS: BuiltinAvatar[] = [
|
||||
{ name: '内置形象-07', fileName: '07.mp4' },
|
||||
{ name: '内置形象-25', fileName: '25.mp4' },
|
||||
{ name: '内置形象-4月5日', fileName: '4月5日.mp4' },
|
||||
];
|
||||
|
||||
const getAvatarSourcePath = (fileName: string): string => {
|
||||
return getCommonResourcePath(path.join('shuiziren', fileName));
|
||||
};
|
||||
|
||||
const getHubDir = (): string => {
|
||||
return path.join(AppEnv.dataRoot, 'hub', 'file');
|
||||
};
|
||||
|
||||
const copyToHub = (srcPath: string, destDir: string, fileName: string): string => {
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
const baseName = path.basename(fileName, path.extname(fileName));
|
||||
const ext = path.extname(fileName);
|
||||
let destPath = path.join(destDir, fileName);
|
||||
let counter = 1;
|
||||
|
||||
while (fs.existsSync(destPath)) {
|
||||
destPath = path.join(destDir, `${baseName}_${counter}${ext}`);
|
||||
counter++;
|
||||
}
|
||||
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
return destPath;
|
||||
};
|
||||
|
||||
export const initBuiltinAvatars = async (): Promise<void> => {
|
||||
try {
|
||||
Log.info('initBuiltinAvatars.start', '开始检查内置形象');
|
||||
|
||||
const hubDir = getHubDir();
|
||||
let imported = 0;
|
||||
|
||||
for (const avatar of BUILTIN_AVATARS) {
|
||||
try {
|
||||
const existing = await DB.first(
|
||||
`SELECT id FROM data_video_template WHERE name = ?`,
|
||||
[avatar.name]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
Log.info('initBuiltinAvatars.exists', '形象已存在', { name: avatar.name });
|
||||
continue;
|
||||
}
|
||||
|
||||
const srcPath = getAvatarSourcePath(avatar.fileName);
|
||||
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
Log.warn('initBuiltinAvatars.fileNotFound', '内置形象文件不存在', { path: srcPath });
|
||||
continue;
|
||||
}
|
||||
|
||||
const savedPath = copyToHub(srcPath, hubDir, avatar.fileName);
|
||||
|
||||
await DB.insert(
|
||||
`INSERT INTO data_video_template (name, video, info) VALUES (?, ?, ?)`,
|
||||
[avatar.name, savedPath, JSON.stringify({})]
|
||||
);
|
||||
|
||||
imported++;
|
||||
Log.info('initBuiltinAvatars.imported', '已导入内置形象', {
|
||||
name: avatar.name,
|
||||
path: savedPath,
|
||||
});
|
||||
} catch (avatarError) {
|
||||
Log.error('initBuiltinAvatars.avatarError', '导入单个形象失败', {
|
||||
name: avatar.name,
|
||||
error: avatarError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Log.info('initBuiltinAvatars.complete', '内置形象初始化完成', { imported });
|
||||
} catch (error) {
|
||||
Log.error('initBuiltinAvatars.error', '内置形象初始化失败', { error });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,852 @@
|
||||
/**
|
||||
* 系统默认模板初始化模块
|
||||
* 在应用启动时自动检查并初始化系统默认模板
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import DB from './main';
|
||||
import { Log } from '../log/main';
|
||||
import { AppEnv } from '../env';
|
||||
import { getCommonResourcePath } from '../../lib/resource-path';
|
||||
import { isPackaged } from '../../lib/env';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
interface CoverTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_system: number;
|
||||
config: any;
|
||||
thumbnailPath?: string;
|
||||
}
|
||||
|
||||
interface SubtitleStyle {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
preview: string;
|
||||
is_system: number;
|
||||
fontName: string;
|
||||
fontSize: number;
|
||||
fontColor: string;
|
||||
outlineColor: string;
|
||||
outlineWidth: number;
|
||||
shadowOffset?: number;
|
||||
shadowColor?: string;
|
||||
shadowBlur?: number;
|
||||
backgroundColor: string;
|
||||
backgroundOpacity: number;
|
||||
position: string;
|
||||
alignment: string;
|
||||
}
|
||||
|
||||
interface SubtitleTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isSystem: boolean;
|
||||
readonly?: boolean;
|
||||
createdAt: number;
|
||||
config: any;
|
||||
}
|
||||
|
||||
interface SystemTemplatesConfig {
|
||||
version: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
subtitleTemplates: SubtitleTemplate[];
|
||||
coverTemplates: CoverTemplate[];
|
||||
}
|
||||
|
||||
interface DefaultTemplatesConfig {
|
||||
version: string;
|
||||
description: string;
|
||||
coverTemplates: CoverTemplate[];
|
||||
subtitleStyles: SubtitleStyle[];
|
||||
}
|
||||
|
||||
interface PendingInlineRestoreResult {
|
||||
templates: SubtitleTemplate[];
|
||||
forceRestoreIds: Set<string>;
|
||||
pendingFilePath: string | null;
|
||||
}
|
||||
|
||||
const DEV_INLINE_RESTORE_PENDING_FILE = 'restore-inline-templates.pending.json';
|
||||
const DEV_SYSTEM_TEMPLATES_MARKER_FILE = 'dev-system-templates.seeded.json';
|
||||
const FORMAL_INLINE_SYSTEM_TEMPLATE_IDS = new Set([
|
||||
'template_system_11',
|
||||
'template_system_22',
|
||||
'template_system_33',
|
||||
'template_system_44',
|
||||
'template_system_55',
|
||||
'template_system_66',
|
||||
'template_system_77',
|
||||
'template_system_88',
|
||||
]);
|
||||
|
||||
const syncFormalInlineTemplateSoundEffects = (
|
||||
templateId: string,
|
||||
bundledKeywordGroupsStyles: any,
|
||||
existingKeywordGroupsStyles: any,
|
||||
): { keywordGroupsStyles: any; updated: boolean } => {
|
||||
if (!FORMAL_INLINE_SYSTEM_TEMPLATE_IDS.has(templateId)) {
|
||||
return { keywordGroupsStyles: existingKeywordGroupsStyles, updated: false };
|
||||
}
|
||||
|
||||
if (!Array.isArray(bundledKeywordGroupsStyles)) {
|
||||
return { keywordGroupsStyles: existingKeywordGroupsStyles, updated: false };
|
||||
}
|
||||
|
||||
if (!Array.isArray(existingKeywordGroupsStyles)) {
|
||||
return { keywordGroupsStyles: bundledKeywordGroupsStyles, updated: true };
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
const mergedKeywordGroupsStyles = bundledKeywordGroupsStyles.map((bundledGroup, index) => {
|
||||
const existingGroup = existingKeywordGroupsStyles[index];
|
||||
if (!existingGroup) {
|
||||
updated = true;
|
||||
return bundledGroup;
|
||||
}
|
||||
|
||||
const mergedGroup = {
|
||||
...existingGroup,
|
||||
};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(bundledGroup, 'soundEffectId')) {
|
||||
const nextSoundEffectId = bundledGroup.soundEffectId ?? null;
|
||||
if ((existingGroup.soundEffectId ?? null) !== nextSoundEffectId) {
|
||||
updated = true;
|
||||
}
|
||||
mergedGroup.soundEffectId = nextSoundEffectId;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(bundledGroup, 'soundEffectConfig')) {
|
||||
const nextSoundEffectConfig = bundledGroup.soundEffectConfig ?? null;
|
||||
if (JSON.stringify(existingGroup.soundEffectConfig ?? null) !== JSON.stringify(nextSoundEffectConfig)) {
|
||||
updated = true;
|
||||
}
|
||||
mergedGroup.soundEffectConfig = nextSoundEffectConfig;
|
||||
}
|
||||
|
||||
return mergedGroup;
|
||||
});
|
||||
|
||||
if (existingKeywordGroupsStyles.length > bundledKeywordGroupsStyles.length) {
|
||||
mergedKeywordGroupsStyles.push(...existingKeywordGroupsStyles.slice(bundledKeywordGroupsStyles.length));
|
||||
}
|
||||
|
||||
return { keywordGroupsStyles: mergedKeywordGroupsStyles, updated };
|
||||
};
|
||||
|
||||
const getDevSystemTemplatesMarkerPath = (): string => {
|
||||
const baseDir = AppEnv.dataRoot || process.cwd();
|
||||
return path.join(baseDir, DEV_SYSTEM_TEMPLATES_MARKER_FILE);
|
||||
};
|
||||
|
||||
const writeDevSystemTemplatesMarker = (reason: string): void => {
|
||||
if (isProductionMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markerPath = getDevSystemTemplatesMarkerPath();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
||||
fs.writeFileSync(markerPath, JSON.stringify({
|
||||
reason,
|
||||
timestamp: Date.now(),
|
||||
}, null, 2), 'utf8');
|
||||
Log.info('initSystemTemplates.writeDevMarker', '已写入开发环境系统模板初始化标记', {
|
||||
markerPath,
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
Log.warn('initSystemTemplates.writeDevMarker', '写入开发环境系统模板初始化标记失败', {
|
||||
markerPath,
|
||||
reason,
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const shouldSkipDevSystemTemplateInit = async (): Promise<boolean> => {
|
||||
if (isProductionMode()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const markerPath = getDevSystemTemplatesMarkerPath();
|
||||
if (fs.existsSync(markerPath)) {
|
||||
Log.info('initSystemTemplates.skipDevInit', '检测到开发环境初始化标记,跳过系统模板自动同步', {
|
||||
markerPath,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 确保表中存在 is_system 列
|
||||
*/
|
||||
const ensureIsSystemColumn = async (): Promise<void> => {
|
||||
try {
|
||||
// 检查并添加 cover_templates 表的 is_system 列
|
||||
try {
|
||||
await DB.execute(`
|
||||
ALTER TABLE cover_templates
|
||||
ADD COLUMN is_system INTEGER DEFAULT 0
|
||||
`);
|
||||
Log.info('initSystemTemplates.ensureIsSystemColumn', '已添加 is_system 列到 cover_templates');
|
||||
} catch (error: any) {
|
||||
// 列已存在时会抛出错误,忽略
|
||||
if (error.message && error.message.includes('duplicate column name')) {
|
||||
Log.info('initSystemTemplates.ensureIsSystemColumn', 'cover_templates 的 is_system 列已存在');
|
||||
} else {
|
||||
Log.warn('initSystemTemplates.ensureIsSystemColumn', 'cover_templates 列检查警告', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并添加 subtitle_styles 表的 is_system 列
|
||||
try {
|
||||
await DB.execute(`
|
||||
ALTER TABLE subtitle_styles
|
||||
ADD COLUMN is_system INTEGER DEFAULT 0
|
||||
`);
|
||||
Log.info('initSystemTemplates.ensureIsSystemColumn', '已添加 is_system 列到 subtitle_styles');
|
||||
} catch (error: any) {
|
||||
// 列已存在时会抛出错误,忽略
|
||||
if (error.message && error.message.includes('duplicate column name')) {
|
||||
Log.info('initSystemTemplates.ensureIsSystemColumn', 'subtitle_styles 的 is_system 列已存在');
|
||||
} else {
|
||||
Log.warn('initSystemTemplates.ensureIsSystemColumn', 'subtitle_styles 列检查警告', { error: error.message });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error('initSystemTemplates.ensureIsSystemColumn', '确保列存在时出错', { error });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取系统模板配置文件 (system-templates.json)
|
||||
*/
|
||||
const loadSystemTemplatesConfig = (): SystemTemplatesConfig | null => {
|
||||
try {
|
||||
// 尝试多个可能的路径
|
||||
const possiblePaths = [
|
||||
// 优先尝试使用统一资源路径工具(支持开发和生产环境)
|
||||
getCommonResourcePath('config/system-templates.json'),
|
||||
// 关键修复:直接使用 process.resourcesPath(生产环境)
|
||||
process.resourcesPath ? path.join(process.resourcesPath, 'extra/common/config/system-templates.json') : '',
|
||||
path.join(__dirname, '../config/system-templates.json'),
|
||||
path.join(__dirname, '../../config/system-templates.json'),
|
||||
path.join(process.cwd(), 'electron/config/system-templates.json'),
|
||||
path.join(process.cwd(), 'dist-electron/config/system-templates.json'),
|
||||
// 添加更多备用路径
|
||||
path.join(process.cwd(), 'resources/extra/common/config/system-templates.json'),
|
||||
].filter(p => p); // 过滤空路径
|
||||
|
||||
let configPath: string | null = null;
|
||||
let configContent: string | null = null;
|
||||
|
||||
console.log('[initSystemTemplates] 开始搜索 system-templates.json');
|
||||
console.log('[initSystemTemplates] process.resourcesPath:', process.resourcesPath);
|
||||
console.log('[initSystemTemplates] process.cwd():', process.cwd());
|
||||
|
||||
for (const tryPath of possiblePaths) {
|
||||
console.log('[initSystemTemplates] 尝试加载系统模板:', tryPath, '存在:', fs.existsSync(tryPath));
|
||||
if (fs.existsSync(tryPath)) {
|
||||
configPath = tryPath;
|
||||
configContent = fs.readFileSync(tryPath, 'utf-8');
|
||||
console.log('[initSystemTemplates] ✅ 找到系统模板配置文件:', tryPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configPath || !configContent) {
|
||||
console.warn('[initSystemTemplates] ⚠️ system-templates.json 不存在');
|
||||
Log.warn('initSystemTemplates.loadSystemTemplatesConfig', '系统模板配置文件不存在', { possiblePaths });
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = JSON.parse(configContent) as SystemTemplatesConfig;
|
||||
|
||||
console.log('[initSystemTemplates] 成功加载系统模板配置:', {
|
||||
version: config.version,
|
||||
subtitleCount: config.subtitleTemplates.length,
|
||||
coverCount: config.coverTemplates.length
|
||||
});
|
||||
|
||||
Log.info('initSystemTemplates.loadSystemTemplatesConfig', '成功加载系统模板配置', {
|
||||
version: config.version,
|
||||
subtitleCount: config.subtitleTemplates.length,
|
||||
coverCount: config.coverTemplates.length
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('[initSystemTemplates] 加载系统模板配置失败:', error);
|
||||
Log.error('initSystemTemplates.loadSystemTemplatesConfig', '加载系统模板配置失败', { error });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingInlineTemplateRestore = (
|
||||
templates: SubtitleTemplate[],
|
||||
): PendingInlineRestoreResult => {
|
||||
const pendingFilePath = path.join(process.cwd(), DEV_INLINE_RESTORE_PENDING_FILE);
|
||||
const enablePendingRestore = process.env.ENABLE_DEV_INLINE_RESTORE === '1';
|
||||
|
||||
if (!enablePendingRestore || isProductionMode() || !fs.existsSync(pendingFilePath)) {
|
||||
if (!isProductionMode() && !enablePendingRestore && fs.existsSync(pendingFilePath)) {
|
||||
Log.warn('initSystemTemplates.applyPendingInlineTemplateRestore', '检测到待恢复 Inline 模板文件,但已默认忽略。设置 ENABLE_DEV_INLINE_RESTORE=1 可重新启用。', {
|
||||
pendingFilePath,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
templates,
|
||||
forceRestoreIds: new Set<string>(),
|
||||
pendingFilePath: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(pendingFilePath, 'utf8');
|
||||
const pending = JSON.parse(raw);
|
||||
const restoreTemplates = Array.isArray(pending?.subtitleTemplates) ? pending.subtitleTemplates : [];
|
||||
const restoreMap = new Map<string, SubtitleTemplate>(
|
||||
restoreTemplates
|
||||
.filter((item: SubtitleTemplate | null | undefined) => Boolean(item?.id))
|
||||
.map((item: SubtitleTemplate) => [item.id, item])
|
||||
);
|
||||
|
||||
if (restoreMap.size === 0) {
|
||||
return {
|
||||
templates,
|
||||
forceRestoreIds: new Set<string>(),
|
||||
pendingFilePath: null,
|
||||
};
|
||||
}
|
||||
|
||||
const forceRestoreIds = new Set<string>();
|
||||
const mergedTemplates = templates.map((template) => {
|
||||
const restoreTemplate = restoreMap.get(template.id);
|
||||
if (!restoreTemplate) {
|
||||
return template;
|
||||
}
|
||||
|
||||
forceRestoreIds.add(template.id);
|
||||
|
||||
return {
|
||||
...template,
|
||||
config: {
|
||||
...template.config,
|
||||
...restoreTemplate.config,
|
||||
previewConfig: template.config?.previewConfig,
|
||||
keywordRenderMode: template.config?.keywordRenderMode,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Log.info('initSystemTemplates.applyPendingInlineTemplateRestore', '检测到待恢复的 Inline 模板配置', {
|
||||
pendingFilePath,
|
||||
count: forceRestoreIds.size,
|
||||
});
|
||||
|
||||
return {
|
||||
templates: mergedTemplates,
|
||||
forceRestoreIds,
|
||||
pendingFilePath,
|
||||
};
|
||||
} catch (error) {
|
||||
Log.error('initSystemTemplates.applyPendingInlineTemplateRestore', '读取待恢复 Inline 模板配置失败', {
|
||||
pendingFilePath,
|
||||
error,
|
||||
});
|
||||
return {
|
||||
templates,
|
||||
forceRestoreIds: new Set<string>(),
|
||||
pendingFilePath: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取默认模板配置文件
|
||||
*/
|
||||
const loadDefaultTemplatesConfig = (): DefaultTemplatesConfig | null => {
|
||||
try {
|
||||
// 尝试多个可能的路径
|
||||
const possiblePaths = [
|
||||
// 优先尝试使用统一资源路径工具(支持开发和生产环境)
|
||||
getCommonResourcePath('config/default-templates.json'),
|
||||
path.join(__dirname, '../config/default-templates.json'),
|
||||
path.join(__dirname, '../../config/default-templates.json'),
|
||||
path.join(process.cwd(), 'electron/config/default-templates.json'),
|
||||
path.join(process.cwd(), 'dist-electron/config/default-templates.json'),
|
||||
];
|
||||
|
||||
let configPath: string | null = null;
|
||||
let configContent: string | null = null;
|
||||
|
||||
for (const tryPath of possiblePaths) {
|
||||
console.log('[initSystemTemplates] 尝试路径:', tryPath);
|
||||
if (fs.existsSync(tryPath)) {
|
||||
configPath = tryPath;
|
||||
configContent = fs.readFileSync(tryPath, 'utf-8');
|
||||
console.log('[initSystemTemplates] ✅ 找到配置文件:', tryPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configPath || !configContent) {
|
||||
console.error('[initSystemTemplates] ❌ 配置文件不存在,尝试的路径:', possiblePaths);
|
||||
Log.error('initSystemTemplates.loadConfig', '配置文件不存在', { possiblePaths });
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = JSON.parse(configContent) as DefaultTemplatesConfig;
|
||||
|
||||
console.log('[initSystemTemplates] 成功加载配置文件:', {
|
||||
version: config.version,
|
||||
coverCount: config.coverTemplates.length,
|
||||
subtitleCount: config.subtitleStyles.length
|
||||
});
|
||||
|
||||
Log.info('initSystemTemplates.loadConfig', '成功加载配置文件', {
|
||||
version: config.version,
|
||||
coverCount: config.coverTemplates.length,
|
||||
subtitleCount: config.subtitleStyles.length
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('[initSystemTemplates] 加载配置文件失败:', error);
|
||||
Log.error('initSystemTemplates.loadConfig', '加载配置文件失败', { error });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否在生产模式
|
||||
*/
|
||||
const isProductionMode = (): boolean => {
|
||||
return isPackaged ||
|
||||
process.env.IS_PACKAGED === 'true' ||
|
||||
process.env.ELECTRON_ENV_PROD === '1' ||
|
||||
process.env.ELECTRON_ENV_PROD === 'true' ||
|
||||
process.env.NODE_ENV === 'production';
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化字幕模板
|
||||
* 从 system-templates.json 导入字幕模板到数据库
|
||||
*/
|
||||
const initSubtitleTemplates = async (
|
||||
templates: SubtitleTemplate[],
|
||||
options?: {
|
||||
forceRestoreIds?: Set<string>;
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const isProd = isProductionMode();
|
||||
const forceRestoreIds = options?.forceRestoreIds || new Set<string>();
|
||||
const systemTemplateIds = templates.filter(template => template.isSystem).map(template => template.id);
|
||||
|
||||
if (systemTemplateIds.length > 0) {
|
||||
const placeholders = systemTemplateIds.map(() => '?').join(', ');
|
||||
await DB.execute(
|
||||
`DELETE FROM subtitle_templates
|
||||
WHERE is_system = 1
|
||||
AND id NOT IN (${placeholders})`,
|
||||
systemTemplateIds
|
||||
);
|
||||
}
|
||||
|
||||
for (const template of templates) {
|
||||
const { id, name, description, isSystem, createdAt, config } = template;
|
||||
const configJson = JSON.stringify(config);
|
||||
|
||||
// 在生产模式下,字幕模板设为只读
|
||||
const readonly = isProd ? 1 : 0;
|
||||
|
||||
// 检查模板是否已存在
|
||||
const existing = await DB.first(
|
||||
`SELECT id, is_system, config FROM subtitle_templates WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// 开发环境下保留数据库中的系统模板修改,避免每次重启都被配置文件覆盖。
|
||||
// 生产环境仍然与配置文件保持同步,确保用户侧系统模板一致。
|
||||
if (isSystem && isProd) {
|
||||
await DB.execute(
|
||||
`UPDATE subtitle_templates
|
||||
SET name = ?, description = ?, config = ?, is_system = ?, readonly = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[name, description, configJson, 1, readonly, now, id]
|
||||
);
|
||||
Log.info('initSystemTemplates.updateSubtitleTemplate', '同步字幕模板配置', { id, name });
|
||||
console.log(`[initSystemTemplates] ✅ 字幕模板已同步: ${name}`);
|
||||
} else if (isSystem) {
|
||||
if (forceRestoreIds.has(id)) {
|
||||
await DB.execute(
|
||||
`UPDATE subtitle_templates
|
||||
SET name = ?, description = ?, config = ?, is_system = ?, readonly = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[name, description, configJson, 1, readonly, now, id]
|
||||
);
|
||||
Log.info('initSystemTemplates.restoreDevSubtitleTemplate', '开发环境恢复系统字幕模板配置', { id, name });
|
||||
console.log(`[initSystemTemplates] ℹ️ 开发环境恢复系统字幕模板: ${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let existingConfig: Record<string, any> = {};
|
||||
let shouldBackfillPreview = false;
|
||||
|
||||
try {
|
||||
existingConfig = existing.config ? JSON.parse(existing.config) : {};
|
||||
const existingPreview = existingConfig.previewConfig || {};
|
||||
shouldBackfillPreview = !(
|
||||
existingPreview.backgroundImage ||
|
||||
existingPreview.backgroundKey ||
|
||||
(Array.isArray(existingPreview.titleTexts) && existingPreview.titleTexts.length > 0) ||
|
||||
(Array.isArray(existingPreview.subtitleTexts) && existingPreview.subtitleTexts.length > 0)
|
||||
);
|
||||
} catch (error) {
|
||||
shouldBackfillPreview = true;
|
||||
Log.warn('initSystemTemplates.parseDevSubtitleTemplate', '开发环境解析系统字幕模板失败,尝试回填预览配置', { id, name, error });
|
||||
}
|
||||
|
||||
const soundSyncResult = syncFormalInlineTemplateSoundEffects(
|
||||
id,
|
||||
config.keywordGroupsStyles,
|
||||
existingConfig.keywordGroupsStyles,
|
||||
);
|
||||
|
||||
if (shouldBackfillPreview || soundSyncResult.updated) {
|
||||
const mergedConfig = {
|
||||
...existingConfig,
|
||||
...(soundSyncResult.updated ? {
|
||||
keywordGroupsStyles: soundSyncResult.keywordGroupsStyles,
|
||||
} : {}),
|
||||
previewConfig: {
|
||||
...(config.previewConfig || {}),
|
||||
...(existingConfig.previewConfig || {}),
|
||||
},
|
||||
};
|
||||
|
||||
await DB.execute(
|
||||
`UPDATE subtitle_templates
|
||||
SET config = ?, is_system = ?, readonly = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[JSON.stringify(mergedConfig), 1, readonly, now, id]
|
||||
);
|
||||
Log.info('initSystemTemplates.backfillDevSubtitleTemplateConfig', '开发环境补齐系统字幕模板配置', {
|
||||
id,
|
||||
name,
|
||||
previewBackfilled: shouldBackfillPreview,
|
||||
inlineSoundSynced: soundSyncResult.updated,
|
||||
});
|
||||
console.log(`[initSystemTemplates] ℹ️ 开发环境补齐系统字幕模板配置: ${name}`);
|
||||
} else {
|
||||
Log.info('initSystemTemplates.keepDevSubtitleTemplate', '开发环境保留数据库中的系统字幕模板', { id, name });
|
||||
console.log(`[initSystemTemplates] ℹ️ 开发环境保留系统字幕模板: ${name}`);
|
||||
}
|
||||
} else {
|
||||
// 用户创建的模板,不覆盖
|
||||
Log.info('initSystemTemplates.checkSubtitleTemplate', '用户自定义模板已存在,保留用户数据', { id, name });
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
// 插入新的字幕模板
|
||||
await DB.execute(
|
||||
`INSERT INTO subtitle_templates (id, name, description, config, is_system, readonly, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, name, description, configJson, isSystem ? 1 : 0, readonly, createdAt, now]
|
||||
);
|
||||
|
||||
Log.info('initSystemTemplates.insertSubtitleTemplate', '创建字幕模板', { id, name });
|
||||
console.log(`[initSystemTemplates] ✅ 创建字幕模板: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
Log.info('initSystemTemplates.initSubtitleTemplates', '字幕模板初始化完成', {
|
||||
count: templates.length,
|
||||
production: isProd
|
||||
});
|
||||
} catch (error) {
|
||||
Log.error('initSystemTemplates.initSubtitleTemplates', '字幕模板初始化失败', { error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化封面模板
|
||||
*/
|
||||
const initCoverTemplates = async (templates: CoverTemplate[]): Promise<void> => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const isProd = isProductionMode();
|
||||
|
||||
for (const template of templates) {
|
||||
const { id, name, description, is_system, config, thumbnailPath } = template;
|
||||
const configJson = JSON.stringify(config);
|
||||
|
||||
// 在生产模式下,封面模板设为只读
|
||||
const readonly = isProd ? 1 : 0;
|
||||
|
||||
// 检查模板是否已存在
|
||||
const existing = await DB.first(
|
||||
`SELECT id, is_system FROM cover_templates WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// 开发环境下保留数据库中的系统模板修改,避免每次重启都被配置文件覆盖。
|
||||
// 生产环境仍然与配置文件保持同步,确保用户侧系统模板一致。
|
||||
if (is_system === 1 && isProd) {
|
||||
await DB.execute(
|
||||
`UPDATE cover_templates
|
||||
SET name = ?, config = ?, thumbnail_path = ?, is_system = ?, readonly = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[name, configJson, thumbnailPath || null, is_system, readonly, now, id]
|
||||
);
|
||||
Log.info('initSystemTemplates.updateCoverTemplate', '同步封面模板配置', { id, name });
|
||||
console.log(`[initSystemTemplates] ✅ 封面模板已同步: ${name}`);
|
||||
} else if (is_system === 1) {
|
||||
Log.info('initSystemTemplates.keepDevCoverTemplate', '开发环境保留数据库中的系统封面模板', { id, name });
|
||||
console.log(`[initSystemTemplates] ℹ️ 开发环境保留系统封面模板: ${name}`);
|
||||
} else if (existing.is_system !== 1) {
|
||||
// 这是用户创建的模板,绝对不覆盖
|
||||
Log.info('initSystemTemplates.checkCoverTemplate', '用户自定义模板已存在,保留用户数据', { id, name });
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
// 插入新的系统模板
|
||||
await DB.execute(
|
||||
`INSERT INTO cover_templates (id, name, config, is_system, readonly, created_at, updated_at, thumbnail_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, name, configJson, is_system, readonly, now, now, thumbnailPath || null]
|
||||
);
|
||||
|
||||
Log.info('initSystemTemplates.insertCoverTemplate', '创建系统封面模板', { id, name });
|
||||
console.log(`[initSystemTemplates] ✅ 创建系统模板: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
Log.info('initSystemTemplates.initCoverTemplates', '封面模板初始化完成', {
|
||||
count: templates.length,
|
||||
production: isProd
|
||||
});
|
||||
} catch (error) {
|
||||
Log.error('initSystemTemplates.initCoverTemplates', '封面模板初始化失败', { error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化字幕样式
|
||||
*/
|
||||
const initSubtitleStyles = async (styles: SubtitleStyle[]): Promise<void> => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
for (const style of styles) {
|
||||
const {
|
||||
id, name, description, preview, is_system,
|
||||
fontName, fontSize, fontColor, outlineColor, outlineWidth,
|
||||
shadowOffset, shadowColor, shadowBlur,
|
||||
backgroundColor, backgroundOpacity, position, alignment
|
||||
} = style;
|
||||
|
||||
// 检查样式是否已存在
|
||||
const existing = await DB.first(
|
||||
`SELECT id, is_system FROM subtitle_styles WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// 🔧 关键修复:不覆盖已存在的样式(无论是系统样式还是用户样式)
|
||||
// 原因:如果覆盖已存在的样式,会导致用户保存的自定义设置被抹掉
|
||||
// 只更新系统样式标记,不覆盖样式内容
|
||||
if (existing.is_system === 1) {
|
||||
// 这是一个系统样式,保持其原样(不更新内容)
|
||||
Log.info('initSystemTemplates.checkSubtitleStyle', '系统字幕样式已存在,跳过覆盖', { id, name });
|
||||
} else {
|
||||
// 这是用户创建的样式,绝对不覆盖
|
||||
Log.info('initSystemTemplates.checkSubtitleStyle', '用户自定义样式已存在,保留用户数据', { id, name });
|
||||
}
|
||||
} else {
|
||||
// 插入新的系统样式
|
||||
await DB.execute(
|
||||
`INSERT INTO subtitle_styles (
|
||||
id, name, description, preview, is_system,
|
||||
fontName, fontSize, fontColor, outlineColor, outlineWidth,
|
||||
shadowOffset, shadowColor, shadowBlur,
|
||||
backgroundColor, backgroundOpacity, position, alignment,
|
||||
createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id, name, description, preview, is_system,
|
||||
fontName, fontSize, fontColor, outlineColor, outlineWidth,
|
||||
shadowOffset || 2, shadowColor || '#000000', shadowBlur || 4,
|
||||
backgroundColor, backgroundOpacity, position, alignment,
|
||||
now, now
|
||||
]
|
||||
);
|
||||
|
||||
Log.info('initSystemTemplates.insertSubtitleStyle', '创建系统字幕样式', { id, name });
|
||||
}
|
||||
}
|
||||
|
||||
Log.info('initSystemTemplates.initSubtitleStyles', '字幕样式初始化完成', {
|
||||
count: styles.length
|
||||
});
|
||||
} catch (error) {
|
||||
Log.error('initSystemTemplates.initSubtitleStyles', '字幕样式初始化失败', { error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化所有系统默认模板
|
||||
* 此函数应在应用启动时调用
|
||||
*/
|
||||
export const initSystemTemplates = async (): Promise<void> => {
|
||||
try {
|
||||
Log.info('initSystemTemplates.start', '开始初始化系统默认模板');
|
||||
|
||||
// 首先确保 is_system 列存在
|
||||
await ensureIsSystemColumn();
|
||||
|
||||
if (await shouldSkipDevSystemTemplateInit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 优先读取系统模板配置(包含字幕和封面)
|
||||
const systemConfig = loadSystemTemplatesConfig();
|
||||
if (systemConfig) {
|
||||
const pendingInlineRestore = applyPendingInlineTemplateRestore(systemConfig.subtitleTemplates || []);
|
||||
|
||||
// 初始化字幕模板
|
||||
if (pendingInlineRestore.templates.length > 0) {
|
||||
await initSubtitleTemplates(pendingInlineRestore.templates, {
|
||||
forceRestoreIds: pendingInlineRestore.forceRestoreIds,
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingInlineRestore.pendingFilePath) {
|
||||
fs.unlinkSync(pendingInlineRestore.pendingFilePath);
|
||||
Log.info('initSystemTemplates.applyPendingInlineTemplateRestore', '已清理待恢复 Inline 模板标记文件', {
|
||||
pendingFilePath: pendingInlineRestore.pendingFilePath,
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化封面模板(从 system-templates.json)
|
||||
if (systemConfig.coverTemplates && systemConfig.coverTemplates.length > 0) {
|
||||
await initCoverTemplates(systemConfig.coverTemplates);
|
||||
}
|
||||
|
||||
Log.info('initSystemTemplates.complete', '系统模板初始化完成', {
|
||||
version: systemConfig.version,
|
||||
subtitleCount: systemConfig.subtitleTemplates.length,
|
||||
coverCount: systemConfig.coverTemplates.length
|
||||
});
|
||||
|
||||
if (!isProductionMode()) {
|
||||
writeDevSystemTemplatesMarker('seeded-from-system-templates');
|
||||
}
|
||||
} else {
|
||||
// 如果 system-templates.json 不存在,回退到加载 default-templates.json
|
||||
console.warn('[initSystemTemplates] 无法加载 system-templates.json,尝试加载 default-templates.json');
|
||||
const config = loadDefaultTemplatesConfig();
|
||||
if (!config) {
|
||||
Log.error('initSystemTemplates.start', '无法加载任何配置文件,跳过初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化封面模板
|
||||
if (config.coverTemplates && config.coverTemplates.length > 0) {
|
||||
await initCoverTemplates(config.coverTemplates);
|
||||
}
|
||||
|
||||
// 初始化字幕样式
|
||||
if (config.subtitleStyles && config.subtitleStyles.length > 0) {
|
||||
await initSubtitleStyles(config.subtitleStyles);
|
||||
}
|
||||
|
||||
Log.info('initSystemTemplates.complete', '系统默认模板初始化完成(从 default-templates.json)', {
|
||||
version: config.version,
|
||||
coverCount: config.coverTemplates.length,
|
||||
subtitleCount: config.subtitleStyles.length
|
||||
});
|
||||
|
||||
if (!isProductionMode()) {
|
||||
writeDevSystemTemplatesMarker('seeded-from-default-templates');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error('initSystemTemplates.error', '系统模板初始化过程出错', { error });
|
||||
// 不抛出错误,避免影响应用启动
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查并修复缺失的系统模板
|
||||
* 可用于运行时检查
|
||||
*/
|
||||
export const checkAndRepairSystemTemplates = async (): Promise<{
|
||||
success: boolean;
|
||||
repairedCover: number;
|
||||
repairedSubtitle: number;
|
||||
}> => {
|
||||
try {
|
||||
const config = loadDefaultTemplatesConfig();
|
||||
if (!config) {
|
||||
return { success: false, repairedCover: 0, repairedSubtitle: 0 };
|
||||
}
|
||||
|
||||
let repairedCover = 0;
|
||||
let repairedSubtitle = 0;
|
||||
|
||||
// 检查封面模板
|
||||
for (const template of config.coverTemplates) {
|
||||
const existing = await DB.first(
|
||||
`SELECT id FROM cover_templates WHERE id = ?`,
|
||||
[template.id]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
repairedCover++;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查字幕样式
|
||||
for (const style of config.subtitleStyles) {
|
||||
const existing = await DB.first(
|
||||
`SELECT id FROM subtitle_styles WHERE id = ?`,
|
||||
[style.id]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
repairedSubtitle++;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有缺失,执行修复
|
||||
if (repairedCover > 0 || repairedSubtitle > 0) {
|
||||
await initSystemTemplates();
|
||||
}
|
||||
|
||||
return { success: true, repairedCover, repairedSubtitle };
|
||||
} catch (error) {
|
||||
Log.error('checkAndRepairSystemTemplates.error', '检查修复失败', { error });
|
||||
return { success: false, repairedCover: 0, repairedSubtitle: 0 };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import sqlite3, {Database} from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import migration from "./migration";
|
||||
import {AppEnv} from "../env";
|
||||
import {Log} from "../log/main";
|
||||
import {ipcMain} from "electron";
|
||||
import fs from "node:fs";
|
||||
import {Files} from "../file/main";
|
||||
|
||||
let dbPath: string | null = null;
|
||||
let dbConn: Database | null = null;
|
||||
let dbSuccess = false;
|
||||
|
||||
const db = {
|
||||
/**
|
||||
* 检查数据库连接是否已初始化
|
||||
* @throws {string} 如果数据库未初始化则抛出异常
|
||||
*/
|
||||
_check() {
|
||||
if (!dbSuccess) {
|
||||
const errorMsg = `DBNotInitialized: dbPath=${dbPath}, dbConn=${dbConn !== null}, dbSuccess=${dbSuccess}`;
|
||||
console.error("[DB:_check]", errorMsg);
|
||||
throw errorMsg;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 执行SQL语句(无返回值)
|
||||
* @param {string} sql - SQL语句
|
||||
* @param {any[]} params - 参数数组
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async execute(sql: string, params: any = []): Promise<void> {
|
||||
db._check();
|
||||
try {
|
||||
dbConn.prepare(sql).run(...params);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 插入数据并返回插入的行ID
|
||||
* @param {string} sql - SQL语句
|
||||
* @param {any[]} params - 参数数组
|
||||
* @returns {Promise<string | number>} 插入的行ID
|
||||
*/
|
||||
async insert(sql: string, params: any = []): Promise<string | number> {
|
||||
db._check();
|
||||
try {
|
||||
const result = dbConn.prepare(sql).run(...params);
|
||||
return result.lastInsertRowid;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 查询单行数据
|
||||
* @param {string} sql - SQL语句
|
||||
* @param {any[]} params - 参数数组
|
||||
* @returns {Promise<any>} 查询结果
|
||||
*/
|
||||
async first(sql: string, params: any = []): Promise<any> {
|
||||
db._check();
|
||||
try {
|
||||
return dbConn.prepare(sql).get(...params);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 查询多行数据
|
||||
* @param {string} sql - SQL语句
|
||||
* @param {any[]} params - 参数数组
|
||||
* @returns {Promise<any[]>} 查询结果数组
|
||||
*/
|
||||
async select(sql: string, params: any = []): Promise<any[]> {
|
||||
db._check();
|
||||
try {
|
||||
return dbConn.prepare(sql).all(...params);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 更新数据并返回影响的行数
|
||||
* @param {string} sql - SQL语句
|
||||
* @param {any[]} params - 参数数组
|
||||
* @returns {Promise<number>} 影响的行数
|
||||
*/
|
||||
async update(sql: string, params: any = []): Promise<number> {
|
||||
db._check();
|
||||
try {
|
||||
const result = dbConn.prepare(sql).run(...params);
|
||||
return result.changes;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 删除数据并返回影响的行数
|
||||
* @param {string} sql - SQL语句
|
||||
* @param {any[]} params - 参数数组
|
||||
* @returns {Promise<number>} 影响的行数
|
||||
*/
|
||||
async delete(sql: string, params: any = []): Promise<number> {
|
||||
db._check();
|
||||
try {
|
||||
const result = dbConn.prepare(sql).run(...params);
|
||||
return result.changes;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const migrate = async () => {
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS migrate
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY,
|
||||
version
|
||||
INTEGER
|
||||
)`);
|
||||
for (const version of migration.versions) {
|
||||
const result = await db.first(
|
||||
`SELECT *
|
||||
FROM migrate
|
||||
WHERE version = ?`,
|
||||
[version.version]
|
||||
);
|
||||
if (!result) {
|
||||
Log.info(`DB.Migrate`, {version: version.version});
|
||||
await version.up(db);
|
||||
await db.execute(
|
||||
`INSERT INTO migrate (version)
|
||||
VALUES (?)`,
|
||||
[version.version]
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化数据库连接
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const init = async () => {
|
||||
console.log("[DB:init] Starting database initialization");
|
||||
console.log("[DB:init] AppEnv.dataRoot =", AppEnv.dataRoot);
|
||||
console.log("[DB:init] AppEnv.userData =", AppEnv.userData);
|
||||
|
||||
dbPath = path.join(AppEnv.dataRoot, "database.db");
|
||||
const userDbPath = path.join(AppEnv.userData, "database.db");
|
||||
|
||||
console.log("[DB:init] Default dbPath =", dbPath);
|
||||
console.log("[DB:init] User dbPath =", userDbPath);
|
||||
console.log("[DB:init] User dbPath exists =", fs.existsSync(userDbPath));
|
||||
|
||||
if (fs.existsSync(userDbPath)) {
|
||||
dbPath = userDbPath;
|
||||
console.log("[DB:init] Using user dbPath instead");
|
||||
}
|
||||
|
||||
console.log("[DB:init] Final dbPath =", dbPath);
|
||||
|
||||
try {
|
||||
console.log("[DB:init] Creating SQLite3 connection...");
|
||||
dbConn = new sqlite3(dbPath);
|
||||
dbSuccess = true;
|
||||
console.log("[DB:init] SQLite3 connection created successfully, dbSuccess =", dbSuccess);
|
||||
|
||||
console.log("[DB:init] Running migrations...");
|
||||
await migrate();
|
||||
console.log("[DB:init] Migrations completed successfully");
|
||||
|
||||
Log.info("Database connected successfully");
|
||||
} catch (err) {
|
||||
console.error("[DB:init] ERROR:", err);
|
||||
Log.error("DBConnect SQLite database failed:", err.message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle("db:execute", (event, sql: string, params: any) => {
|
||||
try {
|
||||
return db.execute(sql, params);
|
||||
} catch (error) {
|
||||
console.error("[db:execute] Error:", {
|
||||
sql,
|
||||
params,
|
||||
paramCount: Array.isArray(params) ? params.length : 0,
|
||||
placeholderCount: (sql.match(/\?/g) || []).length,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("db:insert", (event, sql: string, params: any) => {
|
||||
try {
|
||||
return db.insert(sql, params);
|
||||
} catch (error) {
|
||||
console.error("[db:insert] Error:", {
|
||||
sql,
|
||||
params,
|
||||
paramCount: Array.isArray(params) ? params.length : 0,
|
||||
placeholderCount: (sql.match(/\?/g) || []).length,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("db:first", (event, sql: string, params: any) => {
|
||||
try {
|
||||
return db.first(sql, params);
|
||||
} catch (error) {
|
||||
console.error("[db:first] Error:", {
|
||||
sql,
|
||||
params,
|
||||
paramCount: Array.isArray(params) ? params.length : 0,
|
||||
placeholderCount: (sql.match(/\?/g) || []).length,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("db:select", (event, sql: string, params: any) => {
|
||||
try {
|
||||
return db.select(sql, params);
|
||||
} catch (error) {
|
||||
console.error("[db:select] Error:", {
|
||||
sql,
|
||||
params,
|
||||
paramCount: Array.isArray(params) ? params.length : 0,
|
||||
placeholderCount: (sql.match(/\?/g) || []).length,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("db:update", (event, sql: string, params: any) => {
|
||||
try {
|
||||
return db.update(sql, params);
|
||||
} catch (error) {
|
||||
console.error("[db:update] Error:", {
|
||||
sql,
|
||||
params,
|
||||
paramCount: Array.isArray(params) ? params.length : 0,
|
||||
placeholderCount: (sql.match(/\?/g) || []).length,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("db:delete", (event, sql: string, params: any) => {
|
||||
try {
|
||||
return db.delete(sql, params);
|
||||
} catch (error) {
|
||||
console.error("[db:delete] Error:", {
|
||||
sql,
|
||||
params,
|
||||
paramCount: Array.isArray(params) ? params.length : 0,
|
||||
placeholderCount: (sql.match(/\?/g) || []).length,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const DBMain = {
|
||||
init,
|
||||
execute: db.execute,
|
||||
insert: db.insert,
|
||||
first: db.first,
|
||||
select: db.select,
|
||||
update: db.update,
|
||||
delete: db.delete,
|
||||
// 诊断函数
|
||||
getStatus() {
|
||||
return {
|
||||
dbSuccess,
|
||||
dbPath,
|
||||
dbConnected: dbConn !== null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default DBMain;
|
||||
@@ -0,0 +1,838 @@
|
||||
import StorageMain from "../storage/main";
|
||||
import Files from "../file/main";
|
||||
|
||||
const versions = [
|
||||
{
|
||||
version: 0,
|
||||
up: async (db: DB) => {
|
||||
// await db.execute(`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`);
|
||||
// console.log('db.insert', await db.insert(`INSERT INTO users (name, email) VALUES (?, ?)`,['Alice', 'alice@example.com']));
|
||||
// console.log('db.select', await db.select(`SELECT * FROM users`));
|
||||
// console.log('db.first', await db.first(`SELECT * FROM users`));
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
up: async (db: DB) => {
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS data_sound_tts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
serverName TEXT,
|
||||
serverTitle TEXT,
|
||||
serverVersion TEXT,
|
||||
text TEXT,
|
||||
param TEXT,
|
||||
|
||||
status TEXT,
|
||||
statusMsg TEXT,
|
||||
jobResult TEXT,
|
||||
startTime INTEGER,
|
||||
endTime INTEGER,
|
||||
resultWav TEXT
|
||||
)`);
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS data_sound_clone (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
serverName TEXT,
|
||||
serverTitle TEXT,
|
||||
serverVersion TEXT,
|
||||
promptName TEXT,
|
||||
promptWav TEXT,
|
||||
promptText TEXT,
|
||||
text TEXT,
|
||||
param TEXT,
|
||||
|
||||
status TEXT,
|
||||
statusMsg TEXT,
|
||||
jobResult TEXT,
|
||||
startTime INTEGER,
|
||||
endTime INTEGER,
|
||||
resultWav TEXT
|
||||
)`);
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS data_video_template (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
name TEXT,
|
||||
video TEXT
|
||||
)`);
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS data_video_gen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
serverName TEXT,
|
||||
serverTitle TEXT,
|
||||
serverVersion TEXT,
|
||||
videoTemplateId INTEGER,
|
||||
videoTemplateName TEXT,
|
||||
soundType TEXT,
|
||||
soundTtsId INTEGER,
|
||||
soundTtsText TEXT,
|
||||
soundCloneId INTEGER,
|
||||
soundCloneText TEXT,
|
||||
|
||||
param TEXT,
|
||||
|
||||
status TEXT,
|
||||
statusMsg TEXT,
|
||||
jobResult TEXT,
|
||||
startTime INTEGER,
|
||||
endTime INTEGER,
|
||||
resultMp4 TEXT
|
||||
)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
up: async (db: DB) => {
|
||||
await db.execute(`ALTER TABLE data_sound_tts ADD COLUMN result TEXT`);
|
||||
await db.execute(`ALTER TABLE data_sound_clone ADD COLUMN result TEXT`);
|
||||
await db.execute(`ALTER TABLE data_video_gen ADD COLUMN result TEXT`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
up: async (db: DB) => {
|
||||
await db.execute(`ALTER TABLE data_video_gen ADD COLUMN soundCustomFile TEXT`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
up: async (db: DB) => {
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS data_task (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
|
||||
biz TEXT,
|
||||
|
||||
status TEXT,
|
||||
statusMsg TEXT,
|
||||
startTime INTEGER,
|
||||
endTime INTEGER,
|
||||
|
||||
serverName TEXT,
|
||||
serverTitle TEXT,
|
||||
serverVersion TEXT,
|
||||
|
||||
param TEXT,
|
||||
jobResult TEXT,
|
||||
modelConfig TEXT,
|
||||
result TEXT
|
||||
|
||||
)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 5,
|
||||
up: async (db: DB) => {
|
||||
// await db.execute(`DELETE FROM data_task where 1=1`);
|
||||
// SoundClone
|
||||
let records = await db.select(`SELECT * FROM data_sound_clone`);
|
||||
for (const r of records) {
|
||||
const values = [
|
||||
"SoundClone",
|
||||
r.status,
|
||||
r.statusMsg,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.serverName,
|
||||
r.serverTitle,
|
||||
r.serverVersion,
|
||||
r.param,
|
||||
r.jobResult,
|
||||
JSON.stringify({
|
||||
promptName: r.promptName,
|
||||
promptWav: r.promptWav,
|
||||
promptText: r.promptText,
|
||||
text: r.text,
|
||||
}),
|
||||
JSON.stringify({
|
||||
url: r.resultWav,
|
||||
}),
|
||||
];
|
||||
await db.insert(
|
||||
`INSERT INTO data_task
|
||||
(biz, status, statusMsg, startTime, endTime, serverName, serverTitle, serverVersion, param, jobResult, modelConfig, result)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
values
|
||||
);
|
||||
}
|
||||
// SoundTts
|
||||
records = await db.select(`SELECT * FROM data_sound_tts`);
|
||||
for (const r of records) {
|
||||
const values = [
|
||||
"SoundTts",
|
||||
r.status,
|
||||
r.statusMsg,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.serverName,
|
||||
r.serverTitle,
|
||||
r.serverVersion,
|
||||
r.param,
|
||||
r.jobResult,
|
||||
JSON.stringify({
|
||||
text: r.text,
|
||||
}),
|
||||
JSON.stringify({
|
||||
url: r.resultWav,
|
||||
}),
|
||||
];
|
||||
await db.insert(
|
||||
`INSERT INTO data_task
|
||||
(biz, status, statusMsg, startTime, endTime, serverName, serverTitle, serverVersion, param, jobResult, modelConfig, result)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
values
|
||||
);
|
||||
}
|
||||
// VideoGen
|
||||
records = await db.select(`SELECT * FROM data_video_gen`);
|
||||
for (const r of records) {
|
||||
const values = [
|
||||
"VideoGen",
|
||||
r.status,
|
||||
r.statusMsg,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.serverName,
|
||||
r.serverTitle,
|
||||
r.serverVersion,
|
||||
r.param,
|
||||
r.jobResult,
|
||||
JSON.stringify({
|
||||
videoTemplateId: r.videoTemplateId,
|
||||
videoTemplateName: r.videoTemplateName,
|
||||
soundType: r.soundType,
|
||||
soundTtsId: r.soundTtsId,
|
||||
soundTtsText: r.soundTtsText,
|
||||
soundCloneId: r.soundCloneId,
|
||||
soundCloneText: r.soundCloneText,
|
||||
soundCustomFile: r.soundCustomFile,
|
||||
}),
|
||||
JSON.stringify({
|
||||
url: r.resultMp4,
|
||||
}),
|
||||
];
|
||||
await db.insert(
|
||||
`INSERT INTO data_task
|
||||
(biz, status, statusMsg, startTime, endTime, serverName, serverTitle, serverVersion, param, jobResult, modelConfig, result)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
values
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 6,
|
||||
up: async (db: DB) => {
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS data_storage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
|
||||
biz TEXT,
|
||||
|
||||
title TEXT,
|
||||
sort INTEGER,
|
||||
content TEXT
|
||||
|
||||
)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 7,
|
||||
up: async (db: DB) => {
|
||||
const records = await StorageMain.get("soundClonePrompt", "records", []);
|
||||
for (const r of records) {
|
||||
const values = [
|
||||
"SoundPrompt",
|
||||
r.name,
|
||||
JSON.stringify({
|
||||
url: r.promptWav,
|
||||
promptText: r.promptText,
|
||||
}),
|
||||
];
|
||||
await db.insert(
|
||||
`INSERT INTO data_storage (biz, title, content)
|
||||
VALUES (?, ?, ?)`,
|
||||
values
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 8,
|
||||
up: async (db: DB) => {
|
||||
await db.execute(`ALTER TABLE data_task ADD COLUMN title TEXT`);
|
||||
const records = await db.select(`SELECT * FROM data_task`);
|
||||
for (const r of records) {
|
||||
let modelConfig: any = {};
|
||||
try {
|
||||
modelConfig = JSON.parse(r.modelConfig);
|
||||
} catch (e) {
|
||||
modelConfig = {};
|
||||
}
|
||||
let title = "";
|
||||
if (r.biz === "SoundTts" || r.biz === "SoundClone") {
|
||||
title = Files.textToName(modelConfig.text);
|
||||
} else if (r.biz === "VideoGen") {
|
||||
title = Files.textToName([modelConfig.videoTemplateName, modelConfig.soundTtsText].join("_"));
|
||||
} else if (r.biz === "VideoGenFlow") {
|
||||
title = Files.textToName([modelConfig.videoTemplateName, modelConfig.text].join("_"));
|
||||
}
|
||||
await db.execute(`UPDATE data_task SET title = ? WHERE id = ?`, [title, r.id]);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 9,
|
||||
up: async (db: DB) => {
|
||||
const records = await db.select(`SELECT * FROM data_task where biz in ('SoundTts', 'SoundClone')`);
|
||||
for (const r of records) {
|
||||
const modelConfigOld = JSON.parse(r.modelConfig);
|
||||
const paramOld = JSON.parse(r.param);
|
||||
const modelConfig: any = {
|
||||
type: r.biz,
|
||||
ttsParam: r.biz === "SoundTts" ? paramOld : undefined,
|
||||
cloneParam: r.biz === "SoundClone" ? paramOld : undefined,
|
||||
...modelConfigOld,
|
||||
};
|
||||
const values = [
|
||||
"SoundGenerate",
|
||||
r.title,
|
||||
r.status,
|
||||
r.statusMsg,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.serverName,
|
||||
r.serverTitle,
|
||||
r.serverVersion,
|
||||
JSON.stringify({}),
|
||||
r.jobResult,
|
||||
JSON.stringify(modelConfig),
|
||||
r.result,
|
||||
];
|
||||
await db.insert(
|
||||
`INSERT INTO data_task
|
||||
(biz, title, status, statusMsg, startTime, endTime, serverName, serverTitle, serverVersion, param, jobResult, modelConfig, result)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
values
|
||||
);
|
||||
await db.execute(`DELETE FROM data_task WHERE id = ?`, [r.id]);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 10,
|
||||
up: async (db: DB) => {
|
||||
await db.execute(`ALTER TABLE data_task
|
||||
ADD COLUMN type INTEGER DEFAULT 1`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 11,
|
||||
up: async (db: DB) => {
|
||||
await db.execute(`ALTER TABLE data_video_template
|
||||
ADD COLUMN info TEXT`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 12,
|
||||
up: async (db: DB) => {
|
||||
// 平台账号表
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS platform_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
nickname TEXT,
|
||||
uid TEXT,
|
||||
avatar TEXT,
|
||||
cookies TEXT,
|
||||
tokens TEXT,
|
||||
loginStatus TEXT DEFAULT 'pending',
|
||||
expiresAt INTEGER,
|
||||
createdAt INTEGER,
|
||||
updatedAt INTEGER
|
||||
)`);
|
||||
|
||||
// 发布任务表
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS publish_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
videoPath TEXT,
|
||||
coverPath TEXT,
|
||||
platforms TEXT,
|
||||
publishConfig TEXT,
|
||||
scheduledTime INTEGER,
|
||||
status TEXT DEFAULT 'pending',
|
||||
createdAt INTEGER,
|
||||
updatedAt INTEGER
|
||||
)`);
|
||||
|
||||
// 发布历史表
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS publish_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
taskId INTEGER,
|
||||
platform TEXT,
|
||||
accountId INTEGER,
|
||||
videoUrl TEXT,
|
||||
videoId TEXT,
|
||||
status TEXT,
|
||||
errorMessage TEXT,
|
||||
publishedAt INTEGER,
|
||||
viewCount INTEGER DEFAULT 0,
|
||||
likeCount INTEGER DEFAULT 0
|
||||
)`);
|
||||
|
||||
// 创建索引
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_platform_accounts_platform ON platform_accounts(platform)`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_publish_tasks_status ON publish_tasks(status)`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_publish_history_taskId ON publish_history(taskId)`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_publish_history_platform ON publish_history(platform)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 13,
|
||||
up: async (db: DB) => {
|
||||
// 封面模板表
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS cover_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
thumbnail_path TEXT,
|
||||
config TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`);
|
||||
|
||||
// 创建索引以提升查询性能
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_cover_templates_created_at ON cover_templates(created_at DESC)`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_cover_templates_name ON cover_templates(name)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 14,
|
||||
up: async (db: DB) => {
|
||||
// 关键词分组表 - 用于字幕特效管理
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS keyword_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
keywords TEXT NOT NULL,
|
||||
color TEXT,
|
||||
effectId TEXT,
|
||||
styleOverride TEXT,
|
||||
effectConfig TEXT,
|
||||
styleConfig TEXT,
|
||||
createdAt INTEGER,
|
||||
updatedAt INTEGER
|
||||
)`);
|
||||
|
||||
// 创建索引以提升查询性能
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_keyword_groups_name ON keyword_groups(name)`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_keyword_groups_createdAt ON keyword_groups(createdAt DESC)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 15,
|
||||
up: async (db: DB) => {
|
||||
// 字幕样式表 - 用于保存字幕样式配置
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS subtitle_styles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
preview TEXT,
|
||||
fontName TEXT NOT NULL,
|
||||
fontSize INTEGER,
|
||||
fontColor TEXT,
|
||||
outlineColor TEXT,
|
||||
outlineWidth INTEGER,
|
||||
shadowOffset INTEGER,
|
||||
backgroundColor TEXT,
|
||||
backgroundOpacity REAL,
|
||||
position TEXT,
|
||||
alignment TEXT,
|
||||
createdAt INTEGER,
|
||||
updatedAt INTEGER
|
||||
)`);
|
||||
|
||||
// 创建索引以提升查询性能
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_subtitle_styles_name ON subtitle_styles(name)`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_subtitle_styles_createdAt ON subtitle_styles(createdAt DESC)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 16,
|
||||
up: async (db: DB) => {
|
||||
// 为 cover_templates 表添加 is_system 字段,标识系统默认模板
|
||||
await db.execute(`
|
||||
ALTER TABLE cover_templates
|
||||
ADD COLUMN is_system INTEGER DEFAULT 0
|
||||
`);
|
||||
|
||||
// 为 subtitle_styles 表添加 is_system 字段,标识系统默认样式
|
||||
await db.execute(`
|
||||
ALTER TABLE subtitle_styles
|
||||
ADD COLUMN is_system INTEGER DEFAULT 0
|
||||
`);
|
||||
|
||||
console.log('[Migration v16] 已添加 is_system 字段到 cover_templates 和 subtitle_styles 表');
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 17,
|
||||
up: async (db: DB) => {
|
||||
// 为 subtitle_styles 表添加 shadowColor 和 shadowBlur 字段
|
||||
try {
|
||||
await db.execute(`
|
||||
ALTER TABLE subtitle_styles
|
||||
ADD COLUMN shadowColor TEXT DEFAULT '#000000'
|
||||
`);
|
||||
console.log('[Migration v17] 已添加 shadowColor 字段到 subtitle_styles 表');
|
||||
} catch (error: any) {
|
||||
if (!error.message?.includes('duplicate column')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db.execute(`
|
||||
ALTER TABLE subtitle_styles
|
||||
ADD COLUMN shadowBlur INTEGER DEFAULT 4
|
||||
`);
|
||||
console.log('[Migration v17] 已添加 shadowBlur 字段到 subtitle_styles 表');
|
||||
} catch (error: any) {
|
||||
if (!error.message?.includes('duplicate column')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 18,
|
||||
up: async (db: DB) => {
|
||||
console.log('[Migration v18] 开始处理封面模板系统迁移');
|
||||
|
||||
try {
|
||||
// 第一步:删除旧的系统模板(system-cover-01/02/03/04)
|
||||
const oldSystemTemplates = [
|
||||
'system-cover-01',
|
||||
'system-cover-02',
|
||||
'system-cover-03',
|
||||
'system-cover-04'
|
||||
];
|
||||
|
||||
for (const templateId of oldSystemTemplates) {
|
||||
try {
|
||||
await db.execute(
|
||||
`DELETE FROM cover_templates WHERE id = ?`,
|
||||
[templateId]
|
||||
);
|
||||
console.log(`[Migration v18] ✅ 已删除旧系统模板: ${templateId}`);
|
||||
} catch (error) {
|
||||
// 如果模板不存在,忽略错误(新用户的情况)
|
||||
console.log(`[Migration v18] ℹ️ 旧系统模板不存在(预期行为): ${templateId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:将4个自定义模板标记为新的系统模板(仅对现有模板操作)
|
||||
const newSystemTemplates = [
|
||||
'485183be-6eed-4eae-b74a-8591f08b69fa', // 黄白实描
|
||||
'8f17c467-c69c-45b2-8011-94a6c3071d16', // 斜黄白
|
||||
'6404718a-1b1c-4148-9807-ad3c57a53e0c', // 蓝底白字
|
||||
'705fa010-5bf3-4041-b624-0fd364bea5ad' // 大四字报
|
||||
];
|
||||
|
||||
for (const templateId of newSystemTemplates) {
|
||||
try {
|
||||
const result = await db.execute(
|
||||
`UPDATE cover_templates SET is_system = 1 WHERE id = ?`,
|
||||
[templateId]
|
||||
);
|
||||
console.log(`[Migration v18] ✅ 已标记为系统模板: ${templateId}`);
|
||||
} catch (error) {
|
||||
// 如果模板不存在,忽略(新用户的情况)
|
||||
console.log(`[Migration v18] ℹ️ 模板不存在,跳过标记: ${templateId}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Migration v18] ✅ 封面模板系统迁移完成(仅对现有模板生效)');
|
||||
console.log('[Migration v18] 💡 提示:新用户请运行 \"ipAgent:exportCoverTemplatesToConfig\" 来导出这4个模板到配置文件');
|
||||
} catch (error: any) {
|
||||
console.error('[Migration v18] ❌ 迁移失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 19,
|
||||
up: async (db: DB) => {
|
||||
console.log('[Migration v19] 开始音效系统初始化');
|
||||
|
||||
try {
|
||||
// 第一步:创建音效库表
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS sound_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
type TEXT,
|
||||
audio_source TEXT,
|
||||
volume REAL DEFAULT 0.7,
|
||||
duration REAL,
|
||||
metadata TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
is_system INTEGER DEFAULT 0
|
||||
)`);
|
||||
console.log('[Migration v19] ✅ 创建 sound_effects 表');
|
||||
|
||||
// 第二步:创建用户自定义音效表
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS user_sound_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
duration REAL,
|
||||
file_size INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)`);
|
||||
console.log('[Migration v19] ✅ 创建 user_sound_effects 表');
|
||||
|
||||
// 第三步:检查并添加 keyword_groups 新字段
|
||||
try {
|
||||
await db.execute(`ALTER TABLE keyword_groups ADD COLUMN sound_effect_id TEXT`);
|
||||
console.log('[Migration v19] ✅ 为 keyword_groups 添加 sound_effect_id 列');
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
console.log('[Migration v19] ℹ️ sound_effect_id 列已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db.execute(`ALTER TABLE keyword_groups ADD COLUMN sound_effect_config TEXT`);
|
||||
console.log('[Migration v19] ✅ 为 keyword_groups 添加 sound_effect_config 列');
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
console.log('[Migration v19] ℹ️ sound_effect_config 列已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Migration v19] ✅ 音效系统初始化完成');
|
||||
} catch (error: any) {
|
||||
console.error('[Migration v19] ❌ 迁移失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 20,
|
||||
up: async (db: DB) => {
|
||||
console.log('[Migration v20] 开始贴纸系统初始化');
|
||||
|
||||
try {
|
||||
// 添加 stickerId 列
|
||||
try {
|
||||
await db.execute(`ALTER TABLE keyword_groups ADD COLUMN stickerId TEXT`);
|
||||
console.log('[Migration v20] ✅ 为 keyword_groups 添加 stickerId 列');
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
console.log('[Migration v20] ℹ️ stickerId 列已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 stickerConfig 列
|
||||
try {
|
||||
await db.execute(`ALTER TABLE keyword_groups ADD COLUMN stickerConfig TEXT`);
|
||||
console.log('[Migration v20] ✅ 为 keyword_groups 添加 stickerConfig 列');
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
console.log('[Migration v20] ℹ️ stickerConfig 列已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 effectDuration 列
|
||||
try {
|
||||
await db.execute(`ALTER TABLE keyword_groups ADD COLUMN effectDuration REAL`);
|
||||
console.log('[Migration v20] ✅ 为 keyword_groups 添加 effectDuration 列');
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
console.log('[Migration v20] ℹ️ effectDuration 列已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Migration v20] ✅ 贴纸系统初始化完成');
|
||||
} catch (error: any) {
|
||||
console.error('[Migration v20] ❌ 迁移失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 21,
|
||||
up: async (db: DB) => {
|
||||
console.log('[Migration v21] 开始更新平台账号昵称');
|
||||
|
||||
try {
|
||||
// 更新所有平台账号的昵称为"用户"
|
||||
await db.execute(`
|
||||
UPDATE platform_accounts
|
||||
SET nickname = '用户'
|
||||
WHERE nickname IN ('抖音用户', '快手用户', '视频号用户', '小红书用户')
|
||||
`);
|
||||
|
||||
console.log('[Migration v21] ✅ 已更新所有平台账号昵称为"用户"');
|
||||
} catch (error: any) {
|
||||
console.error('[Migration v21] ❌ 迁移失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 22,
|
||||
up: async (db: DB) => {
|
||||
console.log('[Migration v22] 开始创建用户认证系统表');
|
||||
|
||||
try {
|
||||
// 创建 users 表 - 用户基本信息
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME
|
||||
)`);
|
||||
console.log('[Migration v22] ✅ 创建 users 表');
|
||||
|
||||
// 创建 user_subscriptions 表 - 用户订阅时长管理
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS user_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER UNIQUE NOT NULL,
|
||||
subscription_type TEXT DEFAULT 'trial',
|
||||
expires_at DATETIME NOT NULL,
|
||||
trial_started_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`);
|
||||
console.log('[Migration v22] ✅ 创建 user_subscriptions 表');
|
||||
|
||||
// 创建 user_time_logs 表 - 时长变更日志
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS user_time_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
operator_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
days_added INTEGER,
|
||||
previous_expires_at DATETIME,
|
||||
new_expires_at DATETIME,
|
||||
reason TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (operator_id) REFERENCES users(id)
|
||||
)`);
|
||||
console.log('[Migration v22] ✅ 创建 user_time_logs 表');
|
||||
|
||||
// 创建 password_resets 表 - 密码重置记录(管理员手动重置)
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
operator_id INTEGER,
|
||||
old_password_hash TEXT,
|
||||
reset_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
reason TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (operator_id) REFERENCES users(id)
|
||||
)`);
|
||||
console.log('[Migration v22] ✅ 创建 password_resets 表');
|
||||
|
||||
console.log('[Migration v22] ✅ 用户认证系统表创建完成');
|
||||
} catch (error: any) {
|
||||
console.error('[Migration v22] ❌ 迁移失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 23,
|
||||
up: async (db: DB) => {
|
||||
console.log('[Migration v23] 开始创建字幕模板表和添加 readonly 列');
|
||||
|
||||
try {
|
||||
// 创建 subtitle_templates 表 - 用于保存字幕模板
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS subtitle_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
config TEXT,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
readonly INTEGER DEFAULT 0,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)`);
|
||||
console.log('[Migration v23] ✅ 创建 subtitle_templates 表');
|
||||
|
||||
// 为 cover_templates 表添加 readonly 列(如果不存在)
|
||||
try {
|
||||
await db.execute(`
|
||||
ALTER TABLE cover_templates
|
||||
ADD COLUMN readonly INTEGER DEFAULT 0
|
||||
`);
|
||||
console.log('[Migration v23] ✅ 为 cover_templates 表添加 readonly 列');
|
||||
} catch (error: any) {
|
||||
if (error.message && error.message.includes('duplicate column name')) {
|
||||
console.log('[Migration v23] ℹ️ cover_templates 表已有 readonly 列');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 为 subtitle_templates 表创建索引
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_subtitle_templates_name ON subtitle_templates(name)`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_subtitle_templates_is_system ON subtitle_templates(is_system)`);
|
||||
console.log('[Migration v23] ✅ 创建 subtitle_templates 表的索引');
|
||||
|
||||
console.log('[Migration v23] ✅ 字幕模板表创建完成');
|
||||
} catch (error: any) {
|
||||
console.error('[Migration v23] ❌ 迁移失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 24,
|
||||
up: async (db: DB) => {
|
||||
console.log('[Migration v24] 开始创建素材库表');
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS data_video_material (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
info TEXT,
|
||||
createdAt INTEGER,
|
||||
updatedAt INTEGER
|
||||
)`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS idx_video_material_updatedAt ON data_video_material(updatedAt DESC)`);
|
||||
console.log('[Migration v24] 素材库表创建完成');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
versions,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
const init = () => { };
|
||||
|
||||
const execute = async (sql: string, params: any = []) => {
|
||||
return ipcRenderer.invoke("db:execute", sql, params);
|
||||
};
|
||||
|
||||
const insert = async (sql: string, params: any = []) => {
|
||||
return ipcRenderer.invoke("db:insert", sql, params);
|
||||
};
|
||||
|
||||
const first = async (sql: string, params: any = []) => {
|
||||
return ipcRenderer.invoke("db:first", sql, params);
|
||||
};
|
||||
|
||||
const select = async (sql: string, params: any = []) => {
|
||||
return ipcRenderer.invoke("db:select", sql, params);
|
||||
};
|
||||
|
||||
const update = async (sql: string, params: any = []) => {
|
||||
return ipcRenderer.invoke("db:update", sql, params);
|
||||
};
|
||||
|
||||
const deletes = async (sql: string, params: any = []) => {
|
||||
return ipcRenderer.invoke("db:delete", sql, params);
|
||||
};
|
||||
|
||||
export default {
|
||||
init,
|
||||
execute,
|
||||
insert,
|
||||
first,
|
||||
select,
|
||||
update,
|
||||
delete: deletes,
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 音效数据库操作模块
|
||||
*/
|
||||
|
||||
import type { DB } from './main';
|
||||
|
||||
/**
|
||||
* 音效记录(数据库)
|
||||
*/
|
||||
export interface SoundEffectRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
type: string;
|
||||
audio_source?: string;
|
||||
volume: number;
|
||||
duration?: number;
|
||||
metadata?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
is_system: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户自定义音效记录(数据库)
|
||||
*/
|
||||
export interface UserSoundEffectRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
file_path: string;
|
||||
duration?: number;
|
||||
file_size?: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 音效数据库操作类
|
||||
*/
|
||||
export class SoundEffectDb {
|
||||
private db: DB;
|
||||
|
||||
constructor(db: DB) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
// ==================== 内置音效操作 ====================
|
||||
|
||||
/**
|
||||
* 获取所有系统内置音效
|
||||
*/
|
||||
async getBuiltinEffects(): Promise<SoundEffectRecord[]> {
|
||||
const results = await this.db.select(
|
||||
'SELECT * FROM sound_effects WHERE is_system = 1 ORDER BY created_at ASC'
|
||||
);
|
||||
return results || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有系统内置音效(用于重新初始化)
|
||||
*/
|
||||
async clearBuiltinEffects(): Promise<void> {
|
||||
await this.db.execute('DELETE FROM sound_effects WHERE is_system = 1');
|
||||
console.log('[SoundEffectDb] 已清除所有系统内置音效');
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取音效
|
||||
*/
|
||||
async getSoundEffectById(id: string): Promise<SoundEffectRecord | null> {
|
||||
const result = await this.db.first(
|
||||
'SELECT * FROM sound_effects WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存系统音效
|
||||
*/
|
||||
async saveBuiltinEffect(effect: SoundEffectRecord): Promise<void> {
|
||||
const existing = await this.getSoundEffectById(effect.id);
|
||||
|
||||
if (existing) {
|
||||
// 更新
|
||||
await this.db.execute(
|
||||
`UPDATE sound_effects SET
|
||||
name = ?,
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
audio_source = ?,
|
||||
volume = ?,
|
||||
duration = ?,
|
||||
metadata = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
effect.name,
|
||||
effect.description || null,
|
||||
effect.category,
|
||||
effect.type,
|
||||
effect.audio_source || null,
|
||||
effect.volume,
|
||||
effect.duration || null,
|
||||
effect.metadata || null,
|
||||
Date.now(),
|
||||
effect.id
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// 插入
|
||||
await this.db.execute(
|
||||
`INSERT INTO sound_effects (
|
||||
id, name, description, category, type, audio_source,
|
||||
volume, duration, metadata, created_at, updated_at, is_system
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
effect.id,
|
||||
effect.name,
|
||||
effect.description || null,
|
||||
effect.category,
|
||||
effect.type,
|
||||
effect.audio_source || null,
|
||||
effect.volume,
|
||||
effect.duration || null,
|
||||
effect.metadata || null,
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
1
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 用户自定义音效操作 ====================
|
||||
|
||||
/**
|
||||
* 获取所有用户自定义音效
|
||||
*/
|
||||
async getUserEffects(): Promise<UserSoundEffectRecord[]> {
|
||||
const results = await this.db.select(
|
||||
'SELECT * FROM user_sound_effects ORDER BY created_at DESC'
|
||||
);
|
||||
return results || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取用户音效
|
||||
*/
|
||||
async getUserEffectById(id: string): Promise<UserSoundEffectRecord | null> {
|
||||
const result = await this.db.first(
|
||||
'SELECT * FROM user_sound_effects WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户自定义音效
|
||||
*/
|
||||
async saveUserEffect(effect: UserSoundEffectRecord): Promise<void> {
|
||||
const existing = await this.getUserEffectById(effect.id);
|
||||
|
||||
if (existing) {
|
||||
// 更新
|
||||
await this.db.execute(
|
||||
`UPDATE user_sound_effects SET
|
||||
name = ?,
|
||||
description = ?,
|
||||
file_path = ?,
|
||||
duration = ?,
|
||||
file_size = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
effect.name,
|
||||
effect.description || null,
|
||||
effect.file_path,
|
||||
effect.duration || null,
|
||||
effect.file_size || null,
|
||||
Date.now(),
|
||||
effect.id
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// 插入
|
||||
await this.db.execute(
|
||||
`INSERT INTO user_sound_effects (
|
||||
id, name, description, file_path, duration, file_size,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
effect.id,
|
||||
effect.name,
|
||||
effect.description || null,
|
||||
effect.file_path,
|
||||
effect.duration || null,
|
||||
effect.file_size || null,
|
||||
Date.now(),
|
||||
Date.now()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户自定义音效
|
||||
*/
|
||||
async deleteUserEffect(id: string): Promise<void> {
|
||||
await this.db.execute(
|
||||
'DELETE FROM user_sound_effects WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查音效名称是否已存在
|
||||
*/
|
||||
async isNameExists(name: string, excludeId?: string): Promise<boolean> {
|
||||
let sql = 'SELECT COUNT(*) as count FROM user_sound_effects WHERE name = ?';
|
||||
const params: any[] = [name];
|
||||
|
||||
if (excludeId) {
|
||||
sql += ' AND id != ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
|
||||
const result = await this.db.first(sql, params);
|
||||
return result && result.count > 0;
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
type DB = {
|
||||
execute(sql: string, params?: any): Promise<any>;
|
||||
insert(sql: string, params?: any): Promise<any>;
|
||||
first(sql: string, params?: any): Promise<any>;
|
||||
select(sql: string, params?: any): Promise<any>;
|
||||
update(sql: string, params?: any): Promise<any>;
|
||||
delete(sql: string, params?: any): Promise<any>;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import electron, {BrowserWindow} from "electron";
|
||||
import {Log} from "./log";
|
||||
|
||||
export const AppEnv = {
|
||||
isInit: false,
|
||||
appRoot: null as string,
|
||||
appData: null as string,
|
||||
userData: null as string,
|
||||
dataRoot: null as string,
|
||||
installRoot: null as string,
|
||||
sessionData: null as string,
|
||||
cacheRoot: null as string,
|
||||
tempRoot: null as string,
|
||||
resourceStateRoot: null as string,
|
||||
resourceBundleRoot: null as string,
|
||||
};
|
||||
|
||||
export const AppRuntime = {
|
||||
fileHubRoot: null as string,
|
||||
splashWindow: null as BrowserWindow,
|
||||
mainWindow: null as BrowserWindow,
|
||||
windows: {} as Record<string, BrowserWindow>,
|
||||
mapiInitialized: false, // 🔧 标记 MAPI 是否已初始化
|
||||
};
|
||||
|
||||
export const waitAppEnvReady = async () => {
|
||||
while (!AppEnv.isInit) {
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const callHandleFromMainOrRender = async (name: string, ...args) => {
|
||||
if (electron.ipcRenderer) {
|
||||
return electron.ipcRenderer.invoke(name, ...args);
|
||||
} else {
|
||||
const func = electron.ipcMain._invokeHandlers.get(name);
|
||||
if (func) {
|
||||
const fakeEvent = { sender: { send: () => {} } };
|
||||
return func(fakeEvent, ...args);
|
||||
} else {
|
||||
Log.error(`No handler found for ${name}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import {AppRuntime} from "../env";
|
||||
import {ipcMain, WebContents} from "electron";
|
||||
import {StrUtil} from "../../lib/util";
|
||||
// 导入 taskEventSystem 以注册 IPC handlers
|
||||
import './taskEventSystem';
|
||||
|
||||
const init = async () => {
|
||||
};
|
||||
|
||||
type NameType = "main" | string | WebContents;
|
||||
type EventType = "APP_READY" | "CALL_PAGE" | "CHANNEL" | "BROADCAST";
|
||||
type BroadcastType =
|
||||
| "ConfigChange"
|
||||
| "ConfigEnvChange"
|
||||
| "UserChange"
|
||||
| "DarkModeChange"
|
||||
| "HotkeyWatch"
|
||||
| "Notice"
|
||||
| "MonitorEvent"
|
||||
| "TaskStatusChange"
|
||||
| "TaskProgressChange"
|
||||
| "TaskComplete"
|
||||
| "TaskFailed";
|
||||
|
||||
const broadcast = (
|
||||
type: BroadcastType,
|
||||
data: any,
|
||||
option?: {
|
||||
limit?: boolean;
|
||||
scopes?: string[];
|
||||
pages?: string[];
|
||||
}
|
||||
) => {
|
||||
data = data || {};
|
||||
option = Object.assign(
|
||||
{
|
||||
limit: false,
|
||||
scopes: [],
|
||||
pages: [],
|
||||
},
|
||||
option
|
||||
);
|
||||
if (option.pages.length > 0) {
|
||||
for (const p of option.pages) {
|
||||
send(p, "BROADCAST", {type, data});
|
||||
}
|
||||
} else {
|
||||
if (!option.limit || option.scopes.includes("main")) {
|
||||
send("main", "BROADCAST", {type, data});
|
||||
}
|
||||
if (!option.limit || option.scopes.includes("pages")) {
|
||||
for (let name in AppRuntime.windows) {
|
||||
send(name, "BROADCAST", {type, data});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendRaw = (webContents: any, type: EventType, data: any = {}, id?: string): boolean => {
|
||||
id = id || StrUtil.randomString(32);
|
||||
const payload = {id, type, data};
|
||||
webContents.send("MAIN_PROCESS_MESSAGE", payload);
|
||||
return true;
|
||||
};
|
||||
|
||||
const send = (name: NameType, type: EventType, data: any = {}, id?: string): boolean => {
|
||||
id = id || StrUtil.randomString(32);
|
||||
const payload = {id, type, data};
|
||||
if (typeof name !== 'string') {
|
||||
(name as WebContents).send("MAIN_PROCESS_MESSAGE", payload);
|
||||
return true;
|
||||
}
|
||||
if (name === "main") {
|
||||
if (!AppRuntime.mainWindow) {
|
||||
return false;
|
||||
}
|
||||
// console.log('send', payload)
|
||||
AppRuntime.mainWindow?.webContents.send("MAIN_PROCESS_MESSAGE", payload);
|
||||
} else {
|
||||
if (!AppRuntime.windows[name]) {
|
||||
return false;
|
||||
}
|
||||
AppRuntime.windows[name]?.webContents.send("MAIN_PROCESS_MESSAGE", payload);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
ipcMain.handle("event:send", async (_, name: NameType, type: EventType, data: any) => {
|
||||
send(name, type, data);
|
||||
});
|
||||
|
||||
const callPage = async (
|
||||
name: NameType,
|
||||
type: string,
|
||||
data: any,
|
||||
option?: {
|
||||
waitReadyTimeout?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
): Promise<{
|
||||
code: number;
|
||||
msg: string;
|
||||
data?: any;
|
||||
}> => {
|
||||
option = Object.assign(
|
||||
{
|
||||
waitReadyTimeout: 10 * 1000,
|
||||
timeout: 60 * 1000,
|
||||
},
|
||||
option
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = StrUtil.randomString(32);
|
||||
const timer = setTimeout(() => {
|
||||
ipcMain.removeListener(listenerKey, listener);
|
||||
resolve({code: -1, msg: "timeout"});
|
||||
}, option.timeout);
|
||||
const listener = (_, result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
return true;
|
||||
};
|
||||
const listenerKey = "event:callPage:" + id;
|
||||
ipcMain.once(listenerKey, listener);
|
||||
const payload = {
|
||||
type,
|
||||
data,
|
||||
option: {
|
||||
waitReadyTimeout: option.waitReadyTimeout,
|
||||
},
|
||||
};
|
||||
if (!send(name, "CALL_PAGE", payload, id)) {
|
||||
clearTimeout(timer);
|
||||
ipcMain.removeListener(listenerKey, listener);
|
||||
resolve({code: -1, msg: "send failed"});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
"event:callPage",
|
||||
async (
|
||||
_,
|
||||
name: string,
|
||||
type: string,
|
||||
data: any,
|
||||
option?: {
|
||||
timeout?: number;
|
||||
}
|
||||
) => {
|
||||
return callPage(name, type, data, option);
|
||||
}
|
||||
);
|
||||
|
||||
let onChannelIsListen = false;
|
||||
let channelOnCallback = {};
|
||||
|
||||
const sendChannel = (channel: string, data: any) => {
|
||||
send("main", "CHANNEL", {channel, data});
|
||||
};
|
||||
|
||||
const onChannel = (channel: string, callback: (data: any) => void) => {
|
||||
if (!channelOnCallback[channel]) {
|
||||
channelOnCallback[channel] = [];
|
||||
}
|
||||
channelOnCallback[channel].push(callback);
|
||||
if (!onChannelIsListen) {
|
||||
onChannelIsListen = true;
|
||||
ipcMain.handle("event:channelSend", (event, channel_, data) => {
|
||||
if (channelOnCallback[channel_]) {
|
||||
channelOnCallback[channel_].forEach((callback: (data: any) => void) => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const offChannel = (channel: string, callback: (data: any) => void) => {
|
||||
if (channelOnCallback[channel]) {
|
||||
channelOnCallback[channel] = channelOnCallback[channel].filter((item: (data: any) => void) => {
|
||||
return item !== callback;
|
||||
});
|
||||
}
|
||||
if (channelOnCallback[channel].length === 0) {
|
||||
delete channelOnCallback[channel];
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
init,
|
||||
send,
|
||||
};
|
||||
|
||||
export const Events = {
|
||||
broadcast,
|
||||
send,
|
||||
sendRaw,
|
||||
sendChannel,
|
||||
callPage,
|
||||
onChannel,
|
||||
offChannel,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
const init = () => {};
|
||||
|
||||
const send = (name: string, type: string, data: any = {}) => {
|
||||
return ipcRenderer.invoke("event:send", name, type, data).then();
|
||||
};
|
||||
|
||||
const callPage = async (name: string, type: string, data: any, option: any) => {
|
||||
return ipcRenderer.invoke("event:callPage", name, type, data, option);
|
||||
};
|
||||
|
||||
const channelSend = async (channel: string, data: any) => {
|
||||
return ipcRenderer.invoke("event:channelSend", channel, data);
|
||||
};
|
||||
|
||||
export default {
|
||||
init,
|
||||
send,
|
||||
callPage,
|
||||
channelSend,
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 任务事件系统
|
||||
* 用于在任务状态改变时通知前端,避免轮询
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { Events } from './main';
|
||||
|
||||
class TaskEventSystem extends EventEmitter {
|
||||
private static instance: TaskEventSystem;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
this.setMaxListeners(100); // 防止内存泄漏警告
|
||||
}
|
||||
|
||||
static getInstance(): TaskEventSystem {
|
||||
if (!TaskEventSystem.instance) {
|
||||
TaskEventSystem.instance = new TaskEventSystem();
|
||||
}
|
||||
return TaskEventSystem.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务状态改变时触发
|
||||
* @param taskId 任务 ID
|
||||
* @param oldStatus 旧状态
|
||||
* @param newStatus 新状态
|
||||
* @param data 任务完整数据
|
||||
*/
|
||||
emitTaskStatusChange(
|
||||
taskId: number | string,
|
||||
oldStatus: string | undefined,
|
||||
newStatus: string,
|
||||
data?: any
|
||||
) {
|
||||
console.log(`[TaskEventSystem] 任务 ${taskId} 状态变化: ${oldStatus} → ${newStatus}`);
|
||||
|
||||
// 1. 本地事件(供后端使用)
|
||||
this.emit('task:statusChange', {
|
||||
taskId,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
timestamp: Date.now(),
|
||||
data
|
||||
});
|
||||
|
||||
// 2. 广播到前端(供前端 UI 使用)
|
||||
Events.broadcast('TaskStatusChange', {
|
||||
taskId,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
timestamp: Date.now(),
|
||||
...data
|
||||
});
|
||||
|
||||
console.log(`[TaskEventSystem] 已广播任务状态变化事件`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务进度改变时触发
|
||||
*/
|
||||
emitTaskProgressChange(
|
||||
taskId: number | string,
|
||||
progress: number,
|
||||
message?: string
|
||||
) {
|
||||
// 广播到前端
|
||||
Events.broadcast('TaskProgressChange', {
|
||||
taskId,
|
||||
progress,
|
||||
message,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务完成时触发
|
||||
*/
|
||||
emitTaskComplete(
|
||||
taskId: number | string,
|
||||
result?: any
|
||||
) {
|
||||
Events.broadcast('TaskComplete', {
|
||||
taskId,
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务失败时触发
|
||||
*/
|
||||
emitTaskFailed(
|
||||
taskId: number | string,
|
||||
error: string
|
||||
) {
|
||||
Events.broadcast('TaskFailed', {
|
||||
taskId,
|
||||
error,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const taskEventSystem = TaskEventSystem.getInstance();
|
||||
|
||||
// IPC handlers 供前端调用
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
ipcMain.handle('task:emitStatusChange', (event, taskId, oldStatus, newStatus, data) => {
|
||||
taskEventSystem.emitTaskStatusChange(taskId, oldStatus, newStatus, data);
|
||||
});
|
||||
|
||||
ipcMain.handle('task:emitProgressChange', (event, taskId, progress, message) => {
|
||||
taskEventSystem.emitTaskProgressChange(taskId, progress, message);
|
||||
});
|
||||
|
||||
ipcMain.handle('task:emitComplete', (event, taskId, result) => {
|
||||
taskEventSystem.emitTaskComplete(taskId, result);
|
||||
});
|
||||
|
||||
ipcMain.handle('task:emitFailed', (event, taskId, error) => {
|
||||
taskEventSystem.emitTaskFailed(taskId, error);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user