feat: EAnime AI漫剧制作助手 v1.0

核心功能:
- 角色生成:豆包 Seedream 生图 + 角色库管理
- 场景生成:8种场景风格 + 场景库管理
- 脚本生成:小说拆分 → DeepSeek/豆包生成视频脚本
- 视频生成:Seedance 2.0 支持,最长15秒1080p
- 会话管理:多轮对话 + 自动保存
- 6种角色风格 + 8种场景风格 + 运镜/灯光预设
- API Key配置 + 自定义存储路径

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-26 15:52:12 +08:00
parent 05304ba945
commit 3d10c3f1a2
23 changed files with 9607 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
out/
dist/
*.db
*.db-wal
+20
View File
@@ -0,0 +1,20 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@': resolve('src/renderer/src')
}
},
plugins: [vue()]
}
})
+6501
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "eanime",
"version": "1.0.0",
"description": "AI漫剧制作助手 - 本地桌面工作台",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"postinstall": "electron-builder install-app-deps"
},
"repository": {
"type": "git",
"url": "http://gitea.akaxedx.cn/AKAxedx/EAnime.git"
},
"keywords": [
"ai",
"manga",
"anime",
"creator"
],
"author": "",
"license": "ISC",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"electron-store": "^10.1.0",
"element-plus": "^2.14.3",
"vue": "^3.5.40",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@vitejs/plugin-vue": "^6.0.8",
"electron": "^43.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"typescript": "^7.0.2",
"vite": "^7.3.6",
"vue-tsc": "^3.3.8"
}
}
+40
View File
@@ -0,0 +1,40 @@
import { getApiKeys } from './store'
const DEEPSEEK_BASE = 'https://api.deepseek.com/v1'
function getDeepSeekKey(): string {
const keys = getApiKeys()
return keys['deepseek'] || ''
}
export function hasDeepSeek(): boolean {
return !!getDeepSeekKey()
}
export async function deepseekChat(messages: { role: string; content: string }[], model?: string): Promise<string> {
const apiKey = getDeepSeekKey()
if (!apiKey) throw new Error('DeepSeek API Key 未配置')
const response = await fetch(DEEPSEEK_BASE + '/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
},
body: JSON.stringify({
model: model || 'deepseek-v4-pro',
messages,
stream: false
})
})
if (!response.ok) {
const errText = await response.text()
let detail = errText
try { detail = JSON.parse(errText).error?.message || errText } catch {}
throw new Error('DeepSeek API 错误: ' + detail)
}
const result = await response.json() as { choices: { message: { content: string } }[] }
return result.choices[0]?.message?.content || ''
}
+495
View File
@@ -0,0 +1,495 @@
import { getApiKeys, readImageBase64 } from './store'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string | ChatContent[]
}
export interface ChatContent {
type: 'text' | 'image_url'
text?: string
image_url?: { url: string }
}
// ===== 风格预设 =====
export interface StylePreset {
key: string
label: string
textStyle: string
imageStyle: string
}
export const STYLE_PRESETS: StylePreset[] = [
{
key: 'realistic',
label: '写实风格',
textStyle: '角色设计采用写实风格,五官和身体比例尽量真实自然,服装材质细节丰富。',
imageStyle: 'realistic style, photorealistic rendering, detailed textures, natural lighting, 3D CGI quality'
},
{
key: 'anime',
label: '日漫风格',
textStyle: '角色设计采用经典日式动漫风格,大眼睛、线条干净清晰、发色可以夸张鲜艳。',
imageStyle: 'Japanese anime style, cel shading, clean lineart, vibrant colors, 2D animation look'
},
{
key: 'comic',
label: '美漫风格',
textStyle: '角色设计采用美式漫画风格,强调肌肉线条和英雄感的身材比例,厚涂上色、轮廓硬朗。',
imageStyle: 'American comic book style, bold outlines, heavy shading, heroic proportions, graphic ink style'
},
{
key: 'webtoon',
label: '韩漫画风',
textStyle: '角色设计采用韩式条漫画风,人物修长精致,五官柔和,服装时尚感强,画面干净通透。',
imageStyle: 'Korean webtoon style, clean soft lines, smooth gradients, fashion-forward, elegant pastel tones'
},
{
key: 'ink',
label: '水墨风格',
textStyle: '角色设计采用中国水墨画风格,线条写意流畅,淡雅配色,有留白意境和笔触质感。',
imageStyle: 'traditional Chinese ink wash painting, watercolor brush strokes, elegant minimal palette, flowing texture, artistic'
},
{
key: 'cyberpunk',
label: '赛博朋克',
textStyle: '角色设计采用赛博朋克科幻风格,带有义体改造、科技装备、霓虹灯元素和未来都市感。',
imageStyle: 'cyberpunk style, neon lights, futuristic tech, cybernetic implants, dark atmospheric, sci-fi'
}
]
export function getStylePreset(key: string): StylePreset | undefined {
return STYLE_PRESETS.find(s => s.key === key)
}
// ===== 场景风格预设 =====
export const SCENE_STYLE_PRESETS = [
{ key: 'school', label: '校园', textStyle: '校园场景,教室、走廊、操场等学校环境。', imageStyle: 'school setting, campus, classroom, cherry blossoms, Japanese school anime background' },
{ key: 'city', label: '都市', textStyle: '现代都市场景,街道、高楼、商业区等城市环境。', imageStyle: 'modern city, streets, skyscrapers, urban landscape, detailed background art' },
{ key: 'nature', label: '自然', textStyle: '自然风景场景,森林、山川、湖泊、花海等。', imageStyle: 'nature landscape, forest, mountain, lake, flowers, beautiful natural scenery background' },
{ key: 'scifi', label: '科幻', textStyle: '科幻未来场景,太空站、高科技都市、实验室等。', imageStyle: 'sci-fi futuristic, space station, holographic, high tech, sleek metal surfaces' },
{ key: 'classical', label: '古典', textStyle: '中国古典场景,宫殿、庭院、古城、山水画境等。', imageStyle: 'ancient Chinese palace, traditional architecture, courtyard, ink painting aesthetic, elegant' },
{ key: 'cyberpunk', label: '赛博朋克', textStyle: '赛博朋克城市场景,霓虹街道、暗巷、高科技贫民窟。', imageStyle: 'cyberpunk city, neon lights, rain, dark alleys, holographic billboards, gritty atmosphere' },
{ key: 'fantasy', label: '奇幻', textStyle: '奇幻世界场景,魔法森林、浮空岛、龙巢、神秘遗迹等。', imageStyle: 'fantasy world, magical forest, floating islands, mystical ruins, glowing particles, ethereal' },
{ key: 'wasteland', label: '末日废土', textStyle: '末日废土场景,废墟、荒漠、残垣断壁、废铁堆积。', imageStyle: 'post-apocalyptic wasteland, ruins, desert, rusted metal, debris, dramatic sky, desolate' }
]
export function getSceneStylePreset(key: string) {
return SCENE_STYLE_PRESETS.find(s => s.key === key)
}
interface ChatCompletionResponse {
id: string
choices: {
index: number
message: { role: string; content: string }
finish_reason: string
}[]
}
interface ImageGenerationResponse {
data: { url: string; size?: string }[]
}
const DOUBAO_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'
function getDoubaoKey(): string {
const keys = getApiKeys()
const key = keys['doubao']
if (!key) throw new Error('豆包 API Key 未配置,请先在设置页面配置')
return key
}
/**
* 调用对话 API
*/
export async function chatCompletion(
messages: ChatMessage[],
model: string
): Promise<string> {
if (!model) throw new Error('请配置豆包模型名称')
const apiKey = getDoubaoKey()
const response = await fetch(`${DOUBAO_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({ model, messages, stream: false })
})
if (!response.ok) {
const errorText = await response.text()
let detail = errorText
try { const err = JSON.parse(errorText); detail = err.error?.message || err.message || errorText } catch { /* ignore */ }
throw new Error(`豆包 API 错误 (${response.status}): ${detail}`)
}
const result: ChatCompletionResponse = await response.json()
return result.choices[0]?.message?.content || ''
}
/**
* 调用 Seedream 生图 API
*/
export async function generateImage(prompt: string, model: string): Promise<string> {
if (!model) throw new Error('请选择生图模型')
const apiKey = getDoubaoKey()
const response = await fetch(`${DOUBAO_BASE_URL}/images/generations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({ model, prompt, n: 1, size: '1920x1920' })
})
if (!response.ok) {
const errorText = await response.text()
let detail = errorText
try { const err = JSON.parse(errorText); detail = err.error?.message || err.message || errorText } catch { /* ignore */ }
throw new Error(`Seedream 生图错误 (${response.status}): ${detail}`)
}
const result: ImageGenerationResponse = await response.json()
return result.data[0]?.url || ''
}
/**
* 构建角色生成提示词(文本模型用)
* 让 AI 同时输出中文设定和英文绘图提示词
*/
export function buildCharacterMessages(
input: string,
history?: { role: string; text: string; imageUrl?: string }[],
style?: string
): ChatMessage[] {
const preset = style ? getStylePreset(style) : undefined
const textStyle = preset?.textStyle || ''
const imageStyle = preset?.imageStyle || 'anime style'
const systemPrompt: ChatMessage = {
role: 'user',
content: `你是 EAnime 的角色设计助手。用户会描述他想要的角色,你需要帮他完善并输出结构化的角色设定。
${textStyle ? `\n【画风要求】${textStyle}` : ''}
请严格按照以下格式输出,包含两个部分:
========== 角色设定 ==========
【角色名】xxx
【性别】xxx
【年龄】xxx
【外貌特征】xxx
【性格特点】xxx
【背景故事】xxx
【关键词】xxx
========== Image Prompt ==========
[用英文写一段适合 AI 生图的 prompt。要求:动漫角色全身立绘,纯色无背景,角色居中,包含正面全身和细节展示(类似三视图的感觉)。描述角色的发型、五官、服装、姿势。必须包含风格关键词:${imageStyle}。格式:角色动作描述 + 服装细节 + 全身构图(full-body) + 纯色背景(solid color background) + ${imageStyle}。80词以内]
回答要简洁具体,用中文写设定部分。`
}
// 如果没有历史消息,走新会话流程
if (!history || history.length === 0) {
return [systemPrompt, { role: 'user', content: input }]
}
// 有历史消息:system prompt + 历史对话 + 新输入
const historyMessages: ChatMessage[] = history.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.text
}))
return [systemPrompt, ...historyMessages, { role: 'user', content: input }]
}
/**
* 从角色设定文本中提取 Image Prompt
*/
export function extractImagePrompt(text: string): string {
// 找 ========== Image Prompt ========== 后面的内容(直到结束)
const match = text.match(/Image Prompt\s*[=]*\s*\n([\s\S]+)$/)
if (match) {
const prompt = match[1].trim()
if (prompt) return prompt
}
// 如果没找到标记,用最后一段作为 fallback
const paragraphs = text.split('\n').filter(Boolean)
const last = paragraphs[paragraphs.length - 1]
if (last && last.length > 10 && !last.includes('【')) return last
// 再 fallback:用外貌特征拼接
const appearance = text.match(/【外貌特征】(.+)/)
const keywords = text.match(/【关键词】(.+)/)
return `anime character, ${appearance ? appearance[1] + ', ' : ''} ${keywords ? keywords[1] : 'original design'}, portrait, high quality, anime style`
}
/**
* 生成角色(文本 + 图片串联)
*/
export async function generateCharacter(
input: string,
chatModel: string,
imageModel: string,
history?: { role: string; text: string; imageUrl?: string }[],
style?: string
): Promise<{ text: string; imageUrl: string }> {
const messages = buildCharacterMessages(input, history, style)
const text = await chatCompletion(messages, chatModel)
if (!text) throw new Error('角色设定生成失败')
const imagePrompt = extractImagePrompt(text)
const imageUrl = await generateImage(imagePrompt, imageModel)
return { text, imageUrl }
}
// ===== 场景生成 =====
export function buildSceneMessages(
input: string,
history?: { role: string; text: string }[],
style?: string
): ChatMessage[] {
const preset = style ? getSceneStylePreset(style) : undefined
const textStyle = preset?.textStyle || ''
const imageStyle = preset?.imageStyle || 'anime background style'
const systemPrompt: ChatMessage = {
role: 'user',
content: `你是 EAnime 的场景设计助手。用户会描述他想要的场景,你需要帮他完善并输出结构化的场景设定。
${textStyle ? `\n【画风要求】${textStyle}` : ''}
请严格按照以下格式输出,包含两个部分:
========== 场景设定 ==========
【场景名称】xxx
【氛围】xxx
【时间段】xxx
【天气】xxx
【描述】xxx
【关键词】xxx
========== Image Prompt ==========
[用英文写一段适合 AI 生图的 prompt。要求:场景全景构图,纯色无背景(或简单的室内/室外背景设定),细节丰富。必须包含风格关键词:${imageStyle}。格式:场景描述 + 构图(wide shot / establishing shot) + ${imageStyle}。80词以内]
回答要简洁具体,用中文写设定部分。`
}
if (!history || history.length === 0) {
return [systemPrompt, { role: 'user', content: input }]
}
const historyMessages: ChatMessage[] = history.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.text
}))
return [systemPrompt, ...historyMessages, { role: 'user', content: input }]
}
export async function generateScene(
input: string,
chatModel: string,
imageModel: string,
history?: { role: string; text: string }[],
style?: string
): Promise<{ text: string; imageUrl: string }> {
const messages = buildSceneMessages(input, history, style)
const text = await chatCompletion(messages, chatModel)
if (!text) throw new Error('场景设定生成失败')
const imagePrompt = extractImagePrompt(text)
const imageUrl = await generateImage(imagePrompt, imageModel)
return { text, imageUrl }
}
// ===== 运镜 & 灯光预设 =====
export const CAMERA_MOVEMENTS = [
{ key: 'dolly_in', label: '推镜', prompt: 'camera slowly pushes in towards the subject' },
{ key: 'dolly_out', label: '拉镜', prompt: 'camera slowly pulls back to reveal more' },
{ key: 'pan_right', label: '右摇', prompt: 'camera pans smoothly to the right' },
{ key: 'pan_left', label: '左摇', prompt: 'camera pans smoothly to the left' },
{ key: 'tilt_up', label: '仰拍', prompt: 'camera tilts upward, low angle shot' },
{ key: 'tilt_down', label: '俯拍', prompt: 'camera tilts downward, high angle overhead shot' },
{ key: 'tracking', label: '跟镜', prompt: 'camera smoothly tracks alongside the subject' },
{ key: 'close_up', label: '特写', prompt: 'close-up shot focusing on facial expression or details' },
{ key: 'wide_shot', label: '全景', prompt: 'wide establishing shot showing the full scene' },
{ key: 'static', label: '静止', prompt: 'static shot, no camera movement' }
]
export const LIGHTING_PRESETS = [
{ key: 'natural', label: '自然光', prompt: 'soft natural daylight, realistic ambient lighting' },
{ key: 'rembrandt', label: '伦勃朗光', prompt: 'Rembrandt lighting, dramatic triangular light on cheek' },
{ key: 'side_light', label: '侧光', prompt: 'strong side lighting, half face illuminated half in shadow' },
{ key: 'backlight', label: '逆光', prompt: 'backlighting, rim light, silhouette effect, golden halo' },
{ key: 'soft_diffuse', label: '柔光', prompt: 'soft diffused lighting, gentle shadows, dreamy atmosphere' },
{ key: 'neon', label: '霓虹', prompt: 'vibrant neon lights, colorful reflections, cyberpunk aesthetic' },
{ key: 'warm', label: '暖光', prompt: 'warm golden hour light, sunset tones, cozy atmosphere' },
{ key: 'cool', label: '冷光', prompt: 'cool blue moonlight, sterile white light, cold atmosphere' }
]
// ===== 视频脚本生成 =====
export function buildScriptMessages(
novelText: string,
durationSec: number,
style?: string
): ChatMessage[] {
const sceneCount = Math.max(1, Math.floor(durationSec / 3))
return [
{
role: 'user',
content: '你是视频脚本编剧。根据小说片段,生成约 ' + durationSec + ' 秒的视频拍摄脚本,拆分为约 ' + sceneCount + ' 个镜头。\n\n请按以下格式输出:\n\n========== 分镜脚本 ==========\n\n【镜头1】(0~3秒)\n【画面】xxx\n【人物心理】xxx\n【人物对话】xxx\n【运镜建议】xxx\n【灯光建议】xxx\n\n【镜头2】...\n\n========== Video Prompt ==========\n[英文 AI 视频生成提示词,' + (style || 'cinematic anime style') + '50词以内]'
},
{ role: 'user', content: novelText }
]
}
// ===== 视频生成 API =====
function resolveImageUrl(pathOrUrl: string): string {
if (!pathOrUrl) return ''
// 已经是 data URL 或 http URL,直接返回
if (pathOrUrl.startsWith('data:') || pathOrUrl.startsWith('http')) return pathOrUrl
// 本地文件路径,转 base64
try {
const b64 = readImageBase64(pathOrUrl)
return b64 || pathOrUrl
} catch {
return ''
}
}
export async function createVideoTask(
prompt: string,
durationSec: number,
resolution: string,
model: string,
firstFrameUrl?: string,
refUrls?: string[]
): Promise<string> {
const keys = getApiKeys()
const apiKey = keys['doubao']
if (!apiKey) throw new Error('API Key 未配置')
const content: Record<string, unknown>[] = []
const isV2 = model.includes('2-0') || model.includes('2.0')
// 首帧图:场景图优先,否则用第一个角色图
const frameUrl = firstFrameUrl || (refUrls && refUrls.length > 0 ? refUrls[0] : '')
if (frameUrl) {
const resolved = resolveImageUrl(frameUrl)
if (resolved) content.push({ type: 'image_url', image_url: { url: resolved }, role: 'first_frame' })
}
// 参考图:仅 2.0 支持
if (isV2 && refUrls) {
for (const refUrl of refUrls) {
const resolved = resolveImageUrl(refUrl)
if (resolved && resolved !== resolveImageUrl(frameUrl)) {
content.push({ type: 'image_url', image_url: { url: resolved }, role: 'reference_image' })
}
}
}
content.push({ type: 'text', text: prompt })
const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify({ model, content, duration: durationSec, resolution, generate_audio: false })
})
if (!response.ok) {
const errText = await response.text()
let detail = errText
try { detail = JSON.parse(errText).error?.message || errText } catch {}
throw new Error('视频生成失败: ' + detail)
}
const result = await response.json() as { id: string }
return result.id
}
export async function pollVideoTask(taskId: string): Promise<{ status: string; videoUrl?: string; duration?: number; resolution?: string }> {
const keys = getApiKeys()
const apiKey = keys['doubao']
const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/' + taskId, {
headers: { 'Authorization': 'Bearer ' + apiKey }
})
if (!response.ok) throw new Error('查询视频任务失败: ' + response.status)
const result = await response.json()
return {
status: result.status,
videoUrl: result.content?.video_url,
duration: result.duration,
resolution: result.resolution
}
}
// ===== 长文本拆分 =====
export function buildSplitMessages(novelText: string): ChatMessage[] {
return [
{
role: 'user',
content: `请将以下小说片段拆分为适合制作视频短片的情节单元。每个单元应是一个完整的场景或事件,适合制作 2~12 秒的视频。
要求:
- 每个单元给一个简短标题(如"相遇"、"独白"、"战斗"
- 按原文顺序拆分
- 每个单元 50~300 字,保持情节连贯
请严格按以下格式输出,每行一个:
单元1 | 标题 | 字数
原文内容...
单元2 | 标题 | 字数
原文内容...
不要输出其他内容。`
},
{ role: 'user', content: novelText }
]
}
export function parseSplitResult(text: string): { title: string; content: string }[] {
const segments: { title: string; content: string }[] = []
const lines = text.split('\n')
let current: { title: string; content: string } | null = null
for (const line of lines) {
const match = line.match(/^单元\d+\s*\|\s*(.+?)\s*\|\s*\d+/)
if (match) {
if (current) segments.push(current)
current = { title: match[1].trim(), content: '' }
} else if (current && line.trim()) {
current.content += (current.content ? '\n' : '') + line.trim()
}
}
if (current && current.content) segments.push(current)
// 如果 AI 没按格式输出,直接按段落拆分
if (segments.length === 0) {
const paragraphs = text.split(/\n{2,}/).filter(p => p.trim().length > 20)
for (let i = 0; i < paragraphs.length; i++) {
const p = paragraphs[i]
const title = p.slice(0, 30).replace(/[^一-龥\w]/g, '') || '片段' + (i + 1)
segments.push({ title, content: p.trim() })
}
}
return segments
}
+56
View File
@@ -0,0 +1,56 @@
import { app, BrowserWindow } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipc-handlers'
let mainWindow: BrowserWindow | null = null
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 640,
show: false,
title: 'EAnime - AI漫剧制作助手',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.eanime')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
+143
View File
@@ -0,0 +1,143 @@
import { ipcMain } from 'electron'
import {
getApiKeys, getUiApiKeys, getConfigFileApiKeys,
setApiKey, deleteApiKey, openConfigFile, getConfigPath, getEndpoints,
getCharacters, saveCharacter, deleteCharacter,
getScenes, saveScene, deleteScene,
getDataDir, getImagesDir, setDataDir, openFolder, pickFolder,
getSessions, getSession, saveSession, deleteSession,
downloadImage, readImageBase64, imagesDir
} from './store'
import { generateCharacter, generateScene, createVideoTask, pollVideoTask, buildScriptMessages, buildSplitMessages, parseSplitResult, chatCompletion } from './doubao'
import { deepseekChat, hasDeepSeek } from './deepseek'
function wrap<T>(fn: () => T): T {
try { return fn() } catch (err) { throw new Error((err as Error).message || '操作失败') }
}
async function wrapAsync<T>(fn: () => Promise<T>): Promise<T> {
try { return await fn() } catch (err) { throw new Error((err as Error).message || '操作失败') }
}
export function registerIpcHandlers(): void {
// === API Key ===
ipcMain.handle('store:getApiKeys', () => wrap(() => getApiKeys()))
ipcMain.handle('store:getUiApiKeys', () => wrap(() => getUiApiKeys()))
ipcMain.handle('store:getConfigFileApiKeys', () => wrap(() => getConfigFileApiKeys()))
ipcMain.handle('store:setApiKey', (_e, p, k) => wrap(() => { setApiKey(p, k); return { success: true } }))
ipcMain.handle('store:deleteApiKey', (_e, p) => wrap(() => { deleteApiKey(p); return { success: true } }))
ipcMain.handle('store:openConfig', () => wrap(() => { openConfigFile(); return { success: true } }))
ipcMain.handle('store:getConfigPath', () => wrap(() => getConfigPath()))
ipcMain.handle('store:getEndpoints', () => wrap(() => getEndpoints()))
ipcMain.handle('store:getDataDir', () => wrap(() => getDataDir()))
ipcMain.handle('store:getImagesDir', () => wrap(() => getImagesDir()))
ipcMain.handle('store:setDataDir', (_e, path: string) => wrap(() => setDataDir(path)))
ipcMain.handle('store:openFolder', (_e, path: string) => wrap(() => openFolder(path)))
ipcMain.handle('store:pickFolder', () => wrapAsync(() => pickFolder()))
// === 角色库 ===
ipcMain.handle('characters:getAll', () => wrap(() => getCharacters()))
ipcMain.handle('characters:save', async (_e, c) => {
return wrapAsync(async () => {
if (c.imageUrl && !c.imageUrl.startsWith('data:') && !c.imageUrl.startsWith(imagesDir())) {
try {
c.imageUrl = await downloadImage(c.imageUrl, c.id + '.jpg')
} catch (e) {
console.warn('图片下载失败,保留原始 URL:', (e as Error).message)
}
}
return saveCharacter(c)
})
})
ipcMain.handle('characters:delete', (_e, id) => wrap(() => { deleteCharacter(id); return { success: true } }))
// === 场景库 ===
ipcMain.handle('scenes:getAll', () => wrap(() => getScenes()))
ipcMain.handle('scenes:save', async (_e, s) => {
return wrapAsync(async () => {
if (s.imageUrl && !s.imageUrl.startsWith('data:') && !s.imageUrl.startsWith(imagesDir())) {
try { s.imageUrl = await downloadImage(s.imageUrl, 'scene_' + s.id + '.jpg') } catch {}
}
return saveScene(s)
})
})
ipcMain.handle('scenes:delete', (_e, id) => wrap(() => { deleteScene(id); return { success: true } }))
// === 图片 ===
ipcMain.handle('image:readBase64', (_e, path: string) => {
return wrap(() => readImageBase64(path))
})
// === 会话管理 ===
ipcMain.handle('sessions:getAll', () => wrap(() => getSessions()))
ipcMain.handle('sessions:get', (_e, id) => wrap(() => getSession(id) || null))
ipcMain.handle('sessions:save', (_e, s) => wrap(() => saveSession(s)))
ipcMain.handle('sessions:delete', (_e, id) => wrap(() => { deleteSession(id); return { success: true } }))
// === 豆包 ===
ipcMain.handle('doubao:generateCharacter', async (_e, input, chatModel, imageModel, history, style) => {
return wrapAsync(async () => {
const endpoints = getEndpoints()
let cm = chatModel || endpoints['doubao'] || 'doubao-seed-2-0-mini-260428'
if (cm.includes('deepseek')) cm = 'doubao-seed-2-0-mini-260428'
const im = imageModel || endpoints['doubaoImage'] || 'doubao-seedream-5-0-260128'
return await generateCharacter(input, cm, im, history, style)
})
})
ipcMain.handle('doubao:generateScene', async (_e, input, chatModel, imageModel, history, style) => {
return wrapAsync(async () => {
const endpoints = getEndpoints()
let scm = chatModel || endpoints['doubao'] || 'doubao-seed-2-0-mini-260428'
if (scm.includes('deepseek')) scm = 'doubao-seed-2-0-mini-260428'
const im = imageModel || endpoints['doubaoImage'] || 'doubao-seedream-5-0-260128'
return await generateScene(input, scm, im, history, style)
})
})
// === 视频/脚本 ===
ipcMain.handle('doubao:generateScript', async (_e, novelText, duration, style, model) => {
return wrapAsync(async () => {
const messages = buildScriptMessages(novelText, duration, style)
// 优先用 DeepSeek(生成脚本更强),没配 key 就豆包
if (hasDeepSeek()) {
try {
return await deepseekChat(messages as { role: string; content: string }[], model || 'deepseek-v4-pro')
} catch (e) {
console.warn('DeepSeek 调用失败,fallback 到豆包:', (e as Error).message)
}
}
const endpoints = getEndpoints()
const cm = model || endpoints['doubao'] || 'doubao-seed-2-0-mini-260428'
return await chatCompletion(messages, cm)
})
})
ipcMain.handle('doubao:splitNovel', async (_e, novelText) => {
return wrapAsync(async () => {
const messages = buildSplitMessages(novelText)
if (hasDeepSeek()) {
try {
const raw = await deepseekChat(messages as { role: string; content: string }[], 'deepseek-v4-pro')
return parseSplitResult(raw)
} catch (e) {
console.warn('DeepSeek 拆分失败,fallback 到豆包:', (e as Error).message)
}
}
const endpoints = getEndpoints()
const cm = endpoints['doubao'] || 'doubao-seed-2-0-mini-260428'
const raw = await chatCompletion(messages, cm)
return parseSplitResult(raw)
})
})
ipcMain.handle('doubao:createVideo', async (_e, prompt, duration, resolution, model, firstFrameUrl, refUrls) => {
return wrapAsync(async () => {
const m = model || 'doubao-seedance-2-0-fast-260128'
return await createVideoTask(prompt, duration, resolution, m, firstFrameUrl, refUrls)
})
})
ipcMain.handle('doubao:pollVideo', async (_e, taskId) => {
return wrapAsync(async () => await pollVideoTask(taskId))
})
}
+389
View File
@@ -0,0 +1,389 @@
import { app, shell, dialog } from 'electron'
import { join, dirname } from 'path'
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'fs'
import { get } from 'https'
// 懒加载路径,确保 app 已 ready
let _configPath: string | null = null
let _baseDir: string | null = null
function configPath(): string {
if (!_configPath) _configPath = join(app.getPath('userData'), 'eanime.config.json')
return _configPath
}
function baseDir(): string {
if (_baseDir) return _baseDir
// 从 config 读自定义路径,没有则用默认
try {
if (existsSync(configPath())) {
const raw = readFileSync(configPath(), 'utf-8')
const cfg = JSON.parse(raw)
if (cfg.dataDir && typeof cfg.dataDir === 'string') {
_baseDir = cfg.dataDir
}
}
} catch { /* ignore */ }
if (!_baseDir) _baseDir = app.getPath('userData')
// 确保目录存在
if (!existsSync(_baseDir)) mkdirSync(_baseDir, { recursive: true })
return _baseDir
}
/** 清除路径缓存(更换存储目录后调用) */
function resetPathCache(): void {
_baseDir = null
}
function internalPath(): string {
return join(baseDir(), 'eanime.data.json')
}
// ---- 内部存储读写(UI 设置的 key + 角色库) ----
interface StoredCharacter {
id: string
name: string
description: string
imageUrl: string
createdAt: string
}
interface StoredScene {
id: string
name: string
description: string
imageUrl: string
createdAt: string
}
interface ChatMessage {
role: 'user' | 'assistant'
text: string
imageUrl?: string
saved?: boolean
}
interface ChatSession {
id: string
title: string
mode: string
style: string
chatModel: string
imageModel: string
messages: ChatMessage[]
createdAt: string
updatedAt: string
}
interface InternalData {
uiApiKeys: Record<string, string>
characters: StoredCharacter[]
scenes: StoredScene[]
sessions: ChatSession[]
}
function readInternal(): InternalData {
const p = internalPath()
if (!existsSync(p)) return { uiApiKeys: {}, characters: [], scenes: [], sessions: [] }
try {
return JSON.parse(readFileSync(p, 'utf-8'))
} catch {
return { uiApiKeys: {}, characters: [], scenes: [], sessions: [] }
}
}
function writeInternal(data: InternalData): void {
const p = internalPath()
const dir = dirname(p)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8')
}
// ---- 配置文件读取 ----
function readConfigFileKeys(): Record<string, string> {
const p = configPath()
if (!existsSync(p)) return {}
try {
const raw = readFileSync(p, 'utf-8')
const parsed = JSON.parse(raw)
return parsed.apiKeys || {}
} catch {
return {}
}
}
function readConfigEndpoints(): Record<string, string> {
const p = configPath()
if (!existsSync(p)) return {}
try {
const raw = readFileSync(p, 'utf-8')
const parsed = JSON.parse(raw)
return parsed.endpoints || {}
} catch {
return {}
}
}
function ensureConfigFile(): void {
const p = configPath()
if (!existsSync(p)) {
const dir = dirname(p)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(p, JSON.stringify({
apiKeys: {},
endpoints: {
doubao: 'doubao-seed-2-0-mini-260428'
}
}, null, 2), 'utf-8')
}
}
// ===== 暴露的 API =====
export function getApiKeys(): Record<string, string> {
const internal = readInternal()
const configKeys = readConfigFileKeys()
const merged: Record<string, string> = { ...configKeys }
for (const [key, value] of Object.entries(internal.uiApiKeys)) {
if (value) merged[key] = value
}
return merged
}
export function getUiApiKeys(): Record<string, string> {
return readInternal().uiApiKeys
}
export function getConfigFileApiKeys(): Record<string, string> {
return readConfigFileKeys()
}
export function setApiKey(provider: string, key: string): void {
const data = readInternal()
data.uiApiKeys[provider] = key
writeInternal(data)
}
export function deleteApiKey(provider: string): void {
const data = readInternal()
delete data.uiApiKeys[provider]
writeInternal(data)
}
export function getEndpoints(): Record<string, string> {
return readConfigEndpoints()
}
export function getConfigPath(): string {
ensureConfigFile()
return configPath()
}
export function openConfigFile(): void {
ensureConfigFile()
shell.openPath(configPath())
}
// ===== 角色库 =====
export function getCharacters(): StoredCharacter[] {
return readInternal().characters || []
}
export function saveCharacter(char: StoredCharacter): StoredCharacter {
const data = readInternal()
data.characters = data.characters || []
// 检查重名
if (data.characters.some(c => c.name === char.name)) {
throw new Error(`角色「${char.name}」已存在,请使用其他名称`)
}
data.characters.push(char)
writeInternal(data)
return char
}
export function deleteCharacter(id: string): void {
const data = readInternal()
data.characters = (data.characters || []).filter(c => c.id !== id)
writeInternal(data)
}
// ===== 场景库 =====
export function getScenes(): StoredScene[] {
return readInternal().scenes || []
}
export function saveScene(scene: StoredScene): StoredScene {
const data = readInternal()
data.scenes = data.scenes || []
if (data.scenes.some(s => s.name === scene.name)) {
throw new Error(`场景「${scene.name}」已存在,请使用其他名称`)
}
data.scenes.push(scene)
writeInternal(data)
return scene
}
export function deleteScene(id: string): void {
const data = readInternal()
data.scenes = (data.scenes || []).filter(s => s.id !== id)
writeInternal(data)
}
export function getDataDir(): string {
return baseDir()
}
export function getImagesDir(): string {
return imagesDir()
}
/** 递归复制文件夹 */
function copyDirRecursive(src: string, dest: string): void {
if (!existsSync(src)) return
if (!existsSync(dest)) mkdirSync(dest, { recursive: true })
for (const entry of readdirSync(src)) {
const srcPath = join(src, entry)
const destPath = join(dest, entry)
if (statSync(srcPath).isDirectory()) {
copyDirRecursive(srcPath, destPath)
} else {
// 不覆盖已有文件
if (!existsSync(destPath)) {
copyFileSync(srcPath, destPath)
}
}
}
}
export function setDataDir(newPath: string): void {
const oldDir = baseDir()
if (!newPath || newPath === oldDir) return
// 确保新目录存在
if (!existsSync(newPath)) mkdirSync(newPath, { recursive: true })
// 只迁移我们自己的数据文件(避免复制 Electron 缓存文件)
const FILES_TO_MIGRATE = ['eanime.data.json']
for (const file of FILES_TO_MIGRATE) {
const src = join(oldDir, file)
const dest = join(newPath, file)
if (existsSync(src) && !existsSync(dest)) {
copyFileSync(src, dest)
}
}
// 迁移 images 文件夹
const oldImages = join(oldDir, 'images')
if (existsSync(oldImages)) {
copyDirRecursive(oldImages, join(newPath, 'images'))
}
// 更新 config
ensureConfigFile()
const raw = readFileSync(configPath(), 'utf-8')
const cfg = JSON.parse(raw)
cfg.dataDir = newPath
writeFileSync(configPath(), JSON.stringify(cfg, null, 2), 'utf-8')
// 重置缓存,让后续读取走新路径
resetPathCache()
}
export function openFolder(path: string): void {
shell.openPath(path)
}
export async function pickFolder(): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory']
})
return result.canceled ? null : result.filePaths[0]
}
// ===== 图片本地存储 =====
export function imagesDir(): string {
const dir = join(baseDir(), 'images')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
return dir
}
/** 下载远程图片到本地,返回本地文件路径 */
export async function downloadImage(url: string, filename?: string): Promise<string> {
if (!url) throw new Error('图片 URL 为空')
const ext = url.split('?')[0].endsWith('.png') ? '.png' : '.jpg'
const fname = filename || (Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + ext)
const localPath = join(imagesDir(), fname)
// 如果已经是本地路径,跳过下载
if (url.includes('images') && !url.startsWith('http')) return url
return new Promise((resolve, reject) => {
get(url, (res) => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`下载图片失败: ${res.statusCode}`))
return
}
// 处理重定向
if (res.statusCode && res.statusCode >= 300 && res.headers.location) {
downloadImage(res.headers.location, filename).then(resolve).catch(reject)
return
}
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
try {
writeFileSync(localPath, Buffer.concat(chunks))
resolve(localPath)
} catch (e) {
reject(new Error(`写文件失败: ${(e as Error).message}`))
}
})
res.on('error', reject)
}).on('error', reject)
})
}
/** 读取本地图片并转为 base64 data URL */
export function readImageBase64(localPath: string): string {
try {
if (!existsSync(localPath)) return ''
const buf = readFileSync(localPath)
const ext = localPath.endsWith('.png') ? 'png' : 'jpeg'
return `data:image/${ext};base64,${buf.toString('base64')}`
} catch {
return ''
}
}
// ===== 会话管理 =====
export function getSessions(): ChatSession[] {
return readInternal().sessions || []
}
export function getSession(id: string): ChatSession | undefined {
return (readInternal().sessions || []).find(s => s.id === id)
}
export function saveSession(session: ChatSession): ChatSession {
const data = readInternal()
data.sessions = data.sessions || []
const idx = data.sessions.findIndex(s => s.id === session.id)
if (idx >= 0) {
data.sessions[idx] = session
} else {
data.sessions.push(session)
}
writeInternal(data)
return session
}
export function deleteSession(id: string): void {
const data = readInternal()
data.sessions = (data.sessions || []).filter(s => s.id !== id)
writeInternal(data)
}
+52
View File
@@ -0,0 +1,52 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
// API Key & 配置
getApiKeys: () => ipcRenderer.invoke('store:getApiKeys'),
getUiApiKeys: () => ipcRenderer.invoke('store:getUiApiKeys'),
getConfigFileApiKeys: () => ipcRenderer.invoke('store:getConfigFileApiKeys'),
setApiKey: (p, k) => ipcRenderer.invoke('store:setApiKey', p, k),
deleteApiKey: (p) => ipcRenderer.invoke('store:deleteApiKey', p),
getConfigPath: () => ipcRenderer.invoke('store:getConfigPath'),
openConfig: () => ipcRenderer.invoke('store:openConfig'),
getEndpoints: () => ipcRenderer.invoke('store:getEndpoints'),
getDataDir: () => ipcRenderer.invoke('store:getDataDir'),
getImagesDir: () => ipcRenderer.invoke('store:getImagesDir'),
setDataDir: (path: string) => ipcRenderer.invoke('store:setDataDir', path),
openFolder: (path: string) => ipcRenderer.invoke('store:openFolder', path),
pickFolder: () => ipcRenderer.invoke('store:pickFolder'),
// 角色库
getCharacters: () => ipcRenderer.invoke('characters:getAll'),
saveCharacter: (c) => ipcRenderer.invoke('characters:save', c),
deleteCharacter: (id) => ipcRenderer.invoke('characters:delete', id),
// 场景库
getScenes: () => ipcRenderer.invoke('scenes:getAll'),
saveScene: (s) => ipcRenderer.invoke('scenes:save', s),
deleteScene: (id) => ipcRenderer.invoke('scenes:delete', id),
// 图片
readImageBase64: (path: string) => ipcRenderer.invoke('image:readBase64', path),
// 会话管理
getSessions: () => ipcRenderer.invoke('sessions:getAll'),
getSession: (id) => ipcRenderer.invoke('sessions:get', id),
saveSession: (s) => ipcRenderer.invoke('sessions:save', s),
deleteSession: (id) => ipcRenderer.invoke('sessions:delete', id),
// 豆包
generateCharacter: (input, chatModel?, imageModel?, history?, style?) =>
ipcRenderer.invoke('doubao:generateCharacter', input, chatModel || '', imageModel || '', history || null, style || ''),
generateScene: (input, chatModel?, imageModel?, history?, style?) =>
ipcRenderer.invoke('doubao:generateScene', input, chatModel || '', imageModel || '', history || null, style || ''),
splitNovel: (novelText) => ipcRenderer.invoke('doubao:splitNovel', novelText),
generateScript: (novelText, duration, style?, model?) =>
ipcRenderer.invoke('doubao:generateScript', novelText, duration, style || '', model || ''),
createVideo: (prompt, duration, resolution, model?, firstFrameUrl?, refUrls?) =>
ipcRenderer.invoke('doubao:createVideo', prompt, duration, resolution, model || '', firstFrameUrl || '', refUrls || []),
pollVideo: (taskId) =>
ipcRenderer.invoke('doubao:pollVideo', taskId)
})
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EAnime - AI漫剧制作助手</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<template>
<MainLayout />
</template>
<script setup lang="ts">
import MainLayout from './layouts/MainLayout.vue'
</script>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background-color: #f5f5f5;
}
#app {
width: 100%;
height: 100vh;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
interface StoredCharacter {
id: string
name: string
description: string
imageUrl: string
createdAt: string
}
interface StoredScene {
id: string
name: string
description: string
imageUrl: string
createdAt: string
}
interface ChatMessage {
role: 'user' | 'assistant'
text: string
imageUrl?: string
saved?: boolean
}
interface ChatSession {
id: string
title: string
mode: string
style: string
chatModel: string
imageModel: string
messages: ChatMessage[]
createdAt: string
updatedAt: string
}
interface ElectronAPI {
platform: string
getApiKeys: () => Promise<Record<string, string>>
getUiApiKeys: () => Promise<Record<string, string>>
getConfigFileApiKeys: () => Promise<Record<string, string>>
setApiKey: (provider: string, key: string) => Promise<void>
deleteApiKey: (provider: string) => Promise<void>
getConfigPath: () => Promise<string>
openConfig: () => Promise<void>
getEndpoints: () => Promise<Record<string, string>>
getDataDir: () => Promise<string>
getImagesDir: () => Promise<string>
setDataDir: (path: string) => Promise<void>
openFolder: (path: string) => Promise<void>
pickFolder: () => Promise<string | null>
getCharacters: () => Promise<StoredCharacter[]>
saveCharacter: (char: StoredCharacter) => Promise<StoredCharacter>
deleteCharacter: (id: string) => Promise<void>
getScenes: () => Promise<StoredScene[]>
saveScene: (scene: StoredScene) => Promise<StoredScene>
deleteScene: (id: string) => Promise<void>
readImageBase64: (path: string) => Promise<string>
getSessions: () => Promise<ChatSession[]>
getSession: (id: string) => Promise<ChatSession | undefined>
saveSession: (session: ChatSession) => Promise<ChatSession>
deleteSession: (id: string) => Promise<void>
generateCharacter: (input: string, chatModel?: string, imageModel?: string, history?: { role: string; text: string; imageUrl?: string }[] | null, style?: string) => Promise<{ text: string; imageUrl: string }>
generateScene: (input: string, chatModel?: string, imageModel?: string, history?: { role: string; text: string }[] | null, style?: string) => Promise<{ text: string; imageUrl: string }>
splitNovel: (novelText: string) => Promise<{ title: string; content: string }[]>
generateScript: (novelText: string, duration: number, style?: string, model?: string) => Promise<string>
createVideo: (prompt: string, duration: number, resolution: string, model?: string, firstFrameUrl?: string, refUrls?: string[]) => Promise<string>
pollVideo: (taskId: string) => Promise<{ status: string; videoUrl?: string; duration?: number; resolution?: string }>
}
interface Window {
electronAPI: ElectronAPI
}
+117
View File
@@ -0,0 +1,117 @@
<template>
<el-container style="height: 100vh">
<!-- 侧边导航 -->
<el-aside width="220px" class="sidebar">
<div class="logo-section">
<h1 class="logo-text">EAnime</h1>
<p class="logo-sub">AI漫剧制作助手</p>
</div>
<el-menu
:default-active="currentRoute"
:router="true"
class="nav-menu"
background-color="#1a1a2e"
text-color="#a0a0b8"
active-text-color="#64ffda"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span>首页</span>
</el-menu-item>
<el-menu-item index="/characters">
<el-icon><UserFilled /></el-icon>
<span>角色库</span>
</el-menu-item>
<el-menu-item index="/scenes">
<el-icon><PictureFilled /></el-icon>
<span>场景库</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>设置</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容 -->
<el-container>
<el-header class="header">
<h2 class="page-title">{{ currentTitle }}</h2>
</el-header>
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { HomeFilled, UserFilled, PictureFilled, Setting } from '@element-plus/icons-vue'
const route = useRoute()
const currentRoute = computed(() => route.path)
const currentTitle = computed(() => (route.meta?.title as string) || 'EAnime')
</script>
<style scoped>
.sidebar {
background-color: #1a1a2e;
display: flex;
flex-direction: column;
border-right: 1px solid #16213e;
}
.logo-section {
padding: 24px 20px 16px;
text-align: center;
border-bottom: 1px solid #16213e;
}
.logo-text {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #64ffda;
letter-spacing: 2px;
}
.logo-sub {
margin: 4px 0 0;
font-size: 12px;
color: #a0a0b8;
}
.nav-menu {
border-right: none;
flex: 1;
}
.nav-menu .el-menu-item {
margin: 4px 8px;
border-radius: 8px;
}
.header {
display: flex;
align-items: center;
background: #fff;
border-bottom: 1px solid #e8e8e8;
padding: 0 24px;
height: 60px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.main-content {
background-color: #f5f5f5;
padding: 24px;
overflow-y: auto;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.use(ElementPlus, { locale: undefined })
app.mount('#app')
+19
View File
@@ -0,0 +1,19 @@
import { createRouter, createMemoryHistory } from 'vue-router'
import HomePage from '../views/HomePage.vue'
import CharactersPage from '../views/CharactersPage.vue'
import ScenesPage from '../views/ScenesPage.vue'
import SettingsPage from '../views/SettingsPage.vue'
const routes = [
{ path: '/', name: 'home', component: HomePage, meta: { title: '首页' } },
{ path: '/characters', name: 'characters', component: CharactersPage, meta: { title: '角色库' } },
{ path: '/scenes', name: 'scenes', component: ScenesPage, meta: { title: '场景库' } },
{ path: '/settings', name: 'settings', component: SettingsPage, meta: { title: '设置' } }
]
const router = createRouter({
history: createMemoryHistory(),
routes
})
export default router
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="characters-page">
<div class="page-toolbar">
<el-input v-model="searchQuery" placeholder="搜索角色..." prefix-icon="Search" clearable style="width: 300px" />
<span v-if="searchQuery" class="search-count">找到 {{ filteredCharacters.length }} 个角色</span>
<el-tooltip :content="imagesDir" placement="bottom">
<el-button text size="small" @click="openDataDir">
<el-icon><FolderOpened /></el-icon>
打开图片目录
</el-button>
</el-tooltip>
</div>
<div v-if="filteredCharacters.length === 0" class="empty">
<el-empty :description="searchQuery ? '没有匹配的角色' : '角色库还是空的,去首页生成一个吧'" :image-size="80" />
</div>
<div v-else class="character-grid">
<div v-for="char in filteredCharacters" :key="char.id" class="character-card">
<el-image
:src="imageSrc(char)"
fit="contain"
class="char-image"
loading="lazy"
>
<template #error>
<div class="img-placeholder">🖼</div>
</template>
</el-image>
<div class="char-info">
<h3 class="char-name">{{ char.name }}</h3>
<p class="char-desc">{{ previewDesc(char.description) }}</p>
<div class="char-footer">
<span class="char-date">{{ formatDate(char.createdAt) }}</span>
<el-button size="small" type="danger" text @click="handleDelete(char)">删除</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FolderOpened } from '@element-plus/icons-vue'
const imagesDir = ref('')
async function openDataDir() {
if (imagesDir.value) {
await window.electronAPI.openFolder(imagesDir.value)
}
}
const characters = ref<StoredCharacter[]>([])
const searchQuery = ref('')
const imageCache = ref<Record<string, string>>({})
const filteredCharacters = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return characters.value
return characters.value.filter(
c => c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
)
})
function imageSrc(char: StoredCharacter): string {
return imageCache.value[char.id] || char.imageUrl || ''
}
function previewDesc(text: string): string {
const match = text.match(/【角色名】(.+?)[\n【]/)
if (match) return match[1].trim()
return text.slice(0, 60) + '...'
}
function formatDate(iso: string): string {
return iso.slice(0, 10)
}
async function resolveImages() {
for (const char of characters.value) {
if (!char.imageUrl) continue
// 已经是 data URL 或 HTTP URL 直接用,本地路径就转 base64
if (char.imageUrl.startsWith('data:') || char.imageUrl.startsWith('http')) continue
try {
const b64 = await window.electronAPI.readImageBase64(char.imageUrl)
if (b64) imageCache.value[char.id] = b64
} catch { /* 图片不存在就用原始值 */ }
}
}
async function load() {
try {
const [chars, dir] = await Promise.all([
window.electronAPI.getCharacters(),
window.electronAPI.getImagesDir()
])
characters.value = chars
imagesDir.value = dir
await resolveImages()
} catch {
ElMessage.error('加载角色库失败')
}
}
async function handleDelete(char: StoredCharacter) {
try {
await ElMessageBox.confirm(`确定删除「${char.name}」?`, '确认删除')
await window.electronAPI.deleteCharacter(char.id)
characters.value = characters.value.filter(c => c.id !== char.id)
ElMessage.success('已删除')
} catch { /* 取消 */ }
}
onMounted(load)
</script>
<style scoped>
.characters-page { max-width: 1000px; margin: 0 auto; }
.page-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.search-count { font-size: 13px; color: #999; }
.empty { padding: 80px 0; }
.character-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
.character-card { background: #fff; border-radius: 12px; overflow: hidden; border: 1px solid #eee; transition: box-shadow 0.2s; }
.character-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.char-image { width: 100%; height: 300px; display: block; background: #f8f8f8; }
.img-placeholder { width: 100%; height: 300px; display: flex; align-items: center; justify-content: center; font-size: 48px; background: #f5f5f5; }
.char-info { padding: 14px 16px; }
.char-name { margin: 0 0 6px; font-size: 16px; font-weight: 600; color: #1a1a2e; }
.char-desc { margin: 0 0 10px; font-size: 13px; color: #888; line-height: 1.4; }
.char-footer { display: flex; justify-content: space-between; align-items: center; }
.char-date { font-size: 12px; color: #bbb; }
</style>
+905
View File
@@ -0,0 +1,905 @@
<template>
<div class="chat-layout">
<!-- 会话侧栏 -->
<div class="session-sidebar">
<div class="session-header">
<span class="session-title">会话历史</span>
<el-button size="small" type="primary" :icon="Plus" circle @click="newSession" />
</div>
<div class="session-list">
<div
v-for="s in sessions"
:key="s.id"
class="session-item"
:class="{ active: currentSessionId === s.id }"
@click="switchSession(s.id)"
>
<div class="session-info">
<span class="session-name">{{ s.title }}</span>
<span class="session-time">{{ formatSessionTime(s.createdAt) }}</span>
</div>
<el-button
size="small"
text
type="danger"
:icon="Delete"
@click.stop="handleDeleteSession(s)"
/>
</div>
<div v-if="sessions.length === 0" class="session-empty">暂无会话</div>
</div>
</div>
<!-- 聊天区 -->
<div class="chat-area">
<!-- 顶部工具栏 -->
<div class="toolbar">
<el-radio-group v-model="mode" @change="onModeChange" size="small">
<el-radio-button value="character">
<el-icon><Avatar /></el-icon> 生成角色
</el-radio-button>
<el-radio-button value="scene">
<el-icon><Picture /></el-icon> 生成场景
</el-radio-button>
<el-radio-button value="script">
<el-icon><Document /></el-icon> 生成脚本
</el-radio-button>
</el-radio-group>
<el-select v-if="mode === 'script'" v-model="videoDuration" size="small" style="width: 90px">
<el-option v-for="d in [4,5,6,8,10,12,15]" :key="d" :label="d+'秒'" :value="d" />
</el-select>
<el-select v-model="currentStyle" placeholder="风格" size="small" style="width: 120px">
<el-option v-for="s in styles" :key="s.key" :label="s.label" :value="s.key" />
</el-select>
<div class="model-selectors">
<!-- 对话模型仅脚本模式 -->
<div class="model-pair" v-if="mode === 'script'">
<span class="model-label">对话</span>
<el-select v-model="chatModel" filterable allow-create placeholder="对话模型" size="small" style="width: 180px">
<el-option v-for="m in scriptChatModels" :key="m" :label="m" :value="m" />
</el-select>
</div>
<!-- 生图模型非脚本模式 -->
<div class="model-pair" v-if="mode !== 'script'">
<span class="model-label">生图</span>
<el-select v-model="imageModel" filterable allow-create placeholder="生图模型" size="small" style="width: 180px">
<el-option-group label="推荐">
<el-option v-for="m in recommendImage" :key="m" :label="m" :value="m" />
</el-option-group>
<el-option-group v-if="imageOther.length" label="其他">
<el-option v-for="m in imageOther" :key="m" :label="m" :value="m" />
</el-option-group>
</el-select>
</div>
<!-- 视频模型仅脚本模式 -->
<div class="model-pair" v-if="mode === 'script'">
<span class="model-label">视频</span>
<el-select v-model="videoModel" filterable allow-create size="small" style="width: 180px">
<el-option v-for="v in VIDEO_MODELS" :key="v" :label="v" :value="v" />
</el-select>
</div>
</div>
</div>
<!-- 消息列表 -->
<div class="messages" ref="messagesRef">
<div v-if="messages.length === 0" class="welcome">
<div class="welcome-icon">🎨</div>
<h2>EAnime 创作助手</h2>
<p v-if="mode === 'character'">描述你想要生成的角色AI 会输出设定并绘制形象</p>
<p v-else-if="mode === 'scene'">描述你想要生成的场景AI 会输出设定并绘制画面</p>
<div class="suggestions">
<el-tag v-for="s in suggestions" :key="s" class="suggestion-tag" @click="useSuggestion(s)">
{{ s }}
</el-tag>
</div>
</div>
<div v-for="(msg, i) in messages" :key="i" class="message" :class="msg.role">
<div class="msg-avatar">{{ msg.role === 'user' ? '我' : 'E' }}</div>
<div class="msg-content">
<div v-if="msg.text" class="msg-bubble">
<div class="msg-text">{{ msg.text }}</div>
</div>
<div v-if="msg.imageUrl" class="msg-image">
<el-image
:src="msg.imageUrl"
:preview-src-list="[msg.imageUrl]"
fit="contain"
style="max-width: 360px; max-height: 360px; border-radius: 8px;"
loading="lazy"
>
<template #error>
<div class="image-error">图片加载失败</div>
</template>
</el-image>
<div v-if="msg.role === 'assistant' && msg.imageUrl" class="msg-actions">
<el-button
v-if="!msg.saved"
type="primary" size="small" plain
@click="showSaveDialog(msg)"
>
<el-icon><Plus /></el-icon> 加入{{ mode === 'scene' ? '场景库' : '角色库' }}
</el-button>
<el-tag v-else size="small" type="success">已保存</el-tag>
</div>
</div>
<!-- 脚本模式视频生成控制不在 imageUrl 条件内 -->
<div v-if="mode === 'script' && msg.role === 'assistant' && i === messages.length - 1" class="script-actions">
<el-divider />
<div class="video-controls">
<!-- 角色映射 -->
<div class="control-section">
<div class="section-title">角色映射</div>
<div v-for="(mapping, mi) in characterMappings" :key="mi" class="control-row">
<el-input v-model="mapping.scriptName" size="small" placeholder="脚本中角色名" style="width:130px" />
<span style="color:#999"></span>
<el-select v-model="mapping.charId" size="small" placeholder="角色库角色" style="width:140px" clearable @change="onCharMapped(mapping)">
<el-option v-for="c in characterList" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-button size="small" text type="danger" @click="characterMappings.splice(mi,1)" :disabled="characterMappings.length<=1"></el-button>
</div>
<el-button size="small" text type="primary" @click="characterMappings.push({scriptName:'',charId:''})">+ 添加角色映射</el-button>
</div>
<!-- 场景/运镜/灯光 -->
<div class="control-row">
<span class="control-label">场景</span>
<el-select v-model="selectedSceneImage" size="small" placeholder="(可选)场景库" style="width:140px" clearable>
<el-option v-for="s in sceneList" :key="s.id" :label="s.name" :value="s.imageUrl" />
</el-select>
<span class="control-label">运镜</span>
<el-select v-model="selectedCamera" size="small" placeholder="选择运镜" style="width:130px" clearable>
<el-option v-for="c in CAMERAS" :key="c.key" :label="c.label" :value="c.prompt" />
</el-select>
<span class="control-label">灯光</span>
<el-select v-model="selectedLighting" size="small" placeholder="选择灯光" style="width:130px" clearable>
<el-option v-for="l in LIGHTS" :key="l.key" :label="l.label" :value="l.prompt" />
</el-select>
</div>
<!-- 生成按钮 -->
<div class="control-row">
<el-select v-model="videoResolution" size="small" style="width:90px">
<el-option v-for="r in ['480p','720p','1080p']" :key="r" :label="r" :value="r" />
</el-select>
<el-button type="primary" size="small" :loading="generatingVideo" @click="startVideoGeneration(msg)">
<el-icon><VideoCameraFilled /></el-icon> 生成视频
</el-button>
<el-button v-if="videoTaskId" size="small" @click="checkVideoStatus">
<el-icon><Refresh /></el-icon> 查询状态
</el-button>
</div>
<!-- 视频结果 -->
<div v-if="videoResult" class="video-result">
<video v-if="videoResult.videoUrl" :src="videoResult.videoUrl" controls style="max-width:100%;max-height:300px;border-radius:8px" />
<el-button v-if="videoResult.videoUrl" size="small" type="success" style="margin-top:8px" @click="saveLastFrame(videoResult.videoUrl!)">
保存尾帧到场景库
</el-button>
</div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="message assistant">
<div class="msg-avatar">E</div>
<div class="msg-content">
<div class="msg-bubble">
<div v-if="stepMessage" class="step-msg">{{ stepMessage }}</div>
<div class="typing-dots"><span>.</span><span>.</span><span>.</span></div>
</div>
</div>
</div>
</div>
<!-- 输入区 -->
<div class="input-area">
<el-input v-model="inputText" type="textarea" :rows="2" placeholder="描述你想要生成的角色..." :disabled="loading" @keydown.enter.prevent="sendMessage" />
<el-button type="primary" :loading="loading" :disabled="!inputText.trim()" @click="sendMessage" class="send-btn">
<el-icon><Promotion /></el-icon> 发送
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Avatar, Picture, Document, Promotion, Plus, Delete, VideoCameraFilled, Refresh } from '@element-plus/icons-vue'
const CAMERAS = [
{ key: 'dolly_in', label: '推镜', prompt: 'camera slowly pushes in' },
{ key: 'dolly_out', label: '拉镜', prompt: 'camera slowly pulls back' },
{ key: 'pan_right', label: '右摇', prompt: 'camera pans right' },
{ key: 'pan_left', label: '左摇', prompt: 'camera pans left' },
{ key: 'tilt_up', label: '仰拍', prompt: 'low angle tilting upward' },
{ key: 'tilt_down', label: '俯拍', prompt: 'high angle overhead shot' },
{ key: 'tracking', label: '跟镜', prompt: 'tracking alongside subject' },
{ key: 'close_up', label: '特写', prompt: 'close-up detail shot' },
{ key: 'wide_shot', label: '全景', prompt: 'wide establishing shot' },
{ key: 'static', label: '静止', prompt: 'static no movement' }
]
const VIDEO_MODELS = [
'doubao-seedance-2-0-fast-260128',
'doubao-seedance-2-0-mini-260615',
'doubao-seedance-1-0-pro-250528',
'doubao-seedance-1-0-pro-fast-251015'
]
const LIGHTS = [
{ key: 'natural', label: '自然光', prompt: 'natural daylight' },
{ key: 'rembrandt', label: '伦勃朗', prompt: 'Rembrandt dramatic light' },
{ key: 'side_light', label: '侧光', prompt: 'side lighting half shadow' },
{ key: 'backlight', label: '逆光', prompt: 'backlight rim light' },
{ key: 'soft_diffuse', label: '柔光', prompt: 'soft diffused light' },
{ key: 'neon', label: '霓虹', prompt: 'neon colorful lights' },
{ key: 'warm', label: '暖光', prompt: 'warm golden light' },
{ key: 'cool', label: '冷光', prompt: 'cool blue light' }
]
type Mode = 'character' | 'scene' | 'script'
interface Message {
role: 'user' | 'assistant'
text: string
imageUrl?: string
saved?: boolean
}
// ===== 状态 =====
const mode = ref<Mode>('character')
const inputText = ref('')
const messages = ref<Message[]>([])
const loading = ref(false)
const stepMessage = ref('')
const messagesRef = ref<HTMLElement | null>(null)
// 风格 & 视频
const currentStyle = ref('anime')
const videoDuration = ref(15)
const selectedCamera = ref('')
const selectedLighting = ref('')
const generatingVideo = ref(false)
const videoTaskId = ref('')
const videoResolution = ref('720p')
const videoModel = ref('doubao-seedance-2-0-fast-260128')
const videoResult = ref<{ videoUrl?: string } | null>(null)
const selectedSceneImage = ref('')
const mappedCharacter = ref('')
const characterList = ref<StoredCharacter[]>([])
const sceneList = ref<StoredScene[]>([])
const characterMappings = ref<{ scriptName: string; charId: string }[]>([
{ scriptName: '', charId: '' }
])
const charImageMap = ref<Record<string, string>>({})
const charStyles = [
{ key: 'anime', label: '日漫风格' },
{ key: 'comic', label: '美漫风格' },
{ key: 'webtoon', label: '韩漫画风' },
{ key: 'realistic', label: '写实风格' },
{ key: 'ink', label: '水墨风格' },
{ key: 'cyberpunk', label: '赛博朋克' }
]
const sceneStyles = [
{ key: 'school', label: '校园' },
{ key: 'city', label: '都市' },
{ key: 'nature', label: '自然' },
{ key: 'scifi', label: '科幻' },
{ key: 'classical', label: '古典' },
{ key: 'cyberpunk', label: '赛博朋克' },
{ key: 'fantasy', label: '奇幻' },
{ key: 'wasteland', label: '末日废土' }
]
const styles = computed(() => mode.value === 'scene' ? sceneStyles : charStyles)
// 会话
const sessions = ref<ChatSession[]>([])
const currentSessionId = ref<string>('')
// 模型
const chatModel = ref('doubao-seed-2-0-mini-260428')
const imageModel = ref('doubao-seedream-5-0-260128')
const allModels = ref<string[]>([])
const recommendChat = ['doubao-seed-2-0-mini-260428', 'doubao-seed-2-1-turbo-260628', 'doubao-seed-2-1-pro-260628']
const recommendImage = ['doubao-seedream-5-0-260128', 'doubao-seedream-5-0-pro-260628', 'doubao-seedream-4-5-251128']
const chatOther = computed(() => allModels.value.filter(m => !recommendChat.includes(m) && !recommendImage.includes(m) && !m.includes('deepseek')))
const imageOther = computed(() => allModels.value.filter(m => !recommendChat.includes(m) && !recommendImage.includes(m) && !m.includes('deepseek')))
// 脚本模式用的对话模型推荐(含 DeepSeek)
const scriptChatModels = ['doubao-seed-2-0-mini-260428', 'doubao-seed-2-1-turbo-260628', 'deepseek-v4-pro', 'deepseek-v4-flash']
const suggestions = [
'一个15岁的天才魔法少女,红色双马尾,性格活泼',
'冷酷的暗夜刺客,黑色风衣,银发独眼',
'温柔治愈的图书馆管理员,戴圆框眼镜'
]
// ===== 会话管理 =====
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
/** 生成会话标题(取第一条用户消息) */
function makeTitle(text: string): string {
return text.length > 30 ? text.slice(0, 30) + '...' : text
}
/** 格式化会话时间显示 */
function formatSessionTime(iso: string): string {
const d = new Date(iso)
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
// 今天的只显示时间,非今天的加日期
if (d.toDateString() === now.toDateString()) return time
return `${d.getMonth() + 1}/${d.getDate()} ${time}`
}
/** 新建会话 */
async function newSession() {
if (currentSessionId.value && messages.value.length > 0) {
await saveCurrentSession()
}
currentSessionId.value = ''
messages.value = []
inputText.value = ''
}
/** 切换会话 */
async function switchSession(id: string) {
if (currentSessionId.value && messages.value.length > 0) {
await saveCurrentSession()
}
if (id === currentSessionId.value) return
currentSessionId.value = id
const s = sessions.value.find(x => x.id === id)
if (s) {
messages.value = s.messages.map(m => ({ ...m }))
mode.value = s.mode as Mode
currentStyle.value = s.style || 'anime'
chatModel.value = s.chatModel
imageModel.value = s.imageModel
}
await scrollToBottom()
}
/** 删除会话 */
async function handleDeleteSession(s: ChatSession) {
try {
await ElMessageBox.confirm(`确定删除会话「${s.title}」?`, '确认删除')
await window.electronAPI.deleteSession(s.id)
sessions.value = sessions.value.filter(x => x.id !== s.id)
if (currentSessionId.value === s.id) {
currentSessionId.value = ''
messages.value = []
inputText.value = ''
}
ElMessage.success('已删除')
} catch { /* 取消 */ }
}
/** 保存当前会话到本地 */
async function saveCurrentSession(options?: { title?: string }): Promise<string | undefined> {
if (messages.value.length === 0) return
// 从第一条用户消息取标题
const firstUser = messages.value.find(m => m.role === 'user')
const title = options?.title || (firstUser ? makeTitle(firstUser.text) : '新会话')
const now = new Date().toISOString()
const session: ChatSession = {
id: currentSessionId.value || (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)),
title,
mode: mode.value,
style: currentStyle.value,
chatModel: chatModel.value,
imageModel: imageModel.value,
messages: JSON.parse(JSON.stringify(messages.value)),
createdAt: currentSessionId.value ? (sessions.value.find(s => s.id === currentSessionId.value)?.createdAt || now) : now,
updatedAt: now
}
await window.electronAPI.saveSession(session)
currentSessionId.value = session.id
// 刷新会话列表
sessions.value = await window.electronAPI.getSessions()
return session.id
}
/** 加载会话列表 */
async function loadSessions() {
try {
sessions.value = await window.electronAPI.getSessions()
} catch { /* silent */ }
}
// ===== 消息发送 =====
async function sendMessage() {
const text = inputText.value.trim()
if (!text || loading.value) return
inputText.value = ''
// 如果是新会话的第一条消息,创建会话并生成标题
if (messages.value.length === 0) {
// 先保存一个空会话,让用户能切走
const tempId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
currentSessionId.value = tempId
await window.electronAPI.saveSession({
id: tempId,
title: makeTitle(text),
mode: mode.value,
style: currentStyle.value,
chatModel: chatModel.value,
imageModel: imageModel.value,
messages: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
sessions.value = await window.electronAPI.getSessions()
}
messages.value.push({ role: 'user', text })
loading.value = true
try {
await scrollToBottom()
if (mode.value === 'character') {
stepMessage.value = '正在生成角色设定...'
const history = messages.value.slice(0, -1).map(m => ({ role: m.role, text: m.text }))
const result = await window.electronAPI.generateCharacter(text, chatModel.value, imageModel.value, history, currentStyle.value)
stepMessage.value = '正在绘制角色形象...'
messages.value.push({ role: 'assistant', text: result.text, imageUrl: result.imageUrl })
} else if (mode.value === 'scene') {
stepMessage.value = '正在生成场景设定...'
const history = messages.value.slice(0, -1).map(m => ({ role: m.role, text: m.text }))
const result = await window.electronAPI.generateScene(text, chatModel.value, imageModel.value, history, currentStyle.value)
stepMessage.value = '正在绘制场景画面...'
messages.value.push({ role: 'assistant', text: result.text, imageUrl: result.imageUrl })
} else if (mode.value === 'script') {
// 长文本先拆分
if (text.length > 1500) {
stepMessage.value = '正在拆分小说...'
const segments = await window.electronAPI.splitNovel(text)
let reply = '已将小说拆分为 ' + segments.length + ' 个片段:\n\n'
segments.forEach((s, i) => {
reply += '**【片段' + (i + 1) + '】' + s.title + '**\n' + s.content.slice(0, 100) + '...\n\n'
})
reply += '---\n点击下方片段,发送对应编号生成该段脚本(如:发送「1」或复制片段内容)'
messages.value.push({ role: 'assistant', text: reply })
// 暂存片段供后续使用
;(window as Record<string, unknown>).__segments = segments
} else {
stepMessage.value = '正在生成视频脚本...'
const script = await window.electronAPI.generateScript(text, videoDuration.value, currentStyle.value, chatModel.value)
messages.value.push({ role: 'assistant', text: script })
}
} else {
messages.value.push({ role: 'assistant', text: '功能正在开发中' })
}
// 自动保存
await saveCurrentSession()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '未知错误'
ElMessage.error(msg)
messages.value.push({ role: 'assistant', text: `出错了:${msg}` })
await saveCurrentSession()
} finally {
loading.value = false
stepMessage.value = ''
await scrollToBottom()
}
}
/** 保存角色/场景到库 */
async function showSaveDialog(msg: Message) {
try {
const libLabel = mode.value === 'scene' ? '场景库' : '角色库'
const result = await ElMessageBox.prompt('起个名字', '加入' + libLabel, {
inputPlaceholder: '输入名称',
confirmButtonText: '保存',
cancelButtonText: '取消'
})
const name = result.value.trim()
if (!name) { ElMessage.warning('请输入名称'); return }
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
const item = { id, name, description: msg.text, imageUrl: msg.imageUrl || '', createdAt: new Date().toISOString() }
if (mode.value === 'scene') {
await window.electronAPI.saveScene(item)
} else {
await window.electronAPI.saveCharacter(item)
}
msg.saved = true
ElMessage.success(`${name}」已加入${libLabel}`)
} catch (e: unknown) {
const errorMsg = e instanceof Error ? e.message : ''
if (errorMsg.includes('已存在')) {
ElMessage.warning(errorMsg)
return
}
// 取消弹窗或无关错误静默处理
if (!errorMsg || errorMsg.includes('cancel')) return
ElMessage.error(errorMsg)
}
}
/** 切换模式 */
function onCharMapped(mapping: { scriptName: string; charId: string }) {
if (mapping.charId) {
const c = characterList.value.find(x => x.id === mapping.charId)
if (c) charImageMap.value[mapping.charId] = c.imageUrl
}
}
async function startVideoGeneration(msg: Message) {
let prompt = ''
const vpMatch = msg.text.match(/Video Prompt[=\s]*\n?([\s\S]+?)$/)
if (vpMatch) prompt = vpMatch[1].trim()
else prompt = msg.text.slice(-200)
if (selectedCamera.value) prompt = selectedCamera.value + '. ' + prompt
if (selectedLighting.value) prompt = selectedLighting.value + '. ' + prompt
// 收集映射角色的图片
const mappedChars = characterMappings.value.filter(m => m.charId)
// 首帧:优先用场景图
const firstFrameUrl = selectedSceneImage.value || ''
// 角色参考图:所有有库内映射的角色图
const refUrls = mappedChars.map(m => charImageMap.value[m.charId] || '').filter(Boolean)
generatingVideo.value = true
videoResult.value = null
try {
videoTaskId.value = await window.electronAPI.createVideo(prompt, videoDuration.value, videoResolution.value, videoModel.value, firstFrameUrl, refUrls)
ElMessage.success('视频任务已创建: ' + videoTaskId.value)
await pollForVideo()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '视频生成失败')
} finally {
generatingVideo.value = false
}
}
async function pollForVideo() {
if (!videoTaskId.value) return
const maxPolls = 40
for (let i = 0; i < maxPolls; i++) {
await new Promise(r => setTimeout(r, 5000))
try {
const result = await window.electronAPI.pollVideo(videoTaskId.value)
if (result.status === 'succeeded') {
videoResult.value = result
ElMessage.success('视频生成完成!')
return
}
if (result.status === 'failed') {
ElMessage.error('视频生成失败')
return
}
} catch { /* retry */ }
}
ElMessage.warning('视频生成超时,请稍后查询')
}
async function checkVideoStatus() {
if (!videoTaskId.value) return
try {
const result = await window.electronAPI.pollVideo(videoTaskId.value)
if (result.status === 'succeeded') {
videoResult.value = result
ElMessage.success('视频已完成!')
} else {
ElMessage.info('状态: ' + result.status)
}
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '查询失败')
}
}
async function saveLastFrame(videoUrl: string) {
try {
const name = '视频尾帧_' + Date.now().toString(36)
await window.electronAPI.saveScene({
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
name,
description: '视频生成尾帧 | 任务ID: ' + videoTaskId.value,
imageUrl: videoUrl + '?return_last_frame=true',
createdAt: new Date().toISOString()
})
ElMessage.success('尾帧已保存到场景库')
} catch (e: unknown) {
ElMessage.error('保存失败,请尝试手动截图')
}
}
async function onModeChange() {
if (messages.value.length > 0) {
await saveCurrentSession()
}
currentStyle.value = mode.value === 'scene' ? 'school' : 'anime'
videoDuration.value = 15
// 切换出脚本模式时清掉 deepseek,防止带到角色/场景生成
if (mode.value !== 'script' && chatModel.value.includes('deepseek')) {
chatModel.value = 'doubao-seed-2-0-mini-260428'
}
selectedCamera.value = ''
selectedLighting.value = ''
selectedSceneImage.value = ''
characterMappings.value = [{ scriptName: '', charId: '' }]
newSession()
}
// ===== 工具 =====
function useSuggestion(text: string) { inputText.value = text }
async function scrollToBottom() {
await nextTick()
if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
// 模型列表
async function loadModels() {
try {
const keys = await window.electronAPI.getApiKeys()
if (keys['doubao']) {
const resp = await fetch('https://ark.cn-beijing.volces.com/api/v3/models', {
headers: { 'Authorization': `Bearer ${keys['doubao']}` }
})
if (resp.ok) {
const data = await resp.json()
allModels.value = data.data
.filter((m: { status: string }) => m.status !== 'Shutdown' && m.status !== 'Retiring')
.map((m: { id: string }) => m.id)
}
}
} catch { /* silent */ }
}
onMounted(async () => {
await loadSessions()
await loadModels()
try { characterList.value = await window.electronAPI.getCharacters() } catch {}
try { sceneList.value = await window.electronAPI.getScenes() } catch {}
})
</script>
<style scoped>
.chat-layout {
display: flex;
height: 100%;
gap: 16px;
}
/* 会话侧栏 */
.session-sidebar {
width: 200px;
flex-shrink: 0;
background: #fff;
border-radius: 10px;
border: 1px solid #eee;
display: flex;
flex-direction: column;
overflow: hidden;
}
.session-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #f0f0f0;
}
.session-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 6px 8px;
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 2px;
gap: 4px;
}
.session-item:hover {
background: #f5f5f5;
}
.session-item.active {
background: #e8f8f5;
}
.session-name {
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-empty {
text-align: center;
padding: 24px;
font-size: 13px;
color: #bbb;
}
.session-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.session-time {
font-size: 11px;
color: #aaa;
}
/* 聊天区 */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.model-selectors {
display: flex;
gap: 8px;
}
.model-pair {
display: flex;
align-items: center;
gap: 4px;
}
.model-label {
font-size: 12px;
color: #888;
white-space: nowrap;
}
/* 消息区 */
.messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding: 12px 0;
min-height: 0;
}
.welcome {
text-align: center;
padding: 40px 0;
}
.welcome-icon { font-size: 56px; margin-bottom: 12px; }
.welcome h2 { margin: 0 0 8px; font-size: 22px; color: #1a1a2e; }
.welcome p { margin: 0 0 20px; color: #999; font-size: 14px; }
.suggestions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
.suggestion-tag { cursor: pointer; padding: 6px 12px; font-size: 13px; }
.message { display: flex; gap: 10px; max-width: 90%; }
.message.user { align-self: flex-end; flex-direction: row-reverse; }
.message.assistant { align-self: flex-start; }
.msg-avatar {
width: 30px; height: 30px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; flex-shrink: 0;
}
.message.user .msg-avatar { background: #64ffda; color: #1a1a2e; }
.message.assistant .msg-avatar { background: #1a1a2e; color: #64ffda; }
.msg-content { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.msg-bubble {
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message.user .msg-bubble { background: #64ffda; color: #1a1a2e; }
.message.assistant .msg-bubble { background: #fff; color: #333; border: 1px solid #eee; }
.step-msg { font-size: 13px; color: #999; margin-bottom: 4px; }
.msg-image {
border-radius: 10px;
overflow: hidden;
border: 1px solid #eee;
align-self: flex-start;
}
.image-error {
width: 200px; height: 200px;
display: flex; align-items: center; justify-content: center;
background: #f5f5f5; color: #999; font-size: 13px;
}
.typing-dots { font-size: 24px; color: #999; letter-spacing: 4px; }
.msg-actions {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: center;
}
/* 输入区 */
.input-area {
display: flex; gap: 12px; padding: 12px 0 4px; align-items: flex-end;
}
.input-area :deep(.el-textarea__inner) { border-radius: 10px; resize: none; }
.send-btn { height: 56px; border-radius: 10px; padding: 0 24px; }
/* 脚本/视频控制 */
.script-actions {
margin-top: 8px;
}
.video-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.control-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.control-label {
font-size: 12px;
color: #888;
white-space: nowrap;
}
.control-section {
margin-bottom: 8px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: #555;
margin-bottom: 6px;
}
.video-result {
margin-top: 8px;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<template>
<div class="scenes-page">
<div class="page-toolbar">
<el-input v-model="searchQuery" placeholder="搜索场景..." prefix-icon="Search" clearable style="width: 300px" />
<span v-if="searchQuery" class="search-count">找到 {{ filteredScenes.length }} 个场景</span>
<el-tooltip :content="imagesDir" placement="bottom">
<el-button text size="small" @click="openDataDir">
<el-icon><FolderOpened /></el-icon>
打开图片目录
</el-button>
</el-tooltip>
</div>
<div v-if="filteredScenes.length === 0" class="empty">
<el-empty :description="searchQuery ? '没有匹配的场景' : '场景库还是空的,去首页生成一个吧'" :image-size="80" />
</div>
<div v-else class="scene-grid">
<div v-for="scene in filteredScenes" :key="scene.id" class="scene-card">
<el-image :src="imageSrc(scene)" fit="contain" class="scene-image" loading="lazy">
<template #error>
<div class="img-placeholder">🏞</div>
</template>
</el-image>
<div class="scene-info">
<h3 class="scene-name">{{ scene.name }}</h3>
<p class="scene-desc">{{ previewDesc(scene.description) }}</p>
<div class="scene-footer">
<span class="scene-date">{{ formatDate(scene.createdAt) }}</span>
<el-button size="small" type="danger" text @click="handleDelete(scene)">删除</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FolderOpened } from '@element-plus/icons-vue'
const scenes = ref<StoredScene[]>([])
const searchQuery = ref('')
const imageCache = ref<Record<string, string>>({})
const imagesDir = ref('')
const filteredScenes = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return scenes.value
return scenes.value.filter(
s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
)
})
function imageSrc(scene: StoredScene): string {
return imageCache.value[scene.id] || scene.imageUrl || ''
}
function previewDesc(text: string): string {
const match = text.match(/【场景名称】(.+?)[\n【]/)
if (match) return match[1].trim()
return text.slice(0, 60) + '...'
}
function formatDate(iso: string): string {
return iso.slice(0, 10)
}
async function resolveImages() {
for (const s of scenes.value) {
if (!s.imageUrl) continue
if (s.imageUrl.startsWith('data:') || s.imageUrl.startsWith('http')) continue
try {
const b64 = await window.electronAPI.readImageBase64(s.imageUrl)
if (b64) imageCache.value[s.id] = b64
} catch { /* noop */ }
}
}
async function openDataDir() {
if (imagesDir.value) await window.electronAPI.openFolder(imagesDir.value)
}
async function load() {
try {
const [list, dir] = await Promise.all([
window.electronAPI.getScenes(),
window.electronAPI.getImagesDir()
])
scenes.value = list
imagesDir.value = dir
await resolveImages()
} catch {
ElMessage.error('加载场景库失败')
}
}
async function handleDelete(scene: StoredScene) {
try {
await ElMessageBox.confirm(`确定删除「${scene.name}」?`, '确认删除')
await window.electronAPI.deleteScene(scene.id)
scenes.value = scenes.value.filter(s => s.id !== scene.id)
ElMessage.success('已删除')
} catch { /* 取消 */ }
}
onMounted(load)
</script>
<style scoped>
.scenes-page { max-width: 1000px; margin: 0 auto; }
.page-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.search-count { font-size: 13px; color: #999; }
.empty { padding: 80px 0; }
.scene-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
.scene-card { background: #fff; border-radius: 12px; overflow: hidden; border: 1px solid #eee; transition: box-shadow 0.2s; }
.scene-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.scene-image { width: 100%; height: 200px; display: block; background: #f8f8f8; }
.img-placeholder { width: 100%; height: 200px; display: flex; align-items: center; justify-content: center; font-size: 40px; background: #f5f5f5; }
.scene-info { padding: 14px 16px; }
.scene-name { margin: 0 0 6px; font-size: 16px; font-weight: 600; color: #1a1a2e; }
.scene-desc { margin: 0 0 10px; font-size: 13px; color: #888; line-height: 1.4; }
.scene-footer { display: flex; justify-content: space-between; align-items: center; }
.scene-date { font-size: 12px; color: #bbb; }
</style>
+387
View File
@@ -0,0 +1,387 @@
<template>
<div class="settings-page">
<!-- 卡片UI 设置 -->
<el-card class="settings-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">API Key 配置</span>
<span class="card-desc">在这里设置的 Key 优先级最高留空则读取 config 文件</span>
</div>
</template>
<div class="providers">
<div v-for="provider in providers" :key="provider.key" class="provider-item">
<div class="provider-info">
<div class="provider-name">{{ provider.label }}</div>
<div class="provider-desc">{{ provider.desc }}</div>
<!-- 来源标记 -->
<div v-if="sourceLabel(provider.key)" class="provider-source">
<el-tag :type="sourceTagType(provider.key)" size="small" effect="plain">
{{ sourceLabel(provider.key) }}
</el-tag>
</div>
</div>
<div class="provider-input">
<el-input
v-model="form[provider.key]"
type="password"
:placeholder="configFileKeys[provider.key] ? '从 config 文件读取中…' : '未设置'"
clearable
show-password
/>
</div>
<div class="provider-actions">
<el-button
type="primary"
:loading="saving[provider.key]"
@click="saveKey(provider.key)"
>
保存
</el-button>
<el-button
v-if="hasUiKey(provider.key)"
type="danger"
plain
@click="deleteKey(provider.key)"
>
清除
</el-button>
</div>
</div>
</div>
</el-card>
<!-- 卡片存储路径 -->
<el-card class="settings-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">数据存储位置</span>
<span class="card-desc">角色库会话记录和图片存放的目录默认在系统 AppData</span>
</div>
</template>
<div class="config-path-bar">
<el-icon><Folder /></el-icon>
<code class="path-text">{{ dataDir }}</code>
<el-button size="small" @click="chooseDataDir">
<el-icon><EditPen /></el-icon> 更改
</el-button>
<el-button size="small" @click="openDataDir">
<el-icon><FolderOpened /></el-icon> 打开
</el-button>
</div>
</el-card>
<!-- 卡片config 文件 -->
<el-card class="settings-card config-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">Config 文件</span>
<span class="card-desc">编辑此文件可批量化管理 API Key</span>
</div>
</template>
<div class="config-path-bar">
<el-icon><Document /></el-icon>
<code class="path-text">{{ configPath }}</code>
<el-button size="small" @click="openConfig">
<el-icon><EditPen /></el-icon> 编辑
</el-button>
<el-button size="small" @click="refreshConfig">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</div>
<div v-if="Object.keys(configFileKeys).length" class="config-keys">
<div v-for="(value, key) in configFileKeys" :key="key" class="config-key-row">
<span class="key-label">{{ providerLabel(key) }}</span>
<code class="key-value">{{ maskKey(value) }}</code>
</div>
</div>
<el-empty v-else description="config 文件中没有配置" :image-size="50" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, EditPen, Refresh, Folder, FolderOpened } from '@element-plus/icons-vue'
interface Provider {
key: string
label: string
desc: string
}
const providers: Provider[] = [
{ key: 'doubao', label: '豆包', desc: '用于生成角色设计与剧本' },
{ key: 'jimeng', label: '即梦', desc: '用于图像生成与场景绘制' },
{ key: 'deepseek', label: 'DeepSeek', desc: '用于文本润色与提示词优化' }
]
const configPath = ref('')
const dataDir = ref('')
// UI 输入框绑定的值(对应内部存储)
const form = reactive<Record<string, string>>({})
// config 文件中的 Key
const configFileKeys = ref<Record<string, string>>({})
// 各 provider 是否正在保存
const saving = reactive<Record<string, boolean>>({})
const providerLabels: Record<string, string> = {
doubao: '豆包',
jimeng: '即梦',
deepseek: 'DeepSeek'
}
function providerLabel(key: string): string {
return providerLabels[key] || key
}
function maskKey(key: string): string {
if (!key || key.length < 8) return key || '(空)'
return key.slice(0, 4) + '****' + key.slice(-4)
}
/** 判断当前 provider 的 UI 是否设置了值 */
function hasUiKey(key: string): boolean {
return !!form[key]
}
/** 显示 Key 实际来自哪里 */
function sourceLabel(key: string): string {
if (form[key]) return '来自 UI 设置'
if (configFileKeys.value[key]) return '来自 config 文件'
return ''
}
function sourceTagType(key: string): 'warning' | 'info' | '' {
if (form[key]) return 'warning'
if (configFileKeys.value[key]) return 'info'
return ''
}
async function loadAll() {
try {
const [path, uiKeys, configKeys, dir] = await Promise.all([
window.electronAPI.getConfigPath(),
window.electronAPI.getUiApiKeys(),
window.electronAPI.getConfigFileApiKeys(),
window.electronAPI.getDataDir()
])
configPath.value = path
configFileKeys.value = configKeys
dataDir.value = dir
// 回显输入框:只填充 UI 设置的,config 的留给 placeholder
for (const p of providers) {
form[p.key] = uiKeys[p.key] || ''
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '未知错误'
ElMessage.error('读取配置失败: ' + msg)
}
}
async function saveKey(key: string) {
saving[key] = true
try {
await window.electronAPI.setApiKey(key, form[key] || '')
ElMessage.success(`${providerLabel(key)} 已保存`)
// 如果保存的是空字符串,走的就是清除流程
if (!form[key]) {
// 重新获取 config 刷新 UI
const configKeys = await window.electronAPI.getConfigFileApiKeys()
configFileKeys.value = configKeys
}
} catch {
ElMessage.error('保存失败')
} finally {
saving[key] = false
}
}
async function deleteKey(key: string) {
try {
await window.electronAPI.deleteApiKey(key)
form[key] = ''
const configKeys = await window.electronAPI.getConfigFileApiKeys()
configFileKeys.value = configKeys
ElMessage.success(`已清除 ${providerLabel(key)} 的 UI 设置,将使用 config 文件的值`)
} catch {
ElMessage.error('清除失败')
}
}
async function openConfig() {
try {
await window.electronAPI.openConfig()
} catch {
ElMessage.error('无法打开配置文件')
}
}
async function chooseDataDir() {
try {
const folder = await window.electronAPI.pickFolder()
if (folder) {
await window.electronAPI.setDataDir(folder)
dataDir.value = folder
ElMessage.success('存储路径已更新,下次启动生效')
}
} catch (e: unknown) {
ElMessage.error('更改失败: ' + (e instanceof Error ? e.message : ''))
}
}
async function openDataDir() {
if (dataDir.value) await window.electronAPI.openFolder(dataDir.value)
}
async function refreshConfig() {
try {
const configKeys = await window.electronAPI.getConfigFileApiKeys()
configFileKeys.value = configKeys
ElMessage.success('已刷新')
} catch {
ElMessage.error('刷新失败')
}
}
onMounted(loadAll)
</script>
<style scoped>
.settings-page {
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.settings-card {
border-radius: 12px;
}
.card-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
}
.card-desc {
font-size: 13px;
color: #999;
}
/* ---- UI 设置 ---- */
.providers {
display: flex;
flex-direction: column;
gap: 16px;
}
.provider-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #fafafa;
border-radius: 10px;
border: 1px solid #eee;
}
.provider-info {
width: 120px;
flex-shrink: 0;
}
.provider-name {
font-weight: 600;
font-size: 15px;
color: #1a1a2e;
}
.provider-desc {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.provider-source {
margin-top: 6px;
}
.provider-input {
flex: 1;
min-width: 0;
}
.provider-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* ---- Config 文件 ---- */
.config-card {
margin-top: 0;
}
.config-path-bar {
display: flex;
align-items: center;
gap: 10px;
background: #f5f5f5;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
}
.path-text {
flex: 1;
word-break: break-all;
color: #666;
font-size: 12px;
}
.config-keys {
display: flex;
flex-direction: column;
gap: 8px;
}
.config-key-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: #fafafa;
border-radius: 6px;
border: 1px solid #eee;
}
.key-label {
font-weight: 600;
font-size: 14px;
color: #1a1a2e;
width: 72px;
flex-shrink: 0;
}
.key-value {
flex: 1;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
color: #555;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"outDir": "./out",
"skipLibCheck": true,
"strict": true
},
"include": [
"src/main/**/*.ts",
"src/preload/**/*.ts",
"electron.vite.config.ts"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"outDir": "./out",
"skipLibCheck": true,
"strict": true,
"paths": {
"@/*": ["./src/renderer/src/*"]
}
},
"include": [
"src/renderer/src/**/*.ts",
"src/renderer/src/**/*.d.ts",
"src/renderer/src/**/*.vue"
]
}