This commit is contained in:
949036910@qq.com
2026-05-30 18:44:16 +08:00
parent e10a66ccf2
commit 9c6650373f
162 changed files with 624 additions and 26788 deletions

View File

@@ -5,32 +5,20 @@ alwaysApply: true
# aiclient 项目约定
## 技术栈
## 仓库拆分
- **桌面壳**Tauri 2Rust单例、无边框、可拖动标题栏
- **前端**Vue 3 + Vite 6**纯 JavaScript**(不使用 TypeScript
- **UI**Tailwind CSS v4`@tailwindcss/vite`+ PrimeVue 4Aura 深色预设)
- **状态/路由**Pinia + Vue Router
| 仓库 | 职责 |
|------|------|
| **aiclient**(本仓库) | Tauri 2 桌面壳、Rust 业务、updater、打包 |
| **aiclient_ui**`../aiclient_ui` | Vue 3 前端Vite 开发/构建 |
## 目录结构
```
src/ # Vue 前端
main.js # 入口:插件与全局组件注册
App.vue # 布局:标题栏 + router-view
styles/main.css # Tailwind + 科技感主题令牌
router/ # 路由与守卫
stores/ # Piniaauth 等)
views/ # 页面
components/ # 可复用组件
src-tauri/ # Tauri Rust 与配置
```
`tauri.conf.json` 的 `frontendDist` 为占位目录 `frontend-stub`**发布构建不编译前端**;运行时加载服务器上的 HTML/CSS/JS见 `lib.rs` 中 `PROD_WEB_URL`)。前端开发/部署在 **aiclient_ui** 仓库。
## 常用命令
- `npm run dev` — 仅前端(端口 1420
- `npm run tauri dev` — 桌面开发
- `npm run build` / `npm run tauri:build` — 构建桌面端(仅生成 zip 便携包,不生成 MSI/NSIS 安装包;前端无 `tsc` 步骤)
- 在 **aiclient_ui**`npm run dev` — 仅 Web`npm run build:deploy` — 部署到服务器静态目录
- 在 **aiclient**`npm run dev` — `tauri dev`(调试时连本地 Vite
- 在 **aiclient**`npm run tauri:build` — 仅打包 Rust/updater不构建前端
## 后端 API 约定pythonbackend
@@ -38,55 +26,23 @@ src-tauri/ # Tauri Rust 与配置
### 统一响应 `ApiResponse<T>`
所有 JSON 响应使用同一信封(与 `app/schemas/common.py` 一致):
| 字段 | 类型 | 说明 |
|------|------|------|
| `ok` | `boolean` | `true` 表示业务成功,`false` 表示失败(如校验、鉴权、业务错误) |
| `message` | `string` | 提示文案,默认可为空;失败时展示给用户 |
| `data` | `T \| null` | 成功时的载荷;失败时通常为 `null` 或省略 |
| `ok` | `boolean` | 业务是否成功 |
| `message` | `string` | 用户可见提示 |
| `data` | `T \| null` | 成功载荷 |
前端处理模式:
```js
const body = await res.json();
if (!body.ok) {
// 用 body.message 提示用户
return { ok: false, message: body.message };
}
// 使用 body.data
```
- 业务失败(如用户名已存在)仍返回 **HTTP 200**,以 `ok: false` 区分,勿仅依赖状态码。
- 未认证访问受保护接口返回 **HTTP 401**FastAPI 标准错误体,非 `ApiResponse` 信封)。
业务失败仍返回 **HTTP 200** + `ok: false`。未认证返回 **401**。
### 认证
- 登录/注册成功后,`data` 为 `TokenResponse`
- `access_token`JWT前端持久化如 localStorage `aiclient_token`
- `token_type`:固定 `"bearer"`
- `user``{ id, username }`
- 需登录的请求请求头:`Authorization: Bearer <access_token>`
- 登出:`POST /api/v1/auth/logout`,带同上 Authorization成功 `ok: true``message`: `"已退出登录"`
- JWT`Authorization: Bearer <access_token>`
- 登出:`POST /api/v1/auth/logout`
### 认证相关端点
### 环境
| 方法 | 路径 | 请求体 | `data`(成功时) |
|------|------|--------|------------------|
| POST | `/api/v1/auth/register` | `{ username, password, confirmPassword }` | `TokenResponse` |
| POST | `/api/v1/auth/login` | `{ username, password }` | `TokenResponse` |
| POST | `/api/v1/auth/logout` | 无 | `null` |
| GET | `/api/v1/auth/me` | 无 | `{ id, username }` |
- 注册请求体字段名与前端表单一致:`confirmPassword`camelCase其余 API JSON 字段为 **snake_case**(如 `access_token`)。
- 密码规则与本地逻辑对齐:至少 6 位;用户名 trim 后非空。
### 前端对接注意
- Pinia `auth` store 的 `login` / `register` 可逐步改为调用上述 API保留 `{ ok, message }` 与视图层一致。
- 开发时后端需配置 CORS 包含 `http://localhost:1420`(见 pythonbackend `.env` 的 `CORS_ORIGINS`)。
- 开发时 Vite 将 `/api` 代理到 `http://127.0.0.1:8001`(见 `vite.config.js`API 基址默认 `/api/v1`,生产可设 `VITE_API_BASE_URL`。
- 前端 `VITE_*`aiclient_ui 的 `.env`
- Rust `AICLIENT_*`aiclient 根目录 `.env`
## 通用原则

View File

@@ -31,7 +31,7 @@ alwaysApply: false
## 权限(`capabilities/default.json`
- 拖动窗口需:`core:window:allow-start-dragging`
- 前端标题栏元素使用 `data-tauri-drag-region`(在 `AppTitlebar.vue`
- 前端标题栏元素使用 `data-tauri-drag-region`(在 aiclient_ui 的 `AppTitlebar.vue`
## Rust 约定
@@ -41,6 +41,6 @@ alwaysApply: false
## 开发注意
- Vite 固定端口 `1420`(见 `vite.config.js`),与 `tauri.conf.json` 的 `devUrl` 一致
前端在 **`../aiclient_ui`** 开发并 **`npm run build:deploy`** 部署到服务器;**`tauri build` 不编译前端**。发布版 WebView 加载 `http://81.71.163.140/images/newyaoyan/`(见 `lib.rs`)。
- 修改 capabilities 或插件后需重新 `tauri dev` / `tauri:build`
- 发布构建使用 `npm run tauri:build``bundle.targets` 为空,跳过 MSI/NSIS构建结束后由 `src-tauri/scripts/zip-portable.cjs` 生成 `target/release/bundle/zip/*.zip`

View File

@@ -1,6 +1,3 @@
# 开发默认走 Vite 代理 /api -> http://127.0.0.1:8001
VITE_API_BASE_URL=http://81.70.234.81:8001/api/v1
# Tauri Rust 请求后端基址(拉取 appConfig、脚本等
AICLIENT_API_BASE=http://81.70.234.81:8001

View File

@@ -1,13 +1,2 @@
# 开发默认走 Vite 代理 /api -> http://127.0.0.1:8001
VITE_API_BASE_URL=http://81.71.163.140:8001/api/v1
# Tauri Rust 请求后端基址(拉取 appConfig、脚本等
# Tauri Rust 请求后端基址
AICLIENT_API_BASE=http://81.71.163.140:8001
# ffmpeg抖音流水线提音频必需放入 src-tauri/binaries/ffmpeg.exe 或设置绝对路径
# AICLIENT_FFMPEG_PATH=C:/path/to/ffmpeg.exe
# 应用配置在 pythonbackend 的 MySQL 表 desktop_configsname/value
# 登录后自动同步Node 流水线通过环境变量 AICLIENT_CFG_<NAME> 读取。
# 常用键示例LLM_API_KEY、LLM_API_URL、LLM_MODEL_ID、ASR_MODE、DASHSCOPE_API_KEY、OSS_CONFIGJSON

6
.gitignore vendored
View File

@@ -11,9 +11,6 @@ lerna-debug.log*
*.otf
*.ttf
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
@@ -35,4 +32,5 @@ src-tauri/resources/pythonruntimebackup/
src-tauri/resources/eSpeak/
src-tauri/permissions/autogenerated/
root/yaoyan/yaoyan_admin/images/
# 前端构建产物在 aiclient_ui 仓库(本仓库不打包前端)
../aiclient_ui/dist

View File

@@ -1,7 +1,25 @@
# Tauri + Vanilla TS
# aiclient
This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript.
Tauri 2 桌面壳Rust+ 自动更新updater
## Recommended IDE Setup
前端代码已拆至同级目录 **[aiclient_ui](../aiclient_ui)**。
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
## 目录结构
```
src-tauri/ # Rust、Tauri 配置、资源与脚本
```
## 常用命令
```bash
npm install
npm run dev # tauri dev调试时连 ../aiclient_ui 的 Vite
npm run tauri:build # 仅打包桌面壳,不构建前端
```
发布前请在 **aiclient_ui** 执行 `npm run build:deploy`,将静态资源部署到服务器。
## 环境变量
`.env.example``AICLIENT_*` 为 Rust 侧配置)。前端 `VITE_*` 见 aiclient_ui 仓库。

View File

@@ -11,6 +11,12 @@
},
{
"path": "src-tauri/resources/resources-bundles/python-runtimebackup"
},
{
"path": "../aiclient_ui"
},
{
"path": "../npmrelease"
}
],
"settings": {}

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>aiclient</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2340
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,12 @@
{
"name": "aiclient",
"private": true,
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri",
"dev": "tauri dev",
"tauri:build": "node src-tauri/scripts/bump-version.cjs && tauri build && node src-tauri/scripts/zip-portable.cjs"
},
"dependencies": {
"@primeuix/themes": "^1.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"monaco-editor": "^0.55.1",
"monaco-editor-vue3": "^1.0.5",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.6",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^6.0.0",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"vite": "^6.0.3",
"vite-plugin-monaco-editor": "^1.1.0"
"@tauri-apps/cli": "^2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

File diff suppressed because it is too large Load Diff

37
src-tauri/Cargo.lock generated
View File

@@ -1145,7 +1145,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading 0.7.4",
"libloading 0.8.9",
]
[[package]]
@@ -1548,6 +1548,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -5030,6 +5040,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5091,7 +5112,7 @@ dependencies = [
[[package]]
name = "tauri-app"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"aes-gcm",
"async-trait",
@@ -5102,6 +5123,7 @@ dependencies = [
"eframe",
"egui",
"env_logger",
"flate2",
"futures-util",
"hex",
"hmac",
@@ -5118,6 +5140,7 @@ dependencies = [
"serde",
"serde_json",
"sha1",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -7254,6 +7277,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]]
name = "xcursor"
version = "0.3.10"

View File

@@ -1,6 +1,6 @@
[package]
name = "tauri-app"
version = "0.1.3"
version = "0.1.8"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -99,3 +99,5 @@ windows = { version = "0.58", features = [
] }
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow"] }
egui = "0.31"
flate2 = "1"
tar = "0.4"

View File

@@ -55,6 +55,8 @@ const APP_COMMANDS: &[&str] = &[
];
fn main() {
// 勿再用 winrestauri-build 已写入 VERSION 资源,重复会导致 LNK1123 / CVT1100。
// 本地版本比对优先读同目录 aiclient.version见 zip-portable.cjs、updater engine
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(APP_COMMANDS)),

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>aiclient</title>
</head>
<body>
<!-- 仅占位:运行时由 lib.rs 加载远程前端,不打包 aiclient_ui 资源 -->
</body>
</html>

View File

@@ -1,15 +1,20 @@
/**
* 构建前将版本号 patch 段 +1并同步写入 tauri.conf.json / Cargo.toml / package.json
* 构建前将版本号 patch 段 +1并同步写入 tauri.conf.json / Cargo.toml / aiclient_ui / npmrelease
*/
const fs = require("node:fs");
const path = require("node:path");
const tauriDir = path.join(__dirname, "..");
const rootDir = path.join(tauriDir, "..");
const tauriConfPath = path.join(tauriDir, "tauri.conf.json");
const cargoTomlPath = path.join(tauriDir, "Cargo.toml");
const packageJsonPath = path.join(rootDir, "package.json");
const uiPackageJsonPath = path.join(tauriDir, "..", "aiclient_ui", "package.json");
const npmReleasePackageJsonPath = path.join(
tauriDir,
"..",
"..",
"npmrelease",
"package.json",
);
function bumpPatch(version) {
const parts = String(version)
@@ -50,7 +55,14 @@ const conf = JSON.parse(fs.readFileSync(tauriConfPath, "utf8"));
const newVersion = bumpPatch(conf.version);
updateJsonVersion(tauriConfPath, newVersion);
updateJsonVersion(packageJsonPath, newVersion);
if (fs.existsSync(uiPackageJsonPath)) {
updateJsonVersion(uiPackageJsonPath, newVersion);
}
if (fs.existsSync(npmReleasePackageJsonPath)) {
updateJsonVersion(npmReleasePackageJsonPath, newVersion);
} else {
console.warn(`未找到 npmrelease/package.json: ${npmReleasePackageJsonPath}`);
}
updateCargoVersion(cargoTomlPath, newVersion);
console.log(`版本号已更新: ${conf.version} -> ${newVersion}`);

View File

@@ -32,6 +32,7 @@ async function notifyReleaseVersion(version) {
}
const tauriDir = path.join(__dirname, "..");
const npmReleaseDir = path.join(tauriDir, "..", "..", "npmrelease");
const conf = JSON.parse(
fs.readFileSync(path.join(tauriDir, "tauri.conf.json"), "utf8"),
);
@@ -70,6 +71,25 @@ const stagedExe =
process.platform === "win32" ? `${productName}.exe` : productName;
fs.copyFileSync(exePath, path.join(stageDir, stagedExe));
function writeVersionSidecar(dir, baseName, ver) {
const file = path.join(dir, `${baseName}.version`);
fs.writeFileSync(file, `${ver}\n`, "utf8");
return file;
}
writeVersionSidecar(stageDir, productName, version);
// 复制主程序到 npmrelease供 npm publish 打包
if (process.platform === "win32") {
fs.mkdirSync(npmReleaseDir, { recursive: true });
const npmReleaseExe = path.join(npmReleaseDir, stagedExe);
fs.copyFileSync(exePath, npmReleaseExe);
writeVersionSidecar(npmReleaseDir, productName, version);
console.log(`已复制主程序到 npmrelease: ${npmReleaseExe}`);
} else {
console.warn("非 Windows 平台,跳过复制到 npmrelease");
}
const updaterPath = path.join(releaseDir, "updater.exe");
if (process.platform === "win32" && fs.existsSync(updaterPath)) {
fs.copyFileSync(updaterPath, path.join(stageDir, "updater.exe"));

View File

@@ -9,12 +9,24 @@ use serde_json::Value;
pub const ENV_FROM_UPDATER: &str = "AICLIENT_FROM_UPDATER";
pub const MAIN_EXE: &str = "aiclient.exe";
pub const CHECK_UPDATE_URL: &str = "http://81.71.163.140:8001/api/checkupdate";
pub const DOWNLOAD_URL: &str = "http://81.71.163.140:80/images/mainfile/main.exe";
/// npm 包名(发布到 npmmirror 的桌面端包)
pub const NPM_PACKAGE_NAME: &str = "yaoyanaiclient";
const NPM_REGISTRY_BASE: &str = "https://registry.npmmirror.com";
const NPM_CDN_BASE: &str = "https://cdn.npmmirror.com/packages";
const NPM_FETCH_RETRIES: u32 = 3;
#[derive(Clone, Debug)]
pub enum UpdaterMessage {
Status(String),
/// 本地/远程版本(调试用)
VersionInfo {
local_display: String,
remote_display: String,
local_normalized: String,
remote_normalized: String,
needs_update: bool,
note: String,
},
/// 0.0 ~ 1.0;无总大小时为 None界面显示不确定进度
DownloadProgress { fraction: Option<f32>, detail: String },
Launching,
@@ -48,6 +60,25 @@ impl ProgressSink {
pub fn launching(&self) {
self.send(UpdaterMessage::Launching);
}
pub fn version_info(
&self,
local_display: impl Into<String>,
remote_display: impl Into<String>,
local_normalized: impl Into<String>,
remote_normalized: impl Into<String>,
needs_update: bool,
note: impl Into<String>,
) {
self.send(UpdaterMessage::VersionInfo {
local_display: local_display.into(),
remote_display: remote_display.into(),
local_normalized: local_normalized.into(),
remote_normalized: remote_normalized.into(),
needs_update,
note: note.into(),
});
}
}
fn exe_dir() -> Result<PathBuf, String> {
@@ -62,9 +93,18 @@ fn exe_dir() -> Result<PathBuf, String> {
fn normalize_version(raw: &str) -> String {
let trimmed = raw.trim().trim_start_matches(['v', 'V']);
let mut parts: Vec<u32> = trimmed
.split('.')
.filter_map(|p| p.parse().ok())
// Windows 资源里常见 "0, 1, 3, 0" 或 "0.1.3.0"
let unified = trimmed.replace(',', ".");
let mut parts: Vec<u32> = unified
.split(|c| c == '.' || c == '-' || c == '_')
.filter_map(|p| {
let p = p.trim();
if p.is_empty() {
None
} else {
p.parse().ok()
}
})
.collect();
if parts.is_empty() {
return trimmed.to_string();
@@ -79,127 +119,188 @@ fn normalize_version(raw: &str) -> String {
.join(".")
}
fn versions_match(local: &str, remote: &str) -> bool {
normalize_version(local) == normalize_version(remote)
fn npm_registry_url() -> String {
format!("{NPM_REGISTRY_BASE}/{NPM_PACKAGE_NAME}")
}
fn parse_remote_version(body: &str) -> Result<String, String> {
let trimmed = body.trim();
if trimmed.is_empty() {
return Err("更新接口返回空内容".to_string());
}
fn npm_tgz_download_url(version: &str) -> String {
format!(
"{NPM_CDN_BASE}/{NPM_PACKAGE_NAME}/{version}/{NPM_PACKAGE_NAME}-{version}.tgz"
)
}
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
let msg = value
.get("message")
fn parse_npm_latest_version(body: &str) -> Result<String, String> {
let value: Value = serde_json::from_str(body)
.map_err(|e| format!("解析 npm 元数据 JSON 失败: {e}"))?;
if let Some(latest) = value
.get("dist-tags")
.and_then(|t| t.get("latest"))
.and_then(|v| v.as_str())
.unwrap_or("检查更新失败");
return Err(msg.to_string());
}
for key in ["version", "latest_version", "latestVersion", "app_version"] {
if let Some(v) = value.get(key).and_then(|x| x.as_str()) {
if !v.trim().is_empty() {
return Ok(v.trim().to_string());
}
}
}
if let Some(data) = value.get("data") {
for key in ["version", "latest_version", "latestVersion", "app_version"] {
if let Some(v) = data.get(key).and_then(|x| x.as_str()) {
if !v.trim().is_empty() {
return Ok(v.trim().to_string());
{
let latest = latest.trim();
if !latest.is_empty() {
return Ok(latest.to_string());
}
}
// 兼容仅返回 dist-tags 片段或正则兜底
if let Ok(re) = regex::Regex::new(r#""latest"\s*:\s*"([^"]+)""#) {
if let Some(caps) = re.captures(body) {
if let Some(m) = caps.get(1) {
return Ok(m.as_str().to_string());
}
}
}
if trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
return Ok(trimmed.to_string());
}
Err(format!("无法解析更新接口返回的版本号: {trimmed}"))
Err(format!(
"无法从 npm 元数据提取 latest 版本(包名: {NPM_PACKAGE_NAME}"
))
}
async fn fetch_latest_version(client: &reqwest::Client) -> Result<String, String> {
async fn fetch_npm_registry_once(
client: &reqwest::Client,
registry_url: &str,
) -> Result<String, String> {
let response = client
.get(CHECK_UPDATE_URL)
.get(registry_url)
.send()
.await
.map_err(|e| format!("请求更新接口失败: {e}"))?;
.map_err(|e| format!("请求 npm 仓库失败: {e}"))?;
if !response.status().is_success() {
return Err(format!("更新接口 HTTP {}", response.status()));
return Err(format!("npm 仓库 HTTP {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("读取更新接口响应失败: {e}"))?;
parse_remote_version(&body)
.map_err(|e| format!("读取 npm 元数据失败: {e}"))?;
parse_npm_latest_version(&body)
}
#[cfg(windows)]
fn read_exe_version(path: &Path) -> Result<String, String> {
async fn fetch_latest_version(client: &reqwest::Client) -> Result<String, String> {
let registry_url = npm_registry_url();
let mut last_error = String::from("获取 npm 版本信息失败");
for attempt in 1..=NPM_FETCH_RETRIES {
match fetch_npm_registry_once(client, &registry_url).await {
Ok(version) => return Ok(version),
Err(e) => {
last_error = e;
if attempt < NPM_FETCH_RETRIES {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
}
Err(last_error)
}
/// 与 exe 同目录的 `aiclient.version`(构建时由 zip-portable 写入)
fn read_version_sidecar(exe_path: &Path) -> Option<String> {
let mut sidecar = exe_path.to_path_buf();
sidecar.set_extension("version");
if !sidecar.is_file() {
return None;
}
let text = std::fs::read_to_string(&sidecar).ok()?;
let v = text.trim();
if v.is_empty() {
None
} else {
Some(v.to_string())
}
}
fn write_version_sidecar(exe_path: &Path, version: &str) -> Result<(), String> {
let mut sidecar = exe_path.to_path_buf();
sidecar.set_extension("version");
std::fs::write(&sidecar, format!("{version}\n"))
.map_err(|e| format!("写入 {} 失败: {e}", sidecar.display()))?;
Ok(())
}
fn version_from_fixed_file_info(buffer: &[u8]) -> Option<String> {
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{
GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW,
};
use windows::Win32::Storage::FileSystem::{VerQueryValueW, VS_FIXEDFILEINFO};
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let root: Vec<u16> = "\\".encode_utf16().chain(std::iter::once(0)).collect();
let mut ptr: *mut c_void = std::ptr::null_mut();
let mut len: u32 = 0;
let size = unsafe { GetFileVersionInfoSizeW(PCWSTR(wide.as_ptr()), None) };
if size == 0 {
return Err(format!("无法读取版本信息: {}", path.display()));
}
let mut buffer = vec![0u8; size as usize];
unsafe {
GetFileVersionInfoW(
PCWSTR(wide.as_ptr()),
0,
size,
buffer.as_mut_ptr() as *mut c_void,
let ok = unsafe {
VerQueryValueW(
buffer.as_ptr() as *const c_void,
PCWSTR(root.as_ptr()),
&mut ptr,
&mut len,
)
.map_err(|e| format!("GetFileVersionInfoW 失败: {e}"))?;
};
if !ok.as_bool() || ptr.is_null() || len < std::mem::size_of::<VS_FIXEDFILEINFO>() as u32 {
return None;
}
let mut value_ptr: *mut c_void = std::ptr::null_mut();
let mut value_len: u32 = 0;
let info = unsafe { &*(ptr as *const VS_FIXEDFILEINFO) };
let major = (info.dwProductVersionMS >> 16) as u16;
let minor = (info.dwProductVersionMS & 0xFFFF) as u16;
let build = (info.dwProductVersionLS >> 16) as u16;
let patch = (info.dwProductVersionLS & 0xFFFF) as u16;
if major == 0 && minor == 0 && build == 0 && patch == 0 {
return None;
}
if patch == 0 {
Some(format!("{major}.{minor}.{build}"))
} else {
Some(format!("{major}.{minor}.{build}.{patch}"))
}
}
fn version_from_string_table(buffer: &[u8]) -> Option<String> {
use std::ffi::c_void;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::VerQueryValueW;
let sub_block: Vec<u16> = "\\"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
unsafe {
if !VerQueryValueW(
let mut value_ptr: *mut c_void = std::ptr::null_mut();
let mut value_len: u32 = 0;
if !unsafe {
VerQueryValueW(
buffer.as_ptr() as *const c_void,
PCWSTR(sub_block.as_ptr()),
&mut value_ptr,
&mut value_len,
)
}
.as_bool()
{
return Err("VerQueryValueW 失败".to_string());
}
return None;
}
if value_ptr.is_null() || value_len < 2 {
return Err("版本信息为空".to_string());
if value_ptr.is_null() || value_len < 4 {
return None;
}
let translate = unsafe {
std::slice::from_raw_parts(value_ptr as *const u16, (value_len / 2) as usize)
};
let lang = format!("{:04x}{:04x}", translate[0], translate[1]);
for i in (0..translate.len()).step_by(2) {
let Some((lang_id, code_page)) = translate.get(i).zip(translate.get(i + 1)) else {
break;
};
let lang = format!("{lang_id:04x}{code_page:04x}");
for key in ["ProductVersion", "FileVersion"] {
let query: Vec<u16> = format!("\\StringFileInfo\\{lang}\\{key}")
@@ -223,12 +324,66 @@ fn read_exe_version(path: &Path) -> Result<String, String> {
let end = slice.iter().position(|&c| c == 0).unwrap_or(slice.len());
let version = String::from_utf16_lossy(&slice[..end]);
if !version.trim().is_empty() {
return Ok(version.trim().to_string());
return Some(version.trim().to_string());
}
}
}
}
Err(format!("未在 {} 中找到版本字符串", path.display()))
None
}
/// 读取本地版本:优先 `aiclient.version`,其次 PE 资源。
fn read_local_version(exe_path: &Path) -> Result<(String, String), String> {
if let Some(v) = read_version_sidecar(exe_path) {
return Ok((v, "aiclient.version".to_string()));
}
let pe = read_exe_version(exe_path)?;
Ok((pe, "PE 资源".to_string()))
}
#[cfg(windows)]
fn read_exe_version(path: &Path) -> Result<String, String> {
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{GetFileVersionInfoSizeW, GetFileVersionInfoW};
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let size = unsafe { GetFileVersionInfoSizeW(PCWSTR(wide.as_ptr()), None) };
if size == 0 {
return Err(format!("exe 无版本资源块: {}", path.display()));
}
let mut buffer = vec![0u8; size as usize];
unsafe {
GetFileVersionInfoW(
PCWSTR(wide.as_ptr()),
0,
size,
buffer.as_mut_ptr() as *mut c_void,
)
.map_err(|e| format!("GetFileVersionInfoW 失败: {e}"))?;
}
if let Some(v) = version_from_string_table(&buffer) {
return Ok(v);
}
if let Some(v) = version_from_fixed_file_info(&buffer) {
return Ok(v);
}
Err(format!(
"未在 {} 中找到版本信息(无 StringFileInfo / VS_FIXEDFILEINFO",
path.display()
))
}
#[cfg(not(windows))]
@@ -249,25 +404,69 @@ fn format_bytes(n: u64) -> String {
}
}
#[cfg(windows)]
fn extract_package_from_tgz(tgz_path: &Path, dest_exe: &Path) -> Result<(), String> {
use std::fs::File;
use std::io::copy;
use flate2::read::GzDecoder;
use tar::Archive;
let file = File::open(tgz_path).map_err(|e| format!("打开 tgz 失败: {e}"))?;
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
let entries = archive
.entries()
.map_err(|e| format!("读取 tgz 条目失败: {e}"))?;
let exe_name = MAIN_EXE;
for entry in entries {
let mut entry = entry.map_err(|e| format!("读取 tgz 条目失败: {e}"))?;
let path = entry.path().map_err(|e| format!("解析 tgz 路径失败: {e}"))?;
let path_str = path.to_string_lossy().replace('\\', "/");
if path_str.ends_with(exe_name) {
let parent = dest_exe
.parent()
.ok_or_else(|| "无法获取目标目录".to_string())?;
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?;
let mut outfile =
File::create(dest_exe).map_err(|e| format!("创建 exe 失败: {e}"))?;
copy(&mut entry, &mut outfile).map_err(|e| format!("写入 exe 失败: {e}"))?;
return Ok(());
}
}
Err(format!("npm 包 tgz 中未找到 {exe_name}"))
}
async fn download_and_replace(
client: &reqwest::Client,
main_path: &Path,
remote_version: &str,
sink: &ProgressSink,
) -> Result<(), String> {
let download_url = npm_tgz_download_url(remote_version);
let response = client
.get(DOWNLOAD_URL)
.get(&download_url)
.send()
.await
.map_err(|e| format!("下载主程序失败: {e}"))?;
.map_err(|e| format!("下载 npm 包失败: {e}"))?;
if !response.status().is_success() {
return Err(format!("下载主程序 HTTP {}", response.status()));
return Err(format!(
"下载 npm 包 HTTP {}{}",
response.status(),
download_url
));
}
let total = response.content_length();
let temp_path = main_path.with_extension("exe.download");
let mut file = std::fs::File::create(&temp_path)
.map_err(|e| format!("创建临时文件失败: {e}"))?;
let tgz_path = main_path.with_extension("tgz.download");
let temp_exe = main_path.with_extension("exe.download");
let mut file = std::fs::File::create(&tgz_path)
.map_err(|e| format!("创建临时 tgz 失败: {e}"))?;
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
@@ -276,24 +475,20 @@ async fn download_and_replace(
let chunk = chunk.map_err(|e| format!("下载数据失败: {e}"))?;
use std::io::Write;
file.write_all(&chunk)
.map_err(|e| format!("写入临时文件失败: {e}"))?;
.map_err(|e| format!("写入临时 tgz 失败: {e}"))?;
downloaded += chunk.len() as u64;
let fraction = total.map(|t| {
if t == 0 {
None
} else {
Some((downloaded as f32 / t as f32).clamp(0.0, 1.0))
}
}).flatten();
let fraction = total
.filter(|&t| t > 0)
.map(|t| (downloaded as f32 / t as f32).clamp(0.0, 1.0));
let detail = match total {
Some(t) => format!(
"{} / {}",
"{} / {} (npm)",
format_bytes(downloaded),
format_bytes(t)
),
None => format_bytes(downloaded),
None => format!("{} (npm)", format_bytes(downloaded)),
};
sink.download_progress(fraction, detail);
}
@@ -301,21 +496,42 @@ async fn download_and_replace(
drop(file);
if downloaded == 0 {
let _ = std::fs::remove_file(&temp_path);
return Err("下载的主程序为空".to_string());
let _ = std::fs::remove_file(&tgz_path);
return Err("下载的 npm 包为空".to_string());
}
sink.download_progress(Some(1.0), "正在解压主程序…".to_string());
#[cfg(windows)]
{
extract_package_from_tgz(&tgz_path, &temp_exe)?;
}
#[cfg(not(windows))]
{
let _ = (tgz_path, temp_exe);
return Err("当前平台不支持从 npm tgz 解压".to_string());
}
let _ = std::fs::remove_file(&tgz_path);
sink.download_progress(Some(1.0), "正在替换主程序…".to_string());
if main_path.exists() {
std::fs::remove_file(main_path).map_err(|e| format!("删除旧主程序失败: {e}"))?;
}
std::fs::rename(&temp_path, main_path).map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
std::fs::rename(&temp_exe, main_path).map_err(|e| {
let _ = std::fs::remove_file(&temp_exe);
format!("替换主程序失败: {e}")
})?;
// 与远端版本对齐,确保下次启动 updater 能正确比对
write_version_sidecar(main_path, remote_version)?;
sink.download_progress(
Some(1.0),
format!("已更新 {remote_version} → aiclient.version"),
);
Ok(())
}
@@ -333,7 +549,7 @@ fn launch_main_app(main_path: &Path, dir: &Path) -> Result<(), String> {
}
pub async fn run_updater(sink: ProgressSink) -> Result<(), String> {
sink.status("检查更新");
sink.status("正在从 npm 检查更新…");
let dir = exe_dir()?;
let main_path = dir.join(MAIN_EXE);
@@ -344,19 +560,60 @@ pub async fn run_updater(sink: ProgressSink) -> Result<(), String> {
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
let remote_version = fetch_latest_version(&client).await?;
let needs_update = if main_path.is_file() {
match read_exe_version(&main_path) {
Ok(local_version) => !versions_match(&local_version, &remote_version),
Err(_) => true,
let remote_norm = normalize_version(&remote_version);
let npm_note = format!("npm 包 {NPM_PACKAGE_NAME} @ {NPM_REGISTRY_BASE}");
let (local_display, local_norm, needs_update, note) = if main_path.is_file() {
match read_local_version(&main_path) {
Ok((local_version, source)) => {
let local_norm = normalize_version(&local_version);
let matched = local_norm == remote_norm;
let note = if matched {
format!("版本一致(来源: {source}),跳过下载。{npm_note}")
} else {
format!(
"版本不一致(本地 {} vs npm {},来源: {source})。{npm_note}",
local_norm, remote_norm
)
};
(format!("{local_version} [{source}]"), local_norm, !matched, note)
}
Err(e) => {
let note = format!("读取本地版本失败: {e},将尝试下载。{npm_note}");
(
format!("(读取失败: {e})"),
String::new(),
true,
note,
)
}
}
} else {
true
(
"(文件不存在)".to_string(),
String::new(),
true,
format!("未找到 {}{npm_note}", main_path.display()),
)
};
sink.version_info(
&local_display,
&remote_version,
if local_norm.is_empty() {
""
} else {
&local_norm
},
&remote_norm,
needs_update,
&note,
);
if needs_update {
sink.status("下载中…");
sink.download_progress(None, "准备下载…".to_string());
download_and_replace(&client, &main_path, &sink).await?;
download_and_replace(&client, &main_path, &remote_version, &sink).await?;
} else {
sink.status("当前已是最新版本");
}

View File

@@ -7,8 +7,8 @@ use eframe::egui;
use crate::engine::{ProgressSink, UpdaterMessage};
const WINDOW_WIDTH: f32 = 420.0;
const WINDOW_HEIGHT: f32 = 160.0;
const WINDOW_WIDTH: f32 = 440.0;
const WINDOW_HEIGHT: f32 = 260.0;
pub fn run() -> ! {
let options = eframe::NativeOptions {
@@ -41,6 +41,12 @@ struct UpdaterApp {
detail: String,
progress: Option<f32>,
show_bar: bool,
local_version: String,
remote_version: String,
local_normalized: String,
remote_normalized: String,
needs_update: Option<bool>,
version_note: String,
error: Option<String>,
done: bool,
should_close: bool,
@@ -76,6 +82,12 @@ impl UpdaterApp {
detail: String::new(),
progress: None,
show_bar: false,
local_version: "".to_string(),
remote_version: "".to_string(),
local_normalized: "".to_string(),
remote_normalized: "".to_string(),
needs_update: None,
version_note: String::new(),
error: None,
done: false,
should_close: false,
@@ -91,6 +103,21 @@ impl UpdaterApp {
self.detail.clear();
}
}
UpdaterMessage::VersionInfo {
local_display,
remote_display,
local_normalized,
remote_normalized,
needs_update,
note,
} => {
self.local_version = local_display;
self.remote_version = remote_display;
self.local_normalized = local_normalized;
self.remote_normalized = remote_normalized;
self.needs_update = Some(needs_update);
self.version_note = note;
}
UpdaterMessage::DownloadProgress { fraction, detail } => {
self.show_bar = true;
self.status = "下载中…".to_string();
@@ -129,12 +156,59 @@ impl eframe::App for UpdaterApp {
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.add_space(12.0);
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.heading(&self.status);
});
ui.add_space(16.0);
ui.add_space(10.0);
egui::Frame::group(ui.style())
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.label(egui::RichText::new("版本信息(调试)").strong().size(13.0));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("本地版本:");
ui.monospace(&self.local_version);
});
ui.horizontal(|ui| {
ui.label("远程版本:");
ui.monospace(&self.remote_version);
});
ui.horizontal(|ui| {
ui.label("npm 包:");
ui.monospace(crate::engine::NPM_PACKAGE_NAME);
});
ui.horizontal(|ui| {
ui.label("归一化本地:");
ui.monospace(&self.local_normalized);
});
ui.horizontal(|ui| {
ui.label("归一化远程:");
ui.monospace(&self.remote_normalized);
});
if let Some(needs) = self.needs_update {
let (text, color) = if needs {
("需要下载", egui::Color32::from_rgb(230, 160, 60))
} else {
("无需下载", egui::Color32::from_rgb(80, 180, 120))
};
ui.horizontal(|ui| {
ui.label("比对结果:");
ui.colored_label(color, text);
});
}
if !self.version_note.is_empty() {
ui.add_space(4.0);
ui.label(
egui::RichText::new(&self.version_note)
.size(12.0)
.color(ui.visuals().weak_text_color()),
);
}
});
ui.add_space(10.0);
if self.show_bar {
let mut bar = egui::ProgressBar::new(self.progress.unwrap_or(0.0))

View File

@@ -32,6 +32,20 @@ pub mod publish_store;
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
/// 开发:本地 Vite发布服务器静态资源与 aiclient_ui `build:deploy` 输出目录一致)
const DEV_WEB_URL: &str = "http://localhost:1420/";
const PROD_WEB_URL: &str = "http://81.71.163.140/images/newyaoyan/index.html";
fn webview_url() -> WebviewUrl {
let url = if cfg!(debug_assertions) {
DEV_WEB_URL
} else {
PROD_WEB_URL
};
WebviewUrl::External(url.parse().expect("webview url"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let _ = env_logger::try_init();
@@ -81,25 +95,8 @@ pub fn run() {
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
api_client::spawn_heartbeat_loop(heartbeat_handle);
let url = if cfg!(debug_assertions) {
WebviewUrl::External(
"http://localhost:1420/"
.parse()
.unwrap()
)
} else {
WebviewUrl::External(
"http://81.71.163.140/images/newyaoyan/index.html"
.parse()
.unwrap()
)
};
WebviewWindowBuilder::new(
app,
"main",
url,
)
WebviewWindowBuilder::new(app, "main", webview_url())
.title("MyApp")
.build()?;

View File

@@ -1,13 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "aiclient",
"version": "0.1.3",
"version": "0.1.8",
"identifier": "com.aiclient.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"beforeDevCommand": "npm run dev --prefix ../aiclient_ui",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
"frontendDist": "frontend-stub"
},
"app": {
"withGlobalTauri": true,

View File

@@ -1,12 +0,0 @@
<script setup>
import AppTitlebar from "./components/AppTitlebar.vue";
</script>
<template>
<div class="flex h-full flex-col">
<AppTitlebar />
<main class="min-h-0 flex-1 overflow-hidden">
<RouterView />
</main>
</div>
</template>

View File

@@ -1,113 +0,0 @@
import { apiRequest } from "./client.js";
/** 品牌静态资源基址(与 pythonbackend 根目录 images 挂载的 /images 一致) */
const MEDIA_BASE =
import.meta.env.VITE_MEDIA_BASE_URL || "http://127.0.0.1:8001";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function adminRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
function emptyBrandingForm() {
return {
softwareName: "",
logo: "",
contactWechat: "",
contactQq: "",
wechatQr: "",
qqQr: "",
};
}
/**
* 库内路径 images/xxx → 可访问 URL预览用
* @param {string | null | undefined} path
*/
export function brandingImageUrl(path) {
if (!path?.trim()) return "";
const value = path.trim();
if (value.startsWith("data:image/") || value.startsWith("http")) return value;
const normalized = value.replace(/\\/g, "/").replace(/^\//, "");
if (normalized.startsWith("images/")) {
return `${MEDIA_BASE}/${normalized}`;
}
return `${MEDIA_BASE}/images/${normalized}`;
}
/** 提交前:新上传保留 data URL已有文件只提交 images/ 相对路径 */
function toStoredImageValue(value) {
if (!value?.trim()) return null;
const v = value.trim();
if (v.startsWith("data:image/")) return v;
const match = v.match(/\/images\/(.+?)(?:\?.*)?$/);
if (match) return `images/${match[1]}`;
if (v.startsWith("images/")) return v;
return v;
}
/** API 载荷 → 表单 */
function toForm(data) {
if (!data) return emptyBrandingForm();
return {
softwareName: data.software_name ?? "",
logo: brandingImageUrl(data.logo_path),
contactWechat: data.wechat ?? "",
contactQq: data.qq ?? "",
wechatQr: brandingImageUrl(data.wechat_qrcode),
qqQr: brandingImageUrl(data.qq_qrcode),
};
}
/** 表单 → API 载荷 */
function toPayload(form) {
return {
software_name: String(form.softwareName ?? "").trim(),
logo_path: toStoredImageValue(form.logo),
wechat: form.contactWechat?.trim() || null,
qq: form.contactQq?.trim() || null,
wechat_qrcode: toStoredImageValue(form.wechatQr),
qq_qrcode: toStoredImageValue(form.qqQr),
};
}
/**
* 加载默认 OEMid=1软件配置
* @param {string} token
*/
export async function loadAdminAppBrandingApi(token) {
const res = await adminRequest(token, "/admin/oem-branding");
if (!res.ok) {
return { ok: false, message: res.message || "加载软件配置失败" };
}
return { ok: true, data: { form: toForm(res.data), oemId: res.data?.id ?? 1 } };
}
/**
* 保存默认 OEMid=1软件配置
* @param {string} token
* @param {ReturnType<typeof emptyBrandingForm>} form
*/
export async function saveAdminAppBrandingApi(token, form) {
const res = await adminRequest(token, "/admin/oem-branding", {
method: "PATCH",
body: JSON.stringify(toPayload(form)),
});
if (!res.ok) {
return { ok: false, message: res.message || "保存软件配置失败" };
}
return {
ok: true,
message: res.message || "软件配置已保存",
data: { form: toForm(res.data), oemId: res.data?.id ?? 1 },
};
}

View File

@@ -1,72 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function adminRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{
* page?: number,
* pageSize?: number,
* status?: string,
* username?: string,
* oemId?: number | null,
* agentId?: number | null,
* }} [params]
*/
export function listAdminCardKeysApi(
token,
{ page = 1, pageSize = 20, status = "all", username, oemId, agentId } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
status,
});
const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed);
if (oemId != null) query.set("oem_id", String(oemId));
if (agentId != null) query.set("agent_id", String(agentId));
return adminRequest(token, `/admin/card-keys?${query}`);
}
/**
* @param {string} token
* @param {object} payload
*/
export function createAdminCardKeysApi(token, payload) {
return adminRequest(token, "/admin/card-keys", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} cardId
* @param {object} payload
*/
export function updateAdminCardKeyApi(token, cardId, payload) {
return adminRequest(token, `/admin/card-keys/${cardId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} cardId */
export function deleteAdminCardKeyApi(token, cardId) {
return adminRequest(token, `/admin/card-keys/${cardId}`, {
method: "DELETE",
});
}

View File

@@ -1,62 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function adminRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{ page?: number, pageSize?: number, keyword?: string }} [params]
*/
export function listAdminDesktopConfigsApi(
token,
{ page = 1, pageSize = 20, keyword = "" } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
const kw = keyword?.trim();
if (kw) query.set("keyword", kw);
return adminRequest(token, `/admin/desktop-configs?${query}`);
}
/**
* @param {string} token
* @param {object} payload
*/
export function createAdminDesktopConfigApi(token, payload) {
return adminRequest(token, "/admin/desktop-configs", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} configId
* @param {object} payload
*/
export function updateAdminDesktopConfigApi(token, configId, payload) {
return adminRequest(token, `/admin/desktop-configs/${configId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} configId */
export function deleteAdminDesktopConfigApi(token, configId) {
return adminRequest(token, `/admin/desktop-configs/${configId}`, {
method: "DELETE",
});
}

View File

@@ -1,82 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function adminRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{
* page?: number,
* pageSize?: number,
* username?: string,
* deviceSerial?: string,
* oemId?: number | null,
* agentId?: number | null,
* roleId?: number | null,
* }} [params]
*/
export function listAdminUsersApi(
token,
{ page = 1, pageSize = 20, username, deviceSerial, oemId, agentId, roleId } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed);
const serial = deviceSerial?.trim();
if (serial) query.set("device_serial", serial);
if (oemId != null) query.set("oem_id", String(oemId));
if (agentId != null) query.set("agent_id", String(agentId));
if (roleId != null) query.set("role_id", String(roleId));
return adminRequest(token, `/admin/users?${query}`);
}
/**
* @param {string} token
* @param {object} payload
*/
export function createAdminUserApi(token, payload) {
return adminRequest(token, "/admin/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} userId
* @param {object} payload
*/
export function updateAdminUserApi(token, userId, payload) {
return adminRequest(token, `/admin/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} userId */
export function deleteAdminUserApi(token, userId) {
return adminRequest(token, `/admin/users/${userId}`, {
method: "DELETE",
});
}
/** 管理员解绑用户设备(清空 device_serial */
export function clearAdminUserDeviceSerialApi(token, userId) {
return adminRequest(token, `/admin/users/${userId}/clear-device-serial`, {
method: "POST",
});
}

View File

@@ -1,50 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function agentRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{
* page?: number,
* pageSize?: number,
* status?: string,
* username?: string,
* }} [params]
*/
export function listAgentCardKeysApi(
token,
{ page = 1, pageSize = 20, status = "all", username } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
status,
});
const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed);
return agentRequest(token, `/agent/card-keys?${query}`);
}
/**
* @param {string} token
* @param {number} cardId
* @param {{ agent_remark?: string | null }} payload
*/
export function updateAgentCardKeyApi(token, cardId, payload) {
return agentRequest(token, `/agent/card-keys/${cardId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}

View File

@@ -1,38 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function agentRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{
* page?: number,
* pageSize?: number,
* username?: string,
* roleId?: number | null,
* }} [params]
*/
export function listAgentUsersApi(
token,
{ page = 1, pageSize = 20, username, roleId } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed);
if (roleId != null) query.set("role_id", String(roleId));
return agentRequest(token, `/agent/users?${query}`);
}

View File

@@ -1,47 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {{ username: string, password: string, confirmPassword: string }} payload
*/
export function registerApi(payload) {
return apiRequest("/auth/register", {
method: "POST",
body: JSON.stringify({
username: payload.username,
password: payload.password,
confirmPassword: payload.confirmPassword,
}),
});
}
/**
* @param {{ username: string, password: string }} payload
*/
export function loginApi(payload) {
return apiRequest("/auth/login", {
method: "POST",
body: JSON.stringify({
username: payload.username,
password: payload.password,
}),
});
}
/** @param {string} token */
export function fetchMeApi(token) {
return apiRequest("/auth/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
/** @param {string} token */
export function logoutApi(token) {
return apiRequest("/auth/logout", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
}

View File

@@ -1,14 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {{ username: string, serialNumber: string }} payload
*/
export function activateCardKeyApi({ username, serialNumber }) {
return apiRequest("/card-keys/activate", {
method: "POST",
body: JSON.stringify({
username: username.trim(),
serial_number: serialNumber.trim().toUpperCase(),
}),
});
}

View File

@@ -1,59 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
/** API 路径前缀(实际 HTTP 由 Rust `api_request` 发出) */
const API_BASE = import.meta.env.VITE_API_BASE_URL || "/api/v1";
function isTauri() {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/**
* @param {string} path - 相对 `/api/v1` 的路径,如 `/auth/login`
* @param {RequestInit} options
* @returns {Promise<{ ok: boolean, message: string, data?: unknown }>}
*/
export async function apiRequest(path, options = {}) {
if (!isTauri()) {
return {
ok: false,
message: "API 请求需在 Tauri 桌面端运行(由 Rust 代理并加解密)",
};
}
const method = (options.method || "GET").toUpperCase();
let body;
if (options.body != null && options.body !== "") {
body = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
}
const headers = {};
if (options.headers) {
const h = options.headers;
if (h instanceof Headers) {
h.forEach((value, key) => {
headers[key] = value;
});
} else {
Object.assign(headers, h);
}
}
const relPath = path.startsWith("/") ? path : `/${path}`;
try {
const result = await invoke("api_request", {
path: relPath,
method,
body: body ?? null,
headers: Object.keys(headers).length ? headers : null,
});
if (result && typeof result === "object" && "ok" in result) {
return result;
}
return { ok: false, message: "服务器返回格式异常" };
} catch (err) {
return { ok: false, message: String(err) };
}
}
export { API_BASE };

View File

@@ -1,113 +0,0 @@
import { apiRequest } from "./client.js";
/** 品牌静态资源基址(与 pythonbackend 根目录 images 挂载的 /images 一致) */
const MEDIA_BASE =
import.meta.env.VITE_MEDIA_BASE_URL || "http://127.0.0.1:8001";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function oemRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
function emptyBrandingForm() {
return {
softwareName: "",
logo: "",
contactWechat: "",
contactQq: "",
wechatQr: "",
qqQr: "",
};
}
/**
* 库内路径 images/xxx → 可访问 URL预览用
* @param {string | null | undefined} path
*/
export function brandingImageUrl(path) {
if (!path?.trim()) return "";
const value = path.trim();
if (value.startsWith("data:image/") || value.startsWith("http")) return value;
const normalized = value.replace(/\\/g, "/").replace(/^\//, "");
if (normalized.startsWith("images/")) {
return `${MEDIA_BASE}/${normalized}`;
}
return `${MEDIA_BASE}/images/${normalized}`;
}
/** 提交前:新上传保留 data URL已有文件只提交 images/ 相对路径 */
function toStoredImageValue(value) {
if (!value?.trim()) return null;
const v = value.trim();
if (v.startsWith("data:image/")) return v;
const match = v.match(/\/images\/(.+?)(?:\?.*)?$/);
if (match) return `images/${match[1]}`;
if (v.startsWith("images/")) return v;
return v;
}
/** API 载荷 → 表单 */
function toForm(data) {
if (!data) return emptyBrandingForm();
return {
softwareName: data.software_name ?? "",
logo: brandingImageUrl(data.logo_path),
contactWechat: data.wechat ?? "",
contactQq: data.qq ?? "",
wechatQr: brandingImageUrl(data.wechat_qrcode),
qqQr: brandingImageUrl(data.qq_qrcode),
};
}
/** 表单 → API 载荷 */
function toPayload(form) {
return {
software_name: String(form.softwareName ?? "").trim(),
logo_path: toStoredImageValue(form.logo),
wechat: form.contactWechat?.trim() || null,
qq: form.contactQq?.trim() || null,
wechat_qrcode: toStoredImageValue(form.wechatQr),
qq_qrcode: toStoredImageValue(form.qqQr),
};
}
/**
* 加载默认 OEMid=1软件配置
* @param {string} token
*/
export async function loadOemAppBrandingApi(token) {
const res = await oemRequest(token, "/oem/oem-branding");
if (!res.ok) {
return { ok: false, message: res.message || "加载软件配置失败" };
}
return { ok: true, data: { form: toForm(res.data), oemId: res.data?.id ?? 1 } };
}
/**
* 保存默认 OEMid=1软件配置
* @param {string} token
* @param {ReturnType<typeof emptyBrandingForm>} form
*/
export async function saveOemAppBrandingApi(token, form) {
const res = await oemRequest(token, "/oem/oem-branding", {
method: "PATCH",
body: JSON.stringify(toPayload(form)),
});
if (!res.ok) {
return { ok: false, message: res.message || "保存软件配置失败" };
}
return {
ok: true,
message: res.message || "软件配置已保存",
data: { form: toForm(res.data), oemId: res.data?.id ?? 1 },
};
}

View File

@@ -1,63 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function oemRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{
* page?: number,
* pageSize?: number,
* status?: string,
* username?: string,
* agentId?: number | null,
* }} [params]
*/
export function listOemCardKeysApi(
token,
{ page = 1, pageSize = 20, status = "all", username, agentId } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
status,
});
const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed);
if (agentId != null) query.set("agent_id", String(agentId));
return oemRequest(token, `/oem/card-keys?${query}`);
}
/**
* @param {string} token
* @param {number} cardId
* @param {{ oem_remark?: string | null }} payload
*/
export function updateOemCardKeyApi(token, cardId, payload) {
return oemRequest(token, `/oem/card-keys/${cardId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {{ card_ids: number[], agent_id: number }} payload
*/
export function assignOemCardKeysToAgentApi(token, payload) {
return oemRequest(token, "/oem/card-keys/assign-agent", {
method: "POST",
body: JSON.stringify(payload),
});
}

View File

@@ -1,62 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function oemRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{ page?: number, pageSize?: number, keyword?: string }} [params]
*/
export function listOemDesktopConfigsApi(
token,
{ page = 1, pageSize = 20, keyword = "" } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
const kw = keyword?.trim();
if (kw) query.set("keyword", kw);
return oemRequest(token, `/oem/desktop-configs?${query}`);
}
/**
* @param {string} token
* @param {object} payload
*/
export function createOemDesktopConfigApi(token, payload) {
return oemRequest(token, "/oem/desktop-configs", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} configId
* @param {object} payload
*/
export function updateOemDesktopConfigApi(token, configId, payload) {
return oemRequest(token, `/oem/desktop-configs/${configId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} configId */
export function deleteOemDesktopConfigApi(token, configId) {
return oemRequest(token, `/oem/desktop-configs/${configId}`, {
method: "DELETE",
});
}

View File

@@ -1,35 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
/**
* @returns {Promise<{
* oemId: number,
* softwareName: string,
* logoUrl: string | null,
* wechat: string | null,
* wechatQrcode: string | null,
* }>}
*/
export async function fetchSoftwareInfo() {
const empty = {
oemId: 1,
softwareName: "",
logoUrl: null,
wechat: null,
wechatQrcode: null,
};
try {
const data = await invoke("get_software_info");
if (!data || typeof data !== "object") {
return empty;
}
return {
oemId: data.oemId ?? 1,
softwareName: data.softwareName ?? "",
logoUrl: data.logoUrl || null,
wechat: data.wechat || null,
wechatQrcode: data.wechatQrcodeUrl || null,
};
} catch {
return empty;
}
}

View File

@@ -1,72 +0,0 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function oemRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{
* page?: number,
* pageSize?: number,
* username?: string,
* oemId?: number | null,
* agentId?: number | null,
* roleId?: number | null,
* }} [params]
*/
export function listOemUsersApi(
token,
{ page = 1, pageSize = 20, username, oemId, agentId, roleId } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed);
if (oemId != null) query.set("oem_id", String(oemId));
if (agentId != null) query.set("agent_id", String(agentId));
if (roleId != null) query.set("role_id", String(roleId));
return oemRequest(token, `/oem/users?${query}`);
}
/**
* @param {string} token
* @param {object} payload
*/
export function createOemUserApi(token, payload) {
return oemRequest(token, "/oem/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} userId
* @param {object} payload
*/
export function updateOemUserApi(token, userId, payload) {
return oemRequest(token, `/oem/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} userId */
export function deleteOemUserApi(token, userId) {
return oemRequest(token, `/oem/users/${userId}`, {
method: "DELETE",
});
}

View File

@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,122 +0,0 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useAuthStore } from "../stores/auth";
const route = useRoute();
const auth = useAuthStore();
const baseMenuItems = [
{ name: "home", label: "首页", to: "/", icon: "home" },
{ name: "avatar", label: "形象", to: "/avatar", icon: "avatar" },
{ name: "material", label: "素材", to: "/material", icon: "material" },
{ name: "voice", label: "声音", to: "/voice", icon: "voice" },
// { name: "compose", label: "合成", to: "/compose", icon: "compose" },
// { name: "extract", label: "提取", to: "/extract", icon: "extract" },
// { name: "design", label: "设计", to: "/design", icon: "design" },
// { name: "model", label: "模型", to: "/model", icon: "model" },
{ name: "local-config", label: "本地配置", to: "/local-config", icon: "settings" },
{ name: "card-activate", label: "卡密激活", to: "/card-activate", icon: "card-activate" },
// { name: "help", label: "帮助", to: "/help", icon: "help" },
];
const menuItems = computed(() => {
const items = [...baseMenuItems];
if (auth.isAdmin) {
items.push({ name: "admin", label: "管理", to: "/admin", icon: "admin" });
}
if ( auth.isOEM) {
items.push({ name: "admin", label: "管理", to: "/oem", icon: "admin" });
}
if (auth.isAgent) {
items.push({ name: "agent", label: "代理", to: "/agent", icon: "agent" });
}
if (import.meta.env.DEV) {
items.push({ name: "debug", label: "调试", to: "/debug", icon: "debug" });
}
return items;
});
const activeName = computed(() => {
if (route.path.startsWith("/admin")) return "admin";
if (route.path.startsWith("/agent")) return "agent";
return route.name;
});
</script>
<template>
<nav class="sidebar-nav" aria-label="主导航">
<RouterLink
v-for="item in menuItems"
:key="item.name"
:to="item.to"
class="sidebar-item"
:class="{ 'sidebar-item--active': activeName === item.name }"
>
<span class="sidebar-icon" aria-hidden="true">
<svg v-if="item.icon === 'home'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1v-9.5z" stroke-linejoin="round" />
</svg>
<svg v-else-if="item.icon === 'avatar'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="4" y="4" width="16" height="16" rx="3" />
<path d="M10 9.5v5l4-2.5-4-2.5z" fill="currentColor" stroke="none" />
</svg>
<svg v-else-if="item.icon === 'material'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="8.5" cy="10" r="1.5" fill="currentColor" stroke="none" />
<path d="M3 16l5-4 4 3 5-5 4 4" />
</svg>
<svg v-else-if="item.icon === 'voice'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 4a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V7a3 3 0 0 1 3-3z" />
<path d="M6 11a6 6 0 0 0 12 0M12 17v3" />
</svg>
<svg v-else-if="item.icon === 'compose'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="6" width="18" height="12" rx="2" />
<path d="M7 6V4h10v2M10 12l2 1.5 2-1.5v-3l-2-1.5-2 1.5v3z" fill="currentColor" stroke="none" opacity="0.9" />
</svg>
<svg v-else-if="item.icon === 'extract'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="6" r="2.5" />
<circle cx="18" cy="18" r="2.5" />
<path d="M8 8l8 8M16 6l2 2M6 16l2 2" />
</svg>
<svg v-else-if="item.icon === 'design'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 4l6 6-10 10H4v-6L14 4z" stroke-linejoin="round" />
</svg>
<svg v-else-if="item.icon === 'model'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 3l7 4v10l-7 4-7-4V7l7-4z" />
<path d="M12 3v18M5 7l7 4 7-4" />
</svg>
<svg v-else-if="item.icon === 'settings'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="3" />
<path
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
stroke-linecap="round"
/>
</svg>
<svg v-else-if="item.icon === 'card-activate'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="9" cy="12" r="2.5" />
<path d="M11.5 12h5M15 12v2M17 12v1.5" stroke-linecap="round" />
</svg>
<svg v-else-if="item.icon === 'help'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.7-1.2 1.2-1.2 2.2M12 17h.01" stroke-linecap="round" />
</svg>
<svg v-else-if="item.icon === 'admin'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 3l8 4v6c0 5-3.5 8-8 8s-8-3-8-8V7l8-4z" stroke-linejoin="round" />
<path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else-if="item.icon === 'agent'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="8" r="3" />
<path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6" stroke-linecap="round" />
</svg>
<svg v-else-if="item.icon === 'debug'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 6v2M12 16v2M6 12h2M16 12h2" stroke-linecap="round" />
<circle cx="12" cy="12" r="4" />
<path d="M7 7l1.5 1.5M15.5 15.5L17 17M17 7l-1.5 1.5M8.5 15.5L7 17" stroke-linecap="round" />
</svg>
</span>
<span class="sidebar-label">{{ item.label }}</span>
</RouterLink>
</nav>
</template>

View File

@@ -1,256 +0,0 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useRouter } from "vue-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useAuthStore } from "../stores/auth.js";
import { fetchSoftwareInfo } from "../api/oemSoftwareInfo.js";
import { applyWindowBranding } from "../services/windowBranding.js";
const router = useRouter();
const auth = useAuthStore();
const software = ref({
softwareName: "",
logoUrl: null,
wechat: null,
wechatQrcode: null,
});
const loading = ref(true);
const isMaximized = ref(false);
const wechatQrVisible = ref(false);
const loggingOut = ref(false);
let unlistenResize = null;
const displayUsername = computed(
() => auth.currentUser?.username?.trim() || "—",
);
const vipEndLabel = computed(() => formatVipEnd(auth.currentUser?.vip_end_time));
function isTauri() {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
function formatVipEnd(value) {
if (!value) return "VIP未开通";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "VIP—";
const text = d.toLocaleString("zh-CN", { hour12: false });
return `VIP 至 ${text}`;
}
async function syncMaximizedState() {
if (!isTauri()) return;
try {
isMaximized.value = await getCurrentWindow().isMaximized();
} catch {
/* ignore */
}
}
async function onMinimize() {
if (!isTauri()) return;
try {
await getCurrentWindow().minimize();
} catch {
/* ignore */
}
}
function openWechatQr() {
if (!software.value.wechatQrcode) return;
wechatQrVisible.value = true;
}
async function onToggleMaximize() {
if (!isTauri()) return;
try {
const win = getCurrentWindow();
if (await win.isMaximized()) {
await win.unmaximize();
} else {
await win.maximize();
}
await syncMaximizedState();
} catch {
/* ignore */
}
}
async function onClose() {
if (!isTauri()) return;
try {
await getCurrentWindow().close();
} catch {
/* ignore */
}
}
async function onLogout() {
if (loggingOut.value) return;
loggingOut.value = true;
await auth.logout();
loggingOut.value = false;
await router.push({ name: "login" });
}
onMounted(async () => {
loading.value = true;
software.value = await fetchSoftwareInfo();
loading.value = false;
await applyWindowBranding(software.value);
if (auth.isAuthenticated) {
await auth.refreshProfile();
}
await syncMaximizedState();
if (isTauri()) {
try {
unlistenResize = await getCurrentWindow().onResized(() => {
syncMaximizedState();
});
} catch {
/* ignore */
}
}
});
onUnmounted(() => {
if (typeof unlistenResize === "function") {
unlistenResize();
}
});
</script>
<template>
<header class="app-titlebar flex h-12 shrink-0 items-stretch border-b border-cyan-500/10 bg-bg-base/80 backdrop-blur-sm select-none">
<div
class="app-titlebar__main flex min-w-0 flex-1 items-center gap-2 px-3"
data-tauri-drag-region
>
<template v-if="!loading">
<div
v-if="software.logoUrl"
class="app-titlebar__logo-wrap"
data-tauri-drag-region
>
<img
:src="software.logoUrl"
alt="软件 logo"
class="app-titlebar__logo"
data-tauri-drag-region
/>
</div>
<span
v-if="software.softwareName"
class="app-titlebar__name truncate text-xs font-medium text-slate-200"
data-tauri-drag-region
>
{{ software.softwareName }}
</span>
<span
v-else
class="font-mono text-xs tracking-widest text-text-muted uppercase"
data-tauri-drag-region
>
aiclient
</span>
<button
v-if="software.wechat"
type="button"
class="app-titlebar__wechat shrink-0 truncate text-xs text-text-muted"
:class="{ 'app-titlebar__wechat--clickable': software.wechatQrcode }"
:title="software.wechatQrcode ? '点击查看微信二维码' : undefined"
@click.stop="openWechatQr"
>
客服微信{{ software.wechat }}
</button>
</template>
<span
v-else
class="font-mono text-xs tracking-widest text-text-muted uppercase"
data-tauri-drag-region
>
aiclient
</span>
<span class="min-w-2 flex-1" data-tauri-drag-region />
</div>
<div
v-if="auth.isAuthenticated"
class="app-titlebar__user flex shrink-0 items-center gap-2 border-l border-cyan-500/10 px-3"
>
<span class="app-titlebar__username truncate text-xs text-slate-200">
{{ displayUsername }}
</span>
<span class="app-titlebar__vip shrink-0 text-xs text-text-muted">
{{ vipEndLabel }}
</span>
<Button
label="退出"
size="small"
severity="secondary"
text
class="app-titlebar__logout"
:loading="loggingOut"
@click="onLogout"
/>
</div>
<div class="app-titlebar__controls flex shrink-0 items-stretch">
<button
type="button"
class="app-titlebar__btn"
title="最小化"
aria-label="最小化"
@click="onMinimize"
>
<i class="pi pi-minus text-xs" />
</button>
<button
type="button"
class="app-titlebar__btn"
:title="isMaximized ? '还原' : '最大化'"
:aria-label="isMaximized ? '还原' : '最大化'"
@click="onToggleMaximize"
>
<i
class="pi text-xs"
:class="isMaximized ? 'pi-window-minimize' : 'pi-window-maximize'"
/>
</button>
<button
type="button"
class="app-titlebar__btn app-titlebar__btn--close"
title="关闭"
aria-label="关闭"
@click="onClose"
>
<i class="pi pi-times text-xs" />
</button>
</div>
</header>
<Dialog
v-model:visible="wechatQrVisible"
header="客服微信"
modal
:style="{ width: '18rem' }"
:draggable="false"
>
<div class="flex flex-col items-center gap-3">
<img
v-if="software.wechatQrcode"
:src="software.wechatQrcode"
alt="微信二维码"
class="max-h-56 w-full object-contain"
/>
<p v-if="software.wechat" class="text-center text-sm text-slate-300">
{{ software.wechat }}
</p>
</div>
</Dialog>
</template>

View File

@@ -1,24 +0,0 @@
<script setup>
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: "" },
});
</script>
<template>
<div class="auth-bg flex h-full items-center justify-center overflow-auto p-6">
<div class="relative z-10 w-full max-w-md">
<div class="mb-8 text-center">
<h1 class="gradient-text text-3xl font-bold tracking-tight"></h1>
<p v-if="subtitle" class="mt-2 text-sm text-text-muted">{{ subtitle }}</p>
</div>
<div class="glass-card rounded-xl p-6 md:p-8">
<h2 class="mb-6 font-mono text-lg font-semibold text-slate-100">
{{ title }}
</h2>
<slot />
</div>
</div>
</div>
</template>

View File

@@ -1,101 +0,0 @@
<script setup>
import { ref } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
label: { type: String, required: true },
accept: { type: String, default: "image/png,image/jpeg,image/webp,image/gif" },
maxSizeMb: { type: Number, default: 2 },
});
const emit = defineEmits(["update:modelValue", "error"]);
const fileInputRef = ref(null);
const reading = ref(false);
function openPicker() {
fileInputRef.value?.click();
}
function clearImage() {
emit("update:modelValue", "");
if (fileInputRef.value) fileInputRef.value.value = "";
}
async function onFileChange(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
emit("error", "请选择图片文件");
event.target.value = "";
return;
}
const maxBytes = props.maxSizeMb * 1024 * 1024;
if (file.size > maxBytes) {
emit("error", `图片不能超过 ${props.maxSizeMb}MB`);
event.target.value = "";
return;
}
reading.value = true;
try {
const dataUrl = await readAsDataUrl(file);
emit("update:modelValue", dataUrl);
} catch {
emit("error", "读取图片失败");
} finally {
reading.value = false;
event.target.value = "";
}
}
function readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
</script>
<template>
<div class="admin-image-upload">
<label class="admin-image-upload__label">{{ label }}</label>
<div class="admin-image-upload__body">
<div
class="admin-image-upload__preview"
:class="{ 'admin-image-upload__preview--empty': !modelValue }"
>
<img v-if="modelValue" :src="modelValue" alt="" />
<span v-else class="text-xs text-text-muted">未上传</span>
</div>
<div class="admin-image-upload__actions">
<input
ref="fileInputRef"
type="file"
class="admin-image-upload__input"
:accept="accept"
@change="onFileChange"
/>
<Button
label="选择图片"
size="small"
severity="secondary"
:loading="reading"
@click="openPicker"
/>
<Button
v-if="modelValue"
label="清除"
size="small"
severity="danger"
text
@click="clearImage"
/>
</div>
</div>
</div>
</template>

View File

@@ -1,31 +0,0 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const menuItems = [
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
{ name: "admin-overview", label: "软件配置", to: "/admin" },
];
const activeName = computed(() => route.name);
</script>
<template>
<nav class="admin-sub-nav" aria-label="管理后台导航">
<p class="admin-sub-nav__title"></p>
<RouterLink
v-for="item in menuItems"
:key="item.name"
:to="item.to"
class="admin-sub-nav__item"
:class="{ 'admin-sub-nav__item--active': activeName === item.name }"
>
{{ item.label }}
</RouterLink>
</nav>
</template>

View File

@@ -1,101 +0,0 @@
<script setup>
import { ref } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
label: { type: String, required: true },
accept: { type: String, default: "image/png,image/jpeg,image/webp,image/gif" },
maxSizeMb: { type: Number, default: 2 },
});
const emit = defineEmits(["update:modelValue", "error"]);
const fileInputRef = ref(null);
const reading = ref(false);
function openPicker() {
fileInputRef.value?.click();
}
function clearImage() {
emit("update:modelValue", "");
if (fileInputRef.value) fileInputRef.value.value = "";
}
async function onFileChange(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
emit("error", "请选择图片文件");
event.target.value = "";
return;
}
const maxBytes = props.maxSizeMb * 1024 * 1024;
if (file.size > maxBytes) {
emit("error", `图片不能超过 ${props.maxSizeMb}MB`);
event.target.value = "";
return;
}
reading.value = true;
try {
const dataUrl = await readAsDataUrl(file);
emit("update:modelValue", dataUrl);
} catch {
emit("error", "读取图片失败");
} finally {
reading.value = false;
event.target.value = "";
}
}
function readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
</script>
<template>
<div class="admin-image-upload">
<label class="admin-image-upload__label">{{ label }}</label>
<div class="admin-image-upload__body">
<div
class="admin-image-upload__preview"
:class="{ 'admin-image-upload__preview--empty': !modelValue }"
>
<img v-if="modelValue" :src="modelValue" alt="" />
<span v-else class="text-xs text-text-muted">未上传</span>
</div>
<div class="admin-image-upload__actions">
<input
ref="fileInputRef"
type="file"
class="admin-image-upload__input"
:accept="accept"
@change="onFileChange"
/>
<Button
label="选择图片"
size="small"
severity="secondary"
:loading="reading"
@click="openPicker"
/>
<Button
v-if="modelValue"
label="清除"
size="small"
severity="danger"
text
@click="clearImage"
/>
</div>
</div>
</div>
</template>

View File

@@ -1,30 +0,0 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const menuItems = [
{ name: "agent-users", label: "用户管理", to: "/agent/users" },
{ name: "agent-card-keys", label: "卡密管理", to: "/agent/card-keys" },
];
const activeName = computed(() => route.name);
</script>
<template>
<nav class="admin-sub-nav" aria-label="管理后台导航">
<p class="admin-sub-nav__title"></p>
<RouterLink
v-for="item in menuItems"
:key="item.name"
:to="item.to"
class="admin-sub-nav__item"
:class="{ 'admin-sub-nav__item--active': activeName === item.name }"
>
{{ item.label }}
</RouterLink>
</nav>
</template>

View File

@@ -1,151 +0,0 @@
<script setup>
import { ref } from "vue";
import { useAvatarStore } from "../../stores/avatar.js";
import { pickVideoFile, importAvatarVideo } from "../../services/avatarVideo.js";
import { localVideoToPlayableUrl } from "../../services/localVideo.js";
import { avatarNameExists } from "../../services/avatarDb.js";
const emit = defineEmits(["saved"]);
const avatarStore = useAvatarStore();
const visible = ref(false);
const name = ref("");
const localSourcePath = ref("");
const previewSrc = ref("");
const saving = ref(false);
const errorMessage = ref("");
function resetForm() {
name.value = "";
localSourcePath.value = "";
previewSrc.value = "";
errorMessage.value = "";
}
function openAdd() {
resetForm();
visible.value = true;
}
async function onPickVideo() {
errorMessage.value = "";
try {
const path = await pickVideoFile();
if (!path) return;
localSourcePath.value = path;
previewSrc.value = localVideoToPlayableUrl(path);
} catch (err) {
errorMessage.value =
err instanceof Error ? err.message : String(err || "选择文件失败");
}
}
function onCancel() {
visible.value = false;
}
async function onSave() {
errorMessage.value = "";
const title = name.value.trim();
if (!title) {
errorMessage.value = "请输入名称";
return;
}
if (await avatarNameExists(title)) {
errorMessage.value = "名称重复";
return;
}
if (!localSourcePath.value) {
errorMessage.value = "请选择视频";
return;
}
saving.value = true;
try {
const savedPath = await importAvatarVideo(localSourcePath.value);
await avatarStore.addTemplate({ name: title, filePath: savedPath });
visible.value = false;
emit("saved");
} catch (err) {
errorMessage.value =
err instanceof Error ? err.message : String(err || "保存失败");
} finally {
saving.value = false;
}
}
defineExpose({ openAdd });
</script>
<template>
<Dialog
v-model:visible="visible"
modal
header="添加视频形象"
:style="{ width: '720px' }"
:draggable="false"
class="avatar-edit-dialog"
>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-3">
<div>
<div class="dashboard-field-label mb-1">
名称 <span class="text-red-400">*</span>
</div>
<InputText
v-model="name"
class="w-full"
size="small"
placeholder="例如:主播小美"
/>
</div>
<div>
<div class="dashboard-field-label mb-1">视频 <span class="text-red-400">*</span></div>
<div
v-if="!previewSrc"
class="flex h-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-white/20 bg-white/5 text-center text-sm text-slate-400 transition-colors hover:border-blue-400/40 hover:bg-white/10"
@click="onPickVideo"
>
<i class="pi pi-video mb-2 text-2xl text-slate-500" />
<span>点击选择视频文件</span>
<span class="mt-1 text-xs">支持 mp4 / mov / webm </span>
</div>
<div v-else class="space-y-2">
<div class="overflow-hidden rounded-lg bg-black p-1">
<video :src="previewSrc" controls class="max-h-48 w-full" />
</div>
<Button
label="重新选择"
size="small"
severity="secondary"
outlined
class="w-full"
@click="onPickVideo"
/>
</div>
</div>
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
</div>
<div class="text-sm text-slate-400">
<div class="mb-2 text-base font-medium text-slate-200">形象示例</div>
<div class="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
<div class="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">正脸自拍</div>
<div class="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">可张口闭口</div>
<div class="rounded-lg bg-red-500/10 p-2 text-red-400">面部有干扰</div>
</div>
<div class="dashboard-field-label mb-2">形象视频要求</div>
<ul class="space-y-1.5 text-xs leading-relaxed">
<li>视频时长建议 1030 格式 MP4分辨率 1080p4K</li>
<li>每一帧需正面露脸无遮挡且仅出现同一人脸</li>
<li>建议闭口或微微张口张口幅度不宜过大</li>
<li>可正常语气循环说一二三四五六七八九等文字</li>
</ul>
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" @click="onCancel" />
<Button label="保存" :loading="saving" @click="onSave" />
</template>
</Dialog>
</template>

View File

@@ -1,48 +0,0 @@
<script setup>
import { ref } from "vue";
defineOptions({ name: "DashboardCard" });
const props = defineProps({
title: { type: String, required: true },
step: { type: String, default: "" },
defaultOpen: { type: Boolean, default: true },
grow: { type: Boolean, default: false },
});
const open = ref(props.defaultOpen);
function toggle() {
open.value = !open.value;
}
</script>
<template>
<section
class="dashboard-card"
:class="{ 'dashboard-card--grow': props.grow }"
>
<header class="dashboard-card-header cursor-pointer" @click="toggle">
<div class="flex min-w-0 items-center gap-2">
<span v-if="props.step" class="step-badge">{{ props.step }}</span>
<h3 class="dashboard-card-title truncate">{{ props.title }}</h3>
</div>
<svg
class="dashboard-chevron shrink-0"
:class="{ 'dashboard-chevron--open': open }"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M39.6 17.443 24.043 33 8.487 17.443" />
</svg>
</header>
<div v-show="open" class="dashboard-card-body">
<slot />
</div>
</section>
</template>

View File

@@ -1,9 +0,0 @@
<script setup>
defineProps({
step: { type: String, required: true },
});
</script>
<template>
<span class="step-badge">{{ step }}</span>
</template>

View File

@@ -1,101 +0,0 @@
<script setup>
import { ref } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
label: { type: String, required: true },
accept: { type: String, default: "image/png,image/jpeg,image/webp,image/gif" },
maxSizeMb: { type: Number, default: 2 },
});
const emit = defineEmits(["update:modelValue", "error"]);
const fileInputRef = ref(null);
const reading = ref(false);
function openPicker() {
fileInputRef.value?.click();
}
function clearImage() {
emit("update:modelValue", "");
if (fileInputRef.value) fileInputRef.value.value = "";
}
async function onFileChange(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
emit("error", "请选择图片文件");
event.target.value = "";
return;
}
const maxBytes = props.maxSizeMb * 1024 * 1024;
if (file.size > maxBytes) {
emit("error", `图片不能超过 ${props.maxSizeMb}MB`);
event.target.value = "";
return;
}
reading.value = true;
try {
const dataUrl = await readAsDataUrl(file);
emit("update:modelValue", dataUrl);
} catch {
emit("error", "读取图片失败");
} finally {
reading.value = false;
event.target.value = "";
}
}
function readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
</script>
<template>
<div class="admin-image-upload">
<label class="admin-image-upload__label">{{ label }}</label>
<div class="admin-image-upload__body">
<div
class="admin-image-upload__preview"
:class="{ 'admin-image-upload__preview--empty': !modelValue }"
>
<img v-if="modelValue" :src="modelValue" alt="" />
<span v-else class="text-xs text-text-muted">未上传</span>
</div>
<div class="admin-image-upload__actions">
<input
ref="fileInputRef"
type="file"
class="admin-image-upload__input"
:accept="accept"
@change="onFileChange"
/>
<Button
label="选择图片"
size="small"
severity="secondary"
:loading="reading"
@click="openPicker"
/>
<Button
v-if="modelValue"
label="清除"
size="small"
severity="danger"
text
@click="clearImage"
/>
</div>
</div>
</div>
</template>

View File

@@ -1,31 +0,0 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const menuItems = [
{ name: "oem-users", label: "用户管理", to: "/oem/users" },
{ name: "oem-card-keys", label: "卡密管理", to: "/oem/card-keys" },
{ name: "oem-desktop-config", label: "桌面配置", to: "/oem/desktop-config" },
{ name: "oem-overview", label: "软件配置", to: "/oem" },
];
const activeName = computed(() => route.name);
</script>
<template>
<nav class="admin-sub-nav" aria-label="管理后台导航">
<p class="admin-sub-nav__title"></p>
<RouterLink
v-for="item in menuItems"
:key="item.name"
:to="item.to"
class="admin-sub-nav__item"
:class="{ 'admin-sub-nav__item--active': activeName === item.name }"
>
{{ item.label }}
</RouterLink>
</nav>
</template>

View File

@@ -1,394 +0,0 @@
<script setup>
import { computed, ref } from "vue";
import {
SUBTITLE_FONT_OPTIONS,
buildSubtitlePreviewBoxStyle,
} from "../../config/subtitleStylePresets.js";
const config = defineModel("config", { type: Object, required: true });
const importInputRef = ref(null);
const newGroupName = ref("");
const editingGroupIndex = ref(-1);
const keywordInputs = ref({});
const renderModeOptions = [
{ label: "内联字幕", value: "inline-emphasis" },
{ label: "浮动特效", value: "floating" },
];
const builtinGroupNames = new Set(["行动词", "情感词", "描述词", "重点词/成语词"]);
const enableKeywordEffects = computed({
get: () => config.value.enableKeywordEffects !== false,
set: (v) => {
config.value.enableKeywordEffects = v;
},
});
const keywordRenderMode = computed({
get: () => config.value.keywordRenderMode || "inline-emphasis",
set: (v) => {
config.value.keywordRenderMode = v;
},
});
const groups = computed({
get: () => config.value.keywordGroupsStyles || [],
set: (value) => {
config.value.keywordGroupsStyles = value;
},
});
function ensureGroups() {
if (!Array.isArray(config.value.keywordGroupsStyles)) {
config.value.keywordGroupsStyles = [];
}
return config.value.keywordGroupsStyles;
}
function ensureStyleOverride(group) {
if (!group.styleOverride) {
group.styleOverride = {
fontName: config.value.subtitleStyle?.fontName || "胡晓波男神体",
fontSize: Math.max(Number(config.value.subtitleStyle?.fontSize) || 24, 24),
fontColor: group.color || "#FFFF00",
outlineColor: "#000000",
outlineWidth: 2,
};
}
if (!group.color) group.color = group.styleOverride.fontColor || "#1890ff";
return group.styleOverride;
}
function createGroup() {
const name = newGroupName.value.trim();
if (!name) return;
const list = ensureGroups();
list.push({
groupName: name,
description: `模板分组${name}`,
keywords: [],
styleOverride: {
fontName: config.value.subtitleStyle?.fontName || "胡晓波男神体",
fontSize: 24,
fontColor: "#FFFF00",
outlineColor: "#000000",
outlineWidth: 2,
},
effectId: null,
effectDuration: null,
color: "#1890ff",
soundEffectId: null,
soundEffectConfig: null,
});
newGroupName.value = "";
editingGroupIndex.value = list.length - 1;
}
function removeGroup(index) {
const group = groups.value[index];
if (!group || builtinGroupNames.has(group.groupName)) return;
if (!window.confirm(`确定删除分组「${group.groupName || `分组 ${index + 1}`}」?`)) {
return;
}
groups.value.splice(index, 1);
if (editingGroupIndex.value === index) editingGroupIndex.value = -1;
}
function groupKeywords(group) {
return group.keywords || group.words || [];
}
function addKeyword(group, index) {
const word = String(keywordInputs.value[index] || "").trim();
if (!word) return;
if (!Array.isArray(group.keywords)) group.keywords = groupKeywords(group).slice();
if (!group.keywords.includes(word)) group.keywords.push(word);
keywordInputs.value[index] = "";
}
function removeKeyword(group, keyword) {
group.keywords = groupKeywords(group).filter((item) => item !== keyword);
}
function groupPreviewStyle(group) {
return buildSubtitlePreviewBoxStyle({
...(config.value.subtitleStyle || {}),
...(group.styleOverride || {}),
});
}
function exportGroups() {
const blob = new Blob(
[
JSON.stringify(
{
version: "1.0.0",
exportedAt: new Date().toISOString(),
keywordGroupsStyles: groups.value,
},
null,
2,
),
],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "keyword-groups-config.json";
a.click();
URL.revokeObjectURL(url);
}
async function importGroups(event) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const data = JSON.parse(await file.text());
const list = data.keywordGroupsStyles || data.groups || data;
if (!Array.isArray(list)) {
window.alert("无效的关键词分组配置");
return;
}
groups.value = list;
editingGroupIndex.value = -1;
}
</script>
<template>
<div class="rounded-lg border border-blue-500/30 bg-gradient-to-r from-blue-900/15 to-blue-900/10 p-4">
<div class="mb-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<label class="flex items-center gap-2">
<Checkbox v-model="enableKeywordEffects" binary />
<span class="font-medium text-slate-100">关键词智能识别与特效</span>
</label>
<Tag
:value="enableKeywordEffects ? '已启用' : '未启用'"
:severity="enableKeywordEffects ? 'info' : 'secondary'"
/>
</div>
<p class="mt-1 pl-6 text-xs text-slate-400">
勾选后启用关键词特效功能AI 自动识别字幕中的关键词并添加醒目的放大特效
</p>
</div>
<div v-if="enableKeywordEffects" class="mt-4 space-y-4">
<div class="rounded-lg border border-slate-600/40 bg-slate-900/40 p-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h6 class="text-sm font-medium text-slate-200">关键词呈现方式</h6>
<p class="mt-1 text-xs text-slate-400">
内联字幕用于新模板浮动特效用于旧模板
</p>
</div>
<SelectButton
v-model="keywordRenderMode"
:options="renderModeOptions"
option-label="label"
option-value="value"
size="small"
/>
</div>
<p
class="mt-3 rounded-md border px-3 py-2 text-xs"
:class="
keywordRenderMode === 'inline-emphasis'
? 'border-blue-500/40 bg-blue-900/20 text-blue-200'
: 'border-amber-500/40 bg-amber-900/20 text-amber-200'
"
>
{{
keywordRenderMode === "inline-emphasis"
? "关键词会直接嵌在普通字幕里放大显示,不再单独飞出"
: "关键词会按旧模板方式单独触发浮动/动画特效"
}}
</p>
</div>
<div class="flex items-start gap-2 rounded border border-blue-500/30 bg-blue-900/20 p-3 text-xs text-blue-200">
<i class="pi pi-info-circle mt-0.5" />
<span>关键词将根据字幕内容自动识别和匹配生成视频时会自动应用特效</span>
</div>
<div class="rounded-lg border border-slate-600/40 bg-slate-900/40 p-3">
<h6 class="mb-3 text-sm font-medium text-slate-200">关键词分组管理</h6>
<div class="mb-4 rounded bg-slate-800/60 p-3">
<div class="flex gap-2">
<InputText
v-model="newGroupName"
size="small"
class="flex-1"
placeholder="输入分组名称"
@keydown.enter="createGroup"
/>
<Button label="创建分组" size="small" @click="createGroup" />
</div>
</div>
<div class="space-y-3">
<div class="text-sm font-medium text-slate-200">已有分组 ({{ groups.length }})</div>
<div
v-for="(group, i) in groups"
:key="`${group.groupName || 'group'}-${i}`"
class="rounded-lg border border-slate-600/40 bg-slate-800/50 p-3"
:style="{ borderLeftWidth: '4px', borderLeftColor: group.color || '#1890ff' }"
>
<div class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 font-medium text-slate-100">
<span>{{ group.groupName || `分组 ${i + 1}` }}</span>
</div>
<InputText
v-model="group.description"
size="small"
class="mt-2 w-full"
placeholder="分组描述"
/>
</div>
<div class="flex shrink-0 gap-2">
<Button
:label="editingGroupIndex === i ? '收起样式' : '编辑样式'"
size="small"
text
@click="
ensureStyleOverride(group);
editingGroupIndex = editingGroupIndex === i ? -1 : i;
"
/>
<Button
v-if="!builtinGroupNames.has(group.groupName)"
label="删除"
size="small"
text
severity="danger"
@click="removeGroup(i)"
/>
</div>
</div>
<div class="mb-2">
<div class="mb-1 text-xs text-slate-400">关键词 ({{ groupKeywords(group).length }})</div>
<div v-if="groupKeywords(group).length" class="flex flex-wrap gap-1">
<Tag
v-for="keyword in groupKeywords(group)"
:key="keyword"
:value="keyword"
rounded
:style="{
backgroundColor: group.color || '#1890ff',
color: '#fff',
border: 'none',
}"
class="cursor-pointer"
@click="removeKeyword(group, keyword)"
/>
</div>
<div v-else class="text-xs text-slate-500">暂无关键词</div>
<div class="mt-2 flex gap-2">
<InputText
v-model="keywordInputs[i]"
size="small"
class="flex-1"
placeholder="添加关键词"
@keydown.enter="addKeyword(group, i)"
/>
<Button label="添加" size="small" severity="secondary" @click="addKeyword(group, i)" />
</div>
</div>
<div
v-if="editingGroupIndex === i"
class="mt-3 rounded-lg border border-slate-600/40 bg-white p-3 text-slate-800"
>
<div class="mb-3 text-sm font-medium">分组样式</div>
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<label class="mb-1 block text-xs text-slate-500">字体</label>
<Select
v-model="group.styleOverride.fontName"
:options="SUBTITLE_FONT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
size="small"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">字体大小</label>
<InputNumber
v-model="group.styleOverride.fontSize"
:min="12"
:max="120"
class="w-full"
size="small"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">标签颜色</label>
<div class="flex items-center gap-2">
<input v-model="group.color" type="color" class="h-8 w-10" />
<InputText v-model="group.color" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">文字颜色</label>
<div class="flex items-center gap-2">
<input v-model="group.styleOverride.fontColor" type="color" class="h-8 w-10" />
<InputText v-model="group.styleOverride.fontColor" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">描边颜色</label>
<div class="flex items-center gap-2">
<input v-model="group.styleOverride.outlineColor" type="color" class="h-8 w-10" />
<InputText v-model="group.styleOverride.outlineColor" size="small" class="flex-1" />
</div>
</div>
<div class="col-span-2">
<label class="mb-1 block text-xs text-slate-500">描边宽度</label>
<div class="flex items-center gap-2">
<Slider
v-model="group.styleOverride.outlineWidth"
:min="0"
:max="10"
class="flex-1"
/>
<span class="w-10 text-right font-mono text-sm">
{{ group.styleOverride.outlineWidth }}px
</span>
</div>
</div>
</div>
<div class="mt-3 rounded bg-slate-800 p-3 text-center">
<span :style="groupPreviewStyle(group)">示例关键词</span>
</div>
</div>
</div>
</div>
<div class="mt-4 flex gap-2">
<Button label="导出配置" size="small" severity="secondary" @click="exportGroups" />
<Button
label="导入配置"
size="small"
severity="secondary"
@click="importInputRef?.click()"
/>
</div>
</div>
</div>
<input
ref="importInputRef"
type="file"
accept="application/json,.json"
class="hidden"
@change="importGroups"
/>
</div>
</template>

View File

@@ -1,464 +0,0 @@
<script setup>
import { computed } from "vue";
import {
buildBaseTextStyle,
buildTitleLineStyle,
injectAccentMarkers,
lineScaleFactor,
parseSubtitleLine,
resolveSubtitlePreviewImage,
} from "../../utils/subtitlePreviewStyle.js";
const props = defineProps({
templateName: { type: String, default: "" },
subtitleStyle: { type: Object, default: null },
titleSubtitleConfig: { type: Object, default: null },
keywordGroupsStyles: { type: Array, default: () => [] },
keywordRenderMode: { type: String, default: "inline-emphasis" },
previewConfig: { type: Object, default: null },
isSelected: { type: Boolean, default: false },
previewScale: { type: Number, default: 0.33 },
isSystem: { type: Boolean, default: false },
showActions: { type: Boolean, default: true },
showMeta: { type: Boolean, default: true },
cardClickable: { type: Boolean, default: true },
});
const emit = defineEmits(["select", "edit", "clone", "export", "delete"]);
const inlineEmphasis = computed(
() => props.keywordRenderMode === "inline-emphasis",
);
const previewBg = computed(() => {
const path = props.previewConfig?.backgroundImage;
return resolveSubtitlePreviewImage(path) || "";
});
const bgStyle = computed(() => ({
objectPosition: props.previewConfig?.backgroundPosition || "center 28%",
}));
const overlayStyle = computed(() => {
const start =
props.previewConfig?.overlayStartColor || "rgba(0, 0, 0, 0.12)";
const end = props.previewConfig?.overlayEndColor || "rgba(0, 0, 0, 0.56)";
return {
background: `linear-gradient(180deg, ${start} 0%, rgba(15, 23, 42, 0.04) 38%, ${end} 100%)`,
};
});
const titleTopStyle = computed(() => ({
top: `${props.titleSubtitleConfig?.topMarginPercent ?? 7}%`,
}));
const titleLines = computed(() => {
const cfg = props.titleSubtitleConfig;
if (!cfg?.enabled || !cfg.lines?.length) return [];
const texts = props.previewConfig?.titleTexts?.filter((t) => t?.trim());
return cfg.lines.map((line, i) => ({
...line,
text: texts?.[i] || line.text,
}));
});
const hasTitle = computed(() => titleLines.value.length > 0);
const keywordAccentStyle = computed(() => {
const groups = props.keywordGroupsStyles || [];
const accent =
groups
.map((g) => g?.styleOverride)
.find(
(s) =>
s?.fontColor && s.fontColor !== props.subtitleStyle?.fontColor,
) ||
groups[0]?.styleOverride ||
props.subtitleStyle;
return buildBaseTextStyle(
{
...accent,
fontColor: accent?.fontColor || "#ffe600",
outlineColor: accent?.outlineColor || "#111111",
fontSize: Math.max(
Number(accent?.fontSize) || 0,
(Number(props.subtitleStyle?.fontSize) || 0) + 4,
Math.round((Number(props.subtitleStyle?.fontSize) || 0) * 1.1),
),
},
{
previewScale: props.previewScale,
minFontSize: 14,
scaleMultiplier: 1.02,
maxStrokeWidth: 0.9,
},
);
});
const normalStyle = computed(() =>
buildBaseTextStyle(props.subtitleStyle, {
previewScale: props.previewScale,
minFontSize: hasTitle.value
? inlineEmphasis.value
? 16
: 15
: inlineEmphasis.value
? 13
: 12,
scaleMultiplier: hasTitle.value
? inlineEmphasis.value
? 1.12
: 1.02
: inlineEmphasis.value
? 0.98
: 0.88,
maxStrokeWidth: hasTitle.value ? 0.9 : 0.82,
}),
);
const subtitleLines = computed(() => {
const fromConfig = props.previewConfig?.subtitleTexts?.filter((t) =>
t?.trim(),
);
const fallbackInline = [
"生活不会辜负认真[努力]的人",
"用心前行日子总会[温柔]以待",
];
const fallbackClassic = [
"这套模板展示普通字幕",
"下方只保留统一颜色",
];
const base =
fromConfig?.length > 0
? fromConfig
: inlineEmphasis.value
? fallbackInline
: fallbackClassic;
const lines = inlineEmphasis.value
? base.map((line) => injectAccentMarkers(line, true))
: base;
return lines.map((line) => parseSubtitleLine(line, inlineEmphasis.value));
});
const alignment = computed(
() => props.subtitleStyle?.alignment || "center",
);
const subtitleBoxStyle = computed(() => {
if (alignment.value === "left") {
return {
left: "0",
right: "0",
transform: "none",
width: "100%",
paddingLeft: "10%",
paddingRight: "10%",
alignItems: "stretch",
};
}
if (alignment.value === "right") {
return {
left: "0",
right: "0",
transform: "none",
width: "100%",
paddingLeft: "10%",
paddingRight: "10%",
alignItems: "stretch",
};
}
return {
left: "50%",
transform: "translateX(-50%)",
width: "84%",
alignItems: "center",
};
});
function lineWrapStyle(segments) {
const scale = lineScaleFactor(segments, hasTitle.value, inlineEmphasis.value);
if (alignment.value === "left") {
return {
textAlign: "left",
maxWidth: "100%",
width: "100%",
transform: scale < 0.999 ? `scale(${scale})` : "none",
transformOrigin: "left center",
};
}
if (alignment.value === "right") {
return {
textAlign: "right",
maxWidth: "100%",
width: "100%",
transform: scale < 0.999 ? `scale(${scale})` : "none",
transformOrigin: "right center",
};
}
return {
textAlign: "center",
maxWidth: "100%",
width: "100%",
transform: scale < 0.999 ? `scale(${scale})` : "none",
transformOrigin: "center center",
};
}
function segmentStyle(segment, segments) {
const scale = lineScaleFactor(segments, hasTitle.value, inlineEmphasis.value);
const minSize = hasTitle.value ? (inlineEmphasis.value ? 11 : 10) : 10;
const applyScale = (style) => {
const px = parseFloat(String(style.fontSize || "0"));
if (!px || Number.isNaN(px)) return style;
if (scale >= 0.999) return style;
return {
...style,
fontSize: `${Math.max(minSize, Math.round(px * scale * 10) / 10)}px`,
};
};
if (!segment.accent || !inlineEmphasis.value) {
return applyScale(normalStyle.value);
}
return applyScale({
...keywordAccentStyle.value,
transform: "translateY(-0.06em)",
});
}
function onCardClick() {
if (props.cardClickable) emit("select");
}
</script>
<template>
<div
class="subtitle-preview-card"
:class="{ 'is-selected': isSelected, 'is-static': !cardClickable }"
@click="onCardClick"
>
<div class="preview-shell">
<div class="preview-container">
<img
v-if="previewBg"
:src="previewBg"
class="preview-bg"
:style="bgStyle"
alt="preview"
/>
<div class="preview-overlay" :style="overlayStyle" />
<div
v-if="hasTitle"
class="title-subtitle-preview"
:style="titleTopStyle"
>
<div
v-for="(line, index) in titleLines"
:key="index"
class="title-line-wrap"
style="justify-content: center; padding-left: 2%; padding-right: 2%"
>
<div
class="title-line"
:style="buildTitleLineStyle(line, index, titleLines.length, previewScale)"
>
{{ line.text || `标题 ${index + 1}` }}
</div>
</div>
</div>
<div class="normal-subtitle-preview" :style="subtitleBoxStyle">
<div
v-for="(segments, lineIndex) in subtitleLines"
:key="lineIndex"
class="subtitle-line"
:style="lineWrapStyle(segments)"
>
<span
v-for="(segment, segIndex) in segments"
:key="`${lineIndex}-${segIndex}`"
class="subtitle-segment"
:class="{
'subtitle-segment--accent': segment.accent && inlineEmphasis,
}"
:style="segmentStyle(segment, segments)"
>
{{ segment.text }}
</span>
</div>
</div>
</div>
</div>
<div v-if="showMeta || showActions" class="template-info">
<div v-if="showMeta" class="template-title-row">
<span class="template-name">{{ templateName || "未命名模板" }}</span>
<span v-if="isSelected" class="selected-tag">已选</span>
<span v-else-if="isSystem" class="system-tag">系统</span>
</div>
<div v-if="showActions" class="template-actions">
<Button
label="编辑"
size="small"
icon="pi pi-pencil"
@click.stop="emit('edit')"
/>
<Button
label="复制"
size="small"
outlined
icon="pi pi-copy"
@click.stop="emit('clone')"
/>
<Button
label="导出"
size="small"
outlined
icon="pi pi-download"
@click.stop="emit('export')"
/>
<Button
v-if="!isSystem"
label="删除"
size="small"
outlined
severity="danger"
icon="pi pi-trash"
@click.stop="emit('delete')"
/>
</div>
</div>
</div>
</template>
<style scoped>
.subtitle-preview-card {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 0.75rem;
overflow: hidden;
background: rgba(15, 23, 42, 0.45);
transition:
border-color 0.2s,
box-shadow 0.2s;
cursor: pointer;
}
.subtitle-preview-card.is-static {
cursor: default;
}
.subtitle-preview-card.is-selected {
border-color: rgba(96, 165, 250, 0.85);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
}
.preview-shell {
padding: 0.5rem;
}
.preview-container {
position: relative;
aspect-ratio: 9 / 16;
border-radius: 0.5rem;
overflow: hidden;
background: #0f172a;
}
.preview-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.title-subtitle-preview {
position: absolute;
left: 0;
right: 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 0.12rem;
}
.title-line-wrap {
display: flex;
}
.title-line {
width: 100%;
}
.normal-subtitle-preview {
position: absolute;
bottom: 8%;
z-index: 2;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.subtitle-line {
line-height: 1.2;
}
.subtitle-segment {
display: inline;
}
.template-info {
padding: 0.65rem 0.75rem 0.75rem;
border-top: 1px solid rgba(148, 163, 184, 0.15);
}
.template-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
min-height: 1.25rem;
}
.template-name {
font-size: 0.875rem;
font-weight: 500;
color: #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.system-tag,
.selected-tag {
flex-shrink: 0;
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
}
.system-tag {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.selected-tag {
background: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
}
.template-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.template-actions :deep(.p-button) {
padding: 0.2rem 0.45rem;
font-size: 0.7rem;
}
</style>

View File

@@ -1,264 +0,0 @@
<script setup>
import { computed } from "vue";
import {
SUBTITLE_FONT_OPTIONS,
SUBTITLE_STYLE_PRESETS,
buildSubtitlePreviewBoxStyle,
} from "../../config/subtitleStylePresets.js";
const config = defineModel("config", { type: Object, required: true });
const subtitleStyle = computed({
get: () => config.value.subtitleStyle,
set: (v) => {
config.value.subtitleStyle = v;
},
});
const fontSizeScale = computed({
get: () => config.value.subtitleFontSizeScale ?? 100,
set: (v) => {
config.value.subtitleFontSizeScale = v;
},
});
const subtitlePosition = computed({
get: () => config.value.subtitlePosition || subtitleStyle.value?.position || "bottom",
set: (v) => {
config.value.subtitlePosition = v;
if (subtitleStyle.value) subtitleStyle.value.position = v;
},
});
const noShadow = computed({
get: () => !subtitleStyle.value?.shadowOffset,
set: (checked) => {
if (!subtitleStyle.value) return;
subtitleStyle.value.shadowOffset = checked ? 0 : 2;
if (!checked && !subtitleStyle.value.shadowBlur) {
subtitleStyle.value.shadowBlur = 4;
}
},
});
const noBackground = computed({
get: () =>
!subtitleStyle.value?.backgroundColor ||
subtitleStyle.value.backgroundColor === "transparent" ||
(subtitleStyle.value.backgroundOpacity ?? 0) <= 0,
set: (checked) => {
if (!subtitleStyle.value) return;
if (checked) {
subtitleStyle.value.backgroundColor = "transparent";
subtitleStyle.value.backgroundOpacity = 0;
} else {
subtitleStyle.value.backgroundColor = "#000000";
subtitleStyle.value.backgroundOpacity = 0.7;
}
},
});
const shadowBlurPreview = computed({
get: () => subtitleStyle.value?.shadowBlur ?? 4,
set: (value) => {
if (subtitleStyle.value) subtitleStyle.value.shadowBlur = value;
},
});
const previewStyle = computed(() =>
buildSubtitlePreviewBoxStyle(subtitleStyle.value),
);
function applyPreset(preset) {
Object.assign(subtitleStyle.value, preset.style);
}
</script>
<template>
<div class="subtitle-style-selector space-y-4">
<div class="rounded-lg border border-slate-600/50 bg-slate-800/40 p-4">
<div class="mb-3 flex items-center justify-between">
<label class="text-sm font-medium text-slate-200">字体大小</label>
<span class="text-sm font-bold text-blue-400">{{ fontSizeScale }}%</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-400"></span>
<Slider v-model="fontSizeScale" :min="20" :max="150" :step="5" class="flex-1" />
<span class="text-xs text-slate-400"></span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button
v-for="n in [30, 50, 75, 100]"
:key="n"
:label="`${n}%`"
size="small"
severity="secondary"
@click="fontSizeScale = n"
/>
</div>
</div>
<div class="rounded-lg border border-slate-600/50 bg-slate-800/40 p-4">
<label class="mb-3 block text-sm font-medium text-slate-200">字幕位置</label>
<div class="flex gap-2">
<Button
label="上"
size="small"
:outlined="subtitlePosition !== 'top'"
@click="subtitlePosition = 'top'"
/>
<Button
label="中"
size="small"
:outlined="subtitlePosition !== 'center'"
@click="subtitlePosition = 'center'"
/>
<Button
label="下"
size="small"
:outlined="subtitlePosition !== 'bottom'"
@click="subtitlePosition = 'bottom'"
/>
</div>
</div>
<div class="rounded-lg border border-slate-600/50 bg-white p-4 text-slate-900">
<h5 class="mb-4 text-sm font-medium">自定义字幕样式</h5>
<div class="mb-4 rounded-lg bg-slate-800 p-4">
<div class="mb-3 text-center text-xs text-slate-400">效果预览</div>
<div class="flex min-h-[120px] items-center justify-center">
<div class="preview-text inline-block" :style="previewStyle">示例字幕文字</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="mb-2 block text-sm text-slate-600">字体</label>
<Select
v-model="subtitleStyle.fontName"
:options="SUBTITLE_FONT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
size="small"
/>
</div>
<div>
<label class="mb-2 block text-sm text-slate-600">文字颜色</label>
<div class="flex items-center gap-2">
<input v-model="subtitleStyle.fontColor" type="color" class="h-8 w-10" />
<InputText v-model="subtitleStyle.fontColor" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-2 block text-sm text-slate-600">描边颜色</label>
<div class="flex items-center gap-2">
<input v-model="subtitleStyle.outlineColor" type="color" class="h-8 w-10" />
<InputText v-model="subtitleStyle.outlineColor" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-2 block text-sm text-slate-600">描边宽度</label>
<div class="flex items-center gap-2">
<Slider
v-model="subtitleStyle.outlineWidth"
:min="0"
:max="10"
:step="1"
class="flex-1"
/>
<span class="w-10 text-right font-mono text-sm">{{ subtitleStyle.outlineWidth }}px</span>
</div>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-slate-700">
<Checkbox v-model="noShadow" binary />
无阴影
</label>
</div>
<template v-if="!noShadow">
<div>
<label class="mb-2 block text-sm text-slate-600">阴影偏移</label>
<div class="flex items-center gap-2">
<Slider
v-model="subtitleStyle.shadowOffset"
:min="1"
:max="10"
class="flex-1"
/>
<span class="w-10 text-right font-mono text-sm">{{ subtitleStyle.shadowOffset }}px</span>
</div>
</div>
<div>
<label class="mb-2 block text-sm text-slate-600">阴影颜色</label>
<div class="flex items-center gap-2">
<input v-model="subtitleStyle.shadowColor" type="color" class="h-8 w-10" />
<InputText v-model="subtitleStyle.shadowColor" size="small" class="flex-1" />
</div>
</div>
<div class="col-span-2">
<label class="mb-2 block text-sm text-slate-400">阴影模糊仅预览有效</label>
<div class="flex items-center gap-2">
<Slider
v-model="shadowBlurPreview"
:min="0"
:max="20"
class="flex-1"
disabled
/>
<span class="w-10 text-right font-mono text-sm text-slate-400">
{{ shadowBlurPreview }}px
</span>
</div>
<p class="mt-1 text-xs text-slate-400">
FFmpeg 不支持阴影模糊实际视频中仅显示阴影偏移效果
</p>
</div>
</template>
<div class="col-span-2">
<label class="mb-2 block text-sm text-slate-600">背景颜色</label>
<div class="flex items-center gap-2">
<input
v-model="subtitleStyle.backgroundColor"
type="color"
class="h-8 w-10"
:disabled="noBackground"
/>
<InputText
v-model="subtitleStyle.backgroundColor"
size="small"
class="flex-1"
:disabled="noBackground"
/>
</div>
<label class="mt-2 flex items-center gap-2 text-sm text-slate-700">
<Checkbox v-model="noBackground" binary />
无背景
</label>
<p class="mt-1 text-xs text-slate-500">
背景和描边可同时使用后端支持 BorderStyle=4
</p>
</div>
</div>
<div class="mt-4">
<label class="mb-2 block text-sm text-slate-600">快速预设</label>
<div class="flex flex-wrap gap-2">
<Button
v-for="preset in SUBTITLE_STYLE_PRESETS"
:key="preset.label"
:label="preset.label"
size="small"
severity="secondary"
@click="applyPreset(preset)"
/>
</div>
</div>
</div>
<div class="rounded-lg border border-blue-500/30 bg-blue-900/20 p-3 text-sm text-blue-200">
<strong>提示</strong>
编辑完成后请点击对话框底部的保存模板按钮保存所有设置包括字幕样式
</div>
</div>
</template>

View File

@@ -1,349 +0,0 @@
<script setup>
import { computed, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import SubtitlePreviewCard from "./SubtitlePreviewCard.vue";
import SubtitleTemplateEditor from "./SubtitleTemplateEditor.vue";
import {
cloneSubtitleTemplate,
downloadSubtitleTemplatesJson,
isSystemSubtitleTemplateId,
loadAllSubtitleTemplates,
loadCustomSubtitleTemplates,
parseImportedSubtitleTemplates,
saveCustomSubtitleTemplates,
upsertCustomSubtitleTemplate,
} from "../../services/subtitleTemplateCatalog.js";
import {
createEditDraft,
syncTitleLinesFromReference,
} from "../../utils/subtitleTemplateDraft.js";
const visible = defineModel("visible", { type: Boolean, default: false });
const props = defineProps({
selectedId: { type: String, default: "" },
});
const emit = defineEmits(["select", "update:selectedId"]);
const workflow = useWorkflowStore();
const { titleGenerated } = storeToRefs(workflow);
const templates = ref([]);
const loading = ref(false);
const feedback = ref({ severity: "", message: "" });
const fileInputRef = ref(null);
const editMode = ref(false);
const editingDraft = ref(null);
const saveDialogVisible = ref(false);
const saveTemplateName = ref("");
const saveTemplateDesc = ref("");
const selectedTemplateId = computed({
get: () => props.selectedId,
set: (id) => emit("update:selectedId", id),
});
const dialogHeader = computed(() => (editMode.value ? "字幕设置" : "字幕设置"));
async function refreshTemplates() {
loading.value = true;
feedback.value = { severity: "", message: "" };
try {
templates.value = await loadAllSubtitleTemplates();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
feedback.value = { severity: "error", message: `加载模板失败:${msg}` };
templates.value = [];
} finally {
loading.value = false;
}
}
watch(visible, (open) => {
if (open) {
editMode.value = false;
editingDraft.value = null;
refreshTemplates();
}
});
function showMsg(severity, message) {
feedback.value = { severity, message };
}
function selectTemplate(template) {
selectedTemplateId.value = template.id;
emit("select", template);
visible.value = false;
}
function onExportAll() {
downloadSubtitleTemplatesJson(templates.value, "subtitle-templates-all.json");
showMsg("success", "已导出全部模板");
}
function onExportOne(template) {
downloadSubtitleTemplatesJson([template], `${template.name || template.id}.json`);
showMsg("success", `已导出「${template.name}`);
}
function onImportClick() {
fileInputRef.value?.click();
}
async function onImportFile(event) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const imported = await parseImportedSubtitleTemplates(file);
const custom = loadCustomSubtitleTemplates();
const merged = [...custom];
for (const item of imported) {
const idx = merged.findIndex((t) => t.id === item.id);
if (idx >= 0) merged[idx] = item;
else merged.push(item);
}
saveCustomSubtitleTemplates(merged);
await refreshTemplates();
showMsg("success", `已导入 ${imported.length} 个模板`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showMsg("error", `导入失败:${msg}`);
}
}
function onNewTemplate() {
const base =
templates.value.find((t) => t.id === "template_system_11") || templates.value[0];
if (!base) {
showMsg("error", "暂无可用模板作为新建基础");
return;
}
const created = cloneSubtitleTemplate({ ...base, name: "新建模板" });
enterEditMode(created, true);
}
function onClone(template) {
const created = cloneSubtitleTemplate(template);
enterEditMode(created, true);
showMsg("success", `已复制为「${created.name}」,请编辑后保存`);
}
function onDelete(template) {
if (isSystemSubtitleTemplateId(template.id)) {
showMsg("error", "系统模板不可删除");
return;
}
if (!window.confirm(`确定删除模板「${template.name}」?`)) return;
const custom = loadCustomSubtitleTemplates().filter((t) => t.id !== template.id);
saveCustomSubtitleTemplates(custom);
if (selectedTemplateId.value === template.id) {
selectedTemplateId.value = "template_system_11";
}
refreshTemplates();
showMsg("success", "模板已删除");
}
function enterEditMode(template, isNew = false) {
editingDraft.value = createEditDraft(template);
if (titleGenerated.value?.trim()) {
syncTitleLinesFromReference(editingDraft.value, titleGenerated.value);
}
editMode.value = true;
saveTemplateName.value = isNew ? template.name : template.name;
saveTemplateDesc.value = template.description || "";
feedback.value = { severity: "", message: "" };
}
function onEdit(template) {
enterEditMode(template);
}
function cancelEdit() {
editMode.value = false;
editingDraft.value = null;
}
function openSaveDialog() {
if (!editingDraft.value) return;
const isSystem = isSystemSubtitleTemplateId(editingDraft.value.id);
saveTemplateName.value = isSystem
? `${editingDraft.value.name} 副本`
: editingDraft.value.name;
saveTemplateDesc.value = editingDraft.value.description || "";
saveDialogVisible.value = true;
}
function confirmSaveTemplate() {
const name = saveTemplateName.value.trim();
if (!name) {
showMsg("error", "请输入模板名称");
return;
}
const draft = editingDraft.value;
const isSystem = isSystemSubtitleTemplateId(draft.id);
const toSave = {
...draft,
name,
description: saveTemplateDesc.value.trim(),
isSystem: false,
is_system: 0,
};
if (isSystem) {
toSave.id = `custom_${Date.now()}`;
toSave.createdAt = Date.now();
}
const saved = upsertCustomSubtitleTemplate(toSave);
saveDialogVisible.value = false;
selectedTemplateId.value = saved.id;
emit("select", saved);
editMode.value = false;
editingDraft.value = null;
refreshTemplates();
showMsg("success", `模板「${saved.name}」已保存`);
}
</script>
<template>
<Dialog
v-model:visible="visible"
:header="dialogHeader"
modal
:style="{ width: editMode ? 'min(1200px, 98vw)' : 'min(1120px, 96vw)' }"
:content-style="{ padding: 0 }"
>
<div class="flex max-h-[78vh] flex-col">
<div v-if="!editMode" class="flex-1 overflow-y-auto p-4">
<div
class="rounded-lg border border-blue-500/30 bg-gradient-to-r from-blue-900/20 to-indigo-900/20 p-4"
>
<div class="mb-4 flex items-center justify-between gap-2">
<h5 class="font-medium text-slate-100">字幕模板</h5>
<div class="flex flex-wrap gap-2">
<Button
label="导出全部"
size="small"
icon="pi pi-download"
:disabled="!templates.length"
@click="onExportAll"
/>
<Button
label="导入模板"
size="small"
outlined
icon="pi pi-upload"
@click="onImportClick"
/>
<Button
label="新建模板"
size="small"
icon="pi pi-plus"
@click="onNewTemplate"
/>
</div>
</div>
<p
v-if="feedback.message"
class="mb-3 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div v-if="loading" class="py-12 text-center text-sm text-slate-400">
正在加载字幕模板
</div>
<div
v-else-if="templates.length"
class="grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<SubtitlePreviewCard
v-for="tpl in templates"
:key="tpl.id"
:template-name="tpl.name"
:subtitle-style="tpl.config?.subtitleStyle"
:title-subtitle-config="tpl.config?.titleSubtitleConfig"
:keyword-groups-styles="tpl.config?.keywordGroupsStyles"
:keyword-render-mode="tpl.config?.keywordRenderMode"
:preview-config="tpl.config?.previewConfig"
:is-selected="selectedTemplateId === tpl.id"
:preview-scale="0.33"
:is-system="tpl.is_system === 1 || tpl.isSystem || isSystemSubtitleTemplateId(tpl.id)"
@select="selectTemplate(tpl)"
@edit="onEdit(tpl)"
@clone="onClone(tpl)"
@export="onExportOne(tpl)"
@delete="onDelete(tpl)"
/>
</div>
<div
v-else
class="rounded-lg border border-dashed border-slate-600 py-10 text-center text-sm text-slate-400"
>
<div class="mb-2 text-lg">📝</div>
<div>暂无字幕模板点击新建模板创建</div>
</div>
</div>
</div>
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4">
<SubtitleTemplateEditor
v-model:draft="editingDraft"
:reference-title="titleGenerated"
/>
</div>
</div>
<template v-if="editMode" #footer>
<div class="flex w-full justify-end gap-2">
<Button label="返回列表" severity="secondary" outlined @click="cancelEdit" />
<Button label="保存模板" icon="pi pi-save" @click="openSaveDialog" />
</div>
</template>
<input
ref="fileInputRef"
type="file"
accept="application/json,.json"
class="hidden"
@change="onImportFile"
/>
</Dialog>
<Dialog
v-model:visible="saveDialogVisible"
header="保存字幕模板"
modal
:style="{ width: '28rem' }"
>
<div class="flex flex-col gap-3">
<div>
<label class="mb-1 block text-sm font-medium text-slate-200">模板名称</label>
<InputText v-model="saveTemplateName" placeholder="请输入模板名称" class="w-full" />
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-200">模板描述可选</label>
<Textarea
v-model="saveTemplateDesc"
placeholder="请输入模板描述"
rows="3"
class="w-full"
/>
</div>
<p class="text-xs text-slate-400">
保存内容包括字幕样式位置与大小关键词特效开关标题字幕配置关键词分组等
</p>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="saveDialogVisible = false" />
<Button label="保存" icon="pi pi-check" @click="confirmSaveTemplate" />
</template>
</Dialog>
</template>

View File

@@ -1,60 +0,0 @@
<script setup>
import { computed } from "vue";
import SubtitlePreviewCard from "./SubtitlePreviewCard.vue";
import SubtitleStyleSelector from "./SubtitleStyleSelector.vue";
import TitleSubtitleSettings from "./TitleSubtitleSettings.vue";
import KeywordEffectsSettings from "./KeywordEffectsSettings.vue";
const draft = defineModel("draft", { type: Object, required: true });
defineProps({
referenceTitle: { type: String, default: "" },
});
const config = computed({
get: () => draft.value.config,
set: (v) => {
draft.value.config = v;
},
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="shrink-0 border-b border-slate-700/80 pb-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-blue-400">正在编辑: {{ draft.name }}</span>
<Tag value="编辑模式" severity="info" />
</div>
</div>
<p class="mt-1 text-xs text-slate-500">修改完成后请点击底部的保存模板按钮</p>
</div>
<div class="mt-4 flex min-h-0 flex-1 gap-6">
<div class="sticky top-0 z-10 w-[320px] shrink-0 self-start">
<div class="mb-2 text-center text-xs font-medium text-slate-500">实时预览</div>
<SubtitlePreviewCard
:template-name="draft.name"
:subtitle-style="config.subtitleStyle"
:title-subtitle-config="config.titleSubtitleConfig"
:keyword-groups-styles="config.keywordGroupsStyles"
:keyword-render-mode="config.keywordRenderMode"
:preview-config="config.previewConfig"
:preview-scale="0.42"
:show-actions="false"
:show-meta="false"
:card-clickable="false"
/>
</div>
<div class="min-w-0 flex-1 space-y-6 overflow-y-auto pb-4 pr-1">
<SubtitleStyleSelector v-model:config="config" />
<hr class="border-slate-700" />
<TitleSubtitleSettings v-model:config="config" :reference-title="referenceTitle" />
<hr class="border-slate-700" />
<KeywordEffectsSettings v-model:config="config" />
</div>
</div>
</div>
</template>

View File

@@ -1,167 +0,0 @@
<script setup>
import { computed } from "vue";
import { SUBTITLE_FONT_OPTIONS } from "../../config/subtitleStylePresets.js";
const line = defineModel("line", { type: Object, required: true });
const enableBackground = computed({
get: () =>
line.value.backgroundColor &&
line.value.backgroundColor !== "transparent" &&
(line.value.backgroundOpacity ?? 0) > 0,
set: (on) => {
if (on) {
line.value.backgroundColor = line.value.backgroundColor || "#000000";
line.value.backgroundOpacity = line.value.backgroundOpacity || 0.5;
} else {
line.value.backgroundColor = "transparent";
line.value.backgroundOpacity = 0;
}
},
});
const backgroundOpacityPercent = computed({
get: () => Math.round((Number(line.value.backgroundOpacity) || 0) * 100),
set: (value) => {
line.value.backgroundOpacity = Math.max(0, Math.min(100, Number(value) || 0)) / 100;
},
});
const enableShadow = computed({
get: () => (line.value.shadowOffset ?? 0) > 0,
set: (on) => {
line.value.shadowOffset = on ? line.value.shadowOffset || 2 : 0;
},
});
const previewStyle = computed(() => {
const outline = Number(line.value.outlineWidth) || 0;
const offset = Number(line.value.shadowOffset) || 0;
const shadows = [];
if (outline > 0) {
const c = line.value.outlineColor || "#000";
for (let x = -outline; x <= outline; x++) {
for (let y = -outline; y <= outline; y++) {
if (x || y) shadows.push(`${x}px ${y}px 0 ${c}`);
}
}
}
if (offset > 0) {
shadows.push(`${offset}px ${offset}px 0 ${line.value.shadowColor || "#000"}`);
}
return {
fontFamily: line.value.fontName ? `'${line.value.fontName}'` : "优设标题黑",
fontSize: `${Number(line.value.fontSize) || 80}px`,
color: line.value.fontColor || "#fff",
backgroundColor:
enableBackground.value && line.value.backgroundColor !== "transparent"
? line.value.backgroundColor
: "transparent",
textShadow: shadows.join(", ") || "none",
padding: "8px 16px",
display: "inline-block",
lineHeight: 1.12,
};
});
</script>
<template>
<div class="line-style-editor space-y-4 text-slate-800">
<div>
<div class="mb-2 text-sm font-medium text-slate-700">字体设置</div>
<div class="space-y-3">
<div>
<label class="mb-1 block text-xs text-slate-500">字体</label>
<Select
v-model="line.fontName"
:options="SUBTITLE_FONT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
size="small"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">字体大小像素</label>
<InputNumber v-model="line.fontSize" :min="24" :max="120" class="w-full" size="small" />
</div>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">颜色设置</div>
<div class="flex items-center gap-2">
<input v-model="line.fontColor" type="color" class="h-8 w-10" />
<span class="font-mono text-xs">{{ line.fontColor }}</span>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">描边设置</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">描边宽度</label>
<Slider v-model="line.outlineWidth" :min="0" :max="10" class="flex-1" />
<span class="w-8 text-right text-xs">{{ line.outlineWidth }}</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">描边颜色</label>
<input v-model="line.outlineColor" type="color" class="h-8 w-10" />
</div>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">背景设置</div>
<label class="flex items-center gap-2 text-sm">
<Checkbox v-model="enableBackground" binary />
启用背景
</label>
<div v-if="enableBackground" class="mt-2 grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-xs text-slate-500">背景颜色</label>
<div class="flex items-center gap-2">
<input v-model="line.backgroundColor" type="color" class="h-8 w-10" />
<InputText v-model="line.backgroundColor" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">背景透明度</label>
<div class="flex items-center gap-2">
<Slider v-model="backgroundOpacityPercent" :min="0" :max="100" class="flex-1" />
<span class="w-10 text-right text-xs">{{ backgroundOpacityPercent }}%</span>
</div>
</div>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">阴影设置可选</div>
<label class="mb-2 flex items-center gap-2 text-sm">
<Checkbox v-model="enableShadow" binary />
启用阴影
</label>
<div v-if="enableShadow" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">阴影偏移</label>
<Slider v-model="line.shadowOffset" :min="1" :max="10" class="flex-1" />
<span class="w-8 text-right text-xs">{{ line.shadowOffset }}</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">阴影颜色</label>
<input v-model="line.shadowColor" type="color" class="h-8 w-10" />
<InputText v-model="line.shadowColor" size="small" class="flex-1" />
</div>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">效果预览</div>
<div class="overflow-hidden rounded bg-slate-100 py-4 text-center">
<div :style="previewStyle">
{{ line.text || "标题文字" }}
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,158 +0,0 @@
<script setup>
import { computed, ref, watch } from "vue";
import TitleLineStyleEditor from "./TitleLineStyleEditor.vue";
import { syncTitleLinesFromReference } from "../../utils/subtitleTemplateDraft.js";
const config = defineModel("config", { type: Object, required: true });
const props = defineProps({
referenceTitle: { type: String, default: "" },
});
const titleConfig = computed({
get: () => config.value.titleSubtitleConfig,
set: (v) => {
config.value.titleSubtitleConfig = v;
},
});
const enabled = computed({
get: () => titleConfig.value?.enabled !== false,
set: (v) => {
titleConfig.value.enabled = v;
},
});
const openLineIndex = ref(0);
const displayTitle = computed(
() => props.referenceTitle?.trim() || titleConfig.value?.sourceTitle || "(暂无标题,请先在步骤 04 生成)",
);
function reloadFromReference() {
syncTitleLinesFromReference(
{ config: config.value },
props.referenceTitle || titleConfig.value?.sourceTitle,
);
}
const linePreviews = computed(() => titleConfig.value?.lines || []);
const maxCharMarks = [8, 12, 15, 20];
function markLeft(value) {
return `${((value - 8) / (20 - 8)) * 100}%`;
}
watch(
() => titleConfig.value?.maxCharsPerLine,
() => {
reloadFromReference();
},
);
</script>
<template>
<div class="title-subtitle-settings space-y-4 rounded-lg border border-slate-600/40 bg-slate-800/30 p-4">
<div class="flex items-center gap-3">
<Checkbox v-model="enabled" binary />
<span class="font-medium text-slate-100">标题字幕</span>
</div>
<div v-if="enabled" class="space-y-4">
<div>
<div class="mb-2 text-xs text-slate-400">
当前标题自动读取步骤 04标题标签关键词生成结果
</div>
<div class="flex flex-wrap items-center gap-2 rounded bg-slate-900/50 p-3">
<div class="flex-1 text-sm text-slate-200">{{ displayTitle }}</div>
<Button label="重新加载" size="small" severity="secondary" @click="reloadFromReference" />
</div>
</div>
<div class="space-y-4">
<div class="flex items-center gap-3">
<div class="w-28 shrink-0 text-sm text-slate-300">每行最大字符数</div>
<div class="relative flex-1 pb-5">
<Slider
v-model="titleConfig.maxCharsPerLine"
:min="8"
:max="20"
:step="1"
class="w-full"
/>
<div class="pointer-events-none absolute inset-x-0 top-6 text-xs text-slate-500">
<span
v-for="mark in maxCharMarks"
:key="mark"
class="absolute -translate-x-1/2"
:style="{ left: markLeft(mark) }"
>
{{ mark }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-28 shrink-0 text-sm text-slate-300">行间距%</div>
<InputNumber
v-model="titleConfig.lineSpacingPercent"
:min="3"
:max="15"
class="w-32"
/>
<span class="text-xs text-slate-500">相对于视频高度</span>
</div>
<div class="flex items-center gap-3">
<div class="w-28 shrink-0 text-sm text-slate-300">顶部边距%</div>
<InputNumber
v-model="titleConfig.topMarginPercent"
:min="2"
:max="20"
class="w-32"
/>
<span class="text-xs text-slate-500">相对于视频高度</span>
</div>
</div>
<div v-if="linePreviews.length">
<div class="mb-2 text-xs text-slate-400">分行预览 {{ linePreviews.length }} </div>
<div class="space-y-1">
<div
v-for="(line, i) in linePreviews"
:key="i"
class="rounded bg-slate-900/40 px-2 py-1 text-sm text-slate-300"
>
<span class="text-slate-500">{{ i + 1 }}</span>{{ line.text }}
</div>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-200">每行样式配置</div>
<div class="space-y-2">
<div
v-for="(line, index) in titleConfig.lines"
:key="index"
class="overflow-hidden rounded-lg border border-slate-600/50"
>
<button
type="button"
class="flex w-full items-center justify-between bg-slate-900/50 px-3 py-2 text-left text-sm text-slate-200 hover:bg-slate-900/70"
@click="openLineIndex = openLineIndex === index ? -1 : index"
>
<span>{{ index + 1 }}{{ line.text || "(空)" }}</span>
<i
class="pi text-xs"
:class="openLineIndex === index ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</button>
<div v-show="openLineIndex === index" class="border-t border-slate-600/40 bg-white p-3">
<TitleLineStyleEditor v-model:line="titleConfig.lines[index]" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,119 +0,0 @@
<script setup>
import { ref } from "vue";
const visible = defineModel("visible", { type: Boolean, default: false });
const emit = defineEmits(["submit"]);
const lines = ref([{ text: "" }]);
const pasteVisible = ref(false);
const pasteRaw = ref("");
function addLine() {
lines.value.push({ text: "" });
}
function removeLine(index) {
lines.value.splice(index, 1);
}
function openPaste() {
pasteRaw.value = "";
pasteVisible.value = true;
}
function confirmPaste() {
const items = pasteRaw.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
if (items.length) {
lines.value = items.map((text) => ({ text }));
}
pasteVisible.value = false;
}
function onSubmit() {
const payload = lines.value.map((l) => ({ text: String(l.text || "").trim() }));
if (!payload.length || payload.some((l) => !l.text)) {
window.alert("所有内容不能为空");
return;
}
emit("submit", payload);
visible.value = false;
lines.value = [{ text: "" }];
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
header="批量文本合成"
:style="{ width: '80vw', maxWidth: '960px' }"
:draggable="false"
>
<div class="mb-3 flex flex-wrap items-center gap-2">
<Button label="添加一条" size="small" icon="pi pi-plus" @click="addLine" />
<Button
label="批量粘贴"
size="small"
icon="pi pi-clone"
severity="secondary"
outlined
@click="openPaste"
/>
<span class="text-sm text-slate-500"> {{ lines.length }} </span>
</div>
<div v-if="!lines.length" class="py-12 text-center text-sm text-slate-500">
暂无内容
</div>
<div v-else class="max-h-[50vh] space-y-2 overflow-y-auto pr-1">
<div
v-for="(line, index) in lines"
:key="index"
class="flex gap-2 rounded-lg border border-white/10 bg-white/5 p-3"
>
<Textarea
v-model="line.text"
rows="2"
auto-resize
class="flex-1"
placeholder="输入内容"
:maxlength="1000"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
text
aria-label="删除"
@click="removeLine(index)"
/>
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="visible = false" />
<Button label="提交合成" @click="onSubmit" />
</template>
</Dialog>
<Dialog
v-model:visible="pasteVisible"
modal
header="批量粘贴,每行一条"
:style="{ width: '70vw', maxWidth: '720px' }"
>
<Textarea
v-model="pasteRaw"
rows="12"
class="w-full"
placeholder="批量粘贴,每行一个"
/>
<template #footer>
<Button label="确定" @click="confirmPaste" />
</template>
</Dialog>
</template>

View File

@@ -1,203 +0,0 @@
<script setup>
import { ref, computed, watch, onBeforeUnmount } from "vue";
import { localAudioToPlayableUrl } from "../../services/localAudio.js";
import {
createTempReferencePath,
getAudioDurationSeconds,
formatInvokeError,
importPickedReferenceAudio,
pickReferenceAudioFile,
writeBlobToPath,
} from "../../services/voiceReferenceAudio.js";
const props = defineProps({
modelValue: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue"]);
const referencePath = ref(props.modelValue || "");
const previewSrc = ref("");
const recording = ref(false);
const recordError = ref("");
const durationSec = ref(0);
let mediaStream = null;
let mediaRecorder = null;
let recordChunks = [];
const durationLabel = computed(() => {
if (!durationSec.value) return "";
return `${durationSec.value.toFixed(1)}`;
});
watch(
() => props.modelValue,
async (path) => {
referencePath.value = path || "";
await refreshPreview();
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopRecording();
});
async function refreshPreview() {
const p = referencePath.value;
if (!p) {
previewSrc.value = "";
durationSec.value = 0;
return;
}
try {
previewSrc.value = await localAudioToPlayableUrl(p);
durationSec.value = await getAudioDurationSeconds(p);
} catch {
previewSrc.value = "";
durationSec.value = 0;
}
}
function setPath(path) {
referencePath.value = path || "";
emit("update:modelValue", referencePath.value);
}
async function onPickFile() {
recordError.value = "";
try {
const picked = await pickReferenceAudioFile();
if (!picked) return;
const path = await importPickedReferenceAudio(picked);
setPath(path);
await refreshPreview();
if (!durationSec.value) {
recordError.value = "无法读取音频时长,请换用 wav/mp3 文件重试";
}
} catch (err) {
recordError.value = formatInvokeError(err, "导入参考音频失败");
}
}
async function startRecording() {
recordError.value = "";
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
recordChunks = [];
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm";
mediaRecorder = new MediaRecorder(mediaStream, { mimeType });
mediaRecorder.ondataavailable = (e) => {
if (e.data?.size) recordChunks.push(e.data);
};
mediaRecorder.onstop = async () => {
try {
const blob = new Blob(recordChunks, { type: mimeType });
const ext = mimeType.includes("webm") ? "webm" : "wav";
const tempPath = await createTempReferencePath(ext);
await writeBlobToPath(tempPath, blob);
setPath(tempPath);
await refreshPreview();
} catch (err) {
recordError.value =
err instanceof Error ? err.message : "保存录音失败";
}
};
mediaRecorder.start();
recording.value = true;
} catch (err) {
recordError.value =
err instanceof Error ? err.message : "无法访问麦克风,请检查权限";
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
}
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop());
mediaStream = null;
}
mediaRecorder = null;
recording.value = false;
}
function toggleRecord() {
if (recording.value) stopRecording();
else startRecording();
}
/**
* @returns {Promise<string>}
*/
async function exportReferencePath() {
return referencePath.value;
}
async function getDurationSeconds() {
const p = referencePath.value;
if (!p) return 0;
if (durationSec.value > 0) return durationSec.value;
try {
durationSec.value = await getAudioDurationSeconds(p);
} catch {
durationSec.value = 0;
}
return durationSec.value;
}
defineExpose({
exportReferencePath,
getDurationSeconds,
refreshPreview,
});
</script>
<template>
<div class="voice-reference-recorder space-y-3">
<Message severity="info" :closable="false" class="text-sm">
参考声音控制在 620s保证声音清晰可见
</Message>
<div
class="rounded-lg border border-blue-500/40 bg-slate-900/40 p-3"
>
<div class="flex flex-wrap items-center gap-3">
<Button
:label="recording ? '停止录音' : '开始录音'"
size="small"
:severity="recording ? 'danger' : 'secondary'"
:icon="recording ? 'pi pi-stop-circle' : 'pi pi-circle'"
@click="toggleRecord"
/>
<span v-if="durationLabel" class="text-xs text-slate-400">
时长 {{ durationLabel }}
</span>
</div>
<div v-if="previewSrc" class="mt-3">
<audio :src="previewSrc" controls class="h-9 w-full" />
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 text-sm text-slate-400">
<span class="flex items-center gap-1">
<i class="pi pi-info-circle text-xs" />
支持 wav/mp3 格式
</span>
<Button
label="选择声音文件"
size="small"
severity="secondary"
icon="pi pi-upload"
@click="onPickFile"
/>
</div>
<p v-if="recordError" class="text-xs text-red-400">{{ recordError }}</p>
</div>
</template>

View File

@@ -1,108 +0,0 @@
<script setup>
import { ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
const workflow = useWorkflowStore();
const {
ipBrainModalVisible,
ipBrainProfileUrl,
ipBrainCollecting,
ipBrainCollectProgress,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
const tipItems = [
"输入账号主页或分享链接后点击「开始采集」按钮",
"系统会自动打开浏览器并抓取数据,请保持网络连接正常",
"无需任何手动操作,等待采集完成即可",
"采集完成后会自动将最多 6 个视频标题添加到选题库中",
];
function onCancel() {
if (ipBrainCollecting.value) return;
feedback.value = { severity: "", message: "" };
workflow.closeIpBrainDialog();
}
async function onStart() {
feedback.value = { severity: "", message: "" };
const result = await workflow.startIpBrainCollect();
if (result.ok) {
feedback.value = { severity: "success", message: result.message };
} else {
feedback.value = { severity: "error", message: result.message };
}
}
</script>
<template>
<Dialog
v-model:visible="ipBrainModalVisible"
modal
header="添加IP大脑"
:style="{ width: '600px' }"
:draggable="false"
class="ip-brain-dialog"
:closable="!ipBrainCollecting"
>
<div class="space-y-4">
<Message
v-if="feedback.message && !ipBrainCollecting"
:severity="feedback.severity"
:closable="false"
class="w-full"
>
{{ feedback.message }}
</Message>
<Message
v-if="ipBrainCollecting && ipBrainCollectProgress"
severity="info"
:closable="false"
class="w-full"
>
{{ ipBrainCollectProgress }}
</Message>
<div>
<label class="mb-2 block font-medium text-slate-200">
账号主页或分享链接
</label>
<InputText
v-model="ipBrainProfileUrl"
type="text"
size="large"
class="w-full"
placeholder="请输入抖音账号主页或用户分享链接例如https://www.douyin.com/user/..."
:disabled="ipBrainCollecting"
@keyup.enter="onStart"
/>
<p class="mt-2 text-xs text-slate-500">
当前 IP 学习支持抖音账号主页用户分享链接和短链接程序会自动识别并采集最新
6 个视频标题
</p>
</div>
<div class="elegant-tip-box rounded-lg p-3">
<div class="text-sm text-slate-300">
<div class="mb-1 font-medium">提示:</div>
<ul class="list-inside list-disc space-y-1">
<li v-for="(tip, i) in tipItems" :key="i">{{ tip }}</li>
</ul>
</div>
</div>
</div>
<template #footer>
<Button
label="取消"
severity="secondary"
:disabled="ipBrainCollecting"
@click="onCancel"
/>
<Button label="开始采集" :loading="ipBrainCollecting" @click="onStart" />
</template>
</Dialog>
</template>

View File

@@ -1,65 +0,0 @@
<script setup>
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
const emit = defineEmits(["feedback"]);
const workflow = useWorkflowStore();
const {
hitScriptModalVisible,
hitScriptOptions,
hitScriptSelectedIndex,
} = storeToRefs(workflow);
function onClose() {
workflow.closeHitScriptModal();
}
function onSelect(index) {
hitScriptSelectedIndex.value = index;
}
function onApply() {
emit("feedback", workflow.applyHitScriptOption(hitScriptSelectedIndex.value));
}
</script>
<template>
<Dialog
v-model:visible="hitScriptModalVisible"
modal
header="选择爆款文案方案"
:style="{ width: '640px' }"
:draggable="false"
class="hit-script-dialog"
@hide="onClose"
>
<p class="mb-3 text-sm text-slate-400">
已生成 {{ hitScriptOptions.length }} 个方案点击选择后填入视频文案编辑
</p>
<ul class="max-h-[60vh] space-y-2 overflow-y-auto">
<li
v-for="(option, index) in hitScriptOptions"
:key="index"
class="cursor-pointer rounded-lg border px-3 py-3 text-sm transition-colors"
:class="
hitScriptSelectedIndex === index
? 'border-blue-400/50 bg-blue-500/15 text-slate-100 ring-1 ring-blue-400/40'
: 'border-white/10 bg-white/5 text-slate-300 hover:bg-white/10'
"
@click="onSelect(index)"
>
<div class="mb-1 text-xs font-medium text-blue-400">方案 {{ index + 1 }}</div>
<p class="whitespace-pre-wrap leading-relaxed">{{ option }}</p>
</li>
</ul>
<template #footer>
<Button label="取消" severity="secondary" @click="onClose" />
<Button
label="使用此文案"
:disabled="!hitScriptOptions.length"
@click="onApply"
/>
</template>
</Dialog>
</template>

View File

@@ -1,150 +0,0 @@
<script setup>
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
const emit = defineEmits(["feedback"]);
const workflow = useWorkflowStore();
const { legalModalVisible, legalChecking, legalReport } = storeToRefs(workflow);
function onClose() {
workflow.closeLegalModal();
}
function onApplyFix() {
emit("feedback", workflow.applyLegalFix());
}
</script>
<template>
<Dialog
v-model:visible="legalModalVisible"
modal
header="AI 法务审核报告"
:style="{ width: '600px' }"
:draggable="false"
class="legal-check-dialog"
>
<div v-if="legalChecking && !legalReport" class="py-10 text-center">
<i class="pi pi-spin pi-spinner text-4xl text-blue-400" />
<p class="mt-4 text-sm font-medium text-slate-200">AI 正在进行法务审核...</p>
<p class="mt-1 text-xs text-slate-500">检查违禁词敏感词极限词等法律风险</p>
</div>
<div v-else-if="legalReport" class="space-y-4">
<div
class="flex items-center gap-3 rounded-lg border p-3"
:class="
legalReport.hasViolations
? 'border-amber-700/40 bg-amber-900/20'
: 'border-emerald-700/40 bg-emerald-900/20'
"
>
<i
class="pi text-2xl"
:class="
legalReport.hasViolations
? 'pi-exclamation-triangle text-amber-400'
: 'pi-check-circle text-emerald-400'
"
/>
<div>
<div
class="text-base font-semibold"
:class="legalReport.hasViolations ? 'text-amber-300' : 'text-emerald-300'"
>
{{ legalReport.hasViolations ? "发现风险" : "内容合规" }}
</div>
<div
class="text-sm"
:class="legalReport.hasViolations ? 'text-amber-400/80' : 'text-emerald-400/80'"
>
{{
legalReport.hasViolations
? `共发现 ${legalReport.totalCount} 处风险`
: "文案内容符合规范,可以发布"
}}
</div>
</div>
</div>
<template v-if="legalReport.hasViolations">
<div>
<div class="mb-2 flex items-center gap-2 text-sm text-slate-400">
<i class="pi pi-file" />
<span>原文案分析</span>
</div>
<div
class="legal-highlight-box max-h-36 overflow-y-auto rounded-lg border border-[#2a2a3a] bg-[#0d0d1a] p-3 text-sm leading-relaxed text-slate-300"
v-html="legalReport.highlightedText"
/>
</div>
<div v-if="legalReport.fixedText">
<div class="mb-2 flex items-center gap-2 text-sm text-emerald-400">
<i class="pi pi-pencil" />
<span>优化后文案</span>
</div>
<div
class="rounded-lg border border-emerald-800/40 bg-emerald-950/30 p-3 text-sm leading-relaxed text-slate-200 whitespace-pre-wrap"
>
{{ legalReport.fixedText }}
</div>
</div>
<div v-if="legalReport.analysis">
<div class="mb-2 flex items-center gap-2 text-sm text-blue-400">
<i class="pi pi-lightbulb" />
<span>AI 审核解读</span>
</div>
<div
class="rounded-lg border border-[#2a2a3a] bg-[#12121f] p-3 text-sm leading-relaxed text-slate-300 whitespace-pre-wrap"
>
{{ legalReport.analysis }}
</div>
</div>
<div v-if="legalReport.risks?.length">
<div class="mb-2 flex items-center gap-2 text-sm text-red-400">
<i class="pi pi-list" />
<span>风险详情</span>
</div>
<div class="max-h-48 space-y-2 overflow-y-auto">
<div
v-for="(risk, idx) in legalReport.risks"
:key="idx"
class="rounded p-2 text-sm hover:bg-[#1a1a2e]"
>
<span class="font-medium text-red-300">{{ risk.word }}</span>
<div class="mt-1 text-slate-400">
<span class="text-slate-600"></span>
{{ risk.recommendation ? risk.recommendation : "建议删除" }}
</div>
<div v-if="risk.reason" class="mt-1 text-xs text-slate-500">
{{ risk.reason }}
</div>
</div>
</div>
</div>
</template>
</div>
<template #footer>
<Button label="关闭" severity="secondary" @click="onClose" />
<Button
v-if="legalReport?.hasViolations"
label="采用优化文案"
@click="onApplyFix"
/>
</template>
</Dialog>
</template>
<style scoped>
.legal-highlight-box :deep(.legal-hit) {
background: rgb(254 202 202);
color: rgb(153 27 27);
padding: 0 0.25rem;
border-radius: 0.25rem;
}
</style>

View File

@@ -1,343 +0,0 @@
<script setup>
import { ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import AddIpBrainDialog from "./AddIpBrainDialog.vue";
import VideoLinkDialog from "./VideoLinkDialog.vue";
import HitScriptDialog from "./HitScriptDialog.vue";
const workflow = useWorkflowStore();
const {
ipMode,
videoType,
copyType,
industryPersona,
productBusiness,
sellingPrice,
otherRequirements,
targetWords,
recognizedContent,
ipBenchmarks,
ipBrainSelectedBenchmarkId,
ipTopics,
ipBrainSelectedTopicId,
ipTopicsRewriting,
ipTopicRewriteProgress,
rewrittenTopics,
hitScriptGenerating,
} = storeToRefs(workflow);
const topicFeedback = ref({ severity: "", message: "" });
const hitScriptFeedback = ref({ severity: "", message: "" });
function onSelectBenchmark(id) {
topicFeedback.value = { severity: "", message: "" };
workflow.selectBenchmark(id);
}
function onSelectRewrittenTopic(topicId) {
workflow.selectRewrittenTopic(topicId);
}
async function onRefreshRewritten() {
topicFeedback.value = { severity: "", message: "" };
const result = await workflow.rewriteIpTopics();
if (result.ok) {
topicFeedback.value = { severity: "success", message: result.message };
} else {
topicFeedback.value = { severity: "error", message: result.message };
}
}
function onRemoveBenchmark(id, event) {
event?.stopPropagation?.();
workflow.removeBenchmark(id);
}
function onOpenVideoLink() {
workflow.videoLinkModalVisible = true;
}
function onOpenIpBrain() {
if (workflow.ipBenchmarkCount >= 5) return;
workflow.ipBrainModalVisible = true;
}
function topicCount(benchmark) {
return benchmark?.topicLibrary?.length ?? 0;
}
async function onGenerateHitScript() {
hitScriptFeedback.value = { severity: "", message: "" };
const result = await workflow.generateHitScript();
if (result.message) {
hitScriptFeedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
}
function onHitScriptFeedback(result) {
if (!result?.message) return;
hitScriptFeedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
</script>
<template>
<DashboardCard title="IP深度学习" step="01">
<SelectButton
v-model="ipMode"
:options="workflow.ipModes"
option-label="label"
option-value="value"
class="mb-3 w-full"
size="small"
/>
<template v-if="ipMode === 'ipBrain'">
<Button
label="添加IP大脑"
class="w-full"
size="small"
:disabled="workflow.ipBenchmarkCount >= 5"
@click="onOpenIpBrain"
/>
<p v-if="workflow.ipBenchmarkCount >= 5" class="mt-1 text-center text-xs text-amber-400">
已达上限5/5
</p>
<div class="mt-3">
<div class="dashboard-field-label">
已学习到的对标
<span class="dashboard-field-label--muted text-xs">
({{ workflow.ipBenchmarkCount }}/5)
</span>
</div>
<p v-if="ipBenchmarks.length === 0" class="dashboard-muted py-2">暂无对标</p>
<div v-else class="space-y-1 py-1">
<div
v-for="item in ipBenchmarks"
:key="item.id"
class="benchmark-row flex items-center justify-between gap-2"
>
<button
type="button"
class="benchmark-item min-w-0 flex-1"
:class="{ 'benchmark-item--active': ipBrainSelectedBenchmarkId === item.id }"
@click="onSelectBenchmark(item.id)"
>
<span class="truncate">{{ item.label }}</span>
<span class="ml-1 shrink-0 text-xs opacity-70">
({{ topicCount(item) }}/6)
</span>
</button>
<Button
label="删除"
size="small"
severity="danger"
text
class="shrink-0"
@click="onRemoveBenchmark(item.id, $event)"
/>
</div>
</div>
</div>
<div class="mt-3">
<div class="dashboard-field-label mb-2">选题库</div>
<p
v-if="!ipBrainSelectedBenchmarkId"
class="dashboard-muted py-2 text-center text-sm"
>
请先选择一个对标
</p>
<p v-else-if="ipTopics.length === 0" class="dashboard-muted py-2 text-sm">
采集完成后将显示视频标题
</p>
<ul v-else class="space-y-1 py-1">
<li
v-for="(topic, index) in ipTopics"
:key="topic.id"
class="rounded-lg border border-white/5 bg-white/5 px-2 py-2 text-sm text-slate-400"
>
{{ index + 1 }}. {{ topic.original || topic.title }}
</li>
</ul>
</div>
<div class="mt-3">
<div class="mb-2 flex items-center justify-between gap-2">
<div class="dashboard-field-label">仿写后</div>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
:loading="ipTopicsRewriting"
:disabled="!ipBrainSelectedBenchmarkId || ipTopics.length === 0 || ipTopicsRewriting"
aria-label="刷新仿写标题"
@click="onRefreshRewritten"
/>
</div>
<p v-if="ipTopicRewriteProgress" class="mb-2 text-xs text-blue-400">
{{ ipTopicRewriteProgress }}
</p>
<p
v-if="!ipBrainSelectedBenchmarkId"
class="dashboard-muted py-2 text-center text-sm"
>
请先选择一个对标
</p>
<p
v-else-if="rewrittenTopics.length === 0"
class="dashboard-muted py-2 text-sm"
>
点击右侧刷新按钮根据选题库一键生成仿写标题
</p>
<ul v-else class="space-y-1 py-1">
<li
v-for="(topic, index) in rewrittenTopics"
:key="topic.id"
class="topic-item cursor-pointer rounded-lg border px-2 py-2 text-sm transition-colors"
:class="
ipBrainSelectedTopicId === topic.id
? 'border-blue-400/50 bg-blue-500/20 text-slate-100 ring-1 ring-blue-400/40'
: 'border-white/5 bg-white/5 text-slate-300 hover:bg-white/10'
"
@click="onSelectRewrittenTopic(topic.id)"
>
{{ index + 1 }}. {{ topic.rewritten }}
</li>
</ul>
<p
v-if="topicFeedback.message"
class="mt-2 text-sm"
:class="topicFeedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ topicFeedback.message }}
</p>
</div>
<AddIpBrainDialog />
</template>
<template v-else-if="ipMode === 'videoWrite'">
<Button
label="打开视频链接"
class="w-full"
size="small"
@click="onOpenVideoLink"
/>
<div class="mt-3 space-y-1">
<div class="dashboard-field-label flex items-center gap-1">
<span>识别的原始内容</span>
<span class="text-xs font-normal text-slate-500">(使用视频仿写功能后显示)</span>
</div>
<Textarea
v-model="recognizedContent"
readonly
placeholder="使用【打开视频链接】功能后,识别的原始内容将显示在这里..."
class="dashboard-textarea-mono w-full"
rows="10"
/>
</div>
<VideoLinkDialog />
</template>
<template v-else>
<div class="space-y-2.5">
<div>
<div class="dashboard-field-label">
视频类型 <span class="text-xs text-red-400">*</span>
</div>
<Select v-model="videoType" :options="workflow.videoTypes" size="small" class="w-full" />
</div>
<div>
<div class="dashboard-field-label">
文案类型 <span class="text-xs text-red-400">*</span>
</div>
<Select v-model="copyType" :options="workflow.copyTypes" size="small" class="w-full" />
</div>
<div>
<div class="dashboard-field-label">
行业+人设 <span class="text-xs text-slate-400">可选</span>
</div>
<InputText
v-model="industryPersona"
placeholder="例如餐饮店我叫斌哥在上海有10年餐饮经验"
size="small"
class="w-full"
/>
</div>
<div>
<div class="dashboard-field-label">
产品/业务 <span class="text-xs text-slate-400">可选</span>
</div>
<InputText
v-model="productBusiness"
placeholder="例如:纸巾,麻辣烫,房产,教培,财务..."
size="small"
class="w-full"
/>
</div>
<div>
<div class="dashboard-field-label">
卖点+价格 <span class="text-xs text-slate-400">可选</span>
</div>
<InputText
v-model="sellingPrice"
placeholder="例如纸张柔软亲肤正常价99今天只要59元"
size="small"
class="w-full"
/>
</div>
<div>
<div class="dashboard-field-label">
其他要求 <span class="text-xs text-slate-400">可选</span>
</div>
<Textarea
v-model="otherRequirements"
placeholder="例如风格幽默突出性价比适合30-50岁人群..."
rows="3"
class="w-full"
/>
</div>
<div>
<div class="dashboard-field-label">目标字数</div>
<div class="flex items-center gap-2">
<Slider v-model="targetWords" :min="50" :max="1500" class="flex-1" />
<InputNumber
v-model="targetWords"
:min="50"
:max="1500"
size="small"
class="w-12 shrink-0"
input-class="text-center"
/>
</div>
</div>
<Button
label="生成爆款文案"
size="small"
class="w-full"
:loading="hitScriptGenerating"
:disabled="hitScriptGenerating"
@click="onGenerateHitScript"
/>
<p
v-if="hitScriptFeedback.message"
class="text-sm"
:class="
hitScriptFeedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'
"
>
{{ hitScriptFeedback.message }}
</p>
</div>
<HitScriptDialog @feedback="onHitScriptFeedback" />
</template>
</DashboardCard>
</template>

View File

@@ -1,283 +0,0 @@
<script setup>
import { computed, ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import { useAvatarStore } from "../../stores/avatar.js";
import VoiceManageDialog from "./VoiceManageDialog.vue";
import { revealLocalFileInFolder } from "../../services/fileExport.js";
const workflow = useWorkflowStore();
const avatarStore = useAvatarStore();
const {
voiceEmotion,
speechRate,
voiceLanguage,
avatarSelect,
speechGenerating,
generatedAudioSrc,
generatedAudioPath,
currentAudioId,
audioHistory,
videoGenerating,
generatedVideoSrc,
generatedVideoPath,
currentVideoId,
videoHistory,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
const voiceButtonLabel = computed(() => workflow.selectedVoiceLabel);
const audioHistoryOptions = computed(() =>
audioHistory.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const hasAudio = computed(() => Boolean(generatedAudioSrc.value));
const videoHistoryOptions = computed(() =>
videoHistory.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const hasVideo = computed(() => Boolean(generatedVideoSrc.value));
const canGenerateVideo = computed(
() => hasAudio.value && Boolean(workflow.avatarSelect) && !videoGenerating.value,
);
function onOpenVoiceManage() {
workflow.openVoiceManageDialog();
}
async function onGenerateSpeech() {
feedback.value = { severity: "", message: "" };
const result = await workflow.generateSpeech();
if (result?.message) {
feedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
}
async function onPickAudioHistory() {
if (!currentAudioId.value) return;
await workflow.pickAudioHistory(currentAudioId.value);
}
async function onOpenAudioInExplorer() {
if (!generatedAudioPath.value) {
feedback.value = { severity: "error", message: "暂无音频文件" };
return;
}
try {
await revealLocalFileInFolder(generatedAudioPath.value);
} catch (err) {
feedback.value = {
severity: "error",
message: err instanceof Error ? err.message : String(err),
};
}
}
async function onGenerateTalkingVideo() {
feedback.value = { severity: "", message: "" };
const result = await workflow.generateTalkingVideo();
if (result?.message) {
feedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
}
async function onPickVideoHistory() {
if (!currentVideoId.value) return;
await workflow.pickVideoHistory(currentVideoId.value);
}
async function onOpenVideoInExplorer() {
if (!generatedVideoPath.value) {
feedback.value = { severity: "error", message: "暂无视频文件" };
return;
}
try {
await revealLocalFileInFolder(generatedVideoPath.value);
} catch (err) {
feedback.value = {
severity: "error",
message: err instanceof Error ? err.message : String(err),
};
}
}
onMounted(() => {
avatarStore.refresh();
workflow.refreshAudioHistory();
workflow.refreshVideoHistory();
});
</script>
<template>
<DashboardCard title="音视频生成" step="02">
<div class="grid grid-cols-3 gap-2">
<div>
<div class="mb-1 text-xs text-slate-200">音色</div>
<Button
:label="voiceButtonLabel"
size="small"
severity="secondary"
outlined
class="w-full truncate"
:title="voiceButtonLabel"
@click="onOpenVoiceManage"
/>
</div>
<div>
<div class="mb-1 text-xs text-slate-200">情绪</div>
<Select
v-model="voiceEmotion"
:options="workflow.voiceEmotions"
size="small"
class="w-full"
/>
</div>
<div>
<div class="mb-1 text-xs text-slate-200">语速</div>
<InputNumber
v-model="speechRate"
:min="0.5"
:max="2"
:step="0.1"
size="small"
class="w-full"
/>
</div>
</div>
<div class="mt-3 flex items-end gap-2">
<div class="min-w-0 flex-1">
<div class="mb-1 text-xs text-slate-200">语言</div>
<Select
v-model="voiceLanguage"
:options="workflow.languages"
size="small"
class="w-full"
/>
</div>
<Button
label="生成语音"
size="small"
class="shrink-0"
:loading="speechGenerating"
:disabled="speechGenerating"
@click="onGenerateSpeech"
/>
</div>
<p
v-if="feedback.message"
class="mt-2 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div class="dashboard-preview-box mt-3">
<div class="mb-2 flex items-center justify-between gap-2">
<div class="flex items-center gap-0.5">
<span class="text-xs text-slate-400">音频预览</span>
<Button
icon="pi pi-folder-open"
size="small"
severity="secondary"
text
:disabled="!generatedAudioPath"
aria-label="在资源管理器中打开"
@click="onOpenAudioInExplorer"
/>
</div>
<Select
v-if="audioHistoryOptions.length"
v-model="currentAudioId"
:options="audioHistoryOptions"
option-label="label"
option-value="value"
placeholder="历史记录"
size="small"
class="w-48"
@update:model-value="onPickAudioHistory"
/>
<Select v-else placeholder="暂无历史记录" disabled size="small" class="w-40" />
</div>
<div v-if="hasAudio" class="px-1 py-2">
<audio :src="generatedAudioSrc" controls class="h-9 w-full" />
</div>
<p v-else class="py-4 text-center text-xs text-gray-500">
暂无音频请填写文案后点击生成语音
</p>
</div>
<div class="mt-3">
<div class="mb-2 text-xs text-slate-400">选择形象</div>
<Select
v-model="avatarSelect"
:options="avatarStore.selectOptions"
option-label="label"
option-value="value"
placeholder="请选择形象"
size="small"
class="w-full"
/>
</div>
<Button
label="生成口播视频"
size="small"
class="mt-3 w-full"
:loading="videoGenerating"
:disabled="!canGenerateVideo"
@click="onGenerateTalkingVideo"
/>
<div class="mt-2">
<div class="mb-2 flex items-center justify-between gap-2">
<div class="flex items-center gap-0.5">
<span class="text-xs text-slate-400">视频预览</span>
<Button
icon="pi pi-folder-open"
size="small"
severity="secondary"
text
:disabled="!generatedVideoPath"
aria-label="在资源管理器中打开"
@click="onOpenVideoInExplorer"
/>
</div>
<Select
v-if="videoHistoryOptions.length"
v-model="currentVideoId"
:options="videoHistoryOptions"
option-label="label"
option-value="value"
placeholder="历史记录"
size="small"
class="w-48"
@update:model-value="onPickVideoHistory"
/>
<Select v-else placeholder="暂无历史记录" disabled size="small" class="w-40" />
</div>
<div class="dashboard-aspect-video">
<video
v-if="hasVideo"
:src="generatedVideoSrc"
controls
class="h-full w-full rounded object-contain"
/>
<span v-else class="text-xs text-gray-400">暂无视频预览</span>
</div>
</div>
<VoiceManageDialog />
</DashboardCard>
</template>

View File

@@ -1,164 +0,0 @@
<script setup>
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import { revealLocalFileInFolder } from "../../services/fileExport.js";
const workflow = useWorkflowStore();
const {
pipInPicture,
autoCutBreath,
greenScreen,
videoProcessing,
greenScreenBackgroundPath,
generatedVideoSrc,
generatedVideoPath,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
const canProcess = computed(
() =>
hasSourceVideo.value &&
!videoProcessing.value &&
(autoCutBreath.value || pipInPicture.value || greenScreen.value),
);
const greenScreenFileName = computed(() => {
const p = greenScreenBackgroundPath.value;
if (!p) return "";
return p.split(/[/\\]/).pop() || p;
});
function showFeedback(result) {
if (!result?.message) return;
feedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
async function onAutoProcess() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.autoProcessVideo());
}
async function onPickGreenScreen() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.chooseGreenScreenBackground());
}
async function onPickPipMaterial() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.choosePipMaterial());
}
async function onOpenVideoInExplorer() {
if (!generatedVideoPath.value) {
feedback.value = { severity: "error", message: "暂无视频文件" };
return;
}
try {
await revealLocalFileInFolder(generatedVideoPath.value);
} catch (err) {
feedback.value = {
severity: "error",
message: err instanceof Error ? err.message : String(err),
};
}
}
</script>
<template>
<DashboardCard title="视频编辑" step="03" :grow="true">
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2 text-sm text-slate-200">
<Checkbox v-model="pipInPicture" binary />
画中画
</label>
<Button
v-if="pipInPicture"
label="选择画中画素材"
size="small"
severity="secondary"
outlined
class="w-full"
:disabled="videoProcessing"
@click="onPickPipMaterial"
/>
<label class="flex items-center gap-2 text-sm text-slate-200">
<Checkbox v-model="autoCutBreath" binary />
自动剪气口
</label>
<label class="flex items-center gap-2 text-sm text-slate-200">
<Checkbox v-model="greenScreen" binary />
启动绿幕切换
</label>
<div v-if="greenScreen" class="flex flex-col gap-1 pl-6">
<Button
:label="greenScreenFileName ? '重新选择背景图' : '上传替换图片'"
size="small"
severity="secondary"
outlined
class="w-full"
:disabled="videoProcessing"
@click="onPickGreenScreen"
/>
<p v-if="greenScreenFileName" class="truncate text-xs text-slate-400">
{{ greenScreenFileName }}
</p>
</div>
<Button
label="自动处理"
size="small"
class="w-full"
:loading="videoProcessing"
:disabled="!canProcess"
@click="onAutoProcess"
/>
</div>
<p
v-if="!hasSourceVideo"
class="mt-2 text-xs text-amber-400/90"
>
请先在步骤 02 生成口播视频
</p>
<p
v-if="feedback.message"
class="mt-2 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div class="dashboard-preview-box mt-3">
<div class="mb-2 flex items-center gap-0.5">
<span class="text-xs text-slate-400">编辑预览</span>
<Button
icon="pi pi-folder-open"
size="small"
severity="secondary"
text
:disabled="!generatedVideoPath"
aria-label="在资源管理器中打开"
@click="onOpenVideoInExplorer"
/>
</div>
<div class="dashboard-aspect-video">
<video
v-if="generatedVideoSrc"
:src="generatedVideoSrc"
controls
class="h-full w-full rounded object-contain"
/>
<span v-else class="text-xs text-gray-400">处理后将在此预览</span>
</div>
</div>
</DashboardCard>
</template>

View File

@@ -1,141 +0,0 @@
<script setup>
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
const workflow = useWorkflowStore();
const {
titleGenerated,
tagsGenerated,
keywordTab,
keywordsFocus,
keywordsDescribe,
keywordsAction,
keywordsEmotion,
titleTagsGenerating,
scriptContent,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
const canGenerate = computed(
() => Boolean(scriptContent.value?.trim()) && !titleTagsGenerating.value,
);
function showFeedback(result) {
if (!result?.message) return;
feedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
async function onGenerateTitleTags() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.generateTitleTags());
}
</script>
<template>
<DashboardCard title="标题标签关键词" step="04">
<Button
label="生成标题标签关键词"
size="small"
class="mb-3 w-full"
:loading="titleTagsGenerating"
:disabled="!canGenerate"
@click="onGenerateTitleTags"
/>
<p
v-if="!scriptContent?.trim()"
class="mb-2 text-xs text-amber-400/90"
>
请先在步骤 01 填写或生成视频文案
</p>
<p
v-if="feedback.message"
class="mb-2 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div class="mb-3">
<div class="dashboard-field-label mb-1">生成的标题可编辑</div>
<Textarea
v-model="titleGenerated"
placeholder="标题将显示在这里..."
rows="2"
class="w-full"
/>
</div>
<div class="mb-3">
<div class="dashboard-field-label mb-1">生成的标签可编辑</div>
<Textarea
v-model="tagsGenerated"
placeholder="标签将显示在这里,逗号分隔..."
rows="2"
class="w-full"
/>
</div>
<div>
<div class="dashboard-field-label mb-2">生成的关键词</div>
<Tabs v-model:value="keywordTab">
<TabList>
<Tab value="0">重点词/成语词</Tab>
<Tab value="1">描述词</Tab>
<Tab value="2">行动词</Tab>
<Tab value="3">情感词</Tab>
</TabList>
<TabPanels>
<TabPanel value="0">
<Textarea
v-model="keywordsFocus"
placeholder="请输入重点词/成语词,每行一个..."
rows="8"
class="dashboard-textarea-mono w-full"
/>
<p class="dashboard-muted mt-2 text-sm">
已添加 {{ workflow.keywordCountFocus }} 个关键词
</p>
</TabPanel>
<TabPanel value="1">
<Textarea
v-model="keywordsDescribe"
placeholder="请输入描述词,每行一个..."
rows="8"
class="dashboard-textarea-mono w-full"
/>
<p class="dashboard-muted mt-2 text-sm">
已添加 {{ workflow.keywordCountDescribe }} 个关键词
</p>
</TabPanel>
<TabPanel value="2">
<Textarea
v-model="keywordsAction"
placeholder="请输入行动词,每行一个..."
rows="8"
class="dashboard-textarea-mono w-full"
/>
<p class="dashboard-muted mt-2 text-sm">
已添加 {{ workflow.keywordCountAction }} 个关键词
</p>
</TabPanel>
<TabPanel value="3">
<Textarea
v-model="keywordsEmotion"
placeholder="请输入情感词,每行一个..."
rows="8"
class="dashboard-textarea-mono w-full"
/>
<p class="dashboard-muted mt-2 text-sm">
已添加 {{ workflow.keywordCountEmotion }} 个关键词
</p>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</DashboardCard>
</template>

View File

@@ -1,211 +0,0 @@
<script setup>
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import SubtitleTemplateDialog from "../subtitle/SubtitleTemplateDialog.vue";
import { revealLocalFileInFolder } from "../../services/fileExport.js";
const workflow = useWorkflowStore();
const {
autoSubtitle,
smartSubtitle,
bgmEnabled,
bgmVolume,
subtitleTemplateId,
subtitleBgmGenerating,
generatedVideoPath,
subtitlePreviewVideoPath,
subtitlePreviewVideoSrc,
bgmPreviewSrc,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
const templateDialogVisible = ref(false);
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
const hasSubtitleVideoPreview = computed(() =>
Boolean(subtitlePreviewVideoSrc.value),
);
const canGenerate = computed(
() =>
hasSourceVideo.value &&
!subtitleBgmGenerating.value &&
(autoSubtitle.value || bgmEnabled.value) &&
(!bgmEnabled.value || Boolean(workflow.bgmPath)),
);
function showFeedback(result) {
if (!result?.message) return;
feedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
function onSelectTemplate(template) {
workflow.subtitleTemplateId = template.id;
workflow.subtitleTemplate = template;
}
async function onGenerate() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.generateSubtitleAndBgm());
}
async function onPickBgm() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.chooseBgm());
}
async function onPreviewBgm() {
feedback.value = { severity: "", message: "" };
const result = await workflow.previewBgm();
if (result?.message) showFeedback(result);
}
async function onOpenVideoInExplorer() {
if (!subtitlePreviewVideoPath.value) {
feedback.value = { severity: "error", message: "暂无字幕预览视频" };
return;
}
try {
await revealLocalFileInFolder(subtitlePreviewVideoPath.value);
} catch (err) {
feedback.value = {
severity: "error",
message: err instanceof Error ? err.message : String(err),
};
}
}
</script>
<template>
<DashboardCard title="字幕和音乐" step="05" :grow="true">
<label class="flex items-center gap-2 text-sm text-slate-200">
<Checkbox v-model="autoSubtitle" binary />
自动生成字幕
</label>
<p class="dashboard-muted mt-1 pl-6 text-xs">
提取视频音频并识别烧录 SRT 字幕到画面
</p>
<label class="mt-2 flex items-center gap-2 text-sm text-slate-200">
<Checkbox v-model="smartSubtitle" binary :disabled="!autoSubtitle" />
启用智能字幕
</label>
<p class="dashboard-muted mt-1 pl-6 text-xs">
用步骤 01 文案替换识别文本保留语音时间轴
</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<Button
label="模板选择"
size="small"
outlined
icon="pi pi-palette"
:disabled="!autoSubtitle"
@click="templateDialogVisible = true"
/>
<span class="dashboard-muted truncate text-sm">
已选模板: {{ workflow.subtitleTemplateLabel }}
</span>
</div>
<label class="mt-3 flex items-center gap-2 text-sm text-slate-200">
<Checkbox v-model="bgmEnabled" binary />
添加背景音乐
</label>
<div class="mt-2 flex flex-wrap items-center gap-2">
<InputNumber
v-model="bgmVolume"
:min="0"
:max="100"
:disabled="!bgmEnabled"
size="small"
class="w-20"
/>
<span class="text-sm text-slate-400">%</span>
<Button
label="选择音乐"
size="small"
severity="secondary"
:disabled="!bgmEnabled || subtitleBgmGenerating"
@click="onPickBgm"
/>
<Button
label="试听"
size="small"
outlined
:disabled="!bgmEnabled || !workflow.bgmPath"
@click="onPreviewBgm"
/>
</div>
<p v-if="workflow.bgmFileName" class="mt-1 truncate text-xs text-slate-400">
{{ workflow.bgmFileName }}
</p>
<audio
v-if="bgmPreviewSrc"
:src="bgmPreviewSrc"
controls
class="mt-2 h-8 w-full"
/>
<p
v-if="!hasSourceVideo"
class="mt-2 text-xs text-amber-400/90"
>
请先在步骤 02 生成口播视频
</p>
<p
v-if="feedback.message"
class="mt-2 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<Button
label="自动生成字幕和BGM"
size="small"
class="mt-3 w-full"
:loading="subtitleBgmGenerating"
:disabled="!canGenerate"
@click="onGenerate"
/>
<div class="dashboard-preview-box mt-3">
<div class="mb-2 flex items-center gap-0.5">
<span class="text-xs text-slate-400">字幕视频预览</span>
<Button
icon="pi pi-folder-open"
size="small"
severity="secondary"
text
:disabled="!subtitlePreviewVideoPath"
aria-label="在资源管理器中打开"
@click="onOpenVideoInExplorer"
/>
</div>
<div class="dashboard-aspect-video">
<video
v-if="hasSubtitleVideoPreview"
:key="subtitlePreviewVideoSrc"
:src="subtitlePreviewVideoSrc"
controls
class="h-full w-full rounded object-contain"
/>
<span v-else class="text-xs text-gray-400">
{{ hasSourceVideo ? "生成字幕后将在此预览" : "请先在步骤 02 生成口播视频" }}
</span>
</div>
</div>
<SubtitleTemplateDialog
v-model:visible="templateDialogVisible"
v-model:selected-id="subtitleTemplateId"
@select="onSelectTemplate"
/>
</DashboardCard>
</template>

View File

@@ -1,178 +0,0 @@
<script setup>
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import { COVER_TEMPLATES } from "../../config/coverTemplates.js";
const workflow = useWorkflowStore();
const {
titleGenerated,
generatedVideoPath,
processedVideoPath,
coverGenerating,
coverImageSrc,
coverTemplateId,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
const settingsVisible = ref(false);
const templateOptions = COVER_TEMPLATES.map((t) => ({
label: t.label,
value: t.id,
}));
const hasSourceVideo = computed(
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
);
const hasTitle = computed(() => Boolean(String(titleGenerated.value || "").trim()));
const canGenerate = computed(
() => hasSourceVideo.value && hasTitle.value && !coverGenerating.value,
);
function showFeedback(result) {
if (!result?.message) return;
feedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
async function onGenerateCover() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.generateCover());
}
async function onPickMaterial() {
feedback.value = { severity: "", message: "" };
const result = await workflow.chooseCoverMaterial();
if (result?.message) showFeedback(result);
}
function applySettings() {
settingsVisible.value = false;
feedback.value = {
severity: "success",
message: "封面设置已保存,可点击「自动生成封面」",
};
}
</script>
<template>
<DashboardCard title="封面制作" step="06">
<Button
label="自动生成封面"
size="small"
class="w-full"
:loading="coverGenerating"
:disabled="!canGenerate"
@click="onGenerateCover"
/>
<Button
label="封面设置"
size="small"
severity="secondary"
class="mt-2 w-full"
@click="settingsVisible = true"
/>
<p
v-if="!hasSourceVideo"
class="mt-2 text-xs text-amber-400/90"
>
请先在步骤 02 生成口播视频
</p>
<p
v-else-if="!hasTitle"
class="mt-2 text-xs text-amber-400/90"
>
请先在步骤 04 生成标题文字
</p>
<p
v-if="feedback.message"
class="mt-2 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div class="mt-3">
<div class="dashboard-field-label mb-2">封面预览</div>
<div class="dashboard-aspect-video overflow-hidden">
<img
v-if="coverImageSrc"
:src="coverImageSrc"
alt="封面预览"
class="h-full w-full object-contain"
/>
<span v-else class="dashboard-muted text-sm">暂无封面预览</span>
</div>
</div>
<Dialog
v-model:visible="settingsVisible"
header="封面设置"
modal
:style="{ width: '24rem' }"
>
<div class="flex flex-col gap-3">
<div>
<div class="dashboard-field-label mb-2">样式模板</div>
<div class="flex flex-col gap-2">
<div
v-for="opt in templateOptions"
:key="opt.value"
class="flex items-center gap-2"
>
<RadioButton
v-model="coverTemplateId"
:input-id="`cover-tpl-${opt.value}`"
:value="opt.value"
/>
<label :for="`cover-tpl-${opt.value}`" class="cursor-pointer text-sm">
{{ opt.label }}
</label>
</div>
</div>
</div>
<div>
<div class="dashboard-field-label mb-1">封面素材视频可选</div>
<p class="dashboard-muted mb-2 text-xs">
不选择时从当前口播视频第 3 秒抽帧可上传其他视频作为封面底图
虚化/抠图类模板需 bundled Python 运行时首次较慢
</p>
<div class="flex flex-wrap gap-2">
<Button
label="选择视频"
size="small"
outlined
:disabled="coverGenerating"
@click="onPickMaterial"
/>
<Button
v-if="workflow.coverCustomVideoPath"
label="清除"
size="small"
severity="secondary"
text
@click="workflow.coverCustomVideoPath = ''"
/>
</div>
<p
v-if="workflow.coverMaterialFileName"
class="mt-1 truncate text-xs text-slate-400"
>
{{ workflow.coverMaterialFileName }}
</p>
</div>
</div>
<template #footer>
<Button label="确定" size="small" @click="applySettings" />
</template>
</Dialog>
</DashboardCard>
</template>

View File

@@ -1,530 +0,0 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
const workflow = useWorkflowStore();
const {
publishPlatforms,
publishing,
publishLoggingIn,
titleGenerated,
coverImagePath,
generatedVideoPath,
processedVideoPath,
publishScheduledAt,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
const scheduleDialogVisible = ref(false);
const scheduleDate = ref(null);
const accountDialogVisible = ref(false);
const activePlatformKey = ref("douyin");
const checkingAccountId = ref(null);
const openingAccountId = ref(null);
const hasVideo = computed(
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
);
const hasCover = computed(() => Boolean(coverImagePath.value));
const hasTitle = computed(() => Boolean(String(titleGenerated.value || "").trim()));
const canPublish = computed(
() =>
hasVideo.value &&
hasCover.value &&
hasTitle.value &&
!publishing.value &&
publishPlatforms.value.some((p) => p.checked),
);
const accountOptions = (platform) =>
(platform.accounts || []).map((a) => ({
label: a.nickname || `账号 #${a.id}`,
value: a.id,
}));
const platformIcons = {
douyin: "📱",
kuaishou: "🎬",
shipin: "📺",
xiaohongshu: "📕",
};
const activePlatform = computed(
() =>
publishPlatforms.value.find((p) => p.key === activePlatformKey.value) ||
publishPlatforms.value[0] ||
null,
);
function showFeedback(result) {
if (!result?.message) return;
feedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
onMounted(async () => {
await workflow.refreshPublishAccounts();
});
function openAccountManager() {
if (publishPlatforms.value.length && !activePlatform.value) {
activePlatformKey.value = publishPlatforms.value[0].key;
}
accountDialogVisible.value = true;
}
function selectPlatform(platformKey) {
activePlatformKey.value = platformKey;
}
async function onPublish() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.publishVideo({ autoPublish: true }));
}
async function onPublishManual() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.publishVideo({ autoPublish: false }));
}
function openSchedule() {
scheduleDate.value = null;
scheduleDialogVisible.value = true;
}
function confirmSchedule() {
if (!scheduleDate.value) {
feedback.value = { severity: "error", message: "请选择发布时间" };
return;
}
const d =
scheduleDate.value instanceof Date
? scheduleDate.value
: new Date(scheduleDate.value);
showFeedback(workflow.schedulePublishVideo(d));
scheduleDialogVisible.value = false;
}
function cancelSchedule() {
workflow.clearPublishSchedule();
feedback.value = { severity: "success", message: "已取消定时发布" };
}
async function onLogin(platform, accountId = undefined) {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.loginPublishPlatform(platform.key, accountId));
}
async function onLoginAccount(platform, account) {
await onLogin(platform, account.id);
}
async function onAddAccount(platform) {
await onLogin(platform, null);
}
async function onTestAccount(platform, account) {
checkingAccountId.value = account.id;
const result = await workflow.checkPublishLoginStatus(platform.key, account.id, true);
checkingAccountId.value = null;
showFeedback(result);
}
async function onOpenAccount(platform, account) {
openingAccountId.value = account.id;
const result = await workflow.openPublishAccount(platform.key, account.id);
openingAccountId.value = null;
showFeedback(result);
}
function onDeleteAccount(platform, account) {
platform.accounts = (platform.accounts || []).filter((a) => a.id !== account.id);
if (platform.accountId === account.id) {
platform.accountId = null;
}
if (!platform.accounts.length) {
platform.loggedIn = false;
platform.nickname = "";
}
feedback.value = { severity: "success", message: "已从本地列表移除账号,刷新后可重新获取" };
}
function formatAccountStatus(account) {
if (account?.loginStatus === "active" && account?.hasCookies) {
return { dot: "🟢", text: "已登录", color: "#4ade80" };
}
if (account?.loginStatus === "expired") {
return { dot: "🟠", text: "登录过期", color: "#f59e0b" };
}
return { dot: "⚪", text: "未登录", color: "#999999" };
}
function formatUpdateTime(account) {
const raw = account?.updatedAt || account?.lastLoginAt || account?.createdAt;
if (!raw) return "";
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
</script>
<template>
<DashboardCard title="视频发布" step="07" :grow="true">
<div class="mb-2 flex justify-end">
<Button
label="账号管理"
icon="pi pi-users"
size="small"
text
class="text-gray-400 hover:text-blue-400"
@click="openAccountManager"
/>
</div>
<div
v-for="platform in publishPlatforms"
:key="platform.key"
class="publish-platform-row mb-2 flex items-center justify-between gap-2 rounded border border-white/5 bg-white/5 p-2 hover:bg-white/10"
>
<label class="flex shrink-0 items-center text-sm" style="min-width: 86px">
<Checkbox v-model="platform.checked" binary />
<span class="ml-1">{{ platform.label }}</span>
<span
v-if="platform.loggedIn"
class="text-xs text-emerald-400"
:title="platform.nickname"
>
</span>
</label>
<Select
v-model="platform.accountId"
:options="accountOptions(platform)"
option-label="label"
option-value="value"
placeholder="选择账号"
size="small"
class="min-w-0 max-w-[200px] flex-1"
:disabled="!accountOptions(platform).length"
/>
</div>
<p v-if="!hasVideo" class="mt-1 text-xs text-amber-400/90">
请先在步骤 02 生成口播视频
</p>
<p v-else-if="!hasCover" class="mt-1 text-xs text-amber-400/90">
请先在步骤 06 生成封面
</p>
<p v-else-if="!hasTitle" class="mt-1 text-xs text-amber-400/90">
请先在步骤 04 生成标题
</p>
<p
v-if="feedback.message"
class="mt-2 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<ul
v-if="workflow.publishResults?.length"
class="mt-2 space-y-1 text-xs text-slate-300"
>
<li v-for="(r, idx) in workflow.publishResults" :key="idx">
{{ r.platform }}
<span :class="r.success ? 'text-emerald-400' : r.pending ? 'text-amber-400' : 'text-red-400'">
{{ r.message || r.status }}
</span>
</li>
</ul>
<p v-if="publishScheduledAt" class="mt-2 text-xs text-sky-400">
定时发布{{ new Date(publishScheduledAt).toLocaleString("zh-CN") }}
<Button label="取消" size="small" text class="ml-1" @click="cancelSchedule" />
</p>
<div class="mt-3 flex gap-2">
<Button
label="发布"
size="small"
class="flex-1"
:loading="publishing"
:disabled="!canPublish"
@click="onPublish"
/>
<Button
label="半自动"
size="small"
class="flex-1"
severity="secondary"
:loading="publishing"
:disabled="!canPublish"
title="填写内容后由您在浏览器中手动点发布"
@click="onPublishManual"
/>
<Button
label="定时发布"
size="small"
class="flex-1"
severity="secondary"
:disabled="!canPublish || publishing"
@click="openSchedule"
/>
</div>
<p class="mt-2 text-center text-xs leading-relaxed text-gray-400 opacity-80">
首次发布请手动关闭浏览器内出现的任何弹窗提示说明等防止干扰脚本下次即可流畅运行
</p>
<Dialog
v-model:visible="accountDialogVisible"
header="平台账号管理"
modal
:style="{ width: '640px' }"
>
<div class="account-manager">
<div class="platform-tabs">
<div
v-for="platform in publishPlatforms"
:key="platform.key"
class="platform-tab"
:class="{ active: activePlatform?.key === platform.key }"
@click="selectPlatform(platform.key)"
>
<span class="tab-icon">{{ platformIcons[platform.key] || "📌" }}</span>
<span class="tab-name">{{ platform.label }}</span>
<span v-if="platform.accounts?.length" class="tab-count">{{ platform.accounts.length }}</span>
</div>
</div>
<div class="account-list">
<div
v-for="account in activePlatform?.accounts || []"
:key="`${activePlatform.key}_${account.id}`"
class="account-card"
>
<div class="account-info">
<span class="status-dot">{{ formatAccountStatus(account).dot }}</span>
<div class="account-detail">
<div class="account-nickname">{{ account.nickname || "未登录" }}</div>
<div class="account-meta">
<span class="status-text" :style="{ color: formatAccountStatus(account).color }">
{{ formatAccountStatus(account).text }}
</span>
<span v-if="formatUpdateTime(account)" class="update-time">
· {{ formatUpdateTime(account) }}
</span>
</div>
</div>
</div>
<div class="account-actions">
<Button
label="扫码登录"
size="small"
severity="secondary"
:loading="publishLoggingIn === activePlatform.key"
@click="onLoginAccount(activePlatform, account)"
/>
<Button
v-if="account.hasCookies"
label="测试"
size="small"
severity="secondary"
outlined
:loading="checkingAccountId === account.id"
@click="onTestAccount(activePlatform, account)"
/>
<Button
label="打开"
size="small"
severity="secondary"
outlined
:loading="openingAccountId === account.id"
@click="onOpenAccount(activePlatform, account)"
/>
<Button
label="删除"
size="small"
severity="danger"
outlined
@click="onDeleteAccount(activePlatform, account)"
/>
</div>
</div>
<div v-if="!(activePlatform?.accounts || []).length" class="account-card">
<div class="account-info">
<span class="status-dot"></span>
<div class="account-detail">
<div class="account-nickname">未登录</div>
<div class="account-meta">
<span class="status-text" style="color: #999999">未登录</span>
</div>
</div>
</div>
<div class="account-actions">
<Button
label="扫码登录"
size="small"
severity="secondary"
:loading="publishLoggingIn === activePlatform?.key"
@click="onAddAccount(activePlatform)"
/>
</div>
</div>
<div class="add-account-btn" @click="onAddAccount(activePlatform)">
<span class="add-icon">+</span>
<span>添加{{ activePlatform?.label || "" }}账号</span>
</div>
</div>
</div>
</Dialog>
<Dialog
v-model:visible="scheduleDialogVisible"
header="定时发布"
modal
:style="{ width: '22rem' }"
>
<DatePicker
v-model="scheduleDate"
show-time
hour-format="24"
date-format="yy-mm-dd"
placeholder="选择发布时间"
class="w-full"
/>
<template #footer>
<Button label="取消" size="small" text @click="scheduleDialogVisible = false" />
<Button label="确定" size="small" @click="confirmSchedule" />
</template>
</Dialog>
</DashboardCard>
</template>
<style scoped>
.account-manager {
display: flex;
flex-direction: column;
gap: 12px;
}
.platform-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.platform-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid #333333;
border-radius: 10px;
background: #1c1c1c;
color: #d1d5db;
cursor: pointer;
transition: all 0.2s ease;
}
.platform-tab.active {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.15);
color: #e2e8f0;
}
.tab-count {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.25);
color: #bfdbfe;
font-size: 11px;
line-height: 18px;
text-align: center;
}
.account-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.account-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid #333333;
border-radius: 10px;
background: #1a1a1a;
}
.account-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.account-detail {
min-width: 0;
}
.account-nickname {
color: #e5e7eb;
font-size: 14px;
font-weight: 600;
}
.account-meta {
margin-top: 3px;
color: #9ca3af;
font-size: 12px;
}
.account-actions {
display: flex;
align-items: center;
gap: 8px;
}
.add-account-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed #3f3f46;
border-radius: 10px;
padding: 10px 12px;
color: #cbd5e1;
cursor: pointer;
transition: all 0.2s ease;
}
.add-account-btn:hover {
border-color: #2563eb;
color: #dbeafe;
background: rgba(37, 99, 235, 0.08);
}
.add-icon {
font-size: 18px;
line-height: 1;
}
</style>

View File

@@ -1,101 +0,0 @@
<script setup>
import { ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import LegalCheckDialog from "./LegalCheckDialog.vue";
const workflow = useWorkflowStore();
const {
scriptContent,
editLanguage,
editWordCount,
scriptGenerating,
legalChecking,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
function showFeedback(result) {
if (!result?.message) return;
feedback.value = {
severity: result.ok ? (result.warn ? "warn" : "success") : "error",
message: result.message,
};
}
async function onGenerateScript() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.generateScript());
}
async function onLegalCheck() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.runLegalCheck());
}
function onLegalFeedback(result) {
showFeedback(result);
}
</script>
<template>
<DashboardCard title="视频文案编辑" :grow="true">
<div class="space-y-2">
<div class="dashboard-field-label">文案内容</div>
<Textarea
v-model="scriptContent"
placeholder="文案内容将显示在这里..."
class="dashboard-textarea-mono w-full"
rows="12"
/>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2">
<div class="flex shrink-0 items-center gap-2">
<InputNumber
v-model="editWordCount"
:min="30"
:max="2000"
size="small"
class="w-28 shrink-0"
/>
<span class="shrink-0 text-xs text-slate-400"></span>
</div>
<Select
v-model="editLanguage"
:options="workflow.languages"
size="small"
class="min-w-36 shrink-0 ml-10"
/>
</div>
<div class="flex flex-wrap items-center gap-2 pt-1">
<Button
label="撰写文案"
size="small"
:loading="scriptGenerating"
:disabled="scriptGenerating || legalChecking"
@click="onGenerateScript"
/>
<Button
label="AI法务"
size="small"
severity="secondary"
:loading="legalChecking"
:disabled="scriptGenerating || legalChecking"
@click="onLegalCheck"
/>
</div>
<p
v-if="feedback.message"
class="text-sm"
:class="{
'text-red-400': feedback.severity === 'error',
'text-amber-400': feedback.severity === 'warn',
'text-emerald-400': feedback.severity === 'success',
}"
>
{{ feedback.message }}
</p>
</div>
<LegalCheckDialog @feedback="onLegalFeedback" />
</DashboardCard>
</template>

View File

@@ -1,118 +0,0 @@
<script setup>
import { ref } from "vue";
import { storeToRefs } from "pinia";
import { invoke } from "@tauri-apps/api/core";
import { useWorkflowStore, executeVideoRewrite } from "../../stores/workflow.js";
const workflow = useWorkflowStore();
const {
videoLinkModalVisible,
videoShareText,
videoRewriting,
rewriteProgress,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
const tipItems = [
"已支持抖音分享文本、抖音视频链接、抖音短链接",
"支持直接视频文件 URL如 .mp4、.mov 等)",
"快手、小红书、B站、视频号分享链接目前会提示暂不支持",
"流程:解析链接 → 下载视频 → 提取音频 → 语音识别 → AI 仿写",
];
function onCancel() {
feedback.value = { severity: "", message: "" };
if (videoRewriting.value) {
workflow.videoRewriteAbortRequested = true;
invoke("stop_nodejs_script", { scriptName: "douyin_pipeline.js" }).catch(() => {});
workflow.videoRewriting = false;
workflow.rewriteProgress = "";
}
workflow.videoLinkModalVisible = false;
}
function onVisibleChange(visible) {
if (!visible) onCancel();
}
async function onRewrite() {
feedback.value = { severity: "", message: "" };
const result = await executeVideoRewrite(workflow);
if (result?.cancelled) return;
if (!result.ok) {
feedback.value = { severity: "error", message: result.message };
} else {
feedback.value = { severity: "success", message: result.message };
}
}
</script>
<template>
<Dialog
v-model:visible="videoLinkModalVisible"
modal
header="粘贴视频分享文本 - 快速仿写"
:style="{ width: '600px' }"
:draggable="false"
class="ip-brain-dialog"
@update:visible="onVisibleChange"
>
<div class="space-y-4">
<Message
v-if="feedback.message"
:severity="feedback.severity"
:closable="false"
class="w-full"
>
{{ feedback.message }}
</Message>
<Message
v-if="videoRewriting && rewriteProgress"
severity="info"
:closable="false"
class="w-full"
>
{{ rewriteProgress }}
</Message>
<div>
<label class="mb-2 block font-medium text-slate-200">
请粘贴视频分享文本或视频链接
</label>
<p class="mb-2 text-xs text-slate-500">
支持抖音分享文本抖音视频链接抖音短链接以及直接视频文件链接
</p>
<InputText
v-model="videoShareText"
type="text"
size="large"
class="w-full"
placeholder="请输入分享文本、视频链接,或 mp4/mov/webm 等直链..."
:disabled="videoRewriting"
@keyup.enter="onRewrite"
/>
</div>
<div class="elegant-tip-box rounded-lg p-3">
<div class="text-sm text-slate-300">
<div class="mb-1 font-medium">提示:</div>
<ul class="list-inside list-disc space-y-1">
<li v-for="(tip, i) in tipItems" :key="i">{{ tip }}</li>
</ul>
</div>
</div>
</div>
<template #footer>
<Button
label="取消"
severity="secondary"
:disabled="videoRewriting"
@click="onCancel"
/>
<Button label="一键仿写" :loading="videoRewriting" @click="onRewrite" />
</template>
</Dialog>
</template>

View File

@@ -1,352 +0,0 @@
<script setup>
import { ref, computed, watch } from "vue";
import {
addCloneVoice,
updateCloneVoice,
cloneVoiceTitleExists,
} from "../../services/soundPromptStorage.js";
import {
getAudioDurationSeconds,
persistVoiceReference,
validateReferenceDuration,
} from "../../services/voiceReferenceAudio.js";
import { enrollCloneVoice } from "../../services/voiceCloneEnroll.js";
import {
stripVoiceIdPrefix,
formatVoiceIdWithPrefix,
} from "../../utils/voiceIdPrefix.js";
import VoiceReferenceRecorder from "../voice/VoiceReferenceRecorder.vue";
const emit = defineEmits(["saved"]);
const visible = ref(false);
const editingId = ref(null);
const name = ref("");
const promptText = ref("");
const referencePath = ref("");
const voiceIdInput = ref("");
const saving = ref(false);
const enrolling = ref(false);
const errorMessage = ref("");
const recorderRef = ref(null);
/** 打开编辑时已有的参考音频路径(未更换则保存时不校验时长,对齐 Electron */
const initialReferencePath = ref("");
/** 一键复刻成功时使用的参考路径;更换音频后需重新校验 */
const referencePathAtEnroll = ref("");
const isEdit = computed(() => Boolean(editingId.value));
watch(referencePath, (path) => {
if (
referencePathAtEnroll.value &&
path !== referencePathAtEnroll.value
) {
referencePathAtEnroll.value = "";
}
});
const voiceIdModel = computed({
get: () => voiceIdInput.value,
set: (v) => {
voiceIdInput.value = v;
},
});
function resetForm() {
editingId.value = null;
name.value = "";
promptText.value = "";
referencePath.value = "";
voiceIdInput.value = "";
errorMessage.value = "";
initialReferencePath.value = "";
referencePathAtEnroll.value = "";
}
/**
* 对齐 Electron SoundPromptEditDialog已有 promptWav 或已完成复刻时保存不再做 620s 校验。
* @param {string} path
*/
function shouldSkipReferenceDurationOnSave(path) {
const p = String(path || "").trim();
if (!p) return false;
if (
voiceIdInput.value.trim() &&
referencePathAtEnroll.value &&
p === referencePathAtEnroll.value
) {
return true;
}
if (
isEdit.value &&
initialReferencePath.value &&
p === initialReferencePath.value
) {
return true;
}
return false;
}
function openAdd() {
resetForm();
visible.value = true;
}
/**
* @param {{ id: string, title: string, content?: { promptText?: string, aliyunVoiceId?: string, url?: string } }} record
*/
function openEdit(record) {
editingId.value = record.id;
name.value = record.title || "";
promptText.value = record.content?.promptText || "";
referencePath.value = record.content?.url || "";
initialReferencePath.value = referencePath.value;
referencePathAtEnroll.value = "";
voiceIdInput.value = stripVoiceIdPrefix(record.content?.aliyunVoiceId || "");
errorMessage.value = "";
visible.value = true;
}
function onCancel() {
visible.value = false;
}
async function resolveReferenceForSave() {
const path =
referencePath.value ||
(await recorderRef.value?.exportReferencePath?.()) ||
"";
if (!path) {
return { ok: false, message: "请录制声音或选择声音文件" };
}
if (!shouldSkipReferenceDurationOnSave(path)) {
const duration =
(await recorderRef.value?.getDurationSeconds?.()) ??
(await getAudioDurationSeconds(path));
const check = validateReferenceDuration(duration);
if (!check.ok) return check;
}
const storageId =
editingId.value ||
`clone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
try {
const persisted = await persistVoiceReference(path, storageId);
return { ok: true, path: persisted, storageId };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : "保存参考音频失败",
};
}
}
async function onEnroll() {
errorMessage.value = "";
const title = name.value.trim();
if (!title) {
errorMessage.value = "请先输入音色名称";
return;
}
const path =
referencePath.value ||
(await recorderRef.value?.exportReferencePath?.()) ||
"";
if (!path) {
errorMessage.value = "请先录制或上传音频";
return;
}
const duration =
(await recorderRef.value?.getDurationSeconds?.()) ??
(await getAudioDurationSeconds(path));
const check = validateReferenceDuration(duration, { forEnroll: true });
if (!check.ok) {
errorMessage.value = check.message;
return;
}
enrolling.value = true;
try {
const result = await enrollCloneVoice({
name: title,
audioPath: path,
existingVoiceId: voiceIdInput.value
? formatVoiceIdWithPrefix(voiceIdInput.value)
: "",
});
if (!result.ok) {
errorMessage.value = result.message;
return;
}
voiceIdInput.value = stripVoiceIdPrefix(result.voiceId);
referencePathAtEnroll.value = path;
errorMessage.value = "";
successFlash.value = result.message;
} finally {
enrolling.value = false;
}
}
const successFlash = ref("");
async function onSave() {
errorMessage.value = "";
successFlash.value = "";
const title = name.value.trim();
if (!title) {
errorMessage.value = "请输入名称";
return;
}
if (cloneVoiceTitleExists(title, editingId.value)) {
errorMessage.value = "名称重复";
return;
}
saving.value = true;
try {
const refResult = await resolveReferenceForSave();
if (!refResult.ok) {
errorMessage.value = refResult.message;
return;
}
const aliyunVoiceId = formatVoiceIdWithPrefix(
voiceIdInput.value.trim(),
voiceIdInput.value,
);
const payload = {
title,
content: {
promptText: promptText.value.trim(),
aliyunVoiceId,
url: refResult.path,
},
};
if (editingId.value) {
updateCloneVoice(editingId.value, payload);
} else {
addCloneVoice(payload, refResult.storageId);
}
visible.value = false;
emit("saved");
} catch (err) {
errorMessage.value =
err instanceof Error ? err.message : String(err || "保存失败");
} finally {
saving.value = false;
}
}
defineExpose({ openAdd, openEdit });
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:header="isEdit ? '编辑音色' : '添加音色'"
:style="{ width: '800px' }"
:draggable="false"
class="voice-clone-edit-dialog"
>
<div class="max-h-[60vh] overflow-y-auto">
<div class="flex flex-col gap-4 lg:flex-row">
<div class="w-full shrink-0 space-y-4 lg:w-1/2 lg:pr-4">
<div>
<div class="dashboard-field-label mb-1">
名称 <span class="text-red-400">*</span>
</div>
<InputText
v-model="name"
class="w-full"
size="small"
placeholder="例如:我的克隆音色"
/>
</div>
<div>
<div class="dashboard-field-label mb-1">
参考声音 <span class="text-red-400">*</span>
</div>
<VoiceReferenceRecorder
ref="recorderRef"
v-model="referencePath"
/>
</div>
<div>
<div class="dashboard-field-label mb-1">参考文字</div>
<InputText
v-model="promptText"
class="w-full"
size="small"
placeholder="可选"
/>
<p class="mt-1 text-xs text-slate-500">
部分模型需要使用可选填写参考声音的文字内容
</p>
</div>
<div>
<div class="dashboard-field-label mb-1">音色 ID可选</div>
<div class="flex flex-wrap items-center gap-2">
<InputText
v-model="voiceIdModel"
class="min-w-0 flex-1"
size="small"
placeholder="1111-voice-202603232"
/>
<Button
label="一键复刻"
size="small"
severity="success"
icon="pi pi-bolt"
:loading="enrolling"
:disabled="enrolling || saving"
@click="onEnroll"
/>
</div>
<p class="mt-1 text-xs text-slate-500">
仅限复刻音色如果没有复刻 ID请留空将使用默认音色
</p>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="text-lg font-bold text-slate-100">音色说明</div>
<div
class="mt-2 rounded-lg bg-slate-800/60 p-3 text-xs leading-6 text-slate-300"
>
<div>1. 请在安静的环境下进行录音避免噪音干扰</div>
<div>2. 请使用标准普通话吐字清晰语速适当</div>
<div>3. 录音时长控制在 620 最佳最多不超过 20 </div>
<div>4. 录制完成后先试听看是否达到要求再提交</div>
</div>
</div>
</div>
<p
v-if="successFlash"
class="mt-3 text-sm text-emerald-400"
>
{{ successFlash }}
</p>
<p
v-if="errorMessage"
class="mt-3 text-sm text-red-400"
>
{{ errorMessage }}
</p>
</div>
<template #footer>
<Button label="取消" severity="secondary" @click="onCancel" />
<Button label="保存" :loading="saving" :disabled="enrolling" @click="onSave" />
</template>
</Dialog>
</template>

View File

@@ -1,359 +0,0 @@
<script setup>
import { ref, computed, watch } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import {
VOICE_CATEGORY_OPTIONS,
filterSystemVoices,
} from "../../data/systemVoices.js";
import {
listCloneVoices,
deleteCloneVoice,
} from "../../services/soundPromptStorage.js";
import { previewVoice } from "../../services/voicePreview.js";
import { stripVoiceIdPrefix } from "../../utils/voiceIdPrefix.js";
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
const props = defineProps({
/** 声音页等独立场景:由父组件控制显隐与选中音色 */
standalone: { type: Boolean, default: false },
visible: { type: Boolean, default: false },
selectedVoiceId: { type: String, default: "sys_Cherry" },
});
const emit = defineEmits(["update:visible", "update:selectedVoiceId", "select"]);
const workflow = useWorkflowStore();
const { voiceManageModalVisible, selectedVoiceId: workflowVoiceId } =
storeToRefs(workflow);
const dialogVisible = computed({
get() {
return props.standalone ? props.visible : voiceManageModalVisible.value;
},
set(v) {
if (props.standalone) {
emit("update:visible", v);
} else {
voiceManageModalVisible.value = v;
}
},
});
const currentVoiceId = computed({
get() {
return props.standalone ? props.selectedVoiceId : workflowVoiceId.value;
},
set(v) {
if (props.standalone) {
emit("update:selectedVoiceId", v);
} else {
workflowVoiceId.value = v;
}
},
});
const activeTab = ref("system");
const voiceCategory = ref("putonghua");
const cloneVoices = ref([]);
const cloneLoading = ref(false);
const editDialogRef = ref(null);
const previewingKey = ref(null);
const previewPanelKey = ref(null);
const previewAudioSrc = ref("");
const previewError = ref("");
const audioRef = ref(null);
const filteredSystemVoices = computed(() =>
filterSystemVoices(voiceCategory.value),
);
function loadCloneVoices() {
cloneLoading.value = true;
try {
cloneVoices.value = listCloneVoices();
} finally {
cloneLoading.value = false;
}
}
watch(dialogVisible, (open) => {
if (open) {
activeTab.value = "system";
voiceCategory.value = "putonghua";
loadCloneVoices();
} else {
stopPreview();
}
});
function stopPreview() {
previewingKey.value = null;
previewPanelKey.value = null;
previewAudioSrc.value = "";
previewError.value = "";
if (audioRef.value) {
audioRef.value.pause();
audioRef.value.removeAttribute("src");
}
}
function onClose() {
if (props.standalone) {
dialogVisible.value = false;
} else {
workflow.closeVoiceManageDialog();
}
}
function onSelectVoice(voiceId) {
if (props.standalone) {
currentVoiceId.value = voiceId;
emit("select", voiceId);
dialogVisible.value = false;
} else {
workflow.selectVoice(voiceId);
}
}
function onAddClone() {
editDialogRef.value?.openAdd();
}
function onEditClone(record) {
editDialogRef.value?.openEdit(record);
}
function onDeleteClone(record) {
if (!window.confirm(`确认删除音色「${record.title}」?`)) return;
deleteCloneVoice(record.id);
if (currentVoiceId.value === record.id) {
if (props.standalone) {
currentVoiceId.value = "sys_Cherry";
} else {
workflow.selectVoice("sys_Cherry");
}
}
if (previewPanelKey.value === record.id) {
stopPreview();
}
loadCloneVoices();
}
async function runPreview(opts) {
previewError.value = "";
previewingKey.value = opts.listKey;
const result = await previewVoice(opts);
previewingKey.value = null;
if (!result.ok) {
previewError.value = result.message;
return;
}
previewPanelKey.value = opts.listKey;
previewAudioSrc.value = result.audioSrc;
requestAnimationFrame(() => {
audioRef.value?.play()?.catch(() => {});
});
}
function onPreviewSystem(voice) {
runPreview({
listKey: voice.id,
title: voice.title,
aliyunVoiceId: voice.aliyunVoiceId,
});
}
function onPreviewClone(item) {
const voiceId = String(item.content?.aliyunVoiceId || "").trim();
if (!voiceId) {
previewError.value = "请先配置音色 ID 后再试听";
return;
}
runPreview({
listKey: item.id,
title: item.title,
aliyunVoiceId: voiceId,
});
}
function isPreviewing(key) {
return previewingKey.value === key;
}
function showPreviewPlayer(key) {
return previewPanelKey.value === key && previewAudioSrc.value;
}
</script>
<template>
<Dialog
v-model:visible="dialogVisible"
modal
header="音色管理"
:style="{ width: '900px' }"
:draggable="false"
class="voice-manage-dialog"
@hide="onClose"
>
<audio ref="audioRef" class="hidden" :src="previewAudioSrc || undefined" />
<Tabs v-model:value="activeTab">
<TabList>
<Tab value="system">系统音色</Tab>
<Tab value="clone">克隆音色</Tab>
</TabList>
<TabPanels>
<TabPanel value="system">
<SelectButton
v-model="voiceCategory"
:options="VOICE_CATEGORY_OPTIONS"
option-label="label"
option-value="key"
size="small"
class="mb-3"
/>
<div class="voice-list-scroll max-h-[50vh] space-y-2 overflow-y-auto pr-1">
<div
v-for="voice in filteredSystemVoices"
:key="voice.id"
class="rounded-xl border border-white/10 bg-white/5 p-3 transition-colors hover:border-purple-500/40"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div
class="inline-flex max-w-full flex-wrap items-center gap-x-2 rounded-full bg-white/5 px-2 py-1"
>
<span class="text-sm text-slate-100">{{ voice.title }}</span>
<span class="text-xs text-slate-500">{{ voice.desc }}</span>
</div>
<p class="mt-2 text-xs text-slate-500">音色ID: {{ voice.aliyunVoiceId }}</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
label="试听"
size="small"
severity="secondary"
icon="pi pi-volume-up"
:loading="isPreviewing(voice.id)"
:disabled="Boolean(previewingKey && !isPreviewing(voice.id))"
@click="onPreviewSystem(voice)"
/>
<Button
label="选择"
size="small"
icon="pi pi-check"
@click="onSelectVoice(voice.id)"
/>
</div>
</div>
<div
v-if="showPreviewPlayer(voice.id)"
class="mt-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-2 py-2"
>
<audio
:src="previewAudioSrc"
controls
class="h-8 w-full"
autoplay
/>
</div>
</div>
<p
v-if="filteredSystemVoices.length === 0"
class="py-8 text-center text-sm text-slate-500"
>
暂无音色
</p>
</div>
</TabPanel>
<TabPanel value="clone">
<div class="mb-3 flex justify-end">
<Button
label="添加克隆音色"
size="small"
icon="pi pi-plus"
@click="onAddClone"
/>
</div>
<div class="voice-list-scroll max-h-[50vh] space-y-2 overflow-y-auto pr-1">
<p v-if="cloneLoading" class="py-6 text-center text-sm text-slate-500">加载中...</p>
<p
v-else-if="cloneVoices.length === 0"
class="py-8 text-center text-sm text-slate-500"
>
暂无克隆音色点击右上角添加
</p>
<div
v-for="item in cloneVoices"
:key="item.id"
class="rounded-xl border border-white/10 bg-white/5 p-3 transition-colors hover:border-purple-500/40"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-slate-100">
{{ item.title }}
</div>
<p v-if="item.content?.promptText" class="mt-1 text-xs text-slate-500">
{{ item.content.promptText }}
</p>
<p
v-if="item.content?.aliyunVoiceId"
class="mt-1 text-xs text-blue-400"
>
音色ID: {{ stripVoiceIdPrefix(item.content.aliyunVoiceId) }}
</p>
<p v-else class="mt-1 text-xs text-amber-500/90">
未配置音色ID将使用默认音色
</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
label="试听"
size="small"
severity="secondary"
icon="pi pi-volume-up"
:loading="isPreviewing(item.id)"
:disabled="
!item.content?.aliyunVoiceId ||
Boolean(previewingKey && !isPreviewing(item.id))
"
@click="onPreviewClone(item)"
/>
<Button label="选择" size="small" @click="onSelectVoice(item.id)" />
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
aria-label="编辑"
@click="onEditClone(item)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
aria-label="删除"
@click="onDeleteClone(item)"
/>
</div>
</div>
<div
v-if="showPreviewPlayer(item.id)"
class="mt-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-2 py-2"
>
<audio :src="previewAudioSrc" controls class="h-8 w-full" autoplay />
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<p v-if="previewError" class="mt-2 text-sm text-red-400">{{ previewError }}</p>
<VoiceCloneEditDialog ref="editDialogRef" @saved="loadCloneVoices" />
</Dialog>
</template>

View File

@@ -1,114 +0,0 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
const router = useRouter();
const workflow = useWorkflowStore();
const {
oneClickRunning,
oneClickStatus,
oneClickProgress,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
function showFeedback(result) {
if (!result?.message) return;
feedback.value = {
severity: result.ok ? "success" : "error",
message: result.message,
};
}
async function onStartOneClick() {
feedback.value = { severity: "", message: "" };
const result = await workflow.startOneClickAuto();
showFeedback(result);
}
function onStopTask() {
feedback.value = { severity: "", message: "" };
showFeedback(workflow.stopOneClickAuto());
}
function onClearData() {
if (oneClickRunning.value) {
feedback.value = {
severity: "error",
message: "一键任务运行中,请先停止任务",
};
return;
}
workflow.clearData();
feedback.value = { severity: "success", message: "已清除数据" };
}
function onOpenAgentConfig() {
router.push({ name: "local-config" });
}
</script>
<template>
<section class="dashboard-card p-3">
<button
type="button"
class="dashboard-one-click"
:disabled="oneClickRunning"
@click="onStartOneClick"
>
{{ oneClickRunning ? "一键自动运行中…" : "开启一键自动" }}
</button>
<div
v-if="oneClickRunning || oneClickProgress > 0"
class="one-click-progress mt-2"
>
<div class="mb-1 flex items-center justify-between gap-2 text-xs text-slate-400">
<span class="truncate">{{ oneClickStatus || "准备中…" }}</span>
<span class="shrink-0 tabular-nums">{{ oneClickProgress }}%</span>
</div>
<div class="one-click-progress-track">
<div
class="one-click-progress-bar"
:style="{ width: `${Math.min(100, Math.max(0, oneClickProgress))}%` }"
/>
</div>
</div>
<p
v-if="feedback.message"
class="mt-2 text-xs"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div class="mt-2 grid grid-cols-2 gap-2">
<Button
label="停止任务"
size="small"
text
severity="danger"
:disabled="!oneClickRunning"
@click="onStopTask"
/>
<Button
label="清除数据"
size="small"
text
severity="danger"
:disabled="oneClickRunning"
@click="onClearData"
/>
</div>
<Button
label="智能体配置"
size="small"
outlined
class="mt-2 w-full"
@click="onOpenAgentConfig"
/>
</section>
</template>

View File

@@ -1,112 +0,0 @@
/** 封面样式预设(简易 ffmpeg + 高级 Python 模板) */
export const COVER_TEMPLATES = [
{
id: "default",
label: "底部白字(快速)",
titleFontSize: 90,
titleFontColor: "#FFFFFF",
titlePosition: "bottom",
},
{
id: "top",
label: "顶部白字(快速)",
titleFontSize: 80,
titleFontColor: "#FFFFFF",
titlePosition: "top",
},
{
id: "center",
label: "居中黄字(快速)",
titleFontSize: 88,
titleFontColor: "#FFD700",
titlePosition: "center",
},
{
id: "pil_stroke",
label: "描边标题PIL",
titleFontSize: 96,
titleColor: "#FFFFFF",
titlePosition: "bottom",
titleStrokeWidth: 4,
titleStrokeColor: "#000000",
titleFontFamily: "Microsoft YaHei",
},
{
id: "advanced_blur",
label: "虚化背景 + 抠图",
backgroundBlurEnabled: true,
blurBackground: true,
extractPerson: true,
personSize: 88,
titleFontSize: 72,
titleFontColor: "#FFFFFF",
titlePosition: "bottom",
titleFontFamily: "Microsoft YaHei",
titleBackgroundEnabled: false,
},
{
id: "advanced_outline",
label: "人物描边强调",
extractPerson: true,
personOutlineColor: "#FFD700",
personOutlineWidth: 6,
personSize: 90,
backgroundBlurEnabled: true,
titleFontSize: 80,
titleFontColor: "#FFFFFF",
titlePosition: "top",
titleFontFamily: "Microsoft YaHei",
},
];
/**
* @param {string} templateId
*/
export function getCoverTemplate(templateId) {
return (
COVER_TEMPLATES.find((t) => t.id === templateId) || COVER_TEMPLATES[0]
);
}
/**
* 与 Electron `isNewTemplate` 一致
* @param {Record<string, unknown>} config
*/
export function isAdvancedCoverConfig(config) {
if (!config || typeof config !== "object") return false;
return (
config.blurBackground !== undefined ||
config.extractPerson !== undefined ||
config.personOutlineColor !== undefined ||
config.personOutlineWidth !== undefined ||
config.maskImagePath !== undefined ||
Boolean(config.titleFontFamily) ||
config.titleBackgroundEnabled !== undefined ||
config.personSize !== undefined ||
config.backgroundBlurEnabled !== undefined
);
}
/**
* @param {Record<string, unknown>} tpl
* @param {{ customVideoPath?: string, overrides?: Record<string, unknown> }} [extra]
*/
export function buildCoverEffectStyle(tpl, extra = {}) {
const { id: _id, label: _label, ...style } = tpl;
return {
...style,
...(extra.overrides || {}),
customVideoPath: extra.customVideoPath || "",
};
}
/**
* @param {string} titleGenerated
*/
export function pickCoverTitleText(titleGenerated) {
const raw = String(titleGenerated || "").trim();
if (!raw) return "";
const line = raw.split(/\r?\n/)[0].trim();
return line.slice(0, 80);
}

View File

@@ -1,162 +0,0 @@
/** 字幕样式字体与快速预设(对齐 Electron SubtitleStyleSelector */
export const SUBTITLE_FONT_OPTIONS = [
{ label: "胡晓波男神体", value: "胡晓波男神体" },
{ label: "优设标题黑", value: "优设标题黑" },
{ label: "微软雅黑", value: "Microsoft YaHei" },
{ label: "微软雅黑 UI", value: "Microsoft YaHei UI" },
{ label: "月星楷", value: "月星楷" },
{ label: "USMCCyuanjiantecu", value: "USMCCyuanjiantecu" },
{ label: "庞门正道标题体", value: "庞门正道标题体免费版" },
{ label: "霞鹜文楷", value: "霞鹜文楷-Light" },
{ label: "文鼎PL简中楷", value: "文鼎PL简中楷" },
{ label: "杨任东竹石体", value: "杨任东竹石体" },
{ label: "千图厚黑体", value: "千图厚黑体" },
{ label: "Arial", value: "Arial" },
];
export const SUBTITLE_STYLE_PRESETS = [
{
label: "黑底白字",
style: {
fontColor: "#FFFFFF",
backgroundColor: "#000000",
backgroundOpacity: 0.8,
outlineWidth: 0,
},
},
{
label: "白底黑字",
style: {
fontColor: "#000000",
backgroundColor: "#FFFFFF",
backgroundOpacity: 0.9,
outlineWidth: 0,
},
},
{
label: "抖音风格",
style: {
fontColor: "#FFFFFF",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#000000",
outlineWidth: 3,
shadowOffset: 2,
shadowColor: "#000000",
},
},
{
label: "红底白字",
style: {
fontColor: "#FFFFFF",
backgroundColor: "#FF0000",
backgroundOpacity: 0.85,
outlineWidth: 0,
},
},
{
label: "黄底黑字",
style: {
fontColor: "#000000",
backgroundColor: "#FFD700",
backgroundOpacity: 0.9,
outlineWidth: 0,
},
},
{
label: "蓝底白字",
style: {
fontColor: "#FFFFFF",
backgroundColor: "#2563EB",
backgroundOpacity: 0.85,
outlineWidth: 0,
},
},
{
label: "绿色高亮",
style: {
fontColor: "#00FF88",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#000000",
outlineWidth: 2,
},
},
{
label: "紫色高亮",
style: {
fontColor: "#E879F9",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#000000",
outlineWidth: 2,
},
},
{
label: "立体阴影",
style: {
fontColor: "#FFFFFF",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineWidth: 0,
shadowOffset: 3,
shadowBlur: 4,
shadowColor: "#000000",
},
},
{
label: "霓虹发光",
style: {
fontColor: "#00FFFF",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#FF00FF",
outlineWidth: 2,
shadowOffset: 0,
shadowBlur: 8,
shadowColor: "#00FFFF",
},
},
];
/**
* @param {Record<string, unknown>} style
*/
export function buildSubtitlePreviewBoxStyle(style) {
const fontSize = Number(style?.fontSize) || 35;
const outline = Number(style?.outlineWidth) || 0;
const offset = Number(style?.shadowOffset) || 0;
const shadows = [];
if (outline > 0) {
const c = style?.outlineColor || "#000000";
const w = Math.max(1, outline);
shadows.push(
`${w}px 0 0 ${c}`,
`-${w}px 0 0 ${c}`,
`0 ${w}px 0 ${c}`,
`0 -${w}px 0 ${c}`,
);
}
if (offset > 0) {
shadows.push(
`${offset}px ${offset}px ${Number(style?.shadowBlur) || 4}px ${style?.shadowColor || "#000000"}`,
);
}
const bg =
style?.backgroundColor &&
style.backgroundColor !== "transparent" &&
(Number(style?.backgroundOpacity) ?? 0) > 0
? style.backgroundColor
: "transparent";
return {
fontFamily: style?.fontName ? `'${style.fontName}'` : "Microsoft YaHei UI",
fontSize: `${Math.min(fontSize, 42)}px`,
color: style?.fontColor || "#FFFFFF",
backgroundColor: bg,
padding: bg !== "transparent" ? "4px 12px" : "0",
borderRadius: bg !== "transparent" ? "4px" : "0",
textShadow: shadows.length ? shadows.join(", ") : "0 1px 2px rgba(0,0,0,0.5)",
fontWeight: "bold",
};
}

View File

@@ -1,63 +0,0 @@
/** 字幕样式预设(对齐 Electron 常用底部白字黑边,供 ffmpeg force_style */
export const SUBTITLE_TEMPLATES = [
{
id: "default",
label: "默认白字黑边",
fontName: "Microsoft YaHei",
fontSize: 24,
primaryColor: "#FFFFFF",
outlineColor: "#000000",
outline: 2,
marginV: 40,
},
{
id: "large",
label: "大号字幕",
fontName: "Microsoft YaHei",
fontSize: 32,
primaryColor: "#FFFFFF",
outlineColor: "#000000",
outline: 3,
marginV: 48,
},
{
id: "yellow",
label: "黄色强调",
fontName: "Microsoft YaHei",
fontSize: 26,
primaryColor: "#FFD700",
outlineColor: "#000000",
outline: 2,
marginV: 42,
},
];
/**
* @param {string} templateId
*/
export function getSubtitleTemplate(templateId) {
return (
SUBTITLE_TEMPLATES.find((t) => t.id === templateId) ||
SUBTITLE_TEMPLATES[0]
);
}
/**
* @param {{ fontName?: string, fontSize?: number, primaryColor?: string, outlineColor?: string, outline?: number, marginV?: number }} tpl
*/
export function buildFfmpegForceStyle(tpl) {
const hexToAss = (hex) => {
const h = hex.replace("#", "");
const r = h.substring(0, 2);
const g = h.substring(2, 4);
const b = h.substring(4, 6);
return `&H00${b}${g}${r}`.toUpperCase();
};
const fontName = tpl.fontName || "Microsoft YaHei";
const fontSize = tpl.fontSize || 24;
const primary = hexToAss(tpl.primaryColor || "#FFFFFF");
const outline = hexToAss(tpl.outlineColor || "#000000");
const outlineW = tpl.outline ?? 2;
const marginV = tpl.marginV ?? 40;
return `FontName=${fontName},FontSize=${fontSize},PrimaryColour=${primary},OutlineColour=${outline},Outline=${outlineW},Alignment=2,MarginV=${marginV}`;
}

View File

@@ -1,8 +0,0 @@
/** 语音合成模型选项(对齐 Electron CosyVoice 版本说明) */
export const TTS_MODEL_OPTIONS = [
{
label: "V1 多语言/方言",
value: "cosyvoice-v3-flash",
hint: "选择他国语言需要文案也对应他国,选择方言需对应匹配的音色或克隆音色本身是方言",
},
];

View File

@@ -1,96 +0,0 @@
/**
* 流水线配置校验Rust 合并服务端 desktop_configs + 本地 SQLite同名本地优先
* 实际密钥由 Node 子进程通过环境变量 AICLIENT_CFG_* 读取,无需在前端 params 传递。
*/
import { invoke } from "@tauri-apps/api/core";
/** @returns {Promise<{ ok: true, map: Record<string, string> } | { ok: false, message: string }>} */
export async function loadAppConfigMap() {
try {
const map = await invoke("get_app_config");
if (map && typeof map === "object" && Object.keys(map).length > 0) {
return { ok: true, map };
}
const serverMsg = await invoke("get_app_config_last_message");
if (serverMsg) {
return { ok: false, message: String(serverMsg) };
}
return {
ok: false,
message: "未获取到应用配置,请登录服务端或在「本地配置」中添加密钥",
};
} catch {
return {
ok: false,
message: "请在 Tauri 桌面端使用,并登录或在「本地配置」中设置密钥",
};
}
}
/** 一键仿写 / 在线 ASR 等需百炼密钥 */
export async function ensureAppConfigReady() {
const loaded = await loadAppConfigMap();
if (!loaded.ok) return loaded;
const apiKey = loaded.map.BAILIAN_API_KEY || loaded.map.DASHSCOPE_API_KEY;
if (!apiKey) {
return {
ok: false,
message:
"缺少 BAILIAN_API_KEY或 DASHSCOPE_API_KEY请在配置管理或「本地配置」中设置",
};
}
return { ok: true };
}
/** 撰写文案 / LLM 改写需文本模型配置 */
export async function ensureLlmConfigReady() {
const loaded = await loadAppConfigMap();
if (!loaded.ok) return loaded;
const { map } = loaded;
const apiKey = map.LLM_API_KEY || map.API_KEY;
const apiUrl = map.LLM_BASE_URL || map.LLM_API_URL;
if (!apiKey) {
return {
ok: false,
message: "缺少 LLM_API_KEY请在配置管理或「本地配置」中设置文本模型",
};
}
if (!apiUrl) {
return {
ok: false,
message: "缺少 LLM_BASE_URL或 LLM_API_URL请配置文本模型接口地址",
};
}
return { ok: true };
}
const DOUYIN_RE = /douyin\.com|iesdouyin\.com|v\.douyin\.com/i;
const DIRECT_VIDEO_RE = /\.(mp4|mov|m4v|webm|mkv|flv|avi)(\?|$)/i;
/** @returns {{ ok: true, value: string } | { ok: false, message: string }} */
export function validateShareText(text) {
const raw = String(text || "").trim();
if (!raw) {
return { ok: false, message: "请粘贴视频分享文本或视频链接" };
}
const urls = raw.match(/https?:\/\/[^\s\u4e00-\u9fa5]+/gi) || [];
if (urls.some((u) => DIRECT_VIDEO_RE.test(u))) {
return { ok: true, value: raw };
}
if (DOUYIN_RE.test(raw) || urls.some((u) => DOUYIN_RE.test(u))) {
return { ok: true, value: raw };
}
return {
ok: false,
message:
"请输入抖音分享文本、抖音视频链接或直接视频文件链接mp4/mov/webm/mkv/avi",
};
}
export const DEFAULT_REWRITE_CONFIG = {
style: "casual",
length: "medium",
keepHashtags: true,
keepEmoji: true,
};

View File

@@ -1,110 +0,0 @@
/** 系统音色(对齐 Electron SoundPromptDialog 内置列表) */
export const VOICE_DIALECT_IDS = new Set([
"sys_Jada",
"sys_Dylan",
"sys_Li",
"sys_Marcus",
"sys_Roy",
"sys_Peter",
"sys_Sunny",
"sys_Eric",
"sys_Rocky",
"sys_Kiki",
]);
export const VOICE_FOREIGN_IDS = new Set([
"sys_Jennifer",
"sys_Aiden",
"sys_Bodega",
"sys_Sonrisa",
"sys_Alek",
"sys_Dolce",
"sys_Sohee",
"sys_Ono_Anna",
"sys_Lenn",
"sys_Emilien",
"sys_Katerina",
"sys_Andre",
"sys_Radio_Gol",
]);
export const SYSTEM_VOICES = [
{ id: "sys_Cherry", title: "芊悦(普通话女声)", desc: "阳光积极、亲切自然小姐姐|适合:普通话", aliyunVoiceId: "Cherry" },
{ id: "sys_Serena", title: "苏瑶(普通话女声)", desc: "温柔小姐姐|适合:普通话", aliyunVoiceId: "Serena" },
{ id: "sys_Ethan", title: "晨煦(普通话男声)", desc: "阳光温暖、活力朝气|适合:普通话", aliyunVoiceId: "Ethan" },
{ id: "sys_Chelsie", title: "千雪(二次元女声)", desc: "二次元虚拟女友|适合:普通话", aliyunVoiceId: "Chelsie" },
{ id: "sys_Momo", title: "茉兔(活泼女声)", desc: "撒娇搞怪,逗你开心|适合:普通话", aliyunVoiceId: "Momo" },
{ id: "sys_Vivian", title: "十三(个性女声)", desc: "拽拽的、可爱的小暴躁|适合:普通话", aliyunVoiceId: "Vivian" },
{ id: "sys_Moon", title: "月白(率性男声)", desc: "率性帅气的月白|适合:普通话", aliyunVoiceId: "Moon" },
{ id: "sys_Maia", title: "四月(知性女声)", desc: "知性与温柔的碰撞|适合:普通话", aliyunVoiceId: "Maia" },
{ id: "sys_Kai", title: "凯(沉浸男声)", desc: "耳朵的一场SPA适合普通话", aliyunVoiceId: "Kai" },
{ id: "sys_Nofish", title: "不吃鱼(特色男声)", desc: "不会翘舌音的设计师|适合:普通话", aliyunVoiceId: "Nofish" },
{ id: "sys_Bella", title: "萌宝(萝莉女声)", desc: "喝酒不打醉拳的小萝莉|适合:普通话", aliyunVoiceId: "Bella" },
{ id: "sys_Jennifer", title: "詹妮弗(美语女声)", desc: "品牌级、电影质感般美语女声|适合:英语", aliyunVoiceId: "Jennifer" },
{ id: "sys_Ryan", title: "甜茶(张力男声)", desc: "节奏拉满,戏感炸裂|适合:普通话", aliyunVoiceId: "Ryan" },
{ id: "sys_Katerina", title: "卡捷琳娜(御姐女声)", desc: "御姐音色,韵律回味十足|适合:普通话", aliyunVoiceId: "Katerina" },
{ id: "sys_Aiden", title: "艾登(美语男声)", desc: "精通厨艺的美语大男孩|适合:英语", aliyunVoiceId: "Aiden" },
{ id: "sys_Arthur", title: "徐大爷(故事男声)", desc: "被岁月浸泡过的质朴嗓音|适合:普通话", aliyunVoiceId: "Arthur" },
{ id: "sys_Bellona", title: "燕铮莺(洪亮女声)", desc: "声音洪亮,热血沸腾|适合:普通话", aliyunVoiceId: "Bellona" },
{ id: "sys_Bunny", title: "萌小姬(萌系女声)", desc: "萌属性爆棚的小萝莉|适合:普通话", aliyunVoiceId: "Bunny" },
{ id: "sys_Mia", title: "乖小妹(温顺女声)", desc: "温顺如春水,乖巧如初雪|适合:普通话", aliyunVoiceId: "Mia" },
{ id: "sys_Mochi", title: "沙小弥(早慧童声)", desc: "聪明伶俐的小大人|适合:普通话", aliyunVoiceId: "Mochi" },
{ id: "sys_Neil", title: "阿闻(新闻男声)", desc: "字正腔圆的新闻主持人|适合:普通话", aliyunVoiceId: "Neil" },
{ id: "sys_Nini", title: "邻家妹妹(甜美女声)", desc: "糯米糍一样又软又黏|适合:普通话", aliyunVoiceId: "Nini" },
{ id: "sys_Ebona", title: "诡婆婆(惊悚女声)", desc: "低语般的惊悚氛围|适合:普通话", aliyunVoiceId: "Ebona" },
{ id: "sys_Seren", title: "小婉(助眠女声)", desc: "温和舒缓,助你入眠|适合:普通话", aliyunVoiceId: "Seren" },
{ id: "sys_Pip", title: "顽屁小孩(淘气童声)", desc: "调皮捣蛋却充满童真|适合:普通话", aliyunVoiceId: "Pip" },
{ id: "sys_Stella", title: "少女阿月(元气女声)", desc: "甜系元气少女|适合:普通话", aliyunVoiceId: "Stella" },
{ id: "sys_Vincent", title: "田叔(烟嗓男声)", desc: "独特沙哑烟嗓|适合:普通话", aliyunVoiceId: "Vincent" },
{ id: "sys_Radio_Gol", title: "拉迪奥·戈尔(足球解说)", desc: "足球诗人解说风格|适合:普通话", aliyunVoiceId: "Radio Gol" },
{ id: "sys_Jada", title: "上海-阿珍(上海话女声)", desc: "风风火火的沪上阿姐|适合:上海话", aliyunVoiceId: "Jada" },
{ id: "sys_Dylan", title: "北京-晓东(北京话男声)", desc: "北京胡同里长大的少年|适合:北京话", aliyunVoiceId: "Dylan" },
{ id: "sys_Li", title: "南京-老李(南京话男声)", desc: "耐心的瑜伽老师|适合:南京话", aliyunVoiceId: "Li" },
{ id: "sys_Marcus", title: "陕西-秦川(陕西话男声)", desc: "老陕的味道|适合:陕西话", aliyunVoiceId: "Marcus" },
{ id: "sys_Roy", title: "闽南-阿杰(闽南语男声)", desc: "诙谐直爽、市井活泼|适合:闽南语", aliyunVoiceId: "Roy" },
{ id: "sys_Peter", title: "天津-李彼得(天津话男声)", desc: "天津相声,专业捧哏|适合:天津话", aliyunVoiceId: "Peter" },
{ id: "sys_Sunny", title: "四川-晴儿(四川话女声)", desc: "甜到心里的川妹子|适合:四川话", aliyunVoiceId: "Sunny" },
{ id: "sys_Eric", title: "四川-程川(四川话男声)", desc: "跳脱市井的成都男子|适合:四川话", aliyunVoiceId: "Eric" },
{ id: "sys_Rocky", title: "粤语-阿强(粤语男声)", desc: "幽默风趣,在线陪聊|适合:粤语", aliyunVoiceId: "Rocky" },
{ id: "sys_Kiki", title: "粤语-阿清(粤语女声)", desc: "甜美的港妹闺蜜|适合:粤语", aliyunVoiceId: "Kiki" },
{ id: "sys_Bodega", title: "博德加(西语男声)", desc: "热情的西班牙大叔|适合:西班牙语", aliyunVoiceId: "Bodega" },
{ id: "sys_Sonrisa", title: "索尼莎(西语女声)", desc: "热情开朗的拉美大姐|适合:西班牙语", aliyunVoiceId: "Sonrisa" },
{ id: "sys_Alek", title: "阿列克(俄语男声)", desc: "战斗民族的冷与暖|适合:俄语", aliyunVoiceId: "Alek" },
{ id: "sys_Dolce", title: "多尔切(意语男声)", desc: "慵懒的意大利大叔|适合:意大利语", aliyunVoiceId: "Dolce" },
{ id: "sys_Sohee", title: "素熙(韩语女声)", desc: "温柔开朗,情绪丰富|适合:韩语", aliyunVoiceId: "Sohee" },
{ id: "sys_Ono_Anna", title: "小野杏(日语女声)", desc: "鬼灵精怪的青梅竹马|适合:日语", aliyunVoiceId: "Ono Anna" },
{ id: "sys_Lenn", title: "莱恩(德语男声)", desc: "理性底色,叛逆细节|适合:德语", aliyunVoiceId: "Lenn" },
{ id: "sys_Emilien", title: "埃米尔安(法语男声)", desc: "浪漫的法国大哥哥|适合:法语", aliyunVoiceId: "Emilien" },
{ id: "sys_Andre", title: "安德雷(磁性男声)", desc: "自然舒服、沉稳男声|适合:普通话", aliyunVoiceId: "Andre" },
];
export const DEFAULT_SYSTEM_VOICE_ID = "sys_Cherry";
export const VOICE_CATEGORY_OPTIONS = [
{ key: "putonghua", label: "普通话" },
{ key: "waiwen", label: "外文" },
{ key: "fangyan", label: "方言" },
];
/**
* @param {'putonghua'|'waiwen'|'fangyan'} category
*/
export function filterSystemVoices(category) {
if (category === "fangyan") {
return SYSTEM_VOICES.filter((v) => VOICE_DIALECT_IDS.has(v.id));
}
if (category === "waiwen") {
return SYSTEM_VOICES.filter((v) => VOICE_FOREIGN_IDS.has(v.id));
}
return SYSTEM_VOICES.filter(
(v) => !VOICE_DIALECT_IDS.has(v.id) && !VOICE_FOREIGN_IDS.has(v.id),
);
}
/**
* @param {string} voiceId
*/
export function findSystemVoice(voiceId) {
return SYSTEM_VOICES.find((v) => v.id === voiceId) ?? null;
}

View File

@@ -1,12 +0,0 @@
<script setup>
import AppSidebar from "../components/AppSidebar.vue";
</script>
<template>
<div class="flex h-full min-h-0 bg-bg-deep">
<AppSidebar />
<div class="main-content min-w-0 flex-1 overflow-hidden">
<RouterView />
</div>
</div>
</template>

View File

@@ -1,106 +0,0 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import { listen } from "@tauri-apps/api/event";
import PrimeVue from "primevue/config";
import { AiclientPreset } from "./theme/aiclient-preset.js";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Password from "primevue/password";
import Message from "primevue/message";
import Card from "primevue/card";
import Select from "primevue/select";
import Textarea from "primevue/textarea";
import InputNumber from "primevue/inputnumber";
import Slider from "primevue/slider";
import Checkbox from "primevue/checkbox";
import RadioButton from "primevue/radiobutton";
import SelectButton from "primevue/selectbutton";
import Tabs from "primevue/tabs";
import TabList from "primevue/tablist";
import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
import DatePicker from "primevue/datepicker";
import Dialog from "primevue/dialog";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Tag from "primevue/tag";
import DashboardCard from "./components/dashboard/DashboardCard.vue";
import App from "./App.vue";
import router from "./router";
import { useAuthStore } from "./stores/auth.js";
import "primeicons/primeicons.css";
import "./styles/main.css";
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(PrimeVue, {
theme: {
preset: AiclientPreset,
options: {
darkModeSelector: ".dark",
cssLayer: false,
},
},
});
app.component("Button", Button);
app.component("InputText", InputText);
app.component("Password", Password);
app.component("Message", Message);
app.component("Card", Card);
app.component("Select", Select);
app.component("Textarea", Textarea);
app.component("InputNumber", InputNumber);
app.component("Slider", Slider);
app.component("Checkbox", Checkbox);
app.component("RadioButton", RadioButton);
app.component("SelectButton", SelectButton);
app.component("Tabs", Tabs);
app.component("TabList", TabList);
app.component("Tab", Tab);
app.component("TabPanels", TabPanels);
app.component("TabPanel", TabPanel);
app.component("DashboardCard", DashboardCard);
app.component("DatePicker", DatePicker);
app.component("Dialog", Dialog);
app.component("DataTable", DataTable);
app.component("Column", Column);
app.component("Tag", Tag);
const authStore = useAuthStore(pinia);
authStore.hydrateRustSession();
function setupHeartbeatLogoutListener() {
if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) {
return;
}
listen("auth-heartbeat-failed", async (event) => {
const message =
typeof event.payload === "string"
? event.payload
: event.payload?.message ?? "会话已失效";
console.warn("[heartbeat] failed:", message);
if (!authStore.isAuthenticated) {
return;
}
authStore.redirectToCardActivate(router);
});
}
function setupVipWatch() {
authStore.$subscribe(() => {
if (!authStore.needsVipActivation) return;
if (router.currentRoute.value.name === "card-activate") return;
authStore.redirectToCardActivate(router);
});
}
setupHeartbeatLogoutListener();
setupVipWatch();
app.mount("#app");

View File

@@ -1,446 +0,0 @@
import { createRouter, createWebHashHistory } from "vue-router";
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../stores/auth";
const placeholder = () => import("../views/PlaceholderView.vue");
const mainChildren = [
{
path: "",
name: "home",
component: () => import("../views/HomeView.vue"),
},
{
path: "avatar",
name: "avatar",
component: () => import("../views/AvatarView.vue"),
meta: { title: "形象" },
},
{
path: "material",
name: "material",
component: () => import("../views/MaterialView.vue"),
meta: { title: "素材" },
},
{
path: "voice",
name: "voice",
component: () => import("../views/VoiceView.vue"),
meta: { title: "声音" },
},
// {
// path: "compose",
// name: "compose",
// component: placeholder,
// meta: { title: "合成" },
// },
// {
// path: "extract",
// name: "extract",
// component: placeholder,
// meta: { title: "提取" },
// },
// {
// path: "design",
// name: "design",
// component: placeholder,
// meta: { title: "设计" },
// },
// {
// path: "model",
// name: "model",
// component: placeholder,
// meta: { title: "模型" },
// },
// {
// path: "help",
// name: "help",
// component: placeholder,
// meta: { title: "帮助" },
// },
{
path: "local-config",
name: "local-config",
component: () => import("../views/LocalConfigView.vue"),
meta: { title: "本地配置" },
},
{
path: "card-activate",
name: "card-activate",
component: () => import("../views/CardActivateView.vue"),
meta: { title: "卡密激活" },
},
{
path: "admin",
component: () => import("../views/AdminView.vue"),
meta: { title: "管理", requiresRole: ROLE_ADMIN },
redirect: { name: "admin-users" },
children: [
{
path: "",
name: "admin-overview",
component: () => import("../views/admin/AdminOverviewView.vue"),
meta: { title: "概览" },
},
{
path: "users",
name: "admin-users",
component: () => import("../views/admin/AdminUsersView.vue"),
meta: { title: "用户管理" },
},
{
path: "desktop-config",
name: "admin-desktop-config",
component: () => import("../views/admin/AdminDesktopConfigView.vue"),
meta: { title: "桌面配置" },
},
{
path: "card-keys",
name: "admin-card-keys",
component: () => import("../views/admin/AdminCardKeysView.vue"),
meta: { title: "卡密管理" },
},
],
},
{
path: "oem",
component: () => import("../views/OemView.vue"),
meta: { title: "管理", requiresRole: ROLE_OEM},
redirect: { name: "oem-users" },
children: [
{
path: "",
name: "oem-overview",
component: () => import("../views/oem/OemOverviewView.vue"),
meta: { title: "概览" },
},
{
path: "users",
name: "oem-users",
component: () => import("../views/oem/OemUsersView.vue"),
meta: { title: "用户管理" },
},
{
path: "desktop-config",
name: "oem-desktop-config",
component: () => import("../views/oem/OemDesktopConfigView.vue"),
meta: { title: "桌面配置" },
},
{
path: "card-keys",
name: "oem-card-keys",
component: () => import("../views/oem/OemCardKeysView.vue"),
meta: { title: "卡密管理" },
},
],
},
{
path: "agent",
name: "agent",
component: () => import("../views/AgentView.vue"),
meta: { title: "代理", requiresRole: ROLE_AGENT },
children: [
{
path: "users",
name: "agent-users",
component: () => import("../views/agent/AgentUsersView.vue"),
meta: { title: "用户管理" },
},
{
path: "card-keys",
name: "agent-card-keys",
component: () => import("../views/agent/AgentCardKeysView.vue"),
meta: { title: "卡密管理" },
},
],
},
];
if (import.meta.env.DEV) {
mainChildren.push({
path: "debug",
name: "debug",
component: () => import("../views/DebugView.vue"),
meta: { title: "调试", devOnly: true },
});
}
const routes = [
{
path: "/",
component: () => import("../layouts/MainLayout.vue"),
meta: { requiresAuth: true },
children: mainChildren,
},
{
path: "/login",
name: "login",
component: () => import("../views/LoginView.vue"),
},
{
path: "/register",
name: "register",
component: () => import("../views/RegisterView.vue"),
},
{
path: "/:pathMatch(.*)*",
redirect: { name: "login" },
},
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.name === "card-activate") {
return true;
}
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
if (requiresAuth && !auth.isAuthenticated) {
return { name: "login" };
}
if (auth.needsVipActivation) {
return {
name: "card-activate",
query: { username: auth.currentUser?.username ?? "" },
};
}
if ((to.name === "login" || to.name === "register") && auth.isAuthenticated) {
return { name: "home" };
}
const requiredRole = to.matched
.map((record) => record.meta.requiresRole)
.find((role) => role != null);
if (requiredRole != null && auth.roleId !== requiredRole) {
return { name: "home" };
}
});
export default router;

View File

@@ -1,47 +0,0 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
function ensureTauri() {
if (!isTauri()) {
throw new Error("语音识别需在 Tauri 桌面端使用");
}
}
/**
* @typedef {{ id: number, name: string, sourcePath: string, resultText: string, createdAt: string }} AsrHistoryRecord
*/
/** @returns {Promise<AsrHistoryRecord[]>} */
export async function listAsrHistory() {
ensureTauri();
return invoke("list_asr_history");
}
/**
* @param {string} filePath
* @param {string} [model]
* @returns {Promise<AsrHistoryRecord>}
*/
export async function transcribeMediaFile(filePath, model) {
ensureTauri();
return invoke("transcribe_media_file", {
filePath,
model: model || null,
});
}
/**
* @param {number} id
*/
export async function deleteAsrHistory(id) {
ensureTauri();
return invoke("delete_asr_history", { id });
}
/**
* @param {number} id
* @param {string} name
*/
export async function updateAsrHistoryName(id, name) {
ensureTauri();
return invoke("update_asr_history_name", { id, name });
}

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