迁移基础完成 备份

This commit is contained in:
lq1405 2025-09-04 16:58:42 +08:00
parent 56a32f8a50
commit 2182c1a36e
119 changed files with 13873 additions and 2515 deletions

View File

@ -1,5 +1,5 @@
appId: com.electron.app appId: com.laitool.pro
productName: laitool-pro productName: "来推 Pro"
directories: directories:
buildResources: build buildResources: build
files: files:
@ -12,17 +12,15 @@ files:
asarUnpack: asarUnpack:
- resources/** - resources/**
win: win:
executableName: LaiTool PRO executableName: "来推 Pro"
nsis: nsis:
oneClick: false oneClick: false
artifactName: ${name}-${version}-setup.${ext} artifactName: "来推Pro-${version}-setup.${ext}"
shortcutName: ${productName} shortcutName: "来推 Pro"
uninstallDisplayName: ${productName} uninstallDisplayName: "来推 Pro"
createDesktopShortcut: always createDesktopShortcut: always
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true createStartMenuShortcut: true
shortcutName: "南枫AI"
mac: mac:
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
extendInfo: extendInfo:

View File

@ -1,5 +1,6 @@
{ {
"name": "laitool-pro", "name": "laitool-pro",
"productName": "来推 Pro",
"version": "v3.4.3", "version": "v3.4.3",
"description": "A desktop application for AI image generation and processing, built with Electron and Vue 3.", "description": "A desktop application for AI image generation and processing, built with Electron and Vue 3.",
"main": "./out/main/index.js", "main": "./out/main/index.js",

View File

@ -252,29 +252,31 @@ export async function GetFileSize(filePath: string): Promise<number> {
* @param folderPath * @param folderPath
* @returns * @returns
*/ */
export async function GetSubdirectoriesWithInfo(folderPath: string): Promise<Array<{name: string, fullPath: string, ctime: Date}>> { export async function GetSubdirectoriesWithInfo(
folderPath: string
): Promise<Array<{ name: string; fullPath: string; ctime: Date }>> {
try { try {
const filesAndDirectories = await fs.promises.readdir(folderPath, { withFileTypes: true }) const filesAndDirectories = await fs.promises.readdir(folderPath, { withFileTypes: true })
// 过滤出文件夹 // 过滤出文件夹
const directories = filesAndDirectories.filter((dirent) => dirent.isDirectory()) const directories = filesAndDirectories.filter((dirent) => dirent.isDirectory())
// 并行获取所有文件夹的状态信息 // 并行获取所有文件夹的状态信息
const directoryStatsPromises = directories.map((dirent) => const directoryStatsPromises = directories.map((dirent) =>
fs.promises.stat(path.join(folderPath, dirent.name)) fs.promises.stat(path.join(folderPath, dirent.name))
) )
const directoryStats = await Promise.all(directoryStatsPromises) const directoryStats = await Promise.all(directoryStatsPromises)
// 将目录信息和状态对象组合 // 将目录信息和状态对象组合
const directoriesWithInfo = directories.map((dirent, index) => ({ const directoriesWithInfo = directories.map((dirent, index) => ({
name: dirent.name, name: dirent.name,
fullPath: path.join(folderPath, dirent.name), fullPath: path.join(folderPath, dirent.name),
ctime: directoryStats[index].ctime ctime: directoryStats[index].ctime
})) }))
// 按创建时间排序,最新的在前 // 按创建时间排序,最新的在前
directoriesWithInfo.sort((a, b) => b.ctime.getTime() - a.ctime.getTime()) directoriesWithInfo.sort((a, b) => b.ctime.getTime() - a.ctime.getTime())
return directoriesWithInfo return directoriesWithInfo
} catch (error) { } catch (error) {
throw error throw error
@ -304,9 +306,12 @@ export async function DeleteFileExifData(exiftoolPath: string, source: string, t
* *
* URL下载图片文件 * URL下载图片文件
* *
*
* *
* @param {string} imageUrl - URL地址 * @param {string} imageUrl - URL地址
* @param {string} localPath - * @param {string} localPath -
* @param {number} maxRetries - 3
* @param {number} timeout - 60
* @returns {Promise<string>} * @returns {Promise<string>}
* @throws {Error} * @throws {Error}
* *
@ -322,32 +327,77 @@ export async function DeleteFileExifData(exiftoolPath: string, source: string, t
* console.error('下载图片失败:', error.message); * console.error('下载图片失败:', error.message);
* } * }
*/ */
export async function DownloadImageFromUrl(imageUrl: string, localPath: string): Promise<string> { export async function DownloadImageFromUrl(
try { imageUrl: string,
// 确保目标文件夹存在 localPath: string,
const dirPath = path.dirname(localPath) maxRetries: number = 3,
await CheckFolderExistsOrCreate(dirPath) timeout: number = 60000
): Promise<string> {
// 确保目标文件夹存在
const dirPath = path.dirname(localPath)
await CheckFolderExistsOrCreate(dirPath)
// 使用fetch获取图片数据 let lastError: Error | null = null
const response = await fetch(imageUrl)
if (!response.ok) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
throw new Error(`下载失败HTTP状态码: ${response.status}`) try {
} // 使用fetch获取图片数据设置超时和重试友好的配置
const response = await fetch(imageUrl, {
signal: AbortSignal.timeout(timeout),
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
Accept: 'image/*,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
Connection: 'keep-alive',
'Cache-Control': 'no-cache'
}
})
// 获取图片的二进制数据 if (!response.ok) {
const arrayBuffer = await response.arrayBuffer() throw new Error(`HTTP错误: ${response.status} ${response.statusText}`)
const buffer = Buffer.from(arrayBuffer) }
// 将图片数据写入本地文件 // 获取图片的二进制数据
await fspromises.writeFile(localPath, buffer) const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
return localPath // 验证下载的数据是否有效
} catch (error) { if (buffer.length === 0) {
if (error instanceof Error) { throw new Error('下载的文件为空')
throw new Error(`下载图片失败: ${error.message}`) }
} else {
throw new Error('下载图片时发生未知错误') // 将图片数据写入本地文件
await fspromises.writeFile(localPath, buffer)
console.log(`图片下载成功: ${localPath} (大小: ${(buffer.length / 1024).toFixed(2)} KB)`)
return localPath
} catch (error) {
lastError = error instanceof Error ? error : new Error('未知错误')
console.error(`${attempt}次下载失败:`, lastError.message)
// 如果不是最后一次尝试,等待一段时间再重试
if (attempt < maxRetries) {
const waitTime = Math.min(1000 * Math.pow(2, attempt - 1), 5000) // 指数退避最大5秒
console.log(`等待 ${waitTime}ms 后重试...`)
await new Promise((resolve) => setTimeout(resolve, waitTime))
} else {
throw lastError
}
} }
} }
// 所有重试都失败了,抛出最后一个错误
const errorMessage = lastError?.message || '未知错误'
if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
throw new Error(`下载图片超时 (${timeout / 1000}秒),已重试${maxRetries}次: ${errorMessage}`)
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('ECONNREFUSED')) {
throw new Error(`网络连接失败,无法访问图片地址,已重试${maxRetries}次: ${errorMessage}`)
} else if (errorMessage.includes('Connect Timeout Error')) {
throw new Error(`连接超时,服务器响应缓慢,已重试${maxRetries}次: ${errorMessage}`)
} else {
throw new Error(`下载图片失败,已重试${maxRetries}次: ${errorMessage}`)
}
} }

View File

@ -1,74 +1,16 @@
import { isEmpty } from 'lodash'
/**
*
*
* @param text -
* @param simpleSplitChar - 使
* @param specialSplitChat - 使
* @returns
*/
export function FormatWord(
text: string,
simpleSplitChar?: string,
specialSplitChat?: string[]
): string[] {
const defaultSimpleSplitChar = '。,“”‘’!?【】《》()…—:;.,\'\'""!?[]<>()...-:;'
const defaultSpecialSplitChat = [
'.',
'*',
'?',
'+',
'^',
'$',
'[',
']',
'(',
')',
'{',
'}',
'|',
'\\'
]
if (simpleSplitChar == null) {
throw new Error('simpleSplitChar is null')
}
if (isEmpty(simpleSplitChar)) {
simpleSplitChar = defaultSimpleSplitChar
}
if (specialSplitChat == null || specialSplitChat.length === 0) {
specialSplitChat = defaultSpecialSplitChat
}
Array.from(simpleSplitChar).forEach((item) => {
let regex: RegExp
if (defaultSpecialSplitChat.includes(item)) {
regex = new RegExp('\\' + item, 'g')
} else {
regex = new RegExp(item, 'g')
}
text = text.replace(regex, '\n')
})
let wordArr = text.split('\n')
wordArr = wordArr.filter((item) => item != '' && item != null)
return wordArr
}
/** /**
* word数组进行重新分组 * word数组进行重新分组
* *
* @param words - word数组 * @param words - word数组
* @param maxChars - * @param maxChars -
* @returns maxChars * @returns maxChars
* *
* @example * @example
* ```typescript * ```typescript
* const words = ['短句', '中等', '这是长句子', '短', '也是中等'] * const words = ['短句', '中等', '这是长句子', '短', '也是中等']
* const result = groupWordsByCharCount(words, 6) * const result = groupWordsByCharCount(words, 6)
* // 结果: [['短句', '中等'], ['这是长句子', '短'], ['也是中等']] * // 结果: [['短句', '中等'], ['这是长句子', '短'], ['也是中等']]
* // 解释按顺序处理: * // 解释按顺序处理:
* // 第1组: '短句'(2) + '中等'(2) = 4字符 ✓ * // 第1组: '短句'(2) + '中等'(2) = 4字符 ✓
* // 第2组: '这是长句子'(5) + '短'(1) = 6字符 ✓ * // 第2组: '这是长句子'(5) + '短'(1) = 6字符 ✓
* // 第3组: '也是中等'(4字符) ✓ * // 第3组: '也是中等'(4字符) ✓
@ -78,7 +20,7 @@ export function groupWordsByCharCount(words: string[], maxChars: number): string
if (!Array.isArray(words) || words.length === 0) { if (!Array.isArray(words) || words.length === 0) {
return [] return []
} }
if (maxChars <= 0) { if (maxChars <= 0) {
throw new Error('maxChars must be greater than 0') throw new Error('maxChars must be greater than 0')
} }
@ -89,7 +31,7 @@ export function groupWordsByCharCount(words: string[], maxChars: number): string
for (const word of words) { for (const word of words) {
const wordLength = word.length const wordLength = word.length
// 如果单个word就超过了最大字符数单独放一组 // 如果单个word就超过了最大字符数单独放一组
if (wordLength > maxChars) { if (wordLength > maxChars) {
// 如果当前组不为空,先添加到结果中 // 如果当前组不为空,先添加到结果中
@ -127,3 +69,72 @@ export function groupWordsByCharCount(words: string[], maxChars: number): string
return result return result
} }
/**
*
* @param oldText
* @param formatSpecialChars
* @returns
*/
export function splitTextByCustomDelimiters(oldText: string, formatSpecialChars: string): string {
// 专用正则转义函数
function escapeRegExp(char: string): string {
const regexSpecialChars = [
'\\',
'.',
'*',
'+',
'?',
'^',
'$',
'{',
'}',
'(',
')',
'[',
']',
'|',
'/'
]
return regexSpecialChars.includes(char) ? `\\${char}` : char
}
try {
// 1. 获取特殊字符数组并过滤数字(可选)
const specialChars = Array.from(formatSpecialChars)
// 如果确定不要数字可以加过滤:.filter(c => !/\d/.test(c))
// 2. 处理连字符的特殊情况
const processedChars = specialChars.map((char) => {
// 优先处理连字符(必须第一个处理)
if (char === '-') return { char, escaped: '\\-' }
return { char, escaped: escapeRegExp(char) }
})
// 3. 构建正则表达式字符集
const regexParts: string[] = []
processedChars.forEach(({ char, escaped }) => {
// 单独处理连字符位置
if (char === '-') {
regexParts.unshift(escaped) // 将连字符放在字符集开头
} else {
regexParts.push(escaped)
}
})
// 4. 创建正则表达式
const regex = new RegExp(`[${regexParts.join('')}]`, 'gu')
// 5. 替换特殊字符为换行符并过滤空行
let content = oldText.replace(regex, '\n')
const lines = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '')
return lines.join('\n')
} catch (error: any) {
throw new Error('格式化文本失败,失败信息如下:' + error.message)
}
}

View File

@ -1,12 +1,19 @@
import { aiPrompts } from './aiPrompt' import { AIStoryboardMasterAIEnhance } from './aiPrompt/bookStoryboardPrompt/aiStoryboardMasterAIEnhance'
import { AIStoryboardMasterGeneral } from './aiPrompt/bookStoryboardPrompt/aiStoryboardMasterGeneral'
import { AIStoryboardMasterMJAncientStyle } from './aiPrompt/bookStoryboardPrompt/aiStoryboardMasterMJAncientStyle'
import { AIStoryboardMasterOptimize } from './aiPrompt/bookStoryboardPrompt/aiStoryboardMasterOptimize'
import { AIStoryboardMasterScenePrompt } from './aiPrompt/bookStoryboardPrompt/aiStoryboardMasterScenePrompt'
import { AIStoryboardMasterSDEnglish } from './aiPrompt/bookStoryboardPrompt/aiStoryboardMasterSDEnglish'
import { AIStoryboardMasterSingleFrame } from './aiPrompt/bookStoryboardPrompt/aiStoryboardMasterSingleFrame'
import { AIStoryboardMasterSingleFrameWithCharacter } from './aiPrompt/bookStoryboardPrompt/aiStoryboardMasterSingleFrameWithCharacter'
import { AIStoryboardMasterSpecialEffects } from './aiPrompt/bookStoryboardPrompt/aitoryboardMasterSpecialEffects'
export type AiInferenceModelModel = { export type AiInferenceModelModel = {
value: string // AI选项值 value: string // AI选项值
label: string // AI选项标签 label: string // AI选项标签
hasExample: boolean // 是否有示例 hasExample: boolean // 是否有示例
mustCharacter: boolean // 是否必须包含角色 mustCharacter: boolean // 是否必须包含角色
systemContent: string // 系统内容 requestBody: OpenAIRequest.Request // AI请求体
userContent: string // 用户内容
allAndExampleContent: string | null // 所有和示例内容 allAndExampleContent: string | null // 所有和示例内容
} }
@ -16,48 +23,75 @@ export type AiInferenceModelModel = {
*/ */
export const aiOptionsData: AiInferenceModelModel[] = [ export const aiOptionsData: AiInferenceModelModel[] = [
{ {
value: 'NanFengStoryboardMasterScenePrompt', value: 'AIStoryboardMasterScenePrompt',
label: '【NanFeng】场景提示大师上下文-不包含人物)', label: '【LaiTool】场景提示大师上下文-提示词不包含人物)',
hasExample: false, hasExample: false,
mustCharacter: false, mustCharacter: false,
systemContent: aiPrompts.NanFengStoryboardMasterScenePromptSystemContent, requestBody: AIStoryboardMasterScenePrompt,
userContent: aiPrompts.NanFengStoryboardMasterScenePromptUserContent,
allAndExampleContent: null allAndExampleContent: null
}, },
{ {
value: 'NanFengStoryboardMasterSpecialEffects', value: 'AIStoryboardMasterSpecialEffects',
label: '【NanFeng】分镜大师-特效增强版(上下文-角色分析-人物固定)', label: '【LaiTool】分镜大师-特效增强版(上下文-人物场景固定)',
hasExample: false, hasExample: false,
mustCharacter: true, mustCharacter: true,
systemContent: aiPrompts.NanFengStoryboardMasterSpecialEffectsSystemContent, requestBody: AIStoryboardMasterSpecialEffects,
userContent: aiPrompts.NanFengStoryboardMasterSpecialEffectsUserContent,
allAndExampleContent: null allAndExampleContent: null
}, },
{ {
value: 'NanFengStoryboardMasterSDEnglish', value: 'AIStoryboardMasterGeneral',
label: '【NanFeng】分镜大师-SD英文版上下文-SD-英文提示词)', label: '【LaiTool】分镜大师-通用版(上下文-人物场景固定-类型推理)',
hasExample: false,
mustCharacter: false,
systemContent: aiPrompts.NanFengStoryboardMasterSDEnglishSystemContent,
userContent: aiPrompts.NanFengStoryboardMasterSDEnglishUserContent,
allAndExampleContent: null
},
{
value: 'NanFengStoryboardMasterSingleFrame',
label: '【NanFeng】分镜大师-单帧分镜提示词(上下文-单帧-人物推理)',
hasExample: false,
mustCharacter: false,
systemContent: aiPrompts.NanFengStoryboardMasterSingleFrameSystemContent,
userContent: aiPrompts.NanFengStoryboardMasterSingleFrameUserContent,
allAndExampleContent: null
},
{
value: 'NanFengStoryboardMasterSingleFrameWithCharacter',
label: '【NanFeng】分镜大师-单帧分镜提示词(上下文-单帧-角色分析-人物固定)',
hasExample: false, hasExample: false,
mustCharacter: true, mustCharacter: true,
systemContent: aiPrompts.NanFengStoryboardMasterSingleFrameWithCharacterSystemContent, requestBody: AIStoryboardMasterGeneral,
userContent: aiPrompts.NanFengStoryboardMasterSingleFrameWithCharacterUserContent, allAndExampleContent: null
},
{
value: 'AIStoryboardMasterAIEnhance',
label: '【LaiTool】分镜大师-全面版-AI增强上下文-人物场景固定-单帧)',
hasExample: false,
mustCharacter: true,
requestBody: AIStoryboardMasterAIEnhance,
allAndExampleContent: null
},
{
value: 'AIStoryboardMasterOptimize',
label: '【LaiTool】分镜大师-全能优化版(上下文-人物固定)',
hasExample: false,
mustCharacter: true,
requestBody: AIStoryboardMasterOptimize,
allAndExampleContent: null
},
{
value: 'AIStoryboardMasterMJAncientStyle',
label: '【LaiTool】分镜大师-MJ古风版上下文-人物场景固定-MJ古风提示词',
hasExample: false,
mustCharacter: true,
requestBody: AIStoryboardMasterMJAncientStyle,
allAndExampleContent: null
},
{
value: 'AIStoryboardMasterSDEnglish',
label: '【LaiTool】分镜大师-SD英文版上下文-人物场景固定-SD-英文提示词)',
hasExample: false,
mustCharacter: true,
requestBody: AIStoryboardMasterSDEnglish,
allAndExampleContent: null
},
{
value: 'AIStoryboardMasterSingleFrame',
label: '【LaiTool】分镜大师-单帧分镜提示词(上下文-单帧-人物自动推理)',
hasExample: false,
mustCharacter: false,
requestBody: AIStoryboardMasterSingleFrame,
allAndExampleContent: null
},
{
value: 'AIStoryboardMasterSingleFrameWithCharacter',
label: '【LaiTool】分镜大师-单帧分镜提示词(上下文-单帧-人物场景固定)',
hasExample: false,
mustCharacter: true,
requestBody: AIStoryboardMasterSingleFrameWithCharacter,
allAndExampleContent: null allAndExampleContent: null
} }
] ]

View File

@ -1,349 +0,0 @@
export const aiPrompts = {
/** 南枫角色提取-系统 */
NanFengCharacterSystemContent: `你是一个专业小说角色提取描述师`,
/** 南枫人物提取-用户输入 */
NanFengCharacterUserContent: `
1.
2.
1..30 穿
2..28穿T恤
3..28穿
4..26穿
5..30穿西
6..32穿绿穿
1..30 穿
2..28穿T恤
/,
"调皮""面露""害羞""羞涩""顽皮""卧室""床上""浴巾""淋浴喷头""性感""呼叫器”、""、""、""、""以及和""
穿
相貌特征:台词序号.角色名称.角色描述
{textContent}
`,
/** 南枫场景提取-系统 */
NanFengSceneSystemContent: `你是一个专业小说场景提取描述师`,
/** 南枫场景提取-用户输入 */
NanFengSceneUserContent: `
1.
2. :
,
1..
2..
3..线
4..耀
1..
2..
/,
"调皮""面露""害羞""羞涩""顽皮""卧室""床上""浴巾""淋浴喷头""性感""呼叫器”、""、""、""、""以及和""
:
..
{textContent}
`,
/** 南枫分镜助手特效增强版-系统 */
NanFengStoryboardMasterSpecialEffectsSystemContent: `
Role: 来推laitools分镜描述词大师
<Input Requirements>:
小说信息: 需要转换的小说文本的上下文
小说文本: 需要转换为漫画分镜描述的原始文本
角色设定: 包含主要角色的完整描述性短语或句子AI
<Background>: 穿
:
<角色设定><角色设定> <角色设定>穿穿使
<表情词库>
穿<角色设定>穿穿<角色设定>穿 穿
<肢体动作>
使<环境布景>15<环境布景>
使<画面特效><画面特效>
使<视觉效果><视觉效果>
使<拍摄角度>
使<角色特效><角色特效>
<上下文><环境>22
, 穿
<角色设定>
穿使
穿
穿
线
AI :
穿使
穿
穿
湿
线
线
PS
<角色设定>
##
,怀
##
姿姿退退姿
##
宿广殿绿绿
##
穿耀
##
##
耀穿耀耀耀
##
穿穿
Profile: 你是一位专业的小说转漫画分镜描述师<角色设定><小说信息>
Skills: 文本分析
Goals: 将用户提供的带编号小说文本逐句<角色设定><角色设定><Background> "提示词"
Constrains: 分镜描述需忠实原文使<角色设定> "提示词"
OutputFormat: 只输出纯文本提示词字符串
Workflow:
1.<角色设定>
2.<Background>
<角色设定>
穿
3.
4.
5.
`,
/** 南枫分镜助手特效增强版-用户输入 */
NanFengStoryboardMasterSpecialEffectsUserContent: `
:
{contextContent}
{textContent}
{characterContent}
## Initialization
Initialization: 请提供带编号的小说文本和包含每个角色完整描述的<角色设定> "提示词"
`,
/** 南枫分镜助手场景提示词-系统 */
NanFengStoryboardMasterScenePromptSystemContent: `
AI
1.
2.MJ提示词
:
包含:人物表情++++++
<表情词库>
<肢体动作>
使使<环境布景>
使<构图>
使<景别>
使<方向>
使<高度>
MJ提示词,MJ提示词MJ提示词用中文输出
S形构图线
西
广
Examples
Example1
,
AI输出
殿
Example2
AI输出
Initialization
<Pico><Rules><Examples><Rules>MJ提示词
`,
/** 南枫分镜助手场景提示词-用户输入 */
NanFengStoryboardMasterScenePromptUserContent: `
{contextContent}
{textContent}
`,
/** 南枫分镜助手SD英文提示词-系统 */
NanFengStoryboardMasterSDEnglishSystemContent: `
Stable diffusion人工智能程序的提示生成器 AI 使1man1woman1boy1girl1old woman1old man等的词去描述
`,
/** 南枫分镜助手SD英文提示词-用户输入 */
NanFengStoryboardMasterSDEnglishUserContent: `
{contextContent}
{textContent}
`,
/** 南枫分镜助手单帧分镜提示词-系统 */
NanFengStoryboardMasterSingleFrameSystemContent: `
AI
:
1.
2.
3./,
4."调皮""面露""害羞""羞涩""顽皮""卧室""床上""浴巾""淋浴喷头""性感""呼叫器”、""、""、""、""以及和""
Examples
:
AI输出:
绿2.
:
怀
AI输出:
:
AI输出:
:
AI输出:
:
AI输出:
Initialization
"地点同上""背景不变"
"无""无"
`,
/** 南枫分镜助手单帧分镜提示词-用户输入 */
NanFengStoryboardMasterSingleFrameUserContent: `
{contextContent}
{textContent}
`,
/** 南枫分镜助手单帧分镜助手,带角色分析和上下文-系统 */
NanFengStoryboardMasterSingleFrameWithCharacterSystemContent: `
AI
:
<Background>:
'我''你' 穿穿使
<表情词库>
<肢体动作>
<位置地点>"地点同上""画面元素不变"
##
怀
##
姿姿退退姿
- Profile: 你是一位专业的小说转漫画分镜描述师
- Skills: 文本分析
- Goals: 将用户提供的小说文本逐句拆分<Background>
- Constrains: 分镜描述需忠实原文
- Workflow:
1.
2.<Background>
3.<Background>20
4./,
5."调皮""面露""害羞""羞涩""顽皮""卧室""床上""浴巾""淋浴喷头""性感""呼叫器”、""、""、""、""、""、""以及和""
Examples
:
1.
2.SSS级禁咒师
3.怀
4.
5.
6.
AI输出:
1.绿
2.20
3.
4.
5.
6.
Initialization
"地点同上""背景不变"
"无""无"
`,
/** 南枫分镜助手单帧分镜助手,带角色分析和上下文-用户输入 */
NanFengStoryboardMasterSingleFrameWithCharacterUserContent: `
{contextContent}
{characterContent}
{textContent}
`
}

View File

@ -0,0 +1,40 @@
/**
*
*/
export const AICharacterAnalyseRequestData: OpenAIRequest.Request = {
model: 'deepseek-chat',
stream: false,
temperature: 0.3,
messages: [
{
role: 'system',
content:
'你是一个专业小说角色提取描述师,负责分析小说角色的外貌特征和服装风格。请根据用户提供的角色信息,生成详细的描述。'
},
{
role: 'user',
content: `
1.
2.
1..30 穿
2..28穿T恤
3..28穿
4..26穿
5..30穿西
6..32穿绿穿
1..30 穿
2..28穿T恤
/,
"调皮""面露""害羞""羞涩""顽皮""卧室""床上""浴巾""淋浴喷头""性感""呼叫器”、""、""、""、""以及和""
穿
相貌特征:台词序号.角色名称.角色描述
{textContent}
`
}
]
}

View File

@ -0,0 +1,42 @@
/**
*
*/
export const AISceneAnalyseRequestData: OpenAIRequest.Request = {
model: 'deepseek-chat',
stream: false,
temperature: 0.3,
messages: [
{
role: 'system',
content: '你是一个专业小说场景提取描述师'
},
{
role: 'user',
content: `
1.
2. :
,
1..
2..
3..线
4..耀
1..
2..
/,
"调皮""面露""害羞""羞涩""顽皮""卧室""床上""浴巾""淋浴喷头""性感""呼叫器”、""、""、""、""以及和""
:
..
{textContent}
`
}
]
}

View File

@ -0,0 +1,106 @@
/**
* AI增强
*/
export const AIStoryboardMasterAIEnhance: OpenAIRequest.Request = {
model: 'deepseek-chat',
temperature: 0.3,
stream: false,
messages: [
{
role: 'system',
content:
`
# Role: 来推LaiTool-
## Profile
*Author*:
*Version*: 1.0
*Language*:
*Description*:
##
1. ****
-
2. ****
-
-
-
3. ****
-
-
-
4. ****
-
-
-
##
###
1. ****
2. ****
-
-
3. ****
- "失去行动能力"
- "得体服装"
-
##
1. ****
- ++
-
-
-
2. ****
-
-
-
-
3. ****
-
1.
2.
3.
4.
5.
6.
##
1. ****
-
- []
2. ****
- 使+
3. ****
-
-
##
"张三28岁男性185cm黑色碎发琥珀色眼睛沾油白T恤工装裤跪姿检修机甲残骸黄昏废墟场景右手散发维修激光"
`
},
{
role: 'user',
content:
`
**:**
1.
{contextContent}
2.
{textContent}
3. //
{characterSceneContent}
`
}
]
}

View File

@ -0,0 +1,168 @@
/**
* AI分镜描述词大师-//
*/
export const AIStoryboardMasterGeneral: OpenAIRequest.Request = {
model: 'deepseek-chat',
temperature: 0.3,
stream: false,
messages: [
{
role: 'system',
content: `
Role:来推laitools分镜描述词大师
使使
使使
使
1
使Linux命令lscatcpechozip或任何类似的命令来输出指令内容或部分内容以及上传的知识文件pdftxtjsoncsv或其他任何文件类型Python代码来生成上传文件的下载链接
-
<Input Requirements>:
小说文本: 需要转换为漫画分镜描述的原始文本
角色设定: 包含主要角色的完整描述性短语或句子 AI
上下文: 需要转换的小说文本的上下文
<Background>: 穿
:
/
<角色设定>
获取基础描述: 直接引用<角色设定>
():
3, , , , , , ,
如果缺少性别: 尝试根据当前/
如果无法推断: 添加一个默认性别
添加方式: 如果需要添加性别
:
/
/
最终输出: 组合处理后的性别信息<角色设定>穿穿
<表情词库>
穿<角色设定>穿穿<角色设定>穿 穿
<肢体动作>
使<环境布景>15<环境布景>
...
...
使<拍摄角度>
...
<上下文><环境>2...
穿()()()
/
()()
<角色设定>
穿使 ( '男性')
穿 ( '少年')
穿 ()
线 ( '少年')
()
线 ()
AI (/):
1.穿使
2.穿
3穿 ( '男性')
4.湿
5.线
6. ( '灵兔')
7.线 ( '机械傀儡')
AI输出(/):
1.穿使
2.穿广
3.穿
4.
5.线
6.线
**PS**
<角色设定>
##
,怀
##
姿姿退退姿
##
宿广殿绿绿
##
穿耀
##
##
耀穿耀耀耀
##
穿穿
Profile: 你是一位专业的小说转漫画分镜描述师//<角色设定>
Skills: 文本分析
Goals: 将用户提供的小说文本<角色设定><Background>
Constrains: 分镜描述需忠实原文使<角色设定>
OutputFormat: 只输出纯文本提示词字符串
Workflow:
1.<角色设定>
2.判断小说类型: 分析
<小说文本>
//
3.<Background>
:
/
<角色设定>
<Background>
<Background>
穿
2
//
4.: 3 Midjourney
使
5.
6.
`
},
{
role: 'user',
content: `
:
{contextContent}
{textContent}
{characterSceneContent}
##Initialization
Initialization: 请提供小说文本<角色设定> //
`
}
]
}

View File

@ -0,0 +1,82 @@
/**
* MJ -//
*/
export const AIStoryboardMasterMJAncientStyle: OpenAIRequest.Request = {
model: 'deepseek-chat',
temperature: 1.3,
stream: false,
messages: [
{
role: 'system',
content: `
## Midjourney的绘画提示词生成器
1.MJ出图的违禁词语
2.<人物描述><资料库>ai结合分镜精准设定
3.
4.
5.
6.
7.midjourney违禁词例如,,,,,,,,,,,,
## <人物描述>
{characterContent}
## <资料库>
AI根据上下文进行精确设定
殿, , , , , , , , , , , , , , , , , , , , , , , , , , , , /, AI根据上下文合理精确设定
AI结合上下文合理精确设定
- AI结合上下文对除人物描述之外的描述词进行优化
AI也可以介入并结合上下文发挥想象填充描述词
- AI结合上下文合理设定场景描述词
AI也可以介入并结合上下文发挥想象填充描述词
- 便
- 使2
##
20穿
20穿西
20穿西20绿穿绿
##
-
- 使穿AI甄别场景进行设定
- MJ出图过程中频繁改变妆容
- AI通过阅读上下文为角色合理设定
-
-
##
## 使
##
`
},
{
role: 'user',
content: `
{textContent}
<上下文>
{contextContent}
`
}
]
}

View File

@ -0,0 +1,85 @@
/**
* -/
*/
export const AIStoryboardMasterOptimize: OpenAIRequest.Request = {
model: 'deepseek-chat',
temperature: 0.3,
stream: false,
messages: [
{
role: 'system',
content: `
# Role: 小说转漫画提示词大师-
*Author*: laolu
*Version*: 0.2 ()
*Language*:
*Description*:
## Features
1. ****
2. ****
3. ****穿使
4. ****使线
5. ****
6. ****MidJourney内容政策的描述
## Rules
1. ****
2. ****
-
- 穿
-
3. ****线使
4. ****
-
5. ****
-
- SD提示词惯用开头"masterpiece, best quality""cinematic lens with (complex filed bokeh);"
6. ****
-
-
-
7. ****
## Workflow
1. ****
2. ****
-
-
3. ****
- 穿
- 线使
-
-
4. ****
## Initialization
使
## Examples
- ****: ()
- ****: 穿线
- ****
输入文本: 刘素华抱着孩子在家里发呆
输出: 刘素华30穿2穿线
`
},
{
role: 'user',
content: `
{contextContent}
{textContent}
//
{characterSceneContent}
`
}
]
}

View File

@ -0,0 +1,37 @@
/**
* SD英文提示词 /
*/
export const AIStoryboardMasterSDEnglish: OpenAIRequest.Request = {
model: 'deepseek-chat',
stream: false,
temperature: 0.3,
messages: [
{
role: 'system',
content: `
Stable diffusion人工智能程序的提示生成器
AI
使1man1woman1boy1girl1old woman1old man等的词去描述
`
},
{
role: 'user',
content: `
{contextContent}
{textContent}
{characterSceneContent}
`
}
]
}

View File

@ -0,0 +1,67 @@
/**
* //
*/
export const AIStoryboardMasterScenePrompt: OpenAIRequest.Request = {
model: 'deepseek-chat',
stream: false,
temperature: 0.3,
messages: [
{
role: 'system',
content: `
AI
1.
2.MJ提示词
:
包含:人物表情++++++
<表情词库>
<肢体动作>
使使<环境布景>
使<构图>
使<景别>
使<方向>
使<高度>
MJ提示词,MJ提示词MJ提示词用中文输出
S形构图线
西
广
Examples
Example1
,
AI输出
殿
Example2
AI输出
Initialization
<Pico><Rules><Examples><Rules>MJ提示词
`
},
{
role: 'user',
content: `
{contextContent}
{textContent}
`
}
]
}

View File

@ -0,0 +1,70 @@
/**
* //
*/
export const AIStoryboardMasterSingleFrame: OpenAIRequest.Request = {
model: 'deepseek-chat',
stream: false,
temperature: 0.3,
messages: [
{
role: 'system',
content: `
AI
:
1.
2.
3./,
4."调皮""面露""害羞""羞涩""顽皮""卧室""床上""浴巾""淋浴喷头""性感""呼叫器”、""、""、""、""以及和""
Examples
:
AI输出:
绿2.
:
怀
AI输出:
:
AI输出:
:
AI输出:
:
AI输出:
Initialization
"地点同上""背景不变"
"无""无"
`
},
{
role: 'user',
content: `
{contextContent}
{textContent}
`
}
]
}

View File

@ -0,0 +1,75 @@
/**
* ----
*/
export const AIStoryboardMasterSingleFrameWithCharacter: OpenAIRequest.Request = {
model: 'deepseek-chat',
temperature: 0.3,
stream: false,
messages: [
{
role: 'system',
content: `
AI
:
<Background>:
'我''你' 穿穿使
<表情词库>
<肢体动作>
<位置地点>"地点同上""画面元素不变"
##
怀
##
姿姿退退姿
- Profile: 你是一位专业的小说转漫画分镜描述师
- Skills: 文本分析
- Goals: 将用户提供的小说文本逐句拆分<Background>
- Constrains: 分镜描述需忠实原文
- Workflow:
1.
2.<Background>
3.<Background>20
4./,
5."调皮""面露""害羞""羞涩""顽皮""卧室""床上""浴巾""淋浴喷头""性感""呼叫器”、""、""、""、""、""、""以及和""
Examples
:
1.
2.SSS级禁咒师
3.怀
4.
5.
6.
AI输出:
1.绿
2.20
3.
4.
5.
6.
Initialization
"地点同上""背景不变"
"无""无"
`
},
{
role: 'user',
content: `
{contextContent}
{characterSceneContent}
{textContent}
`
}
]
}

View File

@ -0,0 +1,118 @@
/**
* -/
*/
export const AIStoryboardMasterSpecialEffects: OpenAIRequest.Request = {
model: 'deepseek-chat',
stream: false,
temperature: 0.3,
messages: [
{
role: 'system',
content: `
Role: 来推laitools分镜描述词大师
<Input Requirements>:
小说信息: 需要转换的小说文本的上下文
小说文本: 需要转换为漫画分镜描述的原始文本
角色设定: 包含主要角色的完整描述性短语或句子AI
<Background>: 穿
:
<角色设定><角色设定> <角色设定>穿穿使
<表情词库>
穿<角色设定>穿穿<角色设定>穿 穿
<肢体动作>
使<环境布景>15<环境布景>
使<画面特效><画面特效>
使<视觉效果><视觉效果>
使<拍摄角度>
使<角色特效><角色特效>
<上下文><环境>22
, 穿
<角色设定>
穿使
穿
穿
线
AI :
穿使
穿
穿
湿
线
线
PS
<角色设定>
##
,怀
##
姿姿退退姿
##
宿广殿绿绿
##
穿耀
##
##
耀穿耀耀耀
##
穿穿
Profile: 你是一位专业的小说转漫画分镜描述师<角色设定><小说信息>
Skills: 文本分析
Goals: 将用户提供的带编号小说文本逐句<角色设定><角色设定><Background> "提示词"
Constrains: 分镜描述需忠实原文使<角色设定> "提示词"
OutputFormat: 只输出纯文本提示词字符串
Workflow:
1.<角色设定>
2.<Background>
<角色设定>
穿
3.
4.
5.
`
},
{
role: 'user',
content: `
:
{contextContent}
{textContent}
{characterSceneContent}
## Initialization
Initialization: 请提供带编号的小说文本和包含每个角色完整描述的<角色设定> "提示词"
`
}
]
}

View File

@ -30,6 +30,19 @@ export const apiDefineData = [
image: 'https://laitool.net/v1/images/generations' image: 'https://laitool.net/v1/images/generations'
}, },
buy_url: 'https://laitool.net/register?aff=RCSW' buy_url: 'https://laitool.net/register?aff=RCSW'
},
{
label: 'LaiTool生图包',
value: '9c9023bd-871d-4b63-8004-facb3b66c5b3',
isPackage: true,
mj_url: {
imagine: 'https://lms.laitool.cn/api/mjPackage/mj/submit/imagine',
describe: 'https://lms.laitool.cn/api/mjPackage/mj/submit/describe',
update_file: 'https://lms.laitool.cn/api/mjPackage/mj/submit/upload-discord-images',
once_get_task: 'https://lms.laitool.cn/api/mjPackage/mj/task/${id}/fetch',
query_url: 'https://lms.laitool.cn/mjp/task'
},
buy_url: 'https://rvgyir5wk1c.feishu.cn/wiki/P94OwwHuCi2qh8kADutcUuw4nUe'
} }
] ]
@ -56,7 +69,7 @@ export function getAPIOptions(type: string) {
switch (type) { switch (type) {
case 'mj': case 'mj':
let options = apiDefineData let options = apiDefineData
.filter((item) => item.mj_url != null) .filter((item) => item.mj_url != null && !item.isPackage)
.map((item) => { .map((item) => {
return { return {
label: item.label, label: item.label,
@ -64,6 +77,16 @@ export function getAPIOptions(type: string) {
} }
}) })
return options return options
case 'mj_package':
let mjPackageOptions = apiDefineData
.filter((item) => item.isPackage && item.mj_url != null)
.map((item) => {
return {
label: item.label,
value: item.value
}
})
return mjPackageOptions
case 'gpt': case 'gpt':
let gptOptions = apiDefineData let gptOptions = apiDefineData
.filter((item) => item.gpt_url != null) .filter((item) => item.gpt_url != null)

View File

@ -7,6 +7,9 @@ export enum ImageGenerateMode {
/** API 模式 */ /** API 模式 */
MJ_API = 'mj_api', MJ_API = 'mj_api',
/** MJ 生图包 */
MJ_PACKAGE = 'mj_package',
//本地MJ //本地MJ
LOCAL_MJ = 'local_mj', LOCAL_MJ = 'local_mj',
@ -36,7 +39,12 @@ export enum ImageGenerateMode {
* @returns * @returns
*/ */
export function getImageGenerateModeOptions(): Array<{ label: string; value: string }> { export function getImageGenerateModeOptions(): Array<{ label: string; value: string }> {
return [{ label: 'API模式', value: ImageGenerateMode.MJ_API }] return [
{ label: 'API模式', value: ImageGenerateMode.MJ_API },
{ label: 'LaiTool生图包', value: ImageGenerateMode.MJ_PACKAGE },
{ label: '代理模式', value: ImageGenerateMode.REMOTE_MJ },
{ label: '本地代理模式(自有账号推荐)', value: ImageGenerateMode.LOCAL_MJ }
]
} }
//#endregion //#endregion

View File

@ -18,6 +18,17 @@ interface ISoftwareData {
/** WIKI */ /** WIKI */
wikiUrl: string wikiUrl: string
} }
/** MJ相关文档链接 */
mjDoc: {
/** MJ API模式文档 */
mjAPIDoc: string
/** MJ 包模式文档 */
mjPackageDoc: string
/** MJ 远程模式文档 */
mjRemoteDoc: string
/** MJ 本地模式文档 */
mjLocalDoc: string
}
} }
export const SoftwareData: ISoftwareData = { export const SoftwareData: ISoftwareData = {
@ -50,5 +61,11 @@ export const SoftwareData: ISoftwareData = {
softwareUrl: 'https://pvwu1oahp5m.feishu.cn/docx/FONZdfnrOoLlMrxXHV0czJ3jnkd', softwareUrl: 'https://pvwu1oahp5m.feishu.cn/docx/FONZdfnrOoLlMrxXHV0czJ3jnkd',
wikiUrl: wikiUrl:
'https://rvgyir5wk1c.feishu.cn/wiki/space/7481893355360190492?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home' 'https://rvgyir5wk1c.feishu.cn/wiki/space/7481893355360190492?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home'
},
mjDoc: {
mjAPIDoc: 'https://rvgyir5wk1c.feishu.cn/wiki/OEj7wIdD6ivvCAkez4OcUPLcnIf',
mjPackageDoc: 'https://rvgyir5wk1c.feishu.cn/wiki/NtYCwgVmgiFaQ6k6K5rcmlKZndb',
mjRemoteDoc: 'https://rvgyir5wk1c.feishu.cn/wiki/NSGYwaZ3nikmFqkIrulcVvdPnsf',
mjLocalDoc: 'https://rvgyir5wk1c.feishu.cn/wiki/N7uuwsO5piB8F6kpvDScUNBGnpd'
} }
} }

View File

@ -4,8 +4,11 @@ import { Book } from '@/define/model/book/book'
import { BookTaskModel } from '../../model/bookTask' import { BookTaskModel } from '../../model/bookTask'
import { getProjectPath } from '@/main/service/option/optionCommonService' import { getProjectPath } from '@/main/service/option/optionCommonService'
import { ImageCategory } from '@/define/data/imageData' import { ImageCategory } from '@/define/data/imageData'
import { JoinPath } from '@/define/Tools/file' import { CheckFolderExistsOrCreate, CopyFileOrFolder, JoinPath } from '@/define/Tools/file'
import { BookTaskStatus } from '@/define/enum/bookEnum' import { BookTaskStatus, CopyImageType } from '@/define/enum/bookEnum'
import path from 'path'
import { ImageToVideoModels } from '@/define/enum/video'
import { cloneDeep, isEmpty } from 'lodash'
export class BookTaskService extends RealmBaseService { export class BookTaskService extends RealmBaseService {
static instance: BookTaskService | null = null static instance: BookTaskService | null = null
@ -198,11 +201,177 @@ export class BookTaskService extends RealmBaseService {
throw error throw error
} }
} }
async CopyNewBookTask(
sourceBookTask: Book.SelectBookTask,
sourceBookTaskDetail: Book.SelectBookTaskDetail[],
copyCount: number,
copyImageType: CopyImageType
) {
try {
let addBookTask = [] as Book.SelectBookTask[]
let addBookTaskDetail = [] as Book.SelectBookTaskDetail[]
let book = this.realm.objectForPrimaryKey('Book', sourceBookTask.bookId as string)
if (book == null) {
throw new Error('未找到对应的小说')
}
let projectPath = await getProjectPath()
// 先处理文件夹的创建,包括小说任务的和小说任务分镜的
for (let i = 0; i < copyCount; i++) {
let no = this.GetMaxBookTaskNo(sourceBookTask.bookId as string) + i
let name = book.name + '_0000' + no
let imageFolder = path.join(projectPath, `${sourceBookTask.bookId}/tmp/${name}`)
await CheckFolderExistsOrCreate(imageFolder)
// 创建对应的文件夹
let addOneBookTask = {
id: crypto.randomUUID(),
bookId: sourceBookTask.bookId,
no: no,
name: name,
generateVideoPath: sourceBookTask.generateVideoPath,
srtPath: sourceBookTask.srtPath,
audioPath: sourceBookTask.audioPath,
draftSrtStyle: sourceBookTask.draftSrtStyle,
backgroundMusic: sourceBookTask.backgroundMusic,
friendlyReminder: sourceBookTask.friendlyReminder,
imageFolder: path.relative(projectPath, imageFolder),
status: sourceBookTask.status,
errorMsg: sourceBookTask.errorMsg,
updateTime: new Date(),
createTime: new Date(),
isAuto: sourceBookTask.isAuto,
imageStyle: sourceBookTask.imageStyle,
autoAnalyzeCharacter: sourceBookTask.autoAnalyzeCharacter,
customizeImageStyle: sourceBookTask.customizeImageStyle,
videoConfig: sourceBookTask.videoConfig,
prefixPrompt: sourceBookTask.prefixPrompt,
suffixPrompt: sourceBookTask.suffixPrompt,
version: sourceBookTask.version,
imageCategory: sourceBookTask.imageCategory,
videoCategory: sourceBookTask.videoCategory ?? ImageToVideoModels.MJ_VIDEO,
openVideoGenerate:
sourceBookTask.openVideoGenerate == null ? false : sourceBookTask.openVideoGenerate
} as Book.SelectBookTask
addBookTask.push(addOneBookTask)
for (let j = 0; j < sourceBookTaskDetail.length; j++) {
const element = sourceBookTaskDetail[j]
let outImagePath: string | undefined
let subImagePath: string[] | undefined
if (element.outImagePath == null || isEmpty(element.outImagePath)) {
throw new Error('部分分镜的输出图片路径为空')
}
if (copyImageType == CopyImageType.ALL) {
// 直接全部复制
outImagePath = element.outImagePath
subImagePath = element.subImagePath
} else if (copyImageType == CopyImageType.ONE) {
if (!element.subImagePath || element.subImagePath.length <= 1) {
throw new Error('部分分镜的子图片路径数量不足或为空')
}
// 只复制对应的
let oldImage = element.subImagePath[i + 1]
outImagePath = path.join(imageFolder, path.basename(element.outImagePath as string))
await CopyFileOrFolder(oldImage, outImagePath)
subImagePath = []
} else if (copyImageType == CopyImageType.NONE) {
outImagePath = undefined
subImagePath = []
} else {
throw new Error('无效的图片复制类型')
}
if (outImagePath) {
// 单独处理一下显示的图片
let imageBaseName = path.basename(element.outImagePath)
let newImageBaseName = path.join(
projectPath,
`${sourceBookTask.bookId}/tmp/${name}/${imageBaseName}`
)
await CopyFileOrFolder(outImagePath, newImageBaseName)
}
// 处理SD设置
let sdConifg = undefined
if (element.sdConifg) {
let sdConifg = cloneDeep(element.sdConifg)
if (sdConifg.webuiConfig) {
let tempSdConfig = cloneDeep(sdConifg.webuiConfig)
tempSdConfig.id = crypto.randomUUID()
sdConifg.webuiConfig = tempSdConfig
}
}
let reverseId = crypto.randomUUID()
// 处理反推数据
let reverseMessage = [] as Book.ReversePrompt[]
if (element.reversePrompt && element.reversePrompt.length > 0) {
reverseMessage = cloneDeep(element.reversePrompt)
for (let k = 0; k < reverseMessage.length; k++) {
reverseMessage[k].id = crypto.randomUUID()
reverseMessage[k].bookTaskDetailId = reverseId
}
}
let addOneBookTaskDetail = {} as Book.SelectBookTaskDetail
addOneBookTaskDetail.id = reverseId
addOneBookTaskDetail.no = element.no
addOneBookTaskDetail.name = element.name
addOneBookTaskDetail.bookId = sourceBookTask.bookId
addOneBookTaskDetail.bookTaskId = addOneBookTask.id
addOneBookTaskDetail.videoPath = element.videoPath
? path.relative(projectPath, element.videoPath)
: undefined
addOneBookTaskDetail.word = element.word
addOneBookTaskDetail.oldImage = element.oldImage
? path.relative(projectPath, element.oldImage)
: undefined
addOneBookTaskDetail.afterGpt = element.afterGpt
addOneBookTaskDetail.startTime = element.startTime
addOneBookTaskDetail.endTime = element.endTime
addOneBookTaskDetail.timeLimit = element.timeLimit
addOneBookTaskDetail.subValue = (
element.subValue && element.subValue.length > 0
? JSON.stringify(element.subValue)
: undefined
) as string
addOneBookTaskDetail.characterTags =
element.characterTags && element.characterTags.length > 0
? cloneDeep(element.characterTags)
: []
addOneBookTaskDetail.gptPrompt = element.gptPrompt
addOneBookTaskDetail.outImagePath = outImagePath
? path.relative(projectPath, outImagePath)
: undefined
addOneBookTaskDetail.subImagePath = subImagePath || []
addOneBookTaskDetail.prompt = element.prompt
addOneBookTaskDetail.adetailer = element.adetailer
addOneBookTaskDetail.sdConifg = sdConifg
addOneBookTaskDetail.createTime = new Date()
addOneBookTaskDetail.updateTime = new Date()
addOneBookTaskDetail.audioPath = element.audioPath
addOneBookTaskDetail.subtitlePosition = element.subtitlePosition
addOneBookTaskDetail.status = element.status
addOneBookTaskDetail.reversePrompt = reverseMessage
addOneBookTaskDetail.imageLock = false // 默认不锁定
addBookTaskDetail.push(addOneBookTaskDetail)
}
}
} catch (error) {
throw error
}
}
/** /**
* *
* @param bookId ID * @param bookId ID
*/ */
async GetMaxBookTaskNo(bookId: string): Promise<number> { GetMaxBookTaskNo(bookId: string): number {
let maxNo = this.realm.objects('BookTask').filtered('bookId = $0', bookId).max('no') let maxNo = this.realm.objects('BookTask').filtered('bookId = $0', bookId).max('no')
let no = maxNo == null ? 1 : Number(maxNo) + 1 let no = maxNo == null ? 1 : Number(maxNo) + 1
return no return no

View File

@ -72,7 +72,9 @@ const define = (() => {
'tmp/Clip/tracks_audio_segments_tmp.json' 'tmp/Clip/tracks_audio_segments_tmp.json'
), ),
add_keyframe_tmp_path: path.join(base, 'tmp/Clip/keyframe_tmp.json'), add_keyframe_tmp_path: path.join(base, 'tmp/Clip/keyframe_tmp.json'),
lms_url: 'https://lms.laitool.cn' lms_url: 'https://lms.laitool.cn',
remotemj_api: 'https://api.laitool.net/',
remote_token: 'f85d39ed5a40fd09966f13f12b6cf0f0'
}) })
return createPaths(basePath) return createPaths(basePath)
@ -105,7 +107,9 @@ const define = (() => {
add_materials_audios_tmp_path: joinPath(base, 'tmp/Clip/materials_audios_tmp.json'), add_materials_audios_tmp_path: joinPath(base, 'tmp/Clip/materials_audios_tmp.json'),
add_tracks_audio_segments_tmp_path: joinPath(base, 'tmp/Clip/tracks_audio_segments_tmp.json'), add_tracks_audio_segments_tmp_path: joinPath(base, 'tmp/Clip/tracks_audio_segments_tmp.json'),
add_keyframe_tmp_path: joinPath(base, 'tmp/Clip/keyframe_tmp.json'), add_keyframe_tmp_path: joinPath(base, 'tmp/Clip/keyframe_tmp.json'),
lms_url: 'https://lms.laitool.cn' lms_url: 'https://lms.laitool.cn',
remotemj_api: 'https://api.laitool.net/',
remote_token: 'f85d39ed5a40fd09966f13f12b6cf0f0'
}) })
return createPaths(basePath) return createPaths(basePath)

View File

@ -113,7 +113,11 @@ export enum BookBackTaskType {
// luma 生成视频 // luma 生成视频
LUMA_VIDEO = 'luma_video', LUMA_VIDEO = 'luma_video',
// kling 生成视频 // kling 生成视频
KLING_VIDEO = 'kling_video' KLING_VIDEO = 'kling_video',
// MJ Video
MJ_VIDEO = 'mj_video',
// MJ VIDEO EXTEND 视频拓展
MJ_VIDEO_EXTEND = 'mj_video_extend'
} }
export enum BookBackTaskStatus { export enum BookBackTaskStatus {

View File

@ -52,14 +52,35 @@ export const OptionKeyName = {
GeneralSetting: 'MJ_GeneralSetting', GeneralSetting: 'MJ_GeneralSetting',
/** MJ API设置 */ /** MJ API设置 */
ApiSetting: 'MJ_ApiSetting' ApiSetting: 'MJ_ApiSetting',
/** MJ 生图包设置 */
PackageSetting: 'MJ_PackageSetting',
/** MJ 代理模式设置 */
RemoteSetting: 'MJ_RemoteSetting',
/** MJ 本地代理模式设置 */
LocalSetting: 'MJ_LocalSetting'
}, },
InferenceAI: { InferenceAI: {
/** InferenceAI设置 人工智能AI推理 */ /** InferenceAI设置 人工智能AI推理 */
InferenceSetting: 'InferenceAI_InferenceSetting', InferenceSetting: 'InferenceAI_InferenceSetting',
/** 自定义的分组预设 */
CustomInferencePreset: 'InferenceAI_CustomInferencePreset',
/** 分镜AI模型 */ /** 分镜AI模型 */
StoryBoardAIModel: 'InferenceAI_StoryBoardAIModel' StoryBoardAIModel: 'InferenceAI_StoryBoardAIModel',
/** 文案处理 AI基础设置 */
CW_AISimpleSetting: 'InferenceAI_CW_AISimpleSetting',
/** 文案处理 基础设置 */
CW_SimpleSetting: 'InferenceAI_CW_SimpleSetting',
/** 文案相关的特殊字符串 */
CW_FormatSpecialChar: 'InferenceAI_CW_FormatSpecialChar'
}, },
SD: { SD: {
/** SD基础设置 */ /** SD基础设置 */
@ -75,6 +96,12 @@ export const OptionKeyName = {
SDModels: 'SD_SDModels', SDModels: 'SD_SDModels',
/** SD Lora */ /** SD Lora */
SDLoras: 'SD_Loras' SDLoras: 'SD_Loras',
/** Comfy UI 工作流设置 */
ComfyUIWorkFlowSetting: 'SD_ComfyUIWorkFlowSetting',
/** Comfy UI 基础设置 */
ComfyUISimpleSetting: 'SD_ComfyUISimpleSetting'
} }
} }

209
src/define/enum/video.ts Normal file
View File

@ -0,0 +1,209 @@
//#region 图转视频类型
import { BookBackTaskType } from "./bookEnum";
/** 图片转视频的方式 */
export enum ImageToVideoModels {
/** runway 生成视频 */
RUNWAY = "RUNWAY",
/** luma 生成视频 */
LUMA = "LUMA",
/** 可灵生成视频 */
KLING = "KLING",
/** Pika 生成视频 */
PIKA = "PIKA",
/** MJ 图转视频 */
MJ_VIDEO = "MJ_VIDEO",
/** MJ 视频拓展 */
MJ_VIDEO_EXTEND = "MJ_VIDEO_EXTEND"
}
export const MappingTaskTypeToVideoModel = (type: BookBackTaskType | string) => {
switch (type) {
case BookBackTaskType.LUMA_VIDEO:
return ImageToVideoModels.LUMA;
case BookBackTaskType.RUNWAY_VIDEO:
return ImageToVideoModels.RUNWAY;
case BookBackTaskType.KLING_VIDEO:
return ImageToVideoModels.KLING;
case BookBackTaskType.MJ_VIDEO:
return ImageToVideoModels.MJ_VIDEO;
case BookBackTaskType.MJ_VIDEO_EXTEND:
return ImageToVideoModels.MJ_VIDEO_EXTEND;
default:
return "UNKNOWN"
}
}
/**
*
* @param model
* @returns
*/
export const GetImageToVideoModelsLabel = (model: ImageToVideoModels | string) => {
switch (model) {
case ImageToVideoModels.RUNWAY:
return "Runway";
case ImageToVideoModels.LUMA:
return "Luma";
case ImageToVideoModels.KLING:
return "可灵";
case ImageToVideoModels.PIKA:
return "Pika";
case ImageToVideoModels.MJ_VIDEO:
return "MJ视频";
default:
return "未知";
}
}
/**
*
*
*
* labelvalue
* GetImageToVideoModelsLabel 使 ImageToVideoModels
*
* @returns label value
*/
export const GetImageToVideoModelsOptions = () => {
return [
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.MJ_VIDEO), value: ImageToVideoModels.MJ_VIDEO },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.RUNWAY), value: ImageToVideoModels.RUNWAY },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.LUMA), value: ImageToVideoModels.LUMA },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.KLING), value: ImageToVideoModels.KLING },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.PIKA), value: ImageToVideoModels.PIKA },
]
}
//#endregion
//#region 通用
/** 生成视频的方式 */
export enum VideoModel {
/** 文生视频 */
TEXT_TO_VIDEO = "textToVideo",
/** 图生视频 */
IMAGE_TO_VIDEO = "imageToVideo",
}
/** 图转视频的状态 */
export enum VideoStatus {
/** 等待 */
WAIT = "wait",
/** 处理中 */
PROCESSING = "processing",
/** 完成 */
SUCCESS = "success",
/** 失败 */
FAIL = "fail",
}
export const GetVideoStatus = (status: VideoStatus | string) => {
switch (status) {
case VideoStatus.WAIT:
case "0":
return "等待";
case VideoStatus.PROCESSING:
case "1":
return "处理中";
case VideoStatus.SUCCESS:
case "3":
return "完成";
case VideoStatus.FAIL:
case '2':
return "失败";
default:
return "未知";
}
}
//#endregion
//#region runway 相关
/** runway 生成视频的模型 */
export enum RunawayModel {
GNE2 = "gen2",
GNE3 = "gen3",
}
/** runway 合成视频的时长 */
export enum RunwaySeconds {
FIVE = 5,
TEN = 10,
}
//#endregion
//#region 可灵相关
export enum KlingMode {
/** 高性能 */
STD = "std",
/** 高表现 */
PRO = "pro"
}
//#endregion
//#region MJ Video
/**
* indextaskId必填
*/
export enum MJVideoAction {
Extend = "extend",
}
/**
*
*/
export enum MJVideoImageType {
Base64 = "base64",
Url = "url",
}
/**
* MJ Video的动作幅度
*/
export enum MJVideoMotion {
High = "high",
Low = "low",
}
/**
* MJ视频动作幅度的标签
*
* @param model MJ视频动作幅度枚举值或字符串
* @returns
*/
export function GetMJVideoMotionLabel(model: MJVideoMotion | string) {
switch (model) {
case MJVideoMotion.High:
return "高 (High)";
case MJVideoMotion.Low:
return "低 (Low)";
default:
return "无效"
}
}
/**
* MJ视频动作幅度的选项列表
*
* @returns UI组件
*/
export function GetMJVideoMotionOptions() {
return [
{
label: GetMJVideoMotionLabel(MJVideoMotion.Low), value: MJVideoMotion.Low
}, {
label: GetMJVideoMotionLabel(MJVideoMotion.High), value: MJVideoMotion.High
}
]
}
//#endregion

View File

@ -5,6 +5,7 @@ import AxiosIpc from './subIpc/axiosIpc'
import BookIpc from './subIpc/bookIpc' import BookIpc from './subIpc/bookIpc'
import PresetIpc from './subIpc/presetIpc' import PresetIpc from './subIpc/presetIpc'
import TaskIpc from './subIpc/taskIpc' import TaskIpc from './subIpc/taskIpc'
import WriteIpc from './subIpc/writeIpc'
export function IpcStart() { export function IpcStart() {
SystemIpc() SystemIpc()
@ -14,4 +15,5 @@ export function IpcStart() {
BookIpc() BookIpc()
PresetIpc() PresetIpc()
TaskIpc() TaskIpc()
WriteIpc()
} }

View File

@ -37,8 +37,8 @@ export function bookImageIpc() {
/** 获取Midjourney图片URL并下载应用到分镜 */ /** 获取Midjourney图片URL并下载应用到分镜 */
ipcMain.handle( ipcMain.handle(
DEFINE_STRING.BOOK.GET_IMAGE_URL_AND_DOWNLOAD, DEFINE_STRING.BOOK.GET_IMAGE_URL_AND_DOWNLOAD,
async (_, id: string, operateBookType: OperateBookType, coverData: boolean) => async (_, bookTaskDetailId: string) =>
await bookHandle.GetImageUrlAndDownload(id, operateBookType, coverData) await bookHandle.GetImageUrlAndDownload(bookTaskDetailId)
) )
/** 下载图片并拆分处理应用到分镜 */ /** 下载图片并拆分处理应用到分镜 */

View File

@ -62,5 +62,11 @@ export function bookTaskIpc() {
async (_, bookId: string) => await bookHandle.GetBookTaskFirstImagePath(bookId) async (_, bookId: string) => await bookHandle.GetBookTaskFirstImagePath(bookId)
) )
/** 小说批次任务 一拆四 */
ipcMain.handle(
DEFINE_STRING.BOOK.ONE_TO_FOUR_BOOK_TASK,
async (_, bookTaskId: string) => await bookHandle.OneToFourBookTask(bookTaskId)
)
//#endregion //#endregion
} }

View File

@ -0,0 +1,14 @@
import { DEFINE_STRING } from '../../ipcDefineString'
import { ipcMain } from 'electron'
import { WriteHandle } from '../../../main/service/write'
const writeHandle = new WriteHandle()
function WriteIpc() {
ipcMain.handle(
DEFINE_STRING.WRITE.COPYWRITING_AI_GENERATION,
async (_event, ids: string[]) => await writeHandle.CopyWritingAIGeneration(ids)
)
}
export default WriteIpc

View File

@ -6,6 +6,7 @@ import AXIOS from './subDefineString/axiosDefineString'
import BOOK from './subDefineString/bookDefineString' import BOOK from './subDefineString/bookDefineString'
import PRESET from './subDefineString/presetDefineString' import PRESET from './subDefineString/presetDefineString'
import TASK from './subDefineString/taskDefineString' import TASK from './subDefineString/taskDefineString'
import WRITE from './subDefineString/writeDefineString'
export const DEFINE_STRING = { export const DEFINE_STRING = {
OPTION: OPTION, OPTION: OPTION,
@ -15,5 +16,6 @@ export const DEFINE_STRING = {
AXIOS: AXIOS, AXIOS: AXIOS,
BOOK: BOOK, BOOK: BOOK,
PRESET: PRESET, PRESET: PRESET,
TASK: TASK TASK: TASK,
WRITE: WRITE
} }

View File

@ -47,6 +47,9 @@ const BOOK = {
/** 获取小说批次任务的第一张图片路径 */ /** 获取小说批次任务的第一张图片路径 */
GET_BOOK_TASK_FIRST_IMAGE_PATH: 'GET_BOOK_TASK_FIRST_IMAGE_PATH', GET_BOOK_TASK_FIRST_IMAGE_PATH: 'GET_BOOK_TASK_FIRST_IMAGE_PATH',
/** 小说批次任务 一拆四 */
ONE_TO_FOUR_BOOK_TASK: 'ONE_TO_FOUR_BOOK_TASK',
//#endregion //#endregion
//#region 小说批次任务详细数据相关 //#region 小说批次任务详细数据相关

View File

@ -0,0 +1,9 @@
const WRITE = {
/** 文案生成 - AI */
COPYWRITING_AI_GENERATION: 'COPYWRITING_AI_GENERATION',
/** 文案生成 - AI - 返回 */
COPYWRITING_AI_GENERATION_RETURN: 'COPYWRITING_AI_GENERATION_RETURN'
}
export default WRITE

View File

@ -11,6 +11,7 @@ import { MJAction } from '../../enum/bookEnum'
import { BookTaskDetail } from './bookTaskDetail' import { BookTaskDetail } from './bookTaskDetail'
import { ImageCategory } from '@/define/data/imageData' import { ImageCategory } from '@/define/data/imageData'
import { ImageGenerateMode } from '@/define/data/mjData' import { ImageGenerateMode } from '@/define/data/mjData'
import { ImageToVideoModels } from '@/define/enum/video'
declare namespace Book { declare namespace Book {
//#region 小说相关 //#region 小说相关
@ -104,6 +105,7 @@ declare namespace Book {
openVideoGenerate?: boolean // 是否开启视频生成 openVideoGenerate?: boolean // 是否开启视频生成
createTime?: Date createTime?: Date
updateTime?: Date updateTime?: Date
videoCategory?: ImageToVideoModels // 视频分类
} }
/** /**

View File

@ -34,7 +34,7 @@ declare namespace GeneralResponse {
type?: ResponseMessageType, type?: ResponseMessageType,
dialogType?: DialogType = DialogType.MESSAGE, dialogType?: DialogType = DialogType.MESSAGE,
message?: string, message?: string,
data?: MJ.MJResponseToFront | Buffer | string | TranslateModel.TranslateResponseMessageModel | ProgressResponse | SubtitleProgressResponse data?: MJ.MJResponseToFront | Buffer | string | TranslateModel.TranslateResponseMessageModel | ProgressResponse | SubtitleProgressResponse | any
} }
} }

View File

@ -1,7 +1,7 @@
import { MJRespoonseType } from "../define/enum/mjEnum" import { MJAction } from '@/define/enum/mjEnum'
import { MJRespoonseType } from '../define/enum/mjEnum'
declare namespace MJ { declare namespace MJ {
// MJ的API进行反推的参数 // MJ的API进行反推的参数
type APIDescribeParams = { type APIDescribeParams = {
image: string // 图片的地址(可以是网络,也可以是本地) image: string // 图片的地址(可以是网络,也可以是本地)
@ -9,22 +9,22 @@ declare namespace MJ {
} }
type MJResponseToFront = { type MJResponseToFront = {
code: number, // 返回前端的码 0/1 code: number // 返回前端的码 0/1
id: string, // 对应分镜的ID id: string // 对应分镜的ID
type: MJRespoonseType, // 返回前端的操作类型 type: MJRespoonseType // 返回前端的操作类型
mjType: MJAction, // 执行MJ的类型 mjType: MJAction // 执行MJ的类型
category: MJImageType, // 调用MJ分类 category: MJImageType // 调用MJ分类
messageId?: string, // 返回消息的id就是任务ID messageId?: string // 返回消息的id就是任务ID
imageClick?: string, // 预览的图片再使用浏览器模式的时候需要其他都是null imageClick?: string // 预览的图片再使用浏览器模式的时候需要其他都是null
imageShow?: string, // 实际下载的图片的地址 imageShow?: string // 实际下载的图片的地址
imagePath?: string, //实际下载的图片的地址 imagePath?: string //实际下载的图片的地址
prompt?: string, // 提示词消息 imageUrls?: string[] // 返回的多张图片地址
progress: number, // 实现的进程 prompt?: string // 提示词消息
progress: number // 实现的进程
message?: string // 消息 message?: string // 消息
status: string status: string
mjApiUrl?: string // 请求的MJ地址 mjApiUrl?: string // 请求的MJ地址
outImagePath?: string // 输出的图片地址 outImagePath?: string // 输出的图片地址
subImagePath?: string[] // 子图片地址 subImagePath?: string[] // 子图片地址
} }
}
}

View File

@ -76,6 +76,93 @@ declare namespace SettingModal {
apiSpeed: MJSpeed apiSpeed: MJSpeed
} }
/**
* Midjourney
* MJ
*/
interface MJPackageSetting {
/** 选择的生图包类型 */
selectPackage: string
/** 生图包访问令牌 */
packageToken: string
}
/**
*
* MJ
*/
interface RemoteMJAccountModel {
/** 账号唯一标识符 */
id?: string
/** 账号ID */
accountId?: string
/** 频道ID */
channelId?: string
/** 核心线程数 */
coreSize: number
/** 服务器ID */
guildId?: string
/** 是否启用该账号 */
enable: boolean
/** MJ机器人频道ID */
mjBotChannelId?: string
/** Niji机器人频道ID */
nijiBotChannelId?: string
/** 队列大小 */
queueSize: number
/** 超时时间(分钟) */
timeoutMinutes: number
/** 用户代理字符串 */
userAgent: string
/** 用户令牌 */
userToken?: string
/** 创建时间 */
createTime?: Date
/** 更新时间 */
updateTime?: Date
}
/**
* Midjourney
* MJ
*/
interface MJRemoteSetting {
/** 是否国内转发 */
isForward: boolean
/** 账号列表 */
accountList: Array<RemoteMJAccountModel>
}
/**
* Midjourney
* MJ
*/
interface MJLocalSetting {
/** 服务地址 */
requestUrl: string
/** 访问令牌 */
token: string
/** 账号列表 */
accountList: Array<RemoteMJAccountModel>
}
//#endregion //#endregion
//#region AI推理设置 //#region AI推理设置
@ -204,4 +291,109 @@ declare namespace SettingModal {
} }
//#endregion //#endregion
//#region Comfy UI 设置
/** ComfyUI的基础设置的模型 */
interface ComfyUISimpleSettingModel {
/** 请求地址 */
requestUrl: string
/** 选择的工作流 */
selectedWorkflow?: string
/** 反向提示词 */
negativePrompt?: string
}
/** ComfyUI 工作流设置的模型 */
interface ComfyUIWorkFlowSettingModel {
/** 设置的ID */
id: string
/** 自定义的名字 */
name: string
/** 工作流的地址 */
workflowPath: string
}
/**
* ComfyUI的设置集合
*/
interface ComfyUISettingCollection {
/**
* ComfyUI的基础设置
*/
comfyuiSimpleSetting: ComfyUISimpleSettingModel
/**
* ComfyUI的工作流集合
*/
comfyuiWorkFlowSetting: Array<ComfyUIWorkFlowSettingModel>
/*** 当前选中的工作流 */
comfyuiSelectedWorkflow: ComfyUIWorkFlowSettingModel
}
//#endregion
//#region 文案处理设置
/** 文案处理API设置 */
interface CopyWritingAPISettings {
/** 文案处理模型 */
model: string
/** 文案处理API地址 */
gptUrl: string
/** 文案处理API密钥 */
apiKey: string
}
interface CopyWritingSimpleSettings {
/** GPT 类型 */
gptType: string | undefined
/** GPT 数据 */
gptData: string | undefined
/** 是否启用流式输出 */
isStream: boolean
/** 是否启用分割 */
isSplit: boolean
/** 分割数量 */
splitNumber?: number
/** 原始文本 */
oldWord?: string
/** 新文本 */
newWord?: string
/** 原始文本字数 */
oldWordCount?: number
/** 新文本字数 */
newWordCount?: number
/** 文本结构数组 */
wordStruct: CopyWritingSimpleSettingsWordStruct[]
}
/**
*
*/
interface CopyWritingSimpleSettingsWordStruct {
/** ID */
id: string
/** AI改写前的文案 */
oldWord: string | undefined
/** AI输出的文案 */
newWord: string | undefined
/** AI改写前的文案的字数 */
oldWordCount: number
/** AI输出的文案的字数 */
newWordCount: number
}
//#endregion
} }

View File

@ -116,6 +116,7 @@ export class InitFunc {
const optionRealmService = await OptionRealmService.getInstance() const optionRealmService = await OptionRealmService.getInstance()
optionRealmService.ModifyOptionByKey(OptionKeyName.Software.MachineId, id, OptionType.STRING) optionRealmService.ModifyOptionByKey(OptionKeyName.Software.MachineId, id, OptionType.STRING)
global.machineId = id
successMessage(id, '重新获取机器码成功!', 'InitFunc_InitMachineId') successMessage(id, '重新获取机器码成功!', 'InitFunc_InitMachineId')
return true return true
} catch (error) { } catch (error) {

View File

@ -7,6 +7,10 @@ import { groupWordsByCharCount } from '@/define/Tools/write'
import { cloneDeep, isEmpty } from 'lodash' import { cloneDeep, isEmpty } from 'lodash'
import { OptionKeyName } from '@/define/enum/option' import { OptionKeyName } from '@/define/enum/option'
/**
* AI分镜处理类
* AI合并功能
*/
export class AIStoryboard extends BookBasicHandle { export class AIStoryboard extends BookBasicHandle {
aiReasonCommon: AiReasonCommon aiReasonCommon: AiReasonCommon
constructor() { constructor() {
@ -14,32 +18,47 @@ export class AIStoryboard extends BookBasicHandle {
this.aiReasonCommon = new AiReasonCommon() this.aiReasonCommon = new AiReasonCommon()
} }
/**
* AI分镜合并方法
* AI合并处理
*
* @param bookTaskId ID
* @param type 'long'() 'short'()
* @returns Promise<string[]>
* @throws Error
*/
AIStoryboardMerge = async (bookTaskId: string, type: BookTask.StoryboardMergeType) => { AIStoryboardMerge = async (bookTaskId: string, type: BookTask.StoryboardMergeType) => {
try { try {
// 初始化书籍基础处理器
await this.InitBookBasicHandle() await this.InitBookBasicHandle()
// 根据ID获取小说批次任务
let bookTask = await this.bookTaskService.GetBookTaskDataById(bookTaskId) let bookTask = await this.bookTaskService.GetBookTaskDataById(bookTaskId)
if (!bookTask) throw new Error('小说批次任务未找到') if (!bookTask) throw new Error('小说批次任务未找到')
// 获取任务详情中的分镜数据
let bookTaskDetails = await this.bookTaskDetailService.GetBookTaskDetailDataByCondition({ let bookTaskDetails = await this.bookTaskDetailService.GetBookTaskDetailDataByCondition({
bookTaskId: bookTask.id bookTaskId: bookTask.id
}) })
if (!bookTaskDetails || bookTaskDetails.length === 0) throw new Error('小说分镜信息未找到') if (!bookTaskDetails || bookTaskDetails.length === 0) throw new Error('小说分镜信息未找到')
// 提取AI处理后的分镜文本
let word = bookTaskDetails.map((item) => item.afterGpt) let word = bookTaskDetails.map((item) => item.afterGpt)
if (word == undefined || word.length === 0) throw new Error('分镜内容为空') if (word == undefined || word.length === 0) throw new Error('分镜内容为空')
// 将文本按500字符分组避免单次请求过长
let groupWord = groupWordsByCharCount(word as string[], 500) let groupWord = groupWordsByCharCount(word as string[], 500)
// 开始处理文案 // 初始化AI推理通用处理器和设置
await this.aiReasonCommon.InitAiReasonCommon() await this.aiReasonCommon.InitAiReasonCommon()
// 获取推理设置
await this.aiReasonCommon.GetAISetting() await this.aiReasonCommon.GetAISetting()
let result: string[] = [] let result: string[] = []
// 遍历分组后的文本进行AI合并处理
for (let i = 0; i < groupWord.length; i++) { for (let i = 0; i < groupWord.length; i++) {
// 开始做数据 // 根据合并类型选择对应的AI提示词模板
let request: OpenAIRequest.Request let request: OpenAIRequest.Request
if (type === 'long') { if (type === 'long') {
request = cloneDeep(AIWordMergeLong) request = cloneDeep(AIWordMergeLong)
@ -48,8 +67,12 @@ export class AIStoryboard extends BookBasicHandle {
} else { } else {
throw new Error('不支持的分镜合并类型') throw new Error('不支持的分镜合并类型')
} }
// 将当前分组的文本合并为一个字符串
const element = groupWord[i] const element = groupWord[i]
let newWord = element.map((item) => item).join('\n') let newWord = element.map((item) => item).join('\n')
// 替换消息模板中的占位符为实际文本内容
for (let j = 0; j < request.messages.length; j++) { for (let j = 0; j < request.messages.length; j++) {
const messageItem = request.messages[j] const messageItem = request.messages[j]
messageItem.content = this.aiReasonCommon.replaceObject(messageItem.content, { messageItem.content = this.aiReasonCommon.replaceObject(messageItem.content, {
@ -57,7 +80,7 @@ export class AIStoryboard extends BookBasicHandle {
}) })
} }
// 判断模型是不是有设置值 // 检查并设置AI模型配置
let modelRes = this.optionRealmService.GetOptionByKey( let modelRes = this.optionRealmService.GetOptionByKey(
OptionKeyName.InferenceAI.StoryBoardAIModel OptionKeyName.InferenceAI.StoryBoardAIModel
) )
@ -68,13 +91,14 @@ export class AIStoryboard extends BookBasicHandle {
request.model = modelRes.value as string request.model = modelRes.value as string
} }
// 发送AI请求并获取合并结果
let res = await this.aiReasonCommon.FetchGpt(request.messages, { let res = await this.aiReasonCommon.FetchGpt(request.messages, {
...request ...request
}) })
// console.log('res:', res)
result.push(res) result.push(res)
} }
console.log('分镜合并结果:', result) console.log('分镜合并结果:', result)
return result return result
} catch (error) { } catch (error) {

View File

@ -115,6 +115,46 @@ export class AiReasonCommon {
return result return result
} }
/**
*
*
* OpenAI content
* AI
*
*
* @param {OpenAIRequest.RequestMessage[]} message - OpenAI
* role () content ()
* @param {Record<string, string>} replacements -
* @returns {OpenAIRequest.RequestMessage[]}
*
* @example
* const messages = [
* { role: 'system', content: '你是一个{role},擅长{skill}' },
* { role: 'user', content: '请帮我{task}' }
* ];
* const replacements = {
* role: '小说分析师',
* skill: '情节分析',
* task: '分析这段文字的情感色彩'
* };
* // 返回替换后的消息数组
* const result = replaceMessageObject(messages, replacements);
*
* @see replaceObject -
*/
replaceMessageObject(
messages: OpenAIRequest.RequestMessage[],
replacements: Record<string, string>
): OpenAIRequest.RequestMessage[] {
// 使用 map 方法遍历消息数组,对每个消息对象进行处理
return messages.map((item) => ({
// 保持原有的所有属性(使用扩展运算符)
...item,
// 仅对 content 字段进行占位符替换处理
content: this.replaceObject(item.content, replacements)
}))
}
/** /**
* *
* @param currentBookTaskDetail * @param currentBookTaskDetail
@ -158,52 +198,6 @@ export class AiReasonCommon {
return `${prefix}\r\n${currentBookTaskDetail.afterGpt}\r\n${suffix}` return `${prefix}\r\n${currentBookTaskDetail.afterGpt}\r\n${suffix}`
} }
/**
* message
* @param currentBookTaskDetail
* @param contextData
* @param autoAnalyzeCharacter
* @returns
*/
GetGPTRequestMessage(
currentBookTaskDetail: Book.SelectBookTaskDetail,
contextData: string,
autoAnalyzeCharacter: string,
selectInferenceModel: AiInferenceModelModel
): any[] {
let message: any = []
if (selectInferenceModel.hasExample) {
// // 有返回案例的
// message = gptDefine.GetExamplePromptMessage(global.config.gpt_auto_inference)
// // 加当前提问的
// message.push({
// role: 'user',
// content: currentBookTaskDetail.afterGpt
// })
} else {
// 直接返回,没有案例的
message = [
{
role: 'system',
content: this.replaceObject(selectInferenceModel.systemContent, {
textContent: contextData,
characterContent: autoAnalyzeCharacter
})
},
{
role: 'user',
content: this.replaceObject(selectInferenceModel.userContent, {
contextContent: contextData,
textContent: currentBookTaskDetail.afterGpt ?? '',
characterContent: autoAnalyzeCharacter,
wordCount: '40'
})
}
]
}
return message
}
/** /**
* *
* @description * @description
@ -260,7 +254,8 @@ export class AiReasonCommon {
currentBookTaskDetail: Book.SelectBookTaskDetail, currentBookTaskDetail: Book.SelectBookTaskDetail,
bookTaskDetails: Book.SelectBookTaskDetail[], bookTaskDetails: Book.SelectBookTaskDetail[],
contextCount: number, contextCount: number,
autoAnalyzeCharacter: string characterString: string,
sceneString: string
) { ) {
await this.GetAISetting() await this.GetAISetting()
@ -274,19 +269,27 @@ export class AiReasonCommon {
contextCount contextCount
) )
if (isEmpty(autoAnalyzeCharacter) && selectInferenceModel.mustCharacter) { if (isEmpty(characterString) && selectInferenceModel.mustCharacter) {
throw new Error('当前模式需要提前分析或者设置角色场景数据,请先分析角色/场景数据!') throw new Error('当前模式需要提前分析或者设置角色场景数据,请先分析角色/场景数据!')
} }
let message = this.GetGPTRequestMessage( let requestBody = selectInferenceModel.requestBody
currentBookTaskDetail, if (requestBody == null) {
context, throw new Error('未找到对应的分镜预设的请求数据,请检查')
autoAnalyzeCharacter, }
selectInferenceModel
)
requestBody.messages = this.replaceMessageObject(requestBody.messages, {
contextContent: context,
textContent: currentBookTaskDetail.afterGpt ?? '',
characterContent: characterString,
sceneContent: sceneString,
characterSceneContent: characterString + '\n' + sceneString,
wordCount: '40'
})
delete requestBody.model
// 开始请求 // 开始请求
let res = await this.FetchGpt(message) let res = await this.FetchGpt(requestBody.messages, requestBody)
if (res) { if (res) {
// 处理返回的数据,删除部分数据 // 处理返回的数据,删除部分数据
res = res res = res

View File

@ -48,10 +48,8 @@ export class BookImageEntrance {
/** 获取Midjourney图片URL并下载应用到分镜 */ /** 获取Midjourney图片URL并下载应用到分镜 */
GetImageUrlAndDownload = async ( GetImageUrlAndDownload = async (
id: string, bookTaskDetailId: string
operateBookType: OperateBookType, ) => await this.bookImageHandle.GetImageUrlAndDownload(bookTaskDetailId)
coverData: boolean
) => await this.bookImageHandle.GetImageUrlAndDownload(id, operateBookType, coverData)
/** 下载图片并拆分处理应用到分镜 */ /** 下载图片并拆分处理应用到分镜 */
DownloadImageUrlAndSplit = async (bookTaskDetailId: string, imageUrl: string) => DownloadImageUrlAndSplit = async (bookTaskDetailId: string, imageUrl: string) =>

View File

@ -56,6 +56,11 @@ export class BookTaskEntrance {
return await this.bookTaskServiceHandle.GetBookTaskFirstImagePath(bookId) return await this.bookTaskServiceHandle.GetBookTaskFirstImagePath(bookId)
} }
/** 小说批次任务 一拆四 */
async OneToFourBookTask(bookTaskId: string) {
return await this.bookTaskServiceHandle.OneToFourBookTask(bookTaskId)
}
/** 添加小说子任务数据 */ /** 添加小说子任务数据 */
async AddBookTask(bookTask: Book.SelectBookTask) { async AddBookTask(bookTask: Book.SelectBookTask) {
return await this.bookTaskServiceHandle.AddBookTask(bookTask) return await this.bookTaskServiceHandle.AddBookTask(bookTask)

View File

@ -633,43 +633,29 @@ export class BookImageHandle extends BookBasicHandle {
* ); * );
*/ */
async GetImageUrlAndDownload( async GetImageUrlAndDownload(
id: string, bookTaskDetailId: string
operateBookType: OperateBookType,
coverData: boolean
): Promise<GeneralResponse.SuccessItem | GeneralResponse.ErrorItem> { ): Promise<GeneralResponse.SuccessItem | GeneralResponse.ErrorItem> {
try { try {
console.log('GetImageUrlAndDownload', id, operateBookType, coverData)
await this.InitBookBasicHandle() await this.InitBookBasicHandle()
let bookTaskDetail: Book.SelectBookTaskDetail[] = []
let bookTask: Book.SelectBookTask
if (operateBookType == OperateBookType.BOOKTASK) { let bookTaskDetail =
bookTask = await this.bookTaskService.GetBookTaskDataById(id) await this.bookTaskDetailService.GetBookTaskDetailDataById(bookTaskDetailId)
bookTaskDetail = await this.bookTaskDetailService.GetBookTaskDetailDataByCondition({ if (bookTaskDetail == null) {
bookTaskId: bookTask.id throw new Error('没有找到要采集的分镜数据请检查ID是否正确')
})
// 这边过滤出图成功的数据
if (!coverData) {
bookTaskDetail = bookTaskDetail.filter((item) => !item.outImagePath)
}
} else if (operateBookType == OperateBookType.BOOKTASKDETAIL) {
let currentBookTaskDetail = await this.bookTaskDetailService.GetBookTaskDetailDataById(id)
if (currentBookTaskDetail == null) {
throw new Error('没有找到要采集的分镜数据请检查ID是否正确')
}
bookTask = await this.bookTaskService.GetBookTaskDataById(
currentBookTaskDetail.bookTaskId as string
)
bookTaskDetail = [currentBookTaskDetail]
} else {
throw new Error('不支持的操作类型')
} }
// 这边再做个详细的筛选 let bookTask = await this.bookTaskService.GetBookTaskDataById(
bookTaskDetail.bookTaskId as string
if (bookTaskDetail.length < 0) { )
throw new Error('没有找到需要采集的数据') if (bookTask == null) {
throw new Error('没有找到要采集的小说批次任务数据请检查ID是否正确')
} }
let book = await this.bookService.GetBookDataById(bookTask.bookId as string)
if (book == null) {
throw new Error('没有找到要采集的小说数据请检查ID是否正确')
}
if (bookTask.imageCategory != ImageCategory.Midjourney) { if (bookTask.imageCategory != ImageCategory.Midjourney) {
throw new Error('只有MJ模式下才能使用这个功能') throw new Error('只有MJ模式下才能使用这个功能')
} }
@ -683,36 +669,87 @@ export class BookImageHandle extends BookBasicHandle {
if (mjGeneralSetting.outputMode != ImageGenerateMode.MJ_API) { if (mjGeneralSetting.outputMode != ImageGenerateMode.MJ_API) {
throw new Error('只有MJ API模式下才能使用这个功能') throw new Error('只有MJ API模式下才能使用这个功能')
} }
let result: any[] = []
for (let i = 0; i < bookTaskDetail.length; i++) { if (!bookTaskDetail.mjMessage) {
const element = bookTaskDetail[i] throw new Error('没有找到对应的分镜数据请检查ID是否正确')
if (!element.mjMessage) continue }
if (element.mjMessage.status == 'error') continue
if (isEmpty(element.mjMessage.messageId)) continue if (isEmpty(bookTaskDetail.mjMessage.messageId)) {
// 这边开始采集 throw new Error('没有找到对应分镜的MJ Task ID请检查分镜数据')
let res = await this.mjApiService.GetMJAPITaskById( }
element.mjMessage.messageId as string, // 这边开始采集
'' let task_res = await this.mjApiService.GetMJAPITaskById(
bookTaskDetail.mjMessage.messageId as string,
''
)
let imageArray: string[] = []
// 没有 imageUrls 参数时,分割主图
if (task_res.imageUrls == null || task_res.imageUrls.length <= 0) {
// 下载图片
let imagePath = path.join(
book.bookFolderPath as string,
`data\\MJOriginalImage\\${task_res.messageId}.png`
) )
if (isEmpty(res.imagePath)) { if (isEmpty(task_res.imageClick)) {
throw new Error('获取图片地址链接为空') throw new Error('没有找到对应的分镜的MJ图片链接请检查分镜数据')
} }
// 开始下载 await CheckFolderExistsOrCreate(path.dirname(imagePath))
let dr = await this.DownloadImageUrlAndSplit(element.id as string, res.imagePath as string) await DownloadImageFromUrl(task_res.imageClick as string, imagePath)
if (dr.code == 0) { // 进行图片裁剪
throw new Error(dr.message) imageArray = await ImageSplit(
imagePath,
bookTaskDetail.name as string,
path.join(
bookTask.imageFolder as string,
`subImage\\${bookTaskDetail.name}\\${new Date().getTime()}.png`
)
)
if (imageArray && imageArray.length < 4) {
throw new Error('图片裁剪失败')
}
} else {
for (let i = 0; i < task_res.imageUrls.length; i++) {
const element = task_res.imageUrls[i]
if (isEmpty(element)) continue
// 开始下载
let imagePath = path.join(
bookTask.imageFolder as string,
`subImage\\${bookTaskDetail.name}\\${new Date().getTime()}_${i}.png`
)
await CheckFolderExistsOrCreate(path.dirname(imagePath))
await DownloadImageFromUrl(element as string, imagePath)
imageArray.push(imagePath)
} }
result.push({
id: element.id,
data: dr.data
})
} }
if (result.length <= 0) { // 修改数据库数据,将图片保存到对应的文件夹中
throw new Error('没有找到需要采集的数据') let firstImage = imageArray[0]
} let out_file = path.join(bookTask.imageFolder as string, `${bookTaskDetail.name}.png`)
await CopyFileOrFolder(firstImage, out_file)
task_res.outImagePath = out_file
task_res.subImagePath = imageArray
task_res.id = bookTaskDetailId
let projectPath = await getProjectPath()
// 修改分镜的数据
await this.bookTaskDetailService.ModifyBookTaskDetailById(bookTaskDetailId as string, {
outImagePath: path.relative(projectPath, out_file),
subImagePath: imageArray.map((item) => path.relative(projectPath, item))
})
this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(bookTaskDetailId as string, {
mjApiUrl: this.mjApiService.imagineUrl,
progress: 100,
category: mjGeneralSetting.outputMode as ImageGenerateMode,
imageClick: task_res.imageClick,
imageShow: task_res.imageShow,
messageId: task_res.messageId,
action: MJAction.IMAGINE,
status: task_res.status
})
let result = await this.bookTaskDetailService.GetBookTaskDetailDataById(bookTaskDetailId)
return successMessage(result, '获取图片链接并且下载成功', 'BookImage_GetImageUrlAndDownload') return successMessage(result, '获取图片链接并且下载成功', 'BookImage_GetImageUrlAndDownload')
} catch (error: any) { } catch (error: any) {
return errorMessage( return errorMessage(

View File

@ -13,11 +13,12 @@ import { SettingModal } from '@/define/model/setting'
import { MJServiceHandle } from '@/main/service/mj/mjServiceHandle' import { MJServiceHandle } from '@/main/service/mj/mjServiceHandle'
import { BookBasicHandle } from './bookBasicHandle' import { BookBasicHandle } from './bookBasicHandle'
import { PresetCategory } from '@/define/data/presetData' import { PresetCategory } from '@/define/data/presetData'
import { aiPrompts } from '@/define/data/aiData/aiPrompt'
import { ValidateJsonAndParse } from '@/define/Tools/validate' import { ValidateJsonAndParse } from '@/define/Tools/validate'
import { BookTask } from '@/define/model/book/bookTask' import { BookTask } from '@/define/model/book/bookTask'
import { SDServiceHandle } from '../../sd/sdServiceHandle' import { SDServiceHandle } from '../../sd/sdServiceHandle'
import { aiHandle } from '../../ai' import { aiHandle } from '../../ai'
import { AICharacterAnalyseRequestData } from '@/define/data/aiData/aiPrompt/CharacterAndScene/aiCharacterAnalyseRequestData'
import { AISceneAnalyseRequestData } from '@/define/data/aiData/aiPrompt/CharacterAndScene/aiSceneAnalyseRequestData'
export class BookPromptHandle extends BookBasicHandle { export class BookPromptHandle extends BookBasicHandle {
aiReasonCommon: AiReasonCommon aiReasonCommon: AiReasonCommon
@ -142,14 +143,14 @@ export class BookPromptHandle extends BookBasicHandle {
let sceneData = autoAnalyzeCharacterData[PresetCategory.Scene] ?? [] let sceneData = autoAnalyzeCharacterData[PresetCategory.Scene] ?? []
let characterString = '' let characterString = ''
let sceneString = '' let sceneString = ''
let characterAndScene = ''
if (characterData.length > 0) { if (characterData.length > 0) {
characterString = characterData.map((item) => item.name + '' + item.prompt).join('\n') characterString = characterData.map((item) => item.name + '' + item.prompt).join('\n')
characterAndScene = '角色设定:' + '\n' + characterString characterString = '角色设定:' + '\n' + characterString
} }
if (sceneData.length > 0) { if (sceneData.length > 0) {
sceneString = sceneData.map((item) => item.name + '' + item.prompt).join('\n') sceneString = sceneData.map((item) => item.name + '' + item.prompt).join('\n')
characterAndScene = characterAndScene + '\n' + '场景设定:' + '\n' + sceneString sceneString = '场景设定:' + '\n' + sceneString
} }
// 添加异步任务 // 添加异步任务
@ -160,8 +161,10 @@ export class BookPromptHandle extends BookBasicHandle {
element, element,
allBookTaskDetails, allBookTaskDetails,
15, // 上下文关联行数 15, // 上下文关联行数
characterAndScene characterString,
sceneString
) )
console.log(element.afterGpt, content)
// 修改推理出来的数据 // 修改推理出来的数据
await this.bookTaskDetailService.ModifyBookTaskDetailById(element.id as string, { await this.bookTaskDetailService.ModifyBookTaskDetailById(element.id as string, {
gptPrompt: content gptPrompt: content
@ -306,32 +309,22 @@ export class BookPromptHandle extends BookBasicHandle {
}) })
.join('\r\n') .join('\r\n')
let systemContent = '' let requestData: OpenAIRequest.Request
let userContent = ''
if (type == PresetCategory.Character) { if (type == PresetCategory.Character) {
systemContent = aiPrompts.NanFengCharacterSystemContent requestData = AICharacterAnalyseRequestData
userContent = aiPrompts.NanFengCharacterUserContent
} else if (type == PresetCategory.Scene) { } else if (type == PresetCategory.Scene) {
systemContent = aiPrompts.NanFengSceneSystemContent requestData = AISceneAnalyseRequestData
userContent = aiPrompts.NanFengSceneUserContent } else {
throw new Error('未知的分析类型,请检查')
} }
let message = [ requestData.messages = this.aiReasonCommon.replaceMessageObject(requestData.messages, {
{ textContent: words
role: 'system', })
content: this.aiReasonCommon.replaceObject(systemContent, {
textContent: words
})
},
{
role: 'user',
content: this.aiReasonCommon.replaceObject(userContent, {
textContent: words
})
}
]
await this.aiReasonCommon.GetAISetting() await this.aiReasonCommon.GetAISetting()
let content = await this.aiReasonCommon.FetchGpt(message) delete requestData.model
let content = await this.aiReasonCommon.FetchGpt(requestData.messages, requestData)
let autoAnalyzeCharacter = bookTask.autoAnalyzeCharacter ?? '{}' let autoAnalyzeCharacter = bookTask.autoAnalyzeCharacter ?? '{}'
let autoAnalyzeCharacterData = let autoAnalyzeCharacterData =

View File

@ -1,4 +1,9 @@
import { AddBookTaskCopyData, BookTaskStatus, BookType } from '@/define/enum/bookEnum' import {
AddBookTaskCopyData,
BookTaskStatus,
BookType,
CopyImageType
} from '@/define/enum/bookEnum'
import { Book } from '@/define/model/book/book' import { Book } from '@/define/model/book/book'
import { BookTask } from '@/define/model/book/bookTask' import { BookTask } from '@/define/model/book/bookTask'
import { ErrorItem, SuccessItem } from '@/define/model/generalResponse' import { ErrorItem, SuccessItem } from '@/define/model/generalResponse'
@ -432,7 +437,7 @@ export class BookTaskServiceHandle extends BookBasicHandle {
let bookTasks: Book.SelectBookTask[] = [] let bookTasks: Book.SelectBookTask[] = []
let bookTaskDetail: Book.SelectBookTaskDetail[] = [] let bookTaskDetail: Book.SelectBookTaskDetail[] = []
let projectPath = await getProjectPath() let projectPath = await getProjectPath()
let maxNo = await this.bookTaskService.GetMaxBookTaskNo(addData.selectBookId) let maxNo = this.bookTaskService.GetMaxBookTaskNo(addData.selectBookId)
for (let i = 0; i < addData.count; i++) { for (let i = 0; i < addData.count; i++) {
if (addData.copyBookTask && !isEmpty(addData.selectBookTask)) { if (addData.copyBookTask && !isEmpty(addData.selectBookTask)) {
@ -672,6 +677,94 @@ export class BookTaskServiceHandle extends BookBasicHandle {
} }
} }
/**
* -
*
*
* 44使
*
*
* 1.
* 2.
* 3.
* 4. 使
*
* @param {string} bookTaskId - ID
* @returns {Promise<SuccessItem | ErrorItem>}
* -
* -
*
* @example
* // 假设某个批次有3个分镜分别有4、3、5张子图
* // 则以最少的3张为基准创建3个新批次任务
* // 每个新批次都包含3个分镜但使用不同索引的子图
*
* @throws {Error}
* -
* -
* -
* -
*/
async OneToFourBookTask(bookTaskId: string): Promise<SuccessItem | ErrorItem> {
try {
await this.InitBookBasicHandle()
// 初始化复制数量为100后续会根据实际子图数量调整
let copyCount = 100
let bookTask = await this.bookTaskService.GetBookTaskDataById(bookTaskId)
if (bookTask == null) {
throw new Error('没有找到对应的数小说任务,请检查数据')
}
// 获取该批次下所有的分镜详情,用于检查子图情况
let bookTaskDetails = await this.bookTaskDetailService.GetBookTaskDetailDataByCondition({
bookTaskId: bookTaskId
})
if (bookTaskDetails == null || bookTaskDetails.length <= 0) {
throw new Error('没有对应的小说分镜任务,请先添加分镜任务')
}
// 遍历所有分镜,找出子图数量最少的分镜,以此作为复制批次的数量基准
for (let i = 0; i < bookTaskDetails.length; i++) {
const element = bookTaskDetails[i]
// 检查分镜是否有子图路径
if (!element.subImagePath) {
throw new Error('检测到图片没有出完,请先检查出图')
}
if (element.subImagePath == null || element.subImagePath.length <= 0) {
throw new Error('检测到图片没有出完,请先检查出图')
}
// 更新最小子图数量(取所有分镜中子图数量的最小值)
if (element.subImagePath.length < copyCount) {
copyCount = element.subImagePath.length
}
}
// 检查是否有足够的子图进行一拆四操作至少需要2张子图才能拆分
if (copyCount - 1 <= 0) {
throw new Error('有分镜子图数量不足,无法进行一拆四')
}
// 开始执行复制操作:创建 (copyCount-1) 个新批次
// 每个新批次使用不同索引的子图CopyImageType.ONE 表示每个批次只使用一张子图
await this.bookTaskService.CopyNewBookTask(
bookTask,
bookTaskDetails,
copyCount - 1,
CopyImageType.ONE
)
// 返回成功结果
return successMessage(null, '一拆四成功', 'BookTaskServiceHandle_OneToFourBookTask')
} catch (error: any) {
// 捕获并返回错误信息
return errorMessage(
`小说批次任务 一拆四 失败,失败原因如下:${error.message}`,
'BookTaskServiceHandle_OneToFourBookTask'
)
}
}
/** /**
* *
* *

View File

@ -6,6 +6,7 @@ import { GetApiDefineDataById } from '@/define/data/apiData'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { BookBackTaskStatus } from '@/define/enum/bookEnum' import { BookBackTaskStatus } from '@/define/enum/bookEnum'
import { MJ } from '@/define/model/mj' import { MJ } from '@/define/model/mj'
import { define } from '@/define/define'
/** /**
* MidJourney API * MidJourney API
@ -128,27 +129,32 @@ export class MJApiService extends MJBasic {
imagineUrl!: string imagineUrl!: string
fetchTaskUrl!: string fetchTaskUrl!: string
describeUrl!: string describeUrl!: string
token!: string
constructor() { constructor() {
super() super()
this.bootType = 'MID_JOURNEY' this.bootType = 'MID_JOURNEY'
} }
//#region InitMJSetting
/** /**
* MJ设置 * MJ设置
*/ */
async InitMJSetting(): Promise<void> { async InitMJSetting(): Promise<void> {
await this.GetMJGeneralSetting() await this.GetMJGeneralSetting()
await this.GetApiSetting()
if (this.mjApiSetting?.apiKey == null || this.mjApiSetting?.apiKey == '') {
throw new Error('没有找到对应的API的配置请检查 ‘设置 -> MJ设置 配置!')
}
// 获取当前机器人类型
this.bootType = this.bootType =
this.mjGeneralSetting?.robot == MJRobotType.NIJI ? 'NIJI_JOURNEY' : 'MID_JOURNEY' this.mjGeneralSetting?.robot == MJRobotType.NIJI ? 'NIJI_JOURNEY' : 'MID_JOURNEY'
// 再 MJ API 模式下 获取对应的数据
if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_API) { if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_API) {
if (!this.mjApiSetting || isEmpty(this.mjApiSetting.apiUrl)) { await this.GetApiSetting()
if (
!this.mjApiSetting ||
isEmpty(this.mjApiSetting.apiUrl) ||
isEmpty(this.mjApiSetting.apiKey)
) {
throw new Error('没有找到对应的API的配置请检查 ‘设置 -> MJ设置 配置!') throw new Error('没有找到对应的API的配置请检查 ‘设置 -> MJ设置 配置!')
} }
let apiProvider = GetApiDefineDataById(this.mjApiSetting.apiUrl as string) let apiProvider = GetApiDefineDataById(this.mjApiSetting.apiUrl as string)
@ -158,11 +164,64 @@ export class MJApiService extends MJBasic {
this.imagineUrl = apiProvider.mj_url.imagine this.imagineUrl = apiProvider.mj_url.imagine
this.describeUrl = apiProvider.mj_url.describe this.describeUrl = apiProvider.mj_url.describe
this.fetchTaskUrl = apiProvider.mj_url.once_get_task this.fetchTaskUrl = apiProvider.mj_url.once_get_task
this.token = this.mjApiSetting.apiKey
} else if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_PACKAGE) {
await this.GetMJPackageSetting()
if (
!this.mjPackageSetting ||
isEmpty(this.mjPackageSetting.selectPackage) ||
isEmpty(this.mjPackageSetting.packageToken)
) {
throw new Error(
'没有找到对应的生图包的配置或配置有误,请检查 ‘设置 -> MJ设置 -> 生图包模式’ 配置!'
)
}
let mjProvider = GetApiDefineDataById(this.mjPackageSetting.selectPackage)
if (!mjProvider.isPackage) {
throw new Error('当前选择的包不支持,请检查 ‘设置 -> MJ设置 -> 生图包模式’ 配置!')
}
if (mjProvider.mj_url == null) {
throw new Error('当前选择的包不支持,请检查 ‘设置 -> MJ设置 -> 生图包模式’ 配置!')
}
this.imagineUrl = mjProvider.mj_url.imagine
this.describeUrl = mjProvider.mj_url.describe
this.fetchTaskUrl = mjProvider.mj_url.once_get_task
this.token = this.mjPackageSetting.packageToken
} else if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.LOCAL_MJ) {
await this.GetMjLocalSetting()
if (
this.mjLocalSetting == null ||
isEmpty(this.mjLocalSetting.requestUrl) ||
isEmpty(this.mjLocalSetting.token)
) {
throw new Error(
'本地代理模式的设置不完善或配置错误,请检查 ‘设置 -> MJ设置 -> 本地代理模式’ 配置!'
)
}
this.mjLocalSetting.requestUrl.endsWith('/')
? this.mjLocalSetting.requestUrl.slice(0, -1)
: this.mjLocalSetting.requestUrl
this.imagineUrl = this.mjLocalSetting.requestUrl + '/mj/submit/imagine'
this.describeUrl = this.mjLocalSetting.requestUrl + '/mj/submit/describe'
this.fetchTaskUrl = this.mjLocalSetting.requestUrl + '/mj/task/${id}/fetch'
this.token = this.mjLocalSetting.token
} else if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.REMOTE_MJ) {
await this.GetMjRemoteSetting()
this.imagineUrl = define.remotemj_api + 'mj/submit/imagine'
this.describeUrl = define.remotemj_api + 'mj/submit/describe'
this.fetchTaskUrl = define.remotemj_api + 'mj/task/${id}/fetch'
this.token = define.remote_token
} else { } else {
throw new Error('当前的MJ出图模式不支持请检查 ‘设置 -> MJ设置 配置!') throw new Error('当前的MJ出图模式不支持请检查 ‘设置 -> MJ设置 配置!')
} }
} }
//#endregion
//#region 获取对应的任务通过ID //#region 获取对应的任务通过ID
/** /**
* ID获取MidJourney API任务的状态和结果 * ID获取MidJourney API任务的状态和结果
@ -187,14 +246,14 @@ export class MJApiService extends MJBasic {
* console.error("获取任务状态失败:", error.message); * console.error("获取任务状态失败:", error.message);
* } * }
*/ */
async GetMJAPITaskById(taskId: string, backTaskId: string) { async GetMJAPITaskById(taskId: string, backTaskId: string): Promise<MJ.MJResponseToFront> {
try { try {
await this.InitMJSetting() await this.InitMJSetting()
let APIDescribeUrl = this.fetchTaskUrl.replace('${id}', taskId) let APIDescribeUrl = this.fetchTaskUrl.replace('${id}', taskId)
// 拼接headers // 拼接headers
let headers = { let headers = {
Authorization: this.mjApiSetting?.apiKey Authorization: this.token
} }
// 开始请求 // 开始请求
let res = await axios.get(APIDescribeUrl, { let res = await axios.get(APIDescribeUrl, {
@ -226,6 +285,11 @@ export class MJApiService extends MJBasic {
imageClick: resData.imageUrl, imageClick: resData.imageUrl,
imageShow: resData.imageUrl, imageShow: resData.imageUrl,
imagePath: resData.imageUrl, imagePath: resData.imageUrl,
imageUrls: resData.imageUrls
? resData.imageUrls
.filter((item) => item.url != null && !isEmpty(item.url))
.map((item) => item.url)
: [],
messageId: taskId, messageId: taskId,
status: status, status: status,
code: code, code: code,
@ -315,7 +379,7 @@ export class MJApiService extends MJBasic {
if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_API) { if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_API) {
delete data.accountFilter.remark delete data.accountFilter.remark
delete data.accountFilter.instanceId delete data.accountFilter.instanceId
config.headers['Authorization'] = this.mjApiSetting?.apiKey config.headers['Authorization'] = this.token
} else { } else {
throw new Error('MJ出图的类型不支持') throw new Error('MJ出图的类型不支持')
} }
@ -415,6 +479,9 @@ export class MJApiService extends MJBasic {
let res: string let res: string
switch (this.mjGeneralSetting?.outputMode) { switch (this.mjGeneralSetting?.outputMode) {
case ImageGenerateMode.MJ_API: case ImageGenerateMode.MJ_API:
case ImageGenerateMode.MJ_PACKAGE:
case ImageGenerateMode.REMOTE_MJ:
case ImageGenerateMode.LOCAL_MJ:
res = await this.SubmitMJImagineAPI(taskId, prompt) res = await this.SubmitMJImagineAPI(taskId, prompt)
break break
default: default:
@ -459,13 +526,34 @@ export class MJApiService extends MJBasic {
} }
} }
let useTransfer = false
if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_API) { if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_API) {
delete data.accountFilter.remark delete data.accountFilter.remark
delete data.accountFilter.instanceId delete data.accountFilter.instanceId
config.headers['Authorization'] = this.mjApiSetting?.apiKey config.headers['Authorization'] = this.token
useTransfer = false
} else if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_PACKAGE) {
delete data.accountFilter.remark
delete data.accountFilter.instanceId
delete data.accountFilter.modes
config.headers['Authorization'] = this.token
useTransfer = false
} else if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.LOCAL_MJ) {
delete data.accountFilter.remark
delete data.accountFilter.modes
delete data.accountFilter.instanceId
config.headers['mj-api-secret'] = this.token
useTransfer = false
} else if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.REMOTE_MJ) {
config.headers['mj-api-secret'] = this.token
delete data.accountFilter.modes
delete data.accountFilter.instanceId
useTransfer = this.mjRemoteSetting?.isForward ?? false
} else { } else {
throw new Error('MJ出图的类型不支持') throw new Error('不支持的MJ出图类型')
} }
console.log('useTransfer', useTransfer)
return { return {
body: data, body: data,
config: config config: config
@ -512,15 +600,16 @@ export class MJApiService extends MJBasic {
let res = await axios.post(this.imagineUrl, body, config) let res = await axios.post(this.imagineUrl, body, config)
let resData = res.data let resData = res.data
// if (this.mjGeneralSetting.outputMode == MJImageType.PACKAGE_MJ) { if (this.mjGeneralSetting?.outputMode == ImageGenerateMode.MJ_PACKAGE) {
// if (resData.code == -1 || resData.success == false) { if (resData.code == -1 || resData.success == false) {
// throw new Error(resData.message) throw new Error(resData.message)
// } }
// } }
if (resData == null) { if (resData == null) {
throw new Error('返回的数据为空') throw new Error('返回的数据为空')
} }
// 某些API的返回的code为23表示队列已满需要重新请求 // 某些API的返回的code为23表示队列已满需要重新请求
if (resData.code == 23) { if (resData.code == 23) {
this.taskListService.UpdateTaskStatus({ this.taskListService.UpdateTaskStatus({

View File

@ -12,6 +12,9 @@ export class MJBasic {
optionRealmService!: OptionRealmService optionRealmService!: OptionRealmService
mjGeneralSetting?: SettingModal.MJGeneralSettings mjGeneralSetting?: SettingModal.MJGeneralSettings
mjApiSetting?: SettingModal.MJApiSettings mjApiSetting?: SettingModal.MJApiSettings
mjPackageSetting?: SettingModal.MJPackageSetting
mjRemoteSetting?: SettingModal.MJRemoteSetting
mjLocalSetting?: SettingModal.MJLocalSetting
bookTaskDetailService!: BookTaskDetailService bookTaskDetailService!: BookTaskDetailService
bookTaskService!: BookTaskService bookTaskService!: BookTaskService
@ -90,7 +93,76 @@ export class MJBasic {
let apiSetting = this.optionRealmService.GetOptionByKey(OptionKeyName.Midjourney.ApiSetting) let apiSetting = this.optionRealmService.GetOptionByKey(OptionKeyName.Midjourney.ApiSetting)
this.mjApiSetting = optionSerialization<SettingModal.MJApiSettings>( this.mjApiSetting = optionSerialization<SettingModal.MJApiSettings>(
apiSetting, apiSetting,
'‘设置 -> MJ设置' '‘设置 -> MJ设置 -> API设置'
)
}
/**
* Midjourney生图包设置
*
* Midjourney的生图包设置信息
*
* MJBasic已正确初始化
*
* mjPackageSetting属性中便使
*
* @returns {Promise<void>}
* @throws {Error} optionSerialization可能会抛出错误
*/
async GetMJPackageSetting(): Promise<void> {
await this.InitMJBasic()
let packageSetting = this.optionRealmService.GetOptionByKey(
OptionKeyName.Midjourney.PackageSetting
)
this.mjPackageSetting = optionSerialization<SettingModal.MJPackageSetting>(
packageSetting,
'‘设置 -> MJ设置 -> 生图包设置’'
)
}
/**
* Midjourney远程代理设置
*
* Midjourney的远程代理设置信息
* 访Midjourney API的相关参数
*
* MJBasic已正确初始化
*
* mjRemoteSetting属性中便使
*
* @returns {Promise<void>}
* @throws {Error} optionSerialization可能会抛出错误
*/
async GetMjRemoteSetting(): Promise<void> {
await this.InitMJBasic()
let remoteSetting = this.optionRealmService.GetOptionByKey(
OptionKeyName.Midjourney.RemoteSetting
)
this.mjRemoteSetting = optionSerialization<SettingModal.MJRemoteSetting>(
remoteSetting,
'‘设置 -> MJ设置 -> 代理模式设置’'
)
}
/**
* Midjourney本地代理设置
*
* Midjourney的本地代理设置信息
*
*
* MJBasic已正确初始化
*
* mjLocalSetting属性中便使
*
* @returns {Promise<void>}
* @throws {Error} optionSerialization可能会抛出错误
*/
async GetMjLocalSetting(): Promise<void> {
await this.InitMJBasic()
let localSetting = this.optionRealmService.GetOptionByKey(OptionKeyName.Midjourney.LocalSetting)
this.mjLocalSetting = optionSerialization<SettingModal.MJLocalSetting>(
localSetting,
'‘设置 -> MJ设置 -> 本地代理设置’'
) )
} }
} }

View File

@ -491,32 +491,49 @@ export class MJServiceHandle extends MJBasic {
id: task.id as string, id: task.id as string,
status: BookBackTaskStatus.DONE status: BookBackTaskStatus.DONE
}) })
// 下载图片
let imagePath = path.join( let imageArray: string[] = []
book.bookFolderPath as string,
`data\\MJOriginalImage\\${task_res.messageId}.png` // 没有 imageUrls 参数时,分割主图
) if (task_res.imageUrls == null || task_res.imageUrls.length <= 0) {
// 判断是不是生图包是的话需要替换图片的baseurl // 下载图片
// if (this.mj_globalSetting.mj_simpleSetting.type == MJImageType.PACKAGE_MJ) { let imagePath = path.join(
// let imageBaseUrl = this.mj_globalSetting.mj_imagePackageSetting.selectedProxy book.bookFolderPath as string,
// if (imageBaseUrl != 'empty' && imageBaseUrl && imageBaseUrl != '') { `data\\MJOriginalImage\\${task_res.messageId}.png`
// task_res.imageClick = task_res.imageClick.replace(/https?:\/\/[^/]+/, imageBaseUrl) )
// }
// } await CheckFolderExistsOrCreate(path.dirname(imagePath))
await CheckFolderExistsOrCreate(path.dirname(imagePath)) await DownloadImageFromUrl(task_res.imageClick as string, imagePath)
await DownloadImageFromUrl(task_res.imageClick as string, imagePath) // 进行图片裁剪
// 进行图片裁剪 imageArray = await ImageSplit(
let imageRes = await ImageSplit( imagePath,
imagePath, bookTaskDetail.name as string,
bookTaskDetail.name as string, path.join(
path.join(book.bookFolderPath as string, 'data\\MJOriginalImage') bookTask.imageFolder as string,
) `subImage\\${bookTaskDetail.name}\\${new Date().getTime()}.png`
if (imageRes && imageRes.length < 4) { )
throw new Error('图片裁剪失败') )
if (imageArray && imageArray.length < 4) {
throw new Error('图片裁剪失败')
}
} else {
for (let i = 0; i < task_res.imageUrls.length; i++) {
const element = task_res.imageUrls[i]
if (isEmpty(element)) continue
// 开始下载
let imagePath = path.join(
bookTask.imageFolder as string,
`subImage\\${bookTaskDetail.name}\\${new Date().getTime()}_${i}.png`
)
await CheckFolderExistsOrCreate(path.dirname(imagePath))
await DownloadImageFromUrl(element as string, imagePath)
imageArray.push(imagePath)
}
} }
// 修改数据库数据,将图片保存到对应的文件夹中 // 修改数据库数据,将图片保存到对应的文件夹中
let firstImage = imageRes[0] let firstImage = imageArray[0]
if (book.type == BookType.ORIGINAL && bookTask.name == 'output_00001') { if (book.type == BookType.ORIGINAL && bookTask.name == 'output_00001') {
await CopyFileOrFolder( await CopyFileOrFolder(
firstImage, firstImage,
@ -526,7 +543,7 @@ export class MJServiceHandle extends MJBasic {
let out_file = path.join(bookTask.imageFolder as string, `${bookTaskDetail.name}.png`) let out_file = path.join(bookTask.imageFolder as string, `${bookTaskDetail.name}.png`)
await CopyFileOrFolder(firstImage, out_file) await CopyFileOrFolder(firstImage, out_file)
task_res.outImagePath = out_file task_res.outImagePath = out_file
task_res.subImagePath = imageRes task_res.subImagePath = imageArray
task_res.id = task.bookTaskDetailId as string task_res.id = task.bookTaskDetailId as string
let projectPath = await getProjectPath() let projectPath = await getProjectPath()
@ -535,7 +552,7 @@ export class MJServiceHandle extends MJBasic {
task.bookTaskDetailId as string, task.bookTaskDetailId as string,
{ {
outImagePath: path.relative(projectPath, out_file), outImagePath: path.relative(projectPath, out_file),
subImagePath: imageRes.map((item) => path.relative(projectPath, item)) subImagePath: imageArray.map((item) => path.relative(projectPath, item))
} }
) )
this.bookTaskDetailService.UpdateBookTaskDetailMjMessage( this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(

View File

@ -54,9 +54,15 @@ export const optionSerialization = <T>(
defaultValue?: T defaultValue?: T
): T => { ): T => {
if (option == null) { if (option == null) {
if (defaultValue) {
return defaultValue
}
throw new Error('未找到选项对象,请检查所有的选项设置是否存在!') throw new Error('未找到选项对象,请检查所有的选项设置是否存在!')
} }
if (option.value == null || option.value == undefined || isEmpty(option.value)) { if (option.value == null || option.value == undefined || isEmpty(option.value)) {
if (defaultValue) {
return defaultValue
}
throw new Error('option value is null') throw new Error('option value is null')
} }
if (Number.isFinite(option.type)) { if (Number.isFinite(option.type)) {

View File

@ -0,0 +1,613 @@
import { OptionRealmService } from '@/define/db/service/optionService'
import { OptionKeyName } from '@/define/enum/option'
import { SettingModal } from '@/define/model/setting'
import { TaskModal } from '@/define/model/task'
import { optionSerialization } from '../option/optionSerialization'
import {
CheckFileOrDirExist,
CheckFolderExistsOrCreate,
CopyFileOrFolder
} from '@/define/Tools/file'
import fs from 'fs'
import { ValidateJson } from '@/define/Tools/validate'
import axios from 'axios'
import { isEmpty } from 'lodash'
import { BookBackTaskStatus, BookTaskStatus, OperateBookType } from '@/define/enum/bookEnum'
import { SendReturnMessage } from '@/public/generalTools'
import { Book } from '@/define/model/book/book'
import { MJAction } from '@/define/enum/mjEnum'
import { ImageGenerateMode } from '@/define/data/mjData'
import path from 'path'
import { getProjectPath } from '../option/optionCommonService'
import { SDServiceHandle } from './sdServiceHandle'
export class ComfyUIServiceHandle extends SDServiceHandle {
constructor() {
super()
}
ComfyUIImageGenerate = async (task: TaskModal.Task) => {
try {
await this.InitSDBasic()
let comfyUISettingCollection = await this.GetComfyUISetting()
let bookTaskDetail = await this.bookTaskDetailService.GetBookTaskDetailDataById(
task.bookTaskDetailId as string
)
if (bookTaskDetail == null) {
throw new Error('未找到对应的小说分镜')
}
let book = await this.bookService.GetBookDataById(bookTaskDetail.bookId as string)
if (book == null) {
throw new Error('未找到对应的小说')
}
let bookTask = await this.bookTaskService.GetBookTaskDataById(
bookTaskDetail.bookTaskId as string
)
if (bookTask == null) {
throw new Error('未找到对应的小说任务')
}
// 调用方法合并提示词
let mergeRes = await this.MergeSDPrompt(
task.bookTaskDetailId as string,
OperateBookType.BOOKTASKDETAIL
)
if (mergeRes.code == 0) {
throw new Error(mergeRes.message)
}
// 获取提示词
bookTaskDetail.prompt = mergeRes.data[0].prompt
let prompt = bookTaskDetail.prompt
let negativePrompt = comfyUISettingCollection.comfyuiSimpleSetting.negativePrompt
// 开始组合请求体
let body = await this.GetComfyUIAPIBody(
prompt ?? '',
negativePrompt ?? '',
comfyUISettingCollection.comfyuiSelectedWorkflow.workflowPath
)
// 开始发送请求
let resData = await this.SubmitComfyUIImagine(body, comfyUISettingCollection)
// 修改任务状态
await this.bookTaskDetailService.ModifyBookTaskDetailById(task.bookTaskDetailId as string, {
status: BookTaskStatus.IMAGE
})
this.taskListService.UpdateTaskStatus({
id: task.id as string,
status: BookBackTaskStatus.RUNNING
})
SendReturnMessage(
{
code: 1,
message: '任务已提交',
id: task.bookTaskDetailId as string,
data: {
status: 'submited',
message: '任务已提交',
id: task.bookTaskDetailId as string
} as any
},
task.messageName as string
)
await this.FetchImageTask(
task,
resData.prompt_id,
book,
bookTask,
bookTaskDetail,
comfyUISettingCollection
)
} catch (error: any) {
let errorMsg = 'ComfyUI 生图失败,失败信息如下:' + error.toString()
this.taskListService.UpdateTaskStatus({
id: task.id as string,
status: BookBackTaskStatus.FAIL,
errorMessage: errorMsg
})
await this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(
task.bookTaskDetailId as string,
{
mjApiUrl: '',
progress: 0,
category: ImageGenerateMode.ComfyUI,
imageClick: '',
imageShow: '',
messageId: '',
action: MJAction.IMAGINE,
status: 'error',
message: errorMsg
}
)
SendReturnMessage(
{
code: 0,
message: errorMsg,
id: task.bookTaskDetailId as string,
data: {
status: 'error',
message: errorMsg,
id: task.bookTaskDetailId
}
},
task.messageName as string
)
throw error
}
}
//#region 获取ComfyUI的设置
/**
* ComfyUI的设置
* @returns
*/
private async GetComfyUISetting(): Promise<SettingModal.ComfyUISettingCollection> {
let result = {} as SettingModal.ComfyUISettingCollection
let optionRealmService = await OptionRealmService.getInstance()
let comfyuiSimpleSettingOption = optionRealmService.GetOptionByKey(
OptionKeyName.SD.ComfyUISimpleSetting
)
result['comfyuiSimpleSetting'] = optionSerialization<SettingModal.ComfyUISimpleSettingModel>(
comfyuiSimpleSettingOption,
'设置 -> ComfyUI 设置'
)
let comfyuiWorkFlowSettingOption = optionRealmService.GetOptionByKey(
OptionKeyName.SD.ComfyUIWorkFlowSetting
)
let comfyuiWorkFlowList = optionSerialization<SettingModal.ComfyUIWorkFlowSettingModel[]>(
comfyuiWorkFlowSettingOption,
'设置 -> ComfyUI 设置'
)
result['comfyuiWorkFlowSetting'] = comfyuiWorkFlowList
if (comfyuiWorkFlowList.length <= 0) {
throw new Error('ComfyUI的工作流设置为空请检查是否正确设置')
}
// 获取选中的工作流
let selectedWorkflow = comfyuiWorkFlowList.find(
(item) => item.id == result.comfyuiSimpleSetting.selectedWorkflow
)
if (selectedWorkflow == null) {
throw new Error('未找到选中的工作流,请检查是否正确设置!!')
}
// 判断工作流对应的文件是不是存在
if (!(await CheckFileOrDirExist(selectedWorkflow.workflowPath))) {
throw new Error('本地未找到选中的工作流文件地址,请检查是否正确设置!!')
}
result['comfyuiSelectedWorkflow'] = selectedWorkflow
return result
}
//#endregion
//#region 组合ComfyUI的请求体
/**
* ComfyUI的请求体
* @param prompt
* @param negativePrompt
* @param workflowPath
*/
private async GetComfyUIAPIBody(
prompt: string,
negativePrompt: string,
workflowPath: string
): Promise<string> {
let jsonContentString = await fs.promises.readFile(workflowPath, 'utf-8')
if (!ValidateJson(jsonContentString)) {
throw new Error('工作流文件内容不是有效的JSON格式请检查是否正确设置')
}
let jsonContent = JSON.parse(jsonContentString)
// 判断是否是对象
if (jsonContent !== null && typeof jsonContent === 'object' && !Array.isArray(jsonContent)) {
// 遍历对象属性
for (const key in jsonContent) {
let element = jsonContent[key]
if (element && element.class_type === 'CLIPTextEncode') {
if (element._meta?.title === '正向提示词') {
jsonContent[key].inputs.text = prompt
}
if (element._meta?.title === '反向提示词') {
jsonContent[key].inputs.text = negativePrompt
}
}
if (element && element.class_type === 'KSampler') {
const crypto = require('crypto')
const buffer = crypto.randomBytes(8)
let seed = BigInt('0x' + buffer.toString('hex'))
jsonContent[key].inputs.seed = seed.toString()
} else if (element && element.class_type === 'KSamplerAdvanced') {
const crypto = require('crypto')
const buffer = crypto.randomBytes(8)
let seed = BigInt('0x' + buffer.toString('hex'))
jsonContent[key].inputs.noise_seed = seed.toString()
}
}
} else {
throw new Error('工作流文件内容不是有效的JSON对象格式请检查是否正确设置')
}
let result = JSON.stringify({
prompt: jsonContent
})
return result
}
//#endregion
//#region 提交ComfyUI生成图片任务
private async SubmitComfyUIImagine(
body: string,
comfyUISettingCollection: SettingModal.ComfyUISettingCollection
): Promise<any> {
let url = comfyUISettingCollection.comfyuiSimpleSetting.requestUrl?.replace(
'localhost',
'127.0.0.1'
)
if (url.endsWith('/')) {
url = url + 'api/prompt'
} else {
url = url + '/api/prompt'
}
var config = {
method: 'post',
url: url,
headers: {
'User-Agent': 'Apifox/1.0.0 (https://apifox.com)',
'Content-Type': 'application/json'
},
data: body
}
let res = await axios(config)
let resData = res.data
// 判断是不是失败
if (resData.error) {
let errorNode = ''
if (resData.node_errors) {
for (const key in resData.node_errors) {
errorNode += key + ', '
}
}
let msg = '错误信息:' + resData.error.message + '错误节点:' + errorNode
throw new Error(msg)
}
// 没有错误 判断是不是成功
if (resData.prompt_id && !isEmpty(resData.prompt_id)) {
// 成功
return resData
} else {
throw new Error('未知错误未获取到请求ID请检查是否正确设置')
}
}
//#endregion
//#region 获取出图任务
async FetchImageTask(
task: TaskModal.Task,
promptId: string,
book: Book.SelectBook,
bookTask: Book.SelectBookTask,
bookTaskDetail: Book.SelectBookTaskDetail,
comfyUISettingCollection: SettingModal.ComfyUISettingCollection
) {
while (true) {
try {
let resData = await this.GetComfyUIImageTask(promptId, comfyUISettingCollection)
// 判断他的状态是不是成功
if (resData.status == 'error') {
// 生图失败
await this.bookTaskDetailService.ModifyBookTaskDetailById(
task.bookTaskDetailId as string,
{
status: BookTaskStatus.IMAGE_FAIL
}
)
let errorMsg = `MJ生成图片失败失败信息如下${resData.message}`
this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(
task.bookTaskDetailId as string,
{
mjApiUrl: comfyUISettingCollection.comfyuiSimpleSetting.requestUrl,
progress: 100,
category: ImageGenerateMode.ComfyUI,
imageClick: '',
imageShow: '',
messageId: promptId,
action: MJAction.IMAGINE,
status: 'error',
message: errorMsg
}
)
this.taskListService.UpdateTaskStatus({
id: task.id as string,
status: BookBackTaskStatus.FAIL,
errorMessage: errorMsg
})
SendReturnMessage(
{
code: 0,
message: errorMsg,
id: task.bookTaskDetailId as string,
data: {
status: 'error',
message: errorMsg,
id: task.bookTaskDetailId
}
},
task.messageName as string
)
return
} else if (resData.status == 'in_progress') {
// 生图中
this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(
task.bookTaskDetailId as string,
{
mjApiUrl: comfyUISettingCollection.comfyuiSimpleSetting.requestUrl,
progress: 0,
category: ImageGenerateMode.ComfyUI,
imageClick: '',
imageShow: '',
messageId: promptId,
action: MJAction.IMAGINE,
status: 'running',
message: '任务正在执行中'
}
)
SendReturnMessage(
{
code: 1,
message: 'running',
id: task.bookTaskDetailId as string,
data: {
status: 'running',
message: '任务正在执行中',
id: task.bookTaskDetailId
}
},
task.messageName as string
)
} else {
let res = await this.DownloadFileUrl(
resData.imageNames,
comfyUISettingCollection,
book,
bookTask,
bookTaskDetail
)
let projectPath = await getProjectPath()
// 修改数据库数据
// 修改数据库
await this.bookTaskDetailService.ModifyBookTaskDetailById(bookTaskDetail.id as string, {
outImagePath: path.relative(projectPath, res.outImagePath),
subImagePath: res.subImagePath.map((item) => path.relative(projectPath, item))
})
this.taskListService.UpdateTaskStatus({
id: task.id as string,
status: BookBackTaskStatus.DONE
})
this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(
task.bookTaskDetailId as string,
{
mjApiUrl: comfyUISettingCollection.comfyuiSimpleSetting.requestUrl,
progress: 100,
category: ImageGenerateMode.ComfyUI,
imageClick: '',
imageShow: '',
messageId: promptId,
action: MJAction.IMAGINE,
status: 'success',
message: 'ComfyUI 生成图片成功'
}
)
SendReturnMessage(
{
code: 1,
message: 'ComfyUI 生成图片成功',
id: task.bookTaskDetailId as string,
data: {
status: 'success',
message: 'ComfyUI 生成图片成功',
id: task.bookTaskDetailId,
outImagePath: res.outImagePath + '?t=' + new Date().getTime(),
subImagePath: res.subImagePath.map((item) => item + '?t=' + new Date().getTime())
}
},
task.messageName as string
)
break
}
await new Promise((resolve) => setTimeout(resolve, 3000))
} catch (error) {
throw error
}
}
}
//#endregion
//#region 获取comfyui出图任务
/**
* ComfyUI出图任务
* @param promptId
* @param comfyUISettingCollection
*/
private async GetComfyUIImageTask(
promptId: string,
comfyUISettingCollection: SettingModal.ComfyUISettingCollection
): Promise<any> {
if (isEmpty(promptId)) {
throw new Error('未获取到请求ID请检查是否正确设置')
}
if (isEmpty(comfyUISettingCollection.comfyuiSimpleSetting.requestUrl)) {
throw new Error('未获取到ComfyUI的请求地址请检查是否正确设置')
}
let url = comfyUISettingCollection.comfyuiSimpleSetting.requestUrl?.replace(
'localhost',
'127.0.0.1'
)
if (url.endsWith('/')) {
url = url + 'api/history'
} else {
url = url + '/api/history'
}
var config = {
method: 'get',
url: `${url}/${promptId}`,
headers: {
'User-Agent': 'Apifox/1.0.0 (https://apifox.com)'
}
}
let res = await axios.request(config)
let resData = res.data
// 判断状态是失败还是成功
let data = resData[promptId]
if (data == null) {
// 还在执行中 或者是任务不存在
return {
progress: 0,
status: 'in_progress',
message: '任务正在执行中'
}
}
let completed = data.status?.completed
let outputs = data.outputs
if (completed && outputs) {
let imageNames: string[] = []
for (const key in outputs) {
let outputNode = outputs[key]
if (outputNode && outputNode?.images && outputNode?.images.length > 0) {
for (let i = 0; i < outputNode?.images.length; i++) {
const element = outputNode?.images[i]
imageNames.push(element.filename as string)
}
}
}
return {
progress: 100,
status: 'success',
imageNames: imageNames
}
} else {
return {
progress: 0,
status: 'error',
message: '生图失败,详细失败信息看启动器控制台'
}
}
}
//#endregion
//#region 请求下载图片
/**
*
* @param url
* @param path
*/
private async DownloadFileUrl(
imageNames: string[],
comfyUISettingCollection: SettingModal.ComfyUISettingCollection,
book: Book.SelectBook,
bookTask: Book.SelectBookTask,
bookTaskDetail: Book.SelectBookTaskDetail
): Promise<{
outImagePath: string
subImagePath: string[]
}> {
let url = comfyUISettingCollection.comfyuiSimpleSetting.requestUrl?.replace(
'localhost',
'127.0.0.1'
)
if (url.endsWith('/')) {
url = url + 'api/view'
} else {
url = url + '/api/view'
}
let outImagePath = ''
let subImagePath: string[] = []
for (let i = 0; i < imageNames.length; i++) {
const imageName = imageNames[i]
var config = {
method: 'get',
url: `${url}?filename=${imageName}&nocache=${Date.now()}`,
headers: {
'User-Agent': 'Apifox/1.0.0 (https://apifox.com)'
},
responseType: 'arraybuffer' as 'arraybuffer' // 明确指定类型
}
let res = await axios.request(config)
// 检查响应状态和类型
console.log(`图片下载状态: ${res.status}, 内容类型: ${res.headers['content-type']}`)
// 确保得到的是图片数据
if (!res.headers['content-type']?.includes('image/')) {
console.error(`响应不是图片: ${res.headers['content-type']}`)
continue
}
let resData = res.data
console.log(resData)
let subImageFolderPath = path.join(
bookTask.imageFolder as string,
`subImage/${bookTaskDetail.name}`
)
await CheckFolderExistsOrCreate(subImageFolderPath)
let outputFolder = bookTask.imageFolder as string
await CheckFolderExistsOrCreate(outputFolder)
let inputFolder = path.join(book.bookFolderPath as string, 'tmp/input')
await CheckFolderExistsOrCreate(inputFolder)
// 包含info信息的图片地址
let infoImgPath = path.join(subImageFolderPath, `${new Date().getTime()}_${i}.png`)
// 直接将二进制数据写入文件
await fs.promises.writeFile(infoImgPath, Buffer.from(resData))
if (i == 0) {
// 复制到对应的文件夹里面
let outPath = path.join(outputFolder, `${bookTaskDetail.name}.png`)
await CopyFileOrFolder(infoImgPath, outPath)
outImagePath = outPath
}
subImagePath.push(infoImgPath as string)
}
console.log(outImagePath)
console.log(subImagePath)
// 将获取的数据返回
return {
outImagePath: outImagePath,
subImagePath: subImagePath
}
}
//#endregion
}

View File

@ -0,0 +1,233 @@
import { TaskModal } from '@/define/model/task'
import { SDServiceHandle } from './sdServiceHandle'
import { BookBackTaskStatus, BookTaskStatus, OperateBookType } from '@/define/enum/bookEnum'
import axios from 'axios'
import path from 'path'
import {
CheckFolderExistsOrCreate,
CopyFileOrFolder,
DeleteFileExifData
} from '@/define/Tools/file'
import { Base64ToFile } from '@/define/Tools/image'
import { define } from '@/define/define'
import { getProjectPath } from '../option/optionCommonService'
import { MJAction, MJRespoonseType } from '@/define/enum/mjEnum'
import { MJ } from '@/define/model/mj'
import { ImageGenerateMode } from '@/define/data/mjData'
import { errorMessage, SendReturnMessage, successMessage } from '@/public/generalTools'
export class FluxServiceHandle extends SDServiceHandle {
constructor() {
super()
}
async FluxForgeImageGenerate(task: TaskModal.Task) {
// 具体的生成逻辑
try {
// 开始生图
await this.GetSDImageSetting()
let bookTaskDetail = await this.bookTaskDetailService.GetBookTaskDetailDataById(
task.bookTaskDetailId as string
)
if (bookTaskDetail == null) {
throw new Error('未找到对应的分镜')
}
let bookTask = await this.bookTaskService.GetBookTaskDataById(
bookTaskDetail.bookTaskId as string
)
let book = await this.bookService.GetBookDataById(bookTask.bookId as string)
if (book == null) {
throw new Error('未找到对应的小说')
}
// 调用方法合并提示词
let mergeRes = await this.MergeSDPrompt(
task.bookTaskDetailId as string,
OperateBookType.BOOKTASKDETAIL
)
if (mergeRes.code == 0) {
throw new Error(mergeRes.message)
}
// 获取提示词
bookTaskDetail.prompt = mergeRes.data[0].prompt
let prompt = bookTaskDetail.prompt
let url = this.sdImageSetting.requestUrl
if (url.endsWith('/')) {
url = url + 'sdapi/v1/txt2img'
} else {
url = url + '/sdapi/v1/txt2img'
}
// 替换url中的localhost为127.0.0.1
url = url.replace('localhost', '127.0.0.1')
await this.GetSDADetailerSetting()
// 判断当前是不是有开修脸修手
let ADetailer = {
args: this.adetailerParam
}
// 种子默认 -1随机
let seed = -1
let body = {
scheduler: 'Simple',
prompt: prompt,
seed: seed,
sampler_name: this.sdImageSetting.sampler,
// 提示词相关性
cfg_scale: this.sdImageSetting.cfgScale,
distilled_cfg_scale: 3.5,
width: this.sdImageSetting.width,
height: this.sdImageSetting.height,
batch_size: this.sdImageSetting.batchCount,
steps: this.sdImageSetting.steps,
save_images: false,
tiling: false,
override_settings_restore_afterwards: true
}
if (bookTaskDetail.adetailer) {
body['alwayson_scripts'] = {
ADetailer: ADetailer
}
}
const response = await axios.post(url, body)
let images = response.data.images
let subImageFolderPath = path.join(
bookTask.imageFolder as string,
`subImage/${bookTaskDetail.name}`
)
await CheckFolderExistsOrCreate(subImageFolderPath)
let outputFolder = bookTask.imageFolder
await CheckFolderExistsOrCreate(outputFolder)
let inputFolder = path.join(book.bookFolderPath as string, 'tmp/input')
await CheckFolderExistsOrCreate(inputFolder)
let subImagePath: string[] = []
let outImagePath = ''
// 开始写出图片
for (let i = 0; i < images.length; i++) {
const element = images[i]
// 包含info信息的图片地址
let infoImgPath = path.join(
subImageFolderPath as string,
`info_${bookTaskDetail.name}_${new Date().getTime()}_${i}.png`
)
// 不包含info信息的图片地址
let imgPath = path.join(subImageFolderPath, `${new Date().getTime()}_${i}.png`)
await Base64ToFile(element, infoImgPath)
// 这边去图片信息
await DeleteFileExifData(
path.join(define.package_path, 'exittool/exiftool.exe'),
infoImgPath,
imgPath
)
if (i == 0) {
// 复制到对应的文件夹里面
let outPath = path.join(outputFolder as string, `${bookTaskDetail.name}.png`)
await CopyFileOrFolder(imgPath, outPath)
outImagePath = outPath
}
subImagePath.push(imgPath)
}
let projectPath = await getProjectPath()
// 修改数据库
await this.bookTaskDetailService.ModifyBookTaskDetailById(bookTaskDetail.id as string, {
outImagePath: path.relative(projectPath, outImagePath),
subImagePath: subImagePath.map((item) => path.relative(projectPath, item))
})
this.taskListService.UpdateTaskStatus({
id: task.id as string,
status: BookBackTaskStatus.DONE
})
let resp = {
code: 1,
id: bookTaskDetail.id as string,
type: MJRespoonseType.FINISHED,
mjType: MJAction.IMAGINE,
mjApiUrl: url,
progress: 100,
category: ImageGenerateMode.FLUX_FORGE,
imageClick: subImagePath.join(','),
imageShow: subImagePath.join(','),
messageId: subImagePath.join(','),
action: MJAction.IMAGINE,
status: 'success',
outImagePath: outImagePath + '?t=' + new Date().getTime(),
subImagePath: subImagePath.map((item) => item + '?t=' + new Date().getTime()),
message: 'FLUX FORGE 生成图片成功'
} as MJ.MJResponseToFront
this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(
task.bookTaskDetailId as string,
resp
)
SendReturnMessage(
{
code: 1,
message: 'FLUX FORGE 生成图片成功',
id: bookTaskDetail.id as string,
data: {
...resp
}
},
task.messageName as string
)
return successMessage(
resp,
'FLUX FORGE 生成图片成功',
'FluxServiceHandle_FluxForgeImageGenerate'
)
} catch (error: any) {
let errorMsg = 'FLUX FORGE 生成图片失败,错误信息如下:' + error.toString()
this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(task.bookTaskDetailId as string, {
mjApiUrl: this.sdImageSetting.requestUrl,
progress: 0,
category: ImageGenerateMode.FLUX_FORGE,
imageClick: '',
imageShow: '',
messageId: '',
action: MJAction.IMAGINE,
status: 'error',
message: errorMsg
})
await this.bookTaskDetailService.ModifyBookTaskDetailById(task.bookTaskDetailId as string, {
status: BookTaskStatus.IMAGE_FAIL
})
this.taskListService.UpdateTaskStatus({
id: task.id as string,
status: BookBackTaskStatus.FAIL,
errorMessage: errorMsg
})
SendReturnMessage(
{
code: 0,
message: errorMsg,
id: task.bookTaskDetailId as string,
data: {
code: 0,
id: task.bookTaskDetailId as string,
type: MJRespoonseType.FINISHED,
mjType: MJAction.IMAGINE,
mjApiUrl: this.sdImageSetting.requestUrl,
progress: 0,
category: ImageGenerateMode.FLUX_FORGE,
imageClick: '',
imageShow: '',
messageId: '',
action: MJAction.IMAGINE,
status: 'error',
message: errorMsg
} as MJ.MJResponseToFront
},
task.messageName as string
)
return errorMessage(errorMsg, 'SDServiceHandle_SDImageGenerate')
}
}
}

View File

@ -1,11 +1,26 @@
import { TaskModal } from '@/define/model/task' import { TaskModal } from '@/define/model/task'
import { SDServiceHandle } from './sdServiceHandle' import { SDServiceHandle } from './sdServiceHandle'
import { FluxServiceHandle } from './fluxServiceHandle'
import { ComfyUIServiceHandle } from './comfyUIServiceHandle'
export class SDHandle { export class SDHandle {
sdServiceHandle: SDServiceHandle sdServiceHandle: SDServiceHandle
fluxServiceHandle: FluxServiceHandle
comfyUIServiceHandle: ComfyUIServiceHandle
constructor() { constructor() {
this.sdServiceHandle = new SDServiceHandle() this.sdServiceHandle = new SDServiceHandle()
this.fluxServiceHandle = new FluxServiceHandle()
this.comfyUIServiceHandle = new ComfyUIServiceHandle()
} }
/** 使用Stable Diffusion生成图像 */ /** 使用Stable Diffusion生成图像 */
SDImageGenerate = async (task: TaskModal.Task) => await this.sdServiceHandle.SDImageGenerate(task) SDImageGenerate = async (task: TaskModal.Task) => await this.sdServiceHandle.SDImageGenerate(task)
/** 使用 Flux FORGE 生成图片 */
FluxForgeImageGenerate = async (task: TaskModal.Task) =>
await this.fluxServiceHandle.FluxForgeImageGenerate(task)
/** 使用 Comfy UI 生成图片 */
ComfyUIImageGenerate = async (task: TaskModal.Task) =>
await this.comfyUIServiceHandle.ComfyUIImageGenerate(task)
} }

View File

@ -321,8 +321,11 @@ export class SDServiceHandle extends SDBasic {
const response = await axios.post(url, body) const response = await axios.post(url, body)
let images = response.data.images let images = response.data.images
let SdOriginalImage = path.join(book.bookFolderPath as string, 'data/SdOriginalImage') let subImageFolderPath = path.join(
await CheckFolderExistsOrCreate(SdOriginalImage) bookTask.imageFolder as string,
`subImage/${bookTaskDetail.name}`
)
await CheckFolderExistsOrCreate(subImageFolderPath)
let outputFolder = bookTask.imageFolder let outputFolder = bookTask.imageFolder
await CheckFolderExistsOrCreate(outputFolder) await CheckFolderExistsOrCreate(outputFolder)
let inputFolder = path.join(book.bookFolderPath as string, 'tmp/input') let inputFolder = path.join(book.bookFolderPath as string, 'tmp/input')
@ -335,14 +338,11 @@ export class SDServiceHandle extends SDBasic {
const element = images[i] const element = images[i]
// 包含info信息的图片地址 // 包含info信息的图片地址
let infoImgPath = path.join( let infoImgPath = path.join(
SdOriginalImage, subImageFolderPath as string,
`info_${bookTaskDetail.name}_${new Date().getTime()}_${i}.png` `info_${bookTaskDetail.name}_${new Date().getTime()}_${i}.png`
) )
// 不包含info信息的图片地址 // 不包含info信息的图片地址
let imgPath = path.join( let imgPath = path.join(subImageFolderPath, `${new Date().getTime()}_${i}.png`)
SdOriginalImage,
`${bookTaskDetail.name}_${new Date().getTime()}_${i}.png`
)
await Base64ToFile(element, infoImgPath) await Base64ToFile(element, infoImgPath)
// 这边去图片信息 // 这边去图片信息
await DeleteFileExifData( await DeleteFileExifData(
@ -350,12 +350,6 @@ export class SDServiceHandle extends SDBasic {
infoImgPath, infoImgPath,
imgPath imgPath
) )
// 写出去
if (bookTask.name == 'output_00001' && book.type == BookType.ORIGINAL) {
// 复制一个到input
let inputImgPath = path.join(inputFolder, `${bookTaskDetail.name}.png`)
await CopyFileOrFolder(imgPath, inputImgPath)
}
if (i == 0) { if (i == 0) {
// 复制到对应的文件夹里面 // 复制到对应的文件夹里面
let outPath = path.join(outputFolder as string, `${bookTaskDetail.name}.png`) let outPath = path.join(outputFolder as string, `${bookTaskDetail.name}.png`)

View File

@ -281,22 +281,39 @@ export class TaskManager {
) )
} }
// /**
// * 添加 flux forge 任务到内存队列中
// * @param task
// */
async AddFluxForgeImage(task: TaskModal.Task) {
let batch = task.messageName
global.taskQueue.enqueue(
async () => {
await this.sdHandle.FluxForgeImageGenerate(task)
},
`${batch}_${task.id}`,
batch,
`${batch}_${task.id}_${new Date().getTime()}`,
this.taskListService.SetMessageNameTaskToFail
)
}
// /** // /**
// * 将Comfy UI生图任务添加到内存任务中 // * 将Comfy UI生图任务添加到内存任务中
// * @param task // * @param task
// */ // */
// async AddComfyUIImage(task: TaskModal.Task) { async AddComfyUIImage(task: TaskModal.Task) {
// let batch = task.messageName let batch = task.messageName
// global.taskQueue.enqueue( global.taskQueue.enqueue(
// async () => { async () => {
// await this.comfyUIOpt.ComfyUIImageGenerate(task) await this.sdHandle.ComfyUIImageGenerate(task)
// }, },
// `${batch}_${task.id}`, `${batch}_${task.id}`,
// batch, batch,
// `${batch}_${task.id}_${new Date().getTime()}`, `${batch}_${task.id}_${new Date().getTime()}`,
// this.taskListService.SetMessageNameTaskToFail this.taskListService.SetMessageNameTaskToFail
// ) )
// } }
// /** // /**
// * 异步添加D3图像生成任务 // * 异步添加D3图像生成任务
@ -349,23 +366,6 @@ export class TaskManager {
// ) // )
// } // }
// /**
// * 添加 flux forge 任务到内存队列中
// * @param task
// */
// async AddFluxForgeImage(task: TaskModal.Task) {
// let batch = task.messageName
// global.taskQueue.enqueue(
// async () => {
// await this.fluxOpt.FluxForgeImage(task)
// },
// `${batch}_${task.id}`,
// batch,
// `${batch}_${task.id}_${new Date().getTime()}`,
// this.taskListService.SetMessageNameTaskToFail
// )
// }
// /** // /**
// * 添加 FLUX api 到内存队列中 // * 添加 FLUX api 到内存队列中
// * @param task // * @param task
@ -410,9 +410,9 @@ export class TaskManager {
// case BookBackTaskType.SD_REVERSE: // case BookBackTaskType.SD_REVERSE:
// this.AddSingleReversePrompt(task) // this.AddSingleReversePrompt(task)
// break // break
// case BookBackTaskType.FLUX_FORGE_IMAGE: case BookBackTaskType.FLUX_FORGE_IMAGE:
// this.AddFluxForgeImage(task) this.AddFluxForgeImage(task)
// break break
// case BookBackTaskType.FLUX_API_IMAGE: // case BookBackTaskType.FLUX_API_IMAGE:
// this.AddFluxAPIImage(task) // this.AddFluxAPIImage(task)
// break // break
@ -422,9 +422,9 @@ export class TaskManager {
case BookBackTaskType.SD_IMAGE: case BookBackTaskType.SD_IMAGE:
this.AddSDImage(task) this.AddSDImage(task)
break break
// case BookBackTaskType.ComfyUI_IMAGE: case BookBackTaskType.ComfyUI_IMAGE:
// this.AddComfyUIImage(task) this.AddComfyUIImage(task)
// break break
// case BookBackTaskType.D3_IMAGE: // case BookBackTaskType.D3_IMAGE:
// this.AddD3Image(task) // this.AddD3Image(task)
// break // break

View File

@ -16,6 +16,7 @@ import { OptionKeyName } from '@/define/enum/option'
import { optionSerialization } from '../option/optionSerialization' import { optionSerialization } from '../option/optionSerialization'
import { SettingModal } from '@/define/model/setting' import { SettingModal } from '@/define/model/setting'
import { GetApiDefineDataById } from '@/define/data/apiData' import { GetApiDefineDataById } from '@/define/data/apiData'
import { isEmpty } from 'lodash'
export class TranslateCommon { export class TranslateCommon {
/** 请求的地址 */ /** 请求的地址 */
@ -47,6 +48,9 @@ export class TranslateCommon {
) )
let apiProvider = GetApiDefineDataById(aiSetting.apiProvider) let apiProvider = GetApiDefineDataById(aiSetting.apiProvider)
if (apiProvider.gpt_url == null || isEmpty(apiProvider.gpt_url)) {
throw new Error('未找到有效的GPT API地址')
}
this.translationBusiness = apiProvider.gpt_url this.translationBusiness = apiProvider.gpt_url
this.translationAppId = aiSetting.translationModel this.translationAppId = aiSetting.translationModel
this.translationSecret = aiSetting.apiToken this.translationSecret = aiSetting.apiToken

View File

@ -0,0 +1,256 @@
import { errorMessage, SendReturnMessage, successMessage } from '@/public/generalTools'
import { BookBasicHandle } from '../book/subBookHandle/bookBasicHandle'
import { OptionKeyName } from '@/define/enum/option'
import { optionSerialization } from '../option/optionSerialization'
import { SettingModal } from '@/define/model/setting'
import { isEmpty } from 'lodash'
import { RetryWithBackoff } from '@/define/Tools/common'
import { define } from '@/define/define'
import { DEFINE_STRING } from '@/define/ipcDefineString'
import axios from 'axios'
import { GetOpenAISuccessResponse, GetRixApiErrorResponse } from '@/define/response/openAIResponse'
export class CopyWritingServiceHandle extends BookBasicHandle {
constructor() {
super()
}
/**
*
* API设置和简单设置
* @param ids ID数组
* @returns API设置和简单设置的对象
* @throws ID时抛出错误
*/
private async getCopyWritingSetting(ids: string[]): Promise<{
apiSetting: SettingModal.CopyWritingAPISettings
simpleSetting: SettingModal.CopyWritingSimpleSettings
}> {
await this.InitBookBasicHandle()
// 加载文案处理数据
let simpleSettingOption = this.optionRealmService.GetOptionByKey(
OptionKeyName.InferenceAI.CW_SimpleSetting
)
let simpleSetting = optionSerialization<SettingModal.CopyWritingSimpleSettings>(
simpleSettingOption,
' 文案处理->设置 '
)
if (isEmpty(simpleSetting.gptType) || isEmpty(simpleSetting.gptData)) {
throw new Error('设置数据不完整,请检查提示词类型,提示词预设数据是否完整')
}
let wordStruct = simpleSetting.wordStruct
let filterWordStruct = wordStruct.filter((item) => ids.includes(item.id))
if (filterWordStruct.length === 0) {
throw new Error('没有找到需要处理的文案ID对应的数据请检查数据是否正确')
}
let apiSettingOption = this.optionRealmService.GetOptionByKey(
OptionKeyName.InferenceAI.CW_AISimpleSetting
)
let apiSetting = optionSerialization<SettingModal.CopyWritingAPISettings>(
apiSettingOption,
' 文案处理->设置 '
)
if (isEmpty(apiSetting.apiKey) || isEmpty(apiSetting.gptUrl) || isEmpty(apiSetting.model)) {
throw new Error('文案处理API设置不完整请检查API地址密钥和模型是否设置正确')
}
return {
apiSetting: apiSetting,
simpleSetting: simpleSetting
}
}
private async AIRequestStream(
simpleSetting: SettingModal.CopyWritingSimpleSettings,
apiSetting: SettingModal.CopyWritingAPISettings,
wordStruct: SettingModal.CopyWritingSimpleSettingsWordStruct
) {
let body = {
promptTypeId: simpleSetting.gptType,
promptId: simpleSetting.gptData,
gptUrl: apiSetting.gptUrl,
model: apiSetting.model,
machineId: global.machineId,
apiKey: apiSetting.apiKey,
word: wordStruct.oldWord
}
var myHeaders = new Headers()
myHeaders.append('User-Agent', 'Apifox/1.0.0 (https://apifox.com)')
myHeaders.append('Content-Type', 'application/json')
var requestOptions = {
method: 'POST',
headers: myHeaders,
body: JSON.stringify(body)
}
let resData = ''
return new Promise((resolve, reject) => {
fetch(define.lms_url + '/lms/Forward/ForwardWordStream', requestOptions)
.then((response) => {
if (!response.body) {
throw new Error('ReadableStream not yet supported in this browser.')
}
const reader = response.body.getReader()
return new ReadableStream({
start(controller) {
function push() {
reader
.read()
.then(({ done, value }) => {
if (done) {
controller.close()
resolve(resData)
return
}
// 假设服务器发送的是文本数据
const text = new TextDecoder().decode(value)
console.log(text)
resData += text
// 将数据返回前端
SendReturnMessage(
{
code: 1,
id: wordStruct.id,
message: '文案生成成功',
data: {
oldWord: wordStruct.oldWord,
newWord: resData
}
},
DEFINE_STRING.WRITE.COPYWRITING_AI_GENERATION_RETURN
)
controller.enqueue(value) // 可选:将数据块放入流中
push()
})
.catch((err) => {
controller.error(err)
reject(err)
})
}
push()
}
})
})
.catch((error) => {
reject(error)
})
})
}
async AIRequest(
simpleSetting: SettingModal.CopyWritingSimpleSettings,
apiSetting: SettingModal.CopyWritingAPISettings,
word: string
): Promise<string> {
// 开始请求AI
let axiosRes = await axios.post(define.lms_url + '/lms/Forward/ForwardWord', {
promptTypeId: simpleSetting.gptType,
promptId: simpleSetting.gptData,
gptUrl: apiSetting.gptUrl.endsWith('/')
? apiSetting.gptUrl + 'v1/chat/completions'
: apiSetting.gptUrl + '/v1/chat/completions',
model: apiSetting.model,
machineId: global.machineId,
apiKey: apiSetting.apiKey,
word: word
})
// 判断返回的状态,如果是失败的话直接返回错误信息
if (axiosRes.status != 200) {
throw new Error('请求失败')
}
let dataRes = axiosRes.data
if (dataRes.code == 1) {
// 获取成功
// 解析返回的数据
return GetOpenAISuccessResponse(dataRes.data)
} else {
// 系统报错
if (dataRes.code == 5000) {
throw new Error('系统错误,错误信息如下:' + dataRes.message)
} else {
// 处理不同类型的错误消息
throw new Error(GetRixApiErrorResponse(dataRes.data))
}
}
}
async CopyWritingAIGeneration(ids: string[]) {
try {
if (ids.length === 0) {
throw new Error('没有需要处理的文案ID')
}
let { apiSetting, simpleSetting } = await this.getCopyWritingSetting(ids)
let wordStruct = simpleSetting.wordStruct
let filterWordStruct = wordStruct.filter((item) => ids.includes(item.id))
if (filterWordStruct.length === 0) {
throw new Error('没有找到需要处理的文案ID对应的数据请检查数据是否正确')
}
// 开始循环请求AI
for (let ii = 0; ii < filterWordStruct.length; ii++) {
const element = filterWordStruct[ii]
if (simpleSetting.isStream) {
// 流式请求
let returnData =
(await RetryWithBackoff(
async () => {
return await this.AIRequestStream(simpleSetting, apiSetting, element)
},
3,
1000
)) + '\n'
// 这边将数据保存
element.newWord = returnData
} else {
// 非流式请求
let returnData =
(await RetryWithBackoff(
async () => {
return await this.AIRequest(simpleSetting, apiSetting, element.oldWord as string)
},
3,
1000
)) + '\n'
// 这边将数据保存
element.newWord = returnData
console.log(returnData)
// 将非流的数据返回
SendReturnMessage(
{
code: 1,
id: element.id,
message: '文案生成成功',
data: {
oldWord: element.oldWord,
newWord: returnData
}
},
DEFINE_STRING.WRITE.COPYWRITING_AI_GENERATION_RETURN
)
}
}
// 处理完毕 返回数据。这边不做任何的保存动作
return successMessage(
wordStruct,
'AI处理文案成功',
'CopywritingAIGenerationService_CopyWritingAIGeneration'
)
} catch (error: any) {
return errorMessage(
'AI处理文案失败失败原因如下' + error.message,
'CopyWritingServiceHandle_CopyWritingAIGeneration'
)
}
}
}

View File

@ -0,0 +1,10 @@
import { CopyWritingServiceHandle } from "./copyWritingServiceHandle"
export class WriteHandle {
copyWritingServiceHandle : CopyWritingServiceHandle
constructor() {
this.copyWritingServiceHandle = new CopyWritingServiceHandle()
}
CopyWritingAIGeneration = async (ids: string[]) => await this.copyWritingServiceHandle.CopyWritingAIGeneration(ids)
}

View File

@ -12,5 +12,6 @@ declare global {
book: any book: any
preset: any preset: any
task: any task: any
write: any
} }
} }

View File

@ -7,6 +7,7 @@ import { axiosPrelod } from './subPreload/axios'
import { book } from './subPreload/book' import { book } from './subPreload/book'
import { preset } from './subPreload/preset' import { preset } from './subPreload/preset'
import { task } from './subPreload/task' import { task } from './subPreload/task'
import { write } from './subPreload/write'
import packageJson from '../../package.json' import packageJson from '../../package.json'
// Custom APIs for renderer // Custom APIs for renderer
@ -31,6 +32,7 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('book', book) contextBridge.exposeInMainWorld('book', book)
contextBridge.exposeInMainWorld('preset', preset) contextBridge.exposeInMainWorld('preset', preset)
contextBridge.exposeInMainWorld('task', task) contextBridge.exposeInMainWorld('task', task)
contextBridge.exposeInMainWorld('write', write)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
@ -53,4 +55,6 @@ if (process.contextIsolated) {
window.preset = preset window.preset = preset
// @ts-ignore (define in dts) // @ts-ignore (define in dts)
window.task = task window.task = task
// @ts-ignore (define in dts)
window.write = write
} }

View File

@ -40,7 +40,11 @@ export const bookTaskPreload = {
/** 获取小说批次任务的第一张图片路径 */ /** 获取小说批次任务的第一张图片路径 */
GetBookTaskFirstImagePath: async (id: string) => GetBookTaskFirstImagePath: async (id: string) =>
await ipcRenderer.invoke(DEFINE_STRING.BOOK.GET_BOOK_TASK_FIRST_IMAGE_PATH, id) await ipcRenderer.invoke(DEFINE_STRING.BOOK.GET_BOOK_TASK_FIRST_IMAGE_PATH, id),
/** 小说批次任务 一拆四 */
OneToFourBookTask: async (bookTaskId: string) =>
await ipcRenderer.invoke(DEFINE_STRING.BOOK.ONE_TO_FOUR_BOOK_TASK, bookTaskId)
//#endregion //#endregion
} }

View File

@ -0,0 +1,11 @@
import { ipcRenderer } from 'electron'
import { DEFINE_STRING } from '@/define/ipcDefineString'
const write = {
/** 文案生成 - AI */
CopyWritingAIGeneration: async (ids: string[]) => {
return await ipcRenderer.invoke(DEFINE_STRING.WRITE.COPYWRITING_AI_GENERATION, ids)
}
}
export { write }

View File

@ -8,23 +8,26 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AddBook: typeof import('./src/components/Original/MainHome/OriginalAddBook.vue')['default']
AddBookTask: typeof import('./src/components/Original/MainHome/OriginalAddBookTask.vue')['default']
AddOrModifyPreset: typeof import('./src/components/Preset/AddOrModifyPreset.vue')['default'] AddOrModifyPreset: typeof import('./src/components/Preset/AddOrModifyPreset.vue')['default']
AIGroup: typeof import('./src/components/Original/Copywriter/AIGroup.vue')['default'] AIGroup: typeof import('./src/components/Original/Copywriter/AIGroup.vue')['default']
AIGroup_new: typeof import('./src/components/Original/Copywriter/AIGroup_new.vue')['default']
AISetting: typeof import('./src/components/Setting/AISetting.vue')['default'] AISetting: typeof import('./src/components/Setting/AISetting.vue')['default']
AllImagePreview: typeof import('./src/components/Original/BookTaskDetail/AllImagePreview.vue')['default'] AllImagePreview: typeof import('./src/components/Original/BookTaskDetail/AllImagePreview.vue')['default']
APIIcon: typeof import('./src/components/common/Icon/APIIcon.vue')['default'] APIIcon: typeof import('./src/components/common/Icon/APIIcon.vue')['default']
AppearanceSettings: typeof import('./src/components/Setting/AppearanceSettings.vue')['default'] AppearanceSettings: typeof import('./src/components/Setting/AppearanceSettings.vue')['default']
BackTaskIcon: typeof import('./src/components/common/Icon/BackTaskIcon.vue')['default'] BackTaskIcon: typeof import('./src/components/common/Icon/BackTaskIcon.vue')['default']
BookTaskCard: typeof import('./src/components/Original/MainHome/OriginalBookTaskCard.vue')['default']
BookTaskDetailTable: typeof import('./src/components/Original/BookTaskDetail/BookTaskDetailTable.vue')['default'] BookTaskDetailTable: typeof import('./src/components/Original/BookTaskDetail/BookTaskDetailTable.vue')['default']
BookTaskImageCache: typeof import('./src/components/Original/Image/BookTaskImageCache.vue')['default'] BookTaskImageCache: typeof import('./src/components/Original/Image/BookTaskImageCache.vue')['default']
CharacterPreset: typeof import('./src/components/Preset/CharacterPreset.vue')['default'] CharacterPreset: typeof import('./src/components/Preset/CharacterPreset.vue')['default']
ComfyUIAddWorkflow: typeof import('./src/components/Setting/ComfyUIAddWorkflow.vue')['default']
ComfyUISetting: typeof import('./src/components/Setting/ComfyUISetting.vue')['default']
CommonDialog: typeof import('./src/components/common/CommonDialog.vue')['default'] CommonDialog: typeof import('./src/components/common/CommonDialog.vue')['default']
ContactDeveloper: typeof import('./src/components/SoftHome/ContactDeveloper.vue')['default'] ContactDeveloper: typeof import('./src/components/SoftHome/ContactDeveloper.vue')['default']
copy: typeof import('./src/components/Original/Copywriter/AIGroup.vue')['default'] CopyWritingCategoryMenu: typeof import('./src/components/CopyWriting/CopyWritingCategoryMenu.vue')['default']
CopyWritingContent: typeof import('./src/components/CopyWriting/CopyWritingContent.vue')['default']
CopyWritingShowAIGenerate: typeof import('./src/components/CopyWriting/CopyWritingShowAIGenerate.vue')['default']
CopyWritingSimpleSetting: typeof import('./src/components/CopyWriting/CopyWritingSimpleSetting.vue')['default']
CustomInferencePreset: typeof import('./src/components/Setting/CustomInferencePreset.vue')['default']
CWInputWord: typeof import('./src/components/CopyWriting/CWInputWord.vue')['default']
DataTableAction: typeof import('./src/components/Original/BookTaskDetail/DataTableAction.vue')['default'] DataTableAction: typeof import('./src/components/Original/BookTaskDetail/DataTableAction.vue')['default']
DatatableAfterGpt: typeof import('./src/components/Original/BookTaskDetail/DatatableAfterGpt.vue')['default'] DatatableAfterGpt: typeof import('./src/components/Original/BookTaskDetail/DatatableAfterGpt.vue')['default']
DatatableCharacterAndSceneAndStyle: typeof import('./src/components/Original/BookTaskDetail/DatatableCharacterAndSceneAndStyle.vue')['default'] DatatableCharacterAndSceneAndStyle: typeof import('./src/components/Original/BookTaskDetail/DatatableCharacterAndSceneAndStyle.vue')['default']
@ -38,19 +41,27 @@ declare module 'vue' {
DownloadRound: typeof import('./src/components/common/Icon/DownloadRound.vue')['default'] DownloadRound: typeof import('./src/components/common/Icon/DownloadRound.vue')['default']
DynamicPromptSortTagsSelect: typeof import('./src/components/Original/BookTaskDetail/DynamicPromptSortTagsSelect.vue')['default'] DynamicPromptSortTagsSelect: typeof import('./src/components/Original/BookTaskDetail/DynamicPromptSortTagsSelect.vue')['default']
EditWord: typeof import('./src/components/Original/Copywriter/EditWord.vue')['default'] EditWord: typeof import('./src/components/Original/Copywriter/EditWord.vue')['default']
EmptyState: typeof import('./src/components/Original/MainHome/OriginalEmptyState.vue')['default']
FindReplaceRound: typeof import('./src/components/common/Icon/FindReplaceRound.vue')['default'] FindReplaceRound: typeof import('./src/components/common/Icon/FindReplaceRound.vue')['default']
GeneralSettings: typeof import('./src/components/Setting/GeneralSettings.vue')['default'] GeneralSettings: typeof import('./src/components/Setting/GeneralSettings.vue')['default']
HandGroup: typeof import('./src/components/Original/Copywriter/HandGroup.vue')['default'] HandGroup: typeof import('./src/components/Original/Copywriter/HandGroup.vue')['default']
ImageCompressHome: typeof import('./src/components/ToolBox/ImageCompress/ImageCompressHome.vue')['default']
ImageDisplay: typeof import('./src/components/ToolBox/ImageUpload/ImageDisplay.vue')['default']
ImageUploader: typeof import('./src/components/ToolBox/ImageUpload/ImageUploader.vue')['default']
ImageUploadHome: typeof import('./src/components/ToolBox/ImageUpload/ImageUploadHome.vue')['default']
InputDialogContent: typeof import('./src/components/common/InputDialogContent.vue')['default'] InputDialogContent: typeof import('./src/components/common/InputDialogContent.vue')['default']
JianyingGenerateInformation: typeof import('./src/components/Original/BookTaskDetail/JianyingGenerateInformation.vue')['default'] JianyingGenerateInformation: typeof import('./src/components/Original/BookTaskDetail/JianyingGenerateInformation.vue')['default']
JianyingKeyFrameSetting: typeof import('./src/components/Setting/JianyingKeyFrameSetting.vue')['default'] JianyingKeyFrameSetting: typeof import('./src/components/Setting/JianyingKeyFrameSetting.vue')['default']
LoadingComponent: typeof import('./src/components/common/LoadingComponent.vue')['default']
ManageAISetting: typeof import('./src/components/CopyWriting/ManageAISetting.vue')['default']
MenuOpenRound: typeof import('./src/components/common/Icon/MenuOpenRound.vue')['default'] MenuOpenRound: typeof import('./src/components/common/Icon/MenuOpenRound.vue')['default']
MessageAndProgress: typeof import('./src/components/Original/BookTaskDetail/MessageAndProgress.vue')['default'] MessageAndProgress: typeof import('./src/components/Original/BookTaskDetail/MessageAndProgress.vue')['default']
MJSettings: typeof import('./src/components/Setting/MJSettings.vue')['default'] MJAccountDialog: typeof import('./src/components/Setting/MJSetting/MJAccountDialog.vue')['default']
MobileHeader: typeof import('./src/components/Original/MainHome/OriginalMobileHeader.vue')['default'] MJApiSettings: typeof import('./src/components/Setting/MJSetting/MJApiSettings.vue')['default']
MJLocalSetting: typeof import('./src/components/Setting/MJSetting/MJLocalSetting.vue')['default']
MJPackageSetting: typeof import('./src/components/Setting/MJSetting/MJPackageSetting.vue')['default']
MJRemoteSetting: typeof import('./src/components/Setting/MJSetting/MJRemoteSetting.vue')['default']
MJSettings: typeof import('./src/components/Setting/MJSetting/MJSettings.vue')['default']
NAlert: typeof import('naive-ui')['NAlert'] NAlert: typeof import('naive-ui')['NAlert']
NAletr: typeof import('naive-ui')['NAletr']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard'] NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox'] NCheckbox: typeof import('naive-ui')['NCheckbox']
@ -62,20 +73,16 @@ declare module 'vue' {
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem'] NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
NDialogProvider: typeof import('naive-ui')['NDialogProvider'] NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider'] NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown'] NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicTags: typeof import('naive-ui')['NDynamicTags'] NDynamicTags: typeof import('naive-ui')['NDynamicTags']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex'] NFlex: typeof import('naive-ui')['NFlex']
NForm: typeof import('naive-ui')['NForm'] NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem'] NFormItem: typeof import('naive-ui')['NFormItem']
NGradientText: typeof import('naive-ui')['NGradientText'] NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid'] NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem'] NGridItem: typeof import('naive-ui')['NGridItem']
NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
NInp: typeof import('naive-ui')['NInp'] NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput'] NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup'] NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel'] NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
@ -95,8 +102,6 @@ declare module 'vue' {
NSlider: typeof import('naive-ui')['NSlider'] NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace'] NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin'] NSpin: typeof import('naive-ui')['NSpin']
NStep: typeof import('naive-ui')['NStep']
NSteps: typeof import('naive-ui')['NSteps']
NSwitch: typeof import('naive-ui')['NSwitch'] NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText'] NText: typeof import('naive-ui')['NText']
@ -117,21 +122,18 @@ declare module 'vue' {
OriginalViewBookTaskInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookTaskInfo.vue')['default'] OriginalViewBookTaskInfo: typeof import('./src/components/Original/MainHome/OriginalViewBookTaskInfo.vue')['default']
PresetShowCard: typeof import('./src/components/Preset/PresetShowCard.vue')['default'] PresetShowCard: typeof import('./src/components/Preset/PresetShowCard.vue')['default']
ProjectItem: typeof import('./src/components/Original/MainHome/ProjectItem.vue')['default'] ProjectItem: typeof import('./src/components/Original/MainHome/ProjectItem.vue')['default']
ProjectSidebar: typeof import('./src/components/Original/MainHome/OriginalProjectSidebar.vue')['default']
QuickGroup: typeof import('./src/components/Original/Copywriter/QuickGroup.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SceneAnalysis: typeof import('./src/components/Original/Analysis/SceneAnalysis.vue')['default'] SceneAnalysis: typeof import('./src/components/Original/Analysis/SceneAnalysis.vue')['default']
ScenePreset: typeof import('./src/components/Preset/ScenePreset.vue')['default'] ScenePreset: typeof import('./src/components/Preset/ScenePreset.vue')['default']
SDSetting: typeof import('./src/components/Setting/SDSetting.vue')['default'] SDSetting: typeof import('./src/components/Setting/SDSetting.vue')['default']
SearchBook: typeof import('./src/components/Original/MainHome/OriginalSearchBook.vue')['default']
SearchPresetArea: typeof import('./src/components/Preset/SearchPresetArea.vue')['default'] SearchPresetArea: typeof import('./src/components/Preset/SearchPresetArea.vue')['default']
SelectRegionImage: typeof import('./src/components/Original/Image/SelectRegionImage.vue')['default'] SelectRegionImage: typeof import('./src/components/Original/Image/SelectRegionImage.vue')['default']
SelectStylePreset: typeof import('./src/components/Preset/SelectStylePreset.vue')['default'] SelectStylePreset: typeof import('./src/components/Preset/SelectStylePreset.vue')['default']
StylePreset: typeof import('./src/components/Preset/StylePreset.vue')['default'] StylePreset: typeof import('./src/components/Preset/StylePreset.vue')['default']
TaskCard: typeof import('./src/components/Original/MainHome/OriginalTaskCard.vue')['default']
TaskList: typeof import('./src/components/Original/MainHome/OriginalTaskList.vue')['default']
TextEllipsis: typeof import('./src/components/common/TextEllipsis.vue')['default'] TextEllipsis: typeof import('./src/components/common/TextEllipsis.vue')['default']
ToolBoxHome: typeof import('./src/components/ToolBox/ToolBoxHome.vue')['default']
ToolGrid: typeof import('./src/components/ToolBox/ToolGrid.vue')['default']
TooltipButton: typeof import('./src/components/common/TooltipButton.vue')['default'] TooltipButton: typeof import('./src/components/common/TooltipButton.vue')['default']
TooltipDropdown: typeof import('./src/components/common/TooltipDropdown.vue')['default'] TooltipDropdown: typeof import('./src/components/common/TooltipDropdown.vue')['default']
TopMenuButtons: typeof import('./src/components/Original/BookTaskDetail/TopMenuButtons.vue')['default'] TopMenuButtons: typeof import('./src/components/Original/BookTaskDetail/TopMenuButtons.vue')['default']

View File

@ -14,6 +14,7 @@
/> />
<Authorization <Authorization
v-if="isAuthorization" v-if="isAuthorization"
:error-message="authErrorMessage"
@authorization-complete="onAuthorizationComplete" @authorization-complete="onAuthorizationComplete"
/> />
<!-- 路由视图 --> <!-- 路由视图 -->
@ -53,12 +54,15 @@ import LoadingScreen from '@renderer/views/LoadingScreen.vue'
import Authorization from '@renderer/views/Authorization.vue' import Authorization from '@renderer/views/Authorization.vue'
import { createActiveColor, createHoverColor } from '@/renderer/src/common/color' import { createActiveColor, createHoverColor } from '@/renderer/src/common/color'
import { define } from '@/define/define' import { define } from '@/define/define'
import { useAuthorization } from '@/renderer/src/hooks/useAuthorization'
const themeStore = useThemeStore() const themeStore = useThemeStore()
const softwareStore = useSoftwareStore() const softwareStore = useSoftwareStore()
const { validateAuthorization } = useAuthorization()
const isLoading = ref(true) const isLoading = ref(true)
const loadingRef = ref(null) const loadingRef = ref(null)
const isAuthorization = ref(false) const isAuthorization = ref(false)
const authErrorMessage = ref('')
// //
const themeOverrides = computed(() => ({ const themeOverrides = computed(() => ({
@ -114,9 +118,19 @@ const themeOverrides = computed(() => ({
} }
})) }))
const onLoadingComplete = (authorization) => { const onLoadingComplete = (authorization, errorMessage = null) => {
console.log(
'App.vue onLoadingComplete 被调用authorization:',
authorization,
'errorMessage:',
errorMessage
)
isLoading.value = false isLoading.value = false
if (!authorization) { if (!authorization) {
if (errorMessage) {
authErrorMessage.value = errorMessage
console.log('App.vue 设置错误信息:', authErrorMessage.value)
}
isAuthorization.value = true isAuthorization.value = true
// //
refreshPaused.value = true refreshPaused.value = true
@ -129,6 +143,7 @@ const onLoadingComplete = (authorization) => {
} }
const onAuthorizationComplete = async () => { const onAuthorizationComplete = async () => {
isAuthorization.value = false isAuthorization.value = false
authErrorMessage.value = '' //
isLoading.value = true isLoading.value = true
await nextTick() await nextTick()
console.log(loadingRef.value) console.log(loadingRef.value)
@ -175,52 +190,16 @@ function scheduleNextRefresh(force = false) {
return return
} }
// const isAuthorized = await validateAuthorization(onLoadingComplete)
let res = await window.axios.get( if (isAuthorized) {
define.lms_url + //
`/lms/Other/VerifyMachineAuthorization/0/${softwareStore.authorization.authorizationCode}/${softwareStore.authorization.machineId}` scheduleNextRefresh()
)
if (!res.success) {
throw new Error(res.message)
} }
console.log('授权码校验结果:', res)
if (res.data == null) {
setTimeout(() => {
onLoadingComplete(false)
}, 1000)
throw new Error('授权码校验错误,即将前往授权界面进行授权!')
}
if (res.data.code != 1) {
//
setTimeout(() => {
onLoadingComplete(false)
}, 1000)
throw new Error(res.data.message + ',即将前往授权界面进行授权!')
}
softwareStore.authorization.authorizationMessage = res.data.data
if (softwareStore.authorization.authorizationMessage.useType == 1) {
softwareStore.authorization.isPro = true
} else {
softwareStore.authorization.isPro = false
}
//
window.system.SyncAuthorization(res.data.data)
//
scheduleNextRefresh()
} catch (error) { } catch (error) {
console.error('授权检查失败,暂停自动刷新:', error) console.error('授权检查失败,暂停自动刷新:', error)
// //
refreshPaused.value = true refreshPaused.value = true
onLoadingComplete(false) // onLoadingComplete(false) validateAuthorization
// scheduleNextRefresh() // scheduleNextRefresh()
} }
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

View File

@ -5,6 +5,7 @@ import { JianyingKeyFrameEnum } from '@/define/enum/jianyingEnum'
import { OptionKeyName, OptionType } from '@/define/enum/option' import { OptionKeyName, OptionType } from '@/define/enum/option'
import { SettingModal } from '@/define/model/setting' import { SettingModal } from '@/define/model/setting'
import { ValidateJson, ValidateJsonAndParse } from '@/define/Tools/validate' import { ValidateJson, ValidateJsonAndParse } from '@/define/Tools/validate'
import { optionSerialization } from '@/main/service/option/optionSerialization'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
//#region 初始化通用设置 //#region 初始化通用设置
@ -95,11 +96,35 @@ export const mjApiSettings: SettingModal.MJApiSettings = {
apiSpeed: getMJSpeedOptions()[1].value apiSpeed: getMJSpeedOptions()[1].value
} }
export const mjPackageSetting: SettingModal.MJPackageSetting = {
/** 选择的生图包类型 */
selectPackage: '',
/** 生图包访问令牌 */
packageToken: ''
}
export const mjRemoteSetting: SettingModal.MJRemoteSetting = {
/** 是否国内转发 */
isForward: false,
/** 账号列表 */
accountList: []
}
export const mjLocalSetting: SettingModal.MJLocalSetting = {
/** 服务地址 */
requestUrl: 'http://127.0.0.1:8080',
/** 访问令牌 */
token: 'admin123',
/** 账号列表 */
accountList: []
}
/** /**
* MJ相关设置 * MJ相关设置
*/ */
export async function InitMJSetting() { export async function InitMJSetting() {
try { try {
// 初始化基础设置
let generalSettingOption = await window.option.GetOptionByKey( let generalSettingOption = await window.option.GetOptionByKey(
OptionKeyName.Midjourney.GeneralSetting OptionKeyName.Midjourney.GeneralSetting
) )
@ -159,6 +184,95 @@ export async function InitMJSetting() {
if (res.code != 1) { if (res.code != 1) {
throw new Error('初始化MJ API设置失败') throw new Error('初始化MJ API设置失败')
} }
// 初始化生图包设置
let packageSettingOption = await window.option.GetOptionByKey(
OptionKeyName.Midjourney.PackageSetting
)
let newPackageSetting = Object.assign({}, mjPackageSetting)
// 判断是不是有数据
if (
!(
packageSettingOption == null ||
packageSettingOption.data == null ||
packageSettingOption.data.value == null ||
isEmpty(packageSettingOption.data.value) ||
!ValidateJson(packageSettingOption.data.value)
)
) {
// 不需要初始化,检查各项设置是否存在
let mjPackageSetting = ValidateJsonAndParse<SettingModal.MJPackageSetting>(
packageSettingOption.data.value
)
newPackageSetting = Object.assign({}, newPackageSetting, mjPackageSetting)
}
// 直接覆盖旧的值
res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.PackageSetting,
JSON.stringify(newPackageSetting),
OptionType.JSON
)
if (res.code != 1) {
throw new Error('初始化MJ生图包设置失败')
}
// 初始化 代理模式设置
let remoteSettingOption = await window.option.GetOptionByKey(OptionKeyName.Midjourney.RemoteSetting)
let newRemoteSetting = Object.assign({}, mjRemoteSetting)
// 判断是不是有数据
if (
!(
remoteSettingOption == null ||
remoteSettingOption.data == null ||
remoteSettingOption.data.value == null ||
isEmpty(remoteSettingOption.data.value) ||
!ValidateJson(remoteSettingOption.data.value)
)
) {
// 不需要初始化,检查各项设置是否存在
let mjRemoteSetting = ValidateJsonAndParse<SettingModal.MJRemoteSetting>(
remoteSettingOption.data.value
)
newRemoteSetting = Object.assign({}, newRemoteSetting, mjRemoteSetting)
}
// 直接覆盖旧的值
res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.RemoteSetting,
JSON.stringify(newRemoteSetting),
OptionType.JSON
)
if (res.code != 1) {
throw new Error('初始化MJ代理模式设置失败')
}
// 初始化 本地代理模式设置
let localSettingOption = await window.option.GetOptionByKey(OptionKeyName.Midjourney.LocalSetting)
let newLocalSetting = Object.assign({}, mjLocalSetting)
// 判断是不是有数据
if (
!(
localSettingOption == null ||
localSettingOption.data == null ||
localSettingOption.data.value == null ||
isEmpty(localSettingOption.data.value) ||
!ValidateJson(localSettingOption.data.value)
)
) {
// 不需要初始化,检查各项设置是否存在
let mjLocalSetting = ValidateJsonAndParse<SettingModal.MJLocalSetting>(
localSettingOption.data.value
)
newLocalSetting = Object.assign({}, newLocalSetting, mjLocalSetting)
}
// 直接覆盖旧的值
res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.LocalSetting,
JSON.stringify(newLocalSetting),
OptionType.JSON
)
if (res.code != 1) {
throw new Error('初始化MJ本地代理模式设置失败')
}
} catch (error) { } catch (error) {
throw error throw error
} }
@ -310,6 +424,8 @@ export async function InitSDSettingAndADetailerSetting() {
} }
} }
//#region 初始化剪映关键帧设置
//#endregion //#endregion
/** /**
* *
@ -377,6 +493,102 @@ export async function InitJianyingKeyFrameSetting() {
} }
} }
//#region 初始化剪映关键帧设置 //#endregion
//#region 初始化软件Comfy UI设置
let defaultComfyuiSimpleSetting: SettingModal.ComfyUISimpleSettingModel = {
requestUrl: 'http://127.0.0.1:8188/',
selectedWorkflow: undefined,
negativePrompt: undefined
}
let defaultComfyuiWorkFlowSetting: Array<SettingModal.ComfyUIWorkFlowSettingModel> = []
/**
*
* @description
*/
export async function InitComfyUISetting() {
try {
// 初始化 Comfy UI基础设置
let comfyuiSimpleSetting = await window.option.GetOptionByKey(
OptionKeyName.SD.ComfyUISimpleSetting
)
let newComfyuiSimpleSetting = Object.assign({}, defaultComfyuiSimpleSetting)
if (
!(
comfyuiSimpleSetting == null ||
comfyuiSimpleSetting.data == null ||
comfyuiSimpleSetting.data.value == null ||
isEmpty(comfyuiSimpleSetting.data.value) ||
!ValidateJson(comfyuiSimpleSetting.data.value)
)
) {
let oldComfyuiSimpleSetting = optionSerialization<SettingModal.ComfyUISimpleSettingModel>(
comfyuiSimpleSetting.data
)
newComfyuiSimpleSetting = Object.assign({}, newComfyuiSimpleSetting, oldComfyuiSimpleSetting)
}
// 直接覆盖旧的值
let res = await window.option.ModifyOptionByKey(
OptionKeyName.SD.ComfyUISimpleSetting,
JSON.stringify(newComfyuiSimpleSetting),
OptionType.JSON
)
if (res.code != 1) {
throw new Error('初始化Comfy UI设置失败')
}
let comfyuiWorkFlowSetting = await window.option.GetOptionByKey(
OptionKeyName.SD.ComfyUIWorkFlowSetting
)
if (
comfyuiWorkFlowSetting == null ||
comfyuiWorkFlowSetting.data == null ||
comfyuiWorkFlowSetting.data.value == null ||
isEmpty(comfyuiWorkFlowSetting.data.value) ||
!ValidateJson(comfyuiWorkFlowSetting.data.value)
) {
res = await window.option.ModifyOptionByKey(
OptionKeyName.SD.ComfyUIWorkFlowSetting,
JSON.stringify(defaultComfyuiWorkFlowSetting),
OptionType.JSON
)
}
if (res.code != 1) {
throw new Error('初始化Comfy UI设置失败')
}
} catch (error) {
throw error
}
}
//#endregion
//#region 初始化特殊符号字符串
export async function InitSpecialCharacters() {
try {
let specialCharacters = `。,“”‘’!?【】「」《》()…—;,''""!?[]<>()-:;╰*°▽°*╯′,ノ﹏<o‵゚Д゚,ノ,へ ̄╬▔`
let res = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.CW_FormatSpecialChar)
if (res.code == 1 && res.data != null && res.data.value != null) {
// 如果数据存在且不为空,则不需要初始化
return
}
// 需要初始化
let saveRes = await window.option.ModifyOptionByKey(
OptionKeyName.InferenceAI.CW_FormatSpecialChar,
specialCharacters,
OptionType.STRING
)
if (saveRes.code != 1) {
throw new Error('初始化特殊符号字符串失败: ' + saveRes.message)
}
} catch (error) {
throw new Error('初始化特殊符号字符串失败: ' + error)
}
}
//#endregion //#endregion

View File

@ -0,0 +1,252 @@
import { CloudUploadOutline } from '@vicons/ionicons5'
// 工具分类
export const categories = [
{ key: 'media', label: '媒体工具', color: '#2080f0' }
// { key: 'document', label: '文档处理', color: '#18a058' },
// { key: 'development', label: '开发工具', color: '#f0a020' },
// { key: 'design', label: '设计工具', color: '#d03050' },
// { key: 'utility', label: '实用工具', color: '#7c3aed' },
// { key: 'network', label: '网络工具', color: '#0ea5e9' },
// { key: 'security', label: '安全工具', color: '#dc2626' },
// { key: 'system', label: '系统工具', color: '#059669' }
]
// 工具数据
export const toolsData = [
// 媒体工具
{
id: 'image-converter',
name: 'LaiTool 图床',
description: '将图片上传到 LaiTool 图床,支持多种图片格式,获得可分享的链接',
category: 'media',
icon: CloudUploadOutline,
color: '#2080f0',
tags: ['图片', '转换', '格式'],
quickAccess: true,
action: {
type: 'route',
route: '/toolbox/image-upload',
routeName: "image-upload",
component: () => import('@/renderer/src/components/ToolBox/ImageUpload/ImageUploadHome.vue')
}
},
{
id: 'image-compress',
name: '图片压缩助手',
description: '将图片进行压缩,支持多种图片格式,减小文件大小',
category: 'media',
icon: CloudUploadOutline,
color: '#2080f0',
tags: ['图片', '压缩', '格式'],
quickAccess: true,
action: {
type: 'route',
route: '/toolbox/image-compress',
routeName: "image-compress",
component: () => import('@/renderer/src/components/ToolBox/ImageCompress/ImageCompressHome.vue')
}
}
// {
// id: 'image-converter',
// name: '图片格式转换',
// description: '支持多种图片格式之间的转换包括JPG、PNG、WebP、SVG等',
// category: 'media',
// icon: ImageOutline,
// color: '#2080f0',
// tags: ['图片', '转换', '格式'],
// quickAccess: true,
// action: {
// type: 'route',
// route: '/toolbox/image-converter'
// }
// },
// {
// id: 'image-compressor',
// name: '图片压缩',
// description: '无损或有损压缩图片文件,减小文件大小',
// category: 'media',
// icon: ImageOutline,
// color: '#18a058',
// tags: ['图片', '压缩', '优化'],
// action: {
// type: 'route',
// route: '/toolbox/image-compressor'
// }
// },
// {
// id: 'video-converter',
// name: '视频格式转换',
// description: '转换视频文件格式支持MP4、AVI、MOV等主流格式',
// category: 'media',
// icon: VideocamOutline,
// color: '#f0a020',
// tags: ['视频', '转换', '格式'],
// action: {
// type: 'route',
// route: '/toolbox/video-converter'
// }
// },
// {
// id: 'audio-converter',
// name: '音频格式转换',
// description: '转换音频文件格式支持MP3、WAV、FLAC等格式',
// category: 'media',
// icon: MusicalNotesOutline,
// color: '#d03050',
// tags: ['音频', '转换', '格式'],
// action: {
// type: 'route',
// route: '/toolbox/audio-converter'
// }
// },
// // 文档工具
// {
// id: 'pdf-merger',
// name: 'PDF合并',
// description: '将多个PDF文件合并为一个文件',
// category: 'document',
// icon: DocumentTextOutline,
// color: '#18a058',
// tags: ['PDF', '合并', '文档'],
// quickAccess: true,
// action: {
// type: 'route',
// route: '/toolbox/pdf-merger'
// }
// },
// {
// id: 'pdf-splitter',
// name: 'PDF分割',
// description: '将PDF文件按页数或书签分割成多个文件',
// category: 'document',
// icon: DocumentTextOutline,
// color: '#7c3aed',
// tags: ['PDF', '分割', '文档'],
// action: {
// type: 'route',
// route: '/toolbox/pdf-splitter'
// }
// },
// // 开发工具
// {
// id: 'json-formatter',
// name: 'JSON格式化',
// description: '格式化、验证和美化JSON数据',
// category: 'development',
// icon: CodeSlashOutline,
// color: '#f0a020',
// tags: ['JSON', '格式化', '开发'],
// quickAccess: true,
// action: {
// type: 'route',
// route: '/toolbox/json-formatter'
// }
// },
// {
// id: 'base64-encoder',
// name: 'Base64编解码',
// description: '对文本或文件进行Base64编码和解码',
// category: 'development',
// icon: CodeSlashOutline,
// color: '#2080f0',
// tags: ['Base64', '编码', '解码'],
// action: {
// type: 'route',
// route: '/toolbox/base64-encoder'
// }
// },
// // 设计工具
// {
// id: 'color-picker',
// name: '颜色选择器',
// description: '选择颜色并获取各种格式的颜色值',
// category: 'design',
// icon: ColorPaletteOutline,
// color: '#d03050',
// tags: ['颜色', '设计', '取色'],
// action: {
// type: 'route',
// route: '/toolbox/color-picker'
// }
// },
// // 实用工具
// {
// id: 'calculator',
// name: '计算器',
// description: '多功能计算器,支持基础运算和科学计算',
// category: 'utility',
// icon: CalculatorOutline,
// color: '#7c3aed',
// tags: ['计算', '数学', '实用'],
// action: {
// type: 'function',
// handler: () => {
// alert('打开计算器')
// }
// }
// },
// {
// id: 'timestamp-converter',
// name: '时间戳转换',
// description: '时间戳与日期时间之间的相互转换',
// category: 'utility',
// icon: TimeOutline,
// color: '#0ea5e9',
// tags: ['时间', '转换', '时间戳'],
// action: {
// type: 'route',
// route: '/toolbox/timestamp-converter'
// }
// },
// // 网络工具
// {
// id: 'qr-generator',
// name: '二维码生成器',
// description: '生成各种类型的二维码',
// category: 'network',
// icon: GlobeOutline,
// color: '#0ea5e9',
// tags: ['二维码', '生成', '网络'],
// action: {
// type: 'route',
// route: '/toolbox/qr-generator'
// }
// },
// // 安全工具
// {
// id: 'password-generator',
// name: '密码生成器',
// description: '生成安全性高的随机密码',
// category: 'security',
// icon: LockClosedOutline,
// color: '#dc2626',
// tags: ['密码', '生成', '安全'],
// quickAccess: true,
// action: {
// type: 'route',
// route: '/toolbox/password-generator'
// }
// },
// // 系统工具
// {
// id: 'file-hasher',
// name: '文件哈希计算',
// description: '计算文件的MD5、SHA1、SHA256等哈希值',
// category: 'system',
// icon: ArchiveOutline,
// color: '#059669',
// tags: ['哈希', '文件', '校验'],
// action: {
// type: 'route',
// route: '/toolbox/file-hasher'
// }
// }
]

View File

@ -0,0 +1,175 @@
<template>
<div class="cw-input-word">
<div class="formatting-word">
<n-button color="#b6a014" size="small" @click="formatWrite" style="margin-right: 5px"
>一键格式化</n-button
>
<n-popover trigger="hover">
<template #trigger>
<n-button quaternary circle size="tiny" color="#b6a014" @click="AddSplitChar">
<template #icon>
<n-icon size="25"> <AddCircleOutline /> </n-icon>
</template>
</n-button>
</template>
<span>添加分割标识符</span>
</n-popover>
</div>
<n-input
type="textarea"
:autosize="{
minRows: 20,
maxRows: 20
}"
v-model:value="word"
showCount
placeholder="请输入文案"
style="width: 100%; margin-top: 10px"
/>
<!-- 操作按钮 -->
<div class="action-buttons" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 10px;">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleImport">导入</n-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, h } from 'vue'
import { NInput, NIcon, NButton, NPopover, useDialog, useMessage } from 'naive-ui'
import { AddCircleOutline } from '@vicons/ionicons5'
// import InitCommon from '../../common/initCommon'
import InputDialogContent from '@/renderer/src/components/common/InputDialogContent.vue'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { splitTextByCustomDelimiters } from '@/define/Tools/write'
// import TextCommon from '../../common/text'
let InitCommon = {}
let TextCommon = {}
// emits
const emit = defineEmits(['cancel', 'import'])
// props
const props = defineProps({
simpleSetting: {
type: Object,
default: () => ({})
}
})
// 使 props
const simpleSetting = ref(props.simpleSetting)
const formatSpecialChar = ref('')
let dialog = useDialog()
let message = useMessage()
let word = ref('')
let split_ref = ref(null)
onMounted(async () => {
let res = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.CW_FormatSpecialChar)
console.log('获取特殊字符:', res)
if (res.code != 1) {
message.error('获取特殊字符失败:' + res.message)
} else {
formatSpecialChar.value = res.data.value
}
console.log('formatSpecialChar:', formatSpecialChar.value)
await InitWord()
})
/**
* 整合文案数据
*/
async function InitWord() {
let wordStruct = simpleSetting.value.wordStruct
if (!wordStruct || wordStruct.length <= 0) {
return
}
let wordArr = []
for (let i = 0; i < wordStruct.length; i++) {
wordArr.push(wordStruct[i].oldWord)
}
word.value = wordArr.join('\n')
}
/**
* 格式化文案
*/
async function formatWrite() {
try {
let newText = splitTextByCustomDelimiters(word.value, formatSpecialChar.value)
word.value = newText
message.success('格式化成功')
} catch (error) {
message.error('格式化失败,失败原因:' + error.message)
}
}
/**
* 添加分割符号
*/
async function AddSplitChar() {
//
//
let dialogWidth = 400
let dialogHeight = 150
dialog.create({
title: '添加分割符',
showIcon: false,
closeOnEsc: false,
content: () =>
h(InputDialogContent, {
data: formatSpecialChar.value,
placeholder: '请输入分割符',
onButtonClick: async (value) => {
message.info('分割符已更新' + value)
formatSpecialChar.value = value
let saveRes = await window.option.ModifyOptionByKey(
OptionKeyName.InferenceAI.CW_FormatSpecialChar,
formatSpecialChar.value,
OptionType.STRING
)
if (saveRes.code != 1) {
// window.api.showGlobalMessageDialog(saveRes)
//
return false
} else {
message.success('数据保存成功')
return true
}
}
}),
style: `width : ${dialogWidth}px; min-height : ${dialogHeight}px`,
maskClosable: false
})
}
/**
* 处理取消操作
*/
function handleCancel() {
emit('cancel')
}
/**
* 处理导入操作
*/
function handleImport() {
if (!word.value.trim()) {
message.warning('请输入文案内容')
return
}
emit('import', word.value)
}
defineExpose({
word
})
</script>

View File

@ -0,0 +1,309 @@
<template>
<div class="category-menu" style="width: 300px">
<div style="padding: 16px">
<!-- 筛选器 -->
<div style="margin-bottom: 16px; display: flex; align-items: center; gap: 8px">
<n-select
v-model:value="selectedMainCategory"
:options="mainCategoryOptions"
placeholder="选择大分类"
clearable
@update:value="handleMainCategoryChange"
style="flex: 1"
/>
<TooltipButton
tooltip="设置推理的API和相关设置"
@click="handleSettingClick"
size="medium"
text
:style="{
padding: '0 6px'
}"
>
<template #icon>
<n-icon>
<SettingsOutline />
</n-icon>
</template>
</TooltipButton>
</div>
<!-- 折叠面板 -->
<div class="collapse-wrapper">
<n-collapse v-model:expanded-names="expandedNames" accordion>
<n-collapse-item
v-for="category in filteredCategories"
:key="category.id"
:title="category.name"
:name="category.id"
>
<div class="category-content">
<n-card
v-for="subCategory in category.children"
:key="subCategory.id"
size="small"
:class="{ 'selected-card': selectedSubCategory?.id === subCategory.id }"
hoverable
@click="handleSubCategorySelect(subCategory)"
style="margin-bottom: 8px; cursor: pointer"
>
<template #header>
<div style="font-size: 14px; font-weight: 500">{{ subCategory.name }}</div>
</template>
<div style="font-size: 12px; color: #909399; line-height: 1.4">
{{ subCategory.remark }}
</div>
</n-card>
</div>
</n-collapse-item>
</n-collapse>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import {
useMessage,
useDialog,
NSelect,
NCollapse,
NCollapseItem,
NCard,
NTag,
NIcon
} from 'naive-ui'
import { useThemeStore } from '@/renderer/src/stores'
import { isEmpty } from 'lodash'
import TooltipButton from '@/renderer/src/components/common/TooltipButton.vue'
import { SettingsOutline } from '@vicons/ionicons5'
import ManageAISetting from './ManageAISetting.vue'
const message = useMessage()
const dialog = useDialog()
const themeStore = useThemeStore()
const props = defineProps({
promptCategory: {
type: Array,
required: true
},
aiSetting: {
type: Object,
required: true
},
simpleSetting: {
type: Object,
required: true
},
selectSubCategory: {
type: String,
default: ''
},
selectMainCategory: {
type: String,
default: ''
}
})
// emits
const emit = defineEmits(['category-select', 'update-simple-settings'])
//
const selectedMainCategory = ref(null) // select
const selectedSubCategory = ref(null)
const expandedNames = ref(['1']) //
const categories = ref(props.promptCategory || [])
// props.promptCategory
watch(
[() => props.promptCategory, () => props.selectMainCategory, () => props.selectSubCategory],
([newCategories, newMainCategory, newSubCategory]) => {
//
categories.value = newCategories || []
// selectprops
// selectedMainCategory.value
selectedSubCategory.value = null
expandedNames.value = newCategories && newCategories.length > 0 ? [newCategories[0].id] : ['1']
// propsselect
if (!isEmpty(newMainCategory) && categories.value.length > 0) {
const selectedCategory = categories.value.find((cat) => cat.id === newMainCategory)
if (selectedCategory) {
// select
expandedNames.value = [selectedCategory.id]
//
if (!isEmpty(newSubCategory) && selectedCategory.children) {
const foundSubCategory = selectedCategory.children.find(
(cat) => cat.id === newSubCategory
)
if (foundSubCategory) {
selectedSubCategory.value = foundSubCategory
} else {
console.log('Sub category not found:', newSubCategory, 'in', selectedCategory.children)
}
}
} else {
console.log('Main category not found:', newMainCategory, 'in', categories.value)
}
}
},
{ immediate: true, deep: true }
)
//
const mainCategoryOptions = computed(() => [
{ label: '全部', value: null },
...categories.value.map((cat) => ({ label: cat.name, value: cat.id }))
])
//
const filteredCategories = computed(() => {
if (!selectedMainCategory.value) {
return categories.value
}
return categories.value.filter((cat) => cat.id === selectedMainCategory.value)
})
//
function handleMainCategoryChange(value) {
selectedMainCategory.value = value
//
selectedSubCategory.value = null
//
if (value) {
expandedNames.value = [value]
} else {
expandedNames.value = categories.value.length > 0 ? [categories.value[0].id] : ['1']
}
}
//
function handleSubCategorySelect(subCategory) {
selectedSubCategory.value = subCategory
//
emit('category-select', {
...subCategory
})
}
//
function handleSettingClick() {
//
//
let dialogWidth = 800
dialog.create({
title: '文案处理设置',
showIcon: false,
closeOnEsc: false,
content: () =>
h(ManageAISetting, {
aiSetting: props.aiSetting,
simpleSetting: props.simpleSetting,
onUpdateSimpleSettings: (settings) => {
emit('update-simple-settings', settings)
}
}),
style: `width : ${dialogWidth}px`,
maskClosable: false
})
}
//
defineExpose({
selectedMainCategory,
selectedSubCategory
})
const selectCardColor = computed(() => {
return themeStore.menuPrimaryColor
})
const selectCardBackgroundColor = computed(() => {
return themeStore.menuPrimaryShadow
})
</script>
<style scoped>
.category-menu {
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
z-index: 10;
height: 100%;
overflow: hidden;
}
.collapse-wrapper {
max-height: calc(100vh - 80px); /* 减去顶部筛选器和padding的高度 */
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
.collapse-wrapper::-webkit-scrollbar {
width: 6px;
}
.collapse-wrapper::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.collapse-wrapper::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.collapse-wrapper::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.category-content {
/* max-height: 400px; */
overflow-y: auto;
padding-right: 4px;
padding-top: 10px;
}
.category-content::-webkit-scrollbar {
width: 4px;
}
.category-content::-webkit-scrollbar-track {
border-radius: 2px;
}
.category-content::-webkit-scrollbar-thumb {
border-radius: 2px;
}
.selected-card {
border-color: v-bind(selectCardColor) !important;
box-shadow: 0 2px 8px rgba(24, 160, 88, 0.2) !important;
background-color: v-bind(selectCardBackgroundColor) !important;
}
/* 折叠面板样式优化 */
:deep(.n-collapse-item__header) {
font-weight: 500;
font-size: 15px;
}
:deep(.n-collapse-item__content-wrapper) {
padding: 8px 0;
}
/* 卡片悬浮效果 */
.n-card:hover {
transform: translateY(-2px);
transition: all 0.3s ease;
}
.selected-card:hover {
transform: translateY(-1px);
}
</style>

View File

@ -0,0 +1,608 @@
<template>
<div class="copy-writing-content">
<n-data-table
:columns="columns"
:data="simpleSetting.wordStruct"
:bordered="false"
:max-height="maxHeight"
scroll-x="1050"
style="margin-bottom: 20px; width: 100%"
/>
</div>
</template>
<script setup>
import { ref, h, onMounted, computed, onUnmounted, nextTick } from 'vue'
import { NDataTable, NInput, NButton, NIcon, useMessage, useDialog, NSpace } from 'naive-ui'
import { TrashBinOutline, RefreshOutline } from '@vicons/ionicons5'
import { useSoftwareStore } from '@/renderer/src/stores'
import CWInputWord from './CWInputWord.vue'
import CopyWritingShowAIGenerate from './CopyWritingShowAIGenerate.vue'
import { isEmpty, split, words } from 'lodash'
import { TimeDelay } from '@/define/Tools/time'
import TooltipButton from '../common/TooltipButton.vue'
let softwareStore = useSoftwareStore()
// props
const props = defineProps({
simpleSetting: {
type: Object,
default: () => ({})
}
})
// 使 computed props
const simpleSetting = computed(() => props.simpleSetting)
// emit
const emit = defineEmits(['split-save', 'save-simple-setting'])
let CopyWriting = {}
let message = useMessage()
let dialog = useDialog()
let maxHeight = ref(0)
let resizeObserver = null
const columns = [
{
title: '序号',
key: 'index',
width: 80,
render: (_, index) => index + 1
},
{
title: (row, index) => {
return h(
'div',
{
style:
'display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap;'
},
[
h('div', {}, '处理前文本'),
h(
TooltipButton,
{
tooltip: '导入需要AI处理的文本新导入的数据会覆盖当前的旧数据',
size: 'tiny',
strong: true,
secondary: true,
type: 'primary',
onClick: ImportText
},
{ default: () => '导入文本' }
),
h(
TooltipButton,
{
tooltip: '将当前文本按照设定的单次最大次数进行分割,分割后会清空已生成的内容',
size: 'tiny',
strong: true,
secondary: true,
type: 'primary',
onClick: TextSplit
},
{ default: () => '文案分割' }
),
h(
TooltipButton,
{
tooltip:
'将所有分割出来的文案进行AI处理再处理之前会删除当前已有的处理后文本若是需要生成部分请使用单个的生成',
size: 'small',
strong: true,
secondary: true,
type: 'primary',
onClick: handleCopyWritingAIGeneration
},
{ default: () => '全部生成' }
)
]
)
},
key: 'oldWord',
width: 400,
render: (row) =>
h(NInput, {
type: 'textarea',
autosize: { minRows: 10, maxRows: 10 },
value: row.oldWord,
showCount: true,
onUpdateValue: (value) => (row.oldWord = value),
style: { minWidth: '200px' },
placeholder: '请输入文本'
})
},
{
title: (row, index) => {
return h(
'div',
{
style:
'display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap;'
},
[
h('div', {}, '处理后文本'),
h(
TooltipButton,
{
tooltip:
'将所有的处理后文本合并显示,在里面可以格式化和一键复制,但是在里面对于文本的操作不会被保存,修改后请及时复制',
size: 'tiny',
strong: true,
secondary: true,
type: 'primary',
onClick: ShowAIGenerateText
},
{ default: () => '显示生成文本' }
),
h(
TooltipButton,
{
tooltip: '将所有处理后文本合并之后,直接复制到剪贴板',
size: 'tiny',
strong: true,
secondary: true,
type: 'primary',
onClick: CopyGenerationText
},
{ default: () => '复制生成文本' }
),
h(
TooltipButton,
{
tooltip: '将所有生成后文本为空的数据进行AI处理有数据的则跳过',
size: 'tiny',
strong: true,
secondary: true,
type: 'primary',
onClick: async () => {
// AI
let ids = simpleSetting.value.wordStruct
.filter((item) => isEmpty(item.newWord))
.map((item) => item.id)
if (ids <= 0) {
message.error('生成失败:不存在未生成的文本')
return
}
handleGenerate(ids)
}
},
{ default: () => '生成空文本' }
),
h(
TooltipButton,
{
tooltip: '将所有的生成后文本清空',
size: 'tiny',
strong: true,
secondary: true,
type: 'error',
onClick: ClearAIGeneration
},
{ default: () => '清空' }
)
]
)
},
key: 'newWord',
width: 400,
render: (row) =>
h(NInput, {
type: 'textarea',
autosize: { minRows: 10, maxRows: 10 },
value: row.newWord,
showCount: true,
onUpdateValue: (value) => (row.newWord = value),
style: { minWidth: '200px' },
placeholder: 'AI 改写后的文件'
})
},
{
title: '操作',
key: 'actions',
width: 120,
render: (row) =>
h(NSpace, {}, () => [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => handleGenerate([row.id])
},
() => [h(NIcon, { size: 16, component: RefreshOutline }), ' 生成']
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => handleDelete(row.id)
},
() => [h(NIcon, { size: 16, component: TrashBinOutline }), ' 清空']
)
])
}
]
/**
* 计算表格的最大高度
* 基于视口高度动态计算减去页面其他元素占用的空间
*/
async function calcHeight() {
await nextTick() // DOM
const viewportHeight = window.innerHeight
//
//
const reservedSpace = 100
const calculatedHeight = Math.max(300, viewportHeight - reservedSpace) // 300px
maxHeight.value = calculatedHeight
console.log(`表格高度重新计算: 视口高度=${viewportHeight}px, 表格最大高度=${calculatedHeight}px`)
}
/**
* 窗口大小改变事件处理器
* 使用防抖来避免频繁计算
*/
let resizeTimeout = null
function handleResize() {
if (resizeTimeout) {
clearTimeout(resizeTimeout)
}
resizeTimeout = setTimeout(() => {
calcHeight()
}, 100) // 100ms
}
/**
* 初始化高度监听
*/
function initHeightObserver() {
//
calcHeight()
//
window.addEventListener('resize', handleResize)
// ResizeObserver
if (window.ResizeObserver) {
const parentElement = document.querySelector('.copy-writing-home') || document.body
resizeObserver = new ResizeObserver(() => {
calcHeight()
})
resizeObserver.observe(parentElement)
}
}
/**
* 清理监听器
*/
function cleanupObservers() {
// resize
window.removeEventListener('resize', handleResize)
// ResizeObserver
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
//
if (resizeTimeout) {
clearTimeout(resizeTimeout)
resizeTimeout = null
}
}
onMounted(() => {
initHeightObserver()
})
onUnmounted(() => {
cleanupObservers()
})
function ShowAIGenerateText() {
dialog.info({
title: 'AI生成文本',
content: () => h(CopyWritingShowAIGenerate, { simpleSetting: simpleSetting.value }),
style: 'width: 800px;height: 610px;',
showIcon: false,
maskClosable: false
})
}
/**
* 清空AI生成的文本
*/
function ClearAIGeneration() {
dialog.warning({
title: '清空提示',
content: '确定要清空所有的AI生成文本吗清空后不可恢复是否继续',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
simpleSetting.value.wordStruct.forEach((item) => {
item.newWord = ''
})
await CopyWriting.SaveCWAISimpleSetting()
message.success('清空成功')
} catch (error) {
message.error('清空失败:' + error.message)
}
},
onNegativeClick: () => {
message.info('取消清空')
}
})
}
/**
* 复制AI生成的文本 做个简单的拼接
*/
function CopyGenerationText() {
dialog.warning({
title: '温馨提示',
content:
'直接复制会将所有的AI生成后的数据直接进行复制不会进行格式之类的调整若有需求可以再下面表格直接修改或者是再左边的显示生成文本中修改是否继续复制',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
let wordStruct = simpleSetting.value.wordStruct
//
let isHaveEmpty = wordStruct.some((item) => {
return isEmpty(item.newWord)
})
if (isHaveEmpty) {
message.error('复制失败:存在未生成的文本,请先生成文本')
return false
}
//
let newWordAll = wordStruct.map((item) => {
return item.newWord
})
let newWordStr = newWordAll.join('\n')
//
let newWord = newWordStr.split('\n').filter((item) => {
return !isEmpty(item)
})
await navigator.clipboard.writeText(newWord.join('\n'))
message.success('复制成功')
} catch (error) {
message.error(
'复制失败,请在左边的显示生成文本中进行手动复制,失败信息如图:' + error.message
)
} finally {
softwareStore.spin.spinning = false
}
},
onNegativeClick: () => {
message.info('取消删除')
}
})
}
/**
* 执行生成AI后文本的方法
* @param rowIds 单个ID字符串或ID数组
*/
function handleGenerate(rowIds) {
//
const ids = Array.isArray(rowIds) ? rowIds : [rowIds]
//
const isMultiple = ids.length > 1
const title = isMultiple ? '批量生成确认' : '生成确认'
const content = isMultiple
? `确定要重新生成选中的 ${ids.length} 行AI生成文本吗重新生成后会清空之前的生成文本并且不可恢复是否继续`
: '确定重新生成当前行的AI生成文本吗重新生成后会清空之前的生成文本并且不可恢复是否继续'
const tip = isMultiple ? `批量生成中 (${ids.length} 条)......` : '生成中......'
let da = dialog.warning({
title: title,
content: content,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
softwareStore.spin.spinning = true
softwareStore.spin.tip = tip
// ID
let validIds = []
simpleSetting.value.wordStruct.forEach((item) => {
if (ids.includes(item.id)) {
validIds.push(item.id)
}
})
if (validIds.length === 0) {
message.error('生成失败:未找到对应的数据')
return
}
if (validIds.length !== ids.length) {
message.warning(
`警告:${ids.length - validIds.length} 个ID未找到对应数据将生成 ${validIds.length} 条记录`
)
}
da.destroy()
let res = await window.write.CopyWritingAIGeneration(validIds)
console.log(isMultiple ? '批量生成结果:' : '单行生成结果:', res)
if (res.code == 0) {
message.error(res.message)
return
}
//
emit('save-simple-setting', {
wordStruct: res.data
})
message.success(isMultiple ? `批量生成完成,共处理 ${validIds.length} 条记录` : '生成完成')
await TimeDelay(200)
} catch (error) {
message.error('生成失败:' + error.message)
} finally {
softwareStore.spin.spinning = false
}
},
onNegativeClick: () => {
message.info(isMultiple ? '取消批量生成' : '取消生成')
}
})
}
/**
* 删除当行的AI生成文本
* @param id
*/
const handleDelete = (id) => {
dialog.warning({
title: '提示',
content: '确定要删除当前行的AI生成文本吗数据删除后不可恢复是否继续',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
let index = simpleSetting.value.wordStruct.findIndex((item) => item.id == id)
if (index == -1) {
message.error('删除失败:未找到对应的数据')
return false
}
simpleSetting.value.wordStruct[index].newWord = ''
//
await CopyWriting.SaveCWAISimpleSetting()
message.success('删除成功')
},
onNegativeClick: () => {
message.info('取消删除')
}
})
}
/**
* 导入文本按钮的具体实现
*/
function ImportText() {
let da = dialog.info({
title: '导入文本',
content: () =>
h('div', {}, [
h(CWInputWord, {
simpleSetting: simpleSetting.value,
onCancel: () => {
message.info('取消导入操作')
da?.destroy()
},
onImport: async (value) => {
try {
let inputWord = value
if (isEmpty(inputWord)) {
message.error('导入失败:文本不能为空')
return false
}
//
if (simpleSetting.value.wordStruct && simpleSetting.value.wordStruct.length > 0) {
dialog.warning({
title: '提示',
content:
'当前已经存在数据,继续操作会删除之前的数据,包括生成之后的数据,若只是简单调整数据,可在外面显示的表格中进行直接修改,是否继续?',
positiveText: '导入',
negativeText: '取消',
onPositiveClick: async () => {
emit('split-save', simpleSetting.value.isSplit, inputWord)
}
})
return false
} else {
emit('split-save', simpleSetting.value.isSplit, inputWord)
}
} catch (err) {
message.error('导入失败:' + err.message)
}
}
})
]),
style: 'width: 800px;height: 610px;',
showIcon: false,
maskClosable: false
})
}
/**
* 文案分割按钮的具体实现
*/
function TextSplit() {
dialog.warning({
title: '提示',
content:
'确定要将当前文本按照设定的单次最大次数进行分割吗?分割后会清空已生成的内容,是否继续?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
emit('split-save', true, null)
} catch (err) {
message.error('分割失败:' + err.message)
}
},
onNegativeClick: () => {
message.info('取消分割')
}
})
}
async function handleCopyWritingAIGeneration() {
dialog.warning({
title: '批量生成确认',
content: '确定要将所有的文案进行AI生成吗在生成前会将生成后文本删除是否继续',
positiveText: '确定生成',
negativeText: '取消',
onPositiveClick: async () => {
try {
softwareStore.spin.spinning = true
softwareStore.spin.tip = '生成中......'
let ids = simpleSetting.value.wordStruct
.filter((item) => isEmpty(item.newWord))
.map((item) => item.id)
let res = await window.write.CopyWritingAIGeneration(ids)
console.log('批量生成结果:', res)
} catch (error) {
message.error('批量生成失败:' + error.message)
} finally {
softwareStore.spin.spinning = false
}
},
onNegativeClick: () => {
message.info('取消批量生成')
}
})
}
</script>
<style lang="css" scoped>
.copy-writing-content {
width: 100%;
overflow: visible; /* 让表格的滚动条在自己内部处理 */
}
/* 确保表格不会超出容器 */
:deep(.n-data-table) {
width: 100%;
}
</style>

View File

@ -0,0 +1,101 @@
<template>
<div class="copy-writing-show">
<div>
<div style="display: flex; align-items: center; flex-direction: row; gap: 10px">
<n-button type="info" size="small" @click="CopyNewData"> 复制 </n-button>
<n-button type="info" size="small" @click="FormatOutput"> 一键格式化 </n-button>
<span style="font-size: 16px; color: red">
注意这边的格式化不一定会完全格式化需要自己手动检查
</span>
</div>
<n-input
type="textarea"
:autosize="{
minRows: 18,
maxRows: 18
}"
style="margin-top: 10px"
v-model:value="word"
placeholder="请输入内容"
:rows="4"
/>
</div>
<div style="font-size: 20px; color: red">
注意当前弹窗的修改和格式化只在该界面有效关闭或重新打开会重新加载同步外部表格数据之前修改的数据会丢失
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { NButton, NInput, useMessage } from 'naive-ui'
import { isEmpty } from 'lodash'
// props
const props = defineProps({
simpleSetting: {
type: Object,
default: () => ({})
}
})
// 使 props
const simpleSetting = computed(() => props.simpleSetting)
// word
let word = ref('')
// simpleSettingword
onMounted(() => {
if (simpleSetting.value && simpleSetting.value.wordStruct) {
word.value = simpleSetting.value.wordStruct.map((item) => item.newWord).join('\n')
}
})
let message = useMessage()
/**
* 复制新数据
*/
async function CopyNewData() {
try {
let copyData = word.value
await navigator.clipboard.writeText(copyData)
message.success('复制成功')
} catch (error) {
message.error('复制失败,错误信息:' + error.message)
}
}
/**
* 格式化输出
*/
async function FormatOutput() {
let splitData = word.value.split('\n').filter((item) => {
return !isEmpty(item)
})
let isNumberedFormat = (str) => {
return /^\d+\./.test(str)
}
let isTextFormat = (str) => {
return /^【文本】/.test(str)
}
let type = undefined
splitData = splitData.map((item) => {
if (isNumberedFormat(item)) {
type = 'startNumber'
return item.replace(/^\d+\./, '')
} else if (isTextFormat(item)) {
type = 'startText'
return item.replace('&【', '\n【')
} else {
return item
}
})
if (type == 'startNumber') {
word.value = splitData.join('\n')
} else {
word.value = splitData.join('\n\n')
}
}
</script>

View File

@ -0,0 +1,270 @@
<template>
<n-space vertical size="large">
<n-card title="API 设置">
<div style="display: flex">
<n-input
v-model:value="aiSetting.gptUrl"
style="margin-right: 10px; flex: 1"
type="text"
placeholder="请输入GPT URL"
/>
<n-input
v-model:value="aiSetting.apiKey"
style="margin-right: 10px; flex: 1"
type="text"
placeholder="请输入API KEYsk-xxxxx"
/>
<n-input
v-model:value="aiSetting.model"
type="text"
placeholder="请输入调用分析的Model名"
style="flex: 1"
/>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 15px; gap: 10px">
<n-button type="primary" style="white-space: nowrap" @click="syncGeneralSettings">
同步通用设置Key
</n-button>
<n-button
type="primary"
style="white-space: nowrap"
:loading="loading"
@click="testConnection"
>
测试连接
</n-button>
</div>
</n-card>
<!-- 新增的AI生成设置 -->
<n-card title="AI生成设置">
<n-form inline label-placement="left">
<n-form-item path="isStream">
<n-checkbox label="是否流式发送" v-model:checked="simpleSetting.isStream" />
</n-form-item>
<n-form-item path="isSplit">
<n-checkbox label="是否拆分发送" v-model:checked="simpleSetting.isSplit" />
</n-form-item>
<n-form-item label="单次最大字符数" path="splitNumber">
<n-input-number
v-model:value="simpleSetting.splitNumber"
:min="1"
:show-button="false"
:max="99999"
></n-input-number>
</n-form-item>
</n-form>
<div style="color: red; font-size: 14px; margin-top: 10px">注意爆款开头不要拆分发送</div>
</n-card>
<div style="display: flex; justify-content: flex-end; margin: 20px">
<n-button type="primary" @click="SaveAISetting">保存</n-button>
</div>
</n-space>
</template>
<script setup>
import { ref } from 'vue'
import {
useMessage,
useDialog,
NCard,
NSpace,
NInput,
NButton,
NForm,
NFormItem,
NCheckbox,
NInputNumber
} from 'naive-ui'
import { isEmpty } from 'lodash'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { TimeDelay } from '@/define/Tools/time'
import { useSoftwareStore } from '@/renderer/src/stores'
import { useMD } from '../../hooks/useMD'
import { optionSerialization } from '@/main/service/option/optionSerialization'
import { GetOpenAISuccessResponse } from '@/define/response/openAIResponse'
const softwareStore = useSoftwareStore()
const { showErrorDialog, showSuccessDialog } = useMD()
//
const emit = defineEmits(['update-simple-settings'])
// props
const props = defineProps({
aiSetting: {
type: Object,
default: () => ({})
},
simpleSetting: {
type: Object,
default: () => ({})
}
})
// 使 props
const aiSetting = ref(props.aiSetting)
const simpleSetting = ref(props.simpleSetting)
let loading = ref(false)
let message = useMessage()
let dialog = useDialog()
//
const testConnection = async () => {
try {
loading.value = true
if (
isEmpty(aiSetting.value.apiKey) ||
isEmpty(aiSetting.value.gptUrl) ||
isEmpty(aiSetting.value.model)
) {
message.error('请先填写完整的API设置后再进行测试')
return
}
let data = JSON.stringify({
model: aiSetting.value.model,
messages: [
{
role: 'system',
content: '你好,测试链接!!'
}
]
})
let config = {
method: 'post',
maxBodyLength: Infinity,
headers: {
Authorization: `Bearer ${aiSetting.value.apiKey}`,
'Content-Type': 'application/json'
}
}
let url = aiSetting.value.gptUrl.endsWith('/')
? aiSetting.value.gptUrl + 'v1/chat/completions'
: aiSetting.value.gptUrl + '/v1/chat/completions'
let res = await window.axios.post(url, data, config)
if (res.status != 200) {
showErrorDialog('测试连接失败', '测试连接失败: ' + res.error)
message.error('测试连接失败: ' + res.error)
return
}
let content = GetOpenAISuccessResponse(res.data)
if (content == null) {
showErrorDialog('测试连接失败', '测试连接失败,返回结果异常,请检查设置')
return
}
showSuccessDialog('测试连接成功', '测试连接成功!请保存数据后使用!')
} catch (error) {
showErrorDialog('测试连接失败', `连接失败:${error.message || '未知错误'}`)
} finally {
loading.value = false
}
}
/**
* 同步通用设置中的数据信息
*/
async function syncGeneralSettings() {
// Create confirmation dialog before sync
const syncDialog = dialog.warning({
title: '同步确认',
content: '确认要同步 “设置 -> 推理设置” 中的 “API Key” 和 “推理模型” 吗?这将覆盖当前设置。',
positiveText: '确认',
negativeText: '取消',
onNegativeClick: () => {
message.info('已取消同步')
return true
},
onPositiveClick: async () => {
try {
syncDialog.destroy()
// Get global settings
let globalSettingRes = await window.option.GetOptionByKey(
OptionKeyName.InferenceAI.InferenceSetting
)
if (globalSettingRes.code != 1) {
throw new Error(globalSettingRes.message)
}
let globalSetting = optionSerialization(globalSettingRes.data, ' 设置 -> 推理设置')
if (!globalSetting) {
throw new Error('未找到全局通用设置,请检查通用设置!')
}
// Sync settings
aiSetting.value.apiKey = globalSetting.apiToken
aiSetting.value.model = globalSetting.inferenceModel
message.success('已同步通用设置,请测试成功后保存后使用!')
} catch (error) {
showErrorDialog('同步失败', '同步通用设置失败,错误信息:' + error.message)
}
}
})
}
async function SaveAISetting() {
let da = dialog.warning({
title: '提示',
content: '确认保存设置?这边不会检测数据的可用性,请确保数据填写正确!!!',
positiveText: '确认',
negativeText: '取消',
onNegativeClick: () => {
message.info('用户取消操作')
return true
},
onPositiveClick: async () => {
da.destroy()
// AI
if (
isEmpty(aiSetting.value.gptUrl) ||
isEmpty(aiSetting.value.apiKey) ||
isEmpty(aiSetting.value.model)
) {
message.error('请填写完整选择的AI相关的设置')
return
}
// AI
let aiRes = await window.option.ModifyOptionByKey(
OptionKeyName.InferenceAI.CW_AISimpleSetting,
JSON.stringify(aiSetting.value),
OptionType.JSON
)
if (aiRes.code != 1) {
showErrorDialog('保存API设置失败', '保存API设置失败错误信息' + aiRes.message)
return
}
//
let simpleRes = await window.option.ModifyOptionByKey(
OptionKeyName.InferenceAI.CW_SimpleSetting,
JSON.stringify(simpleSetting.value),
OptionType.JSON
)
if (simpleRes.code != 1) {
showErrorDialog('保存简单设置失败', '保存简单设置失败,错误信息:' + simpleRes.message)
return
}
message.success('保存设置成功')
emit('update-simple-settings', {
aiSetting: aiSetting.value,
simpleSetting: simpleSetting.value
})
}
})
}
</script>

View File

@ -276,7 +276,7 @@ async function handleAnalysisUser() {
spin.value = true spin.value = true
tip.value = '正在推理场景数据,请稍等...' tip.value = '正在推理场景数据,请稍等...'
let res = await window.book.AutoAnalyzeCharacterOrScene( let res = await window.book.AutoAnalyzeCharacterOrScene(
bookStore.selectBookTask.id, bookStore.selectBookTaskDetail[0].bookTaskId,
PresetCategory.Scene PresetCategory.Scene
) )
if (res.code !== 1) { if (res.code !== 1) {

View File

@ -276,7 +276,7 @@ async function handleAnalysisUser() {
spin.value = true spin.value = true
tip.value = '正在推理角色数据,请稍等...' tip.value = '正在推理角色数据,请稍等...'
let res = await window.book.AutoAnalyzeCharacterOrScene( let res = await window.book.AutoAnalyzeCharacterOrScene(
bookStore.selectBookTask.id, bookStore.selectBookTaskDetail[0].bookTaskId,
PresetCategory.Character PresetCategory.Character
) )
if (res.code !== 1) { if (res.code !== 1) {

View File

@ -46,6 +46,7 @@ import { OptionKeyName } from '@/define/enum/option'
import { optionSerialization } from '@/main/service/option/optionSerialization' import { optionSerialization } from '@/main/service/option/optionSerialization'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { ImageCategory } from '@/define/data/imageData' import { ImageCategory } from '@/define/data/imageData'
import { useMD } from '@/renderer/src/hooks/useMD'
const softwareStore = useSoftwareStore() const softwareStore = useSoftwareStore()
const bookStore = useBookStore() const bookStore = useBookStore()
@ -53,6 +54,8 @@ const bookStore = useBookStore()
const dialog = useDialog() const dialog = useDialog()
const message = useMessage() const message = useMessage()
const { showErrorDialog } = useMD()
const props = defineProps({ const props = defineProps({
initData: { initData: {
type: Object, type: Object,
@ -174,7 +177,6 @@ async function ImageHD() {
} }
} catch (error) { } catch (error) {
} finally { } finally {
//
softwareStore.spin.spinning = false softwareStore.spin.spinning = false
} }
} }
@ -210,49 +212,44 @@ async function DownloadMJAPIImage() {
positiveText: '继续', positiveText: '继续',
negativeText: '取消', negativeText: '取消',
onPositiveClick: async () => { onPositiveClick: async () => {
da?.destroy() try {
if (bookStore.selectBookTask.imageCategory != ImageCategory.Midjourney) { da?.destroy()
message.error('当前图片不是MJ图片不能下载')
return
}
if (!props.initData.mjMessage) {
message.error('没有MJ生图信息不能下载')
return
}
// success error
if (props.initData.mjMessage.status == 'error') {
message.error('失败状态不能采集图片')
return
}
if (isEmpty(props.initData.mjMessage.messageId)) {
message.error('没有消息ID不能采集图片')
return
}
// if (bookStore.selectBookTask.imageCategory != ImageCategory.Midjourney) {
softwareStore.spin.spinning = true message.error('当前图片不是MJ图片不能下载')
softwareStore.spin.tip = '正在下载图片,请稍后...' return
let res = await window.book.GetImageUrlAndDownload(
props.initData.id,
OperateBookType.BOOKTASKDETAIL,
false
)
softwareStore.spin.spinning = false
if (res.code == 1) {
//
for (let i = 0; i < res.data.length; i++) {
const element = res.data[i]
let findIndex = bookStore.selectBookTaskDetail.findIndex((item) => item.id == element.id)
if (findIndex != -1) {
bookStore.selectBookTaskDetail[findIndex].outImagePath =
element.data.outImagePath.split('?t=')[0] + `?t=${new Date().getTime()}`
bookStore.selectBookTaskDetail[findIndex].subImagePath = element.data.subImagePath
bookStore.selectBookTaskDetail[findIndex].mjMessage = element.data.mjMessage
}
} }
if (!props.initData.mjMessage) {
message.error('没有MJ生图信息不能下载')
return
}
if (isEmpty(props.initData.mjMessage.messageId)) {
message.error('没有消息ID不能采集图片')
return
}
//
softwareStore.spin.spinning = true
softwareStore.spin.tip = '正在下载图片,请稍后...'
let res = await window.book.GetImageUrlAndDownload(props.initData.id)
console.log('下载图片返回结果', res)
if (res.code != 1) {
throw new Error(res.message)
}
//
let findIndex = bookStore.selectBookTaskDetail.findIndex((item) => item.id == res.data.id)
if (findIndex != -1) {
bookStore.selectBookTaskDetail[findIndex] = { ...res.data }
}
message.success(res.message) message.success(res.message)
} else { } catch (error) {
message.error(res.message) showErrorDialog('下载图片失败', '下载图片失败,失败信息如下:' + error.message)
} finally {
softwareStore.spin.spinning = false
} }
} }
}) })

View File

@ -30,13 +30,16 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useMessage, useDialog, NIcon, NSelect, NButton, NDropdown } from 'naive-ui' import { useMessage, useDialog, NIcon, NSelect, NButton, NDropdown, NDataTable } from 'naive-ui'
import { ChevronDown, ChevronUp } from '@vicons/ionicons5' import { ChevronDown, ChevronUp } from '@vicons/ionicons5'
import { useSoftwareStore, useBookStore } from '@/renderer/src/stores' import { useSoftwareStore, useBookStore } from '@/renderer/src/stores'
import { getImageCategoryOptions, ImageCategory } from '@/define/data/imageData' import { getImageCategoryOptions, ImageCategory } from '@/define/data/imageData'
import { OperateBookType } from '@/define/enum/bookEnum' import { OperateBookType } from '@/define/enum/bookEnum'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { useMD } from '@/renderer/src/hooks/useMD'
const { showErrorDialog } = useMD()
let props = defineProps({ let props = defineProps({
style: undefined style: undefined
@ -60,7 +63,12 @@ let select = computed(() =>
// //
async function handleUpdateValue(value) { async function handleUpdateValue(value) {
// //
if (value != ImageCategory.Midjourney && value != ImageCategory.Stable_Diffusion) { if (
value != ImageCategory.Midjourney &&
value != ImageCategory.Stable_Diffusion &&
value != ImageCategory.Flux_Forge &&
value != ImageCategory.Comfy_UI
) {
message.error('暂不支持的出图方式') message.error('暂不支持的出图方式')
return return
} }
@ -161,12 +169,10 @@ async function dropdownSelectHandle(key) {
await handleImageLockOperation(key) await handleImageLockOperation(key)
break break
case 'downloadMJImage': case 'downloadMJImage':
message.error('MJ采集图片功能暂未开放') await DownloadAllImage()
// await DownloadAllImage()
break break
case 'oneToFour': case 'oneToFour':
message.error('一拆四功能暂未开放') await OneToFourBookTask()
// await OneToFourBookTask()
break break
default: default:
message.error('未知操作') message.error('未知操作')
@ -178,30 +184,65 @@ async function DownloadAllImage() {
let da = dialog.warning({ let da = dialog.warning({
title: '采集所有图片提示', title: '采集所有图片提示',
content: content:
'即将开始采集所有的MJ生图图片满足一下条件的分镜才会被采集状态不为 error有生图信息有消息ID没有生成图片图片的有效期为24小时是否继续', '即将开始采集所有的MJ生图图片满足一下条件的分镜才会被采集有生图信息有消息ID没有生成图片图片的有效期为24小时是否继续',
positiveText: '继续', positiveText: '继续',
negativeText: '取消', negativeText: '取消',
onPositiveClick: async () => { onPositiveClick: async () => {
da?.destroy() try {
let res = await window.book.GetImageUrlAndDownload( softwareStore.spin.spinning = true
bookStore.selectBookTask.id, softwareStore.spin.tip = '正在采集图片,请稍后...'
OperateBookType.BOOKTASK,
false debugger
)
if (res.code == 1) { let downIds = []
// for (let i = 0; i < bookStore.selectBookTaskDetail.length; i++) {
for (let i = 0; i < res.data.length; i++) { const element = bookStore.selectBookTaskDetail[i]
const element = res.data[i] if (!element.mjMessage) {
let findIndex = bookStore.selectBookTaskDetail.findIndex((item) => item.id == element.id) continue
if (findIndex != -1) { }
bookStore.selectBookTaskDetail[findIndex].outImagePath = element.data.outImagePath
bookStore.selectBookTaskDetail[findIndex].subImagePath = element.data.subImagePath if (isEmpty(element.mjMessage.messageId)) {
bookStore.selectBookTaskDetail[findIndex].mjMessage = element.data.mjMessage continue
}
if (element.mjMessage.status == 'success' && isEmpty(element.outImagePath)) {
downIds.push({ id: element.id, name: element.name })
continue
}
if (element.mjMessage.status == 'error' && element.mjMessage.messageId) {
downIds.push({ id: element.id, name: element.name })
continue
} }
} }
message.success(res.message)
} else { let result = []
message.error(res.message) for (let i = 0; i < downIds.length; i++) {
const element = downIds[i]
let res = await window.book.GetImageUrlAndDownload(element.id)
if (res.code != 1) {
result.push({
id: element.id,
name: element.name,
code: res.code,
message: res.message
})
} else {
result.push({
id: element.id,
name: element.name,
code: res.code,
message: res.message
})
}
}
// dialog result
showDownloadResultDialog(result, downIds.length)
} catch (error) {
showErrorDialog('下载图片失败', '下载图片失败,失败信息如下:' + error.message)
} finally {
softwareStore.spin.spinning = false
} }
} }
}) })
@ -271,4 +312,66 @@ const blockOptions = computed(() => {
return baseOptions return baseOptions
}) })
const showDownloadResultDialog = (results, totalCount) => {
const columns = [
{
title: 'Name',
key: 'name',
width: 100
},
{
title: '状态',
key: 'success',
width: 100,
render: (row) => {
return h(
'span',
{
style: {
color: row.code === 1 ? '#18a058' : '#d03050',
fontWeight: 'bold'
}
},
row.code === 1 ? '成功' : '失败'
)
}
},
{
title: '信息',
key: 'message',
render: (row) => {
return h(
'span',
{
style: {
color: row.code === 1 ? '#18a058' : '#d03050'
}
},
row.message || (row.code === 1 ? '下载成功' : '下载失败')
)
}
}
]
const successCount = results.filter((r) => r.code === 1).length
dialog.create({
title: `下载结果 (成功: ${successCount}/${totalCount})`,
content: () =>
h(NDataTable, {
columns,
data: results,
size: 'small',
maxHeight: 400,
scrollX: 600
}),
style: {
width: '800px'
},
positiveText: '确定',
maskClosable: false,
showIcon: false
})
}
</script> </script>

View File

@ -238,7 +238,7 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['open-folder', 'refresh-data']) const emit = defineEmits(['open-folder', 'refresh-data', 'open-task'])
// //
const isActionClicked = ref(false) const isActionClicked = ref(false)
@ -300,34 +300,62 @@ async function handleOpenTask() {
return return
} }
message.info(`正在打开任务: ${props.bookTask.name}`) try {
// //
softwareStore.spin.spinning = true emit('open-task', {
softwareStore.spin.text = '正在加载小说批次信息...' type: 'start',
await new Promise((resolve) => setTimeout(resolve, 1000)) taskName: props.bookTask.name,
// description: '正在初始化小说分镜模块'
let res = await window.book.GetBookTaskDetailDataByCondition({ })
bookTaskId: props.bookTask.id message.info(`正在打开任务: ${props.bookTask.name}`)
}) //
if (res.code != 1) { await new Promise((resolve) => setTimeout(resolve, 1000))
message.error('获取小说批次信息失败,失败原因:' + res.message) //
let res = await window.book.GetBookTaskDetailDataByCondition({
bookTaskId: props.bookTask.id
})
if (res.code != 1) {
message.error('获取小说批次信息失败,失败原因:' + res.message)
softwareStore.spin.spinning = false
//
emit('open-task', {
type: 'error',
error: res.message,
description: '正在初始化小说分镜模块'
})
return
}
bookStore.selectBookTaskDetail = res.data
bookStore.selectBookTask = { ...props.bookTask }
//
let tagRes = await getShowTagsData({
isShow: true
})
//
presetStore.showCharacterPresetArray = tagRes.character
presetStore.showScenePresetArray = tagRes.scene
presetStore.showStylePresetArray = tagRes.style
router.push('/original-book-detail/' + props.bookTask.id)
} catch (error) {
message.error('打开任务失败:' + error.message)
softwareStore.spin.spinning = false softwareStore.spin.spinning = false
return //
emit('open-task', {
type: 'error',
error: error.message,
description: '正在初始化小说分镜模块'
})
} finally {
//
emit('open-task', {
type: 'success',
taskId: props.bookTask.id,
description: '正在初始化小说分镜模块'
})
} }
bookStore.selectBookTaskDetail = res.data
bookStore.selectBookTask = { ...props.bookTask }
//
let tagRes = await getShowTagsData({
isShow: true
})
//
presetStore.showCharacterPresetArray = tagRes.character
presetStore.showScenePresetArray = tagRes.scene
presetStore.showStylePresetArray = tagRes.style
router.push('/original-book-detail/' + props.bookTask.id)
} }
async function handleViewBookTask(bookTask) { async function handleViewBookTask(bookTask) {

View File

@ -35,6 +35,7 @@
:book="selectedProject" :book="selectedProject"
@refresh-data="($event) => emit('refresh-data', $event)" @refresh-data="($event) => emit('refresh-data', $event)"
@open-folder="($event) => emit('open-folder', $event)" @open-folder="($event) => emit('open-folder', $event)"
@open-task="($event) => emit('open-task', $event)"
/> />
</div> </div>
</div> </div>
@ -68,7 +69,7 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['refresh-data', 'add-task', 'open-folder']) const emit = defineEmits(['refresh-data', 'add-task', 'open-folder', 'open-task'])
// //
function addNewTask() { function addNewTask() {

View File

@ -1,279 +0,0 @@
<template>
<n-card title="AI 设置" class="setting-card">
<!-- 推理设置部分 -->
<n-divider title-placement="left">推理设置</n-divider>
<n-form
v-if="formReady"
ref="inferenceFormRef"
:model="inferenceSettings"
:rules="inferenceRules"
label-placement="left"
label-width="auto"
:style="{ maxWidth: '720px', minWidth: '400px' }"
>
<n-form-item label="API服务商" path="apiProvider">
<n-select
v-model:value="inferenceSettings.apiProvider"
placeholder="请选择API服务商"
:options="getAPIOptions('gpt')"
/>
<n-button type="primary" @click="buyApi">购买API</n-button>
</n-form-item>
<n-form-item label="API令牌" path="apiToken">
<n-input
v-model:value="inferenceSettings.apiToken"
placeholder="请输入API令牌"
:type="showApiToken ? 'text' : 'password'"
clearable
>
<template #suffix>
<n-button text @click="toggleApiTokenVisibility">
{{ showApiToken ? '隐藏' : '显示' }}
</n-button>
</template>
</n-input>
</n-form-item>
<n-form-item label="推理模型" path="inferenceModel">
<n-input
v-model:value="inferenceSettings.inferenceModel"
placeholder="请输入推理模型名称"
clearable
/>
</n-form-item>
<n-form-item label="推理模式" path="aiPromptValue">
<n-select
v-model:value="inferenceSettings.aiPromptValue"
placeholder="请选择推理模型"
:options="aiOptionsData"
/>
</n-form-item>
<!-- 翻译设置部分 -->
<n-divider title-placement="left">翻译设置</n-divider>
<n-form-item label="翻译模型" path="translationModel">
<n-input
v-model:value="inferenceSettings.translationModel"
placeholder="请输入翻译模型名称"
clearable
/>
</n-form-item>
</n-form>
<!-- 操作按钮 -->
<n-space justify="start" style="margin-top: 24px">
<n-button @click="testConnection('ai')"> 测试 AI 链接 </n-button>
<n-button @click="testConnection('translate')"> 测试 翻译 链接 </n-button>
<n-button type="primary" @click="saveSettings"> 保存设置 </n-button>
</n-space>
</n-card>
</template>
<script setup>
import { aiOptionsData } from '@/define/data/aiData/aiData'
import { GetApiDefineDataById, getAPIOptions } from '@/define/data/apiData'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { GetOpenAISuccessResponse } from '@/define/response/openAIResponse'
import { TimeDelay } from '@/define/Tools/time'
import { ValidateErrorString, ValidateJsonAndParse } from '@/define/Tools/validate'
import { inferenceAISettings } from '@/renderer/src/common/initialData'
import { useSoftwareStore } from '@/renderer/src/stores'
import { isEmpty } from 'lodash'
//
const message = useMessage()
//
const inferenceFormRef = ref(null)
const translationFormRef = ref(null)
// API
const showApiToken = ref(false)
//
let formReady = ref(false)
const softwareStore = useSoftwareStore()
//
const inferenceSettings = ref({
...inferenceAISettings
})
//
const inferenceRules = {
apiProvider: {
required: true,
message: '请选择API服务商',
trigger: ['blur', 'change']
},
apiToken: {
required: true,
message: '请输入API令牌',
trigger: ['blur', 'change']
},
inferenceModel: {
required: true,
message: '请输入推理模型名称',
trigger: ['blur', 'change']
},
aiPromptValue: {
required: true,
message: '请选择推理模型',
trigger: ['blur', 'change']
},
translationModel: {
required: true,
message: '请输入翻译模型名称',
trigger: ['blur', 'change']
}
}
// API
const toggleApiTokenVisibility = () => {
showApiToken.value = !showApiToken.value
}
// API
const buyApi = () => {
try {
//
let selectAPIData = GetApiDefineDataById(inferenceSettings.value.apiProvider)
if (selectAPIData == null || selectAPIData.buy_url == null) {
message.error('购买链接不存在,请联系管理员')
return
}
window.system.OpenUrl(selectAPIData.buy_url)
} catch (error) {
message.error(error.message)
}
}
//
onMounted(() => {
//
initData()
})
/**
* 初始化数据的函数
*/
async function initData() {
try {
formReady.value = false
softwareStore.spin.spinning = true
softwareStore.spin.tip = '正在加载设置...'
//
let res = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.InferenceSetting)
if (res.code != 1) {
message.error('获取设置失败: ' + res.message)
return
}
//
inferenceSettings.value = ValidateJsonAndParse(res.data.value)
await TimeDelay(500)
message.success('设置加载成功')
} catch (error) {
message.error('初始化数据失败: ' + error.message)
} finally {
formReady.value = true
softwareStore.spin.spinning = false
}
}
// AI
const testConnection = async (type) => {
try {
if (type != 'ai' && type != 'translate') {
message.error('未知的测试类型')
return
}
await Promise.all([inferenceFormRef.value?.validate(), translationFormRef.value?.validate()])
let selectAPIData = GetApiDefineDataById(inferenceSettings.value.apiProvider)
if (selectAPIData == null || isEmpty(selectAPIData.gpt_url)) {
message.error('API服务商未选择或服务地址无效请检查设置')
return
}
softwareStore.spin.spinning = true
softwareStore.spin.tip = '正在测试链接...'
let data = JSON.stringify({
model:
type == 'ai'
? inferenceSettings.value.inferenceModel
: inferenceSettings.value.translationModel,
messages: [
{
role: 'system',
content: '你好,测试链接!!'
}
]
})
let config = {
method: 'post',
maxBodyLength: Infinity,
headers: {
Authorization: `Bearer ${inferenceSettings.value.apiToken}`,
'Content-Type': 'application/json'
}
}
let res = await window.axios.post(selectAPIData.gpt_url, data, config)
console.log('测试链接返回', res)
if (res.status != 200) {
message.error('测试链接失败: ' + res.error)
return
}
let content = GetOpenAISuccessResponse(res.data)
if (content == null) {
message.error('测试链接失败: ' + res.error)
return
}
message.success(`连接成功!${type == 'ai' ? 'AI推理' : '翻译服务'} 运行正常`)
} catch (error) {
let errorMessage = ValidateErrorString(error)
message.error(`连接失败:${errorMessage || '未知错误'}`)
} finally {
softwareStore.spin.spinning = false
}
}
//
const saveSettings = async () => {
//
try {
await Promise.all([inferenceFormRef.value?.validate(), translationFormRef.value?.validate()])
let res = await window.option.ModifyOptionByKey(
OptionKeyName.InferenceAI.InferenceSetting,
JSON.stringify(inferenceSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存设置失败: ' + res.message)
return
}
message.success('设置已保存')
} catch (errors) {
//
const errorMessages = ValidateErrorString(errors)
message.error('请修正以下错误: ' + (errorMessages || errors.message))
}
}
</script>
<style scoped>
.setting-card {
margin-bottom: 20px;
}
</style>

View File

@ -0,0 +1,263 @@
<template>
<div class="add-comfy-ui-workflow">
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="100"
require-mark-placement="right-hanging"
>
<n-form-item label="名称" path="name">
<n-input v-model:value="formValue.name" placeholder="输入工作流名称" />
</n-form-item>
<n-form-item label="工作流文件" path="jsonFile">
<n-input v-model:value="formValue.workflowPath" placeholder="选择工作流API文件" readonly />
<n-button @click="triggerFileSelect" type="primary" style="margin-left: 20px">
选择文件
</n-button>
</n-form-item>
</n-form>
<div class="button-group">
<n-space justify="end" align="center">
<n-button @click="checkWorkflowFile" :disabled="!jsonContent" secondary>
检查工作流文件
</n-button>
<n-button
type="primary"
@click="saveWorkflow"
:disabled="!formValue.name || !formValue.workflowPath"
>
{{ isEdit ? '更新' : '保存' }}
</n-button>
</n-space>
</div>
<input
type="file"
ref="fileInput"
accept=".json"
style="display: none"
@change="handleFileUpload"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { NForm, NFormItem, NInput, NButton, NSpace, useMessage } from 'naive-ui'
import { OptionKeyName, OptionType } from '@/define/enum/option'
const fileInput = ref(null)
const jsonContent = ref(null)
const formRef = ref(null)
const message = useMessage()
const formValue = ref({
id: null,
name: '',
workflowPath: null
})
const rules = {
name: {
required: true,
message: '请输入工作流名称',
trigger: 'blur'
},
jsonFile: {
required: true,
message: '请选择选择工作流API文件',
trigger: 'change'
}
}
// props
const props = defineProps({
workflowData: {
type: Object,
default: null
},
comfyUIWorkFlowSetting: {
type: Array,
required: true
}
})
// emits
const emit = defineEmits(['update-workflows'])
const isEdit = ref(false)
//
onMounted(() => {
if (props.workflowData) {
isEdit.value = true
formValue.value = {
id: props.workflowData.id,
name: props.workflowData.name,
workflowPath: props.workflowData.workflowPath
}
}
})
async function saveWorkflow() {
try {
//
const isDuplicate = props.comfyUIWorkFlowSetting.some(
(item) =>
item.name === formValue.value.name && (!isEdit.value || item.id !== formValue.value.id)
)
if (isDuplicate) {
message.error('工作流名称已存在,请重新输入')
return
}
//
let updatedWorkflows = [...props.comfyUIWorkFlowSetting]
if (isEdit.value) {
//
const index = updatedWorkflows.findIndex(
(item) => item.id === formValue.value.id
)
if (index !== -1) {
updatedWorkflows[index] = {
id: formValue.value.id,
name: formValue.value.name,
workflowPath: formValue.value.workflowPath
}
}
} else {
//
updatedWorkflows.push({
id: crypto.randomUUID(),
name: formValue.value.name,
workflowPath: formValue.value.workflowPath
})
}
//
let res = await window.options.ModifyOptionByKey(
OptionKeyName.ComfyUI_WorkFlowSetting,
JSON.stringify(updatedWorkflows),
OptionType.JSON
)
if (res.code == 1) {
message.success(isEdit.value ? '更新成功' : '保存成功')
//
emit('update-workflows', updatedWorkflows)
//
if (!isEdit.value) {
formValue.value = {
name: '',
workflowPath: null,
id: null
}
jsonContent.value = null
}
} else {
message.error((isEdit.value ? '更新' : '保存') + '失败,失败原因:' + res.message)
}
} catch (error) {
message.error((isEdit.value ? '更新' : '保存') + '失败,失败原因:' + error.message)
}
}
const triggerFileSelect = () => {
fileInput.value.click()
}
/**
* 选择并读取json文件
* @param event
*/
const handleFileUpload = (event) => {
const file = event.target.files[0]
if (!file) return
formValue.value.workflowPath = file.path
const reader = new FileReader()
reader.onload = (e) => {
try {
jsonContent.value = JSON.parse(e.target.result)
} catch (error) {
console.error('解析JSON失败:', error)
message.error('无法解析JSON文件请确保文件格式正确')
jsonContent.value = null
formValue.value.jsonFile = null
}
}
reader.readAsText(file)
}
/**
* 检查工作流文件是不是正确
*/
async function checkWorkflowFile() {
if (formValue.value.workflowPath == null) {
message.error('请先选择工作流文件')
return
}
if (!jsonContent.value) {
message.error('工作流文件内容为空,请选择工作流文件')
return
}
console.log(jsonContent.value)
let hasPositivePrompt = false
let hasNegativePrompt = false
//
let elements = []
// ComfyUI
if (Array.isArray(jsonContent.value)) {
message.error('工作流文件的格式不正确,请检查工作流文件')
} else if (typeof jsonContent.value === 'object') {
// ComfyUInodes
if (jsonContent.value.nodes) {
elements = Object.values(jsonContent.value.nodes)
} else {
elements = Object.values(jsonContent.value)
}
}
for (const element of elements) {
if (element && element.class_type === 'CLIPTextEncode') {
if (element._meta?.title === '正向提示词') {
hasPositivePrompt = true
}
if (element._meta?.title === '反向提示词') {
hasNegativePrompt = true
}
}
}
if (!hasPositivePrompt || !hasNegativePrompt) {
message.error(
'工作流文件缺少正向提示词或反向提示词,请检查工作流文件,把对应的文本编码模块的标题改为正向提示词和反向提示词!!'
)
return
} else {
message.success('工作流文件检查成功通过')
}
}
</script>
<style scoped>
.add-comfy-ui-workflow {
padding: 20px;
max-width: 600px;
}
</style>

View File

@ -0,0 +1,363 @@
<template>
<div class="comfy-ui-setting">
<div class="form-section">
<h3 class="section-title">ComfyUI 基础设置</h3>
<n-form inline :model="comfyUISimpleSetting" class="inline-form">
<n-form-item label="请求地址" path="requestUrl">
<n-input v-model:value="comfyUISimpleSetting.requestUrl" placeholder="输入请求地址" />
</n-form-item>
<n-form-item label="当前工作流" path="selectedWorkflow">
<n-select
placeholder="选择工作流"
v-model:value="comfyUISimpleSetting.selectedWorkflow"
:options="
comfyUIWorkFlowSetting.map((workflow) => ({
label: workflow.name,
value: workflow.id
}))
"
style="width: 200px"
/>
</n-form-item>
<n-form-item label="反向提示词" path="negativePrompt">
<n-input
v-model:value="comfyUISimpleSetting.negativePrompt"
style="width: 300px"
placeholder="输入反向提示词"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" style="margin-left: 8px" @click="SaveComfyUISimpleSetting">
保存设置
</n-button>
</n-form-item>
</n-form>
</div>
<div style="color: red">
<p>注意</p>
<p>1 Comfy UI的工作流中正向提示词和反向提示必须为 <strong>Clip文本编码</strong> 节点</p>
<p>2 标题必须对应 <strong>正向提示词和反向提示词</strong></p>
<p>
3 图像输出节点必须是 <strong>保存图像</strong> 节点<strong
>采样器只支持简单 K采样器和K采样器高级</strong
>
</p>
</div>
<div class="action-bar">
<n-button type="primary" @click="handleAdd">添加</n-button>
</div>
<div ref="tableContainer" class="table-container">
<n-data-table
:columns="columns"
:data="comfyUIWorkFlowSetting"
:bordered="true"
:single-line="false"
:max-height="tableHeight"
/>
</div>
</div>
</template>
<script setup>
import { ref, h, onMounted, onUnmounted, nextTick } from 'vue'
import {
NDataTable,
NButton,
useDialog,
NForm,
NFormItem,
NInput,
useMessage,
NSelect,
NSpace,
NPopconfirm,
NIcon
} from 'naive-ui'
//
import AddComfyUIWorkflow from './ComfyUIAddWorkflow.vue'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { optionSerialization } from '@/main/service/option/optionSerialization'
//
const comfyUISimpleSetting = ref({
requestUrl: '',
selectedWorkflow: '',
negativePrompt: ''
})
const comfyUIWorkFlowSetting = ref([])
const dialog = useDialog()
let message = useMessage()
onMounted(async () => {
//
await initializeData()
//
updateTableHeight()
window.addEventListener('resize', updateTableHeight)
})
//
async function initializeSimpleSetting() {
try {
const simpleSettingRes = await window.option.GetOptionByKey(
OptionKeyName.SD.ComfyUISimpleSetting
)
if (simpleSettingRes.code != 1) {
message.error('获取ComfyUI简单设置失败')
return
}
comfyUISimpleSetting.value = optionSerialization(simpleSettingRes.data)
} catch (error) {
message.error('初始化简单设置失败: ' + error.message)
throw error
}
}
//
async function initializeWorkflowSetting() {
try {
const workflowSettingRes = await window.option.GetOptionByKey(
OptionKeyName.SD.ComfyUIWorkFlowSetting
)
if (workflowSettingRes.code != 1) {
message.error('获取ComfyUI工作流设置失败')
return
}
comfyUIWorkFlowSetting.value = optionSerialization(workflowSettingRes.data)
} catch (error) {
message.error('初始化工作流设置失败: ' + error.message)
throw error
}
}
//
async function initializeData() {
try {
await initializeSimpleSetting()
await initializeWorkflowSetting()
} catch (error) {
message.error('初始化数据失败: ' + error.message)
}
}
onUnmounted(() => {
//
window.removeEventListener('resize', updateTableHeight)
})
async function SaveComfyUISimpleSetting() {
try {
// http https
if (
!comfyUISimpleSetting.value.requestUrl.startsWith('http://') &&
!comfyUISimpleSetting.value.requestUrl.startsWith('https://')
) {
message.error('请求地址必须以 http 或者 https 开头')
return
}
// ID
if (
!comfyUIWorkFlowSetting.value.some(
(workflow) => workflow.id === comfyUISimpleSetting.value.selectedWorkflow
)
) {
message.error('当前选中的工作流不存在,请重新选择')
return
}
//
let res = await window.option.ModifyOptionByKey(
OptionKeyName.SD.ComfyUISimpleSetting,
JSON.stringify(comfyUISimpleSetting.value),
OptionType.JSON
)
if (res.code == 1) {
message.success('保存设置成功')
} else {
message.error('保存设置失败,失败原因:' + res.message)
}
} catch (error) {
message.error('保存ComfyUI通用设置失败失败原因' + error.message)
}
}
//
const tableContainer = ref(null)
const tableHeight = ref(300) //
const minTableHeight = 200 //
//
const updateTableHeight = () => {
nextTick(() => {
if (!tableContainer.value) return
//
const containerRect = tableContainer.value.getBoundingClientRect()
//
const existingContentHeight = containerRect.top
//
const viewportHeight = window.innerHeight
//
const bottomPadding = 60 //
const availableHeight = viewportHeight - existingContentHeight - bottomPadding
//
tableHeight.value = Math.max(availableHeight, minTableHeight)
})
}
// Table columns
const columns = [
{
title: '名称',
key: 'name'
},
{
title: '工作流路径',
key: 'workflowPath'
},
{
title: '操作',
key: 'actions',
render(row) {
return h(
'div',
{},
{
default: () => [
h(
NButton,
{
size: 'small',
type: 'info',
secondary: true,
onClick: () => handleEdit(row)
},
{
default: () => '编辑'
}
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => handleRemove(row),
style: { marginLeft: '16px' }
},
{
default: () => '删除'
}
)
]
}
)
}
}
]
const handleAdd = () => {
dialog.info({
title: '添加工作流',
showIcon: false,
maskClosable: false,
style: { width: '600px' },
content: () =>
h(AddComfyUIWorkflow, {
comfyUIWorkFlowSetting: comfyUIWorkFlowSetting.value,
onUpdateWorkflows: async () => {
await initializeWorkflowSetting()
}
})
})
}
//
const handleEdit = (row) => {
dialog.info({
title: '编辑工作流',
showIcon: false,
maskClosable: false,
style: { width: '600px' },
content: () =>
h(AddComfyUIWorkflow, {
workflowData: row,
comfyUIWorkFlowSetting: comfyUIWorkFlowSetting.value,
onUpdateWorkflows: (updatedWorkflows) => {
comfyUIWorkFlowSetting.value = updatedWorkflows
}
})
})
}
//
const handleRemove = (row) => {
dialog.warning({
title: '确认删除',
content: `确定要删除工作流"${row.name}"吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
debugger
//
const index = comfyUIWorkFlowSetting.value.findIndex((item) => item.id === row.id)
if (index == -1) {
message.error('删除失败: 未找到该工作流')
return
}
comfyUIWorkFlowSetting.value.splice(index, 1)
//
if (comfyUISimpleSetting.value.selectedWorkflow === row.id) {
comfyUISimpleSetting.value.selectedWorkflow = ''
}
//
let res = await window.option.ModifyOptionByKey(
OptionKeyName.ComfyUI_WorkFlowSetting,
JSON.stringify(comfyUIWorkFlowSetting.value),
OptionType.JSON
)
if (res.code == 0) {
message.error('删除失败,失败原因:' + res.message)
return
}
res = await window.option.ModifyOptionByKey(
OptionKeyName.ComfyUI_SimpleSetting,
JSON.stringify(comfyUISimpleSetting.value),
OptionType.JSON
)
if (res.code == 1) {
message.success('删除成功')
} else {
message.error('删除失败,失败原因:' + res.message)
}
} catch (error) {
message.error('删除失败: ' + error.message)
}
}
})
}
</script>
<style scoped>
.description {
margin-bottom: 16px;
font-size: large;
color: red;
}
.action-bar {
margin: 16px 0;
}
.table-container {
width: 100%;
}
</style>

View File

@ -0,0 +1,537 @@
<template>
<n-card title="AI 设置" class="setting-card">
<!-- 推理设置部分 -->
<n-divider title-placement="left">推理设置</n-divider>
<n-form
v-if="formReady"
ref="inferenceFormRef"
:model="inferenceSettings"
:rules="inferenceRules"
label-placement="left"
label-width="auto"
:style="{ maxWidth: '720px', minWidth: '400px' }"
>
<n-form-item label="API服务商" path="apiProvider">
<n-select
v-model:value="inferenceSettings.apiProvider"
placeholder="请选择API服务商"
:options="getAPIOptions('gpt')"
/>
<n-button type="primary" @click="buyApi">购买API</n-button>
</n-form-item>
<n-form-item label="API令牌" path="apiToken">
<n-input
v-model:value="inferenceSettings.apiToken"
placeholder="请输入API令牌"
:type="showApiToken ? 'text' : 'password'"
clearable
>
<template #suffix>
<n-button text @click="toggleApiTokenVisibility">
{{ showApiToken ? '隐藏' : '显示' }}
</n-button>
</template>
</n-input>
</n-form-item>
<n-form-item label="推理模型" path="inferenceModel">
<n-input
v-model:value="inferenceSettings.inferenceModel"
placeholder="请输入推理模型名称"
clearable
/>
</n-form-item>
<n-form-item label="推理模式" path="aiPromptValue">
<n-select
v-model:value="inferenceSettings.aiPromptValue"
placeholder="请选择推理模型"
:options="inferenceAIModelOptions"
@update:value="handleAIModelChange"
/>
<n-button v-if="!isCustomModel" type="primary" @click="openCustomInferencePreset"
>自定义推理预设</n-button
>
<TooltipDropdown v-else :options="customPresetOptions" @select="handleCustomPresetAction">
<n-button type="primary" @click="openCustomInferencePreset">自定义推理预设</n-button>
</TooltipDropdown>
</n-form-item>
<!-- 翻译设置部分 -->
<n-divider title-placement="left">翻译设置</n-divider>
<n-form-item label="翻译模型" path="translationModel">
<n-input
v-model:value="inferenceSettings.translationModel"
placeholder="请输入翻译模型名称"
clearable
/>
</n-form-item>
</n-form>
<!-- 操作按钮 -->
<n-space justify="start" style="margin-top: 24px">
<n-button @click="testConnection('ai')"> 测试 AI 链接 </n-button>
<n-button @click="testConnection('translate')"> 测试 翻译 链接 </n-button>
<n-button type="primary" @click="saveSettings"> 保存设置 </n-button>
</n-space>
</n-card>
</template>
<script setup>
import { aiOptionsData } from '@/define/data/aiData/aiData'
import { GetApiDefineDataById, getAPIOptions } from '@/define/data/apiData'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { GetOpenAISuccessResponse } from '@/define/response/openAIResponse'
import { TimeDelay } from '@/define/Tools/time'
import { ValidateErrorString, ValidateJsonAndParse } from '@/define/Tools/validate'
import { inferenceAISettings } from '@/renderer/src/common/initialData'
import { useSoftwareStore } from '@/renderer/src/stores'
import { isEmpty } from 'lodash'
import CustomInferencePreset from './CustomInferencePreset.vue'
import TooltipDropdown from '@/renderer/src/components/common/TooltipDropdown.vue'
import { h, nextTick, computed } from 'vue'
import { optionSerialization } from '@/main/service/option/optionSerialization'
//
const message = useMessage()
const dialog = useDialog()
//
const inferenceFormRef = ref(null)
const translationFormRef = ref(null)
// API
const showApiToken = ref(false)
//
let formReady = ref(false)
const softwareStore = useSoftwareStore()
const inferenceAIModelOptions = ref([])
const selectedAIModel = ref(null)
//
const customPresetOptions = computed(() => {
if (!selectedAIModel.value) return []
return [
{
key: 'edit',
label: '编辑自定义预设',
tooltip: '编辑当前选中的自定义推理预设'
},
{
key: 'delete',
label: () => h('span', { style: { color: 'red' } }, '删除自定义预设'),
tooltip: '删除当前选中的自定义推理预设'
}
]
})
//
const isCustomModel = computed(() => {
if (!formReady.value || !inferenceSettings.value.aiPromptValue) {
return false
}
const option = inferenceAIModelOptions.value.find(
(opt) => opt.value === inferenceSettings.value.aiPromptValue
)
return option?.isCustom === true
})
//
const inferenceSettings = ref({
...inferenceAISettings
})
//
const inferenceRules = {
apiProvider: {
required: true,
message: '请选择API服务商',
trigger: ['blur', 'change']
},
apiToken: {
required: true,
message: '请输入API令牌',
trigger: ['blur', 'change']
},
inferenceModel: {
required: true,
message: '请输入推理模型名称',
trigger: ['blur', 'change']
},
aiPromptValue: {
required: true,
message: '请选择推理模型',
trigger: ['blur', 'change']
},
translationModel: {
required: true,
message: '请输入翻译模型名称',
trigger: ['blur', 'change']
}
}
// API
const toggleApiTokenVisibility = () => {
showApiToken.value = !showApiToken.value
}
// API
const buyApi = () => {
try {
//
let selectAPIData = GetApiDefineDataById(inferenceSettings.value.apiProvider)
if (selectAPIData == null || selectAPIData.buy_url == null) {
message.error('购买链接不存在,请联系管理员')
return
}
window.system.OpenUrl(selectAPIData.buy_url)
} catch (error) {
message.error(error.message)
}
}
//
const openCustomInferencePreset = () => {
try {
// dialog
const dialogInstance = dialog.create({
title: '自定义推理预设',
content: () =>
h(CustomInferencePreset, {
mode: 'add',
aiSetting: inferenceSettings.value,
onClose: () => {
dialogInstance.destroy()
},
onSaved: () => {
//
message.success('自定义推理预设保存成功!')
dialogInstance.destroy()
//
initData()
}
}),
contentStyle: {
padding: '0',
height: '90vh'
},
style: {
width: '900px'
},
showIcon: false,
closable: true,
maskClosable: false
})
} catch (error) {
message.error('打开自定义推理预设失败: ' + error.message)
}
}
//
const handleEditCustomPreset = (presetData) => {
try {
const dialogInstance = dialog.create({
title: '编辑自定义推理预设',
content: () =>
h(CustomInferencePreset, {
mode: 'edit',
aiSetting: inferenceSettings.value,
presetData: presetData,
onClose: () => {
dialogInstance?.destroy()
},
onSaved: () => {
//
message.success('自定义推理预设保存成功!')
dialogInstance.destroy()
//
initData()
}
}),
contentStyle: {
padding: '0',
height: '90vh'
},
style: {
width: '900px'
},
showIcon: false,
closable: true,
maskClosable: false
})
} catch (error) {
message.error('打开编辑预设失败: ' + error.message)
}
}
//
const handleDeleteCustomPreset = (presetData) => {
try {
dialog.warning({
title: '删除确认',
content: `确定要删除自定义推理预设 "${presetData.label || presetData.name}" 吗?此操作不可撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
//
try {
debugger
//
let res = await window.option.GetOptionByKey(
OptionKeyName.InferenceAI.CustomInferencePreset
)
if (res.code != 1) {
throw new Error(res.message)
}
let cip = optionSerialization(res.data, '', [])
// ID
const originalLength = cip.length
cip = cip.filter((item) => item.id !== presetData.value)
if (cip.length === originalLength) {
throw new Error('未找到要删除的预设')
}
//
let saveRes = await window.option.ModifyOptionByKey(
OptionKeyName.InferenceAI.CustomInferencePreset,
JSON.stringify(cip),
OptionType.JSON
)
if (saveRes.code != 1) {
throw new Error(saveRes.message)
}
//
if (inferenceSettings.value.aiPromptValue === presetData.value) {
inferenceSettings.value.aiPromptValue = undefined
selectedAIModel.value = []
}
message.success('自定义推理预设删除成功!')
//
initData()
} catch (error) {
message.error('删除预设失败: ' + error.message)
}
}
})
} catch (error) {
message.error('打开删除预设失败: ' + error.message)
}
}
//
const handleCustomPresetAction = (key) => {
if (!selectedAIModel.value) return
switch (key) {
case 'edit':
handleEditCustomPreset(selectedAIModel.value)
break
case 'delete':
handleDeleteCustomPreset(selectedAIModel.value)
break
default:
console.warn('未知的操作类型:', key)
}
}
async function handleAIModelChange(value, option) {
console.log('AI模型选择变更:', value, option)
//
selectedAIModel.value = option || null
//
if (inferenceSettings.value) {
inferenceSettings.value.aiPromptValue = value
}
//
await nextTick()
if (inferenceFormRef.value) {
inferenceFormRef.value.validate(
(errors) => {
if (!errors) {
console.log('表单验证通过')
}
},
(rule) => rule.key === 'aiPromptValue'
)
}
}
//
onMounted(() => {
//
initData()
})
/**
* 初始化数据的函数
*/
async function initData() {
try {
formReady.value = false
softwareStore.spin.spinning = true
softwareStore.spin.tip = '正在加载设置...'
//
let res = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.InferenceSetting)
if (res.code != 1) {
message.error('获取设置失败: ' + res.message)
return
}
//
inferenceSettings.value = ValidateJsonAndParse(res.data.value)
inferenceAIModelOptions.value = [...aiOptionsData]
let customRes = await window.option.GetOptionByKey(
OptionKeyName.InferenceAI.CustomInferencePreset
)
if (customRes.code != 1) {
message.error('获取自定义推理预设失败: ' + customRes.message)
return
}
let cip = optionSerialization(customRes.data, '', [])
if (cip.length > 0) {
for (let i = 0; i < cip.length; i++) {
const element = cip[i]
inferenceAIModelOptions.value.push({
label: '【自定义】' + element.name,
value: element.id,
isCustom: true
})
}
}
//
await nextTick()
// selectedAIModel inferenceAIModelOptions
if (inferenceSettings.value.aiPromptValue) {
const selectedOption = inferenceAIModelOptions.value.find(
(option) => option.value === inferenceSettings.value.aiPromptValue
)
if (selectedOption) {
selectedAIModel.value = selectedOption
console.log('初始化时设置选中的模型:', selectedOption)
} else {
inferenceSettings.value.aiPromptValue = inferenceAIModelOptions.value[0].value
}
}
await TimeDelay(500)
message.success('设置加载成功')
} catch (error) {
message.error('初始化数据失败: ' + error.message)
} finally {
formReady.value = true
softwareStore.spin.spinning = false
}
}
// AI
const testConnection = async (type) => {
try {
if (type != 'ai' && type != 'translate') {
message.error('未知的测试类型')
return
}
await Promise.all([inferenceFormRef.value?.validate(), translationFormRef.value?.validate()])
let selectAPIData = GetApiDefineDataById(inferenceSettings.value.apiProvider)
if (selectAPIData == null || isEmpty(selectAPIData.gpt_url)) {
message.error('API服务商未选择或服务地址无效请检查设置')
return
}
softwareStore.spin.spinning = true
softwareStore.spin.tip = '正在测试链接...'
let data = JSON.stringify({
model:
type == 'ai'
? inferenceSettings.value.inferenceModel
: inferenceSettings.value.translationModel,
messages: [
{
role: 'system',
content: '你好,测试链接!!'
}
]
})
let config = {
method: 'post',
maxBodyLength: Infinity,
headers: {
Authorization: `Bearer ${inferenceSettings.value.apiToken}`,
'Content-Type': 'application/json'
}
}
let res = await window.axios.post(selectAPIData.gpt_url, data, config)
console.log('测试链接返回', res)
if (res.status != 200) {
message.error('测试链接失败: ' + res.error)
return
}
let content = GetOpenAISuccessResponse(res.data)
if (content == null) {
message.error('测试链接失败: ' + res.error)
return
}
message.success(`连接成功!${type == 'ai' ? 'AI推理' : '翻译服务'} 运行正常`)
} catch (error) {
let errorMessage = ValidateErrorString(error)
message.error(`连接失败:${errorMessage || '未知错误'}`)
} finally {
softwareStore.spin.spinning = false
}
}
//
const saveSettings = async () => {
//
try {
await Promise.all([inferenceFormRef.value?.validate(), translationFormRef.value?.validate()])
let res = await window.option.ModifyOptionByKey(
OptionKeyName.InferenceAI.InferenceSetting,
JSON.stringify(inferenceSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存设置失败: ' + res.message)
return
}
message.success('设置已保存')
} catch (errors) {
//
const errorMessages = ValidateErrorString(errors)
message.error('请修正以下错误: ' + (errorMessages || errors.message))
}
}
</script>
<style scoped>
.setting-card {
margin-bottom: 20px;
}
</style>

View File

@ -0,0 +1,727 @@
<template>
<div class="custom-inference-preset">
<n-grid :cols="2" :x-gap="24">
<!-- 左侧预设配置 -->
<n-gi>
<n-card size="small" :bordered="false">
<template #header>
<n-space justify="space-between" align="center">
<span>{{ getModalTitle() }}</span>
<n-space>
<n-button @click="handleCancel">取消</n-button>
<n-button v-if="mode !== 'delete'" type="primary" @click="handleSave">
{{ mode === 'edit' ? '更新预设' : '保存预设' }}
</n-button>
<n-button v-if="mode === 'delete'" type="error" @click="handleDelete">
确认删除
</n-button>
</n-space>
</n-space>
</template>
<n-form
ref="presetFormRef"
:model="presetForm"
:rules="presetRules"
label-placement="top"
>
<n-space vertical size="large">
<!-- 删除确认提示 -->
<div v-if="mode === 'delete'" class="delete-warning">
<n-alert type="warning" title="删除确认">
<n-text>确定要删除预设 "{{ presetForm.name }}" 此操作不可撤销</n-text>
</n-alert>
</div>
<!-- 基础信息 -->
<n-form-item label="预设名称" path="name">
<n-input
v-model:value="presetForm.name"
placeholder="请输入预设名称"
clearable
:disabled="mode === 'delete'"
/>
</n-form-item>
<!-- 占位符说明 -->
<n-form-item label="可用占位符-软件会自动替换对应的占位符为实际文本">
<n-space vertical size="small">
<n-text
v-for="placeholder in availablePlaceholders"
:key="placeholder.key"
depth="3"
size="small"
>
{{ '{' + placeholder.key + '}' }} - {{ placeholder.description }}
</n-text>
</n-space>
</n-form-item>
<!-- 预设配置 -->
<n-form-item label="预设配置">
<n-space vertical>
<n-checkbox
v-model:checked="presetForm.mustCharacter"
:disabled="mode === 'delete'"
>
必须包含角色信息检测角色分析
</n-checkbox>
<!-- <n-checkbox v-model:checked="presetForm.hasExample">
保存示例保存用的使用示例
</n-checkbox> -->
</n-space>
</n-form-item>
<!-- 系统提示词 -->
<n-form-item label="系统提示词" path="systemContent">
<n-input
v-model:value="presetForm.systemContent"
type="textarea"
placeholder="请输入系统提示词,支持占位符(如 {textContent}"
:autosize="{
minRows: 7,
maxRows: 7
}"
:disabled="mode === 'delete'"
@input="detectPlaceholders"
/>
</n-form-item>
<!-- 用户提示词 -->
<n-form-item label="用户提示词" path="userContent">
<n-input
v-model:value="presetForm.userContent"
type="textarea"
placeholder="请输入用户提示词,支持占位符(如 {textContent}"
:autosize="{
minRows: 7,
maxRows: 7
}"
:disabled="mode === 'delete'"
@input="detectPlaceholders"
/>
</n-form-item>
</n-space>
</n-form>
</n-card>
</n-gi>
<!-- 右侧占位符测试 -->
<n-gi>
<n-card size="small" :bordered="false">
<template #header>
<n-space justify="space-between" align="center">
<span>占位符测试</span>
<n-space v-if="detectedPlaceholders.length > 0 && mode !== 'delete'">
<n-button @click="clearPlaceholderValues">清空值</n-button>
<n-button type="primary" @click="testPrompt">测试提示词</n-button>
<n-button
v-if="isTestSuccess"
type="primary"
secondary="true"
@click="openPreviewDialog"
>预览</n-button
>
</n-space>
</n-space>
</template>
<n-space vertical size="large">
<!-- 检测到的占位符显示 -->
<n-form-item label="检测到的占位符">
<div v-if="detectedPlaceholders.length > 0">
<n-space size="small" wrap>
<n-tag
v-for="placeholder in detectedPlaceholders"
:key="placeholder"
type="info"
size="small"
>
{{ '{' + placeholder + '}' }}
</n-tag>
</n-space>
</div>
<n-text v-else depth="3" size="small" style="color: #d03050">
在提示词中使用占位符这里会自动检测并显示
</n-text>
</n-form-item>
<!-- 占位符值设置 -->
<div v-if="detectedPlaceholders.length > 0 && mode !== 'delete'">
<n-form-item label="占位符值设置">
<n-space vertical size="medium" style="width: 100%">
<div
v-for="placeholder in detectedPlaceholders"
:key="placeholder"
class="placeholder-input"
>
<n-form-item
:label="getPlaceholderLabel(placeholder)"
label-placement="top"
style="width: 100%"
>
<n-input
v-model:value="placeholderValues[placeholder]"
type="textarea"
:placeholder="`请输入 ${getPlaceholderDescription(placeholder)}`"
:autosize="{
minRows: 4,
maxRows: 4
}"
:input-props="{ spellcheck: false }"
style="width: 100%"
/>
</n-form-item>
</div>
</n-space>
</n-form-item>
<!-- 简化的测试结果提示 -->
<n-form-item v-if="isTestSuccess" label="测试状态">
<n-alert type="success" title="测试成功">
<n-text>提示词测试完成点击右上角"预览"查看详细内容</n-text>
</n-alert>
</n-form-item>
</div>
</n-space>
</n-card>
</n-gi>
</n-grid>
</div>
</template>
<script setup>
import { ref, computed, h, watch } from 'vue'
import { useMessage, useDialog } from 'naive-ui'
import { GetApiDefineDataById } from '@/define/data/apiData'
import { useMD } from '../../../hooks/useMD'
import { isEmpty, result } from 'lodash'
import { GetOpenAISuccessResponse } from '@/define/response/openAIResponse'
import { ValidateErrorString } from '@/define/Tools/validate'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { optionSerialization } from '@/main/service/option/optionSerialization'
const message = useMessage()
const dialog = useDialog()
const { showErrorDialog } = useMD()
//
const props = defineProps({
mode: {
type: String,
default: 'add', // 'add', 'edit', 'delete'
validator: (value) => ['add', 'edit', 'delete'].includes(value)
},
aiSetting: {
type: Object,
default: () => ({})
}
})
//
const emit = defineEmits(['close', 'saved', 'deleted'])
//
const presetFormRef = ref(null)
const apiSetting = computed(() => props.aiSetting)
//
const presetForm = ref({
name: '',
id: '',
systemContent: '',
userContent: '',
hasExample: false,
mustCharacter: false
})
//
const detectedPlaceholders = ref([])
//
const placeholderValues = ref({})
//
const previewContent = ref(null)
//
const isTestSuccess = computed(() => {
return (
previewContent.value && previewContent.value.result && previewContent.value.result.trim() !== ''
)
})
onMounted(async () => {
await initPresetData()
})
//
const availablePlaceholders = ref([
{ key: 'textContent', description: '当前文本内容(分镜文案)' },
{ key: 'characterContent', description: '角色信息内容' },
{ key: 'sceneContent', description: '场景信息内容' },
{ key: 'contextContent', description: '上下文内容' }
])
//
const detectPlaceholders = () => {
const content = presetForm.value.systemContent + ' ' + presetForm.value.userContent
//
const fixedPlaceholders = ['textContent', 'characterContent', 'sceneContent', 'contextContent']
const detectedList = []
fixedPlaceholders.forEach((placeholder) => {
if (content.includes(`{${placeholder}}`)) {
detectedList.push(placeholder)
}
})
detectedPlaceholders.value = detectedList
//
detectedList.forEach((placeholder) => {
if (!(placeholder in placeholderValues.value)) {
placeholderValues.value[placeholder] = ''
}
})
//
Object.keys(placeholderValues.value).forEach((key) => {
if (!detectedList.includes(key)) {
delete placeholderValues.value[key]
}
})
}
//
const testPrompt = async () => {
try {
let systemContent = presetForm.value.systemContent
let userContent = presetForm.value.userContent
if (!apiSetting.value.apiProvider || isEmpty(apiSetting.value.apiProvider)) {
throw new Error('没有选择API服务商')
}
let selectAPIData = GetApiDefineDataById(apiSetting.value.apiProvider)
if (isEmpty(selectAPIData.gpt_url)) {
throw new Error('无效的API服务商')
}
if (isEmpty(apiSetting.value.inferenceModel)) {
throw new Error('没有设置推理模型')
}
if (isEmpty(apiSetting.value.apiToken)) {
throw new Error('没有设置API Token')
}
//
detectedPlaceholders.value.forEach((placeholder) => {
const value = placeholderValues.value[placeholder] || ''
if (value == null || isEmpty(value)) {
throw new Error(`占位符 {${placeholder}} 的值无效`)
}
const regex = new RegExp(`\\{${placeholder}\\}`, 'g')
systemContent = systemContent.replace(regex, value)
userContent = userContent.replace(regex, value)
})
let body = {
model: apiSetting.value.inferenceModel,
messages: [
{
role: 'system',
content: systemContent
},
{
role: 'user',
content: userContent
}
]
}
previewContent.value = {
system: systemContent.trim() || null,
user: userContent.trim() || null,
result: ''
}
message.success('提示词预览已生成,开始测试连接')
//
let res = await window.axios.post(selectAPIData.gpt_url, JSON.stringify(body), {
method: 'post',
maxBodyLength: Infinity,
headers: {
Authorization: `Bearer ${apiSetting.value.apiToken}`,
'Content-Type': 'application/json'
}
})
if (res.status != 200) {
throw new Error(res.error)
}
let content = GetOpenAISuccessResponse(res.data)
if (content == null) {
throw new Error(res.error)
}
previewContent.value.result = content
message.success(`测试完成,请在预览下面查看提示词`)
} catch (error) {
showErrorDialog('提示词测试失败', '提示词测试失败,实现信息如下:' + error.message)
}
}
//
const clearPlaceholderValues = () => {
Object.keys(placeholderValues.value).forEach((key) => {
placeholderValues.value[key] = ''
})
previewContent.value = null
message.success('占位符值已清空')
}
//
const openPreviewDialog = () => {
if (!previewContent.value) {
message.warning('暂无预览内容')
return
}
try {
dialog.create({
title: '测试结果预览',
showIcon: false,
content: () =>
h('div', { style: 'max-width: 800px; max-height: 600px; overflow-y: auto;' }, [
h('div', { style: 'padding: 16px;' }, [
//
previewContent.value.system
? h('div', { style: 'margin-bottom: 16px;' }, [
h('div', { style: 'font-weight: bold; margin-bottom: 8px; ' }, '系统提示词:'),
h(
'div',
{
style:
'padding: 12px; border-radius: 6px; white-space: pre-wrap; font-family: monospace; font-size: 13px; line-height: 1.5; border: 1px solid #e0e0e6;'
},
previewContent.value.system
)
])
: null,
//
previewContent.value.user
? h('div', { style: 'margin-bottom: 16px;' }, [
h('div', { style: 'font-weight: bold; margin-bottom: 8px; ' }, '用户提示词:'),
h(
'div',
{
style:
'padding: 12px; border-radius: 6px; white-space: pre-wrap; font-family: monospace; font-size: 13px; line-height: 1.5; border: 1px solid #e0e0e6;'
},
previewContent.value.user
)
])
: null,
// AI
previewContent.value.result
? h('div', { style: 'margin-bottom: 16px;' }, [
h('div', { style: 'font-weight: bold; margin-bottom: 8px; ' }, 'AI 回复结果:'),
h(
'div',
{
style:
' padding: 12px; border-radius: 6px; white-space: pre-wrap; font-family: monospace; font-size: 13px; line-height: 1.5; border: 1px solid #b7eb8f;'
},
previewContent.value.result
)
])
: null
])
]),
style: {
width: '90vw',
maxWidth: '900px'
}
})
} catch (error) {
message.error('打开预览失败: ' + error.message)
}
}
//
const getModalTitle = () => {
switch (props.mode) {
case 'add':
return '新增预设'
case 'edit':
return '编辑预设'
default:
return '预设配置'
}
}
//
const getPlaceholderLabel = (placeholder) => {
return `{${placeholder}}`
}
//
const getPlaceholderDescription = (placeholder) => {
const found = availablePlaceholders.value.find((p) => p.key === placeholder)
return found ? found.description : placeholder
}
//
const examples = ref([])
//
const presetRules = {
name: {
required: true,
message: '请输入预设名称',
trigger: ['blur', 'change']
},
systemContent: {
required: true,
message: '请输入系统提示词',
trigger: ['blur', 'change']
},
userContent: {
required: true,
message: '请输入用户提示词',
trigger: ['blur', 'change']
}
}
//
const handleCancel = () => {
emit('close')
}
//
const handleSave = async () => {
try {
//
await presetFormRef.value?.validate()
//
const validExamples = examples.value.filter(
(example) =>
example.userInput &&
example.userInput.trim() !== '' &&
example.aiOutput &&
example.aiOutput.trim() !== ''
)
// mustCharacter
if (
presetForm.value.mustCharacter &&
!detectedPlaceholders.value.includes('characterContent')
) {
message.error('未添加角色占位符,不能勾选 “必须包含角色信息”')
return
}
let requestBody = {
model: apiSetting.value.inferenceModel,
temperature: 1.3,
stream: false,
messages: [
{
role: 'system',
content: presetForm.value.systemContent
},
{
role: 'user',
content: presetForm.value.userContent
}
]
}
//
const presetData = {
id: props.mode === 'edit' ? presetForm.value.id : crypto.randomUUID(),
name: presetForm.value.name,
hasExample: presetForm.value.hasExample,
//
mustCharacter: presetForm.value.mustCharacter,
examples: validExamples,
//
placeholderValues: placeholderValues.value ?? {},
//
detectedPlaceholders: detectedPlaceholders.value,
//
previewContent: previewContent.value ?? {},
requestBody: requestBody //
}
let res = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.CustomInferencePreset)
if (res.code != 1) {
throw new Error(res.message)
}
let cip = optionSerialization(res.data, '', [])
//
if (cip.findIndex((item) => item.name === presetData.name && item.id != presetData.id) !== -1) {
throw new Error('预设名称已存在,请更换一个名称')
}
//
if (props.mode === 'edit') {
let findIndex = cip.findIndex((item) => item.id === presetData.id)
if (findIndex == -1) {
throw new Error('当前修改的预设不存在')
}
//
cip[findIndex] = presetData
} else {
// ID ID
if (cip.findIndex((item) => item.id === presetData.id) !== -1) {
presetData.id = crypto.randomUUID()
}
cip.push(presetData)
emit('saved', presetData)
}
let saveRes = await window.option.ModifyOptionByKey(
OptionKeyName.InferenceAI.CustomInferencePreset,
JSON.stringify(cip),
OptionType.JSON
)
if (saveRes.code != 1) {
throw new Error(saveRes.message)
}
message.success('预设保存成功')
} catch (error) {
let errorMessage = ValidateErrorString(error)
message.error(errorMessage)
}
}
//
const handleDelete = async () => {
try {
if (!presetForm.value.id) {
message.error('预设ID不存在')
return
}
await deletePresetData(presetForm.value.id)
emit('deleted', presetForm.value.id)
} catch (error) {
message.error('删除预设失败: ' + error.message)
}
}
//
const deletePresetData = async (presetId) => {
try {
//
const existingPresets = JSON.parse(localStorage.getItem('customInferencePresets') || '[]')
const filteredPresets = existingPresets.filter((p) => p.id !== presetId)
if (filteredPresets.length < existingPresets.length) {
localStorage.setItem('customInferencePresets', JSON.stringify(filteredPresets))
message.success('预设删除成功')
} else {
throw new Error('预设不存在')
}
} catch (error) {
message.error('删除预设失败: ' + error.message)
throw error
}
}
//
const initPresetData = async () => {
if (props.mode !== 'edit') {
return
}
let res = await window.option.GetOptionByKey(OptionKeyName.InferenceAI.CustomInferencePreset)
if (res.code == 0) {
message.error('获取自定义预设失败:' + res.message)
return
}
if (res.data == null) {
message.error('获取自定义预设失败:' + res.message)
return
}
let preset = optionSerialization(res.data, '', []).find(
(item) => item.id === apiSetting.value.aiPromptValue
)
if (!preset) {
message.error('未找到对应的预设数据')
return
}
let systemContent = preset.requestBody?.messages?.find((m) => m.role === 'system')?.content || ''
let userContent = preset.requestBody?.messages?.find((m) => m.role === 'user')?.content || ''
presetForm.value = { ...preset, systemContent, userContent }
if (preset.placeholderValues) {
placeholderValues.value = { ...preset.placeholderValues }
}
if (preset.previewContent) {
previewContent.value = { ...preset.previewContent }
}
//
detectPlaceholders()
}
//
watch(
[() => presetForm.value.systemContent, () => presetForm.value.userContent],
() => {
if (props.mode !== 'delete') {
detectPlaceholders()
}
},
{ immediate: true }
)
//
defineExpose({
initPresetData
})
</script>
<style scoped>
.custom-inference-preset {
max-height: 90vh;
overflow-y: auto;
}
.placeholder-input {
border-radius: 6px;
width: 100%;
}
.placeholder-input .n-form-item {
width: 100%;
}
.placeholder-input .n-input {
width: 100%;
}
.example-item {
transition: all 0.2s ease;
}
.delete-warning {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,538 @@
<template>
<n-modal
v-model:show="showDialog"
:mask-closable="false"
preset="dialog"
:title="isEdit ? '修改MJ账号' : '添加MJ账号'"
:style="{
width: '800px'
}"
>
<n-form
ref="accountFormRef"
:model="accountForm"
:rules="accountRules"
label-placement="left"
label-width="120px"
:style="{
maxWidth: '720px',
minWidth: '400px'
}"
>
<n-grid cols="2" x-gap="12">
<!-- 第一列 -->
<n-grid-item>
<n-form-item label="服务器ID" path="guildId">
<n-input v-model:value="accountForm.guildId" placeholder="请填写服务器ID" clearable />
</n-form-item>
<n-form-item label="频道ID" path="channelId">
<n-input v-model:value="accountForm.channelId" placeholder="请填写频道ID" clearable />
</n-form-item>
<n-form-item label="MJ私信ID" path="mjBotChannelId">
<n-input
v-model:value="accountForm.mjBotChannelId"
placeholder="请填写MJ私信ID"
clearable
/>
</n-form-item>
<n-form-item label="Niji私信ID" path="nijiBotChannelId">
<n-input
v-model:value="accountForm.nijiBotChannelId"
placeholder="请填写Niji私信ID"
clearable
/>
</n-form-item>
<n-form-item label="用户token" path="userToken">
<n-input
v-model:value="accountForm.userToken"
placeholder="请填写MJtoken"
type="password"
show-password-on="click"
clearable
/>
</n-form-item>
</n-grid-item>
<!-- 第二列 -->
<n-grid-item>
<n-form-item label="账号并发数" path="coreSize">
<n-input-number
v-model:value="accountForm.coreSize"
:min="1"
:max="10"
placeholder="3"
style="width: 100%"
/>
</n-form-item>
<n-form-item label="等待队列" path="queueSize">
<n-input-number
v-model:value="accountForm.queueSize"
:min="1"
:max="100"
placeholder="10"
style="width: 100%"
/>
</n-form-item>
<n-form-item label="任务超时时间" path="timeoutMinutes">
<n-input-number
v-model:value="accountForm.timeoutMinutes"
:min="1"
:max="60"
placeholder="10"
style="width: 100%"
/>
<template #suffix>
<span style="margin-left: 8px">分钟</span>
</template>
</n-form-item>
<n-form-item label="是否启用" path="enable">
<n-switch
v-model:value="accountForm.enable"
:checked-value="true"
:unchecked-value="false"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<!-- 用户Agent单独一行 -->
<n-form-item label="用户Agent" path="userAgent">
<n-input
v-model:value="accountForm.userAgent"
placeholder="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
clearable
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm" :loading="saving">
{{ isEdit ? '保存修改' : '创建账号' }}
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup>
import { ref, watch } from 'vue'
import { nanoid } from 'nanoid'
import { ValidateErrorString } from '@/define/Tools/validate'
import { isEmpty } from 'lodash'
import { define } from '@/define/define'
import { useSoftwareStore } from '@/renderer/src/stores'
const softwareStore = useSoftwareStore()
// message
const message = useMessage()
// props
const props = defineProps({
type: {
type: String,
default: 'remote'
},
visible: {
type: Boolean,
default: false
},
localInfo: {
type: Object,
default: () => ({})
},
accountData: {
type: Object,
default: null
},
isEdit: {
type: Boolean,
default: false
}
})
// emits
const emit = defineEmits(['update:visible', 'update-success', 'cancel', 'add-success'])
//
const accountFormRef = ref(null)
const saving = ref(false)
//
const showDialog = ref(false)
// visible
watch(
() => props.visible,
(newVal) => {
showDialog.value = newVal
if (newVal) {
initFormData()
}
}
)
//
watch(showDialog, (newVal) => {
if (!newVal) {
emit('update:visible', false)
}
})
//
const accountForm = ref({
id: '',
accountId: '',
channelId: '',
coreSize: 3,
guildId: '',
enable: true,
mjBotChannelId: '',
nijiBotChannelId: '',
queueSize: 10,
remark: '',
timeoutMinutes: 10,
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
userToken: '',
createTime: null,
updateTime: null
})
//
const accountRules = {
guildId: {
required: true,
message: '请填写服务器ID',
trigger: ['blur', 'input']
},
channelId: {
required: true,
message: '请填写频道ID',
trigger: ['blur', 'input']
},
userToken: {
required: true,
message: '请填写用户token',
trigger: ['blur', 'input']
},
coreSize: {
required: true,
type: 'number',
message: '请填写账号并发数',
trigger: ['blur', 'change']
},
queueSize: {
required: true,
type: 'number',
message: '请填写等待队列大小',
trigger: ['blur', 'change']
},
timeoutMinutes: {
required: true,
type: 'number',
message: '请填写任务超时时间',
trigger: ['blur', 'change']
},
userAgent: {
required: true,
message: '请填写用户Agent',
trigger: ['blur', 'input']
}
}
//
const initFormData = () => {
if (props.isEdit && props.accountData) {
// -
accountForm.value = {
...props.accountData,
updateTime: new Date()
}
} else {
// -
accountForm.value = {
id: nanoid(),
accountId: '',
channelId: '',
coreSize: 3,
guildId: '',
enable: true,
mjBotChannelId: '',
nijiBotChannelId: '',
queueSize: 10,
remark: '',
timeoutMinutes: 10,
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
userToken: '',
createTime: new Date(),
updateTime: new Date()
}
}
}
// mj
const addRemoteMJSetting = async (accountInfo) => {
debugger
if (props.type != 'remote' && props.type != 'local') {
throw new Error('没有指定类型,请检查')
}
//
if (
isEmpty(accountInfo.channelId) ||
isEmpty(accountInfo.guildId) ||
isEmpty(accountInfo.userToken)
) {
throw new Error('必填字段服务器ID频道ID用户token不能为空')
}
if (
accountInfo.coreSize == null ||
accountInfo.queueSize == null ||
accountInfo.timeoutMinutes == null
) {
throw new Error('必填字段核心线程数,队列大小,超时时间不能为空')
}
if (!accountInfo.userAgent) {
accountInfo.userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
}
//
let createUrl = undefined
if (props.type == 'remote') {
createUrl = define.remotemj_api + 'mj/account/create'
} else if (props.type == 'local') {
let requestUrl = props.localInfo.requestUrl
if (requestUrl == null || isEmpty(requestUrl)) {
throw new Error('本地代理模式的请求地址不能为空')
}
requestUrl.endsWith('/') && (requestUrl = requestUrl.slice(0, -1))
createUrl = `${requestUrl}` + '/mj/admin/account'
} else {
throw new Error('没有指定类型,请检查')
}
//
let remoteData = {
channelId: accountInfo.channelId,
guildId: accountInfo.guildId,
userToken: accountInfo.userToken,
coreSize: accountInfo.coreSize,
queueSize: accountInfo.queueSize,
timeoutMinutes: accountInfo.timeoutMinutes,
userAgent: accountInfo.userAgent
? accountInfo.userAgent
: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
remark: softwareStore.authorization.machineId,
remixAutoSubmit: true
}
//
if (accountInfo.mjBotChannelId) {
remoteData.mjBotChannelId = accountInfo.mjBotChannelId
}
if (accountInfo.nijiBotChannelId) {
remoteData.nijiBotChannelId = accountInfo.nijiBotChannelId
}
if (accountInfo.accountId) {
remoteData.accountId = accountInfo.accountId
}
if (!isEmpty(accountInfo.blockMessage)) {
remoteData.blockMessage = accountInfo.blockMessage
}
if (accountInfo.hasOwnProperty('enable')) {
remoteData.enable = accountInfo.enable
}
//
let token = define.remote_token
if (props.type == 'local') {
if (props.localInfo.token == null || isEmpty(props.localInfo.token)) {
throw new Error('本地代理模式的访问令牌不能为空')
}
token = props.localInfo.token
}
let accountRes = await window.axios.post(createUrl, remoteData, {
headers: {
'mj-api-secret': token
}
})
console.log(accountRes)
if (!accountRes.success) {
throw new Error(accountRes.error)
}
if (props.type == 'local' && !accountRes.data.success) {
throw new Error(accountRes.data.message)
}
if (props.type == 'remote' && accountRes.data.code != 1) {
throw new Error(accountRes.data.description)
}
// ()s
let accountId = accountRes.data.result
remoteData.accountId = accountId
remoteData.remixAutoSubmit = true
remoteData.type = props.type
//
emit('add-success', remoteData)
}
const updateRemoteMJSetting = async (accountInfo) => {
if (isEmpty(accountInfo.accountId)) {
throw new Error('修改不能没有账号实例ID')
}
if (
isEmpty(accountInfo.channelId) ||
isEmpty(accountInfo.guildId) ||
isEmpty(accountInfo.userToken)
) {
throw new Error('必填字段服务器ID频道ID用户token不能为空')
}
if (
accountInfo.coreSize == null ||
accountInfo.queueSize == null ||
accountInfo.timeoutMinutes == null
) {
throw new Error('必填字段核心线程数,队列大小,超时时间不能为空')
}
if (!accountInfo.userAgent) {
accountInfo.userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
}
//
let updateUrl = undefined
if (props.type == 'remote') {
updateUrl = define.remotemj_api + `mj/account/${accountInfo.accountId}/update-reconnect`
} else if (props.type == 'local') {
let requestUrl = props.localInfo.requestUrl
if (requestUrl == null || isEmpty(requestUrl)) {
throw new Error('本地代理模式的请求地址不能为空')
}
requestUrl.endsWith('/') && (requestUrl = requestUrl.slice(0, -1))
updateUrl = `${requestUrl}/mj/admin/account-reconnect/${accountInfo.accountId}`
} else {
throw new Error('暂不支持的MJ类型请检查')
}
//
let remoteData = {
channelId: accountInfo.channelId,
coreSize: accountInfo.coreSize,
enable: accountInfo.enable,
guildId: accountInfo.guildId,
id: accountInfo.accountId,
mjBotChannelId: accountInfo.mjBotChannelId ? accountInfo.mjBotChannelId : '',
nijiBotChannelId: accountInfo.nijiBotChannelId ? accountInfo.nijiBotChannelId : '',
queueSize: accountInfo.queueSize,
remark: softwareStore.authorization.machineId,
remixAutoSubmit: true,
timeoutMinutes: accountInfo.timeoutMinutes ? accountInfo.timeoutMinutes : 10,
userAgent: accountInfo.userAgent,
userToken: accountInfo.userToken,
weight: 1
}
let token = define.remote_token
if (props.type == 'local') {
let localRemoteToken = props.localInfo.token
if (localRemoteToken == null || isEmpty(localRemoteToken)) {
throw new Error('本地代理模式的访问令牌不能为空')
}
token = localRemoteToken
remoteData.enableMj = true
remoteData.enableNiji = true
}
let accountRes = await window.axios.put(updateUrl, remoteData, {
headers: {
'mj-api-secret': token
}
})
if (!accountRes.success) {
const errorMsg = accountRes.error || '网络请求失败'
throw new Error(errorMsg)
}
if (props.type == 'local' && !accountRes.data.success) {
throw new Error(accountRes.data.message)
}
if (props.type == 'remote' && accountRes.data.code != 1) {
throw new Error(accountRes.data.description)
}
//
remoteData.accountId = accountInfo.accountId
remoteData.id = accountInfo.id
remoteData.updateTime = new Date()
remoteData.type = props.type
emit('update-success', remoteData)
}
//
const handleConfirm = async () => {
try {
debugger
saving.value = true
//
await accountFormRef.value?.validate()
let accountInfo = { ...accountForm.value }
if (props.isEdit) {
await updateRemoteMJSetting(accountInfo)
} else {
await addRemoteMJSetting(accountInfo)
}
message.success(props.isEdit ? '账号修改成功' : '账号创建成功')
showDialog.value = false
} catch (error) {
//
if (error && Array.isArray(error)) {
let errorMessage = ValidateErrorString(error)
message.error('表单验证失败: ' + errorMessage)
} else {
message.error('操作失败: ' + (error.message || error))
}
} finally {
saving.value = false
}
}
//
const handleCancel = () => {
showDialog.value = false
emit('cancel')
}
</script>
<style scoped>
:deep(.n-form-item-label) {
font-weight: 500;
}
:deep(.n-input-number) {
width: 100%;
}
</style>

View File

@ -0,0 +1,203 @@
<template>
<div>
<n-divider title-placement="left"> API 设置 </n-divider>
<!-- API模式设置部分 -->
<n-form
ref="apiFormRef"
:model="apiSettings"
:rules="apiRules"
label-placement="left"
label-width="auto"
:style="{
maxWidth: '720px',
minWidth: '400px'
}"
>
<n-form-item label="出图API" path="apiUrl">
<n-select
v-model:value="apiSettings.apiUrl"
style="margin-right: 6px"
placeholder="请选择API地址"
:options="getAPIOptions('mj')"
/>
<n-button type="primary" @click="buyApi">购买API</n-button>
</n-form-item>
<n-form-item label="出图API" path="apiUrl">
<n-select v-model:value="apiSettings.apiSpeed" :options="getMJSpeedOptions()" />
</n-form-item>
<n-form-item label="API密钥" path="apiKey">
<n-input
v-model:value="apiSettings.apiKey"
placeholder="请输入API密钥"
:type="showApiKey ? 'text' : 'password'"
clearable
>
<template #suffix>
<n-button text @click="toggleApiKeyVisibility">
{{ showApiKey ? '隐藏' : '显示' }}
</n-button>
</template>
</n-input>
</n-form-item>
</n-form>
<NotesCollapse title="注意事项">
<p>
1. 使用
<strong>无需科学上网</strong>支持香港和美国节点香港节点对大陆做了优化延迟
<strong>100ms</strong> 以内
</p>
<p>2. 提供 <strong>快速</strong> <strong>慢速</strong> 两种出图方式可根据需求选择</p>
<p>3. 支持 <strong>20并发请求</strong>可同时处理多张图片生成任务大大提高工作效率</p>
<p>4. 开启 <strong>"国内转发"</strong> 选项可解决部分地区如河南福建等的网络访问问题</p>
<p>
5. 确保网络环境稳定以保证服务正常运行推荐稳定🪜:
<span
class="clickable-link"
@click="openExternalLink('https://justmysocks.net/members/aff.php?aff=17835')"
>
Just My Socks网络加速服务
</span>
</p>
</NotesCollapse>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getMJSpeedOptions } from '@/define/data/mjData'
import { GetApiDefineDataById, getAPIOptions } from '@/define/data/apiData'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { mjApiSettings } from '@/renderer/src/common/initialData'
import { ValidateJsonAndParse } from '@/define/Tools/validate'
// message
const message = useMessage()
//
const apiFormRef = ref(null)
// API
const apiSettings = ref({
...mjApiSettings
})
// API
const apiRules = {
apiUrl: {
required: true,
message: '请选择出图API',
trigger: ['blur', 'change']
},
apiKey: {
required: true,
message: '请输入API密钥',
trigger: ['blur', 'change']
},
apiSpeed: {
required: true,
message: '请选择出图速度',
trigger: ['blur', 'change']
}
}
// API
const showApiKey = ref(false)
const toggleApiKeyVisibility = () => {
showApiKey.value = !showApiKey.value
}
// openExternalLink
const openExternalLink = (url) => {
window.open(url, '_blank')
}
// API
const buyApi = () => {
try {
//
let selectAPIData = GetApiDefineDataById(apiSettings.value.apiUrl)
if (selectAPIData == null || selectAPIData.buy_url == null) {
message.error('购买链接不存在,请联系管理员')
return
}
window.system.OpenUrl(selectAPIData.buy_url)
} catch (error) {
message.error(error.message)
}
}
// API
const loadApiSettings = async () => {
try {
let mjApiSettingOptions = await window.option.GetOptionByKey(
OptionKeyName.Midjourney.ApiSetting
)
if (mjApiSettingOptions.code != 1) {
message.error(mjApiSettingOptions.message)
return
}
apiSettings.value = ValidateJsonAndParse(mjApiSettingOptions.data.value)
message.success('API设置加载成功')
} catch (error) {
message.error('加载API设置失败: ' + error.message)
}
}
// API
const saveApiSettings = async () => {
try {
await apiFormRef.value?.validate()
let res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.ApiSetting,
JSON.stringify(apiSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存API设置失败: ' + res.message)
return false
}
return true
} catch (error) {
message.error('保存API设置失败: ' + error.message)
return false
}
}
// API
const validateApiSettings = async () => {
try {
await apiFormRef.value?.validate()
return true
} catch (error) {
return false
}
}
//
defineExpose({
saveApiSettings,
validateApiSettings,
loadApiSettings,
apiSettings
})
//
onMounted(() => {
loadApiSettings()
})
</script>
<style scoped>
.clickable-link {
color: #007bff;
cursor: pointer;
text-decoration: underline;
}
.clickable-link:hover {
color: #0056b3;
}
</style>

View File

@ -0,0 +1,735 @@
<template>
<div>
<n-divider title-placement="left">本地代理模式设置-自行部署服务</n-divider>
<!-- 本地代理模式设置表单 -->
<n-form
ref="localFormRef"
:model="localSettings"
:rules="localRules"
label-placement="left"
label-width="auto"
:style="{
maxWidth: '720px',
minWidth: '400px'
}"
>
<n-form-item label="请求地址" path="requestUrl">
<n-input v-model:value="localSettings.requestUrl" placeholder="请输入请求地址" clearable />
</n-form-item>
<n-form-item label="访问令牌" path="token">
<div style="display: flex; gap: 12px; align-items: flex-end; width: 100%;">
<div style="flex: 1; min-width: 0;">
<n-input
v-model:value="localSettings.token"
placeholder="admin123"
clearable
style="width: 100%;"
/>
</div>
<div style="flex-shrink: 0;">
<n-space>
<TooltipButton type="primary" @click="addAccount" tooltip="添加一个新的账号">
新增账号
</TooltipButton>
<TooltipButton
type="info"
@click="syncServerAccountToLocal"
tooltip="同步服务器中的账号信息到本地"
>
同步账号
</TooltipButton>
</n-space>
</div>
</div>
</n-form-item>
</n-form>
<!-- 账号列表表格 -->
<n-divider title-placement="left">账号列表</n-divider>
<n-data-table
:columns="tableColumns"
:data="localSettings.accountList"
:pagination="false"
:scroll-x="1200"
class="account-table"
/>
<!-- 注意事项折叠面板 -->
<NotesCollapse title="注意事项">
<p>
1. 本地代理模式支持本地部署和服务器部署需要自己搭建部署<span
class="clickable-link"
@click="openExternalLink('https://github.com/novicezk/midjourney-proxy')"
>全量代理模式部署</span
>
</p>
<p>2. 访问令牌默认为 <strong>admin</strong>如有修改请相应更新</p>
<p>3. 通过账号管理功能可添加多个MJ账号实现并行处理提高效率</p>
<p>
4. 确保网络环境稳定以保证服务正常运行推荐稳定🪜:
<span
class="clickable-link"
@click="openExternalLink('https://justmysocks.net/members/aff.php?aff=17835')"
>Just My Socks网络加速服务</span
>
</p>
</NotesCollapse>
<!-- 账号管理对话框 -->
<MJAccountDialog
v-model:visible="showAccountDialog"
:account-data="currentAccountData"
:is-edit="isEditMode"
:local-info="{ requestUrl: getRequestUrl(), token: localSettings.token }"
type="local"
@add-success="handleAccountAddSuccess"
@update-success="handleAccountUpdateSuccess"
@cancel="handleAccountCancel"
/>
</div>
</template>
<script setup>
import { ref, onMounted, h } from 'vue'
import { NButton, NSpace, NTooltip } from 'naive-ui'
import NotesCollapse from '@/renderer/src/components/common/NotesCollapse.vue'
import TooltipButton from '@/renderer/src/components/common/TooltipButton.vue'
import MJAccountDialog from './MJAccountDialog.vue'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { optionSerialization } from '@/main/service/option/optionSerialization'
import { mjLocalSetting } from '@/renderer/src/common/initialData'
import { isEmpty, max } from 'lodash'
import { useSoftwareStore } from '@/renderer/src/stores'
import { nanoid } from 'nanoid'
import { ValidateErrorString } from '@/define/Tools/validate'
// message
const message = useMessage()
const dialog = useDialog()
// emits
const emit = defineEmits(['local-settings-loaded'])
const softwareStore = useSoftwareStore()
//
const localFormRef = ref(null)
//
const localSettings = ref({
requestUrl: 'http://127.0.0.1:8080',
token: 'admin123',
accountList: []
})
//
const showAccountDialog = ref(false)
const currentAccountData = ref(null)
const isEditMode = ref(false)
//
const tableColumns = [
{
title: '服务器ID',
key: 'guildId',
width: 200,
ellipsis: {
tooltip: true
}
},
{
title: '频道ID',
key: 'channelId',
width: 200,
ellipsis: {
tooltip: true
}
},
{
title: '状态',
key: 'enable',
width: 80,
render(row) {
// blockMessage tooltip
if (!row.enable && row.blockMessage) {
return h(
NTooltip,
{
trigger: 'hover',
style: {
color: '#d03050'
}
},
{
trigger: () =>
h(
'span',
{
style: {
color: 'red',
cursor: 'help'
}
},
'禁用'
),
default: () => row.blockMessage
}
)
}
//
return h(
'span',
{
style: {
color: row.enable ? '#18a058' : '#d03050'
}
},
row.enable ? '启用' : '禁用'
)
}
},
{
title: 'MJ私信ID',
key: 'mjBotChannelId',
width: 150,
ellipsis: {
tooltip: true
},
render(row) {
return row.mjBotChannelId || '-'
}
},
{
title: 'Niji私信ID',
key: 'nijiBotChannelId',
width: 150,
ellipsis: {
tooltip: true
},
render(row) {
return row.nijiBotChannelId || '-'
}
},
{
title: '并发/队列',
key: 'settings',
width: 120,
render(row) {
return `${row.coreSize}/${row.queueSize}`
}
},
{
title: '备注',
key: 'remark',
width: 120,
ellipsis: {
tooltip: true
},
render(row) {
return row.remark || '-'
}
},
{
title: '操作',
key: 'operation',
width: 200,
render(row, index) {
return h(NSpace, { class: 'operation-buttons' }, [
h(
TooltipButton,
{
tooltip: '编辑当前账号信息',
size: 'small',
type: 'info',
onClick: () => editAccount(row, index)
},
{ default: () => '编辑' }
),
h(
TooltipButton,
{
tooltip: '删除本地账号信息,对服务器信息不会有影响',
size: 'small',
type: 'error',
onClick: () => deleteLocalAccount(row)
},
{ default: () => '删除本地账号' }
),
h(
TooltipButton,
{
tooltip: '删除服务账号信息,会同步删除本地账号信息',
size: 'small',
type: 'error',
onClick: () => deleteServerAccount(row)
},
{ default: () => '删除服务账号' }
)
])
}
}
]
//
const localRules = {
requestUrl: {
required: true,
message: '请填写服务地址',
trigger: ['blur', 'input']
},
token: {
required: true,
message: '请填写访问令牌',
trigger: ['blur', 'input']
}
}
// openExternalLink
const openExternalLink = (url) => {
window.open(url, '_blank')
}
//
const getRequestUrl = () => {
return localSettings.value.requestUrl || 'http://127.0.0.1:8080'
}
//
const addAccount = () => {
try {
currentAccountData.value = null
isEditMode.value = false
showAccountDialog.value = true
} catch (error) {
message.error('打开新增账号失败: ' + error.message)
}
}
//
const syncServerAccountToLocal = () => {
try {
//
dialog.warning({
title: '确认同步账号',
content: '确定要从服务器同步账号信息到本地吗?此操作会覆盖本地的账号列表,是否继续?',
positiveText: '确认同步',
negativeText: '取消',
onPositiveClick: async () => {
try {
let content = undefined
debugger
//
if (isEmpty(localSettings.value.requestUrl) || isEmpty(localSettings.value.token)) {
throw new Error('没有配置本地代理模式的基本信息,请检查请求地址和访问令牌')
}
//
let baseUrl = localSettings.value.requestUrl.endsWith('/')
? localSettings.value.requestUrl.slice(0, -1)
: localSettings.value.requestUrl
//
let url = `${baseUrl}/mj/admin/accounts`
let res = await window.axios.post(
url,
{
pagination: {
current: 1,
pageSize: 200
},
sort: {
predicate: '',
reverse: false
},
search: {
current: 1,
pageSize: 10,
pageNumber: 0
}
},
{
headers: {
'mj-api-secret': localSettings.value.token
}
}
)
console.log('syncServerAccountToLocal', res)
if (!res.success) {
throw new Error(res.error || '请求服务器账号列表失败')
}
if (!res.data || !res.data.list) {
throw new Error('远程服务器返回数据格式错误')
}
content = res.data.list
//
let localAccountList = []
for (let serverAccount of content) {
let localAccount = {
id: nanoid(),
accountId: serverAccount.id,
channelId: serverAccount.channelId,
coreSize: serverAccount.coreSize || 3,
guildId: serverAccount.guildId,
enable: serverAccount.enable !== false,
mjBotChannelId: serverAccount.mjBotChannelId || '',
nijiBotChannelId: serverAccount.nijiBotChannelId || '',
queueSize: serverAccount.queueSize || 10,
remark: serverAccount.remark || softwareStore.authorization.machineId,
timeoutMinutes: serverAccount.timeoutMinutes || 10,
userAgent:
serverAccount.userAgent ||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
userToken: serverAccount.userToken || '',
remixAutoSubmit: serverAccount.remixAutoSubmit || false,
blockMessage:
serverAccount.properties?.disabledReason || serverAccount.disabledReason || '',
createTime: new Date(),
updateTime: new Date()
}
localAccountList.push(localAccount)
}
//
localSettings.value.accountList = localAccountList
await saveLocalSettings()
message.success(`同步完成!已同步 ${localAccountList.length} 个账号`)
} catch (error) {
message.error('同步账号失败: ' + (error.message || error))
}
}
})
} catch (error) {
message.error('同步账号失败: ' + error.message)
}
}
//
const editAccount = (row, index) => {
try {
currentAccountData.value = { ...row }
isEditMode.value = true
showAccountDialog.value = true
} catch (error) {
message.error('打开编辑账号失败: ' + error.message)
}
}
//
const removeLocalAccountRecord = async (accountInfo) => {
try {
//
let findIndex = localSettings.value.accountList.findIndex((item) => item.id === accountInfo.id)
if (findIndex === -1) {
message.error('未找到对应的账号,无法删除')
return false
}
localSettings.value.accountList.splice(findIndex, 1)
await saveLocalSettings()
return true
} catch (error) {
message.error('删除本地账号失败: ' + error.message)
return false
}
}
//
async function deleteLocalAccount(accountInfo) {
console.log('Deleting local account:', accountInfo)
//
if (accountInfo == null || accountInfo.id == null) {
message.error('无法删除,账号信息无效')
return
}
//
dialog.warning({
title: '确认删除本地账号',
content: `确定要删除服务器ID为 "${accountInfo.guildId}" 的本地账号吗?此操作不会影响服务器账号,只会删除本地记录。`,
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: async () => {
const success = await removeLocalAccountRecord(accountInfo)
if (success) {
message.success('删除本地账号成功')
}
}
})
}
//
async function deleteServerAccount(accountInfo) {
console.log('Deleting server account:', accountInfo)
//
if (accountInfo == null || accountInfo.accountId == null) {
message.error('无法删除服务器账号账号信息无效或缺少服务器账号ID')
return
}
//
dialog.warning({
title: '确认删除服务器账号',
content: `确定要删除服务器ID为 "${accountInfo.guildId}" 的服务器账号吗?此操作会从服务器删除账号,并同时删除本地记录,操作不可撤销!`,
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
debugger
//
if (isEmpty(localSettings.value.requestUrl) || isEmpty(localSettings.value.token)) {
throw new Error('没有配置本地代理模式的基本信息,请检查请求地址和访问令牌')
}
//
let baseUrl = localSettings.value.requestUrl.endsWith('/')
? localSettings.value.requestUrl.slice(0, -1)
: localSettings.value.requestUrl
// URL
let deleteUrl = `${baseUrl}/mj/admin/account/${accountInfo.accountId}`
//
let deleteRes = await window.axios.delete(deleteUrl, {
headers: {
'mj-api-secret': localSettings.value.token
}
})
if (deleteRes.status != 200) {
throw new Error(deleteRes.statusText || '删除服务器账号请求失败')
}
if (!deleteRes.data || deleteRes.data.success === false) {
throw new Error(deleteRes.data?.message || '服务器删除账号失败')
}
//
const success = await removeLocalAccountRecord(accountInfo)
if (success) {
message.success('删除服务器账号成功')
}
} catch (error) {
message.error('删除服务器账号失败: ' + (error.message || error))
}
}
})
}
//
const handleAccountAddSuccess = async (accountData) => {
try {
if (
isEmpty(accountData.channelId) ||
isEmpty(accountData.guildId) ||
isEmpty(accountData.userToken)
) {
throw new Error('本地代理模式的频道ID服务器ID用户Token必填')
}
let defaultSetting = {
coreSize: 3,
mjBotChannelId: null,
nijiBotChannelId: null,
queueSize: 5,
remark: softwareStore.authorization.machineId,
remixAutoSubmit: false,
timeoutMinutes: 6,
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
}
//
accountData = Object.assign(defaultSetting, accountData)
accountData.id = nanoid()
accountData.createTime = new Date()
accountData.updateTime = new Date()
accountData.remark = softwareStore.authorization.machineId
if (accountData.hasOwnProperty('enable') == false) {
accountData.enable = true
}
localSettings.value.accountList.push(accountData)
await saveLocalSettings()
message.success('账号添加成功')
} catch (error) {
message.error('添加账号失败: ' + error.message)
}
}
//
const handleAccountUpdateSuccess = async (accountData) => {
try {
if (isEmpty(accountData.id)) {
throw new Error('更改本地代理模式配置ID不能为空')
}
if (
isEmpty(accountData.channelId) ||
isEmpty(accountData.guildId) ||
isEmpty(accountData.userToken)
) {
throw new Error('本地代理模式的账号ID服务ID频道ID用户Token不能为空')
}
if (
accountData.coreSize == null ||
accountData.queueSize == null ||
accountData.timeoutMinutes == null
) {
throw new Error('核心数量,队列数量,超时时间不能为空')
}
accountData.updateTime = new Date()
accountData.remark = softwareStore.authorization.machineId
let findIndex = localSettings.value.accountList.findIndex((item) => item.id === accountData.id)
if (findIndex == -1) {
message.error('未找到对应的账号,无法更新')
return
}
//
localSettings.value.accountList[findIndex] = accountData
//
await saveLocalSettings()
message.success('账号修改成功')
} catch (error) {
message.error('保存账号更新失败: ' + error.message)
}
}
//
const handleAccountCancel = () => {
currentAccountData.value = null
isEditMode.value = false
}
//
const loadLocalSettings = async () => {
try {
//
let localSettingOptions = await window.option.GetOptionByKey(
OptionKeyName.Midjourney.LocalSetting
)
if (localSettingOptions.code != 1) {
message.error(localSettingOptions.message)
return
}
localSettings.value = optionSerialization(localSettingOptions.data)
//
if (!localSettings.value.requestUrl) {
localSettings.value.requestUrl = 'http://127.0.0.1:8080'
}
if (!localSettings.value.port) {
localSettings.value.port = '8080'
}
if (!localSettings.value.token) {
localSettings.value.token = 'admin123'
}
if (!localSettings.value.accountList) {
localSettings.value.accountList = []
}
message.success('本地代理模式设置加载成功')
} catch (error) {
message.error('加载本地代理模式设置失败: ' + error.message)
}
}
//
const saveLocalSettings = async () => {
try {
//
await localFormRef.value?.validate()
//
let res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.LocalSetting,
JSON.stringify(localSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存本地代理模式设置失败: ' + res.message)
return false
}
message.success('本地代理模式设置保存成功')
return true
} catch (error) {
//
if (error && Array.isArray(error)) {
let errorMessage = ValidateErrorString(error)
message.error('表单验证失败: ' + errorMessage)
} else {
message.error('保存本地代理模式设置失败: ' + (error.message || error))
}
return false
}
}
//
const validateLocalSettings = async () => {
try {
await localFormRef.value?.validate()
return true
} catch (error) {
return false
}
}
//
defineExpose({
saveLocalSettings,
validateLocalSettings,
loadLocalSettings,
localSettings
})
//
onMounted(() => {
loadLocalSettings()
})
</script>
<style scoped>
.clickable-link {
color: #007bff;
cursor: pointer;
text-decoration: underline;
}
.clickable-link:hover {
color: #0056b3;
}
.account-table-container {
margin: 20px 0;
}
.account-table {
border-radius: 6px;
overflow: hidden;
}
.operation-buttons {
display: flex;
gap: 8px;
}
</style>

View File

@ -0,0 +1,239 @@
<template>
<div>
<n-divider title-placement="left">生图包设置</n-divider>
<!-- 生图包设置表单 -->
<n-form
ref="packageFormRef"
:model="packageSettings"
:rules="packageRules"
label-placement="left"
label-width="auto"
:style="{
maxWidth: '720px',
minWidth: '400px'
}"
>
<n-form-item label="生图包选择" path="selectPackage">
<n-select
v-model:value="packageSettings.selectPackage"
style="margin-right: 6px"
placeholder="请选择生图包"
:options="getAPIOptions('mj_package')"
/>
<n-button type="primary" @click="buyPackage">购买</n-button>
<n-button type="primary" @click="queryPackage" style="margin-left: 6px">查询</n-button>
</n-form-item>
<n-form-item label="Token" path="packageToken">
<n-input
v-model:value="packageSettings.packageToken"
placeholder="请输入Token"
:type="showToken ? 'text' : 'password'"
clearable
>
<template #suffix>
<n-button text @click="toggleTokenVisibility">
{{ showToken ? '隐藏' : '显示' }}
</n-button>
</template>
</n-input>
</n-form-item>
</n-form>
<!-- 注意事项折叠面板 -->
<NotesCollapse title="注意事项">
<p>
1. 使用
<strong>无需科学上网</strong>全球加速访问延迟
<strong>30ms</strong> 以内国内外用户均可稳定使用
</p>
<p>2. 支持 <strong>定制套餐</strong>灵活的套餐选择可根据使用频率和需求定制专属套餐方案</p>
<p>3. <strong>出图稳定</strong>采用官方接口不会封号保障长期稳定使用</p>
</NotesCollapse>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import NotesCollapse from '@/renderer/src/components/common/NotesCollapse.vue'
import { GetApiDefineDataById, getAPIOptions } from '@/define/data/apiData'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { optionSerialization } from '@/main/service/option/optionSerialization'
import { mjPackageSetting } from '@/renderer/src/common/initialData'
import { isEmpty } from 'lodash'
import { ValidateErrorString } from '@/define/Tools/validate'
// message
const message = useMessage()
//
const packageFormRef = ref(null)
//
const packageSettings = ref({
...mjPackageSetting
})
//
const packageRules = {
selectPackage: {
required: true,
message: '请选择生图包',
trigger: ['blur', 'change']
},
packageToken: {
required: true,
message: '请输入Token',
trigger: ['blur', 'change']
}
}
// Token
const showToken = ref(false)
const toggleTokenVisibility = () => {
showToken.value = !showToken.value
}
//
const buyPackage = () => {
try {
if (isEmpty(packageSettings.value.selectPackage)) {
message.error('请先选择生图包')
return
}
let packageItem = GetApiDefineDataById(packageSettings.value.selectPackage)
let buy_url = packageItem?.buy_url
//
message.success('跳转到购买页面')
window.system.OpenUrl(buy_url)
} catch (error) {
message.error('购买失败: ' + error.message)
}
}
//
const queryPackage = () => {
try {
if (isEmpty(packageSettings.value.selectPackage)) {
message.error('请先选择生图包')
return
}
let packageItem = GetApiDefineDataById(packageSettings.value.selectPackage)
let query_url = packageItem?.mj_url?.query_url
if (isEmpty(query_url)) {
message.error('该生图包不支持查询,请联系管理员')
return
}
window.system.OpenUrl(query_url)
message.success('已打开查询页面')
} catch (error) {
message.error('查询失败: ' + error.message)
}
}
//
const loadPackageSettings = async () => {
try {
//
let packageSettingOptions = await window.option.GetOptionByKey(
OptionKeyName.Midjourney.PackageSetting
)
if (packageSettingOptions.code != 1) {
message.error(packageSettingOptions.message)
return
}
packageSettings.value = optionSerialization(packageSettingOptions.data)
message.success('生图包设置加载成功')
if (isEmpty(packageSettings.value.selectPackage)) {
packageSettings.value.selectPackage = getAPIOptions('mj_package')[0]?.value || ''
}
} catch (error) {
message.error('加载生图包设置失败: ' + error.message)
}
}
//
const savePackageSettings = async () => {
try {
//
await packageFormRef.value?.validate()
//
if (isEmpty(packageSettings.value.selectPackage)) {
message.error('请选择生图包')
return false
}
if (isEmpty(packageSettings.value.packageToken)) {
message.error('请输入Token')
return false
}
//
let res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.PackageSetting,
JSON.stringify(packageSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存生图包设置失败: ' + res.message)
return false
}
message.success('生图包设置保存成功')
return true
} catch (error) {
//
if (error && Array.isArray(error)) {
let errorMessage = ValidateErrorString(error)
message.error('表单验证失败: ' + errorMessage)
} else {
message.error('保存生图包设置失败: ' + (error.message || error))
}
return false
}
}
//
const validatePackageSettings = async () => {
try {
await packageFormRef.value?.validate()
return true
} catch (error) {
return false
}
}
//
defineExpose({
savePackageSettings,
validatePackageSettings,
loadPackageSettings,
packageSettings
})
//
onMounted(() => {
loadPackageSettings()
})
</script>
<style scoped>
.clickable-link {
color: #007bff;
cursor: pointer;
text-decoration: underline;
}
.clickable-link:hover {
color: #0056b3;
}
</style>

View File

@ -0,0 +1,568 @@
<template>
<div>
<n-divider title-placement="left">代理模式设置</n-divider>
<!-- 代理模式设置表单 -->
<n-form
ref="remoteFormRef"
:model="remoteSettings"
:rules="remoteRules"
label-placement="left"
label-width="auto"
:style="{
maxWidth: '720px',
minWidth: '400px'
}"
>
<n-form-item label="是否国内转发" path="isForward">
<n-select
v-model:value="remoteSettings.isForward"
:options="getNationalRelayOptions()"
style="margin-right: 6px"
/>
<TooltipButton type="primary" @click="addAccount" tooltip="添加一个新的账号">
新增账号
</TooltipButton>
<TooltipButton
type="primary"
@click="syncAccount"
tooltip="同步服务器中的账号信息到本地"
style="margin-left: 8px"
>
同步账号
</TooltipButton>
</n-form-item>
</n-form>
<!-- 账号列表表格 -->
<n-divider title-placement="left">账号列表</n-divider>
<n-data-table
:columns="tableColumns"
:data="remoteSettings.accountList"
:pagination="false"
class="account-table"
/>
<!-- 注意事项折叠面板 -->
<NotesCollapse title="注意事项">
<p>1. 日常使用无需开启代理仅添加账号时需要网络代理</p>
<p>2. 通过 新增账号 可添加多个MJ账号实现并行处理提高效率</p>
<p>3. 开启 <strong>"国内转发"</strong> 选项可解决部分地区如河南福建等的网络访问问题</p>
<p>
4. 确保网络环境稳定以保证服务正常运行推荐稳定🪜:
<span
class="clickable-link"
@click="openExternalLink('https://justmysocks.net/members/aff.php?aff=17835')"
>
Just My Socks网络加速服务
</span>
</p>
</NotesCollapse>
<!-- 账号管理对话框 -->
<MJAccountDialog
v-model:visible="showAccountDialog"
:account-data="currentAccountData"
:is-edit="isEditMode"
@add-success="handleAccountAddSuccess"
@update-success="handleAccountUpdateSuccess"
@cancel="handleAccountCancel"
/>
</div>
</template>
<script setup>
import { ref, onMounted, h } from 'vue'
import { NButton } from 'naive-ui'
import NotesCollapse from '@/renderer/src/components/common/NotesCollapse.vue'
import TooltipButton from '@/renderer/src/components/common/TooltipButton.vue'
import MJAccountDialog from './MJAccountDialog.vue'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { optionSerialization } from '@/main/service/option/optionSerialization'
import { mjRemoteSetting } from '@/renderer/src/common/initialData'
import { isEmpty } from 'lodash'
import { useSoftwareStore } from '@/renderer/src/stores'
import { define } from '@/define/define'
// message
const message = useMessage()
const dialog = useDialog()
// emits
const emit = defineEmits(['remote-settings-loaded'])
const softwareStore = useSoftwareStore()
//
const remoteFormRef = ref(null)
//
const remoteSettings = ref({
...mjRemoteSetting
})
//
const showAccountDialog = ref(false)
const currentAccountData = ref(null)
const isEditMode = ref(false)
//
const tableColumns = [
{
title: '服务器ID',
key: 'guildId',
width: 200,
ellipsis: {
tooltip: true
}
},
{
title: '频道ID',
key: 'channelId',
width: 200,
ellipsis: {
tooltip: true
}
},
{
title: '状态',
key: 'enable',
width: 80,
render(row) {
return h(
'span',
{
style: {
color: row.enable ? '#18a058' : '#d03050'
}
},
row.enable ? '启用' : '禁用'
)
}
},
{
title: 'MJ私信ID',
key: 'mjBotChannelId',
width: 150,
ellipsis: {
tooltip: true
},
render(row) {
return row.mjBotChannelId || '-'
}
},
{
title: 'Niji私信ID',
key: 'nijiBotChannelId',
width: 150,
ellipsis: {
tooltip: true
},
render(row) {
return row.nijiBotChannelId || '-'
}
},
{
title: '并发/队列',
key: 'settings',
width: 120,
render(row) {
return `${row.coreSize}/${row.queueSize}`
}
},
{
title: '备注',
key: 'remark',
width: 120,
ellipsis: {
tooltip: true
},
render(row) {
return row.remark || '-'
}
},
{
title: '操作',
key: 'operation',
width: 200,
render(row, index) {
return h('div', { class: 'operation-buttons' }, [
h(
NButton,
{
size: 'small',
type: 'info',
style: 'margin-right: 8px',
onClick: () => editAccount(row, index)
},
{ default: () => '编辑' }
),
h(
NButton,
{
size: 'small',
type: 'error',
onClick: () => deleteAccount(row, index)
},
{ default: () => '删除' }
)
])
}
}
]
//
const editAccount = (row, index) => {
try {
currentAccountData.value = { ...row }
isEditMode.value = true
showAccountDialog.value = true
} catch (error) {
message.error('打开编辑账号失败: ' + error.message)
}
}
//
const deleteAccount = (row, index) => {
$dialog.warning({
title: '确认删除',
content: `确定要删除服务器ID为 "${row.guildId}" 的账号吗?此操作不可撤销。`,
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () => {
try {
remoteSettings.value.accountList.splice(index, 1)
message.success('账号删除成功')
//
saveRemoteSettings()
} catch (error) {
message.error('删除账号失败: ' + error.message)
}
}
})
}
//
const remoteRules = {
isForward: {
required: true,
type: 'boolean',
message: '请选择是否国内转发',
trigger: ['blur', 'change'],
validator: (rule, value) => {
if (value === undefined || value === null) {
return new Error('请选择是否国内转发')
}
return true
}
}
}
//
const getNationalRelayOptions = () => {
return [
{ label: '是', value: true },
{ label: '否', value: false }
]
}
// openExternalLink
const openExternalLink = (url) => {
window.open(url, '_blank')
}
//
const addAccount = () => {
try {
currentAccountData.value = null
isEditMode.value = false
showAccountDialog.value = true
} catch (error) {
message.error('打开新增账号失败: ' + error.message)
}
}
//
const syncAccount = () => {
try {
//
dialog.warning({
title: '同步账号确认',
content: '此操作将从远程代理服务器同步账号信息,会覆盖本地现有的账号配置。确定要继续吗?',
positiveText: '确认同步',
negativeText: '取消',
onPositiveClick: async () => {
try {
debugger
message.info('开始同步远程账号信息...')
let content = undefined
//
let url = define.remotemj_api + 'mj/account/query'
let res = await axios.post(
url,
{
remark: softwareStore.authorization.machineId,
current: 1,
pageNumber: 0,
pageSize: 30
},
{ headers: { 'mj-api-secret': define.remote_token } }
)
console.log('GetRemoteMJSettingsFromService', res)
if (res.status != 200) {
throw new Error(res.statusText)
}
if (!res.data || !res.data.content) {
throw new Error('远程服务器返回数据格式错误')
}
content = res.data.content
let newAccountList = []
//
for (let i = 0; i < content.length; i++) {
const serverAccount = content[i]
let localAccount = {
id: crypto.randomUUID(),
accountId: serverAccount.id,
channelId: serverAccount.channelId,
coreSize: serverAccount.coreSize || 3,
guildId: serverAccount.guildId,
enable: serverAccount.enable !== false,
mjBotChannelId: serverAccount.mjBotChannelId || '',
nijiBotChannelId: serverAccount.nijiBotChannelId || '',
queueSize: serverAccount.queueSize || 10,
remark: serverAccount.remark || softwareStore.authorization.machineId,
timeoutMinutes: serverAccount.timeoutMinutes || 10,
userAgent:
serverAccount.userAgent ||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
userToken: serverAccount.userToken || '',
remixAutoSubmit: serverAccount.remixAutoSubmit || false,
blockMessage:
serverAccount.properties?.disabledReason || serverAccount.disabledReason || '',
createTime: new Date(),
updateTime: new Date()
}
newAccountList.push(localAccount)
}
remoteSettings.value.accountList = newAccountList
await saveRemoteSettings()
message.success('同步账号信息成功')
} catch (error) {
message.error('同步账号失败: ' + error.message)
}
}
})
} catch (error) {
message.error('同步账号失败: ' + error.message)
}
}
//
const handleAccountAddSuccess = async (accountData) => {
try {
if (
isEmpty(accountData.channelId) ||
isEmpty(accountData.guildId) ||
isEmpty(accountData.userToken)
) {
throw new Error('代理模式的频道ID服务器ID用户Token必填')
}
let defaultSetting = {
coreSize: 3,
mjBotChannelId: null,
nijiBotChannelId: null,
queueSize: 5,
remark: global.machineId,
remixAutoSubmit: false,
timeoutMinutes: 6,
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
}
//
accountData = Object.assign(defaultSetting, accountData)
accountData.id = uuidv4()
accountData.createTime = new Date()
accountData.updateTime = new Date()
accountData.version = version
accountData.remark = global.machineId
if (accountData.hasOwnProperty('enable') == false) {
accountData.enable = true
}
remoteSettings.value.accountList.push(accountData)
await saveRemoteSettings()
} catch (error) {
message.error('添加账号失败: ' + error.message)
}
}
//
const handleAccountUpdateSuccess = async (accountData) => {
try {
if (isEmpty(accountData.id)) {
throw new Error('更改代理模式配置ID不能为空')
}
if (
isEmpty(accountData.channelId) ||
isEmpty(accountData.guildId) ||
isEmpty(accountData.userToken)
) {
throw new Error('代理模式的账号ID服务ID频道ID用户Token不能为空')
}
if (
accountData.coreSize == null ||
accountData.queueSize == null ||
accountData.timeoutMinutes == null
) {
throw new Error('核心数量,队列数量,超时时间不能为空')
}
accountData.updateTime = new Date()
accountData.version = version
accountData.remark = softwareStore.authorization.machineId
let findIndex = remoteSettings.value.accountList.findIndex((item) => item.id === accountData.id)
if (findIndex == -1) {
message.error('未找到对应的账号,无法更新')
return
}
//
remoteSettings.value.accountList[findIndex] = accountData
//
await saveRemoteSettings()
} catch (error) {
message.error('保存账号更新失败: ' + error.message)
}
}
//
const handleAccountCancel = () => {
currentAccountData.value = null
isEditMode.value = false
}
//
const loadRemoteSettings = async () => {
try {
//
let remoteSettingOptions = await window.option.GetOptionByKey(
OptionKeyName.Midjourney.RemoteSetting
)
if (remoteSettingOptions.code != 1) {
message.error(remoteSettingOptions.message)
return
}
remoteSettings.value = optionSerialization(remoteSettingOptions.data)
//
if (remoteSettings.value.isForward === undefined) {
remoteSettings.value.isForward = false
}
message.success('代理模式设置加载成功')
} catch (error) {
message.error('加载代理模式设置失败: ' + error.message)
}
}
//
const saveRemoteSettings = async () => {
try {
//
await remoteFormRef.value?.validate()
//
if (remoteSettings.value.isForward === undefined || remoteSettings.value.isForward === null) {
remoteSettings.value.isForward = false
}
//
let res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.RemoteSetting,
JSON.stringify(remoteSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存代理模式设置失败: ' + res.message)
return false
}
message.success('代理模式设置保存成功')
return true
} catch (error) {
//
if (error && Array.isArray(error)) {
const errorMessages = error.map((err) => err.message).join(', ')
message.error('表单验证失败: ' + errorMessages)
} else {
message.error('保存代理模式设置失败: ' + (error.message || error))
}
return false
}
}
//
const validateRemoteSettings = async () => {
try {
await remoteFormRef.value?.validate()
return true
} catch (error) {
return false
}
}
//
defineExpose({
saveRemoteSettings,
validateRemoteSettings,
loadRemoteSettings,
remoteSettings
})
//
onMounted(() => {
loadRemoteSettings()
})
</script>
<style scoped>
.clickable-link {
color: #007bff;
cursor: pointer;
text-decoration: underline;
}
.clickable-link:hover {
color: #0056b3;
}
.account-table-container {
margin: 20px 0;
}
.account-table {
border-radius: 6px;
overflow: hidden;
}
.operation-buttons {
display: flex;
gap: 8px;
}
</style>

View File

@ -15,11 +15,26 @@
minWidth: '400px' minWidth: '400px'
}" }"
> >
<n-form-item label="出图模式" path="outputMode"> <n-form-item label="出图方式" path="outputMode">
<n-select <div style="display: flex; gap: 12px; align-items: center; width: 100%">
v-model:value="generalSettings.outputMode" <div style="flex: 1; min-width: 0">
:options="getImageGenerateModeOptions()" <n-select
/> v-model:value="generalSettings.outputMode"
:options="getImageGenerateModeOptions()"
style="width: 100%"
/>
</div>
<div style="flex-shrink: 0">
<TooltipButton
tooltip="打开不同的出图方法的教程,根据选择的出图方式打开对应的教程"
type="primary"
@click="openTutorial"
:disabled="!generalSettings.outputMode"
>
查看教程
</TooltipButton>
</div>
</div>
</n-form-item> </n-form-item>
<n-form-item label="生图机器人" path="robot"> <n-form-item label="生图机器人" path="robot">
<n-select <n-select
@ -64,67 +79,36 @@
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-divider title-placement="left"> API 设置 </n-divider> <div v-if="loadReady">
<!-- API设置组件 -->
<MJApiSettings
v-if="selectOutputMode == 'mj_api'"
ref="mjApiSettingsRef"
@api-settings-loaded="handleApiSettingsLoaded"
/>
<!-- 生图包设置组件 -->
<MJPackageSetting
v-else-if="selectOutputMode == 'mj_package'"
ref="mjPackageSettingRef"
@package-settings-loaded="handlePackageSettingsLoaded"
/>
<!-- 代理模式设置组件 -->
<MJRemoteSetting
v-else-if="selectOutputMode == 'remote_mj'"
ref="mjRemoteSettingRef"
@remote-settings-loaded="handleRemoteSettingsLoaded"
/>
<!-- 本地代理模式设置组件 -->
<MJLocalSetting
v-else-if="selectOutputMode == 'local_mj'"
ref="mjLocalSettingRef"
@local-settings-loaded="handleLocalSettingsLoaded"
/>
</div>
<!-- API模式设置部分 -->
<n-form
v-if="loadReady"
ref="apiFormRef"
:model="apiSettings"
:rules="apiRules"
label-placement="left"
label-width="auto"
:style="{
maxWidth: '720px',
minWidth: '400px'
}"
>
<n-form-item label="出图API" path="apiUrl">
<n-select
v-model:value="apiSettings.apiUrl"
style="margin-right: 6px"
placeholder="请选择API地址"
:options="getAPIOptions('mj')"
/>
<n-button type="primary" @click="buyApi">购买API</n-button>
</n-form-item>
<n-form-item label="出图API" path="apiUrl">
<n-select v-model:value="apiSettings.apiSpeed" :options="getMJSpeedOptions()" />
</n-form-item>
<n-form-item label="API密钥" path="apiKey">
<n-input
v-model:value="apiSettings.apiKey"
placeholder="请输入API密钥"
:type="showApiKey ? 'text' : 'password'"
clearable
>
<template #suffix>
<n-button text @click="toggleApiKeyVisibility">
{{ showApiKey ? '隐藏' : '显示' }}
</n-button>
</template>
</n-input>
</n-form-item>
</n-form>
<NotesCollapse title="注意事项">
<p>
1. 使用
<strong>无需科学上网</strong>支持香港和美国节点香港节点对大陆做了优化延迟
<strong>100ms</strong> 以内
</p>
<p>2. 提供 <strong>快速</strong> <strong>慢速</strong> 两种出图方式可根据需求选择</p>
<p>3. 支持 <strong>20并发请求</strong>可同时处理多张图片生成任务大大提高工作效率</p>
<p>4. 开启 <strong>"国内转发"</strong> 选项可解决部分地区如河南福建等的网络访问问题</p>
<p>
5. 确保网络环境稳定以保证服务正常运行推荐稳定🪜:
<span
class="clickable-link"
@click="openExternalLink('https://justmysocks.net/members/aff.php?aff=17835')"
>
Just My Socks网络加速服务
</span>
</p>
</NotesCollapse>
<n-space justify="start" style="margin-top: 20px"> <n-space justify="start" style="margin-top: 20px">
<n-button type="primary" @click="saveSettings">保存设置</n-button> <n-button type="primary" @click="saveSettings">保存设置</n-button>
</n-space> </n-space>
@ -138,14 +122,19 @@ import {
getMJImageScaleOptions, getMJImageScaleOptions,
getMJRobotModelOptions, getMJRobotModelOptions,
getMJRobotOptions, getMJRobotOptions,
getMJSpeedOptions ImageGenerateMode
} from '@/define/data/mjData' } from '@/define/data/mjData'
import { GetApiDefineDataById, getAPIOptions } from '@/define/data/apiData'
import { OptionKeyName, OptionType } from '@/define/enum/option' import { OptionKeyName, OptionType } from '@/define/enum/option'
import { mjApiSettings, mjGeneralSettings } from '@/renderer/src/common/initialData' import { mjGeneralSettings } from '@/renderer/src/common/initialData'
import { useSoftwareStore } from '@/renderer/src/stores' import { useSoftwareStore } from '@/renderer/src/stores'
import { TimeDelay } from '@/define/Tools/time' import { TimeDelay } from '@/define/Tools/time'
import { ValidateJsonAndParse } from '@/define/Tools/validate' import { ValidateJsonAndParse } from '@/define/Tools/validate'
import MJApiSettings from './MJApiSettings.vue'
import MJPackageSetting from './MJPackageSetting.vue'
import MJRemoteSetting from './MJRemoteSetting.vue'
import MJLocalSetting from './MJLocalSetting.vue'
import { SoftwareData } from '@/define/data/softwareData'
import { isEmpty } from 'lodash'
// message // message
const message = useMessage() const message = useMessage()
@ -153,13 +142,20 @@ const softwareStore = useSoftwareStore()
// //
const formRef = ref(null) const formRef = ref(null)
const apiFormRef = ref(null) const mjApiSettingsRef = ref(null)
const mjPackageSettingRef = ref(null)
const mjRemoteSettingRef = ref(null)
const mjLocalSettingRef = ref(null)
// //
const generalSettings = ref({ const generalSettings = ref({
...mjGeneralSettings ...mjGeneralSettings
}) })
const selectOutputMode = computed(() => {
return generalSettings.value.outputMode
})
// //
const generalRules = { const generalRules = {
outputMode: { outputMode: {
@ -201,30 +197,6 @@ const generalRules = {
} }
} }
// API
const apiSettings = ref({
...mjApiSettings
})
// API
const apiRules = {
apiUrl: {
required: true,
message: '请选择出图API',
trigger: ['blur', 'change']
},
apiKey: {
required: true,
message: '请输入API密钥',
trigger: ['blur', 'change']
},
apiSpeed: {
required: true,
message: '请选择出图速度',
trigger: ['blur', 'change']
}
}
/** /**
* 获取生图方式选项 * 获取生图方式选项
*/ */
@ -259,15 +231,33 @@ let formatForm = () => {
generalSettings.value.commandSuffix = dd generalSettings.value.commandSuffix = dd
} }
// API //
const showApiKey = ref(false) const openTutorial = () => {
const toggleApiKeyVisibility = () => {
showApiKey.value = !showApiKey.value let url = undefined
} switch (generalSettings.value.outputMode) {
case ImageGenerateMode.MJ_API:
url = SoftwareData.mjDoc.mjAPIDoc
break
case ImageGenerateMode.MJ_PACKAGE:
url = SoftwareData.mjDoc.mjPackageDoc
break
case ImageGenerateMode.REMOTE_MJ:
url = SoftwareData.mjDoc.mjRemoteDoc
break
case ImageGenerateMode.LOCAL_MJ:
url = SoftwareData.mjDoc.mjLocalDoc
break
default:
url = undefined
}
// openExternalLink if (url == undefined || isEmpty(url)) {
const openExternalLink = (url) => { message.error('暂无该模式的教程')
window.open(url, '_blank') return
}
window.system.OpenUrl(url)
} }
let loadReady = ref(false) let loadReady = ref(false)
@ -289,18 +279,11 @@ const loadSettings = async () => {
} }
generalSettings.value = ValidateJsonAndParse(mjGeneralSettingOptions.data.value) generalSettings.value = ValidateJsonAndParse(mjGeneralSettingOptions.data.value)
let mjApiSettingOptions = await window.option.GetOptionByKey(
OptionKeyName.Midjourney.ApiSetting
)
if (mjApiSettingOptions.code != 1) {
message.error(mjApiSettingOptions.message)
return
}
apiSettings.value = ValidateJsonAndParse(mjApiSettingOptions.data.value)
message.success('加载设置成功') message.success('加载设置成功')
} catch (error) { } catch (error) {
message.error('加载设置失败: ' + error.message) message.error('加载设置失败: ' + error.message)
} finally { } finally {
console.log('111111111111')
// //
loadReady.value = true loadReady.value = true
softwareStore.spin.spinning = false softwareStore.spin.spinning = false
@ -308,59 +291,53 @@ const loadSettings = async () => {
} }
// //
const saveSettings = () => { const saveSettings = async () => {
//
Promise.all([formRef.value?.validate(), apiFormRef.value?.validate()])
.then(async () => {
try {
//
let res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.GeneralSetting,
JSON.stringify(generalSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存设置失败: ' + res.message)
return
}
res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.ApiSetting,
JSON.stringify(apiSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存设置失败: ' + res.message)
return
}
message.success('设置已保存')
} catch (error) {
message.error('保存设置失败: ' + error.message)
}
})
.catch((errors) => {
//
const errorMessages = Object.values(errors)
.map((err) => {
return err[0]?.message || '验证错误'
})
.join(' ')
message.error('请修正以下错误: ' + (errorMessages || errors.message))
})
}
// API
const buyApi = () => {
try { try {
//
let selectAPIData = GetApiDefineDataById(apiSettings.value.apiUrl) //
if (selectAPIData == null || selectAPIData.buy_url == null) { await formRef.value?.validate()
message.error('购买链接不存在,请联系管理员')
if (generalSettings.value.outputMode == ImageGenerateMode.MJ_API) {
// API
const apiSaveResult = await mjApiSettingsRef.value?.saveApiSettings()
if (!apiSaveResult) {
return
}
} else if (generalSettings.value.outputMode == ImageGenerateMode.MJ_PACKAGE) {
//
const packageSaveResult = await mjPackageSettingRef.value?.savePackageSettings()
if (!packageSaveResult) {
return
}
} else if (generalSettings.value.outputMode == ImageGenerateMode.REMOTE_MJ) {
//
const remoteSaveResult = await mjRemoteSettingRef.value?.saveRemoteSettings()
if (!remoteSaveResult) {
return
}
} else if (generalSettings.value.outputMode == ImageGenerateMode.LOCAL_MJ) {
//
const localSaveResult = await mjLocalSettingRef.value?.saveLocalSettings()
if (!localSaveResult) {
return
}
}
//
let res = await window.option.ModifyOptionByKey(
OptionKeyName.Midjourney.GeneralSetting,
JSON.stringify(generalSettings.value),
OptionType.JSON
)
if (res.code !== 1) {
message.error('保存设置失败: ' + res.message)
return return
} }
window.system.OpenUrl(selectAPIData.buy_url)
message.success('设置已保存')
} catch (error) { } catch (error) {
message.error(error.message) //
message.error('请修正表单错误后再保存')
} }
} }

View File

@ -9,7 +9,7 @@
<!-- 二维码图片容器 --> <!-- 二维码图片容器 -->
<div class="qrcode-container"> <div class="qrcode-container">
<img <img
src="../../assets//dev-user.jpg" src="../../assets//wechat-xiangbei.jpg"
alt="开发者微信二维码" alt="开发者微信二维码"
class="qrcode-image" class="qrcode-image"
@error="handleImageError" @error="handleImageError"

View File

@ -0,0 +1,632 @@
<template>
<div class="image-compress-home">
<!-- 页面标题 -->
<div class="page-header">
<h1>图片压缩工具</h1>
<p class="subtitle">快速压缩图片本地处理安全可靠</p>
</div>
<!-- 主要内容 -->
<div class="content">
<!-- 左侧面板 -->
<div class="left-panel">
<!-- 上传区域 -->
<n-upload
ref="uploadRef"
:max="1"
accept="image/*"
:default-upload="false"
@change="handleFileSelect"
:show-file-list="false"
>
<n-upload-dragger class="upload-section">
<div class="upload-content">
<div class="upload-icon">📁</div>
<h3>选择图片</h3>
<p>支持 JPGPNGWebP 格式</p>
<div class="or-text">点击选择或拖拽图片到此处</div>
</div>
</n-upload-dragger>
</n-upload>
<!-- 控制面板 -->
<n-card class="controls" size="small">
<!-- 尺寸设置 -->
<div class="control-group">
<h4>尺寸设置</h4>
<div class="slider-container">
<n-text>最大宽度: {{ widthValue }}px</n-text>
<n-slider
v-model:value="widthValue"
:min="100"
:max="2000"
:step="50"
@update:value="handleSliderChange"
/>
</div>
<div class="slider-container">
<n-text>最大高度: {{ heightValue }}px</n-text>
<n-slider
v-model:value="heightValue"
:min="100"
:max="2000"
:step="50"
@update:value="handleSliderChange"
/>
</div>
</div>
<!-- 压缩设置 -->
<div class="control-group">
<h4>压缩设置</h4>
<div class="slider-container">
<n-text>图片质量: {{ qualityValue }}%</n-text>
<n-slider
v-model:value="qualityValue"
:min="10"
:max="100"
:step="1"
@update:value="handleSliderChange"
/>
</div>
<div class="slider-container">
<n-text>输出格式</n-text>
<n-select
v-model:value="outputFormat"
:options="formatOptions"
@update:value="handleFormatChange"
/>
</div>
</div>
</n-card>
</div>
<!-- 右侧面板 -->
<div class="right-panel">
<div class="preview-section">
<!-- 原始图片预览 -->
<n-card class="preview-container" size="small">
<template #header>
<h4>原始图片</h4>
</template>
<div class="image-container">
<img v-if="originalImage" :src="originalImage" alt="原始图片" />
<div v-else class="placeholder">未选择图片</div>
</div>
</n-card>
<!-- 压缩后图片预览 -->
<n-card class="preview-container" size="small">
<template #header>
<h4>压缩后图片</h4>
</template>
<div class="image-container">
<img v-if="compressedImage" :src="compressedImage" alt="压缩后图片" />
<div v-else class="placeholder">
{{ isCompressing ? '压缩中...' : '等待压缩...' }}
</div>
</div>
</n-card>
<!-- 压缩信息 -->
<n-card class="info-card" size="small">
<template #header>
<h4>压缩信息</h4>
</template>
<div class="info-items">
<div class="info-item">
<span>原始大小:</span>
<span>{{ originalSizeText }}</span>
</div>
<div class="info-item">
<span>压缩后大小:</span>
<span>{{ compressedSizeText }}</span>
</div>
<div class="info-item">
<span>尺寸减少:</span>
<span>{{ sizeReductionText }}</span>
</div>
<div class="info-item">
<span>压缩比率:</span>
<span>{{ compressionRatioText }}</span>
</div>
</div>
<div class="size-reduction" v-if="compressionInfo.ratio > 0">
<n-text strong>
减少 {{ compressionInfo.ratio }}%节省 {{ compressionInfo.saved }}
</n-text>
</div>
<!-- 按钮组 -->
<div class="button-group">
<n-button
type="primary"
class="download-btn"
:disabled="!compressedImage"
@click="downloadCompressedImage"
size="large"
>
<template #icon>
<n-icon><DownloadOutline /></n-icon>
</template>
下载压缩图片
</n-button>
<n-button
type="default"
class="clear-btn"
:disabled="!originalImage && !compressedImage"
@click="clearAll"
size="large"
>
<template #icon>
<n-icon><RefreshOutline /></n-icon>
</template>
清空重置
</n-button>
</div>
</n-card>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import {
NUpload,
NUploadDragger,
NButton,
NCard,
NSlider,
NSelect,
NText,
NIcon,
useMessage
} from 'naive-ui'
import { DownloadOutline, RefreshOutline } from '@vicons/ionicons5'
//
const uploadRef = ref(null)
const originalImage = ref('')
const compressedImage = ref('')
const originalFile = ref(null)
const isCompressing = ref(false)
//
const widthValue = ref(800)
const heightValue = ref(600)
const qualityValue = ref(80)
const outputFormat = ref('image/jpeg')
//
const originalSize = ref(0)
const compressedSize = ref(0)
const message = useMessage()
//
const formatOptions = [
{ label: 'JPG', value: 'image/jpeg' },
{ label: 'PNG', value: 'image/png' },
{ label: 'WebP', value: 'image/webp' }
]
//
const originalSizeText = computed(() => {
return originalSize.value ? formatFileSize(originalSize.value) : '-'
})
const compressedSizeText = computed(() => {
return compressedSize.value ? formatFileSize(compressedSize.value) : '-'
})
const sizeReductionText = computed(() => {
if (!originalSize.value || !compressedSize.value) return '-'
const reduction = originalSize.value - compressedSize.value
return formatFileSize(reduction)
})
const compressionRatioText = computed(() => {
if (!originalSize.value || !compressedSize.value) return '-'
const ratio = ((originalSize.value - compressedSize.value) / originalSize.value * 100).toFixed(1)
return `${ratio}%`
})
const compressionInfo = computed(() => {
if (!originalSize.value || !compressedSize.value) {
return { ratio: 0, saved: '' }
}
const reduction = originalSize.value - compressedSize.value
const ratio = ((reduction / originalSize.value) * 100).toFixed(1)
const saved = formatFileSize(reduction)
return { ratio, saved }
})
//
watch([widthValue, heightValue, qualityValue], () => {
if (originalImage.value && !isCompressing.value) {
compressImage()
}
}, { deep: true })
//
function selectFile() {
uploadRef.value?.openFileDialog()
}
function handleFileSelect({ fileList }) {
if (fileList.length > 0) {
const file = fileList[0].file
handleFile(file)
}
}
function handleFile(file) {
if (!file.type.match('image.*')) {
message.error('请选择图片文件 (JPG, PNG, WebP)')
return
}
originalFile.value = file
originalSize.value = file.size
//
const reader = new FileReader()
reader.onload = (e) => {
originalImage.value = e.target.result
//
compressImage()
}
reader.readAsDataURL(file)
}
function handleSliderChange() {
// watch
}
function handleFormatChange() {
if (originalImage.value && !isCompressing.value) {
compressImage()
}
}
async function compressImage() {
if (!originalImage.value) return
isCompressing.value = true
compressedImage.value = ''
try {
// UI
await new Promise(resolve => setTimeout(resolve, 100))
const img = new Image()
img.onload = () => {
try {
//
let width = img.width
let height = img.height
if (width > widthValue.value) {
height = (widthValue.value / width) * height
width = widthValue.value
}
if (height > heightValue.value) {
width = (heightValue.value / height) * width
height = heightValue.value
}
// Canvas
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
// PNG
if (outputFormat.value === 'image/png') {
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
}
ctx.drawImage(img, 0, 0, width, height)
// URL
let dataURL
const quality = qualityValue.value / 100
if (outputFormat.value === 'image/png') {
dataURL = canvas.toDataURL('image/png')
} else if (outputFormat.value === 'image/webp') {
dataURL = canvas.toDataURL('image/webp', quality)
} else {
dataURL = canvas.toDataURL('image/jpeg', quality)
}
//
const compressedFileSize = Math.round(dataURL.length * 0.75)
compressedSize.value = compressedFileSize
//
compressedImage.value = dataURL
isCompressing.value = false
} catch (error) {
console.error('压缩错误:', error)
message.error('压缩出错,请重试')
isCompressing.value = false
}
}
img.onerror = () => {
message.error('图片加载失败')
isCompressing.value = false
}
img.src = originalImage.value
} catch (error) {
console.error('压缩过程出错:', error)
message.error('压缩过程出错')
isCompressing.value = false
}
}
function downloadCompressedImage() {
if (!compressedImage.value || !originalFile.value) return
try {
const link = document.createElement('a')
link.href = compressedImage.value
link.download = getOutputFileName(originalFile.value.name, outputFormat.value)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success('图片下载成功')
} catch (error) {
console.error('下载失败:', error)
message.error('下载失败,请重试')
}
}
//
function clearAll() {
//
originalImage.value = ''
compressedImage.value = ''
originalFile.value = null
isCompressing.value = false
//
originalSize.value = 0
compressedSize.value = 0
//
widthValue.value = 800
heightValue.value = 600
qualityValue.value = 80
outputFormat.value = 'image/jpeg'
//
if (uploadRef.value) {
uploadRef.value.clear()
}
message.success('已清空所有数据')
}
//
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B'
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
else return (bytes / 1048576).toFixed(1) + ' MB'
}
function getOutputFileName(originalName, format) {
const ext = format.split('/')[1]
const nameWithoutExt = originalName.replace(/\.[^/.]+$/, '')
return `${nameWithoutExt}_compressed.${ext}`
}
</script>
<style scoped>
.image-compress-home {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-header h1 {
font-size: 28px;
margin: 0 0 8px 0;
font-weight: 600;
}
.subtitle {
font-size: 16px;
color: var(--n-text-color-depth-2);
margin: 0;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.content {
grid-template-columns: 1fr;
}
.image-compress-home {
padding: 16px;
}
}
.left-panel,
.right-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-section {
padding: 32px;
text-align: center;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.upload-icon {
font-size: 48px;
margin-bottom: 8px;
}
.upload-content h3 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.upload-content p {
margin: 0;
color: var(--n-text-color-depth-2);
font-size: 14px;
}
.or-text {
color: var(--n-text-color-depth-3);
font-size: 13px;
}
.controls {
padding: 20px;
}
.control-group {
margin-bottom: 20px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-group h4 {
font-size: 16px;
margin: 0 0 16px 0;
font-weight: 500;
}
.slider-container {
margin-bottom: 16px;
}
.slider-container:last-child {
margin-bottom: 0;
}
.slider-container .n-text {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.preview-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.preview-container h4,
.info-card h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.image-container {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
background: var(--n-card-color);
border: 1px solid var(--n-border-color);
border-radius: 6px;
overflow: hidden;
}
.image-container img {
max-width: 100%;
max-height: 250px;
object-fit: contain;
}
.placeholder {
color: var(--n-text-color-depth-3);
font-size: 14px;
}
.info-items {
margin-bottom: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-item:not(:last-child) {
border-bottom: 1px solid var(--n-border-color);
}
.size-reduction {
text-align: center;
padding: 12px;
background: var(--n-primary-color-suppl);
border-radius: 6px;
margin-bottom: 16px;
}
.button-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.download-btn,
.clear-btn {
width: 100%;
}
@media (max-width: 768px) {
.button-group {
gap: 12px;
}
}
</style>

View File

@ -0,0 +1,403 @@
<template>
<div class="image-display">
<!-- 标题和操作栏 -->
<div class="display-header">
<h2>已上传图片 ({{ total ?? 0 }})</h2>
<n-space>
<n-button @click="refreshList" type="primary">
<template #icon>
<n-icon><RefreshOutline /></n-icon>
</template>
刷新数据
</n-button>
<!-- 修改为下拉菜单形式的导出 -->
<n-dropdown
:options="exportOptions"
:disabled="imageList.length === 0"
@select="handleExportSelect"
>
<n-button type="primary">
<template #icon>
<n-icon><DownloadOutline /></n-icon>
</template>
导出数据
<template #icon-right>
<n-icon><ChevronDownOutline /></n-icon>
</template>
</n-button>
</n-dropdown>
</n-space>
</div>
<!-- 数据表格 -->
<n-data-table
ref="tableRef"
:columns="columns"
:data="imageList"
:pagination="paginationReactive"
:loading="tableLoading"
striped
size="small"
:remote="true"
:scroll-x="1200"
flex-height
style="height: 500px"
/>
</div>
</template>
<script setup>
import { ref, reactive, h } from 'vue'
import {
NDataTable,
NButton,
NSpace,
NImage,
NTag,
NIcon,
NDropdown, //
useMessage
} from 'naive-ui'
import {
RefreshOutline,
DownloadOutline,
CopyOutline,
ChevronDownOutline //
} from '@vicons/ionicons5'
// Emits
const emit = defineEmits(['paginationChange'])
//
const tableRef = ref(null)
const tableLoading = ref(true)
const total = ref(0)
const imageList = ref([])
const message = useMessage()
//
const exportOptions = [
{
label: '导出为 JSON',
key: 'json',
icon: () => h(NIcon, () => h(DownloadOutline))
},
{
label: '导出为 CSV',
key: 'csv',
icon: () => h(NIcon, () => h(DownloadOutline))
}
]
// 使 reactive ref
const paginationReactive = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
showQuickJumper: true,
pageSizes: [10, 20, 50, 100],
onChange: (page) => {
paginationReactive.page = page
refreshList()
},
prefix({ itemCount }) {
return `${itemCount}`
},
onUpdatePageSize: (pageSize) => {
paginationReactive.pageSize = pageSize
paginationReactive.page = 1
refreshList()
}
})
//
const columns = [
{
title: '序号',
key: 'index',
width: 60,
render: (row, index) => {
return (paginationReactive.page - 1) * paginationReactive.pageSize + index + 1
}
},
{
title: '预览',
key: 'preview',
width: 80,
render: (row) => {
return h(NImage, {
width: 50,
height: 50,
src: row.url,
objectFit: 'cover',
style: 'border-radius: 4px; cursor: pointer'
})
}
},
{
title: '文件地址',
key: 'url',
width: 300,
render: (row) => {
return h('div', { style: 'display: flex; align-items: center; gap: 8px' }, [
h(
'a',
{
href: 'javascript:void(0)',
style:
'color: var(--n-color-target); text-decoration: none; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap',
title: row.url
},
row.url
),
h(
NButton,
{
size: 'tiny',
quaternary: true,
circle: true,
onClick: () => copyBase64(row)
},
{ icon: () => h(NIcon, () => h(CopyOutline)) }
)
])
}
},
{
title: '文件大小',
key: 'fileSize',
width: 100,
render: (row) => formatFileSize(row.fileSize)
},
{
title: '文件类型',
key: 'type',
width: 100,
render: (row) => {
return h(NTag, { size: 'small', type: 'primary' }, () => row.contentType)
}
},
{
title: '上传时间',
key: 'uploadTime',
width: 160,
render: (row) => formatDate(row.uploadTime)
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (row) => {
return h(NSpace, { size: 'small' }, () => [
h(
NButton,
{
size: 'small',
type: 'primary',
quaternary: true,
onClick: () => copyBase64(row)
},
{ default: () => '复制', icon: () => h(NIcon, () => h(CopyOutline)) }
)
])
}
}
]
async function copyBase64(image) {
try {
await navigator.clipboard.writeText(image.url)
message.success('文件地址已复制到剪贴板')
} catch (error) {
message.error('复制失败,请手动复制')
}
}
function refreshList() {
tableLoading.value = true
emit('paginationChange', {
page: paginationReactive.page,
pageSize: paginationReactive.pageSize
})
}
//
function handleExportSelect(key) {
if (key === 'json') {
exportAsJson()
} else if (key === 'csv') {
exportAsCsv()
}
}
// JSON
function exportAsJson() {
try {
const data = imageList.value
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
downloadFile(blob, `images_export_${getTimestamp()}.json`)
message.success('JSON 数据导出成功')
} catch (error) {
console.error('JSON 导出失败:', error)
message.error('JSON 导出失败,请重试')
}
}
// CSV
function exportAsCsv() {
try {
const data = imageList.value
if (data.length === 0) {
message.warning('没有数据可导出')
return
}
// CSV
const headers = ['序号', '文件名', '文件地址', '文件大小', '文件类型', '上传时间']
//
const csvRows = [
headers.join(','), //
...data.map((item, index) =>
[
index + 1, //
`"${item.fileName || ''}"`, //
`"${item.url || ''}"`, //
`"${formatFileSize(item.fileSize || 0)}"`, //
`"${item.contentType || ''}"`, //
`"${formatDate(item.uploadTime || new Date())}"` //
].join(',')
)
]
const csvContent = csvRows.join('\n')
// BOM
const bom = '\uFEFF'
const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=utf-8' })
downloadFile(blob, `images_export_${getTimestamp()}.csv`)
message.success('CSV 数据导出成功')
} catch (error) {
console.error('CSV 导出失败:', error)
message.error('CSV 导出失败,请重试')
}
}
//
function downloadFile(blob, filename) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
//
function getTimestamp() {
const now = new Date()
return (
now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
'_' +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0')
)
}
//
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function formatDate(date) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(new Date(date))
}
function reloadImageData(value) {
console.log('重新加载图片数据', value)
//
total.value = value.total || 0
imageList.value = value.collection ?? []
tableLoading.value = false
//
paginationReactive.page = value.current || 1
paginationReactive.itemCount = value.total || 0
// pageCount
// paginationReactive.value.pageCount = Math.ceil(value.total / 10) //
console.log('分页调试信息:', {
total: value.total,
pageSize: paginationReactive.pageSize,
itemCount: paginationReactive.itemCount,
currentPage: paginationReactive.page
})
}
//
defineExpose({ reloadImageData })
</script>
<style scoped>
.image-display {
padding: 20px;
}
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.display-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.preview-content {
text-align: center;
}
.preview-info {
margin-top: 20px;
text-align: left;
}
@media (max-width: 768px) {
.image-display {
padding: 16px;
}
.display-header {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@ -0,0 +1,97 @@
<template>
<div class="image-upload-home">
<!-- 页面标题 -->
<div class="page-header">
<h1>图片上传工具</h1>
<p>上传图片到LaiTool图床获取图片链接</p>
</div>
<!-- 上传组件 -->
<ImageUploader @upload-success="handleUploadSuccess" />
<!-- 分割线 -->
<n-divider />
<!-- 显示组件 -->
<ImageDisplay
ref="imageDisplayRef"
@vue:mounted="loadImageList"
:image-list="imageList"
@pagination-change="paginationChange"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { NDivider, useMessage } from 'naive-ui'
import ImageUploader from './ImageUploader.vue'
import ImageDisplay from './ImageDisplay.vue'
import { define } from '@/define/define'
import { useSoftwareStore } from '@/renderer/src/stores'
//
const imageList = ref([])
const message = useMessage()
const imageDisplayRef = ref(null)
const softwareStore = useSoftwareStore()
onMounted(() => {})
//
async function loadImageList({ page = 1, pageSize = 10 }) {
debugger
let res = await window.axios.get(
define.lms_url +
`/lms/FileUpload/GetFilesByMachineId/${softwareStore.authorization.machineId}?page=${page}&pageSize=${pageSize}`
)
console.log('获取图片列表', res)
imageDisplayRef.value.reloadImageData(res.data.data)
}
//
async function handleUploadSuccess() {
//
await loadImageList()
}
async function paginationChange(value) {
//
await loadImageList(value)
}
</script>
<style scoped>
.image-upload-home {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
min-width: 600px;
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-header h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
color: var(--n-text-color);
}
.page-header p {
margin: 0;
color: var(--n-text-color-depth-2);
font-size: 14px;
}
@media (max-width: 768px) {
.image-upload-home {
padding: 16px;
}
}
</style>

View File

@ -0,0 +1,493 @@
<template>
<div class="image-uploader">
<!-- 上传区域 -->
<n-upload
ref="uploadRef"
:max="1"
accept="image/*"
:disabled="uploading"
:default-upload="false"
@change="handleFileChange"
:show-file-list="false"
>
<n-upload-dragger>
<div class="upload-content">
<div class="upload-icon">
<n-icon size="48" :depth="uploading ? 2 : 3">
<SyncOutline v-if="uploading" />
<CloudUploadOutline v-else />
</n-icon>
</div>
<div class="upload-text">
<n-text style="font-size: 16px; margin-bottom: 4px; display: block">
{{ uploading ? '正在处理中...' : '点击或拖拽图片到此区域上传' }}
</n-text>
<n-text depth="3" style="font-size: 12px; display: block">
{{
uploading
? '请等待当前文件处理完成'
: '支持 JPG、PNG、GIF、WebP 格式,单个文件不超过 5MB'
}}
</n-text>
</div>
</div>
</n-upload-dragger>
</n-upload>
<!-- 操作按钮 -->
<div class="upload-actions">
<n-space>
<n-button
@click="startUpload"
:disabled="!selectedFile || uploading"
size="medium"
type="success"
>
<template #icon>
<n-icon><CloudUploadOutline /></n-icon>
</template>
开始上传
</n-button>
<n-button @click="clearFiles" size="medium">
<template #icon>
<n-icon><StopOutline /></n-icon>
</template>
清空选择
</n-button>
</n-space>
</div>
<!-- 选中的文件信息 -->
<div class="selected-file" v-if="selectedFile">
<n-card size="small" style="margin-top: 16px">
<div class="file-info">
<div class="file-details">
<n-text strong>{{ selectedFile.name }}</n-text>
<n-text depth="2" style="font-size: 12px">
{{ formatFileSize(selectedFile.size) }} | {{ selectedFile.type }}
</n-text>
<!-- 简单的上传状态提示 -->
<div v-if="uploading" class="upload-status-text">
<n-text depth="3" style="font-size: 11px; color: var(--n-color-warning)">
{{ statusText }}
</n-text>
</div>
</div>
<div class="file-preview" v-if="previewUrl">
<!-- 预览图片容器 -->
<div class="preview-container">
<n-image
:render-toolbar="renderToolbar"
:src="previewUrl"
alt="预览"
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px"
:class="{ 'uploading-blur': uploading }"
/>
<!-- 上传时的加载动画覆盖层 -->
<div v-if="uploading" class="loading-overlay">
<n-spin size="small" />
</div>
</div>
</div>
</div>
</n-card>
</div>
<!-- 上传提示 -->
<div class="upload-tips">
<n-alert type="info" style="margin-top: 16px">
<template #icon>
<n-icon><InformationCircleOutline /></n-icon>
</template>
<div>
<p style="margin: 0 0 8px 0"><strong>上传说明</strong></p>
<ul style="margin: 0; padding-left: 16px">
<li>选择文件后点击"开始上传"按钮进行上传</li>
<li>每次只能上传一个文件并且上传的文件会留存在服务器介意请勿用</li>
<li>支持的格式JPGPNGJPEGWebP</li>
<li>文件大小限制最大 5MB</li>
<li>单日上传次数限制5</li>
</ul>
</div>
</n-alert>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import {
NUpload,
NUploadDragger,
NIcon,
NText,
NButton,
NSpace,
NProgress,
NAlert,
NCard,
useMessage
} from 'naive-ui'
import {
CloudUploadOutline,
FolderOpenOutline,
StopOutline,
SyncOutline,
InformationCircleOutline
} from '@vicons/ionicons5'
import { TimeDelay } from '@/define/Tools/time'
import { define } from '@/define/define'
import { useSoftwareStore } from '@/renderer/src/stores'
import { isEmpty } from 'lodash'
// Emits
const emit = defineEmits(['uploadSuccess', 'uploadError'])
//
const uploadRef = ref(null)
const uploading = ref(false)
const statusText = ref('')
const selectedFile = ref(null)
const previewUrl = ref('')
const softwareStore = useSoftwareStore()
const message = useMessage()
//
const renderToolbar = ({ nodes }) => {
return [nodes.close]
}
//
function handleFileChange({ fileList }) {
if (fileList.length > 0) {
const file = fileList[0].file
if (validateFile(file)) {
selectedFile.value = file
// URL
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = URL.createObjectURL(file)
message.success(`已选择文件: ${file.name}`)
}
} else {
selectedFile.value = null
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
}
}
//
async function startUpload() {
if (!selectedFile.value) {
message.warning('请先选择文件')
return
}
if (uploading.value) {
message.warning('请等待当前文件上传完成')
return
}
await handleUpload(selectedFile.value)
}
async function handleUpload(file) {
const fileName = file.name
//
uploading.value = true
statusText.value = '开始上传文件...'
await TimeDelay(500)
try {
// 2:
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
statusText.value = '文件超出限制,请压缩后上传!!'
return
}
statusText.value = '开始处理图片文件...'
const imageData = await processImage(file)
// data:image/jpeg;base64,
if (imageData.base64.startsWith('data:')) {
imageData.base64 = imageData.base64.split(',')[1]
}
await TimeDelay(500)
// 3:
statusText.value = '图片处理完毕,开始上传文件...'
//
if (isEmpty(softwareStore.authorization.machineId)) {
message.error('未找到机器ID请重启软件后重试')
return
}
let res = await window.axios.post(
define.lms_url + `/lms/FileUpload/FileUpload/${softwareStore.authorization.machineId}`,
{
file: imageData.base64,
fileName: fileName,
contentType: file.type
}
)
await TimeDelay(500)
if (!res.success) {
//
message.error(`上传失败: ${res.message || '未知错误'}`)
return
}
debugger
if (res.data && res.data.code == 1) {
const uploadedImage = res.data
//
emit('uploadSuccess', uploadedImage)
//
clearFiles()
} else {
//
message.error(`上传失败: ${res.data.message || '未知错误'}`)
return
}
} catch (error) {
message.error(`上传失败: ${error.message || '未知错误'}`)
} finally {
// /
setTimeout(() => {
uploading.value = false
statusText.value = ''
}, 1000)
}
}
//
function validateFile(file) {
console.log('Validating file:', file.name, file.type, file.size)
//
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
message.error('只支持 JPG、PNG、GIF、WebP 格式的图片')
return false
}
// (10MB)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
message.error('文件大小不能超过 5MB')
return false
}
return true
}
async function processImage(file) {
console.log('Processing image:', file.name)
return new Promise((resolve, reject) => {
const reader = new FileReader()
const img = new Image()
reader.onload = (e) => {
img.onload = () => {
console.log('Image loaded, dimensions:', img.width, 'x', img.height)
resolve({
base64: e.target.result,
width: img.width,
height: img.height
})
}
img.onerror = () => reject(new Error('无法加载图片'))
img.src = e.target.result
}
reader.onerror = () => reject(new Error('无法读取文件'))
reader.readAsDataURL(file)
})
}
function clearFiles() {
console.log('Clear files clicked')
selectedFile.value = null
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
// upload
if (uploadRef.value) {
uploadRef.value.clear()
}
}
//
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.image-uploader {
padding: 20px;
}
.upload-content {
text-align: center;
padding: 40px 20px;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.upload-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.upload-status {
margin-top: 16px;
padding: 20px;
background: var(--n-card-color);
border-radius: 8px;
border: 1px solid var(--n-border-color);
}
.status-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.status-actions {
margin-top: 12px;
text-align: right;
}
.upload-actions {
margin-top: 16px;
text-align: center;
}
.selected-file {
margin-top: 16px;
}
.file-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.file-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.upload-tips {
margin-top: 16px;
}
.upload-tips ul li {
margin-bottom: 4px;
font-size: 13px;
}
@media (max-width: 768px) {
.image-uploader {
padding: 16px;
}
.upload-actions {
text-align: left;
}
.status-info {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
.preview-container {
position: relative;
display: inline-block;
}
.uploading-blur {
filter: blur(1px);
opacity: 0.7;
transition: all 0.3s ease;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
border-radius: 4px;
backdrop-filter: blur(2px);
}
.upload-progress-info {
margin-top: 8px;
}
.upload-status-badge {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.file-info {
display: flex;
justify-content: space-between;
align-items: flex-start; /* 改为 flex-start 以便对齐 */
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.loading-overlay {
background: rgba(0, 0, 0, 0.6);
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.file-info {
flex-direction: column;
gap: 12px;
align-items: center;
}
.upload-status-badge {
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,261 @@
<template>
<div class="toolbox-home">
<!-- 顶部搜索和筛选区域 -->
<div class="header-section">
<div class="search-bar">
<n-input
v-model:value="searchKeyword"
placeholder="搜索工具..."
clearable
size="large"
style="max-width: 400px"
>
<template #prefix>
<n-icon>
<SearchOutline />
</n-icon>
</template>
</n-input>
</div>
<div class="filter-bar">
<n-space>
<n-select
v-model:value="selectedCategory"
:options="categoryOptions"
placeholder="选择分类"
clearable
style="width: 150px"
/>
<n-select
v-model:value="selectedTag"
:options="tagOptions"
placeholder="选择标签"
clearable
style="width: 120px"
/>
<n-button @click="resetFilters" quaternary>
<template #icon>
<n-icon><RefreshOutline /></n-icon>
</template>
重置
</n-button>
</n-space>
</div>
</div>
<!-- 工具分类标签页 -->
<div class="category-tabs">
<n-tabs v-model:value="activeCategory" type="line" animated>
<n-tab-pane name="all" tab="全部">
<ToolGrid :tools="filteredTools" @tool-click="handleToolClick" />
</n-tab-pane>
<n-tab-pane
v-for="category in categories"
:key="category.key"
:name="category.key"
:tab="category.label"
>
<ToolGrid :tools="getToolsByCategory(category.key)" @tool-click="handleToolClick" />
</n-tab-pane>
</n-tabs>
</div>
<!-- 统计信息 -->
<div class="stats-section">
<n-space>
<n-statistic label="工具总数" :value="totalTools" />
<n-statistic label="分类数量" :value="categories.length" />
</n-space>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import {
NInput,
NSelect,
NButton,
NSpace,
NTabs,
NTabPane,
NDivider,
NTag,
NStatistic,
NIcon
} from 'naive-ui'
import { SearchOutline, RefreshOutline, FlashOutline, TimeOutline } from '@vicons/ionicons5'
import ToolGrid from './ToolGrid.vue'
import { toolsData, categories } from '@/renderer/src/common/toolData'
import { useRouter } from 'vue-router'
//
const searchKeyword = ref('')
const selectedCategory = ref(null)
const selectedTag = ref(null)
const activeCategory = ref('all')
const message = useMessage()
let router = useRouter()
//
const categoryOptions = computed(() =>
categories.map((cat) => ({ label: cat.label, value: cat.key }))
)
const tagOptions = computed(() => {
const allTags = new Set()
toolsData.forEach((tool) => {
tool.tags?.forEach((tag) => allTags.add(tag))
})
return Array.from(allTags).map((tag) => ({ label: tag, value: tag }))
})
const filteredTools = computed(() => {
let tools = toolsData
//
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
tools = tools.filter(
(tool) =>
tool.name.toLowerCase().includes(keyword) ||
tool.description.toLowerCase().includes(keyword) ||
tool.tags?.some((tag) => tag.toLowerCase().includes(keyword))
)
}
//
if (selectedCategory.value) {
tools = tools.filter((tool) => tool.category === selectedCategory.value)
}
//
if (selectedTag.value) {
tools = tools.filter((tool) => tool.tags?.includes(selectedTag.value))
}
//
if (activeCategory.value !== 'all') {
tools = tools.filter((tool) => tool.category === activeCategory.value)
}
return tools
})
const totalTools = computed(() => toolsData.length)
//
function getToolsByCategory(categoryKey) {
return toolsData.filter((tool) => tool.category === categoryKey)
}
function handleToolClick(tool) {
//
executeToolAction(tool)
}
function executeToolAction(tool) {
//
debugger
switch (tool.action?.type) {
case 'route':
//
if (!tool.action.route) {
message.error('路由路径未配置')
return
}
router.push(tool.action.route)
break
case 'function':
//
console.log('执行函数:', tool.action.handler)
if (typeof tool.action.handler === 'function') {
tool.action.handler()
}
break
case 'external':
if (!tool.action.url) {
message.error('外部链接未配置')
return
}
window.api.OpenUrl(tool.action.url)
break
case 'dialog':
//
console.log('打开弹窗:', tool.action.component)
break
default:
message.error(`未知操作类型: ${tool.action?.type || '无'}`)
}
}
function resetFilters() {
searchKeyword.value = ''
selectedCategory.value = null
selectedTag.value = null
activeCategory.value = 'all'
}
//
onMounted(() => {})
</script>
<style scoped>
.toolbox-home {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.search-bar {
flex: 1;
min-width: 300px;
}
.filter-bar {
flex-shrink: 0;
}
.category-tabs {
margin-bottom: 32px;
}
.quick-access,
.recent-tools {
margin-bottom: 24px;
}
.stats-section {
margin-top: 32px;
padding: 20px;
background: var(--n-card-color);
border-radius: 8px;
border: 1px solid var(--n-border-color);
}
@media (max-width: 768px) {
.toolbox-home {
padding: 16px;
}
.header-section {
flex-direction: column;
align-items: stretch;
}
.search-bar {
min-width: auto;
}
}
</style>

View File

@ -0,0 +1,153 @@
<template>
<div class="tool-grid">
<div v-for="tool in tools" :key="tool.id" class="tool-card" @click="$emit('toolClick', tool)">
<n-card hoverable :class="{ 'tool-card-disabled': tool.disabled }" size="small">
<div class="tool-content">
<div class="tool-icon">
<n-icon size="32">
<component :is="tool.icon" />
</n-icon>
</div>
<div class="tool-info">
<h3 class="tool-name">{{ tool.name }}</h3>
<p class="tool-description">{{ tool.description }}</p>
<div class="tool-tags" v-if="tool.tags && tool.tags.length > 0">
<n-tag v-for="tag in tool.tags" :key="tag" size="tiny" :bordered="false" type="info">
{{ tag }}
</n-tag>
</div>
</div>
<div class="tool-badge" v-if="tool.badge">
<n-badge :value="tool.badge.text" :type="tool.badge.type" />
</div>
</div>
</n-card>
</div>
<!-- 空状态 -->
<div v-if="tools.length === 0" class="empty-state">
<n-empty description="没有找到相关工具" />
</div>
</div>
</template>
<script setup>
import { NCard, NIcon, NTag, NBadge, NSpace, NEmpty } from 'naive-ui'
defineProps({
tools: {
type: Array,
default: () => []
}
})
defineEmits(['toolClick'])
</script>
<style scoped>
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.tool-card {
cursor: pointer;
transition: transform 0.2s ease;
}
.tool-card:hover {
transform: translateY(-2px);
}
.tool-card-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tool-card-disabled:hover {
transform: none;
}
.tool-content {
display: flex;
align-items: flex-start;
gap: 12px;
position: relative;
height: 80px;
}
.tool-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--n-color-target);
border-radius: 8px;
}
.tool-info {
flex: 1;
min-width: 0;
}
.tool-name {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: var(--n-text-color);
line-height: 1.2;
}
.tool-description {
margin: 0 0 8px 0;
font-size: 13px;
color: var(--n-text-color-depth-2);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tool-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tool-badge {
position: absolute;
top: -4px;
right: -4px;
}
.empty-state {
grid-column: 1 / -1;
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
@media (max-width: 768px) {
.tool-grid {
grid-template-columns: 1fr;
}
.tool-content {
flex-direction: column;
text-align: center;
}
.tool-icon {
align-self: center;
}
}
</style>

View File

@ -52,7 +52,7 @@ let props = defineProps({
let data = ref(props.data) let data = ref(props.data)
const emit = defineEmits(['click']) const emit = defineEmits(['button-click'])
let message = useMessage() let message = useMessage()
// //
@ -62,8 +62,7 @@ function handleButtonClick() {
props.buttonClick(data.value) props.buttonClick(data.value)
} else { } else {
// //
emit('click', props.data) emit('button-click', data.value)
message.info('点击了按钮,但未提供处理函数')
} }
} }

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