11111
33
.cursor/rules/aiclient-ui-overview.mdc
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: aiclient_ui 前端项目约定
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# aiclient_ui 项目约定
|
||||
|
||||
## 职责
|
||||
|
||||
本仓库仅包含 **Vue 3 前端**,不含 Tauri / Rust。桌面壳在同级目录 `../aiclient`。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3 + Vite 6,**纯 JavaScript**
|
||||
- Tailwind CSS v4 + PrimeVue 4
|
||||
- Pinia + Vue Router
|
||||
- `@tauri-apps/api`:在 Tauri 窗口内调用桌面能力
|
||||
|
||||
## 常用命令
|
||||
|
||||
- `npm run dev` — http://localhost:1420
|
||||
- `npm run build` — 输出 `dist/`(本地预览)
|
||||
- `npm run build:deploy` — 部署到服务器静态目录(桌面端 Release 加载此地址)
|
||||
|
||||
## API
|
||||
|
||||
- 基址:`VITE_API_BASE_URL`(见 `.env.example`)
|
||||
- 开发代理:`vite.config.js` 将 `/api` 代理到 `http://127.0.0.1:8001`
|
||||
- 响应信封 `ApiResponse` 约定见 aiclient 或 pythonbackend 规则
|
||||
|
||||
## 通用原则
|
||||
|
||||
- 界面文案默认简体中文
|
||||
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# 开发默认走 Vite 代理 /api -> http://127.0.0.1:8001
|
||||
VITE_API_BASE_URL=http://81.70.234.81:8001/api/v1
|
||||
1
.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://81.71.163.140:8001/api/v1
|
||||
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
.env
|
||||
.env.local
|
||||
33
README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# aiclient-ui
|
||||
|
||||
aiclient 桌面端的前端工程(Vue 3 + Vite 6)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3、Vue Router、Pinia
|
||||
- PrimeVue 4 + Tailwind CSS v4
|
||||
- 通过 `@tauri-apps/api` 调用桌面壳能力(开发时由 `aiclient` 仓库的 Tauri 加载)
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 仅 Web 开发,http://localhost:1420
|
||||
npm run build # 输出到 dist/(本地预览)
|
||||
npm run build:deploy # 部署到服务器(发布前端请用此命令)
|
||||
```
|
||||
|
||||
## 与 aiclient(Tauri)联调
|
||||
|
||||
桌面壳 **不打包** 本仓库资源;发布版直接加载服务器上的静态页。
|
||||
|
||||
在 `../aiclient` 目录执行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 调试:WebView 连 http://localhost:1420
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
见 `.env.example`。`VITE_API_BASE_URL` 为业务 API 基址;开发时 `/api` 由 Vite 代理到 `http://127.0.0.1:8001`。
|
||||
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
2557
package-lock.json
generated
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "aiclient-ui",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:deploy": "cross-env VITE_BUILD_OUT_DIR=/root/yaoyan/yaoyan_admin/images/newyaoyan vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^7.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-monaco-editor": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
4543
public/subtitle-templates/system-templates.json
Normal file
12
src/App.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
113
src/api/adminAppBranding.js
Normal file
@@ -0,0 +1,113 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载默认 OEM(id=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 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存默认 OEM(id=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 },
|
||||
};
|
||||
}
|
||||
72
src/api/adminCardKeys.js
Normal file
@@ -0,0 +1,72 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
62
src/api/adminDesktopConfigs.js
Normal file
@@ -0,0 +1,62 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
82
src/api/adminUsers.js
Normal file
@@ -0,0 +1,82 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
50
src/api/agentCardKeys.js
Normal file
@@ -0,0 +1,50 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
38
src/api/agentUsers.js
Normal file
@@ -0,0 +1,38 @@
|
||||
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}`);
|
||||
}
|
||||
47
src/api/auth.js
Normal file
@@ -0,0 +1,47 @@
|
||||
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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
14
src/api/cardKeys.js
Normal file
@@ -0,0 +1,14 @@
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
59
src/api/client.js
Normal file
@@ -0,0 +1,59 @@
|
||||
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 };
|
||||
113
src/api/oemAppBranding.js
Normal file
@@ -0,0 +1,113 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载默认 OEM(id=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 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存默认 OEM(id=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 },
|
||||
};
|
||||
}
|
||||
63
src/api/oemCardKeys.js
Normal file
@@ -0,0 +1,63 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
62
src/api/oemDesktopConfigs.js
Normal file
@@ -0,0 +1,62 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
35
src/api/oemSoftwareInfo.js
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
72
src/api/oemUsers.js
Normal file
@@ -0,0 +1,72 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
6
src/assets/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
src/assets/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
122
src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<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>
|
||||
256
src/components/AppTitlebar.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<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>
|
||||
24
src/components/AuthLayout.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<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>
|
||||
101
src/components/admin/AdminImageUpload.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<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>
|
||||
31
src/components/admin/AdminSubNav.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<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>
|
||||
101
src/components/agent/AgentImageUpload.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<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>
|
||||
30
src/components/agent/AgentSubNav.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<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>
|
||||
151
src/components/avatar/AvatarEditDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<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>视频时长建议 10~30 秒,格式 MP4,分辨率 1080p~4K</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>
|
||||
48
src/components/dashboard/DashboardCard.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<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>
|
||||
9
src/components/dashboard/StepBadge.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
step: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="step-badge">{{ step }}</span>
|
||||
</template>
|
||||
101
src/components/oem/OemImageUpload.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<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>
|
||||
31
src/components/oem/OemSubNav.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<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>
|
||||
394
src/components/subtitle/KeywordEffectsSettings.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<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>
|
||||
464
src/components/subtitle/SubtitlePreviewCard.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<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>
|
||||
264
src/components/subtitle/SubtitleStyleSelector.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<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>
|
||||
349
src/components/subtitle/SubtitleTemplateDialog.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<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>
|
||||
60
src/components/subtitle/SubtitleTemplateEditor.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<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>
|
||||
167
src/components/subtitle/TitleLineStyleEditor.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<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>
|
||||
158
src/components/subtitle/TitleSubtitleSettings.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<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>
|
||||
119
src/components/voice/BatchTextSynthesizeDialog.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<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>
|
||||
203
src/components/voice/VoiceReferenceRecorder.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<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">
|
||||
参考声音控制在 6~20s,保证声音清晰可见
|
||||
</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>
|
||||
108
src/components/workflow/AddIpBrainDialog.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
65
src/components/workflow/HitScriptDialog.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<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>
|
||||
150
src/components/workflow/LegalCheckDialog.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<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>
|
||||
343
src/components/workflow/Step01IpLearning.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<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>
|
||||
283
src/components/workflow/Step02AudioVideo.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<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>
|
||||
164
src/components/workflow/Step03VideoEdit.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<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>
|
||||
141
src/components/workflow/Step04TitleTags.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<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>
|
||||
211
src/components/workflow/Step05SubtitleMusic.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<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>
|
||||
178
src/components/workflow/Step06Cover.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<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>
|
||||
530
src/components/workflow/Step07Publish.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<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>
|
||||
101
src/components/workflow/StepScriptEditor.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<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>
|
||||
118
src/components/workflow/VideoLinkDialog.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<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>
|
||||
352
src/components/workflow/VoiceCloneEditDialog.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<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 或已完成复刻时保存不再做 6~20s 校验。
|
||||
* @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. 录音时长控制在 6~20秒 最佳,最多不超过 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>
|
||||
359
src/components/workflow/VoiceManageDialog.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<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>
|
||||
114
src/components/workflow/WorkflowQuickActions.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<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>
|
||||
112
src/config/coverTemplates.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/** 封面样式预设(简易 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);
|
||||
}
|
||||
162
src/config/subtitleStylePresets.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/** 字幕样式字体与快速预设(对齐 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",
|
||||
};
|
||||
}
|
||||
63
src/config/subtitleTemplates.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/** 字幕样式预设(对齐 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}`;
|
||||
}
|
||||
8
src/config/ttsModels.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** 语音合成模型选项(对齐 Electron CosyVoice 版本说明) */
|
||||
export const TTS_MODEL_OPTIONS = [
|
||||
{
|
||||
label: "V1 多语言/方言",
|
||||
value: "cosyvoice-v3-flash",
|
||||
hint: "选择他国语言需要文案也对应他国,选择方言需对应匹配的音色或克隆音色本身是方言",
|
||||
},
|
||||
];
|
||||
96
src/config/videoPipeline.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 流水线配置校验: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,
|
||||
};
|
||||
110
src/data/systemVoices.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/** 系统音色(对齐 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;
|
||||
}
|
||||
12
src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
106
src/main.js
Normal file
@@ -0,0 +1,106 @@
|
||||
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");
|
||||
446
src/router/index.js
Normal file
@@ -0,0 +1,446 @@
|
||||
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;
|
||||
|
||||
47
src/services/asrDb.js
Normal file
@@ -0,0 +1,47 @@
|
||||
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 });
|
||||
}
|
||||
49
src/services/audioDb.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("语音历史需在 Tauri 桌面端使用");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, filePath: string, sourceText: string, createdAt: string }} GeneratedAudioRecord
|
||||
*/
|
||||
|
||||
/** @returns {Promise<GeneratedAudioRecord[]>} */
|
||||
export async function listGeneratedAudios() {
|
||||
ensureTauri();
|
||||
return invoke("list_generated_audios");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name 可为空,空时由后端填入当前时间
|
||||
* @param {string} filePath
|
||||
* @param {string} [sourceText]
|
||||
* @returns {Promise<GeneratedAudioRecord>}
|
||||
*/
|
||||
export async function insertGeneratedAudio(name, filePath, sourceText = "") {
|
||||
ensureTauri();
|
||||
return invoke("insert_generated_audio", {
|
||||
name,
|
||||
filePath,
|
||||
sourceText: sourceText || "",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function updateGeneratedAudioName(id, name) {
|
||||
ensureTauri();
|
||||
return invoke("update_generated_audio_name", { id, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
export async function deleteGeneratedAudio(id) {
|
||||
ensureTauri();
|
||||
return invoke("delete_generated_audio", { id });
|
||||
}
|
||||
89
src/services/avatarDb.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
const LEGACY_STORAGE_KEY = "aiclient_video_templates";
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("形象管理需在 Tauri 桌面端使用");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} AvatarRecord
|
||||
*/
|
||||
|
||||
/** @returns {Promise<AvatarRecord[]>} */
|
||||
export async function listAvatars() {
|
||||
ensureTauri();
|
||||
return invoke("list_avatars");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<AvatarRecord>}
|
||||
*/
|
||||
export async function insertAvatar(name, filePath) {
|
||||
ensureTauri();
|
||||
return invoke("insert_avatar", { name, filePath });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function updateAvatarName(id, name) {
|
||||
ensureTauri();
|
||||
return invoke("update_avatar_name", { id, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
export async function deleteAvatar(id) {
|
||||
ensureTauri();
|
||||
return invoke("delete_avatar", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number | null} [excludeId]
|
||||
*/
|
||||
export async function avatarNameExists(name, excludeId = null) {
|
||||
ensureTauri();
|
||||
return invoke("avatar_name_exists", {
|
||||
name,
|
||||
excludeId: excludeId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 将旧版 localStorage 数据迁入 SQLite(仅当库为空且存在 legacy 数据时) */
|
||||
export async function migrateLegacyAvatarsIfNeeded() {
|
||||
ensureTauri();
|
||||
const current = await listAvatars();
|
||||
if (current.length > 0) return;
|
||||
|
||||
let legacy = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
legacy = Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
} catch {
|
||||
legacy = [];
|
||||
}
|
||||
if (!legacy.length) return;
|
||||
|
||||
for (const item of legacy) {
|
||||
const name = String(item.name || "").trim();
|
||||
const filePath = String(item.video || item.filePath || "").trim();
|
||||
if (!name || !filePath) continue;
|
||||
try {
|
||||
await insertAvatar(name, filePath);
|
||||
} catch {
|
||||
/* 重名等跳过 */
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
32
src/services/avatarVideo.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickVideoFile() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端选择视频文件");
|
||||
}
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频",
|
||||
extensions: ["mp4", "mov", "webm", "mkv", "m4v", "avi"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sourcePath
|
||||
* @returns {Promise<string>} 应用内保存路径
|
||||
*/
|
||||
export async function importAvatarVideo(sourcePath) {
|
||||
return invoke("import_avatar_video", { sourcePath });
|
||||
}
|
||||
|
||||
113
src/services/coverGenerate.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import {
|
||||
buildCoverEffectStyle,
|
||||
getCoverTemplate,
|
||||
pickCoverTitleText,
|
||||
} from "../config/coverTemplates.js";
|
||||
|
||||
const COVER_SCRIPT = "cover_generate.js";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickCoverMaterialVideo() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频",
|
||||
extensions: ["mp4", "mov", "mkv", "avi", "webm"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function localImageToPlayableUrl(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端预览封面");
|
||||
}
|
||||
return convertFileSrc(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateCover(store) {
|
||||
const videoPath = String(
|
||||
store.processedVideoPath || store.generatedVideoPath || "",
|
||||
).trim();
|
||||
if (!videoPath) {
|
||||
return { ok: false, message: "请先在步骤 02 生成口播视频(步骤 03/05 处理后亦可)" };
|
||||
}
|
||||
|
||||
const titleText = pickCoverTitleText(store.titleGenerated);
|
||||
if (!titleText) {
|
||||
return { ok: false, message: "请先在步骤 04 生成标题文字" };
|
||||
}
|
||||
|
||||
const tpl = getCoverTemplate(store.coverTemplateId || "default");
|
||||
const effectStyle = buildCoverEffectStyle(tpl, {
|
||||
customVideoPath: store.coverCustomVideoPath || "",
|
||||
});
|
||||
|
||||
store.coverGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: COVER_SCRIPT,
|
||||
params: {
|
||||
videoPath,
|
||||
titleText,
|
||||
effectStyle,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.coverPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "封面生成失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
store.coverImagePath = result.coverPath;
|
||||
store.coverImageSrc = localImageToPlayableUrl(result.coverPath);
|
||||
if (Array.isArray(result.previewImages) && result.previewImages.length) {
|
||||
store.coverPreviewImages = result.previewImages;
|
||||
}
|
||||
|
||||
const modeHint =
|
||||
result.mode === "advanced_python"
|
||||
? "(高级模板)"
|
||||
: result.mode === "pil_python"
|
||||
? "(PIL)"
|
||||
: "";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "封面生成完成") + modeHint,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `封面生成失败:${msg}` };
|
||||
} finally {
|
||||
store.coverGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function chooseCoverMaterial(store) {
|
||||
const path = await pickCoverMaterialVideo();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
store.coverCustomVideoPath = path;
|
||||
return { ok: true, message: "已选择封面素材视频" };
|
||||
}
|
||||
101
src/services/fileExport.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端下载文件");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
function basename(filePath) {
|
||||
return String(filePath || "")
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} filePath
|
||||
* @param {string} fallbackExt
|
||||
*/
|
||||
export function suggestExportName(name, filePath, fallbackExt = "bin") {
|
||||
const base = basename(filePath) || "";
|
||||
const dot = base.lastIndexOf(".");
|
||||
const ext = dot > 0 ? base.slice(dot) : `.${fallbackExt}`;
|
||||
const safe = String(name || "file").replace(/[<>:"/\\|?*]/g, "_").trim() || "file";
|
||||
if (safe.toLowerCase().endsWith(ext.toLowerCase())) return safe;
|
||||
return `${safe}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} defaultName
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
function extensionFromName(defaultName) {
|
||||
const base = basename(defaultName) || "";
|
||||
const dot = base.lastIndexOf(".");
|
||||
if (dot <= 0 || dot === base.length - 1) return undefined;
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sourcePath
|
||||
* @param {string} defaultName
|
||||
* @returns {Promise<{ ok: true } | { ok: false, cancelled: true }>}
|
||||
*/
|
||||
export async function exportLocalFile(sourcePath, defaultName) {
|
||||
ensureTauri();
|
||||
const ext = extensionFromName(defaultName);
|
||||
const dest = await save({
|
||||
title: "保存文件",
|
||||
defaultPath: defaultName,
|
||||
filters: ext ? [{ name: "文件", extensions: [ext] }] : undefined,
|
||||
});
|
||||
if (dest == null) {
|
||||
return { ok: false, cancelled: true };
|
||||
}
|
||||
await invoke("copy_local_file", {
|
||||
sourcePath,
|
||||
destPath: String(dest),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string} defaultName
|
||||
* @returns {Promise<{ ok: true } | { ok: false, cancelled: true }>}
|
||||
*/
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export async function revealLocalFileInFolder(filePath) {
|
||||
ensureTauri();
|
||||
await invoke("reveal_local_file_in_folder", { path: filePath });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string} defaultName
|
||||
* @returns {Promise<{ ok: true } | { ok: false, cancelled: true }>}
|
||||
*/
|
||||
export async function exportTextFile(text, defaultName) {
|
||||
ensureTauri();
|
||||
const dest = await save({
|
||||
title: "保存文本",
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: "文本", extensions: ["txt"] }],
|
||||
});
|
||||
if (dest == null) {
|
||||
return { ok: false, cancelled: true };
|
||||
}
|
||||
await invoke("write_local_text_file", {
|
||||
path: String(dest),
|
||||
content: String(text ?? ""),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
22
src/services/localAudio.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
export function mimeFromAudioPath(filePath) {
|
||||
const lower = String(filePath || "").toLowerCase();
|
||||
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
||||
if (lower.endsWith(".ogg")) return "audio/ogg";
|
||||
if (lower.endsWith(".webm")) return "audio/webm";
|
||||
if (lower.endsWith(".m4a")) return "audio/mp4";
|
||||
return "audio/wav";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export async function localAudioToPlayableUrl(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端使用音频播放功能");
|
||||
}
|
||||
const base64 = await invoke("read_local_file_base64", { path: filePath });
|
||||
const mime = mimeFromAudioPath(filePath);
|
||||
return `data:${mime};base64,${base64}`;
|
||||
}
|
||||
11
src/services/localVideo.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function localVideoToPlayableUrl(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端使用视频播放功能");
|
||||
}
|
||||
return convertFileSrc(filePath);
|
||||
}
|
||||
49
src/services/materialDb.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("素材管理需在 Tauri 桌面端使用");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, path: string, type: 'image' | 'video', info: object, createdAt: number, updatedAt: number }} MaterialRecord
|
||||
*/
|
||||
|
||||
/** @returns {Promise<MaterialRecord[]>} */
|
||||
export async function listMaterials() {
|
||||
ensureTauri();
|
||||
return invoke("list_materials");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function updateMaterialName(id, name) {
|
||||
ensureTauri();
|
||||
return invoke("update_material_name", { id, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
export async function deleteMaterial(id) {
|
||||
ensureTauri();
|
||||
return invoke("delete_material", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} sourcePaths
|
||||
* @returns {Promise<MaterialRecord[]>}
|
||||
*/
|
||||
export async function importMaterialFiles(sourcePaths) {
|
||||
ensureTauri();
|
||||
return invoke("import_material_files", { sourcePaths });
|
||||
}
|
||||
|
||||
export const MATERIAL_UPDATED_EVENT = "video-material-updated";
|
||||
|
||||
export function dispatchMaterialUpdated() {
|
||||
window.dispatchEvent(new CustomEvent(MATERIAL_UPDATED_EVENT));
|
||||
}
|
||||
50
src/services/materialMedia.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
|
||||
const MEDIA_EXTENSIONS = [
|
||||
"mp4",
|
||||
"mov",
|
||||
"avi",
|
||||
"mkv",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
];
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function pickMaterialFiles() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端选择素材文件");
|
||||
}
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
title: "选择素材文件",
|
||||
filters: [
|
||||
{
|
||||
name: "媒体文件",
|
||||
extensions: MEDIA_EXTENSIONS,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return [];
|
||||
if (Array.isArray(selected)) {
|
||||
return selected.map(String).filter(Boolean);
|
||||
}
|
||||
return [String(selected)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function localMediaToPlayableUrl(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端预览本地素材");
|
||||
}
|
||||
return convertFileSrc(filePath);
|
||||
}
|
||||
15
src/services/mediaDuration.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function getMediaDurationSeconds(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("获取媒体时长需在 Tauri 桌面端使用");
|
||||
}
|
||||
const path = String(filePath || "").trim();
|
||||
if (!path) return 0;
|
||||
const secs = await invoke("get_media_duration_seconds", { path });
|
||||
return typeof secs === "number" && secs > 0 ? secs : 0;
|
||||
}
|
||||
200
src/services/oneClickPipeline.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 一键智能流程(对齐 Electron executeOneClickIntelligence / kS)
|
||||
*
|
||||
* 顺序:标题标签 → 语音 → 口播视频 → 视频编辑 → 字幕/BGM → 封面 →(发布待实现则跳过)
|
||||
*/
|
||||
|
||||
const STEP_DEFS = [
|
||||
{ key: "titleTags", label: "正在生成标题标签关键词…", progress: 10 },
|
||||
{ key: "speech", label: "正在生成语音…", progress: 25 },
|
||||
{ key: "video", label: "正在生成口播视频…", progress: 45 },
|
||||
{ key: "videoEdit", label: "正在自动剪辑视频…", progress: 58 },
|
||||
{ key: "subtitleBgm", label: "正在生成字幕和 BGM…", progress: 75 },
|
||||
{ key: "cover", label: "正在生成封面…", progress: 90 },
|
||||
{ key: "publish", label: "正在发布视频…", progress: 96 },
|
||||
];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function throwIfCancelled(store) {
|
||||
if (store.oneClickCancelRequested) {
|
||||
throw new Error("用户已取消一键智能流程");
|
||||
}
|
||||
}
|
||||
|
||||
function setProgress(store, label, percent) {
|
||||
store.oneClickStatus = label;
|
||||
store.oneClickProgress = percent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => boolean} isBusy
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
async function waitUntilIdle(isBusy, timeoutMs) {
|
||||
const start = Date.now();
|
||||
while (isBusy()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("操作超时,请重试");
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOk(result, fallback) {
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.message || fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function hasVideoEditOptions(store) {
|
||||
return store.autoCutBreath || store.pipInPicture || store.greenScreen;
|
||||
}
|
||||
|
||||
function shouldRunSubtitleBgm(store) {
|
||||
return store.autoSubtitle || store.bgmEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeOneClickPipeline(store) {
|
||||
if (store.oneClickRunning) {
|
||||
return { ok: false, message: "一键流程正在运行中" };
|
||||
}
|
||||
|
||||
const script = String(store.scriptContent || "").trim();
|
||||
if (!script) {
|
||||
return { ok: false, message: "请先在文案编辑区输入口播文案" };
|
||||
}
|
||||
if (!store.avatarSelect) {
|
||||
return { ok: false, message: "请先在步骤 02 选择形象" };
|
||||
}
|
||||
if (store.greenScreen && !store.greenScreenBackgroundPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已勾选绿幕切换,请先上传替换背景图,或取消绿幕后再一键运行",
|
||||
};
|
||||
}
|
||||
if (store.bgmEnabled && !store.bgmPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用背景音乐,请先选择音乐文件,或关闭 BGM 后再一键运行",
|
||||
};
|
||||
}
|
||||
if (store.pipInPicture) {
|
||||
const { loadVideoMixCutSettings } = await import("./videoEditProcess.js");
|
||||
const mix = await loadVideoMixCutSettings();
|
||||
const hasReplacements =
|
||||
Array.isArray(mix?.replacements) && mix.replacements.length > 0;
|
||||
const hasSimple = Boolean(mix?.simplePip?.materialPath);
|
||||
if (!hasReplacements && !hasSimple) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用画中画,请先在步骤 03 选择画中画素材",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
store.oneClickRunning = true;
|
||||
store.oneClickCancelRequested = false;
|
||||
setProgress(store, "检查文案内容…", 2);
|
||||
|
||||
const savedSubtitleFlags = {
|
||||
autoSubtitle: store.autoSubtitle,
|
||||
smartSubtitle: store.smartSubtitle,
|
||||
};
|
||||
|
||||
try {
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[0].label, STEP_DEFS[0].progress);
|
||||
assertOk(await store.generateTitleTags(), "标题标签关键词生成失败");
|
||||
await waitUntilIdle(() => store.titleTagsGenerating, 120_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[1].label, STEP_DEFS[1].progress);
|
||||
assertOk(await store.generateSpeech(), "语音生成失败");
|
||||
await waitUntilIdle(() => store.speechGenerating, 300_000);
|
||||
if (!store.generatedAudioPath) {
|
||||
throw new Error("语音生成失败,请检查 TTS 与网络配置");
|
||||
}
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[2].label, STEP_DEFS[2].progress);
|
||||
assertOk(await store.generateTalkingVideo(), "口播视频生成失败");
|
||||
await waitUntilIdle(() => store.videoGenerating, 600_000);
|
||||
if (!store.generatedVideoPath) {
|
||||
throw new Error("口播视频生成失败,请检查数字人/云端配置");
|
||||
}
|
||||
throwIfCancelled(store);
|
||||
|
||||
if (hasVideoEditOptions(store)) {
|
||||
setProgress(store, STEP_DEFS[3].label, STEP_DEFS[3].progress);
|
||||
assertOk(await store.autoProcessVideo(), "视频剪辑失败");
|
||||
await waitUntilIdle(() => store.videoProcessing, 300_000);
|
||||
throwIfCancelled(store);
|
||||
} else {
|
||||
setProgress(store, "跳过视频编辑(未勾选剪辑项)", 55);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (!shouldRunSubtitleBgm(store)) {
|
||||
store.autoSubtitle = true;
|
||||
}
|
||||
setProgress(store, STEP_DEFS[4].label, STEP_DEFS[4].progress);
|
||||
assertOk(await store.generateSubtitleAndBgm(), "字幕/BGM 处理失败");
|
||||
await waitUntilIdle(() => store.subtitleBgmGenerating, 300_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[5].label, STEP_DEFS[5].progress);
|
||||
assertOk(await store.generateCover(), "封面生成失败");
|
||||
await waitUntilIdle(() => store.coverGenerating, 120_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
const hasPublish = store.publishPlatforms?.some((p) => p.checked);
|
||||
if (hasPublish) {
|
||||
setProgress(store, STEP_DEFS[6].label, STEP_DEFS[6].progress);
|
||||
assertOk(await store.publishVideo({ autoPublish: true }), "视频发布失败");
|
||||
await waitUntilIdle(() => store.publishing, 600_000);
|
||||
throwIfCancelled(store);
|
||||
} else {
|
||||
setProgress(store, "未选择发布平台,跳过发布", 98);
|
||||
}
|
||||
|
||||
setProgress(store, "一键智能流程全部完成", 100);
|
||||
return { ok: true, message: "一键智能流程全部完成" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
const cancelled = store.oneClickCancelRequested;
|
||||
setProgress(store, cancelled ? "已停止" : `失败:${msg}`, store.oneClickProgress);
|
||||
return {
|
||||
ok: false,
|
||||
message: cancelled ? "已停止一键智能流程" : msg,
|
||||
cancelled,
|
||||
};
|
||||
} finally {
|
||||
store.autoSubtitle = savedSubtitleFlags.autoSubtitle;
|
||||
store.smartSubtitle = savedSubtitleFlags.smartSubtitle;
|
||||
store.oneClickRunning = false;
|
||||
store.titleTagsGenerating = false;
|
||||
store.speechGenerating = false;
|
||||
store.videoGenerating = false;
|
||||
store.videoProcessing = false;
|
||||
store.subtitleBgmGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export function stopOneClickPipeline(store) {
|
||||
if (!store.oneClickRunning) {
|
||||
return { ok: false, message: "当前没有运行中的一键任务" };
|
||||
}
|
||||
store.oneClickCancelRequested = true;
|
||||
store.oneClickStatus = "正在停止…";
|
||||
return { ok: true, message: "已请求停止,将在当前步骤结束后中断" };
|
||||
}
|
||||
24
src/services/soundMedia.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickAsrMediaFile() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端选择文件");
|
||||
}
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
title: "选择音频/视频文件",
|
||||
filters: [
|
||||
{
|
||||
name: "媒体文件",
|
||||
extensions: ["mp3", "wav", "flac", "aac", "m4a", "mp4", "avi", "mov", "mkv"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||