Compare commits

...

10 Commits

Author SHA1 Message Date
cat-shark a180295b3b 补充客户端打包入口配置 2026-06-20 18:38:49 +08:00
cat-shark 4d7a19dc8a 同步模板维护脚本的资源路径 2026-06-20 18:38:06 +08:00
cat-shark b8a6f0c649 更新运行时资源查找路径 2026-06-20 18:37:46 +08:00
cat-shark c703229518 调整打包脚本使用 packaging 资源路径 2026-06-20 18:37:23 +08:00
cat-shark e3d4fd1aa1 迁移可入库打包资源到 packaging 2026-06-20 18:36:55 +08:00
cat-shark 028d407a78 整理 clean 目录结构说明和忽略规则 2026-06-20 18:36:36 +08:00
cat-shark b6f30a838f 更新本地部署说明和忽略规则 2026-06-20 10:17:04 +08:00
cat-shark 8922a3eef6 接入本地认证启动配置 2026-06-20 10:13:00 +08:00
cat-shark 0fd56f8841 接入原版管理后台静态页面 2026-06-20 10:08:44 +08:00
cat-shark cf82d606aa 新增本地认证与管理接口服务 2026-06-20 10:07:44 +08:00
101 changed files with 13286 additions and 202 deletions
+50 -22
View File
@@ -45,17 +45,20 @@ database.db
src/lang/source-use.json
/data
/local-auth-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/
/archive/
/packaging/resources/linux-arm64/
/packaging/resources/linux-x86/
/packaging/resources/osx/
/packaging/resources/osx-arm64/
/packaging/resources/osx-x86/
/packaging/resources/win-x86-backup/
/packaging/resources/common/chromium/
/packaging/vendor/
nul
# Python cache
@@ -91,15 +94,30 @@ fix_server.py
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/
/resources-bundles/
/dist/
/dist-ssr/
/dist-electron/
/dist-release/
/dist-release-final/
/dist-release-build/
/dist-release-new/
/release-assets/
/win-unpacked/
/builder-debug.yml
/builder-effective-config.yaml
*.blockmap
*.nsis.7z
/packaging/resources/common/playwright/
/packaging/resources/common/python/
/packaging/resources/common/fonts/
/packaging/resources/common/models/
/packaging/resources/common/bgm/
/packaging/resources/common/soundEffects/
/packaging/resources/common/python-scripts/
/packaging/resources/stickers/
/packaging/resources/common/config/* - 副本*.json
/packaging/resources/common/config/*-backup-*.json
# Clean repository: media/content packs are not source code
*.mp3
@@ -133,10 +151,20 @@ active-run.json
activation-server/*.db
user-server/*.db
user-server/_archive_old_versions/
fonts/
bgm/
ziti/
resources/
resources-bundles/
/fonts/
/bgm/
/ziti/
/resources/
/resources-bundles/
tools/**/runs/
electron/resources/voice-preview-cache/
/packaging/resources/voice-preview-cache/*.wav
# Clean repository: local auth/admin runtime data
local-auth-data/
server/local-auth-data/
*.sqlite
*.sqlite3
users.json
# Clean repository: large redistributable installers
/packaging/build-resources/vc_redist*.exe
+69 -5
View File
@@ -2,7 +2,7 @@
这是从原目录整理出的独立副本,后续开发、启动和构建请在本目录执行。
## 启动
## 启动应用
```bat
start-local.bat
@@ -14,14 +14,78 @@ start-local.bat
npm.cmd run start:local
```
默认会同时启动本地认证服务、Vite 和 Electron。
- 认证服务:`http://127.0.0.1:3302`
- 应用认证 API`http://127.0.0.1:3302/api`
- 管理后台:`http://127.0.0.1:3302/admin/`
- 默认账号:`13800138000`
- 默认密码:`123456`
## 单独启动认证/管理服务
```bat
npm.cmd run auth:local
```
管理后台是从遗失代码中恢复的原版纯静态页面,源码位于 `admin-panel/`;服务端代码位于 `server/local-auth/`。当前为了本地测试,认证服务会顺带托管 `admin-panel/`,之后可以把 `admin-panel/` 单独部署到任意静态站点。
独立部署后台时,默认请求同域 `/api`。如果后台和认证服务不在同一个域名,可以通过 `api` 参数指定认证服务地址:
```text
https://admin.example.com/?api=https://auth.example.com
```
本地测试也可以这样写:
```text
http://127.0.0.1:3302/admin/?api=http://127.0.0.1:3302
```
## 自检
```bat
npm.cmd run start:check
```
## 说明
## 打包客户端
- 默认开发端口已改为 `3354`,避免和原目录的 `3344` 服务冲突
- 已排除旧目录中的构建产物、日志、缓存、用户数据和 Git 信息。
- `node_modules` 是实拷贝,不是软链接;同时排除了旧 Electron 目录中误混入的巨大运行数据。
客户端安装包不包含本地认证服务和管理后台,只会连接你指定的认证服务地址
```bat
npm.cmd run build:client:win -- --auth-url=https://auth.example.com
```
本地测试可以先只打包目录,速度更快:
```bat
npm.cmd run build:client:win -- --auth-url=http://127.0.0.1:3302 --dir
```
说明:
- `--auth-url` 可以带或不带 `/api`,脚本会自动规范化。
- 打包时会把该地址注入客户端默认配置。
- 运行时仍可通过环境变量 `AUTH_SERVER_URL` 覆盖认证服务地址。
- 打包配置已排除 `server/``admin-panel/``scripts/local-auth-server.cjs``local-auth-data/`
## 系统 API Key 配置
进入 `http://127.0.0.1:3302/admin/` 后,在“系统配置”里填写:
- `aliyun_bailian_api_key`QwenTTS / DashScope API Key
- `aliyun_bailian_base_url`:默认 `https://dashscope.aliyuncs.com/compatible-mode/v1`
- 其他 OSS、RunningHub、火山引擎、云美颜配置也可以在这里维护
这些配置会通过 `/api/system/config/public` 提供给本地应用作为兜底配置。
## 目录结构约定
clean 目录就是后续入库的项目根目录;外层 `aIzhinengti` 只作为临时整理和参考目录。
- 源码放在 `src/``electron/``server/``admin-panel/`
- 打包相关资源统一放在 `packaging/`
- 大型本地依赖放在 `packaging/vendor/`,例如 Python runtime、FFmpeg、Playwright、模型、字体、BGM;这些内容默认不入库。
- 构建输出、运行数据和历史整理残留分别由 `.gitignore``archive/` 隔离。
更完整说明见 `docs/PROJECT_LAYOUT.md``packaging/README.md`
+35
View File
@@ -0,0 +1,35 @@
# 管理后台
这是从遗失服务端代码中恢复的原版静态管理后台,可以和认证服务分开部署。
## 本地访问
认证服务启动后访问:
```text
http://127.0.0.1:3302/admin/
```
## 独立部署
把本目录作为静态站点部署即可。默认情况下后台会请求同域 `/api`
如果后台和认证服务不在同一个域名,通过 `api` 参数指定认证服务地址:
```text
https://admin.example.com/?api=https://auth.example.com
```
本地测试示例:
```text
http://127.0.0.1:3302/admin/?api=http://127.0.0.1:3302
```
认证服务需要开放 CORS;本地服务默认允许跨域。
## 资源
- `index.html`:原版管理后台页面
- `login-bg.png`:原版登录背景图
- `logo.png`:原版 Logo
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

+4 -4
View File
@@ -165,7 +165,7 @@ if (fs.existsSync(sourceQrcode)) {
// ========== 3. 复制 LOGO 图片 ==========
const logoDirs = [
{ src: 'public', file: 'logo.svg' },
{ src: 'electron/resources/build', file: 'logo.png' },
{ src: 'packaging/build-resources', file: 'logo.png' },
];
const sourceLogo = oemConfig.logoPath ? path.join(__dirname, oemConfig.logoPath) : null;
if (sourceLogo && fs.existsSync(sourceLogo)) {
@@ -174,10 +174,10 @@ if (sourceLogo && fs.existsSync(sourceLogo)) {
fs.copyFileSync(sourceLogo, path.join(publicDir, 'logo.png'));
console.log('[OK] Copied logo.png to public/');
}
const buildDir = path.join(__dirname, 'electron/resources/build');
const buildDir = path.join(__dirname, 'packaging/build-resources');
if (fs.existsSync(buildDir)) {
fs.copyFileSync(sourceLogo, path.join(buildDir, 'logo.png'));
console.log('[OK] Copied logo.png to electron/resources/build/');
console.log('[OK] Copied logo.png to packaging/build-resources/');
}
const allPublicDirs = fs.readdirSync(__dirname).filter(d => {
const sub = path.join(__dirname, d, 'public');
@@ -195,7 +195,7 @@ if (sourceLogo && fs.existsSync(sourceLogo)) {
// ========== 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');
const buildDir = path.join(__dirname, 'packaging/build-resources');
if (fs.existsSync(buildDir)) {
const sharp = require('sharp');
const iconBuf = fs.readFileSync(sourceIcon);
+33
View File
@@ -0,0 +1,33 @@
# Project Layout
`aIzhinengti-clean` is the repository-ready project root. The parent `aIzhinengti` directory is only a temporary workspace/reference area.
## Root
- `src/`: renderer application source.
- `electron/`: Electron main/preload code and app configuration source.
- `server/`, `admin-panel/`: local auth/admin support code.
- `scripts/`: development, packaging, release, and maintenance scripts.
- `packaging/`: build resources and local packaging dependencies.
- `docs/`: project notes and operational documentation.
- `archive/`: preserved cleanup leftovers. Do not treat this as active source.
## Packaging Split
- Track small, intentional packaging assets such as icons and JSON config.
- Keep large runtimes and media packs under `packaging/vendor` or ignored resource subdirectories.
- Do not place Python runtimes, FFmpeg builds, Playwright browsers, models, fonts, BGM, or generated release output in the project root.
## Generated Or Local-Only Directories
The following root directories are local outputs or runtime data and should stay ignored:
```text
build/
data/
dist/
dist-electron/
dist-release-final/
resources-bundles/
node_modules/
```
+34 -26
View File
@@ -14,7 +14,7 @@
"productName": "天英超级IP智能体",
"directories": {
"output": "dist-release-final",
"buildResources": "electron/resources/build"
"buildResources": "packaging/build-resources"
},
"afterPack": "./scripts/build_optimize.cjs",
"files": [
@@ -29,10 +29,20 @@
"*.html"
]
},
"scripts"
{
"from": "scripts",
"to": "scripts",
"filter": [
"**/*",
"!local-auth-server.cjs"
]
},
"!server/**/*",
"!admin-panel/**/*",
"!local-auth-data/**/*"
],
"win": {
icon: "electron/resources/build/logo.ico",
icon: "packaging/build-resources/logo.ico",
"target": [
{
"target": "nsis",
@@ -44,7 +54,7 @@
"artifactName": "${productName}-${version}-win-${arch}.${ext}",
"extraResources": [
{
"from": "electron/resources/extra",
"from": "packaging/resources",
"to": "extra",
"filter": [
"common/config/**/*",
@@ -52,49 +62,47 @@
"common/resource-manifest.json",
"common/cover-templates/**/*",
"common/shuiziren/**/*",
"common/soundEffects/**/*",
"win-x86/**/*",
"win-x86-backup/**/*"
"common/soundEffects/**/*"
]
},
{
"from": "electron/resources/extra/common/playwright",
"from": "packaging/vendor/playwright",
"to": "resources-bundles/playwright"
},
{
"from": "electron/resources/extra/common/python",
"from": "packaging/vendor/python-runtime",
"to": "resources-bundles/python-runtime"
},
{
"from": "electron/resources/extra/win-x86",
"from": "packaging/vendor/ffmpeg",
"to": "resources-bundles/ffmpeg"
},
{
"from": "electron/resources/extra/common/soundEffects",
"from": "packaging/resources/common/soundEffects",
"to": "resources-bundles/soundEffects"
},
{
"from": "electron/resources/extra/common/models",
"from": "packaging/vendor/models",
"to": "resources-bundles/models"
},
{
"from": "electron/resources/extra/common/fonts",
"from": "packaging/vendor/fonts",
"to": "resources-bundles/fonts"
},
{
"from": "ziti",
"from": "packaging/vendor/ziti",
"to": "resources-bundles/ziti"
},
{
"from": "bgm",
"from": "packaging/vendor/bgm",
"to": "resources-bundles/bgm"
},
{
"from": "electron/resources/voice-preview-cache",
"from": "packaging/resources/voice-preview-cache",
"to": "voice-preview-cache"
},
{
"from": "electron/resources/build",
"from": "packaging/build-resources",
"to": "build",
"filter": [
"tray.png",
@@ -119,9 +127,9 @@
"warningsAsErrors": false,
"runAfterFinish": true,
"allowElevation": true,
"installerIcon": "electron/resources/build/logo.ico",
"uninstallerIcon": "electron/resources/build/logo.ico",
"installerHeaderIcon": "electron/resources/build/logo.ico"
"installerIcon": "packaging/build-resources/logo.ico",
"uninstallerIcon": "packaging/build-resources/logo.ico",
"installerHeaderIcon": "packaging/build-resources/logo.ico"
},
"appx": {
"identityName": "IP",
@@ -134,7 +142,7 @@
]
},
"mac": {
"icon": "logo.icns",
"icon": "packaging/build-resources/logo.icns",
"target": [
{
"target": "dir",
@@ -147,7 +155,7 @@
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
"extraResources": [
{
"from": "electron/resources/extra",
"from": "packaging/resources",
"to": "extra",
"filter": [
"common",
@@ -155,7 +163,7 @@
]
},
{
"from": "electron/resources/voice-preview-cache",
"from": "packaging/resources/voice-preview-cache",
"to": "voice-preview-cache"
}
],
@@ -173,7 +181,7 @@
"identity": "Xi'an Yanyi Information Technology Co., Ltd (Q96H3H33RK)"
},
"linux": {
"icon": "logo.icns",
"icon": "packaging/build-resources/logo.icns",
"desktop": "logo",
"maintainer": "天英超级IP智能体",
"category": "Utility",
@@ -196,7 +204,7 @@
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",
"extraResources": [
{
"from": "electron/resources/extra",
"from": "packaging/resources",
"to": "extra",
"filter": [
"common",
@@ -204,7 +212,7 @@
]
},
{
"from": "electron/resources/voice-preview-cache",
"from": "packaging/resources/voice-preview-cache",
"to": "voice-preview-cache"
}
]
@@ -2874,5 +2874,5 @@
"is_system": 1
}
],
"timestamp": "2026-06-16T08:45:31.753Z"
"timestamp": "2026-06-20T10:27:18.332Z"
}
+1 -1
View File
@@ -4294,5 +4294,5 @@
"is_system": 1
}
],
"timestamp": "2026-06-16T08:45:31.753Z"
"timestamp": "2026-06-20T10:27:18.332Z"
}
+2 -3
View File
@@ -59,9 +59,8 @@ 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'),
path.join(app.getAppPath(), 'packaging/vendor/playwright'),
path.join(process.cwd(), 'packaging/vendor/playwright'),
].filter(Boolean);
const chromiumVersions = ['chromium-1200'];
+29 -3
View File
@@ -19,11 +19,15 @@ export function isDevelopment(): boolean {
return false;
}
// 🔧 修复:不硬编码项目文件夹名,改用检查开发目录结构是否存在
// 开发环境特征:项目根目录有 electron/resources/extra 目录
const devResourcePath = path.join(appRoot, 'electron', 'resources', 'extra');
// 开发环境特征:项目根目录有 packaging/resources 目录
const devResourcePath = path.join(appRoot, 'packaging', 'resources');
if (fs.existsSync(devResourcePath)) {
return true;
}
const legacyDevResourcePath = path.join(appRoot, 'electron', 'resources', 'extra');
if (fs.existsSync(legacyDevResourcePath)) {
return true;
}
// 回退:检查旧的文件夹名(兼容)
return appRoot.includes('aigcpanel-main');
}
@@ -35,8 +39,12 @@ export function getResourceRoot(): string {
const isDev = isDevelopment();
if (isDev) {
// 开发环境:项目根目录/electron/resources/extra
// 开发环境:项目根目录/packaging/resources
const appRoot = process.env.APP_ROOT || process.cwd();
const devResourcePath = path.join(appRoot, 'packaging', 'resources');
if (fs.existsSync(devResourcePath)) {
return devResourcePath;
}
return path.join(appRoot, 'electron', 'resources', 'extra');
} else {
// 生产环境:使用 process.resourcesPath 获取 resources 目录
@@ -142,6 +150,10 @@ export function getPythonExecutablePath(): string {
if (fs.existsSync(runtimePath)) {
return runtimePath;
}
const devPath = path.join(process.env.APP_ROOT || process.cwd(), 'packaging', 'vendor', 'python-runtime', pythonFileName);
if (fs.existsSync(devPath)) {
return devPath;
}
return getCommonResourcePath(path.join('python', pythonFileName));
}
@@ -160,6 +172,11 @@ export function getFFmpegExecutablePath(): string {
return runtimePath;
}
const devPath = path.join(process.env.APP_ROOT || process.cwd(), 'packaging', 'vendor', 'ffmpeg', ffmpegFileName);
if (fs.existsSync(devPath)) {
return devPath;
}
return getPlatformResourcePath(ffmpegFileName);
}
@@ -178,6 +195,11 @@ export function getFFprobeExecutablePath(): string {
return runtimePath;
}
const devPath = path.join(process.env.APP_ROOT || process.cwd(), 'packaging', 'vendor', 'ffmpeg', ffprobeFileName);
if (fs.existsSync(devPath)) {
return devPath;
}
return getPlatformResourcePath(ffprobeFileName);
}
@@ -190,6 +212,10 @@ export function getModelPath(modelFileName: string): string {
if (fs.existsSync(runtimePath)) {
return runtimePath;
}
const devPath = path.join(process.env.APP_ROOT || process.cwd(), 'packaging', 'vendor', 'models', modelFileName);
if (fs.existsSync(devPath)) {
return devPath;
}
return getCommonResourcePath(path.join('models', modelFileName));
}
+1 -1
View File
@@ -251,7 +251,7 @@ export async function copyPreCacheFromResources(): Promise<boolean> {
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.cwd(), 'packaging/resources/voice-preview-cache'),
// 备选路径
path.join(process.resourcesPath, 'voice-preview-cache'),
path.join(process.resourcesPath, '../voice-preview-cache'),
+13 -2
View File
@@ -144,8 +144,12 @@ function getZitiDir(): string {
console.warn('[FontManager] 所有 ziti 路径都不存在,返回默认路径');
return path.join(fallbackRoot, 'resources-bundles', 'ziti');
} else {
// 开发环境:ziti 在项目根目录
// 开发环境:ziti 在 packaging/vendor/ziti
const appRoot = getAppRoot();
const devZitiDir = path.join(appRoot, 'packaging', 'vendor', 'ziti');
if (fs.existsSync(devZitiDir)) {
return devZitiDir;
}
return path.join(appRoot, 'ziti');
}
}
@@ -251,7 +255,11 @@ function scanZitiFonts(): any[] {
*/
function getBundledFonts(): any[] {
const appRoot = getAppRoot();
const metadataFile = path.join(appRoot, 'fonts', 'bundled', 'fonts_metadata.json');
const metadataCandidates = [
path.join(appRoot, 'packaging', 'vendor', 'fonts', 'bundled', 'fonts_metadata.json'),
path.join(appRoot, 'fonts', 'bundled', 'fonts_metadata.json'),
];
const metadataFile = metadataCandidates.find(file => fs.existsSync(file)) || metadataCandidates[0];
try {
if (!fs.existsSync(metadataFile)) {
@@ -554,6 +562,9 @@ function getBundledFontDirs(): string[] {
pushIfExists(path.join(bundleRoot, 'fonts', 'ziti'));
}
pushIfExists(path.join(appRoot, 'packaging', 'vendor', 'ziti'));
pushIfExists(path.join(appRoot, 'packaging', 'vendor', 'fonts'));
pushIfExists(path.join(appRoot, 'packaging', 'vendor', 'fonts', 'ziti'));
pushIfExists(path.join(appRoot, 'ziti'));
pushIfExists(path.join(appRoot, 'resources-bundles', 'ziti'));
pushIfExists(path.join(appRoot, 'resources-bundles', 'fonts'));
+7 -3
View File
@@ -79,6 +79,7 @@ function getBundledZitiDirs(): string[] {
};
if (isDev) {
pushIfExists(path.join(process.env.APP_ROOT || process.cwd(), 'packaging', 'vendor', 'ziti'));
pushIfExists(path.join(process.env.APP_ROOT || process.cwd(), 'ziti'));
return dirs;
}
@@ -4875,14 +4876,17 @@ ${subtitleText}
let defaultPath = "";
// 尝试查找bgm目录
// 开发环境: 项目根目录/bgm
// 生产环境: resources/app.asar.unpacked/bgm (由asarUnpack解压)
// 开发环境: packaging/vendor/bgm
// 生产环境: resources-bundles/bgm
const possiblePaths = [
path.join(process.cwd(), 'packaging', 'vendor', 'bgm'),
AppEnv.resourceBundleRoot ? path.join(AppEnv.resourceBundleRoot, 'bgm') : '',
path.join(process.cwd(), 'resources-bundles', 'bgm'),
path.join(process.cwd(), 'bgm'),
path.join(process.resourcesPath, 'app.asar.unpacked', 'bgm'),
path.join(process.resourcesPath, 'bgm'),
path.join(app.getAppPath(), 'bgm')
];
].filter(Boolean);
for (const p of possiblePaths) {
try {
+2 -2
View File
@@ -10,7 +10,7 @@ import { app } from 'electron';
/**
* 音效 ID 到文件名的映射
* 这些音效文件存储在 resources/extra/common/soundEffects/ 目录下
* 这些音效文件存储在 packaging/resources/common/soundEffects/ 目录下
*/
export const SOUND_EFFECT_FILES: Record<string, string> = {
// 原有音效
@@ -81,7 +81,7 @@ export function getSoundEffectsDir(): string {
const isDev = !app.isPackaged;
if (isDev) {
// 开发模式:使用 app.getAppPath() 获取项目根目录
return path.join(app.getAppPath(), 'electron/resources/extra/common/soundEffects');
return path.join(app.getAppPath(), 'packaging/resources/common/soundEffects');
} else {
// 生产模式:使用 process.resourcesPath
return path.join(process.resourcesPath, 'extra/common/soundEffects');
+5 -2
View File
@@ -14,7 +14,9 @@
"scripts": {
"start:local": "node scripts/start-local.cjs",
"start:check": "node scripts/start-local.cjs --check",
"dev": "set VITE_DEV_SERVER_URL=http://127.0.0.1:3354&& vite --host 127.0.0.1 --port 3354 --strictPort",
"auth:local": "node scripts/local-auth-server.cjs",
"admin:local": "node server/local-auth/index.cjs",
"dev": "set AUTH_SERVER_URL=http://127.0.0.1:3302&& set VITE_DEV_SERVER_URL=http://127.0.0.1:3354&& vite --host 127.0.0.1 --port 3354 --strictPort",
"typecheck": "vue-tsc --noEmit",
"build": "node scripts/clean-build-artifacts.cjs && vite build",
"build:dev": "node scripts/clean-build-artifacts.cjs && vite build --mode development",
@@ -22,9 +24,10 @@
"version:sync": "node scripts/sync-version.cjs",
"dev:all": "npm run dev:electron",
"dev:win": "npm run dev:electron",
"dev:electron": "concurrently \"set VITE_DEV_SERVER_URL=http://127.0.0.1:3354&& set EXTERNAL_ELECTRON_DEV=1&& vite --host 127.0.0.1 --port 3354 --strictPort\" \"set VITE_DEV_SERVER_URL=http://127.0.0.1:3354&& node scripts/dev-electron.cjs\"",
"dev:electron": "concurrently \"set AUTH_SERVER_URL=http://127.0.0.1:3302&& set VITE_DEV_SERVER_URL=http://127.0.0.1:3354&& set EXTERNAL_ELECTRON_DEV=1&& vite --host 127.0.0.1 --port 3354 --strictPort\" \"set AUTH_SERVER_URL=http://127.0.0.1:3302&& set VITE_DEV_SERVER_URL=http://127.0.0.1:3354&& node scripts/dev-electron.cjs\"",
"prepare-package": "node apply-oem.cjs && py -3 scripts/export-runtime-system-templates.py && node scripts/prepare-package.cjs",
"build:win": "npm run prepare-package && npm run build && node scripts/build-resource-artifacts.cjs && electron-builder --win --x64 && node scripts/fix-latest-yml.cjs && node scripts/assemble-release-artifacts.cjs",
"build:client:win": "node scripts/package-client.cjs",
"build:win:only": "electron-builder --win --x64",
"build:mac": "npm run prepare-package && npm run typecheck && vite build && electron-builder --mac",
"postinstall": "electron-builder install-app-deps",
+20
View File
@@ -0,0 +1,20 @@
# Packaging Layout
`packaging/` keeps build-time assets separate from application source code.
## Directories
- `build-resources/`: Electron Builder icons and installer assets. Small icon files are source-controlled. Large redistributables such as `vc_redist*.exe` stay local and are ignored.
- `resources/`: Files copied into the packaged app as `resources/extra`, such as config files, cover templates, tray assets, sound effects, and voice preview cache metadata.
- `vendor/`: Local packaging dependencies and large runtime bundles, including Playwright, Python runtime, FFmpeg, models, fonts, ziti, and BGM. This directory is intentionally ignored by Git.
## Restore Local Vendor Assets
Use the existing preparation scripts when a fresh checkout needs local packaging dependencies:
```bat
npm.cmd run prepare-package
node scripts\copy-playwright-browser.cjs
```
Dependency download helpers write into `packaging/vendor` instead of the project root.
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -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
@@ -0,0 +1,28 @@
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<!--
Minimal fontconfig configuration for FFmpeg
This file prevents "Cannot load default config file" errors
and allows drawtext filter to work with fontfile parameter
-->
<fontconfig>
<!-- Font directories - use prefix="cwd" to avoid ambiguous path warning -->
<dir prefix="cwd">.</dir>
<!-- Disable font matching since we use explicit fontfile paths -->
<match target="pattern">
<edit name="family" mode="append" binding="weak">
<string>sans-serif</string>
</edit>
</match>
<!-- Cache configuration - resolved from XDG_CACHE_HOME at runtime -->
<cachedir prefix="xdg">fontconfig-cache</cachedir>
<!-- Minimal config to satisfy fontconfig initialization -->
<config>
<rescan>
<int>0</int>
</rescan>
</config>
</fontconfig>
@@ -0,0 +1,66 @@
{
"templates": [
{
"name": "简约风格",
"description": "简洁大方的黑色半透明背景,适合各类视频",
"config": {
"style": "default",
"fontSize": 80,
"fontColor": [255, 255, 255],
"fontName": "NotoSerifCJK-VF", "fontPath": "",
"position": "top",
"maxWidth": 1920
}
},
{
"name": "描边风格",
"description": "白色文字配黑色描边,清晰醒目",
"config": {
"style": "outline",
"fontSize": 90,
"fontColor": [255, 255, 255],
"outlineColor": [0, 0, 0],
"fontName": "NotoSerifCJK-VF", "fontPath": "",
"position": "top",
"maxWidth": 1920
}
},
{
"name": "模糊背景",
"description": "背景模糊效果,突出标题文字",
"config": {
"style": "blur_bg",
"fontSize": 85,
"fontColor": [255, 255, 255],
"fontName": "NotoSerifCJK-VF", "fontPath": "",
"position": "center",
"maxWidth": 1920
}
},
{
"name": "渐变遮罩",
"description": "优雅的渐变半透明遮罩效果",
"config": {
"style": "gradient",
"fontSize": 75,
"fontColor": [255, 255, 255],
"fontName": "NotoSerifCJK-VF", "fontPath": "",
"position": "top",
"maxWidth": 1920
}
},
{
"name": "分栏设计",
"description": "左侧标题配右侧装饰,设计感十足",
"config": {
"style": "split",
"fontSize": 70,
"fontColor": [255, 255, 255],
"fontName": "NotoSerifCJK-VF", "fontPath": "",
"position": "top",
"accentText": "✨",
"maxWidth": 1920
}
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

@@ -0,0 +1,94 @@
{
"config": {
"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>\n可用变量:{{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}}"
},
"videoMixCutSettings": {
"enabled": false,
"mode": "fixed",
"fixedModeConfig": {
"enabled": false,
"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": []
}
},
"subtitleSettings": {
"defaultFont": "1.程荣光刻楷",
"defaultFontSize": 48,
"defaultPosition": "bottom",
"enableKeywordEffects": true
},
"modelSettings": {
"defaultModel": "gpt-4",
"temperature": 0.7,
"maxTokens": 2000
},
"otherSettings": {
"darkMode": "auto",
"guideWatched": false,
"updaterCheckAtLaunch": "yes"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener(
"click",
function (e) {
e.preventDefault();
e.stopPropagation();
return false;
},
true
);
});
@@ -0,0 +1,77 @@
{
"manifestVersion": 1,
"generatedAt": "2026-06-20T06:40:16.667Z",
"appVersion": "9.9.8",
"bundles": {
"playwright": {
"version": "b2c0ba43945b43d5",
"archive": "playwright-b2c0ba43945b43d5.zip",
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates/playwright-b2c0ba43945b43d5.zip",
"sha256": "5febfa8992005fedba49d6448d04ea7e1dfdfe4d1a64e12dacc9439bcb6fd3df",
"size": 342061468,
"required": true,
"extractTo": "resources-bundles/playwright",
"source": "packaging/vendor/playwright"
},
"python-runtime": {
"version": "43b1bc61b300f429",
"archive": "python-runtime-43b1bc61b300f429.zip",
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates/python-runtime-43b1bc61b300f429.zip",
"sha256": "4d6a0872953d779f4b18a5b45279437c11c21ef057512cf82602b0aa307391fb",
"size": 242930983,
"required": true,
"extractTo": "resources-bundles/python-runtime",
"source": "packaging/vendor/python-runtime"
},
"ffmpeg": {
"version": "b3ee3c78959a363a",
"archive": "ffmpeg-b3ee3c78959a363a.zip",
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates/ffmpeg-b3ee3c78959a363a.zip",
"sha256": "5648084968b1e4228a8b00cd48fb74e5c4daef09a3b3620e5ba9b1081cf6e051",
"size": 148417067,
"required": true,
"extractTo": "resources-bundles/ffmpeg",
"source": "packaging/vendor/ffmpeg"
},
"models": {
"version": "2ba4d03d6459eace",
"archive": "models-2ba4d03d6459eace.zip",
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates/models-2ba4d03d6459eace.zip",
"sha256": "02b9e0f71a7822143c2f4122ce8cdd39f23569705bb0032717655230896e7a3d",
"size": 164653874,
"required": true,
"extractTo": "resources-bundles/models",
"source": "packaging/vendor/models"
},
"fonts": {
"version": "03a3d7c54491326b",
"archive": "fonts-03a3d7c54491326b.zip",
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates/fonts-03a3d7c54491326b.zip",
"sha256": "7520ae93e5b1b7ddb63157ca98e6e6ae0dd8b7f5cd94999a24aee99a7f99dfa3",
"size": 241090999,
"required": false,
"extractTo": "resources-bundles/fonts",
"source": "packaging/vendor/fonts"
},
"ziti": {
"version": "431cd09bfeb5140f",
"archive": "ziti-431cd09bfeb5140f.zip",
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates/ziti-431cd09bfeb5140f.zip",
"sha256": "b6bb315b308d96c8621965342f01dfc74b1e61be513ca543d2d53fccd80bbc8a",
"size": 199724902,
"required": true,
"extractTo": "resources-bundles/ziti",
"source": "packaging/vendor/ziti"
},
"bgm": {
"version": "39c0cca46011e3fd",
"archive": "bgm-39c0cca46011e3fd.zip",
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates/bgm-39c0cca46011e3fd.zip",
"sha256": "7902156de9b99908901e4ad50d19bfeba9dd2bea15e6fc87be8e2f0a7972e836",
"size": 83505038,
"required": false,
"extractTo": "resources-bundles/bgm",
"source": "packaging/vendor/bgm"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -0,0 +1,99 @@
# 音色预缓存目录
此目录用于存放系统默认音色的预缓存文件,在应用打包时包含这些文件可以让用户首次使用时无需等待音色生成。
## 目录用途
- **开发环境**: 用于测试预缓存功能
- **生产环境**: 打包时将此目录包含在应用中,首次启动时会自动复制到用户数据目录
## 如何生成预缓存文件
### 方式一:使用应用自动生成(推荐)
1. 启动应用(开发模式或打包后的应用)
2. 确保已配置 DashScope / 千问 API Key(在模型设置中)
3. 打开音色管理对话框,逐个试听所有默认音色
4. 试听后的音色会自动保存到用户数据目录的 `voice-preview-cache/` 文件夹
5. 找到用户数据目录:
- **Windows**: `C:\Users\<用户名>\AppData\Roaming\zhenqianba-v2\voice-preview-cache`
- **macOS**: `~/Library/Application Support/zhenqianba-v2/voice-preview-cache`
- **Linux**: `~/.config/zhenqianba-v2/voice-preview-cache`
6. 将生成的 `.wav` 文件复制到此目录(`packaging/resources/voice-preview-cache/`
### 方式二:手动触发预缓存
在应用的开发者控制台中执行:
```javascript
await window.$mapi.aliyun.cosyvoice.preCacheSystemVoices('<DashScope API Key>', true);
```
然后从用户数据目录复制生成的文件到此目录。
## 预缓存文件列表
当前系统音色应以 `sys_*.wav` 形式存放,例如:
- `sys_Cherry.wav`
- `sys_Serena.wav`
- `sys_Ethan.wav`
- `sys_Chelsie.wav`
- `sys_Momo.wav`
- `sys_Vivian.wav`
- `sys_Moon.wav`
- `sys_Maia.wav`
- `sys_Kai.wav`
- `sys_Nofish.wav`
- `sys_Bella.wav`
- `sys_Jennifer.wav`
- `sys_Ryan.wav`
- `sys_Katerina.wav`
- `sys_Aiden.wav`
- `sys_Jada.wav`
- `sys_Dylan.wav`
- `sys_Li.wav`
- `sys_Marcus.wav`
- `sys_Roy.wav`
- `sys_Peter.wav`
- `sys_Sunny.wav`
- `sys_Eric.wav`
- `sys_Rocky.wav`
- `sys_Kiki.wav`
- `sys_Bodega.wav`
- `sys_Sonrisa.wav`
- `sys_Alek.wav`
- `sys_Dolce.wav`
- `sys_Sohee.wav`
- `sys_Ono_Anna.wav`
- `sys_Lenn.wav`
- `sys_Emilien.wav`
- `sys_Andre.wav`
## 打包配置
确保在 `electron-builder.json5` 中包含此目录:
```json5
{
"extraResources": [
{
"from": "packaging/resources/voice-preview-cache",
"to": "voice-preview-cache"
}
]
}
```
## 工作原理
1. **首次启动**: 应用检测到用户数据目录没有预缓存文件
2. **资源复制**: 从应用资源目录 (`resources/voice-preview-cache/`) 复制到用户数据目录
3. **即时可用**: 用户试听时直接使用本地缓存,无需等待 API 生成
4. **自动补全**: 如果某些音色未包含在打包中,用户试听时会自动生成并保存
## 注意事项
- 当前试听缓存已改为 `.wav` 文件
- 用户后续试听新音色时,仍会自动保存到本地缓存
- 此功能不影响用户自定义音色的使用
+1 -1
View File
@@ -7,7 +7,7 @@ console.log('=== 全面分析所有配置源和数据库中的模板差异 ===\n
// 1. 收集所有配置文件
const configFiles = [
{ name: 'C盘根目录', path: 'C:\\system-templates.json' },
{ name: '项目打包配置', path: 'electron/resources/extra/common/config/system-templates.json' },
{ name: '项目打包配置', path: 'packaging/resources/common/config/system-templates.json' },
{ name: '项目开发配置', path: 'electron/config/system-templates.json' },
{ name: '历史配置v2', path: 'electron/config/default-subtitle-templates-v2.json' },
{ name: 'TypeScript源文件', path: 'src/config/systemSubtitleTemplates.ts' },
+2 -2
View File
@@ -10,12 +10,12 @@ exports.default = async function (context) {
context: context,
root: context.appOutDir,
});
// copy extra electron/resources/extra/[name]-[arch] to extra
// copy extra packaging/resources/[name]-[arch] to extra
const platformName = common.platformName();
const platformArch = common.platformArch();
const name = platformName + "-" + platformArch;
const srcDir = `electron/resources/extra/${name}`;
const srcDir = `packaging/resources/${name}`;
let destDir = null;
if (platformName === 'osx') {
destDir = common.pathResolve(
+1 -1
View File
@@ -1,6 +1,6 @@
const fs = require('fs');
const content = fs.readFileSync('electron/resources/extra/common/config/system-templates.json', 'utf-8');
const content = fs.readFileSync('packaging/resources/common/config/system-templates.json', 'utf-8');
const data = JSON.parse(content);
console.log('模板数量:', data.subtitleTemplates.length);
+1 -1
View File
@@ -3,7 +3,7 @@ const path = require('path');
// 读取 C 盘根目录的配置文件
const cDriveFile = 'C:\\system-templates.json';
const projectFile = path.join(__dirname, '../electron/resources/extra/common/config/system-templates.json');
const projectFile = path.join(__dirname, '../packaging/resources/common/config/system-templates.json');
console.log('=== 对比 C 盘配置 vs 项目配置 ===\n');
+1 -2
View File
@@ -1,7 +1,7 @@
const fs = require('fs');
const cDriveFile = 'C:\\system-templates.json';
const projectFile = 'electron/resources/extra/common/config/system-templates.json';
const projectFile = 'packaging/resources/common/config/system-templates.json';
const cConfig = JSON.parse(fs.readFileSync(cDriveFile, 'utf-8'));
const pConfig = JSON.parse(fs.readFileSync(projectFile, 'utf-8'));
@@ -71,4 +71,3 @@ Object.keys(pGroups).forEach(key => {
console.log(`模板 ${pGroups[key].join(', ')} 配置相同`);
}
});
+1 -1
View File
@@ -6,7 +6,7 @@ const historicalFile = path.join(__dirname, '../electron/config/default-subtitle
const historical = JSON.parse(fs.readFileSync(historicalFile, 'utf-8'));
// 读取当前配置
const currentFile = path.join(__dirname, '../electron/resources/extra/common/config/system-templates.json');
const currentFile = path.join(__dirname, '../packaging/resources/common/config/system-templates.json');
const current = JSON.parse(fs.readFileSync(currentFile, 'utf-8'));
console.log('=== 历史配置 vs 当前配置对比 ===\n');
+1 -1
View File
@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
// 读取 JSON 配置文件
const jsonFile = path.join(__dirname, '../electron/resources/extra/common/config/system-templates.json');
const jsonFile = path.join(__dirname, '../packaging/resources/common/config/system-templates.json');
const data = JSON.parse(fs.readFileSync(jsonFile, 'utf-8'));
console.log('=== 模板标题字幕配置对比 ===\n');
+1 -1
View File
@@ -11,7 +11,7 @@ async function copyPlaywrightBrowser() {
console.log('='.repeat(60));
// 目标目录:项目打包资源
const targetDir = path.join(__dirname, '../electron/resources/extra/common/playwright');
const targetDir = path.join(__dirname, '../packaging/vendor/playwright');
await fs.ensureDir(targetDir);
// 源目录:Playwright 的缓存位置(开发环境)
+8 -6
View File
@@ -4,7 +4,9 @@ const https = require('https');
const http = require('http');
const { execSync } = require('child_process');
const BASE_DIR = path.join(__dirname, '../electron/resources/extra');
const PACKAGING_DIR = path.join(__dirname, '../packaging');
const RESOURCE_DIR = path.join(PACKAGING_DIR, 'resources');
const VENDOR_DIR = path.join(PACKAGING_DIR, 'vendor');
// 下载进度显示
function downloadFile(url, destPath, description) {
@@ -80,11 +82,11 @@ async function main() {
// 1. 下载 Python 3.12 嵌入式版本(使用淘宝镜像)
console.log('\n📦 步骤 1/5: 下载 Python 3.12 嵌入式版本(国内镜像)');
const pythonDir = path.join(BASE_DIR, 'common/python');
const pythonDir = path.join(VENDOR_DIR, 'python-runtime');
await fs.ensureDir(pythonDir);
if (!fs.existsSync(path.join(pythonDir, 'python.exe'))) {
const pythonZip = path.join(BASE_DIR, 'python-3.12.0-embed-amd64.zip');
const pythonZip = path.join(VENDOR_DIR, 'python-3.12.0-embed-amd64.zip');
// 尝试多个镜像源
const mirrors = [
@@ -206,7 +208,7 @@ trusted-host = pypi.tuna.tsinghua.edu.cn
// 4. FFmpeg 手动下载提示
console.log('\n📦 步骤 4/5: FFmpeg(需要手动下载)');
const ffmpegDir = path.join(BASE_DIR, 'win-x86');
const ffmpegDir = path.join(VENDOR_DIR, 'ffmpeg');
await fs.ensureDir(ffmpegDir);
if (!fs.existsSync(path.join(ffmpegDir, 'ffmpeg.exe'))) {
@@ -226,7 +228,7 @@ trusted-host = pypi.tuna.tsinghua.edu.cn
// 5. Chromium
console.log('\n📦 步骤 5/5: Chromium 浏览器');
const chromiumDir = path.join(BASE_DIR, 'common/chromium');
const chromiumDir = path.join(RESOURCE_DIR, 'common/chromium');
await fs.ensureDir(chromiumDir);
if (!fs.existsSync(path.join(chromiumDir, 'chrome-win'))) {
@@ -268,7 +270,7 @@ trusted-host = pypi.tuna.tsinghua.edu.cn
// 6. AI 模型
console.log('\n📦 步骤 6/6: AI 模型');
const modelsDir = path.join(BASE_DIR, 'common/models');
const modelsDir = path.join(VENDOR_DIR, 'models');
await fs.ensureDir(modelsDir);
const u2netPath = path.join(modelsDir, 'u2net.onnx');
+8 -6
View File
@@ -4,7 +4,9 @@ const https = require('https');
const http = require('http');
const { execSync } = require('child_process');
const BASE_DIR = path.join(__dirname, '../electron/resources/extra');
const PACKAGING_DIR = path.join(__dirname, '../packaging');
const RESOURCE_DIR = path.join(PACKAGING_DIR, 'resources');
const VENDOR_DIR = path.join(PACKAGING_DIR, 'vendor');
// 下载进度显示
function downloadFile(url, destPath, description) {
@@ -81,11 +83,11 @@ async function main() {
// 1. 下载 Python 3.12 嵌入式版本
console.log('\n📦 步骤 1/4: 下载 Python 3.12 嵌入式版本');
const pythonDir = path.join(BASE_DIR, 'common/python');
const pythonDir = path.join(VENDOR_DIR, 'python-runtime');
await fs.ensureDir(pythonDir);
if (!fs.existsSync(path.join(pythonDir, 'python.exe'))) {
const pythonZip = path.join(BASE_DIR, 'python-3.12.0-embed-amd64.zip');
const pythonZip = path.join(VENDOR_DIR, 'python-3.12.0-embed-amd64.zip');
await downloadFile(
'https://www.python.org/ftp/python/3.12.0/python-3.12.0-embed-amd64.zip',
@@ -160,7 +162,7 @@ async function main() {
// 3. 下载 FFmpeg
console.log('\n📦 步骤 3/4: 下载 FFmpeg');
const ffmpegDir = path.join(BASE_DIR, 'win-x86');
const ffmpegDir = path.join(VENDOR_DIR, 'ffmpeg');
await fs.ensureDir(ffmpegDir);
if (!fs.existsSync(path.join(ffmpegDir, 'ffmpeg.exe'))) {
@@ -178,7 +180,7 @@ async function main() {
// 4. 下载 Chromium (通过 puppeteer)
console.log('\n📦 步骤 4/4: 准备 Chromium 浏览器');
const chromiumDir = path.join(BASE_DIR, 'common/chromium');
const chromiumDir = path.join(RESOURCE_DIR, 'common/chromium');
await fs.ensureDir(chromiumDir);
if (!fs.existsSync(path.join(chromiumDir, 'chrome-win'))) {
@@ -220,7 +222,7 @@ async function main() {
// 5. 下载 AI 模型
console.log('\n📦 步骤 5/5: 下载 AI 模型');
const modelsDir = path.join(BASE_DIR, 'common/models');
const modelsDir = path.join(VENDOR_DIR, 'models');
await fs.ensureDir(modelsDir);
const u2netPath = path.join(modelsDir, 'u2net.onnx');
+1 -1
View File
@@ -6,7 +6,7 @@ console.log('=== 查找8个模板的不同配置(普通字幕字体、特效
// 检查所有可能的配置文件
const filesToCheck = [
{ name: 'default-ipagent-config.json', path: 'electron/config/default-ipagent-config.json' },
{ name: 'default-ipagent-config.json (resources)', path: 'electron/resources/extra/common/config/default-ipagent-config.json' },
{ name: 'default-ipagent-config.json (resources)', path: 'packaging/resources/common/config/default-ipagent-config.json' },
{ name: 'default-subtitle-templates.json', path: 'electron/config/default-subtitle-templates.json' },
{ name: 'C盘配置', path: 'C:\\system-templates.json' },
];
+1 -1
View File
@@ -5,7 +5,7 @@ console.log('=== 查找8个模板的不同配置 ===\n');
// 检查所有可能的配置文件
const filesToCheck = [
{ name: 'default-templates.json (subtitleStyles)', path: 'electron/resources/extra/common/config/default-templates.json' },
{ name: 'default-templates.json (subtitleStyles)', path: 'packaging/resources/common/config/default-templates.json' },
{ name: 'default-subtitle-templates.json', path: 'electron/config/default-subtitle-templates.json' },
{ name: 'C盘配置', path: 'C:\\system-templates.json' },
];
@@ -6,7 +6,7 @@ console.log('=== 8个模板配置差异全面分析报告 ===\n');
// 读取所有配置源
const sources = {
'C盘根目录': 'C:\\system-templates.json',
'项目打包配置': 'electron/resources/extra/common/config/system-templates.json',
'项目打包配置': 'packaging/resources/common/config/system-templates.json',
'项目开发配置': 'electron/config/system-templates.json',
'历史配置v2': 'electron/config/default-subtitle-templates-v2.json',
'TypeScript源': 'src/config/systemSubtitleTemplates.ts',
+2 -2
View File
@@ -11,8 +11,8 @@ echo "PROJECT_ROOT: ${PROJECT_ROOT}"
path_svg="${PROJECT_ROOT}/public/logo.svg"
path_white_svg="${PROJECT_ROOT}/public/logo-white.svg"
path_build="${PROJECT_ROOT}/electron/resources/build"
path_extra="${PROJECT_ROOT}/electron/resources/extra"
path_build="${PROJECT_ROOT}/packaging/build-resources"
path_extra="${PROJECT_ROOT}/packaging/resources"
path_source_png="${path_build}/logo_1024x1024.png"
cp -a "${path_svg}" "${PROJECT_ROOT}/src/assets/image/logo.svg"
+22 -21
View File
@@ -15,29 +15,30 @@ else
cd ..
fi
rm -rfv electron/resources/extra/osx-arm64
mkdir -p electron/resources/extra/osx-arm64
cp -a share-binary/osx-arm64/ffmpeg electron/resources/extra/osx-arm64/ffmpeg
cp -a share-binary/osx-arm64/ffprobe electron/resources/extra/osx-arm64/ffprobe
rm -rfv packaging/resources/osx-arm64
mkdir -p packaging/resources/osx-arm64
cp -a share-binary/osx-arm64/ffmpeg packaging/resources/osx-arm64/ffmpeg
cp -a share-binary/osx-arm64/ffprobe packaging/resources/osx-arm64/ffprobe
rm -rfv electron/resources/extra/osx-x86
mkdir -p electron/resources/extra/osx-x86
cp -a share-binary/osx-x86/ffmpeg electron/resources/extra/osx-x86/ffmpeg
cp -a share-binary/osx-x86/ffprobe electron/resources/extra/osx-x86/ffprobe
rm -rfv packaging/resources/osx-x86
mkdir -p packaging/resources/osx-x86
cp -a share-binary/osx-x86/ffmpeg packaging/resources/osx-x86/ffmpeg
cp -a share-binary/osx-x86/ffprobe packaging/resources/osx-x86/ffprobe
rm -rfv electron/resources/extra/linux-arm64
mkdir -p electron/resources/extra/linux-arm64
cp -a share-binary/linux-arm64/ffmpeg electron/resources/extra/linux-arm64/ffmpeg
cp -a share-binary/linux-arm64/ffprobe electron/resources/extra/linux-arm64/ffprobe
rm -rfv packaging/resources/linux-arm64
mkdir -p packaging/resources/linux-arm64
cp -a share-binary/linux-arm64/ffmpeg packaging/resources/linux-arm64/ffmpeg
cp -a share-binary/linux-arm64/ffprobe packaging/resources/linux-arm64/ffprobe
rm -rfv electron/resources/extra/linux-x86
mkdir -p electron/resources/extra/linux-x86
cp -a share-binary/linux-x86/ffmpeg electron/resources/extra/linux-x86/ffmpeg
cp -a share-binary/linux-x86/ffprobe electron/resources/extra/linux-x86/ffprobe
rm -rfv packaging/resources/linux-x86
mkdir -p packaging/resources/linux-x86
cp -a share-binary/linux-x86/ffmpeg packaging/resources/linux-x86/ffmpeg
cp -a share-binary/linux-x86/ffprobe packaging/resources/linux-x86/ffprobe
rm -rfv electron/resources/extra/win-x86
mkdir -p electron/resources/extra/win-x86
cp -a share-binary/win-x86/ffmpeg.exe electron/resources/extra/win-x86/ffmpeg.exe
cp -a share-binary/win-x86/ffprobe.exe electron/resources/extra/win-x86/ffprobe.exe
rm -rfv packaging/vendor/ffmpeg
mkdir -p packaging/vendor/ffmpeg
cp -a share-binary/win-x86/ffmpeg.exe packaging/vendor/ffmpeg/ffmpeg.exe
cp -a share-binary/win-x86/ffprobe.exe packaging/vendor/ffmpeg/ffprobe.exe
ls -R electron/resources/extra
ls -R packaging/resources
ls -R packaging/vendor/ffmpeg
+1
View File
@@ -0,0 +1 @@
require("../server/local-auth/index.cjs");
+4 -6
View File
@@ -12,10 +12,8 @@ function isLargeBundledResource(entry) {
return to.startsWith('resources-bundles/')
|| from === 'ziti'
|| from === 'bgm'
|| from.includes('/common/playwright')
|| from.includes('/common/python')
|| from.includes('/common/models')
|| from.includes('/common/fonts');
|| from.startsWith('packaging/vendor/')
|| from.includes('/packaging/vendor/');
}
const config = JSON5.parse(fs.readFileSync(sourcePath, 'utf8'));
@@ -114,7 +112,7 @@ if (config.win && Array.isArray(config.win.extraResources)) {
.filter(entry => !isLargeBundledResource(entry))
.map(entry => {
const from = String(entry && entry.from || '').replace(/\\/g, '/');
if (from.endsWith('electron/resources/extra') && Array.isArray(entry.filter)) {
if (from.endsWith('packaging/resources') && Array.isArray(entry.filter)) {
return {
...entry,
filter: entry.filter
@@ -134,7 +132,7 @@ if (config.win && Array.isArray(config.win.extraResources)) {
.concat(oemCoverTemplateFiles)
};
}
if (from.endsWith('electron/resources/build') && Array.isArray(entry.filter)) {
if (from.endsWith('packaging/build-resources') && Array.isArray(entry.filter)) {
return {
...entry,
filter: entry.filter.filter(pattern => String(pattern).replace(/\\/g, '/') !== 'vc_redist.x64.exe'),
+163
View File
@@ -0,0 +1,163 @@
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const projectRoot = path.resolve(__dirname, "..");
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const electronBuilder = path.join(
projectRoot,
"node_modules",
".bin",
process.platform === "win32" ? "electron-builder.cmd" : "electron-builder",
);
function usage() {
console.log(`
客户端打包脚本(不包含认证服务/管理后台)
用法:
npm.cmd run build:client:win -- --auth-url=https://auth.example.com
npm.cmd run build:client:win -- --auth-url=http://127.0.0.1:3302 --dry-run
参数:
--auth-url <url> 认证服务地址,可带或不带 /api
--dir 只打包目录,不生成安装包
--dry-run 只打印将执行的命令,不真正打包
--help 显示帮助
`);
}
function readArg(name) {
const prefix = `${name}=`;
const inline = process.argv.find(arg => arg.startsWith(prefix));
if (inline) return inline.slice(prefix.length);
const index = process.argv.indexOf(name);
if (index >= 0) return process.argv[index + 1];
return "";
}
function hasArg(name) {
return process.argv.includes(name);
}
function normalizeAuthUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
if (/^https?\/\//i.test(raw)) {
throw new Error(`认证服务地址无效,协议后缺少冒号:${raw}`);
}
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new Error(`认证服务地址无效:${raw}`);
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error(`认证服务地址只支持 http 或 https:${raw}`);
}
if (["http", "https"].includes(parsed.hostname) && parsed.pathname.startsWith("//")) {
throw new Error(`认证服务地址疑似多写了协议,请检查:${raw}`);
}
if (!parsed.hostname || parsed.hostname.includes("/")) {
throw new Error(`认证服务地址主机名无效:${raw}`);
}
parsed.pathname = parsed.pathname.replace(/\/api\/?$/i, "").replace(/\/+$/, "");
parsed.search = "";
parsed.hash = "";
return parsed.toString().replace(/\/+$/, "");
}
function quoteDisplay(value) {
const text = String(value);
return /\s/.test(text) ? `"${text}"` : text;
}
function quoteCmdArg(value) {
const text = String(value);
if (!/[ \t"&|<>^]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function isWindowsCmd(command) {
return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
}
function run(command, args, env, dryRun) {
const display = [command, ...args].map(quoteDisplay).join(" ");
console.log(`[package-client] ${display}`);
if (dryRun) return;
const spawnCommand = isWindowsCmd(command)
? (process.env.ComSpec || "cmd.exe")
: command;
const spawnArgs = isWindowsCmd(command)
? ["/d", "/s", "/c", [quoteCmdArg(command), ...args.map(quoteCmdArg)].join(" ")]
: args;
const result = spawnSync(spawnCommand, spawnArgs, {
cwd: projectRoot,
stdio: "inherit",
env,
shell: false,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`命令执行失败:${display}`);
}
}
function main() {
if (hasArg("--help") || hasArg("-h")) {
usage();
return;
}
const authUrl = normalizeAuthUrl(
readArg("--auth-url") ||
process.env.PACKAGE_AUTH_SERVER_URL ||
process.env.AUTH_SERVER_URL ||
process.env.VITE_AUTH_SERVER_URL,
);
const dryRun = hasArg("--dry-run");
const dirOnly = hasArg("--dir");
if (!authUrl) {
usage();
throw new Error("打包客户端必须指定认证服务地址:--auth-url=https://auth.example.com");
}
const env = {
...process.env,
AUTH_SERVER_URL: authUrl,
VITE_AUTH_SERVER_URL: authUrl,
PACKAGE_CLIENT_ONLY: "1",
};
console.log(`[package-client] 认证服务地址:${authUrl}`);
console.log("[package-client] 本脚本只打包客户端,不会启动或打包本地认证服务。");
run(npmCommand, ["run", "prepare-package"], env, dryRun);
run(npmCommand, ["run", "build"], env, dryRun);
run(process.execPath, ["scripts/build-resource-artifacts.cjs"], env, dryRun);
run(electronBuilder, ["--win", "--x64", ...(dirOnly ? ["--dir"] : [])], env, dryRun);
if (!dirOnly) {
run(process.execPath, ["scripts/fix-latest-yml.cjs"], env, dryRun);
run(process.execPath, ["scripts/assemble-release-artifacts.cjs"], env, dryRun);
}
}
try {
main();
} catch (error) {
console.error(`[package-client] ${error.message}`);
process.exit(1);
}
+45 -21
View File
@@ -6,8 +6,14 @@ async function preparePackage() {
console.log('📦 开始准备打包资源...\n');
console.log('='.repeat(60));
const extraDir = path.join(__dirname, '../electron/resources/extra');
const packagingDir = path.join(__dirname, '../packaging');
const extraDir = path.join(packagingDir, 'resources');
const vendorDir = path.join(packagingDir, 'vendor');
const commonDir = path.join(extraDir, 'common');
const pythonRuntimeDir = path.join(vendorDir, 'python-runtime');
const ffmpegDir = path.join(vendorDir, 'ffmpeg');
const modelDir = path.join(vendorDir, 'models');
const fontBundleDir = path.join(vendorDir, 'fonts');
// 0. 发布前同步系统字幕模板,避免安装包带入旧候选模板或旧音效配置
console.log('\n0锔忊儯 鍚屾绯荤粺瀛楀箷妯℃澘...');
@@ -16,16 +22,16 @@ async function preparePackage() {
// 1. 确保目录存在
console.log('\n1️⃣ 确保目录结构存在...');
await fs.ensureDir(path.join(commonDir, 'python'));
await fs.ensureDir(pythonRuntimeDir);
await fs.ensureDir(path.join(commonDir, 'chromium'));
await fs.ensureDir(path.join(commonDir, 'python-scripts'));
await fs.ensureDir(path.join(commonDir, 'models'));
await fs.ensureDir(path.join(commonDir, 'fonts'));
await fs.ensureDir(path.join(commonDir, 'bgm'));
await fs.ensureDir(modelDir);
await fs.ensureDir(fontBundleDir);
await fs.ensureDir(vendorDir);
await fs.ensureDir(path.join(commonDir, 'demo'));
await fs.ensureDir(path.join(commonDir, 'config'));
await fs.ensureDir(path.join(commonDir, 'cover-templates'));
await fs.ensureDir(path.join(extraDir, 'win-x86'));
await fs.ensureDir(ffmpegDir);
console.log(' ✅ 目录结构准备完成\n');
// 2. 复制 Python 脚本
@@ -60,7 +66,6 @@ async function preparePackage() {
const fileCount = await countFiles(pythonDest);
console.log(` ✅ Python 脚本复制到 python-scripts 完成 (${fileCount} 个文件)`);
const pythonRuntimeDir = path.join(commonDir, 'python');
await copyWithLockTolerance(pythonSrc, pythonRuntimeDir, {
overwrite: true,
filter: pythonFilter
@@ -72,10 +77,13 @@ async function preparePackage() {
// 3. 复制字体
console.log('3️⃣ 复制字体文件...');
const fontSrc = path.join(__dirname, '../ziti');
const fontDest = path.join(commonDir, 'fonts/ziti');
const fontSrc = await firstExistingPath([
path.join(vendorDir, 'ziti'),
path.join(__dirname, '../ziti')
]);
const fontDest = path.join(fontBundleDir, 'ziti');
if (fs.existsSync(fontSrc)) {
if (fontSrc && fs.existsSync(fontSrc)) {
if (process.env.OEM_BATCH_BUILD === '1' && await fs.pathExists(fontDest)) {
const existingFileCount = await countFiles(fontDest);
if (existingFileCount > 0) {
@@ -105,20 +113,27 @@ async function preparePackage() {
// 4. 复制 BGM
console.log('4️⃣ 复制背景音乐...');
const bgmSrc = path.join(__dirname, '../bgm');
const bgmDest = path.join(commonDir, 'bgm');
const bgmSrc = await firstExistingPath([
path.join(vendorDir, 'bgm'),
path.join(__dirname, '../bgm')
]);
const bgmDest = path.join(vendorDir, 'bgm');
if (fs.existsSync(bgmSrc)) {
if (bgmSrc && fs.existsSync(bgmSrc)) {
if (process.env.OEM_BATCH_BUILD === '1' && await fs.pathExists(bgmDest)) {
const existingFileCount = await countFiles(bgmDest);
if (existingFileCount > 0) {
console.log(` [SKIP] OEM batch build reuses prepared BGM (${existingFileCount} files)\n`);
} else {
if (path.resolve(bgmSrc) !== path.resolve(bgmDest)) {
await copyWithLockTolerance(bgmSrc, bgmDest, { overwrite: true });
}
}
} else {
if (path.resolve(bgmSrc) !== path.resolve(bgmDest)) {
await copyWithLockTolerance(bgmSrc, bgmDest, { overwrite: true });
}
}
const fileCount = await countFiles(bgmDest);
const totalSize = await getDirSize(bgmDest);
console.log(` ✅ BGM 复制完成 (${fileCount} 个文件, ${(totalSize / 1024 / 1024).toFixed(2)} MB)\n`);
@@ -175,7 +190,7 @@ async function preparePackage() {
name: 'default-ipagent-config.json'
},
{
src: path.join(__dirname, '../electron/resources/extra/common/config/system-templates.json'),
src: path.join(commonDir, 'config/system-templates.json'),
dest: path.join(configDest, 'system-templates.json'),
name: 'system-templates.json (系统模板配置 - 8套字幕+8套封面)'
}
@@ -217,7 +232,7 @@ async function preparePackage() {
];
// 动态查找最新的 Playwright Chromium 版本
const playwrightDir = path.join(commonDir, 'playwright');
const playwrightDir = path.join(vendorDir, 'playwright');
let playwrightChromiumCheck = null;
if (await fs.pathExists(playwrightDir)) {
const chromiumDirs = (await fs.readdir(playwrightDir))
@@ -236,13 +251,13 @@ async function preparePackage() {
}
const checks = [
{ name: 'Python 环境', path: path.join(commonDir, 'python/python.exe') },
{ name: 'Python 依赖 (opencv)', path: path.join(commonDir, 'python/Lib/site-packages/cv2') },
{ name: 'FFmpeg (win-x86)', path: path.join(extraDir, 'win-x86/ffmpeg.exe') },
{ name: 'FFprobe (win-x86)', path: path.join(extraDir, 'win-x86/ffprobe.exe') },
...ffmpegDlls.map(dll => ({ name: `FFmpeg DLL (${dll})`, path: path.join(extraDir, `win-x86/${dll}`) })),
{ name: 'Python 环境', path: path.join(pythonRuntimeDir, 'python.exe') },
{ name: 'Python 依赖 (opencv)', path: path.join(pythonRuntimeDir, 'Lib/site-packages/cv2') },
{ name: 'FFmpeg (win-x86)', path: path.join(ffmpegDir, 'ffmpeg.exe') },
{ name: 'FFprobe (win-x86)', path: path.join(ffmpegDir, 'ffprobe.exe') },
...ffmpegDlls.map(dll => ({ name: `FFmpeg DLL (${dll})`, path: path.join(ffmpegDir, dll) })),
...(playwrightChromiumCheck ? [playwrightChromiumCheck] : []),
{ name: 'U2-Net 模型', path: path.join(commonDir, 'models/u2net.onnx') },
{ name: 'U2-Net 模型', path: path.join(modelDir, 'u2net.onnx') },
];
let allOk = true;
@@ -295,6 +310,15 @@ async function copyWithLockTolerance(src, dest, options = {}) {
}
}
async function firstExistingPath(paths) {
for (const candidate of paths) {
if (candidate && await fs.pathExists(candidate)) {
return candidate;
}
}
return null;
}
// 辅助函数:统计文件数量
async function countFiles(dir) {
let count = 0;
+1 -1
View File
@@ -6,7 +6,7 @@
const fs = require('fs');
const path = require('path');
const configPath = path.join(__dirname, '../electron/resources/extra/common/config/system-templates.json');
const configPath = path.join(__dirname, '../packaging/resources/common/config/system-templates.json');
console.log('📖 读取配置文件:', configPath);
+1 -1
View File
@@ -5,7 +5,7 @@
const fs = require('fs');
const path = require('path');
const configPath = path.join(__dirname, '../electron/resources/extra/common/config/system-templates.json');
const configPath = path.join(__dirname, '../packaging/resources/common/config/system-templates.json');
console.log('📖 读取配置文件:', configPath);
+11 -8
View File
@@ -1,54 +1,57 @@
const path = require('path');
const root = path.join(__dirname, '..');
const packagingRoot = path.join(root, 'packaging');
const resourceRoot = path.join(packagingRoot, 'resources');
const vendorRoot = path.join(packagingRoot, 'vendor');
module.exports = {
releaseBaseUrlEnv: 'RESOURCE_BASE_URL',
releaseBaseUrl: 'https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates',
outputDir: path.join(root, 'dist-release-final', 'resource-bundles'),
releaseManifestPath: path.join(root, 'dist-release-final', 'resource-manifest.json'),
embeddedManifestPath: path.join(root, 'electron', 'resources', 'extra', 'common', 'resource-manifest.json'),
embeddedManifestPath: path.join(resourceRoot, 'common', 'resource-manifest.json'),
buildStatePath: path.join(root, 'build', 'resource-build-state.json'),
bundles: [
{
name: 'playwright',
source: path.join(root, 'electron', 'resources', 'extra', 'common', 'playwright'),
source: path.join(vendorRoot, 'playwright'),
required: true,
extractTo: 'resources-bundles/playwright',
},
{
name: 'python-runtime',
source: path.join(root, 'electron', 'resources', 'extra', 'common', 'python'),
source: path.join(vendorRoot, 'python-runtime'),
required: true,
extractTo: 'resources-bundles/python-runtime',
},
{
name: 'ffmpeg',
source: path.join(root, 'electron', 'resources', 'extra', 'win-x86'),
source: path.join(vendorRoot, 'ffmpeg'),
required: true,
extractTo: 'resources-bundles/ffmpeg',
},
{
name: 'models',
source: path.join(root, 'electron', 'resources', 'extra', 'common', 'models'),
source: path.join(vendorRoot, 'models'),
required: true,
extractTo: 'resources-bundles/models',
},
{
name: 'fonts',
source: path.join(root, 'electron', 'resources', 'extra', 'common', 'fonts'),
source: path.join(vendorRoot, 'fonts'),
required: false,
extractTo: 'resources-bundles/fonts',
},
{
name: 'ziti',
source: path.join(root, 'ziti'),
source: path.join(vendorRoot, 'ziti'),
required: true,
extractTo: 'resources-bundles/ziti',
},
{
name: 'bgm',
source: path.join(root, 'bgm'),
source: path.join(vendorRoot, 'bgm'),
required: false,
extractTo: 'resources-bundles/bgm',
},
+77 -13
View File
@@ -5,6 +5,7 @@ const { spawn, spawnSync } = require("node:child_process");
const projectRoot = path.resolve(__dirname, "..");
const serverUrl = process.env.VITE_DEV_SERVER_URL || "http://127.0.0.1:3354";
const authServerUrl = process.env.AUTH_SERVER_URL || "http://127.0.0.1:3302";
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const requiredNodeMajor = 20;
const checkOnly = process.argv.includes("--check");
@@ -18,11 +19,11 @@ function fail(message) {
process.exit(1);
}
function parseServerUrl(url) {
function parseUrl(url, name) {
try {
return new URL(url);
} catch {
fail(`VITE_DEV_SERVER_URL 无效:${url}`);
fail(`${name} 无效:${url}`);
}
}
@@ -57,13 +58,13 @@ function checkDependencies() {
}
}
function assertPortAvailable(host, port) {
function assertPortAvailable(host, port, label) {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", error => {
if (error.code === "EADDRINUSE") {
reject(new Error(`端口 ${port} 已被占用,请先关闭已有 Vite/Electron 开发进程。`));
reject(new Error(`${label} 端口 ${port} 已被占用,请先关闭已有进程。`));
return;
}
reject(error);
@@ -77,24 +78,75 @@ function assertPortAvailable(host, port) {
});
}
function canConnect(host, port) {
return new Promise(resolve => {
const socket = net.createConnection({ host, port });
const finish = ok => {
socket.destroy();
resolve(ok);
};
socket.setTimeout(500);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
async function startLocalAuth(authUrl) {
const authHost = authUrl.hostname || "127.0.0.1";
const authPort = Number(authUrl.port || 3302);
if (await canConnect(authHost, authPort)) {
log(`本地认证服务已在运行:${authServerUrl}`);
return null;
}
const child = spawn(process.execPath, ["scripts/local-auth-server.cjs"], {
cwd: projectRoot,
stdio: "inherit",
env: {
...process.env,
LOCAL_AUTH_HOST: authHost,
LOCAL_AUTH_PORT: String(authPort),
},
shell: false,
});
child.on("error", error => {
fail(`本地认证服务启动失败:${error.message}`);
});
for (let attempt = 0; attempt < 40; attempt++) {
if (await canConnect(authHost, authPort)) {
return child;
}
await new Promise(resolve => setTimeout(resolve, 250));
}
child.kill();
fail(`本地认证服务未能在 ${authServerUrl} 启动。`);
}
async function main() {
process.chdir(projectRoot);
const parsedUrl = parseServerUrl(serverUrl);
const host = parsedUrl.hostname || "127.0.0.1";
const port = Number(parsedUrl.port || 3344);
const parsedServerUrl = parseUrl(serverUrl, "VITE_DEV_SERVER_URL");
const parsedAuthUrl = parseUrl(authServerUrl, "AUTH_SERVER_URL");
const viteHost = parsedServerUrl.hostname || "127.0.0.1";
const vitePort = Number(parsedServerUrl.port || 3354);
log("========================================");
log(" 本地快速启动:Vite + Electron");
log(" 本地快速启动:Local Auth + Vite + Electron");
log("========================================");
log(`项目目录:${projectRoot}`);
log(`认证服务:${authServerUrl}`);
log(`开发地址:${serverUrl}`);
log("");
checkNodeVersion();
const npmVersion = checkCommand(npmCommand, ["--version"], "npm");
checkDependencies();
await assertPortAvailable(host, port);
await assertPortAvailable(viteHost, vitePort, "Vite");
log(`Node.js${process.version}`);
log(`npm${npmVersion}`);
@@ -105,24 +157,36 @@ async function main() {
return;
}
const authChild = await startLocalAuth(parsedAuthUrl);
log("");
log("正在启动应用,关闭此窗口即可停止开发服务。");
log("默认本地账号:13800138000 / 123456");
log("");
const child = spawn(npmCommand, ["run", "dev:electron"], {
const appChild = spawn(npmCommand, ["run", "dev:electron"], {
cwd: projectRoot,
stdio: "inherit",
env: {
...process.env,
VITE_DEV_SERVER_URL: serverUrl,
AUTH_SERVER_URL: authServerUrl,
},
shell: process.platform === "win32",
});
child.on("exit", code => {
const shutdown = code => {
if (authChild && !authChild.killed) {
authChild.kill();
}
process.exit(code ?? 0);
});
};
child.on("error", error => {
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
appChild.on("exit", shutdown);
appChild.on("error", error => {
fail(`启动失败:${error.message}`);
});
}
+27 -3
View File
@@ -3,9 +3,10 @@ const path = require('path');
const repoRoot = path.join(__dirname, '..');
const tsSourcePath = path.join(repoRoot, 'src', 'config', 'systemSubtitleTemplates.ts');
const packagingResourceRoot = path.join(repoRoot, 'packaging', 'resources');
const runtimeDatabasePath = path.join(repoRoot, 'data', 'database.db');
const runtimeExportPath = path.join(repoRoot, 'build', 'runtime-system-subtitle-templates.json');
const previewAssetDir = path.join(repoRoot, 'electron', 'resources', 'extra', 'common', 'cover-templates');
const previewAssetDir = path.join(packagingResourceRoot, 'common', 'cover-templates');
const packagedPreviewPrefix = 'extra/common/cover-templates';
const inlineTemplateIds = new Set([
'template_system_11',
@@ -27,12 +28,12 @@ const defaultTriggeredSoundConfig = {
const systemTemplateJsonPaths = [
path.join(repoRoot, 'electron', 'config', 'system-templates.json'),
path.join(repoRoot, 'electron', 'resources', 'extra', 'common', 'config', 'system-templates.json'),
path.join(packagingResourceRoot, 'common', 'config', 'system-templates.json'),
];
const defaultSubtitleTemplateJsonPaths = [
path.join(repoRoot, 'electron', 'config', 'default-subtitle-templates.json'),
path.join(repoRoot, 'electron', 'resources', 'extra', 'common', 'config', 'default-subtitle-templates.json'),
path.join(packagingResourceRoot, 'common', 'config', 'default-subtitle-templates.json'),
];
const isAbsoluteLocalPath = (value) => /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\');
@@ -148,6 +149,19 @@ const readTemplatesFromTsSource = () => {
return templates;
};
const readTemplatesFromJsonFile = (filePath) => {
if (!fs.existsSync(filePath)) {
return null;
}
const json = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const templates = json.subtitleTemplates;
if (!Array.isArray(templates) || templates.length !== 16) {
return null;
}
return templates;
};
const readCanonicalTemplates = () => {
const dbTemplates = readTemplatesFromRuntimeDatabase();
if (dbTemplates) {
@@ -157,6 +171,16 @@ const readCanonicalTemplates = () => {
};
}
for (const filePath of systemTemplateJsonPaths) {
const jsonTemplates = readTemplatesFromJsonFile(filePath);
if (jsonTemplates) {
return {
source: filePath,
templates: jsonTemplates.map(normalizeTemplateForPackaging),
};
}
}
return {
source: 'ts-source',
templates: readTemplatesFromTsSource().map(normalizeTemplateForPackaging),
+1 -1
View File
@@ -3,7 +3,7 @@ const path = require('path');
// 读取 TypeScript 配置文件
const tsFilePath = path.join(__dirname, '../src/config/systemSubtitleTemplates.ts');
const jsonFilePath = path.join(__dirname, '../electron/resources/extra/common/config/system-templates.json');
const jsonFilePath = path.join(__dirname, '../packaging/resources/common/config/system-templates.json');
console.log('读取 TypeScript 配置文件:', tsFilePath);
const tsContent = fs.readFileSync(tsFilePath, 'utf-8');
+1 -1
View File
@@ -1,7 +1,7 @@
const fs = require('fs');
const path = require('path');
const jsonFilePath = path.join(__dirname, '../electron/resources/extra/common/config/system-templates.json');
const jsonFilePath = path.join(__dirname, '../packaging/resources/common/config/system-templates.json');
const data = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8'));
console.log('验证字幕模板配置:\n');
+354
View File
@@ -0,0 +1,354 @@
const { publicUser, requireAdmin } = require("./auth.cjs");
const { loadDb, saveDb, getSubscription, addUserLog } = require("./db.cjs");
const { sendJson } = require("./http-utils.cjs");
const { nowIso, addHours, passwordHash } = require("./security.cjs");
const { systemConfigDescriptions } = require("./config.cjs");
function isValidSubscription(subscription) {
return subscription && new Date(subscription.expires_at).getTime() > Date.now();
}
function userRow(db, user) {
const subscription = getSubscription(db, user.id);
const owner = db.users.find(item => Number(item.id) === Number(user.owner_admin_id));
const deviceCount = db.devices.filter(item => Number(item.user_id) === Number(user.id)).length || (user.device_fingerprint ? 1 : 0);
return {
...publicUser(user),
avatar: user.avatar || null,
device_fingerprint: user.device_fingerprint || "",
current_token: null,
subscription_type: subscription.subscription_type,
expires_at: subscription.expires_at,
owner_admin_name: owner?.username || null,
device_count: deviceCount,
};
}
function configRows(db) {
return Object.entries(db.systemConfigs).map(([config_key, config_value], index) => ({
id: index + 1,
config_key,
config_value: String(config_value ?? ""),
description: systemConfigDescriptions[config_key] || "",
updated_at: nowIso(),
updated_by: "local-admin",
}));
}
function logRows(db, logs) {
return logs
.slice()
.sort((left, right) => new Date(right.created_at) - new Date(left.created_at))
.map(log => ({
...log,
username: db.users.find(user => Number(user.id) === Number(log.user_id))?.username || null,
}));
}
function generationStats(db, userId) {
const logs = userId
? db.generationLogs.filter(log => Number(log.user_id) === Number(userId))
: db.generationLogs;
const stats = { script: 0, voice: 0, video: 0, subtitle: 0, total: logs.length };
for (const log of logs) {
if (Object.prototype.hasOwnProperty.call(stats, log.type)) stats[log.type] += 1;
}
return stats;
}
function updateUserRole(user, role, inviteCode) {
user.role = role === "admin" ? "admin" : "user";
user.is_super_admin = user.is_super_admin || 0;
if (inviteCode !== undefined) user.invite_code = inviteCode || null;
user.updated_at = nowIso();
}
async function handleAdminApi(req, res, pathname, body, requestUrl) {
if (!pathname.startsWith("/api/admin")) return false;
const db = loadDb();
const admin = requireAdmin(db, req);
if (!admin) {
sendJson(res, 401, { success: false, message: "需要管理员登录" });
return true;
}
if (req.method === "GET" && pathname === "/api/admin/stats") {
const activeUsers = db.users.filter(user => user.status === "active").length;
const adminCount = db.users.filter(user => user.role === "admin" || Number(user.is_super_admin) === 1).length;
const validSubscriptions = db.subscriptions.filter(isValidSubscription).length;
sendJson(res, 200, {
success: true,
stats: {
total_users: db.users.length,
active_users: activeUsers,
admin_count: adminCount,
valid_subscriptions: validSubscriptions,
expired_subscriptions: db.subscriptions.length - validSubscriptions,
total_devices: db.devices.length,
},
});
return true;
}
if (req.method === "GET" && pathname === "/api/admin/users") {
const search = String(requestUrl.searchParams.get("search") || "").trim().toLowerCase();
const status = String(requestUrl.searchParams.get("status") || "").trim();
const subscription = String(requestUrl.searchParams.get("subscription") || "").trim();
let users = db.users;
if (search) {
users = users.filter(user =>
String(user.username || "").toLowerCase().includes(search) ||
String(user.email || "").toLowerCase().includes(search),
);
}
if (status) users = users.filter(user => user.status === status);
if (subscription === "valid") users = users.filter(user => isValidSubscription(getSubscription(db, user.id)));
if (subscription === "expired") users = users.filter(user => !isValidSubscription(getSubscription(db, user.id)));
saveDb(db);
sendJson(res, 200, { success: true, users: users.map(user => userRow(db, user)) });
return true;
}
if (req.method === "GET" && pathname === "/api/admin/admins") {
const admins = db.users
.filter(user => user.role === "admin" || Number(user.is_super_admin) === 1)
.map(user => ({
id: user.id,
username: user.username,
email: user.email,
invite_code: user.invite_code || null,
is_super_admin: Number(user.is_super_admin || 0),
status: user.status,
owned_user_count: db.users.filter(item => Number(item.owner_admin_id) === Number(user.id)).length,
}));
sendJson(res, 200, { success: true, admins });
return true;
}
if (req.method === "POST" && pathname === "/api/admin/add-time") {
const user = db.users.find(item => Number(item.id) === Number(body.user_id));
if (!user) {
sendJson(res, 200, { success: false, message: "用户不存在" });
return true;
}
const subscription = getSubscription(db, user.id);
const base = new Date(subscription.expires_at).getTime() > Date.now() ? new Date(subscription.expires_at) : new Date();
subscription.expires_at = addHours(base, Number(body.hours || 0)).toISOString();
subscription.updated_at = nowIso();
addUserLog(db, user.id, "add_time", body.reason || `管理员增加 ${body.hours || 0} 小时`);
saveDb(db);
sendJson(res, 200, { success: true, subscription });
return true;
}
if (req.method === "POST" && pathname === "/api/admin/user/status") {
const user = db.users.find(item => Number(item.id) === Number(body.user_id));
if (!user) {
sendJson(res, 200, { success: false, message: "用户不存在" });
return true;
}
user.status = body.status === "disabled" ? "disabled" : "active";
user.updated_at = nowIso();
addUserLog(db, user.id, "status", `管理员修改状态为 ${user.status}`);
saveDb(db);
sendJson(res, 200, { success: true, user: userRow(db, user) });
return true;
}
if (req.method === "POST" && pathname === "/api/admin/device/unbind") {
const user = db.users.find(item => Number(item.id) === Number(body.user_id));
if (!user) {
sendJson(res, 200, { success: false, message: "用户不存在" });
return true;
}
user.device_fingerprint = "";
user.last_login_device = null;
db.devices = db.devices.filter(item => Number(item.user_id) !== Number(user.id));
addUserLog(db, user.id, "device_unbind", "管理员解绑设备");
saveDb(db);
sendJson(res, 200, { success: true });
return true;
}
if (req.method === "POST" && pathname === "/api/admin/user/reset-password") {
const user = db.users.find(item => Number(item.id) === Number(body.user_id));
if (!user || !body.new_password) {
sendJson(res, 200, { success: false, message: "用户或新密码无效" });
return true;
}
user.password = passwordHash(body.new_password);
user.updated_at = nowIso();
addUserLog(db, user.id, "reset_password", "管理员重置密码");
saveDb(db);
sendJson(res, 200, { success: true });
return true;
}
if (req.method === "POST" && pathname === "/api/admin/create-admin") {
const email = String(body.email || "").trim();
const username = String(body.username || "").trim();
const password = String(body.password || "");
if (!email || !username || !password) {
sendJson(res, 200, { success: false, message: "管理员账号信息不完整" });
return true;
}
if (db.users.some(user => user.email === email || user.username === username)) {
sendJson(res, 200, { success: false, message: "账号已存在" });
return true;
}
const user = {
id: db.nextUserId++,
username,
email,
password: passwordHash(password),
role: "admin",
status: "active",
points: 999999,
is_super_admin: 0,
owner_admin_id: admin.id,
invite_code: body.invite_code || null,
max_devices: 99,
device_fingerprint: "",
created_at: nowIso(),
updated_at: nowIso(),
last_login_at: null,
last_login_device: null,
current_token: "",
cloud_digital_human_disabled: 0,
};
db.users.push(user);
getSubscription(db, user.id);
addUserLog(db, user.id, "create_admin", `${admin.username} 创建管理员`);
saveDb(db);
sendJson(res, 200, { success: true, admin: userRow(db, user) });
return true;
}
if (req.method === "POST" && pathname === "/api/admin/points/modify") {
const user = db.users.find(item => Number(item.id) === Number(body.user_id));
if (!user) {
sendJson(res, 200, { success: false, message: "用户不存在" });
return true;
}
user.points = Number(user.points || 0) + Number(body.points_change || 0);
user.updated_at = nowIso();
addUserLog(db, user.id, "points_modify", body.reason || `积分变更 ${body.points_change || 0}`);
saveDb(db);
sendJson(res, 200, { success: true, points: user.points });
return true;
}
const userLogMatch = pathname.match(/^\/api\/admin\/logs\/user\/(\d+)$/);
if (req.method === "GET" && userLogMatch) {
const logs = db.userLogs.filter(log => Number(log.user_id) === Number(userLogMatch[1]));
sendJson(res, 200, { success: true, logs: logRows(db, logs) });
return true;
}
if (req.method === "GET" && pathname === "/api/admin/logs/all") {
const limit = Number(requestUrl.searchParams.get("limit") || 100);
sendJson(res, 200, { success: true, logs: logRows(db, db.userLogs).slice(0, limit), page: 1, limit });
return true;
}
if (req.method === "POST" && pathname === "/api/admin/user/assign-owner") {
const user = db.users.find(item => Number(item.id) === Number(body.user_id));
if (!user) {
sendJson(res, 200, { success: false, message: "用户不存在" });
return true;
}
user.owner_admin_id = body.owner_admin_id ? Number(body.owner_admin_id) : null;
user.updated_at = nowIso();
addUserLog(db, user.id, "assign_owner", `管理员归属变更为 ${user.owner_admin_id || "无"}`);
saveDb(db);
sendJson(res, 200, { success: true, user: userRow(db, user) });
return true;
}
if (req.method === "PUT" && pathname === "/api/admin/admin/invite-code") {
const user = db.users.find(item => Number(item.id) === Number(body.admin_id));
if (!user) {
sendJson(res, 200, { success: false, message: "管理员不存在" });
return true;
}
user.invite_code = body.invite_code || null;
user.updated_at = nowIso();
addUserLog(db, user.id, "invite_code", "管理员邀请码变更");
saveDb(db);
sendJson(res, 200, { success: true, admin: userRow(db, user) });
return true;
}
if (req.method === "POST" && pathname === "/api/admin/user/role") {
const user = db.users.find(item => Number(item.id) === Number(body.user_id));
if (!user) {
sendJson(res, 200, { success: false, message: "用户不存在" });
return true;
}
updateUserRole(user, body.role, body.invite_code);
addUserLog(db, user.id, "role", `角色变更为 ${user.role}`);
saveDb(db);
sendJson(res, 200, { success: true, user: userRow(db, user) });
return true;
}
if (req.method === "GET" && pathname === "/api/admin/generation-stats") {
sendJson(res, 200, {
success: true,
stats: generationStats(db, requestUrl.searchParams.get("userId")),
});
return true;
}
if (req.method === "GET" && pathname === "/api/admin/generation-logs") {
const userId = requestUrl.searchParams.get("userId");
const limit = Number(requestUrl.searchParams.get("limit") || 100);
const logs = userId ? db.generationLogs.filter(log => Number(log.user_id) === Number(userId)) : db.generationLogs;
const rows = logRows(db, logs).slice(0, limit);
sendJson(res, 200, { success: true, logs: rows, total: logs.length });
return true;
}
if (req.method === "GET" && pathname === "/api/admin/system/config") {
sendJson(res, 200, { success: true, configs: configRows(db) });
return true;
}
if (req.method === "PUT" && pathname === "/api/admin/system/config") {
const updates = Array.isArray(body.configs)
? body.configs.reduce((acc, item) => ({ ...acc, [item.config_key]: item.config_value }), {})
: body.configs || body;
for (const [key, value] of Object.entries(updates || {})) {
if (key in db.systemConfigs) db.systemConfigs[key] = String(value ?? "");
}
addUserLog(db, admin.id, "system_config", "管理员更新系统配置");
saveDb(db);
sendJson(res, 200, { success: true, configs: configRows(db) });
return true;
}
if (req.method === "PUT" && pathname === "/api/admin/user/cloud-digital-human") {
const user = db.users.find(item => Number(item.id) === Number(body.user_id));
if (!user) {
sendJson(res, 200, { success: false, message: "用户不存在" });
return true;
}
user.cloud_digital_human_disabled = body.disabled ? 1 : 0;
user.updated_at = nowIso();
addUserLog(db, user.id, "cloud_digital_human", `云数字人状态 ${user.cloud_digital_human_disabled ? "禁用" : "启用"}`);
saveDb(db);
sendJson(res, 200, { success: true, user: userRow(db, user) });
return true;
}
sendJson(res, 404, { success: false, message: `Admin endpoint not found: ${req.method} ${pathname}` });
return true;
}
module.exports = {
handleAdminApi,
};
+177
View File
@@ -0,0 +1,177 @@
const { publicUser, findUserByToken } = require("./auth.cjs");
const { loadDb, saveDb, getSubscription, addUserLog, addGenerationLog } = require("./db.cjs");
const { sendJson } = require("./http-utils.cjs");
const { nowIso, createToken, checkPassword, passwordHash, daysRemaining } = require("./security.cjs");
function findLoginUser(db, account) {
const value = String(account || "").trim();
return db.users.find(user => user.email === value || user.username === value);
}
async function handleAppApi(req, res, pathname, body) {
const db = loadDb();
if (req.method === "GET" && pathname === "/api/system/config/public") {
sendJson(res, 200, { success: true, config: db.systemConfigs });
return true;
}
if (req.method === "POST" && pathname === "/api/register") {
const username = String(body.username || "").trim();
const email = String(body.email || "").trim();
const password = String(body.password || "");
if (!username || !email || !password) {
sendJson(res, 200, { success: false, message: "用户名、手机号和密码不能为空" });
return true;
}
if (db.users.some(user => user.email === email || user.username === username)) {
sendJson(res, 200, { success: false, message: "用户名或手机号已存在" });
return true;
}
const isFirstUser = db.users.length === 0;
const user = {
id: db.nextUserId++,
username,
email,
password: passwordHash(password),
role: isFirstUser ? "admin" : "user",
status: "active",
points: isFirstUser ? 999999 : 1000,
is_super_admin: isFirstUser ? 1 : 0,
owner_admin_id: null,
invite_code: body.invite_code || null,
max_devices: 99,
device_fingerprint: body.device_fingerprint || "",
created_at: nowIso(),
updated_at: nowIso(),
last_login_at: nowIso(),
last_login_device: body.device_fingerprint || null,
current_token: "",
cloud_digital_human_disabled: 0,
};
const token = createToken(user.id);
user.current_token = token;
db.users.push(user);
const subscription = getSubscription(db, user.id);
addUserLog(db, user.id, "register", "本地注册成功", { device_fingerprint: body.device_fingerprint });
saveDb(db);
sendJson(res, 200, {
success: true,
token,
userId: user.id,
user: publicUser(user),
subscription,
message: "本地注册成功",
});
return true;
}
if (req.method === "POST" && pathname === "/api/login") {
const account = body.email || body.username || body.phone;
const password = String(body.password || "");
const user = findLoginUser(db, account);
if (!user || !checkPassword(password, user.password)) {
sendJson(res, 200, { success: false, message: "账号或密码错误" });
return true;
}
if (user.status !== "active") {
sendJson(res, 200, { success: false, message: "账户已被禁用" });
return true;
}
const token = createToken(user.id);
user.current_token = token;
user.last_login_at = nowIso();
user.last_login_device = body.device_fingerprint || null;
const subscription = getSubscription(db, user.id);
addUserLog(db, user.id, "login", "本地登录成功", { device_fingerprint: body.device_fingerprint });
saveDb(db);
sendJson(res, 200, {
success: true,
token,
user: publicUser(user),
subscription,
message: "本地登录成功",
});
return true;
}
if (req.method === "POST" && pathname === "/api/verify") {
const user = findUserByToken(db, body.token);
if (!user || user.status !== "active") {
sendJson(res, 200, { valid: false, success: false, message: "Token 无效" });
return true;
}
const subscription = getSubscription(db, user.id);
saveDb(db);
sendJson(res, 200, {
valid: true,
success: true,
payload: { userId: user.id, role: user.role },
user: publicUser(user),
subscription,
});
return true;
}
if (req.method === "POST" && pathname === "/api/subscription/check") {
const userId = Number(body.user_id || body.userId);
const subscription = getSubscription(db, userId);
const remainingDays = daysRemaining(subscription.expires_at);
saveDb(db);
sendJson(res, 200, {
success: true,
hasAccess: remainingDays > 0,
expiresAt: subscription.expires_at,
remainingDays,
isExpired: remainingDays <= 0,
subscription,
});
return true;
}
if (req.method === "POST" && pathname === "/api/device/checkin") {
const userId = Number(body.user_id || body.userId || 0);
if (userId) {
const user = db.users.find(item => Number(item.id) === userId);
if (user) {
user.device_fingerprint = body.device_fingerprint || user.device_fingerprint || "";
user.last_login_device = body.device_fingerprint || user.last_login_device || null;
user.updated_at = nowIso();
}
if (body.device_fingerprint && !db.devices.some(item => item.user_id === userId && item.device_fingerprint === body.device_fingerprint)) {
db.devices.push({ user_id: userId, device_fingerprint: body.device_fingerprint, created_at: nowIso() });
}
saveDb(db);
}
sendJson(res, 200, { success: true, message: "本地设备校验通过" });
return true;
}
if (req.method === "POST" && pathname === "/api/user/generation-log") {
addGenerationLog(
db,
body.user_id || body.userId,
body.type,
body.detail || body.message || body,
{ device_fingerprint: body.device_fingerprint },
);
saveDb(db);
sendJson(res, 200, { success: true });
return true;
}
return false;
}
module.exports = {
handleAppApi,
};
+33
View File
@@ -0,0 +1,33 @@
const { verifyToken } = require("./security.cjs");
function publicUser(user) {
if (!user) return null;
const { password, current_token, ...safeUser } = user;
return safeUser;
}
function findUserByToken(db, token) {
const payload = verifyToken(token);
if (!payload?.userId) return null;
return db.users.find(user => Number(user.id) === Number(payload.userId)) || null;
}
function getBearerToken(req) {
const header = req.headers.authorization || "";
const match = String(header).match(/^Bearer\s+(.+)$/i);
return match ? match[1] : "";
}
function requireAdmin(db, req) {
const user = findUserByToken(db, getBearerToken(req));
if (!user || user.status !== "active") return null;
if (user.role !== "admin" && Number(user.is_super_admin || 0) !== 1) return null;
return user;
}
module.exports = {
publicUser,
findUserByToken,
getBearerToken,
requireAdmin,
};
+75
View File
@@ -0,0 +1,75 @@
const path = require("node:path");
const os = require("node:os");
const root = path.resolve(__dirname, "../..");
const defaultSystemConfig = {
aliyun_bailian_api_key: "",
aliyun_bailian_base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1",
aliyun_oss_access_key_id: "",
aliyun_oss_access_key_secret: "",
aliyun_oss_bucket: "",
aliyun_oss_region: "oss-cn-beijing",
volcengine_api_key: "",
volcengine_model: "doubao-seed-2-0-mini-260215",
runninghub_api_key: "",
runninghub_workflow_id_1: "2013514129943826433",
runninghub_workflow_id_2: "2013514129943826433",
runninghub_tts_webapp_id_v2: "1965614643077070850",
runninghub_tts_webapp_id_v3: "2028779949334728706",
runninghub_tts_v2_audio_node: "13",
runninghub_tts_v2_text_node: "14",
runninghub_tts_v2_emotion_node: "15",
cloud_beauty_base_url: "",
cloud_beauty_api_key: "",
cloud_beauty_default_mode: "256m",
cloud_beauty_enabled: "false",
trial_duration_hours: "168",
update_url: "",
};
const systemConfigDescriptions = {
aliyun_bailian_api_key: "阿里云百炼 / DashScope API Key",
aliyun_bailian_base_url: "阿里云百炼兼容 OpenAI Base URL",
aliyun_oss_access_key_id: "阿里云 OSS AccessKey ID",
aliyun_oss_access_key_secret: "阿里云 OSS AccessKey Secret",
aliyun_oss_bucket: "阿里云 OSS Bucket",
aliyun_oss_region: "阿里云 OSS Region",
volcengine_api_key: "火山引擎 API Key",
volcengine_model: "火山引擎默认模型",
runninghub_api_key: "RunningHub API Key",
runninghub_workflow_id_1: "RunningHub 工作流 ID 1",
runninghub_workflow_id_2: "RunningHub 工作流 ID 2",
runninghub_tts_webapp_id_v2: "RunningHub TTS WebApp ID V2",
runninghub_tts_webapp_id_v3: "RunningHub TTS WebApp ID V3",
runninghub_tts_v2_audio_node: "RunningHub TTS V2 音频节点",
runninghub_tts_v2_text_node: "RunningHub TTS V2 文本节点",
runninghub_tts_v2_emotion_node: "RunningHub TTS V2 情绪节点",
cloud_beauty_base_url: "云端美颜 Base URL",
cloud_beauty_api_key: "云端美颜 API Key",
cloud_beauty_default_mode: "云端美颜默认模式",
cloud_beauty_enabled: "是否启用云端美颜",
trial_duration_hours: "试用时长(小时)",
update_url: "软件更新地址",
};
function resolveDataDir() {
if (process.env.LOCAL_AUTH_DATA_DIR) return path.resolve(process.env.LOCAL_AUTH_DATA_DIR);
return path.join(process.env.APPDATA || os.homedir() || root, "aIzhinengti-clean-local-auth");
}
module.exports = {
root,
host: process.env.LOCAL_AUTH_HOST || "127.0.0.1",
port: Number(process.env.LOCAL_AUTH_PORT || process.env.PORT || 3302),
dataDir: resolveDataDir(),
tokenSecret: process.env.LOCAL_AUTH_SECRET || "local-auth-dev-secret",
adminPanelDir: process.env.ADMIN_PANEL_DIR || path.join(root, "admin-panel"),
defaultUser: {
username: process.env.LOCAL_AUTH_DEFAULT_USERNAME || "本地管理员",
email: process.env.LOCAL_AUTH_DEFAULT_EMAIL || "13800138000",
password: process.env.LOCAL_AUTH_DEFAULT_PASSWORD || "123456",
},
defaultSystemConfig,
systemConfigDescriptions,
};
+186
View File
@@ -0,0 +1,186 @@
const fs = require("node:fs");
const path = require("node:path");
const { dataDir, defaultUser, defaultSystemConfig } = require("./config.cjs");
const { addYears, nowIso, passwordHash } = require("./security.cjs");
const dbPath = path.join(dataDir, "users.json");
let memoryDb = null;
function createInitialDb() {
const expiresAt = addYears(new Date(), 100).toISOString();
const user = {
id: 1,
username: defaultUser.username,
email: defaultUser.email,
password: passwordHash(defaultUser.password),
role: "admin",
status: "active",
points: 999999,
is_super_admin: 1,
owner_admin_id: null,
invite_code: "LOCALADMIN",
max_devices: 99,
device_fingerprint: "",
created_at: nowIso(),
updated_at: nowIso(),
last_login_at: null,
last_login_device: null,
current_token: "",
cloud_digital_human_disabled: 0,
};
return {
nextUserId: 2,
nextSubscriptionId: 2,
nextUserLogId: 1,
nextGenerationLogId: 1,
users: [user],
subscriptions: [
{
id: 1,
user_id: 1,
subscription_type: "local",
expires_at: expiresAt,
trial_started_at: nowIso(),
created_at: nowIso(),
updated_at: nowIso(),
},
],
generationLogs: [],
userLogs: [],
devices: [],
systemConfigs: { ...defaultSystemConfig },
};
}
function normalizeSystemConfigs(value) {
if (Array.isArray(value)) {
return value.reduce((configs, item) => {
if (item?.config_key) configs[item.config_key] = String(item.config_value ?? "");
return configs;
}, { ...defaultSystemConfig });
}
return { ...defaultSystemConfig, ...(value || {}) };
}
function normalizeDb(db) {
const initial = createInitialDb();
const normalized = { ...initial, ...(db || {}) };
normalized.users = Array.isArray(normalized.users) ? normalized.users : initial.users;
normalized.subscriptions = Array.isArray(normalized.subscriptions) ? normalized.subscriptions : initial.subscriptions;
normalized.generationLogs = Array.isArray(normalized.generationLogs) ? normalized.generationLogs : [];
normalized.userLogs = Array.isArray(normalized.userLogs) ? normalized.userLogs : [];
normalized.devices = Array.isArray(normalized.devices) ? normalized.devices : [];
normalized.systemConfigs = normalizeSystemConfigs(normalized.systemConfigs);
for (const user of normalized.users) {
user.role = user.role || "user";
user.status = user.status || "active";
user.points = Number(user.points || 0);
user.max_devices = Number(user.max_devices || 1);
user.is_super_admin = Number(user.is_super_admin || 0);
user.cloud_digital_human_disabled = Number(user.cloud_digital_human_disabled || 0);
user.created_at = user.created_at || nowIso();
user.updated_at = user.updated_at || nowIso();
}
normalized.nextUserId = Math.max(
Number(normalized.nextUserId || 1),
...normalized.users.map(user => Number(user.id || 0) + 1),
);
normalized.nextSubscriptionId = Math.max(
Number(normalized.nextSubscriptionId || 1),
...normalized.subscriptions.map(item => Number(item.id || 0) + 1),
);
normalized.nextUserLogId = Math.max(
Number(normalized.nextUserLogId || 1),
...normalized.userLogs.map(item => Number(item.id || 0) + 1),
);
normalized.nextGenerationLogId = Math.max(
Number(normalized.nextGenerationLogId || 1),
...normalized.generationLogs.map(item => Number(item.id || 0) + 1),
);
return normalized;
}
function ensureDb() {
if (memoryDb) return;
try {
fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, JSON.stringify(createInitialDb(), null, 2), "utf8");
}
} catch (error) {
memoryDb = createInitialDb();
console.warn(`[local-auth] cannot write ${dbPath}; using in-memory database: ${error.message}`);
}
}
function loadDb() {
ensureDb();
if (memoryDb) return memoryDb;
return normalizeDb(JSON.parse(fs.readFileSync(dbPath, "utf8")));
}
function saveDb(db) {
const normalized = normalizeDb(db);
if (memoryDb) {
memoryDb = normalized;
return;
}
fs.writeFileSync(dbPath, JSON.stringify(normalized, null, 2), "utf8");
}
function getSubscription(db, userId) {
const existing = db.subscriptions.find(item => Number(item.user_id) === Number(userId));
if (existing) return existing;
const subscription = {
id: db.nextSubscriptionId++,
user_id: Number(userId),
subscription_type: "local",
expires_at: addYears(new Date(), 100).toISOString(),
trial_started_at: nowIso(),
created_at: nowIso(),
updated_at: nowIso(),
};
db.subscriptions.push(subscription);
return subscription;
}
function addUserLog(db, userId, action, message, extra = {}) {
db.userLogs.push({
id: db.nextUserLogId++,
user_id: Number(userId || 0) || null,
action,
message,
device_fingerprint: extra.device_fingerprint || null,
ip_address: extra.ip_address || "127.0.0.1",
created_at: nowIso(),
});
}
function addGenerationLog(db, userId, type, detail, extra = {}) {
db.generationLogs.push({
id: db.nextGenerationLogId++,
user_id: Number(userId || 0) || null,
type: type || "unknown",
detail: typeof detail === "string" ? detail : JSON.stringify(detail || {}),
device_fingerprint: extra.device_fingerprint || null,
ip_address: extra.ip_address || "127.0.0.1",
created_at: nowIso(),
});
}
module.exports = {
dbPath,
ensureDb,
loadDb,
saveDb,
getSubscription,
addUserLog,
addGenerationLog,
};
+68
View File
@@ -0,0 +1,68 @@
const fs = require("node:fs");
const path = require("node:path");
function sendJson(res, statusCode, body) {
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Cache-Control": "no-store",
});
res.end(JSON.stringify(body));
}
function sendText(res, statusCode, text, contentType = "text/plain; charset=utf-8") {
res.writeHead(statusCode, {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
});
res.end(text);
}
function redirect(res, location) {
res.writeHead(302, { Location: location });
res.end();
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const text = Buffer.concat(chunks).toString("utf8");
if (!text) return {};
try {
return JSON.parse(text);
} catch {
return {};
}
}
function serveFile(res, filePath, contentType = "text/html; charset=utf-8") {
if (!fs.existsSync(filePath)) {
sendText(res, 404, "Not found");
return;
}
res.writeHead(200, {
"Content-Type": contentType,
"Cache-Control": "no-store",
});
fs.createReadStream(filePath).pipe(res);
}
function safeJoin(baseDir, requestPath) {
const resolved = path.resolve(baseDir, requestPath.replace(/^\/+/, ""));
if (!resolved.startsWith(path.resolve(baseDir))) return null;
return resolved;
}
module.exports = {
sendJson,
sendText,
redirect,
readBody,
serveFile,
safeJoin,
};
+60
View File
@@ -0,0 +1,60 @@
const http = require("node:http");
const { host, port, defaultUser } = require("./config.cjs");
const { dbPath, ensureDb } = require("./db.cjs");
const { handleAppApi } = require("./app-api.cjs");
const { handleAdminApi } = require("./admin-api.cjs");
const { handleAdminStatic } = require("./static-admin.cjs");
const { sendJson } = require("./http-utils.cjs");
const { nowIso } = require("./security.cjs");
const { readBody } = require("./http-utils.cjs");
function createLocalAuthServer() {
ensureDb();
return http.createServer(async (req, res) => {
const requestUrl = new URL(req.url, `http://${req.headers.host || `${host}:${port}`}`);
const pathname = requestUrl.pathname.replace(/\/+$/, "") || "/";
try {
if (req.method === "OPTIONS") {
sendJson(res, 200, { success: true });
return;
}
if (req.method === "GET" && (pathname === "/" || pathname === "/health" || pathname === "/api/health")) {
sendJson(res, 200, { success: true, service: "local-auth", admin: "/admin/", time: nowIso() });
return;
}
if (handleAdminStatic(req, res, requestUrl.pathname)) return;
const body = ["POST", "PUT", "PATCH"].includes(req.method) ? await readBody(req) : {};
if (await handleAdminApi(req, res, pathname, body, requestUrl)) return;
if (await handleAppApi(req, res, pathname, body, requestUrl)) return;
sendJson(res, 404, { success: false, message: `Local auth endpoint not found: ${req.method} ${pathname}` });
} catch (error) {
sendJson(res, 500, { success: false, message: error.message || "Local auth server error" });
}
});
}
function start() {
const server = createLocalAuthServer();
server.listen(port, host, () => {
console.log(`[local-auth] running at http://${host}:${port}`);
console.log(`[local-auth] admin panel: http://${host}:${port}/admin/`);
console.log(`[local-auth] default account: ${defaultUser.email} / ${defaultUser.password}`);
console.log(`[local-auth] data file: ${dbPath}`);
});
return server;
}
if (require.main === module || module.parent?.filename?.endsWith("local-auth-server.cjs")) {
start();
}
module.exports = {
createLocalAuthServer,
start,
};
+63
View File
@@ -0,0 +1,63 @@
const crypto = require("node:crypto");
const { tokenSecret } = require("./config.cjs");
function nowIso() {
return new Date().toISOString();
}
function addYears(date, years) {
const next = new Date(date);
next.setFullYear(next.getFullYear() + years);
return next;
}
function addHours(date, hours) {
return new Date(date.getTime() + Number(hours || 0) * 3600000);
}
function sign(value) {
return crypto.createHmac("sha256", tokenSecret).update(value).digest("base64url");
}
function createToken(userId) {
const payload = Buffer.from(JSON.stringify({ userId, issuedAt: Date.now() })).toString("base64url");
return `${payload}.${sign(payload)}`;
}
function verifyToken(token) {
if (!token || typeof token !== "string" || !token.includes(".")) return null;
const [payload, signature] = token.split(".");
if (!payload || !signature || sign(payload) !== signature) return null;
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
} catch {
return null;
}
}
function passwordHash(password, salt = crypto.randomBytes(16).toString("hex")) {
const hash = crypto.pbkdf2Sync(String(password), salt, 120000, 32, "sha256").toString("hex");
return `${salt}:${hash}`;
}
function checkPassword(password, stored) {
const [salt] = String(stored || "").split(":");
if (!salt) return false;
return passwordHash(password, salt) === stored;
}
function daysRemaining(expiresAt) {
return Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86400000));
}
module.exports = {
nowIso,
addYears,
addHours,
createToken,
verifyToken,
passwordHash,
checkPassword,
daysRemaining,
};
+40
View File
@@ -0,0 +1,40 @@
const path = require("node:path");
const { adminPanelDir } = require("./config.cjs");
const { redirect, serveFile, safeJoin } = require("./http-utils.cjs");
function handleAdminStatic(req, res, pathname) {
if (req.method !== "GET") return false;
if (pathname === "/admin") {
redirect(res, "/admin/");
return true;
}
if (pathname === "/admin/") {
serveFile(res, path.join(adminPanelDir, "index.html"));
return true;
}
if (pathname.startsWith("/admin/")) {
const filePath = safeJoin(adminPanelDir, pathname.replace(/^\/admin\//, ""));
if (!filePath) return false;
const ext = path.extname(filePath).toLowerCase();
const contentType = {
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
}[ext] || "application/octet-stream";
serveFile(res, filePath, contentType);
return true;
}
return false;
}
module.exports = {
handleAdminStatic,
};
@@ -161,10 +161,10 @@
<a-card :title="t('字体目录')" :bordered="false">
<a-descriptions :column="1" bordered>
<a-descriptions-item :label="t('预置字体目录')">
<code class="text-xs">fonts/bundled/</code>
<code class="text-xs">packaging/vendor/fonts/bundled/</code>
</a-descriptions-item>
<a-descriptions-item :label="t('ziti字体目录')">
<code class="text-xs">ziti/</code>
<code class="text-xs">packaging/vendor/ziti/</code>
<a-button size="mini" type="text" @click="openZitiDir" class="ml-2">
<template #icon>
<icon-folder />
@@ -429,7 +429,7 @@ const openFontDir = async () => {
const openZitiDir = async () => {
try {
const path = require('path');
const zitiPath = path.join(process.cwd(), 'ziti');
const zitiPath = path.join(process.cwd(), 'packaging', 'vendor', 'ziti');
console.log('打开 ziti 目录:', zitiPath);
// 🔧 使用 $mapi 代替直接 require('electron')
@@ -97,7 +97,7 @@ const handleOpenConfigPath = async () => {
<div class="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p class="font-semibold text-sm mb-2">{{ t('配置文件位置') }}</p>
<div class="text-xs bg-gray-200 dark:bg-gray-800 rounded p-2 font-mono break-all">
electron/resources/extra/common/config/system-templates.json
packaging/resources/common/config/system-templates.json
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">
此文件在应用打包时会被包含所有系统模板都会被初始化
+10 -1
View File
@@ -3,6 +3,15 @@ import packageJson from "../package.json";
// import {TimeUtil} from "../electron/lib/util"; // ❌ 这是 Node.js 版本,会被打包到渲染进程
const BASE_URL = "";
const viteEnv = import.meta.env || {};
const processEnv = (typeof process !== "undefined" ? process.env : {}) as Record<string, string | undefined>;
const authServerUrl = viteEnv.VITE_AUTH_SERVER_URL || processEnv.AUTH_SERVER_URL || "http://127.0.0.1:3302";
function normalizeApiBaseUrl(url: string): string {
const trimmed = String(url || "").trim().replace(/\/+$/, "");
if (!trimmed) return "http://127.0.0.1:3302/api";
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`;
}
export const AppConfig = {
name: "天英超级IP智能体",
@@ -12,7 +21,7 @@ export const AppConfig = {
website: ``,
websiteGithub: "",
websiteGitee: "",
apiBaseUrl: `http://49.233.157.120:3002/api`,
apiBaseUrl: normalizeApiBaseUrl(authServerUrl),
updaterUrl: ``,
downloadUrl: ``,
feedbackUrl: ``,
+1 -1
View File
@@ -1,4 +1,4 @@
import systemTemplatesConfig from '../../electron/resources/extra/common/config/system-templates.json';
import systemTemplatesConfig from '../../packaging/resources/common/config/system-templates.json';
const rawSystemTemplates = Array.isArray(systemTemplatesConfig?.subtitleTemplates)
? systemTemplatesConfig.subtitleTemplates
+1 -1
View File
@@ -246,7 +246,7 @@ export class ZimuShengcheng {
// 鑾峰彇璧勬簮璺緞
const path = await import('path');
let resourcesPath = path.join(process.cwd(), 'electron/resources/extra');
let resourcesPath = path.join(process.cwd(), 'packaging/resources');
try {
// @ts-ignore

Some files were not shown because too many files have changed in this diff Show More