@clawhub-xiyunnet-8c9ade1624
Generate AI images with multiple resolutions and models, smartly split into grids, manage galleries, and support five languages including batch downloads.
--- name: Nano-Banana-V2 version: 1.1.0 description: AI image generation and smart splitting tool based on AceData Nano Banana model. Supports multiple resolutions and sizes, automatic 2/4/6/9 grid splitting, waterfall gallery management, and batch download. Supports 5 languages: Chinese, English, Traditional Chinese, Japanese, Korean. author: Xiao Zhu email: [email protected] wechat: jakeycis tags: [ai-image, banana2, nodejs, openclaw, skill, multi-language] icon: https://ext.zjhn.com/banana2/logo.png homepage: https://banana2.zjhn.com repository: https://github.com/xiyunnet/banana2 license: MIT --- # Nano Banana V2 - AI Image Creation Tool **English** | **[简体中文](docs/zh/SKILL.md)** ## Overview Nano Banana V2 is a powerful AI image generation and smart splitting tool based on the AceData Nano Banana series models. It provides a complete web interface for image generation, editing, splitting, and management. ### Core Features #### 🎨 Image Generation - **Multiple Models**: Nano Banana Pro, Nano Banana 2 - **Multiple Resolutions**: 1:1 (Square), 16:9 (Landscape), 9:16 (Portrait), 4:3 (Standard), 3:4 (Portrait) - **Multiple Qualities**: 1K (1024px), 2K (2048px), 4K (4096px) - **Image-to-Image**: Support up to 6 reference images for editing #### ✂️ Smart Splitting - **Multiple Grids**: Support 2/4/6/9 grid splitting - **Smart Adaptation**: Automatically detect aspect ratio and choose optimal splitting method #### 🖼️ Gallery Management - **Waterfall Layout**: Automatically adapt to different image ratios - **Thumbnail Generation**: Automatic thumbnail creation - **Batch Download**: Support ZIP packaging - **File Management**: One-click open file directory #### 🌐 Multi-Language Support - **简体中文** (zh) - **English** (en) - **繁體中文** (zh-TW) - **日本語** (ja) - **한국어** (ko) #### 🎨 User Experience - **Dual Themes**: Light/Dark theme switching - **Real-time Preview**: Image preview, split preview - **Modern UI**: Frosted glass effect, smooth animations, waterfall layout - **AI Prompt Generation**: Integrated LLM for intelligent prompt generation --- ## Screenshots ### Main Interface  --- ## Quick Start ### 1. Install Dependencies ```bash cd ~/.openclaw/workspace/skills/nano-banana-v2 npm install ``` ### 2. Start Service ```bash npm start ``` ### 3. Access Application Open browser at http://localhost:2688 --- ## Configuration ### Get API Key First-time use requires API Key configuration: 1. Visit: https://share.acedata.cloud/r/1uN88BrUTQ 2. Register and get API Key 3. Fill in API Key in settings page ### Configuration Items | Item | Required | Description | |------|----------|-------------| | API Key | ✅ | For image generation | | Platform Token | ❌ | For image-to-image feature | | LLM API Key | ❌ | For AI prompt generation | | Save Path | ❌ | Image save directory (Default: Desktop/banana2) | --- ## Usage ### Generate Image 1. Enter prompt in the bottom editor 2. Select model, resolution, quality 3. Select splitting method (optional) 4. Click generate button 5. Wait for completion ### Image-to-Image 1. Click upload button to select images (max 6) 2. Enter description 3. Click generate ### AI Prompt Generation 1. Click AI button to open prompt generation window 2. Enter simple description 3. Click generate, AI will create detailed prompt ### View Works - Click image to view full size - View split image list - Download, delete works --- ## Tech Stack - **Backend**: Node.js + Express + SQLite - **Frontend**: HTML5 + CSS3 + JavaScript (jQuery) - **Image Processing**: Sharp - **API**: AceData Nano Banana API --- ## Directory Structure ``` nano-banana-v2/ ├── server/ │ ├── index.js # Main server │ └── services/ # Service classes │ ├── database.js # Database service │ ├── request.js # Request handling │ ├── task.js # Task processing │ └── upload.js # Upload handling ├── public/ │ ├── index.html # Main page │ ├── list.html # List page │ ├── set.html # Settings page │ ├── components/ # UI components │ ├── css/ # Styles │ ├── js/ # Application logic │ └── lan/ # Language files ├── config/ │ ├── set.json # Main config │ ├── system.prompt # AI system prompt │ └── cut_*.prompt # Split prompts ├── database/ │ ├── 1.sql # Database init │ └── 2.sql # Database migration ├── package.json ├── README.md ├── LICENSE └── CHANGELOG.md ``` --- ## API Endpoints | Method | Path | Description | |--------|------|-------------| | GET | /api/get_set | Get config | | POST | /api/generate | Submit generation task | | POST | /api/poll/:id | Poll task status | | POST | /api/upload | Upload image | | GET | /api/works | Get works list | | GET | /api/work/:id | Get single work | | POST | /api/tasks/add | Add task manually | | POST | /api/admin/delete/:id | Delete work | | POST | /api/open-folder | Open folder | | POST | /api/generate-prompt | AI generate prompt | | POST | /api/shutdown | Shutdown service | --- ## Changelog ### v1.1.0 (2026-03-30) - Added Korean (한국어) support - Completed multi-language translation for all components - Optimized dynamic image path mapping - Cleaned up redundant API endpoints - Fixed known issues ### v1.0.0 (2026-03-26) - Initial release --- ## Open Source Info - **License**: MIT License - **Repository**: https://github.com/xiyunnet/banana2 - **Issues**: https://github.com/xiyunnet/banana2/issues - **Homepage**: https://banana2.zjhn.com --- ## Contact - **Author**: Xiao Zhu - **Email**: [email protected] - **WeChat**: jakeycis FILE:.gitignore # Dependencies node_modules/ # Database database/*.db database/*.sqlite database/*.sqlite3 # Logs logs/ *.log npm-debug.log* # Environment .env .env.local .env.*.local # IDE .idea/ .vscode/ *.swp *.swo # OS .DS_Store Thumbs.db # Build dist/ build/ # Temp tmp/ temp/ *.tmp # Backup *.zip *.bak # Test coverage/ FILE:package.json { "name": "nano-banana-v2", "version": "1.1.0", "description": "云羲AI绘图分影工具 - 一键AI图片生成、智能切割分影、多语言支持的Web应用", "main": "server/index.js", "scripts": { "start": "node server/index.js", "dev": "node server/index.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ "ai", "image-generation", "image-split", "nano-banana", "ai-art", "image-processing", "multi-language", "chinese", "english", "japanese", "korean" ], "author": "小潴 (Xiao Zhu) <[email protected]>", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/xiyunnet/banana2.git" }, "homepage": "https://banana2.zjhn.com", "bugs": { "url": "https://github.com/xiyunnet/banana2/issues", "email": "[email protected]" }, "type": "commonjs", "engines": { "node": ">=16.0.0" }, "dependencies": { "axios": "^1.13.6", "cors": "^2.8.6", "express": "^5.2.1", "form-data": "^4.0.0", "sharp": "^0.34.5", "sqlite3": "^6.0.1" } } FILE:server/index.js const express = require('express'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); const axios = require('axios'); const Database = require('./services/database'); const RequestHandler = require('./services/request'); const TaskHandler = require('./services/task'); const UploadHandler = require('./services/upload'); const app = express(); const PORT = 2688; // 中间件 app.use(cors()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true })); // 静态文件 app.use(express.static(path.join(__dirname, '../public'))); // 图片文件服务 - 使用环境变量中的保存路径 const getImageBasePath = () => { if (process.env.SAVE_PATH) { return process.env.SAVE_PATH; } const os = require('os'); const platform = os.platform(); const homeDir = os.homedir(); // 根据平台设置默认目录 if (platform === 'win32') { return path.join(homeDir, 'Desktop', 'banana2'); } else if (platform === 'darwin') { return path.join(homeDir, 'Desktop', 'banana2'); } else { return path.join(homeDir, 'banana2'); } }; // 动态图片服务 - 支持多个保存目录 app.use('/images', (req, res, next) => { const match = req.path.match(/^\/(\d+)\//); if (!match) { const basePath = getImageBasePath(); return express.static(basePath)(req, res, next); } const workId = match[1]; db.getWorkById(parseInt(workId)).then(work => { if (work && work.path) { const baseDir = path.dirname(work.path); express.static(baseDir, { setHeaders: (res) => { res.set('Cache-Control', 'public, max-age=86400'); } })(req, res, next); } else { const basePath = getImageBasePath(); express.static(basePath)(req, res, next); } }).catch(err => { console.error('[Images] 查询 work 失败:', err); const basePath = getImageBasePath(); express.static(basePath)(req, res, next); }); }); // 读取配置文件 function getConfig() { const configPath = path.join(__dirname, '../config/set.json'); return JSON.parse(fs.readFileSync(configPath, 'utf-8')); } // 初始化数据库 const db = new Database(); db.init(); // 初始化处理器 const taskHandler = new TaskHandler(db); const requestHandler = new RequestHandler(db, taskHandler); const uploadHandler = new UploadHandler(db); // ==================== API 路由 ==================== // 获取配置 app.get('/api/get_set', (req, res) => { try { const config = getConfig(); res.json({ success: true, data: config }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 保存配置到 JSON 文件 app.post('/api/config/save-json', (req, res) => { try { const config = req.body; const configPath = path.join(__dirname, '../config/set.json'); // 验证基本结构 if (!config.server || !config.models || !config.llm) { return res.status(400).json({ success: false, error: '配置结构不完整' }); } // 写入文件 fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); res.json({ success: true, msg: '配置已保存' }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // ==================== Prompts API ==================== // 获取提示词文件列表 app.get('/api/prompts/list', (req, res) => { try { const promptsDir = path.join(__dirname, '../config'); const files = fs.readdirSync(promptsDir) .filter(f => f.endsWith('.prompt')) .map(f => { const stat = fs.statSync(path.join(promptsDir, f)); return { name: f, size: stat.size, modified: stat.mtime }; }); res.json({ success: true, files }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 获取单个提示词文件内容 app.get('/api/prompts/get', (req, res) => { try { const file = req.query.file; if (!file || !file.endsWith('.prompt')) { return res.status(400).json({ success: false, error: '无效的文件名' }); } const filePath = path.join(__dirname, '../config', file); if (!fs.existsSync(filePath)) { return res.status(404).json({ success: false, error: '文件不存在' }); } const content = fs.readFileSync(filePath, 'utf-8'); res.json({ success: true, content, name: file }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 保存提示词文件 app.post('/api/prompts/save', (req, res) => { try { const { oldName, newName, name, content, originalName } = req.body; // 兼容两种参数格式 const targetName = newName || name; const sourceName = oldName || originalName; if (!targetName || !targetName.endsWith('.prompt')) { return res.status(400).json({ success: false, error: '无效的文件名' }); } const configDir = path.join(__dirname, '../config'); const filePath = path.join(configDir, targetName); // 如果是重命名,删除旧文件 if (sourceName && sourceName !== targetName) { const oldPath = path.join(configDir, sourceName); if (fs.existsSync(oldPath)) { fs.unlinkSync(oldPath); } } fs.writeFileSync(filePath, content || '', 'utf-8'); res.json({ success: true, msg: '保存成功', name: targetName }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 删除提示词文件 app.post('/api/prompts/delete', (req, res) => { try { const { filename, name } = req.body; // 兼容两种参数名 const targetName = filename || name; if (!targetName || !targetName.endsWith('.prompt')) { return res.status(400).json({ success: false, error: '无效的文件名' }); } const filePath = path.join(__dirname, '../config', targetName); if (!fs.existsSync(filePath)) { return res.status(404).json({ success: false, error: '文件不存在' }); } fs.unlinkSync(filePath); res.json({ success: true, msg: '删除成功' }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 生成图片 app.post('/api/generate', async (req, res) => { try { const result = await requestHandler.generate(req.body); res.json(result); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); // 轮询任务状态 app.post('/api/poll/:id', async (req, res) => { try { const { api_key } = req.body; if (api_key) { process.env.API_KEY = api_key; } const result = await taskHandler.poll(parseInt(req.params.id)); res.json(result); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 上传图片 app.post('/api/upload', async (req, res) => { try { const result = await uploadHandler.upload(req.body); res.json(result); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 获取作品列表 app.get('/api/works', async (req, res) => { try { const page = parseInt(req.query.page) || 1; const pageSize = parseInt(req.query.pageSize) || 30; const keyword = req.query.keyword || ''; const result = await db.getWorksPaginated(page, pageSize, keyword); const works = result.works.map(work => { if (work.path) { const pathParts = work.path.replace(/\\/g, '/').split('/'); const workId = pathParts.pop(); work.http_path = `/images/workId`; } return work; }); res.json({ success: true, data: works, pagination: { page: result.page, pageSize: result.pageSize, total: result.total, totalPages: result.totalPages } }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 获取单个作品 app.get('/api/work/:id', async (req, res) => { try { const work = await db.getWorkById(parseInt(req.params.id)); if (work && work.path) { const pathParts = work.path.replace(/\\/g, '/').split('/'); const workId = pathParts.pop(); work.http_path = `/images/workId`; } res.json({ success: true, data: work }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 手动添加任务(list.html 使用) app.post('/api/tasks/add', async (req, res) => { try { const { task_id, cut, ratio, prompt, api_key, platform_token, model_api_key, save_path } = req.body; if (api_key) process.env.API_KEY = api_key; if (platform_token) process.env.PLATFORM_TOKEN = platform_token; if (model_api_key) process.env.MODEL_API_KEY = model_api_key; if (save_path) process.env.SAVE_PATH = save_path; if (!task_id) { return res.status(400).json({ success: false, error: 'Task ID 不能为空' }); } if (!process.env.API_KEY) { return res.status(400).json({ success: false, error: '请先配置 API Key' }); } const workId = await db.createWork({ task_id: task_id, prompt: prompt || '', cut: cut || 1, model: 'nano-banana-pro', ratio: ratio || '1:1', quality: '2K', size: '2K', path: '', request_data: {} }); const config = getConfig(); if (taskHandler) { requestHandler.startPolling(workId, task_id, config); } res.json({ success: true, msg: '任务添加成功,已启动轮询', work_id: workId }); } catch (error) { console.error('添加任务失败:', error); res.status(500).json({ success: false, error: error.message }); } }); // 删除作品 app.post('/api/admin/delete/:id', async (req, res) => { try { await db.deleteWork(parseInt(req.params.id)); res.json({ success: true, msg: '删除成功' }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // 打开文件夹 app.post('/api/open-folder', (req, res) => { try { const { path: folderPath } = req.body; if (!folderPath) { return res.status(400).json({ success: false, error: '路径不能为空' }); } if (!fs.existsSync(folderPath)) { return res.status(404).json({ success: false, error: '文件夹不存在' }); } const { exec } = require('child_process'); let command; if (process.platform === 'win32') { command = `explorer "folderPath"`; } else if (process.platform === 'darwin') { command = `open "folderPath"`; } else { command = `xdg-open "folderPath"`; } res.json({ success: true, msg: '已打开文件夹' }); exec(command, (error) => { if (error) { console.error('打开文件夹失败:', error); } }); } catch (error) { console.error('打开文件夹异常:', error); res.status(500).json({ success: false, error: error.message }); } }); // 关闭服务 app.post('/api/shutdown', (req, res) => { res.json({ success: true, msg: '服务正在关闭' }); setTimeout(() => process.exit(0), 1000); }); // AI生成提示词(ai-prompt.html 使用) app.post('/api/generate-prompt', async (req, res) => { try { const { input, api_key } = req.body; if (!input) { return res.status(400).json({ success: false, error: '请输入描述内容' }); } const modelApiKey = api_key || process.env.MODEL_API_KEY; if (!modelApiKey) { return res.status(400).json({ success: false, error: '请先配置大模型 API Key' }); } const config = getConfig(); const llmConfig = config.llm || {}; let llmUrl = llmConfig.url; const llmModel = llmConfig.model; if (!llmUrl || !llmModel) { return res.status(400).json({ success: false, error: '请先在设置页面配置 LLM URL 和模型', needConfig: true }); } if (!llmUrl.includes('/chat/completions')) { llmUrl = llmUrl.replace(/\/+$/, ''); llmUrl += '/chat/completions'; } const systemPromptPath = path.join(__dirname, '../config/system.prompt'); let systemPrompt = ''; if (fs.existsSync(systemPromptPath)) { systemPrompt = fs.readFileSync(systemPromptPath, 'utf-8'); } const requestBody = { model: llmModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: input } ] }; const response = await axios.post( llmUrl, requestBody, { headers: { 'Authorization': `Bearer modelApiKey`, 'Content-Type': 'application/json' }, timeout: 60000 } ); const prompt = response.data.choices?.[0]?.message?.content?.trim(); if (prompt) { res.json({ success: true, prompt }); } else { res.status(500).json({ success: false, error: '生成失败,请重试' }); } } catch (error) { console.error('[AI Prompt] 错误:', error.message); let errorMsg = '生成失败'; if (error.response) { const errorData = error.response.data; if (typeof errorData === 'object') { errorMsg = errorData.error?.message || errorData.message || JSON.stringify(errorData); } else { errorMsg = errorData || error.message; } } else { errorMsg = error.message; } res.status(500).json({ success: false, error: errorMsg }); } }); // 错误处理中间件 app.use((err, req, res, next) => { console.error('服务器错误:', err); res.status(500).json({ success: false, error: err.message || '服务器内部错误' }); }); // 启动服务器 app.listen(PORT, () => { console.log(`🚀 Nano Banana V2 Server running at http://localhost:PORT`); console.log(`📁 Database: db.dbPath`); console.log(`🎨 API: http://localhost:PORT`); }); FILE:server/services/database.js const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const fs = require('fs'); class Database { constructor() { this.dbPath = path.join(__dirname, '../../database/works.db'); this.db = null; } async init() { return new Promise((resolve, reject) => { const dbDir = path.dirname(this.dbPath); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } // 检查数据库文件是否存在 const dbExists = fs.existsSync(this.dbPath); this.db = new sqlite3.Database(this.dbPath, async (err) => { if (err) { console.error('数据库连接失败:', err); reject(err); } else { console.log('✅ 数据库连接成功'); // 如果数据库是新创建的,重置迁移版本 if (!dbExists) { const infoPath = path.join(__dirname, '../../database/info.txt'); if (fs.existsSync(infoPath)) { fs.writeFileSync(infoPath, '0'); console.log('✅ 数据库新创建,重置迁移版本'); } } await this.runMigrations(); resolve(); } }); }); } async runMigrations() { const migrationsDir = path.join(__dirname, '../../database'); const infoPath = path.join(migrationsDir, 'info.txt'); let currentVersion = 0; if (fs.existsSync(infoPath)) { currentVersion = parseInt(fs.readFileSync(infoPath, 'utf-8')) || 0; } const files = fs.readdirSync(migrationsDir) .filter(f => f.endsWith('.sql')) .sort((a, b) => { const numA = parseInt(a); const numB = parseInt(b); return numA - numB; }); for (const file of files) { const version = parseInt(file); if (version > currentVersion) { const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8'); // 分割多条 SQL 语句并逐个执行 const statements = sql.split(';').filter(s => s.trim()); for (const statement of statements) { if (statement.trim()) { await this.run(statement); } } console.log(`✅ 执行迁移: file`); currentVersion = version; } } fs.writeFileSync(infoPath, currentVersion.toString()); } run(sql, params = []) { return new Promise((resolve, reject) => { this.db.run(sql, params, (err) => { if (err) reject(err); else resolve(); }); }); } get(sql, params = []) { return new Promise((resolve, reject) => { this.db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); }); }); } all(sql, params = []) { return new Promise((resolve, reject) => { this.db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows); }); }); } // 创建任务 async createWork(data) { const sql = `INSERT INTO creat ( date, state, task_id, prompt, size, quality, cut, path, model, ratio, request_data, callback_url, poll_count, last_request ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; const now = Date.now(); const params = [ new Date().toISOString(), 1, // pending data.task_id || null, data.prompt, data.size, data.quality, data.cut || 1, data.path || '', data.model, data.ratio, JSON.stringify(data.request_data || {}), data.callback_url || '', 0, now + 60000 // 1分钟后开始轮询 ]; return new Promise((resolve, reject) => { this.db.run(sql, params, function(err) { if (err) reject(err); else resolve(this.lastID); }); }); } // 获取所有作品 async getAllWorks() { const sql = `SELECT * FROM creat WHERE state >= 0 ORDER BY date DESC LIMIT 50`; return await this.all(sql); } // 分页获取作品 async getWorksPaginated(page = 1, pageSize = 30, keyword = '') { const offset = (page - 1) * pageSize; // 构建搜索条件 let whereClause = 'state >= 0'; const params = []; if (keyword) { whereClause += ' AND prompt LIKE ?'; params.push(`%keyword%`); } // 获取总数 const countSql = `SELECT COUNT(*) as total FROM creat WHERE whereClause`; const countResult = await this.get(countSql, params); const total = countResult?.total || 0; // 获取分页数据 const sql = `SELECT * FROM creat WHERE whereClause ORDER BY date DESC LIMIT ? OFFSET ?`; params.push(pageSize, offset); const works = await this.all(sql, params); return { works, page, pageSize, total, totalPages: Math.ceil(total / pageSize) }; } // 获取单个作品 async getWorkById(id) { const sql = `SELECT * FROM creat WHERE id = ?`; return await this.get(sql, [id]); } // 更新任务状态 async updateWorkState(id, state, error) { const sql = `UPDATE creat SET state = ?, error = ? WHERE id = ?`; await this.run(sql, [state, error || null, id]); } // 更新任务信息 async updateWork(id, data) { const fields = []; const values = []; for (const [key, value] of Object.entries(data)) { if (typeof value === 'object') { fields.push(`key = ?`); values.push(JSON.stringify(value)); } else { fields.push(`key = ?`); values.push(value); } } values.push(id); const sql = `UPDATE creat SET fields.join(', ') WHERE id = ?`; await this.run(sql, values); } // 删除任务 async deleteWork(id) { const sql = `UPDATE creat SET state = -1 WHERE id = ?`; await this.run(sql, [id]); } // 获取待轮询任务 async getPendingTasks() { const sql = `SELECT * FROM creat WHERE state = 1 AND task_id IS NOT NULL AND last_request <= ?`; const now = Date.now(); return await this.all(sql, [now]); } // 更新轮询信息 async updatePollInfo(id, pollCount, nextPollTime) { const sql = `UPDATE creat SET poll_count = ?, last_request = ? WHERE id = ?`; await this.run(sql, [pollCount, nextPollTime, id]); } // 保存上传记录 async saveUploadRecord(data) { const sql = `INSERT INTO upload (date, state, timeout, url, filename, file_hash) VALUES (?, ?, ?, ?, ?, ?)`; const timeout = Date.now() + (24 * 60 * 60 * 1000); // 24小时后超时 const params = [ new Date().toISOString(), 1, timeout, data.url, data.filename, data.file_hash ]; return new Promise((resolve, reject) => { this.db.run(sql, params, function(err) { if (err) reject(err); else resolve(this.lastID); }); }); } // 获取上传记录 async getUploadRecord(fileHash) { const sql = `SELECT * FROM upload WHERE file_hash = ? AND timeout > ? AND state = 1`; const now = Date.now(); return await this.get(sql, [fileHash, now]); } // 清理过期上传记录 async cleanupExpiredUploads() { const sql = `UPDATE upload SET state = 0 WHERE timeout < ? AND state = 1`; const now = Date.now(); return new Promise((resolve, reject) => { this.db.run(sql, [now], function(err) { if (err) reject(err); else resolve(this.changes); }); }); } } module.exports = Database; FILE:server/services/request.js const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); const axios = require('axios'); // 存储活跃的轮询任务 const activePollingTasks = new Map(); class RequestHandler { constructor(db, taskHandler = null) { this.db = db; this.taskHandler = taskHandler; } // 设置 TaskHandler(用于轮询) setTaskHandler(taskHandler) { this.taskHandler = taskHandler; } // 启动轮询任务 startPolling(workId, task_id, config) { // 如果已经在轮询中,跳过 if (activePollingTasks.has(workId)) { console.log(`[Polling] 任务 workId 已在轮询中`); return; } const interval = config.polling?.interval || 60000; // 默认60秒 const maxTimes = config.polling?.max_times || 20; // 默认最多20次 let pollCount = 0; console.log(`[Polling] 启动轮询任务 work_id=workId, task_id=task_id, 间隔=intervalms`); const pollTask = async () => { pollCount++; console.log(`[Polling] 第 pollCount/maxTimes 次轮询 work_id=workId`); try { if (!this.taskHandler) { console.error('[Polling] TaskHandler 未设置'); activePollingTasks.delete(workId); return; } const result = await this.taskHandler.poll(workId); console.log(`[Polling] work_id=workId 状态:`, result.status); // 更新轮询计数 await this.db.updatePollInfo(workId, pollCount, Date.now() + interval); if (result.status === 'completed') { console.log(`[Polling] 任务 workId 完成!`); activePollingTasks.delete(workId); return; } if (result.status === 'failed') { console.log(`[Polling] 任务 workId 失败:`, result.msg); activePollingTasks.delete(workId); return; } // 检查是否超过最大轮询次数 if (pollCount >= maxTimes) { console.log(`[Polling] 任务 workId 轮询次数超限`); await this.db.updateWorkState(workId, 99, '轮询超时'); activePollingTasks.delete(workId); return; } // 继续轮询 const timer = setTimeout(pollTask, interval); activePollingTasks.set(workId, timer); } catch (error) { console.error(`[Polling] 轮询错误 work_id=workId:`, error.message); // 如果还有轮询次数,继续尝试 if (pollCount < maxTimes) { const timer = setTimeout(pollTask, interval); activePollingTasks.set(workId, timer); } else { activePollingTasks.delete(workId); } } }; // 首次轮询延迟5秒后开始(给API一些处理时间) const initialDelay = 5000; const timer = setTimeout(pollTask, initialDelay); activePollingTasks.set(workId, timer); } // 获取切割提示词 getCutPrompt(cutNum) { if (cutNum <= 1) return ''; const configDir = path.join(__dirname, '../../config'); const cutPromptPath = path.join(configDir, `cut_cutNum.prompt`); // 如果文件不存在,创建默认提示词 if (!fs.existsSync(cutPromptPath)) { const defaultPrompt = this.createDefaultCutPrompt(cutNum); fs.writeFileSync(cutPromptPath, defaultPrompt, 'utf-8'); console.log(`[Request] 已创建默认切割提示词文件: cut_cutNum.prompt`); return defaultPrompt; } // 文件已存在,直接读取 return fs.readFileSync(cutPromptPath, 'utf-8'); } // 创建默认切割提示词(仅在文件不存在时调用) createDefaultCutPrompt(cutNum) { const prompts = { 2: `#按要求生成以下内容 `, 4: `#按要求生成以下内容 `, 6: `#按要求生成以下内容 `, 9: `#按要求生成以下内容 ` }; return prompts[cutNum] || `#按要求生成以下内容 `; } // 使用 https.request 发送请求 async sendRequest(url, data, headers) { return new Promise((resolve, reject) => { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const lib = isHttps ? https : http; const options = { hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: urlObj.pathname + urlObj.search, method: 'POST', headers: { 'Content-Type': 'application/json', ...headers } }; const req = lib.request(options, (res) => { let body = ''; res.on('data', (chunk) => body += chunk); res.on('end', () => { try { const response = { status: res.statusCode, statusText: res.statusMessage, data: JSON.parse(body) }; resolve(response); } catch (e) { resolve({ status: res.statusCode, statusText: res.statusMessage, data: body }); } }); }); req.on('error', (e) => { reject(e); }); req.setTimeout(120000, () => { req.destroy(); reject(new Error('请求超时')); }); req.write(JSON.stringify(data)); req.end(); }); } async generate(data) { // 从请求中获取配置,并存储到环境变量(供后续轮询使用) if (data.api_key) { process.env.API_KEY = data.api_key; } if (data.platform_token) { process.env.PLATFORM_TOKEN = data.platform_token; } if (data.model_api_key) { process.env.MODEL_API_KEY = data.model_api_key; } if (data.save_path) { process.env.SAVE_PATH = data.save_path; } const apiKey = process.env.API_KEY; const platformToken = process.env.PLATFORM_TOKEN; // 检查 API_KEY if (!apiKey) { throw new Error('请先配置 API Key'); } // 读取配置 const configPath = path.join(__dirname, '../../config/set.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); // 获取当前模型配置 const modelConfig = config.models.find(m => m.model === data.model); // 获取图片URLs(前端已上传) const imageUrls = data.image_urls || []; console.log(`[Request] 图片URLs:`, imageUrls); // 构建请求参数 // 处理切割提示词(cut > 1 时添加系统提示词到 prompt 顶部) let finalPrompt = data.prompt; const cutNum = data.cut || 1; if (cutNum > 1) { const cutPrompt = this.getCutPrompt(cutNum); if (cutPrompt) { finalPrompt = cutPrompt + data.prompt; console.log(`[Request] 添加 cutNum 宫格切割提示词`); } } const requestData = { model: data.model || 'nano-banana-pro', prompt: finalPrompt, action: imageUrls.length > 0 ? 'edit' : 'generate', callback_url: 'jakey' }; // 根据模型配置的request映射添加参数 if (modelConfig && modelConfig.request) { const requestMapping = modelConfig.request; if (data.ratio && requestMapping.size) { requestData[requestMapping.size] = data.ratio; } if (data.quality && requestMapping.quality) { requestData[requestMapping.quality] = data.quality; } } else { if (data.ratio) requestData.aspect_ratio = data.ratio; if (data.quality) requestData.resolution = data.quality; } // 添加图片URLs if (imageUrls.length > 0) { requestData.image_urls = imageUrls; } // 调试日志:显示完整的请求数据 console.log('[Request] 完整请求数据:', JSON.stringify(requestData, null, 2)); try { // 使用 https.request 提交到模型服务器 const response = await this.sendRequest( config.server.url, requestData, { 'Authorization': `Bearer apiKey` } ); // 调试日志 console.log('[Request] 响应状态:', response.status); console.log('[Request] 响应数据:', JSON.stringify(response.data, null, 2)); // 检查是否有 task_id(即使状态码不是 200,只要有 task_id 就说明任务已创建) const task_id = response.data?.task_id; // 如果有错误信息但没有 task_id,则抛出错误 if (!task_id) { if (response.data?.error?.message) { throw new Error(response.data.error.message); } if (!response.data.success) { throw new Error(response.data.message || '提交失败'); } throw new Error(`HTTP response.status: JSON.stringify(response.data)`); } // 如果有警告信息,记录但不阻止 if (response.data?.error?.message) { console.warn('[Request] API 警告:', response.data.error.message); } // 保存到数据库 const workId = await this.db.createWork({ task_id, prompt: data.prompt, size: data.quality || '2K', quality: data.quality || '2K', cut: data.cut || 1, path: '', model: data.model || 'nano-banana-pro', ratio: data.ratio || '1:1', request_data: requestData }); // 启动轮询任务 if (this.taskHandler) { this.startPolling(workId, task_id, config); } else { console.warn('[Request] TaskHandler 未设置,无法启动自动轮询'); } return { success: true, msg: '任务提交成功,已启动轮询', work_id: workId, task_id, polling: true, debug: { request: requestData, response: response.data } }; } catch (error) { let errorMsg = '提交失败'; let debugInfo = { request_url: config.server.url, request_data: requestData, error: null }; if (error.response) { debugInfo.error = { status: error.response.status, data: error.response.data }; errorMsg = error.response.data?.error || error.response.data?.message || JSON.stringify(error.response.data); } else { debugInfo.error = { message: error.message, code: error.code }; errorMsg = error.message; } throw new Error(`errorMsg\n\n[调试信息]\n请求URL: debugInfo.request_url\n请求数据: JSON.stringify(debugInfo.request_data, null, 2)\n错误: JSON.stringify(debugInfo.error, null, 2)`); } } } module.exports = RequestHandler; FILE:server/services/task.js const axios = require('axios'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); class TaskHandler { constructor(db) { this.db = db; } // 轮询单个任务 async poll(workId) { const work = await this.db.getWorkById(workId); if (!work || !work.task_id) { throw new Error('任务不存在'); } const result = await this.checkTaskStatus(work); return result; } // 使用 https.request 发送请求 async sendRequest(url, data, headers) { return new Promise((resolve, reject) => { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const lib = isHttps ? https : http; const options = { hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: urlObj.pathname + urlObj.search, method: 'POST', headers: { 'Content-Type': 'application/json', ...headers } }; const req = lib.request(options, (res) => { let body = ''; res.on('data', (chunk) => body += chunk); res.on('end', () => { try { const response = { status: res.statusCode, statusText: res.statusMessage, data: JSON.parse(body) }; resolve(response); } catch (e) { resolve({ status: res.statusCode, statusText: res.statusMessage, data: body }); } }); }); req.on('error', (e) => { reject(e); }); req.setTimeout(120000, () => { req.destroy(); reject(new Error('请求超时')); }); req.write(JSON.stringify(data)); req.end(); }); } // 检查任务状态 async checkTaskStatus(work) { if (!process.env.API_KEY) { throw new Error('请先配置 API_KEY'); } // 读取配置 const configPath = path.join(__dirname, '../../config/set.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); const requestData = { id: work.task_id, action: 'retrieve' }; try { // 使用 https.request const response = await this.sendRequest( config.server.task_url, requestData, { 'Authorization': `Bearer process.env.API_KEY` } ); const data = response.data; // 检查HTTP状态码 if (response.status !== 200) { return { success: true, status: 'pending', msg: `HTTP response.status: response.statusText`, debug: { request_url: config.server.task_url, request_data: requestData, response: data } }; } // 检查是否有response if (!data.response) { return { success: true, status: 'pending', msg: '任务处理中', debug: { request_url: config.server.task_url, request_data: requestData, response: data } }; } // 检查response.success if (data.response.success === false) { await this.db.updateWorkState(work.id, 99, '生成失败'); return { success: false, status: 'failed', msg: '生成失败', debug: { request_url: config.server.task_url, request_data: requestData, response: data } }; } // 检查是否有error if (data.response.error) { await this.db.updateWorkState(work.id, 99, data.response.error); return { success: false, status: 'failed', msg: data.response.error, debug: { request_url: config.server.task_url, request_data: requestData, response: data } }; } // 成功,处理图片 if (data.response.data && data.response.data.length > 0) { const imageData = data.response.data[0]; // 保存图片 const saveResult = await this.saveImage(work, imageData, config); return { success: true, status: 'completed', msg: '生成成功', data: saveResult, debug: { request_url: config.server.task_url, request_data: requestData, response: data } }; } return { success: true, status: 'pending', msg: '任务处理中', debug: { request_url: config.server.task_url, request_data: requestData, response: data } }; } catch (error) { // 如果是网络错误或超时,继续轮询 if (error.code === 'ECONNABORTED' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT' || error.message === '请求超时') { console.log(`任务 work.task_id 网络错误,继续轮询`); return { success: true, status: 'pending', msg: '网络错误,继续轮询', debug: { request_url: config.server.task_url, request_data: requestData, error: { code: error.code, message: error.message } } }; } // 其他错误 - 返回详细信息 const errorMsg = error.message; console.error(`任务 work.task_id 检查失败:`, errorMsg); return { success: true, status: 'pending', msg: errorMsg, debug: { request_url: config.server.task_url, request_data: requestData, error: { message: error.message } } }; } } // 保存图片 async saveImage(work, imageData, config) { const imageUrl = imageData.image_url; const prompt = imageData.prompt || work.prompt; // 确定保存路径 - 使用id作为目录名 let baseDir = process.env.SAVE_PATH; if (!baseDir) { const os = require('os'); const platform = os.platform(); const homeDir = os.homedir(); // 根据平台设置默认目录 if (platform === 'win32') { // Windows: 桌面/banana2 baseDir = path.join(homeDir, 'Desktop', 'banana2'); } else if (platform === 'darwin') { // macOS: 桌面/banana2 baseDir = path.join(homeDir, 'Desktop', 'banana2'); } else { // Linux 及其他: 用户主目录/banana2 baseDir = path.join(homeDir, 'banana2'); } } const saveDir = path.join(baseDir, String(work.id)); // 创建目录 try { if (!fs.existsSync(saveDir)) { fs.mkdirSync(saveDir, { recursive: true }); console.log(`[Save] 创建目录: saveDir`); } } catch (mkdirErr) { console.error(`[Save] 创建目录失败:`, mkdirErr); throw mkdirErr; } // 下载主图 const mainImagePath = path.join(saveDir, 'main.png'); try { console.log(`[Save] 开始下载图片: imageUrl`); await this.downloadFile(imageUrl, mainImagePath); console.log(`[Save] 图片下载完成: mainImagePath`); } catch (downloadErr) { console.error(`[Save] 下载图片失败:`, downloadErr.message); await this.db.updateWorkState(work.id, 99, '下载失败: ' + downloadErr.message); throw downloadErr; } // 创建480p缩略图 const sharp = require('sharp'); const thumbPath = path.join(saveDir, 'thumb.png'); try { await sharp(mainImagePath) .resize(480, null, { fit: 'inside', withoutEnlargement: true }) .png() .toFile(thumbPath); console.log(`[Save] 缩略图已创建: thumbPath`); } catch (err) { console.error(`[Save] 创建缩略图失败:`, err.message); // 缩略图失败不影响主流程 } // 更新数据库 await this.db.updateWork(work.id, { state: 10, path: saveDir, filename: 'main', ext: 'png', response_data: JSON.stringify(imageData) }); // 如果需要切割 if (work.cut > 1) { try { await this.cutImage(mainImagePath, saveDir, work.cut, config); } catch (cutErr) { console.error(`[Save] 切割图片失败:`, cutErr.message); // 切割失败不影响主流程 } } // 保存请求和响应信息 const infoPath = path.join(saveDir, 'info.json'); const infoData = { id: work.id, task_id: work.task_id, prompt: prompt, model: work.model, ratio: work.ratio, quality: work.quality, cut: work.cut, created_at: work.date, image_url: imageUrl, request: JSON.parse(work.request_data || '{}'), software: { name: '云羲多图创作', author: '小潴', email: '[email protected]', wechat: 'jakeycis', website: 'banana2.zjhn.com' } }; try { fs.writeFileSync(infoPath, JSON.stringify(infoData, null, 2)); console.log(`[Save] 信息文件已保存: infoPath`); } catch (infoErr) { console.error(`[Save] 保存信息文件失败:`, infoErr.message); } return { path: saveDir, main_image: mainImagePath, thumb_image: thumbPath, prompt: prompt }; } // 下载文件 downloadFile(url, filePath, retries = 3) { return new Promise((resolve, reject) => { const attemptDownload = (attempt) => { const protocol = url.startsWith('https') ? https : http; // 如果文件存在,先尝试删除 if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (unlinkErr) { console.warn(`[Download] 无法删除现有文件: unlinkErr.message`); } } const file = fs.createWriteStream(filePath); const request = protocol.get(url, (response) => { if (response.statusCode === 302 || response.statusCode === 301) { // 处理重定向 file.close(); try { fs.unlinkSync(filePath); } catch (e) {} this.downloadFile(response.headers.location, filePath, retries) .then(resolve) .catch(reject); return; } if (response.statusCode !== 200) { file.close(); try { fs.unlinkSync(filePath); } catch (e) {} reject(new Error(`下载失败: HTTP response.statusCode`)); return; } response.pipe(file); file.on('finish', () => { file.close((closeErr) => { if (closeErr) { console.warn(`[Download] 关闭文件时出错: closeErr.message`); if (attempt < retries) { console.log(`[Download] 重试 attempt + 1/retries...`); setTimeout(() => attemptDownload(attempt + 1), 1000); return; } reject(closeErr); } else { console.log(`[Download] 文件下载成功: filePath`); resolve(); } }); }); file.on('error', (err) => { file.close(); try { fs.unlinkSync(filePath); } catch (e) {} if (attempt < retries) { console.log(`[Download] 写入错误,重试 attempt + 1/retries: err.message`); setTimeout(() => attemptDownload(attempt + 1), 1000); } else { reject(err); } }); }); request.on('error', (err) => { file.close(); try { fs.unlinkSync(filePath); } catch (e) {} if (attempt < retries) { console.log(`[Download] 请求错误,重试 attempt + 1/retries: err.message`); setTimeout(() => attemptDownload(attempt + 1), 1000); } else { reject(err); } }); request.setTimeout(60000, () => { request.destroy(); file.close(); try { fs.unlinkSync(filePath); } catch (e) {} if (attempt < retries) { console.log(`[Download] 超时,重试 attempt + 1/retries`); setTimeout(() => attemptDownload(attempt + 1), 1000); } else { reject(new Error('下载超时')); } }); }; attemptDownload(0); }); } // 切割图片 async cutImage(imagePath, saveDir, cutNum, config) { const sharp = require('sharp'); // 检查源文件是否存在 if (!fs.existsSync(imagePath)) { console.error(`[Cut] 源图片不存在: imagePath`); return; } // 检查目标目录是否存在 if (!fs.existsSync(saveDir)) { fs.mkdirSync(saveDir, { recursive: true }); } // 读取切割提示词配置 - 文件名为 cut_{num}.prompt const configDir = path.join(__dirname, '../../config'); const cutPromptPath = path.join(configDir, `cut_cutNum.prompt`); let cutPrompt = ''; if (fs.existsSync(cutPromptPath)) { cutPrompt = fs.readFileSync(cutPromptPath, 'utf-8'); } else { // 创建默认切割提示词 cutPrompt = this.getDefaultCutPrompt(cutNum); fs.writeFileSync(cutPromptPath, cutPrompt); } // 获取图片尺寸 let metadata; try { const image = sharp(imagePath); metadata = await image.metadata(); } catch (err) { console.error(`[Cut] 读取图片失败:`, err.message); return; } const width = metadata.width; const height = metadata.height; // 计算切割参数 let cols, rows; if (cutNum === 2) { cols = 2; rows = 1; } else if (cutNum === 4) { cols = 2; rows = 2; } else if (cutNum === 6) { cols = 3; rows = 2; } else if (cutNum === 9) { cols = 3; rows = 3; } else { return; } // 获取切割内缩像素,默认3px const padding = config?.cut?.padding || 3; // 计算每个切割区域的精确位置和尺寸 // 起始位置向上取整后增加padding,结束位置向下取整后减少padding // 例如:1000/3 = 333.33...,padding=3 // 第1列:ceil(0)+3=3 到 floor(333.33)-3=330 → 宽度327 // 第2列:ceil(333.33)+3=337 到 floor(666.66)-3=663 → 宽度326 // 第3列:ceil(666.66)+3=670 到 floor(1000)-3=997 → 宽度327 const calculateCutPositions = (totalSize, parts, paddingPx) => { const unitSize = totalSize / parts; const positions = []; for (let i = 0; i < parts; i++) { const rawStart = i * unitSize; const rawEnd = (i + 1) * unitSize; // 起始位置:向上取整 + padding const start = Math.ceil(rawStart) + paddingPx; // 结束位置:向下取整 - padding const end = Math.floor(rawEnd) - paddingPx; const size = end - start; positions.push({ start, size }); } return positions; }; const xPositions = calculateCutPositions(width, cols, padding); const yPositions = calculateCutPositions(height, rows, padding); console.log(`[Cut] 图片尺寸: widthxheight, 切割: colsxrows, 内缩: paddingpx`); console.log(`[Cut] X轴切割:`, xPositions.map(p => `p.start-p.start + p.size`).join(', ')); console.log(`[Cut] Y轴切割:`, yPositions.map(p => `p.start-p.start + p.size`).join(', ')); // 切割图片 let index = 1; let lastPieceWidth = 0; let lastPieceHeight = 0; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const left = xPositions[col].start; const top = yPositions[row].start; const pieceWidth = xPositions[col].size; const pieceHeight = yPositions[row].size; lastPieceWidth = pieceWidth; lastPieceHeight = pieceHeight; const outputPath = path.join(saveDir, `index.png`); try { await sharp(imagePath) .extract({ left, top, width: pieceWidth, height: pieceHeight }) .toFile(outputPath); console.log(`[Cut] 切割 index: left=left, top=top, width=pieceWidth, height=pieceHeight`); } catch (cutErr) { console.error(`[Cut] 切割 index 失败:`, cutErr.message); } index++; } } } // 轮询所有待处理任务 async pollPendingTasks() { const tasks = await this.db.getPendingTasks(); const configPath = path.join(__dirname, '../../config/set.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); const maxPollTimes = config.polling?.max_times || 20; for (const task of tasks) { try { // 检查轮询次数 if (task.poll_count >= maxPollTimes) { await this.db.updateWorkState(task.id, 99, '轮询超时'); continue; } // 检查任务状态 const result = await this.checkTaskStatus(task); // 更新轮询信息 const newPollCount = (task.poll_count || 0) + 1; const nextPollTime = Date.now() + (config.polling?.interval || 60000); await this.db.updatePollInfo(task.id, newPollCount, nextPollTime); } catch (error) { console.error(`任务 task.id 轮询失败:`, error.message); } } } } module.exports = TaskHandler; FILE:server/services/upload.js const axios = require('axios'); const fs = require('fs'); const crypto = require('crypto'); const path = require('path'); const FormData = require('form-data'); class UploadHandler { constructor(db) { this.db = db; this.uploadDir = path.join(__dirname, '../../uploads'); // 确保上传目录存在 if (!fs.existsSync(this.uploadDir)) { fs.mkdirSync(this.uploadDir, { recursive: true }); } } /** * 处理上传流程: * 支持多种输入格式: * 1. base64 格式 - 直接解码上传 * 2. 本地绝对路径 - 从硬盘读取文件上传 * 3. URL 格式 - 下载后上传 * 4. 相对路径(/images/...)- 转换为本地路径后读取上传 */ async upload(data) { // 从请求中获取 platform_token,优先使用请求中的,否则使用环境变量 const platformToken = data.platform_token || process.env.PLATFORM_TOKEN; // 存储到环境变量(供后续使用) if (data.platform_token) { process.env.PLATFORM_TOKEN = data.platform_token; } if (!platformToken) { throw new Error('请先配置 PLATFORM_TOKEN'); } let fileBuffer; let filename; let fileHash; let ext = 'png'; // 默认扩展名 // 1. 处理 base64 数据 console.log(data) if (data.base64 && data.base64.startsWith('data:image')) { const result = this._processBase64(data.base64, data.name); fileBuffer = result.buffer; filename = result.filename; fileHash = result.hash; ext = result.ext || 'png'; } // 2. 处理相对路径(/images/...) else if (data.relative_path || (typeof data.base64 === 'string' && this._isRelativePath(data.base64))) { const relativePath = data.relative_path || data.base64; const result = await this._processRelativePath(relativePath, data.name); fileBuffer = result.buffer; filename = result.filename; fileHash = result.hash; ext = result.ext || 'png'; } // 3. 处理本地文件路径 else if (data.file_path || (typeof data.base64 === 'string' && this._isLocalPath(data.base64))) { const filePath = data.file_path || data.base64; const result = await this._processLocalFile(filePath, data.name); fileBuffer = result.buffer; filename = result.filename; fileHash = result.hash; ext = result.ext || 'png'; } // 4. 处理 URL else if (data.url || (typeof data.base64 === 'string' && this._isUrl(data.base64))) { const url = data.url || data.base64; const result = await this._processUrl(url, data.name); fileBuffer = result.buffer; filename = result.filename; fileHash = result.hash; ext = result.ext || 'png'; } // 5. 直接传入 buffer else if (data.file && Buffer.isBuffer(data.file)) { fileBuffer = data.file; fileHash = crypto.createHash('md5').update(fileBuffer).digest('hex'); filename = data.name || `upload_fileHash.png`; // 从文件名提取扩展名 const nameExt = path.extname(filename).slice(1); ext = nameExt || 'png'; } else { throw new Error('不支持的图片格式,请提供 base64、本地路径、相对路径或 URL'); } console.log(`📁 文件信息: filename, hash: fileHash, 大小: fileBuffer.length bytes`); // 检查数据库是否已存在该 hash 的记录 const existingRecord = await this.db.getUploadRecord(fileHash); if (existingRecord) { console.log(`✅ 使用缓存的上传记录: fileHash`); return { success: true, url: existingRecord.url, cached: true, hash: fileHash }; } // 读取配置 const configPath = path.join(__dirname, '../../config/set.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); try { // 创建 FormData const formData = new FormData(); formData.append('file', fileBuffer, { filename: filename, contentType: `image/ext` }); console.log(`📤 上传图片到服务器: filename`); // 上传到服务器 const response = await axios.post( config.server.upload_url, formData, { headers: { 'Authorization': `Bearer platformToken`, ...formData.getHeaders() }, timeout: 60000 } ); const url = response.data.url || response.data.file_url || response.data.data?.url; if (!url) { throw new Error('上传成功但未获取到URL: ' + JSON.stringify(response.data)); } console.log(`✅ 上传成功: url`); // 保存记录到数据库 await this.db.saveUploadRecord({ url, filename: filename, file_hash: fileHash }); return { success: true, url, cached: false, hash: fileHash }; } catch (error) { let errorMsg = '上传失败'; if (error.response) { const errorData = error.response.data; if (typeof errorData === 'object') { errorMsg = errorData.error || errorData.message || errorData.detail || JSON.stringify(errorData, null, 2); } else { errorMsg = errorData || error.message; } } else { errorMsg = error.message; } throw new Error(errorMsg); } } /** * 处理 base64 数据 */ _processBase64(base64Str, name) { const matches = base64Str.match(/^data:image\/(\w+);base64,(.+)$/); if (!matches) { throw new Error('无效的 base64 图片格式'); } const ext = matches[1] || 'png'; const base64Data = matches[2]; const buffer = Buffer.from(base64Data, 'base64'); const hash = crypto.createHash('md5').update(buffer).digest('hex'); const filename = name || `upload_hash.ext`; return { buffer, filename, hash, ext }; } /** * 处理相对路径(/images/...) * 转换为本地绝对路径后读取 */ async _processRelativePath(relativePath, name) { // /images/53/5.png -> 桌面/banana2/53/5.png // 读取配置获取保存路径 const configPath = path.join(__dirname, '../../config/set.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); // 获取默认保存路径 let basePath = config.default_save_path || ''; if (!basePath) { // 默认使用桌面/banana2 const os = require('os'); basePath = path.join(os.homedir(), 'Desktop', 'banana2'); } // 移除 /images 前缀 let relativePart = relativePath; if (relativePart.startsWith('/images/')) { relativePart = relativePart.substring(8); // 移除 '/images/' } const absolutePath = path.join(basePath, relativePart); console.log(`📂 相对路径转换: relativePath -> absolutePath`); const result = await this._processLocalFile(absolutePath, name); return result; } /** * 处理本地文件路径 */ async _processLocalFile(filePath, name) { // 检查文件是否存在 if (!fs.existsSync(filePath)) { throw new Error(`文件不存在: filePath`); } const buffer = fs.readFileSync(filePath); const hash = crypto.createHash('md5').update(buffer).digest('hex'); const ext = path.extname(filePath).slice(1) || 'png'; const filename = name || path.basename(filePath) || `upload_hash.ext`; console.log(`📂 从本地读取文件: filePath`); return { buffer, filename, hash, ext }; } /** * 处理 URL */ async _processUrl(url, name) { console.log(`🌐 从 URL 下载图片: url`); const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 30000 }); const buffer = Buffer.from(response.data); const hash = crypto.createHash('md5').update(buffer).digest('hex'); // 从 URL 或 Content-Type 获取扩展名 let ext = 'png'; const contentType = response.headers['content-type']; if (contentType && contentType.startsWith('image/')) { ext = contentType.split('/')[1].split(';')[0]; } else { const urlExt = path.extname(url).slice(1); if (urlExt && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(urlExt.toLowerCase())) { ext = urlExt.toLowerCase(); } } const filename = name || `upload_hash.ext`; return { buffer, filename, hash, ext }; } /** * 判断是否为相对路径(/images/...) */ _isRelativePath(str) { return str.startsWith('/images/') || str.startsWith('/image/'); } /** * 判断是否为本地路径 */ _isLocalPath(str) { // Windows: C:\path 或 D:/path // Unix: /path (但不是 /images/...) return /^[A-Za-z]:[/\\]/.test(str) || (str.startsWith('/') && !this._isRelativePath(str)); } /** * 判断是否为 URL */ _isUrl(str) { return /^https?:\/\//i.test(str); } /** * 获取本地图片路径 */ getLocalPath(hash) { const files = fs.readdirSync(this.uploadDir); const file = files.find(f => f.startsWith(hash)); return file ? path.join(this.uploadDir, file) : null; } /** * 清理过期的上传记录 */ async cleanup() { const count = await this.db.cleanupExpiredUploads(); console.log(`🧹 清理了 count 条过期上传记录`); return count; } } module.exports = UploadHandler; FILE:public/index.html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title data-i18n="app.title">云羲AI绘图分影工具</title> <link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/font.css"> <link rel="stylesheet" href="/css/win.css"> <link rel="stylesheet" href="/css/logo.css"> </head> <body> <!-- 导航栏 --> <nav class="navbar"> <div class="nav-left"> <div class="logo" id="logo-btn" style="cursor: pointer;"></div> <span class="title" data-i18n="app.title">云羲AI绘图分影工具</span> </div> <div class="nav-right"> <!-- 搜索框 --> <div class="search-box"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="11" cy="11" r="8"></circle> <line x1="21" y1="21" x2="16.65" y2="16.65"></line> </svg> <input type="text" id="search-input" placeholder="搜索提示词..." data-i18n-placeholder="nav.searchPlaceholder"> </div> <button class="nav-btn active icon icon-bianji" id="btn-editor" title="编辑器" data-i18n-title="nav.editor"> </button> <button class="nav-btn" id="btn-list" title="列表" data-i18n-title="nav.list" onclick="location.href='list.html'"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="4" y1="5" x2="21" y2="5"></line> <line x1="4" y1="12" x2="21" y2="12"></line> <line x1="4" y1="19" x2="21" y2="19"></line> </svg> </button> <!-- 语言选择 --> <div class="lang-selector"> <button class="nav-btn lang-btn" id="btn-lang" title="语言" data-i18n-title="nav.language"> <span class="lang-text" id="lang-text">中</span> </button> <div class="lang-menu" id="lang-menu"> <!-- 语言选项由JavaScript动态生成 --> </div> </div> <button class="nav-btn" id="btn-theme" title="主题切换" data-i18n-title="nav.theme"> <svg class="sun-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="5"></circle> <line x1="12" y1="1" x2="12" y2="3"></line> <line x1="12" y1="21" x2="12" y2="23"></line> <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> <line x1="1" y1="12" x2="3" y2="12"></line> <line x1="21" y1="12" x2="23" y2="12"></line> <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> </svg> <svg class="moon-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none;"> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> </svg> </button> <button class="nav-btn" id="btn-settings" title="设置" data-i18n-title="nav.settings"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="3"></circle> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path> </svg> </button> <button class="nav-btn" id="btn-close" title="关闭服务" data-i18n-title="nav.close"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="18" y1="6" x2="6" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line> </svg> </button> </div> </nav> <!-- 主内容区 --> <main class="main-content"> <div class="gallery" id="gallery"> <!-- 瀑布流图片列表 --> </div> </main> <!-- 编辑器 --> <div class="editor" id="editor"> <div class="editor-upload" id="editor-upload" style="display: none;"> <!-- 上传的图片列表 --> </div> <div class="editor-main"> <button class="upload-btn icon icon-image-up" style="font-size: 24px;color: #666;" id="upload-btn" title="上传图片" data-i18n-title="editor.upload"> </button> <input type="file" id="file-input" multiple accept="image/*" style="display: none;"> <div class="editor-input" id="editor-input" contenteditable="plaintext-only" data-i18n-placeholder="editor.placeholder"></div> </div> <div class="editor-controls"> <div class="param-selector"> <button class="param-btn" id="model-btn"> <span class="param-icon">🍌</span> <span class="param-text">Nano Banana Pro</span> </button> <div class="param-menu" id="model-menu"></div> </div> <div class="param-selector" id="size-selector"> <button class="param-btn" id="size-btn"> <div class="ratio-icon"><div class="ratio-box"></div></div> <span class="param-text">1:1 方形</span> </button> <div class="param-menu" id="size-menu"></div> </div> <div class="param-selector" id="quality-selector"> <button class="param-btn" id="quality-btn"> <icon class="quality-icon icon icon-fenbianshuai"></icon> <span class="param-text">2K (2048px)</span> </button> <div class="param-menu" id="quality-menu"></div> </div> <div class="param-selector" id="cut-selector"> <button class="param-btn" id="cut-btn"> <div class="cut-icon"><div class="cut-box single"></div></div> <span class="param-text">不切割</span> </button> <div class="param-menu" id="cut-menu"></div> </div> <button class="ai-prompt-btn icon icon-AI" id="ai-prompt-btn" title="AI生成提示词" data-i18n-title="editor.aiPrompt"> </button> <button class="submit-btn" id="submit-btn"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="22" y1="2" x2="11" y2="13"></line> <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> </svg> </button> </div> </div> <!-- 图片查看器 --> <div class="viewer" id="viewer" style="display: none;"> <button class="viewer-close icon icon-close-fill" id="viewer-close"></button> <div class="viewer-content"> <div class="viewer-actions-left"> <button class="viewer-action-btn" id="action-info" title="信息" data-i18n-title="viewer.info">ℹ️</button> <button class="viewer-action-btn" id="action-folder" title="打开文件夹" data-i18n-title="viewer.openFolder">📁</button> <button class="viewer-action-btn icon icon-chuangzuo1" id="action-edit" title="编辑" data-i18n-title="viewer.edit"></button> </div> <div class="viewer-main-area"> <div class="viewer-image-container"> <img class="viewer-image" id="viewer-image" src="" alt=""> </div> </div> <div class="viewer-cuts" id="viewer-cuts"> <!-- 切割图列表 --> </div> </div> </div> <!-- Toast 通知容器 --> <div id="toast-container"></div> <script src="/js/jquery.min.js"></script> <script src="/js/win.js"></script> <script src="/js/app.js"></script> </body> </html> FILE:public/list.html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>任务列表管理 - Nano Banana V2</title> <link rel="stylesheet" href="/css/style.css"> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; min-height: 100vh; padding: 20px; overflow-y: auto; } body.dark { background: #1a1a2e; color: #fff; } .container { max-width: 1400px; margin: 0 auto; } .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #e5e7eb; } body.dark .header { border-bottom-color: #374151; } .header h1 { font-size: 20px; font-weight: 600; } .header-actions { display: flex; gap: 10px; } .btn { padding: 8px 16px; border: none; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.2s; } .btn-primary { background: #3b82f6; color: #fff; } .btn-primary:hover { background: #2563eb; } .btn-secondary { background: #e5e7eb; color: #374151; } .btn-secondary:hover { background: #d1d5db; } body.dark .btn-secondary { background: #374151; color: #fff; } body.dark .btn-secondary:hover { background: #4b5563; } .btn-danger { background: #ef4444; color: #fff; } .btn-danger:hover { background: #dc2626; } /* 筛选栏 */ .filter-bar { display: flex; gap: 15px; margin-bottom: 20px; flex-wrap: wrap; } .filter-group { display: flex; align-items: center; gap: 8px; } .filter-group label { font-size: 13px; color: #6b7280; } .filter-group select, .filter-group input { padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 13px; background: #fff; } body.dark .filter-group select, body.dark .filter-group input { background: #374151; border-color: #4b5563; color: #fff; } /* 表格 */ .table-container { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } body.dark .table-container { background: #1f2937; } table { width: 100%; border-collapse: collapse; } th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #e5e7eb; } body.dark th, body.dark td { border-bottom-color: #374151; } th { background: #f9fafb; font-weight: 600; font-size: 13px; color: #374151; } body.dark th { background: #374151; color: #fff; } td { font-size: 13px; color: #4b5563; } body.dark td { color: #d1d5db; } tr:hover { background: #f9fafb; } body.dark tr:hover { background: #374151; } /* 状态标签 */ .status-badge { display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; } .status-pending { background: #fef3c7; color: #92400e; } .status-completed { background: #d1fae5; color: #065f46; } .status-failed { background: #fee2e2; color: #991b1b; } body.dark .status-pending { background: rgba(251, 191, 36, 0.2); color: #fbbf24; } body.dark .status-completed { background: rgba(16, 185, 129, 0.2); color: #34d399; } body.dark .status-failed { background: rgba(239, 68, 68, 0.2); color: #f87171; } /* 操作按钮 */ .action-btns { display: flex; gap: 6px; } .action-btn { padding: 4px 8px; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; transition: all 0.2s; } .action-btn-view { background: #dbeafe; color: #1d4ed8; } .action-btn-folder { background: #fef3c7; color: #92400e; } .action-btn-delete { background: #fee2e2; color: #dc2626; } .action-btn:hover { opacity: 0.8; } /* 缩略图 */ .thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; cursor: pointer; } .thumb:hover { transform: scale(1.1); } /* 分页 */ .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 20px; padding: 15px; } .pagination button { padding: 8px 12px; border: 1px solid #e5e7eb; background: #fff; border-radius: 6px; cursor: pointer; } body.dark .pagination button { background: #374151; border-color: #4b5563; color: #fff; } .pagination button:disabled { opacity: 0.5; cursor: not-allowed; } .pagination span { font-size: 13px; color: #6b7280; } /* 提示词截断 */ .prompt-text { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 空状态 */ .empty-state { text-align: center; padding: 60px 20px; color: #9ca3af; } .empty-state-icon { font-size: 48px; margin-bottom: 16px; } /* 复选框 */ .checkbox-cell { width: 40px; } .checkbox-cell input { width: 16px; height: 16px; cursor: pointer; } /* 弹窗样式 */ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; transition: opacity 0.3s; } .modal-overlay.show { opacity: 1; } .modal-box { background: #fff; border-radius: 12px; width: 500px; max-width: 90%; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); } body.dark .modal-box { background: #1f2937; } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #e5e7eb; } body.dark .modal-header { border-bottom-color: #374151; } .modal-header h3 { margin: 0; font-size: 16px; } .modal-close { width: 32px; height: 32px; border: none; background: transparent; font-size: 24px; cursor: pointer; border-radius: 6px; } .modal-close:hover { background: #f3f4f6; } body.dark .modal-close:hover { background: #374151; } .modal-body { padding: 20px; } .form-group { margin-bottom: 16px; } .form-group label { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 500; } .form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 13px; background: #fff; } body.dark .form-group input, body.dark .form-group textarea, body.dark .form-group select { background: #374151; border-color: #4b5563; color: #fff; } .form-group textarea { min-height: 100px; resize: vertical; } .form-hint { font-size: 12px; color: #6b7280; margin-top: 4px; } .modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 20px; border-top: 1px solid #e5e7eb; } body.dark .modal-footer { border-top-color: #374151; } </style> </head> <body> <div class="container"> <div class="header"> <h1>📋 任务列表管理</h1> <div class="header-actions"> <button class="btn btn-primary" onclick="showAddTask()">➕ 添加任务</button> <button class="btn btn-secondary" onclick="refreshList()">🔄 刷新</button> <button class="btn btn-danger" onclick="deleteSelected()">🗑️ 删除选中</button> <button class="btn btn-secondary" onclick="window.location.href='/'">🏠 返回主页</button> </div> </div> <!-- 添加任务弹窗 --> <div class="modal-overlay" id="add-task-modal" style="display: none;"> <div class="modal-box"> <div class="modal-header"> <h3>➕ 添加任务</h3> <button class="modal-close" onclick="hideAddTask()">×</button> </div> <div class="modal-body"> <div class="form-group"> <label>Task ID <span style="color: #ef4444;">*</span></label> <input type="text" id="add-task-id" placeholder="输入任务ID"> <div class="form-hint">从 API 返回的任务ID</div> </div> <div class="form-group"> <label>提示词</label> <textarea id="add-task-prompt" placeholder="输入提示词内容(可选)"></textarea> </div> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;"> <div class="form-group"> <label>比例</label> <select id="add-task-ratio"> <option value="1:1">1:1 方形</option> <option value="3:2">3:2 横版</option> <option value="2:3">2:3 竖版</option> <option value="16:9">16:9 横屏</option> <option value="9:16">9:16 竖屏</option> <option value="4:3">4:3 标准</option> <option value="3:4">3:4 竖版</option> <option value="4:5">4:5 照片</option> <option value="5:4">5:4 照片</option> <option value="21:9">21:9 超宽</option> </select> </div> <div class="form-group"> <label>切割数量</label> <select id="add-task-cut"> <option value="1">不切割</option> <option value="2">2宫格</option> <option value="4">4宫格</option> <option value="6">6宫格</option> <option value="9">9宫格</option> </select> </div> </div> </div> <div class="modal-footer"> <button class="btn btn-secondary" onclick="hideAddTask()">取消</button> <button class="btn btn-primary" onclick="submitAddTask()">添加任务</button> </div> </div> </div> <div class="filter-bar"> <div class="filter-group"> <label>状态:</label> <select id="filter-status" onchange="filterList()"> <option value="">全部</option> <option value="10">已完成</option> <option value="1">处理中</option> <option value="99">失败</option> </select> </div> <div class="filter-group"> <label>切割:</label> <select id="filter-cut" onchange="filterList()"> <option value="">全部</option> <option value="1">不切割</option> <option value="2">2宫格</option> <option value="4">4宫格</option> <option value="6">6宫格</option> <option value="9">9宫格</option> </select> </div> <div class="filter-group"> <label>搜索:</label> <input type="text" id="filter-search" placeholder="搜索提示词..." oninput="filterList()"> </div> </div> <div class="table-container"> <table> <thead> <tr> <th class="checkbox-cell"><input type="checkbox" id="select-all" onchange="toggleSelectAll()"></th> <th style="width:60px;">预览</th> <th>ID</th> <th>提示词</th> <th>模型</th> <th>比例</th> <th>切割</th> <th>状态</th> <th>日期</th> <th>操作</th> </tr> </thead> <tbody id="list-body"> <!-- 动态填充 --> </tbody> </table> </div> <div class="pagination"> <button id="prev-btn" onclick="changePage(-1)">上一页</button> <span id="page-info">第 1 页</span> <button id="next-btn" onclick="changePage(1)">下一页</button> </div> </div> <script src="/js/jquery.min.js"></script> <script> // 状态 let allWorks = []; let filteredWorks = []; let currentPage = 1; const pageSize = 20; // 加载列表 function loadList() { $.get('/api/works', function(res) { if (res.success) { allWorks = res.data || []; filterList(); } }); } // 筛选 function filterList() { const status = $('#filter-status').val(); const cut = $('#filter-cut').val(); const search = $('#filter-search').val().toLowerCase(); filteredWorks = allWorks.filter(w => { if (status && w.state != status) return false; if (cut && w.cut != cut) return false; if (search && !w.prompt?.toLowerCase().includes(search)) return false; return true; }); currentPage = 1; renderList(); } // 渲染 function renderList() { const start = (currentPage - 1) * pageSize; const end = start + pageSize; const pageWorks = filteredWorks.slice(start, end); if (pageWorks.length === 0) { $('#list-body').html(` <tr> <td colspan="10"> <div class="empty-state"> <div class="empty-state-icon">📭</div> <p>暂无数据</p> </div> </td> </tr> `); return; } const html = pageWorks.map(work => { const date = new Date(work.date); const dateStr = `date.getFullYear()-String(date.getMonth()+1).padStart(2,'0')-String(date.getDate()).padStart(2,'0') String(date.getHours()).padStart(2,'0'):String(date.getMinutes()).padStart(2,'0')`; let statusText = ''; let statusClass = ''; if (work.state === 1) { statusText = '处理中'; statusClass = 'status-pending'; } else if (work.state === 10) { statusText = '已完成'; statusClass = 'status-completed'; } else if (work.state === 99) { statusText = '失败'; statusClass = 'status-failed'; } // 缩略图 let thumbHtml = ''; if (work.state === 10 && work.path) { let httpPath = work.http_path; if (!httpPath) { // 兼容旧数据,尝试转换路径 httpPath = work.path.replace(/^[A-Z]:\\Users\\[^\\]+\\Desktop\\banana2/i, '/images'); } thumbHtml = `<img src="httpPath/thumb.png" class="thumb" onclick="openViewer(work.id)" onerror="this.onerror=null; this.src='httpPath/main.png';">`; } return ` <tr data-id="work.id" data-path="work.path || ''"> <td class="checkbox-cell"><input type="checkbox" class="row-checkbox" value="work.id"></td> <td>thumbHtml</td> <td>work.id</td> <td class="prompt-text" title="work.prompt || ''">work.prompt || '-'</td> <td>work.model || '-'</td> <td>work.ratio || '-'</td> <td>'-'</td> <td><span class="status-badge statusClass">statusText</span></td> <td>dateStr</td> <td> <div class="action-btns"> work.state === 10 ? `<button class="action-btn action-btn-view" onclick="openViewer(${work.id)">👁️</button>` : ''} work.path ? `<button class="action-btn action-btn-folder" data-path="${work.path">📁</button>` : ''} <button class="action-btn action-btn-delete" onclick="deleteWork(work.id)">🗑️</button> </div> </td> </tr> `; }).join(''); $('#list-body').html(html); updatePagination(); } // 分页 function updatePagination() { const totalPages = Math.ceil(filteredWorks.length / pageSize); $('#page-info').text(`第 currentPage / totalPages || 1 页 (共 filteredWorks.length 条)`); $('#prev-btn').prop('disabled', currentPage <= 1); $('#next-btn').prop('disabled', currentPage >= totalPages); } function changePage(delta) { const totalPages = Math.ceil(filteredWorks.length / pageSize); currentPage = Math.max(1, Math.min(totalPages, currentPage + delta)); renderList(); } // 全选 function toggleSelectAll() { const checked = $('#select-all').prop('checked'); $('.row-checkbox').prop('checked', checked); } // 刷新 function refreshList() { loadList(); } // 查看图片 function viewImage(src) { window.open(src, '_blank'); } // 打开查看器 function openViewer(id) { window._winViewerId = id; // 加载 viewer 组件 $.get('/components/viewer.html', function(html) { // 移除旧的 viewer $('#viewer-layer').remove(); // 插入新的 viewer $('body').append(html); }); } // 查看任务 function viewWork(id) { window.location.href = `/?view=id`; } // 打开文件夹 function openFolder(folderPath) { $.ajax({ url: '/api/open-folder', type: 'POST', contentType: 'application/json', data: JSON.stringify({ path: folderPath }), success: function(res) { if (!res.success) { alert('打开失败: ' + res.error); } // 成功时不提示 }, error: function(xhr) { alert('打开失败: ' + (xhr.responseJSON?.error || '网络错误')); } }); } // 删除单个 function deleteWork(id) { if (!confirm('确定要删除这条记录吗?')) return; $.post(`/api/admin/delete/id`, function(res) { if (res.success) { loadList(); } else { alert('删除失败: ' + res.error); } }); } // 删除选中 function deleteSelected() { const ids = []; $('.row-checkbox:checked').each(function() { ids.push($(this).val()); }); if (ids.length === 0) { alert('请先选择要删除的记录'); return; } if (!confirm(`确定要删除选中的 ids.length 条记录吗?`)) return; // 逐个删除 let deleted = 0; ids.forEach(id => { $.post(`/api/admin/delete/id`, function(res) { deleted++; if (deleted === ids.length) { loadList(); } }); }); } // 事件委托 - 打开文件夹按钮 $(document).on('click', '.action-btn-folder', function() { const folderPath = $(this).data('path'); if (folderPath) { openFolder(folderPath); } }); // 显示添加任务弹窗 function showAddTask() { $('#add-task-modal').show().addClass('show'); $('#add-task-id').val(''); $('#add-task-cut').val('1'); $('#add-task-ratio').val('1:1'); $('#add-task-prompt').val(''); } // 隐藏添加任务弹窗 function hideAddTask() { $('#add-task-modal').removeClass('show'); setTimeout(() => { $('#add-task-modal').hide(); }, 300); } // 提交添加任务 function submitAddTask() { const taskId = $('#add-task-id').val().trim(); const cut = parseInt($('#add-task-cut').val()) || 1; const ratio = $('#add-task-ratio').val() || '1:1'; const prompt = $('#add-task-prompt').val().trim(); if (!taskId) { alert('请输入 Task ID'); return; } // 从 localStorage 获取 API Key const apiKey = localStorage.getItem('banana_api_key') || ''; const platformToken = localStorage.getItem('banana_platform_token') || ''; const modelApiKey = localStorage.getItem('banana_model_api_key') || ''; const savePath = localStorage.getItem('banana_save_path') || ''; if (!apiKey) { alert('请先在主页配置 API Key'); return; } $.ajax({ url: '/api/tasks/add', type: 'POST', contentType: 'application/json', data: JSON.stringify({ task_id: taskId, cut: cut, ratio: ratio, prompt: prompt, api_key: apiKey, platform_token: platformToken, model_api_key: modelApiKey, save_path: savePath }), success: function(res) { if (res.success) { alert('任务添加成功!'); hideAddTask(); loadList(); } else { alert('添加失败: ' + res.error); } }, error: function(xhr) { alert('添加失败: ' + (xhr.responseJSON?.error || xhr.statusText)); } }); } // 初始化 loadList(); </script> </body> </html> FILE:public/set.html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>系统设置 - Nano Banana V2</title> <link rel="stylesheet" href="/css/style.css"> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; min-height: 100vh; padding: 20px; overflow-y: auto; } body.dark { background: #1a1a2e; color: #fff; } .container { max-width: 900px; margin: 0 auto; } .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #e5e7eb; } body.dark .header { border-bottom-color: #374151; } .header h1 { font-size: 20px; font-weight: 600; } .btn { padding: 8px 16px; border: none; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.2s; } .btn-primary { background: #3b82f6; color: #fff; } .btn-primary:hover { background: #2563eb; } .btn-secondary { background: #e5e7eb; color: #374151; } .btn-secondary:hover { background: #d1d5db; } body.dark .btn-secondary { background: #374151; color: #fff; } .btn-success { background: #10b981; color: #fff; } .btn-success:hover { background: #059669; } /* 设置卡片 */ .settings-card { background: #fff; border-radius: 12px; margin-bottom: 20px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } body.dark .settings-card { background: #1f2937; } .card-header { padding: 16px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; display: flex; align-items: center; justify-content: space-between; } body.dark .card-header { background: #374151; border-bottom-color: #4b5563; } .card-header h2 { font-size: 15px; font-weight: 600; } .card-body { padding: 20px; } /* 表单 */ .form-group { margin-bottom: 16px; } .form-group:last-child { margin-bottom: 0; } .form-group label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; } body.dark .form-group label { color: #d1d5db; } .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 13px; background: #fff; transition: all 0.2s; } .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } body.dark .form-group input, body.dark .form-group select, body.dark .form-group textarea { background: #374151; border-color: #4b5563; color: #fff; } .form-group textarea { min-height: 100px; resize: vertical; font-family: 'Monaco', 'Menlo', monospace; } .form-hint { font-size: 12px; color: #6b7280; margin-top: 4px; } /* JSON 编辑器 */ .json-editor { font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 12px; line-height: 1.6; height: 60vh; } /* 模型列表 */ .model-list { display: flex; flex-direction: column; gap: 12px; } .model-item { display: grid; grid-template-columns: 40px 1fr 1fr auto; gap: 12px; align-items: center; padding: 12px; background: #f9fafb; border-radius: 8px; } body.dark .model-item { background: #374151; } .model-item input { padding: 8px 10px; } .model-logo { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 24px; background: #fff; border-radius: 8px; } body.dark .model-logo { background: #4b5563; } /* 分辨率列表 */ .resolution-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; } .resolution-item { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: #f9fafb; border-radius: 8px; } body.dark .resolution-item { background: #374151; } .resolution-item input { flex: 1; padding: 6px 8px; font-size: 12px; } /* 语言列表 - 每3项一行 */ .language-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } @media (max-width: 800px) { .language-list { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 500px) { .language-list { grid-template-columns: 1fr; } } .language-item { display: grid; grid-template-columns: 70px 1fr 50px; gap: 8px; align-items: center; padding: 10px 12px; background: #f9fafb; border-radius: 8px; } body.dark .language-item { background: #374151; } .language-item input { padding: 6px 8px; font-size: 12px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; } body.dark .language-item input { background: #4b5563; border-color: #6b7280; color: #fff; } /* 模型列表 */ .model-list-container { display: flex; flex-direction: column; gap: 16px; } .model-card { background: #f9fafb; border-radius: 12px; padding: 16px; position: relative; } body.dark .model-card { background: #374151; } .model-card-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } .model-card-logo { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 24px; background: #fff; border-radius: 10px; border: 1px solid #e5e7eb; } body.dark .model-card-logo { background: #4b5563; border-color: #6b7280; } .model-card-title { flex: 1; } .model-card-title input { font-size: 14px; font-weight: 600; border: none; background: transparent; padding: 4px 0; width: 100%; } .model-card-body { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } @media (max-width: 600px) { .model-card-body { grid-template-columns: 1fr; } } .model-card-body .form-group { margin-bottom: 0; } .model-card-body label { font-size: 12px; color: #6b7280; } .model-card-body input, .model-card-body select { padding: 8px 10px; font-size: 13px; } .model-card-body .full-width { grid-column: 1 / -1; } .model-card-remove { position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; border: none; background: #fee2e2; color: #dc2626; border-radius: 6px; cursor: pointer; font-size: 14px; } .model-card-remove:hover { background: #fecaca; } /* 提示词模板 */ .prompt-list { display: flex; flex-direction: column; gap: 16px; height: auto !important; max-height: none !important; } .prompt-card { background: #f9fafb; border-radius: 12px; } body.dark .prompt-card { background: #374151; } .prompt-card-header { padding: 12px 16px; background: #f3f4f6; border-bottom: 1px solid #e5e7eb; } body.dark .prompt-card-header { background: #4b5563; border-bottom-color: #6b7280; } .prompt-card-header input { width: 100%; padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 13px; background: #fff; } body.dark .prompt-card-header input { background: #374151; border-color: #6b7280; color: #fff; } .prompt-card-body { padding: 16px; } .prompt-card-body textarea { width: 100%; min-height: 120px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 13px; font-family: 'Monaco', 'Menlo', monospace; line-height: 1.6; resize: vertical; background: #fff; box-sizing: border-box; } body.dark .prompt-card-body textarea { background: #1f2937; border-color: #6b7280; color: #fff; } .prompt-card-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid #e5e7eb; } body.dark .prompt-card-footer { border-top-color: #6b7280; } .btn-danger { background: #ef4444; color: #fff; } .btn-danger:hover { background: #dc2626; } /* API Key 区域 */ .api-section { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } @media (max-width: 600px) { .api-section { grid-template-columns: 1fr; } } /* 操作按钮组 */ .btn-group { display: flex; gap: 10px; } /* 状态提示 */ .status-msg { padding: 10px 16px; border-radius: 8px; margin-top: 16px; font-size: 13px; } .status-success { background: #d1fae5; color: #065f46; } .status-error { background: #fee2e2; color: #991b1b; } body.dark .status-success { background: rgba(16, 185, 129, 0.2); color: #34d399; } body.dark .status-error { background: rgba(239, 68, 68, 0.2); color: #f87171; } /* Tab 切换 */ .tabs { display: flex; gap: 4px; margin-bottom: 20px; background: #fff; padding: 4px; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } body.dark .tabs { background: #1f2937; } .tab-btn { flex: 1; padding: 10px 16px; border: none; background: transparent; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.2s; color: #6b7280; } .tab-btn:hover { background: #f3f4f6; } .tab-btn.active { background: #3b82f6; color: #fff; } body.dark .tab-btn:hover { background: #374151; } .tab-content { display: none; } .tab-content.active { display: block; } </style> </head> <body> <div class="container"> <div class="header"> <h1>⚙️ 系统设置</h1> <div class="btn-group"> <button class="btn btn-secondary" onclick="window.location.href='/'">🏠 返回主页</button> </div> </div> <!-- Tab 切换 --> <div class="tabs"> <button class="tab-btn active" onclick="switchTab('config')">📋 配置参数</button> <button class="tab-btn" onclick="switchTab('json')">📄 JSON 编辑</button> <button class="tab-btn" onclick="switchTab('api')">🔑 API 密钥</button> <button class="tab-btn" onclick="switchTab('prompts')">📝 提示词模板</button> </div> <!-- 配置参数 Tab --> <div id="tab-config" class="tab-content active"> <!-- 服务器配置 --> <div class="settings-card"> <div class="card-header"> <h2>🌐 服务器配置</h2> </div> <div class="card-body"> <div class="form-group"> <label>图片生成接口地址</label> <input type="text" id="config-server-url" placeholder="API 服务器地址"> </div> <div class="form-group"> <label>图片任务获取地址</label> <input type="text" id="config-server-task-url" placeholder="任务查询 API 地址"> </div> <div class="form-group"> <label>图片上传地址</label> <input type="text" id="config-server-upload-url" placeholder="图片上传地址"> </div> <div class="form-group"> <label>服务端口</label> <input type="number" id="config-server-port" placeholder="2688"> </div> </div> </div> <!-- 大模型配置 --> <div class="settings-card"> <div class="card-header"> <h2>🤖 大模型配置</h2> </div> <div class="card-body"> <div class="form-group"> <label>大模型API地址</label> <input type="text" id="config-llm-url" placeholder="大模型 API 地址"> </div> <div class="form-group"> <label>大模型名称</label> <input type="text" id="config-llm-name" placeholder="GPT-3.5 Turbo"> </div> <div class="form-group"> <label>模型调用接口名</label> <input type="text" id="config-llm-model" placeholder="gpt-3.5-turbo"> </div> </div> </div> <!-- 模型配置 --> <div class="settings-card"> <div class="card-header"> <h2>🎨 生成模型配置</h2> </div> <div class="card-body"> <div id="models-list" class="model-list-container"> <!-- 动态填充 --> </div> <button class="btn btn-secondary" style="margin-top: 12px;" onclick="addModel()">➕ 添加模型</button> </div> </div> <!-- 轮询配置 --> <div class="settings-card"> <div class="card-header"> <h2>🔄 轮询配置</h2> </div> <div class="card-body"> <div class="form-group"> <label>轮询间隔 (毫秒)</label> <input type="number" id="config-polling-interval" placeholder="20000"> <div class="form-hint">检查任务状态的间隔时间,建议 10000-60000</div> </div> <div class="form-group"> <label>最大轮询次数</label> <input type="number" id="config-polling-max-times" placeholder="60"> <div class="form-hint">超过此次数后停止轮询</div> </div> </div> </div> <!-- 切割配置 --> <div class="settings-card"> <div class="card-header"> <h2>✂️ 切割配置</h2> </div> <div class="card-body"> <div class="form-group"> <label>切割内缩像素</label> <input type="number" id="config-cut-padding" placeholder="3"> <div class="form-hint">切割时向内缩进的像素值,避免黑边</div> </div> <div class="form-group"> <label>默认切割模式</label> <select id="config-cut-mode"> <option value="none">不切割</option> <option value="grid">网格切割</option> <option value="smart">智能切割</option> </select> <div class="form-hint">选择默认的切割方式</div> </div> <div class="form-group"> <label>默认网格数</label> <input type="number" id="config-cut-grid" placeholder="9" min="2" max="16"> <div class="form-hint">默认切割成几宫格(如 9 表示九宫格)</div> </div> <div class="form-group"> <label>切割线宽度</label> <input type="number" id="config-cut-line-width" placeholder="0" min="0" max="10"> <div class="form-hint">切割线宽度(0 表示不绘制切割线)</div> </div> <div class="form-group"> <label>切割线颜色</label> <input type="text" id="config-cut-line-color" placeholder="#ffffff"> <div class="form-hint">切割线颜色(十六进制,如 #ffffff)</div> </div> <div class="form-group"> <label>输出格式</label> <select id="config-cut-format"> <option value="png">PNG</option> <option value="jpg">JPG</option> <option value="webp">WebP</option> </select> <div class="form-hint">切割后图片的输出格式</div> </div> <div class="form-group"> <label>输出质量</label> <input type="number" id="config-cut-quality" placeholder="90" min="1" max="100"> <div class="form-hint">输出图片质量(1-100,仅对 JPG/WebP 有效)</div> </div> </div> </div> <!-- 默认保存路径 --> <div class="settings-card"> <div class="card-header"> <h2>📁 默认保存路径</h2> </div> <div class="card-body"> <div class="form-group"> <label>默认路径</label> <input type="text" id="config-default-save-path" placeholder="留空使用默认路径"> <div class="form-hint">新任务的默认保存路径</div> </div> </div> </div> <!-- 语言配置 --> <div class="settings-card"> <div class="card-header"> <h2>🌍 语言配置</h2> </div> <div class="card-body"> <div id="languages-list" class="language-list"> <!-- 动态填充 --> </div> <button class="btn btn-secondary" style="margin-top: 12px;" onclick="addLanguage()">➕ 添加语言</button> </div> </div> <!-- 保存按钮 --> <div style="text-align: center; margin-top: 20px;padding-bottom: 50px;"> <button class="btn btn-success" onclick="saveAllSettings()" style="padding: 12px 40px; font-size: 15px;">💾 保存全部配置</button> </div> </div> <!-- API 密钥 Tab --> <div id="tab-api" class="tab-content"> <div class="settings-card"> <div class="card-header"> <h2>🔑 API 密钥配置</h2> </div> <div class="card-body"> <div class="api-section"> <div class="form-group"> <label>API Key <span style="color: #ef4444;">*</span></label> <input type="password" id="config-api-key" placeholder="用于图片生成"> <div class="form-hint">从 <a href="https://share.acedata.cloud/r/1uN88BrUTQ" target="_blank">AceData</a> 获取</div> </div> <div class="form-group"> <label>Platform Token</label> <input type="password" id="config-platform-token" placeholder="用于图片上传"> <div class="form-hint">可选,用于图生图功能</div> </div> </div> <div class="api-section"> <div class="form-group"> <label>大模型 API Key</label> <input type="password" id="config-model-api-key" placeholder="用于 AI 生成提示词"> <div class="form-hint">可选,用于 AI 提示词生成</div> </div> <div class="form-group"> <label>图片保存路径</label> <input type="text" id="config-save-path" placeholder="默认: 桌面/banana2"> <div class="form-hint">留空使用默认路径</div> </div> </div> <button class="btn btn-primary" onclick="saveApiSettings()">💾 保存密钥设置</button> <div id="api-status" class="status-msg" style="display: none;"></div> </div> </div> </div> <!-- 提示词模板 Tab --> <div id="tab-prompts" class="tab-content"> <div class="settings-card"> <div class="card-header"> <h2>📝 提示词模板文件</h2> <button class="btn btn-primary" onclick="createNewPrompt()">➕ 新建模板</button> </div> <div class="card-body"> <div id="prompts-list" class="prompt-list"> <!-- 动态填充 --> </div> </div> </div> </div> <!-- JSON 编辑 Tab --> <div id="tab-json" class="tab-content" style="padding-bottom: 30px;"> <div class="settings-card"> <div class="card-header"> <h2>📄 JSON 配置编辑器</h2> <button class="btn btn-secondary" onclick="formatJson()">📋 格式化</button> </div> <div class="card-body"> <div class="form-group"> <label>set.json 内容</label> <textarea id="json-editor" class="json-editor" placeholder="JSON 配置内容..."></textarea> </div> <div class="btn-group"> <button class="btn btn-secondary" onclick="loadJson()">🔄 重置</button> <button class="btn btn-success" onclick="saveJson()">💾 保存 JSON</button> </div> <div id="json-status" class="status-msg" style="display: none;"></div> </div> </div> </div> </div> <script src="/js/jquery.min.js"></script> <script> let originalConfig = null; // 切换 Tab function switchTab(tabName) { $('.tab-btn').removeClass('active'); $('.tab-content').removeClass('active'); $(`[onclick="switchTab('tabName')"]`).addClass('active'); $(`#tab-tabName`).addClass('active'); } // 加载配置 function loadConfig() { $.get('/api/get_set', function(res) { if (res.success) { originalConfig = res.data; populateForm(res.data); populateJsonEditor(res.data); } }); // 加载 API 密钥 // 从 localStorage 加载 API 设置 $('#config-api-key').val(localStorage.getItem('banana_api_key') || ''); $('#config-platform-token').val(localStorage.getItem('banana_platform_token') || ''); $('#config-model-api-key').val(localStorage.getItem('banana_model_api_key') || ''); $('#config-save-path').val(localStorage.getItem('banana_save_path') || ''); // 加载 LLM 配置 } // 填充表单 function populateForm(config) { // 服务器配置 if (config.server) { $('#config-server-url').val(config.server.url || ''); $('#config-server-task-url').val(config.server.task_url || ''); $('#config-server-upload-url').val(config.server.upload_url || ''); $('#config-server-port').val(config.server.port || 2688); } if (config.llm) { $('#config-llm-url').val(config.llm.url || ''); $('#config-llm-model').val(config.llm.model || ''); } // 轮询配置 if (config.polling) { $('#config-polling-interval').val(config.polling.interval || 20000); $('#config-polling-max-times').val(config.polling.max_times || 60); } // 切割配置 - 完整填充所有字段 if (config.cut) { $('#config-cut-padding').val(config.cut.padding !== undefined ? config.cut.padding : 3); $('#config-cut-mode').val(config.cut.mode || 'none'); $('#config-cut-grid').val(config.cut.grid || 9); $('#config-cut-line-width').val(config.cut.line_width !== undefined ? config.cut.line_width : 0); $('#config-cut-line-color').val(config.cut.line_color || '#ffffff'); $('#config-cut-format').val(config.cut.format || 'png'); $('#config-cut-quality').val(config.cut.quality !== undefined ? config.cut.quality : 90); } // LLM 配置 if (config.llm) { $('#config-llm-url').val(config.llm.url || ''); $('#config-llm-model').val(config.llm.model || ''); $('#config-llm-name').val(config.llm.name || ''); } // 模型配置 if (config.models && config.models.length > 0) { const html = config.models.map((m, i) => ` <div class="model-card" data-model-index="i"> <button class="model-card-remove" onclick="removeModel(i)">✕</button> <div class="model-card-header"> <div class="model-card-logo"> <input type="text" value="m.logo || '🍌'" data-field="m-logo" data-index="i" style="width:30px;text-align:center;border:none;background:transparent;font-size:20px;"> </div> <div class="model-card-title"> <input type="text" value="m.name" data-field="m-name" data-index="i" placeholder="模型名称"> </div> </div> <div class="model-card-body"> <div class="form-group"> <label>模型 ID</label> <input type="text" value="m.model" data-field="m-model" data-index="i" placeholder="nano-banana-pro"> </div> <div class="form-group"> <label>显示名称</label> <input type="text" value="m.displayName || m.name" data-field="m-displayname" data-index="i" placeholder="显示名称"> </div> <div class="form-group"> <label>描述</label> <input type="text" value="m.description || ''" data-field="m-desc" data-index="i" placeholder="模型描述"> </div> <div class="form-group"> <label>支持比例</label> <input type="text" value="(m.size || []).join(', ')" data-field="m-size" data-index="i" placeholder="1:1, 16:9, 9:16"> </div> <div class="form-group"> <label>支持质量</label> <input type="text" value="(m.quality || []).join(', ')" data-field="m-quality" data-index="i" placeholder="1K, 2K, 4K"> </div> <div class="form-group"> <label>支持切割数</label> <input type="text" value="(m.cut || [1, 2, 4, 6, 9]).join(', ')" data-field="m-cut" data-index="i" placeholder="1, 2, 4, 6, 9"> <div class="form-hint">支持的切割宫格数,如 1, 2, 4, 6, 9</div> </div> <div class="form-group"> <label>size 参数名</label> <input type="text" value="m.request?.size || 'aspect_ratio'" data-field="m-request-size" data-index="i" placeholder="aspect_ratio"> </div> <div class="form-group"> <label>quality 参数名</label> <input type="text" value="m.request?.quality || 'resolution'" data-field="m-request-quality" data-index="i" placeholder="resolution"> </div> <div class="form-group"> <label>最大生成数量</label> <input type="number" value="m.max || 1" data-field="m-max" data-index="i" placeholder="1" min="1" max="10"> </div> </div> </div> `).join(''); $('#models-list').html(html); } // 语言 if (config.languages) { const html = config.languages.map((l, i) => ` <div class="language-item"> <input type="text" value="l.code" data-field="lang-code" data-index="i" placeholder="代码"> <input type="text" value="l.name" data-field="lang-name" data-index="i" placeholder="名称"> <input type="text" value="l.short" data-field="lang-short" data-index="i" placeholder="简称"> </div> `).join(''); $('#languages-list').html(html); } // 默认保存路径 $('#config-default-save-path').val(config.default_save_path || ''); } // 填充 JSON 编辑器 function populateJsonEditor(config) { $('#json-editor').val(JSON.stringify(config, null, 2)); } // 添加模型 function addModel() { const index = $('#models-list .model-card').length; const html = ` <div class="model-card" data-model-index="index"> <button class="model-card-remove" onclick="removeModel(index)">✕</button> <div class="model-card-header"> <div class="model-card-logo"> <input type="text" value="🍌" data-field="m-logo" data-index="index" style="width:30px;text-align:center;border:none;background:transparent;font-size:20px;"> </div> <div class="model-card-title"> <input type="text" data-field="m-name" data-index="index" placeholder="模型名称"> </div> </div> <div class="model-card-body"> <div class="form-group"> <label>模型 ID</label> <input type="text" data-field="m-model" data-index="index" placeholder="nano-banana-pro"> </div> <div class="form-group"> <label>显示名称</label> <input type="text" data-field="m-displayname" data-index="index" placeholder="显示名称"> </div> <div class="form-group"> <label>描述</label> <input type="text" data-field="m-desc" data-index="index" placeholder="模型描述"> </div> <div class="form-group"> <label>支持比例</label> <input type="text" data-field="m-size" data-index="index" placeholder="1:1, 16:9, 9:16"> </div> <div class="form-group"> <label>支持质量</label> <input type="text" data-field="m-quality" data-index="index" placeholder="1K, 2K, 4K"> </div> <div class="form-group"> <label>支持切割数</label> <input type="text" value="1, 2, 4, 6, 9" data-field="m-cut" data-index="index" placeholder="1, 2, 4, 6, 9"> <div class="form-hint">支持的切割宫格数,如 1, 2, 4, 6, 9</div> </div> <div class="form-group"> <label>size 参数名</label> <input type="text" value="aspect_ratio" data-field="m-request-size" data-index="index" placeholder="aspect_ratio"> </div> <div class="form-group"> <label>quality 参数名</label> <input type="text" value="resolution" data-field="m-request-quality" data-index="index" placeholder="resolution"> </div> <div class="form-group"> <label>最大生成数量</label> <input type="number" value="1" data-field="m-max" data-index="index" placeholder="1" min="1" max="10"> </div> </div> </div> `; $('#models-list').append(html); } // 删除模型 function removeModel(index) { $(`[data-model-index="index"]`).remove(); } // 添加语言 function addLanguage() { const html = ` <div class="language-item"> <input type="text" data-field="lang-code" placeholder="代码"> <input type="text" data-field="lang-name" placeholder="名称"> <input type="text" data-field="lang-short" placeholder="简称"> </div> `; $('#languages-list').append(html); } // 收集表单数据 - 深度合并,保留不在表单中的字段 function collectFormData() { // 深拷贝原始配置,保留所有不在表单中显示的字段 const config = deepClone(originalConfig || {}); // 服务器配置 - 只更新表单中有的字段 config.server = config.server || {}; config.server.url = $('#config-server-url').val(); config.server.task_url = $('#config-server-task-url').val(); config.server.upload_url = $('#config-server-upload-url').val(); config.server.port = parseInt($('#config-server-port').val()) || 2688; // LLM 配置 config.llm = config.llm || {}; config.llm.url = $('#config-llm-url').val(); config.llm.model = $('#config-llm-model').val(); config.llm.name = $('#config-llm-name').val(); // 模型配置 config.models = []; $('#models-list .model-card').each(function() { const $card = $(this); const model = { name: $card.find('[data-field="m-name"]').val(), displayName: $card.find('[data-field="m-displayname"]').val(), model: $card.find('[data-field="m-model"]').val(), logo: $card.find('[data-field="m-logo"]').val() || '🍌', description: $card.find('[data-field="m-desc"]').val(), size: ($card.find('[data-field="m-size"]').val() || '').split(',').map(s => s.trim()).filter(s => s), quality: ($card.find('[data-field="m-quality"]').val() || '').split(',').map(s => s.trim()).filter(s => s), cut: ($card.find('[data-field="m-cut"]').val() || '1, 2, 4, 6, 9').split(',').map(s => parseInt(s.trim())).filter(s => s && !isNaN(s)), max: parseInt($card.find('[data-field="m-max"]').val()) || 1, request: { size: $card.find('[data-field="m-request-size"]').val() || 'aspect_ratio', quality: $card.find('[data-field="m-request-quality"]').val() || 'resolution' } }; if (model.name && model.model) { config.models.push(model); } }); // 轮询配置 config.polling = config.polling || {}; config.polling.interval = parseInt($('#config-polling-interval').val()) || 20000; config.polling.max_times = parseInt($('#config-polling-max-times').val()) || 60; // 切割配置 - 完整收集所有字段,同时保留可能存在的其他字段 config.cut = config.cut || {}; config.cut.padding = parseInt($('#config-cut-padding').val()) || 3; config.cut.mode = $('#config-cut-mode').val() || 'none'; config.cut.grid = parseInt($('#config-cut-grid').val()) || 9; config.cut.line_width = parseInt($('#config-cut-line-width').val()) || 0; config.cut.line_color = $('#config-cut-line-color').val() || '#ffffff'; config.cut.format = $('#config-cut-format').val() || 'png'; config.cut.quality = parseInt($('#config-cut-quality').val()) || 90; // 注意:config.cut 中其他未在表单中的字段会保留(因为使用了深拷贝) // 默认保存路径 config.default_save_path = $('#config-default-save-path').val() || ''; // 语言 config.languages = []; $('#languages-list .language-item').each(function() { const code = $(this).find('[data-field="lang-code"]').val(); const name = $(this).find('[data-field="lang-name"]').val(); const short = $(this).find('[data-field="lang-short"]').val(); if (code && name) { config.languages.push({ code, name, short: short || name.substring(0, 2) }); } }); return config; } // 深拷贝函数 - 确保完全复制对象,避免引用问题 function deepClone(obj) { if (obj === null || typeof obj !== 'object') { return obj; } if (Array.isArray(obj)) { return obj.map(item => deepClone(item)); } const cloned = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { cloned[key] = deepClone(obj[key]); } } return cloned; } // 保存全部设置 function saveAllSettings() { const config = collectFormData(); // 验证配置 const errors = validateConfig(config); if (errors.length > 0) { showValidationErrors(errors); return; } // 保存到 JSON 文件 $.ajax({ url: '/api/config/save-json', type: 'POST', contentType: 'application/json', data: JSON.stringify(config), success: function(res) { if (res.success) { alert('配置保存成功!'); originalConfig = config; populateJsonEditor(config); } else { alert('保存失败: ' + res.error); } }, error: function(xhr) { alert('保存失败: ' + (xhr.responseJSON?.error || xhr.statusText)); } }); } // 保存 API 密钥设置 function saveApiSettings() { const apiKey = $('#config-api-key').val(); const platformToken = $('#config-platform-token').val(); const modelApiKey = $('#config-model-api-key').val(); const savePath = $('#config-save-path').val(); // 只有非空值才保存,避免覆盖已有值 if (apiKey) localStorage.setItem('banana_api_key', apiKey); if (platformToken) localStorage.setItem('banana_platform_token', platformToken); if (modelApiKey) localStorage.setItem('banana_model_api_key', modelApiKey); if (savePath) localStorage.setItem('banana_save_path', savePath); const $status = $('#api-status'); $status.removeClass('status-error').addClass('status-success').text('✓ API 密钥设置已保存').show(); setTimeout(() => $status.fadeOut(), 3000); } // 保存 LLM 配置 function saveLlmSettings() { const llmUrl = $('#config-llm-url').val().trim(); const llmModel = $('#config-llm-model').val().trim(); if (!llmUrl || !llmModel) { $('#llm-status').removeClass('status-success').addClass('status-error').text('✗ 请填写完整的 LLM 配置').show(); setTimeout(() => $('#llm-status').fadeOut(), 3000); return; } // 保存到 set.json const config = JSON.parse(JSON.stringify(window.currentConfig || {})); config.llm = { url: llmUrl, model: llmModel }; $.ajax({ url: '/api/config/json', type: 'POST', contentType: 'application/json', data: JSON.stringify(config), success: function(res) { if (res.success) { window.currentConfig = config; $('#llm-status').removeClass('status-error').addClass('status-success').text('✓ LLM 配置已保存').show(); } else { $('#llm-status').removeClass('status-success').addClass('status-error').text('✗ 保存失败: ' + res.error).show(); } setTimeout(() => $('#llm-status').fadeOut(), 3000); }, error: function() { $('#llm-status').removeClass('status-success').addClass('status-error').text('✗ 保存失败').show(); setTimeout(() => $('#llm-status').fadeOut(), 3000); } }); } // 格式化 JSON function formatJson() { try { const json = JSON.parse($('#json-editor').val()); $('#json-editor').val(JSON.stringify(json, null, 2)); } catch (e) { alert('JSON 格式错误: ' + e.message); } } // 重置 JSON function loadJson() { if (originalConfig) { populateJsonEditor(originalConfig); } } // 验证 JSON 配置的完整性和合法性 function validateConfig(config) { const errors = []; // 必须有 server 配置 if (!config.server || typeof config.server !== 'object') { errors.push('缺少 server 配置对象'); } else { if (!config.server.url || typeof config.server.url !== 'string') { errors.push('server.url 必须是非空字符串'); } if (!config.server.task_url || typeof config.server.task_url !== 'string') { errors.push('server.task_url 必须是非空字符串'); } if (typeof config.server.port !== 'number' || config.server.port < 1 || config.server.port > 65535) { errors.push('server.port 必须是 1-65535 之间的数字'); } } // 必须有 models 配置 if (!Array.isArray(config.models) || config.models.length === 0) { errors.push('models 必须是非空数组'); } else { config.models.forEach((m, i) => { if (!m.name || typeof m.name !== 'string') { errors.push(`models[i].name 必须是非空字符串`); } if (!m.model || typeof m.model !== 'string') { errors.push(`models[i].model 必须是非空字符串`); } if (!Array.isArray(m.size) || m.size.length === 0) { errors.push(`models[i].size 必须是非空数组`); } if (!Array.isArray(m.quality) || m.quality.length === 0) { errors.push(`models[i].quality 必须是非空数组`); } if (typeof m.max !== 'number' || m.max < 1) { errors.push(`models[i].max 必须是大于 0 的数字`); } }); } // 必须有 llm 配置 if (!config.llm || typeof config.llm !== 'object') { errors.push('缺少 llm 配置对象'); } else { if (!config.llm.url || typeof config.llm.url !== 'string') { errors.push('llm.url 必须是非空字符串'); } if (!config.llm.model || typeof config.llm.model !== 'string') { errors.push('llm.model 必须是非空字符串'); } } // 必须有 languages 配置 if (!Array.isArray(config.languages) || config.languages.length === 0) { errors.push('languages 必须是非空数组'); } else { config.languages.forEach((l, i) => { if (!l.code || typeof l.code !== 'string') { errors.push(`languages[i].code 必须是非空字符串`); } if (!l.name || typeof l.name !== 'string') { errors.push(`languages[i].name 必须是非空字符串`); } }); } // 必须有 polling 配置 if (!config.polling || typeof config.polling !== 'object') { errors.push('缺少 polling 配置对象'); } else { if (typeof config.polling.interval !== 'number' || config.polling.interval < 1000) { errors.push('polling.interval 必须是大于 1000 的数字(毫秒)'); } if (typeof config.polling.max_times !== 'number' || config.polling.max_times < 1) { errors.push('polling.max_times 必须是大于 0 的数字'); } } // 必须有 cut 配置 if (!config.cut || typeof config.cut !== 'object') { errors.push('缺少 cut 配置对象'); } else { if (typeof config.cut.padding !== 'number' || config.cut.padding < 0) { errors.push('cut.padding 必须是大于等于 0 的数字'); } // cut.mode 验证 if (config.cut.mode && !['none', 'grid', 'smart'].includes(config.cut.mode)) { errors.push('cut.mode 必须是 none, grid 或 smart'); } // cut.grid 验证 if (config.cut.grid !== undefined && (typeof config.cut.grid !== 'number' || config.cut.grid < 2 || config.cut.grid > 16)) { errors.push('cut.grid 必须是 2-16 之间的数字'); } // cut.line_width 验证 if (config.cut.line_width !== undefined && (typeof config.cut.line_width !== 'number' || config.cut.line_width < 0)) { errors.push('cut.line_width 必须是大于等于 0 的数字'); } // cut.quality 验证 if (config.cut.quality !== undefined && (typeof config.cut.quality !== 'number' || config.cut.quality < 1 || config.cut.quality > 100)) { errors.push('cut.quality 必须是 1-100 之间的数字'); } } // resolutions 和 qualities 是可选的,如果存在则验证格式 if (config.resolutions !== undefined) { if (!Array.isArray(config.resolutions)) { errors.push('resolutions 必须是数组'); } else { config.resolutions.forEach((r, i) => { if (!r.name || typeof r.name !== 'string') { errors.push(`resolutions[i].name 必须是非空字符串`); } if (!r.ratio || typeof r.ratio !== 'string') { errors.push(`resolutions[i].ratio 必须是非空字符串`); } }); } } if (config.qualities !== undefined) { if (!Array.isArray(config.qualities)) { errors.push('qualities 必须是数组'); } else { config.qualities.forEach((q, i) => { if (!q.name || typeof q.name !== 'string') { errors.push(`qualities[i].name 必须是非空字符串`); } if (!q.size || typeof q.size !== 'string') { errors.push(`qualities[i].size 必须是非空字符串`); } }); } } return errors; } // 显示错误弹窗 function showValidationErrors(errors) { const errorHtml = errors.map(e => `<li>e</li>`).join(''); const html = ` <div style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;"> <div style="background:#fff;border-radius:12px;padding:24px;max-width:500px;max-height:80vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,0.3);"> <h3 style="color:#dc2626;margin:0 0 16px 0;font-size:18px;">❌ JSON 配置验证失败</h3> <ul style="margin:0;padding-left:20px;color:#374151;font-size:14px;line-height:1.8;">errorHtml</ul> <button onclick="this.parentElement.parentElement.remove()" style="margin-top:20px;padding:10px 24px;background:#3b82f6;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:14px;">关闭</button> </div> </div> `; $('body').append(html); } // 保存 JSON function saveJson() { const $status = $('#json-status'); // 1. 先验证 JSON 语法 let config; try { config = JSON.parse($('#json-editor').val()); } catch (e) { showValidationErrors(['JSON 语法错误: ' + e.message]); return; } // 2. 验证配置结构和必填字段 const errors = validateConfig(config); if (errors.length > 0) { showValidationErrors(errors); return; } // 3. 验证通过,保存配置 $.ajax({ url: '/api/config/save-json', type: 'POST', contentType: 'application/json', data: JSON.stringify(config), success: function(res) { if (res.success) { $status.removeClass('status-error').addClass('status-success').text('✓ JSON 配置已保存').show(); originalConfig = config; populateForm(config); } else { $status.removeClass('status-success').addClass('status-error').text('✗ 保存失败: ' + res.error).show(); } setTimeout(() => $status.fadeOut(), 3000); }, error: function(xhr) { $status.removeClass('status-success').addClass('status-error').text('✗ 保存失败: ' + (xhr.responseJSON?.error || xhr.statusText)).show(); } }); } // 初始化 loadConfig(); loadPrompts(); // 监听表单变更,同步到 JSON 编辑器 $(document).on('input change', '#tab-config input, #tab-config select, #tab-config textarea', function() { syncFormToJsonEditor(); }); // 同步表单数据到 JSON 编辑器 function syncFormToJsonEditor() { const config = collectFormData(); $('#json-editor').val(JSON.stringify(config, null, 2)); } // 监听 JSON 编辑器变更,同步到表单(防抖) let jsonEditorTimer = null; $('#json-editor').on('input', function() { clearTimeout(jsonEditorTimer); jsonEditorTimer = setTimeout(function() { try { const config = JSON.parse($('#json-editor').val()); populateForm(config); } catch (e) { // JSON 格式错误,不同步 } }, 500); }); // 加载提示词模板 function loadPrompts() { $.get('/api/prompts/list', function(res) { if (res.success && res.files) { renderPrompts(res.files); } }); } // 渲染提示词列表 function renderPrompts(files) { if (files.length === 0) { $('#prompts-list').html('<p style="text-align:center;color:#9ca3af;padding:40px;">暂无模板文件</p>'); return; } const html = files.map(file => ` <div class="prompt-card" data-file="file.name"> <div class="prompt-card-header"> <input type="text" value="file.name" class="prompt-filename" data-original="file.name"> </div> <div class="prompt-card-body"> <textarea class="prompt-content" data-file="file.name">加载中...</textarea> </div> <div class="prompt-card-footer"> <button class="btn btn-primary" onclick="savePrompt('file.name')">💾 保存</button> <button class="btn btn-danger" onclick="deletePrompt('file.name')">🗑️ 删除</button> </div> </div> `).join(''); $('#prompts-list').html(html); // 加载每个文件的内容 files.forEach(file => { $.get(`/api/prompts/get?file=encodeURIComponent(file.name)`, function(res) { if (res.success) { $(`.prompt-content[data-file="file.name"]`).val(res.content); } }); }); } // 创建新模板 function createNewPrompt() { const name = prompt('请输入模板文件名(如:cut_5.prompt):'); if (!name) return; // 检查文件名格式 if (!name.endsWith('.prompt')) { alert('文件名必须以 .prompt 结尾'); return; } // 添加新卡片 const html = ` <div class="prompt-card" data-file="name"> <div class="prompt-card-header"> <input type="text" value="name" class="prompt-filename" data-original="name"> </div> <div class="prompt-card-body"> <textarea class="prompt-content" data-file="name" placeholder="输入提示词模板内容..."></textarea> </div> <div class="prompt-card-footer"> <button class="btn btn-primary" onclick="savePrompt('name')">💾 保存</button> <button class="btn btn-danger" onclick="deletePrompt('name')">🗑️ 删除</button> </div> </div> `; $('#prompts-list').prepend(html); } // 保存提示词 function savePrompt(filename) { const $card = $(`.prompt-card[data-file="filename"]`); const newName = $card.find('.prompt-filename').val(); const content = $card.find('.prompt-content').val(); $.ajax({ url: '/api/prompts/save', type: 'POST', contentType: 'application/json', data: JSON.stringify({ oldName: filename, newName: newName, content: content }), success: function(res) { if (res.success) { alert('保存成功'); // 更新 data-file 属性 $card.attr('data-file', newName); $card.find('.prompt-content').attr('data-file', newName); $card.find('.prompt-filename').attr('data-original', newName); // 更新按钮的 onclick $card.find('.prompt-card-footer .btn-primary').attr('onclick', `savePrompt('newName')`); } else { alert('保存失败: ' + res.error); } }, error: function(xhr) { alert('保存失败: ' + (xhr.responseJSON?.error || xhr.statusText)); } }); } // 删除提示词 function deletePrompt(filename) { if (!confirm(`确定要删除 filename 吗?`)) return; $.ajax({ url: '/api/prompts/delete', type: 'POST', contentType: 'application/json', data: JSON.stringify({ filename: filename }), success: function(res) { if (res.success) { $(`.prompt-card[data-file="filename"]`).remove(); } else { alert('删除失败: ' + res.error); } }, error: function(xhr) { alert('删除失败: ' + (xhr.responseJSON?.error || xhr.statusText)); } }); } </script> </body> </html> FILE:public/lan/en.json { "app": { "title": "Yunxi AI Image Generator & Splitter", "version": "1.1.0", "description": "One-click image generation and splitting, perfect style consistency, video creation assistant." }, "nav": { "editor": "Editor", "list": "List", "language": "Language", "theme": "Toggle Theme", "settings": "Settings", "close": "Close Service", "searchPlaceholder": "Search prompts..." }, "editor": { "placeholder": "Enter prompt description...", "upload": "Upload Image", "submit": "Submit", "aiPrompt": "AI Generate Prompt" }, "params": { "model": "Model", "size": "Size", "quality": "Quality", "cut": "Split", "noCut": "No Split", "grid": "Grid" }, "sizes": { "1:1": "1:1 Square", "16:9": "16:9 Landscape", "9:16": "9:16 Portrait", "4:3": "4:3 Standard", "3:4": "3:4 Portrait", "5:4": "5:4 Photo" }, "qualities": { "1K": "1K (1024px)", "2K": "2K (2048px)", "4K": "4K (4096px)" }, "about": { "title": "Yunxi AI Image Generator & Splitter", "description": "One-click image generation and splitting, perfect style consistency, video creation assistant.", "features": "Features", "featuresText": "AI image generation, splitting, save to local directory.", "version": "Version", "author": "Author", "authorName": "Xiao Zhu", "email": "Email", "wechat": "WeChat" }, "settings": { "title": "Settings", "configNote": "Configuration Notes", "configNote1": "API Key and Platform Token are provided by Ace Data platform.", "configNote2": "API Key is used for image generation, Platform Token is used for image upload.", "configNote3": "All keys are stored in browser localStorage.", "apiKey": "API Key", "apiKeyRequired": "Required", "apiKeyPlaceholder": "Enter API Key", "getApiKey": "Get API Key →", "platformToken": "Platform Token", "platformTokenOptional": "Optional", "platformTokenPlaceholder": "Enter Platform Token", "platformTokenHint": "Only for image-to-image/editing features", "modelApiKey": "LLM API Key", "modelApiKeyPlaceholder": "For AI prompt generation", "modelApiKeyHint": "Used for AI prompt generation feature", "setModel": "Set Model", "savePath": "Image Save Path", "savePathPlaceholder": "Default: Desktop/banana2", "savePathHint": "Images will be saved to this directory. Leave empty for default path.", "cancel": "Cancel", "save": "Save Settings", "saved": "Settings saved" }, "aiPrompt": { "title": "AI Generate Prompt", "noKeyWarning": "⚠️ LLM API not configured. Please configure your LLM in settings first.", "goSettings": "Go to Settings", "label": "Describe the image you want", "placeholder": "You are a screenwriter. Based on the story of Wu Song fighting the tiger from Water Margin, generate 9 images for video creation in strict order of the story, each clip about 6-10 seconds. Output in order of Image 1...Image 9, along with the style prompt for image generation.", "hint": "Feed your detailed requirements to AI to help generate detailed image content.", "cancel": "Cancel", "generate": "✨ Generate Prompt", "generating": "Generating...", "resultLabel": "Generated Prompt", "regenerate": "Regenerate", "usePrompt": "Use This Prompt", "inputRequired": "Please enter description", "noApiKey": "Please configure LLM API Key first", "generateFailed": "Generation failed", "unknownError": "Unknown error", "networkError": "Network error" }, "error": { "title": "Error Message", "close": "Close", "unknownError": "An unknown error occurred" }, "taskDetail": { "loading": "Loading...", "invalidId": "Invalid task ID", "notFound": "Task not found", "generating": "⏳ Generating", "failed": "❌ Generation failed", "completed": "✅ Completed", "unknownStatus": "Unknown status", "prompt": "Prompt", "none": "None", "errorMsg": "Error Message", "model": "Model", "ratio": "Ratio", "resolution": "Resolution", "cut": "Split", "noCut": "No Split", "grid": "Grid", "saveLocation": "Save Location", "copy": "Copy", "copied": "Copied", "originalLink": "Original Image Link", "taskId": "Task ID", "fetchImage": "📥 Fetch Image", "fetching": "Fetching...", "fetchSuccess": "Success! Image saved", "stillProcessing": "Task still processing", "generateFailed": "Generation failed", "fetchFailed": "Fetch failed", "networkError": "Network error", "delete": "🗑️ Delete", "deleting": "Deleting...", "confirmDelete": "Are you sure you want to delete this task?", "deleteFailed": "Delete failed", "loadFailed": "Load failed" }, "viewer": { "info": "Info", "edit": "Edit", "regenerate": "Regenerate", "openFolder": "Open Folder", "delete": "Delete", "mainImage": "Main.png" }, "messages": { "noWorks": "You haven't created any works yet", "startCreate": "Start creating with Nano Banana2 Pro!", "generating": "Generating...", "generateFailed": "Generation failed", "unknownError": "Unknown error", "loadingFailed": "Loading failed", "confirmClose": "Are you sure you want to close the service?", "confirmDelete": "Are you sure you want to delete this work?", "configSaved": "Configuration saved!", "apiKeyRequired": "API Key cannot be empty!", "promptRequired": "Please enter prompt description!", "maxImages": "Maximum 6 images can be uploaded!", "maxImagesSelect": "Maximum 6 images can be uploaded! Only the first {n} will be selected.", "uploadFailed": "Image upload failed", "generatePromptFailed": "Generation failed, please retry", "inputRequired": "Please enter description!", "configApiKey": "Please configure API Key first", "noResults": "No matching results found" }, "language": { "zh": "中文", "en": "English", "zh-TW": "繁體中文", "ja": "日本語", "ko": "한국어" } } FILE:public/lan/ja.json { "app": { "title": "雲羲AI画像生成・分割ツール", "version": "1.1.0", "description": "ワンクリックで画像生成・分割、完璧なスタイルの一貫性、動画制作アシスタント。" }, "nav": { "editor": "エディタ", "list": "リスト", "language": "言語", "theme": "テーマ切替", "settings": "設定", "close": "サービス終了", "searchPlaceholder": "プロンプトを検索..." }, "editor": { "placeholder": "プロンプトを入力...", "upload": "画像をアップロード", "submit": "送信", "aiPrompt": "AIプロンプト生成" }, "params": { "model": "モデル", "size": "サイズ", "quality": "品質", "cut": "分割", "noCut": "分割なし", "grid": "グリッド" }, "sizes": { "1:1": "1:1 正方形", "16:9": "16:9 横向き", "9:16": "9:16 縦向き", "4:3": "4:3 標準", "3:4": "3:4 縦向き", "5:4": "5:4 写真" }, "qualities": { "1K": "1K (1024px)", "2K": "2K (2048px)", "4K": "4K (4096px)" }, "about": { "title": "雲羲AI画像生成・分割ツール", "description": "ワンクリックで画像生成・分割、完璧なスタイルの一貫性、動画制作アシスタント。", "features": "機能", "featuresText": "AI画像生成、分割、ローカルディレクトリに保存。", "version": "バージョン", "author": "作者", "authorName": "小潴", "email": "Email", "wechat": "WeChat" }, "settings": { "title": "設定", "configNote": "設定説明", "configNote1": "API KeyとPlatform TokenはAce Dataプラットフォームから提供されます。", "configNote2": "API Keyは画像生成に、Platform Tokenは画像アップロードに使用されます。", "configNote3": "すべてのキーはブラウザのlocalStorageに保存されます。", "apiKey": "API Key", "apiKeyRequired": "必須", "apiKeyPlaceholder": "API Keyを入力", "getApiKey": "API Keyを取得 →", "platformToken": "Platform Token", "platformTokenOptional": "オプション", "platformTokenPlaceholder": "Platform Tokenを入力", "platformTokenHint": "画像編集機能のみに使用", "modelApiKey": "LLM API Key", "modelApiKeyPlaceholder": "AIプロンプト生成用", "modelApiKeyHint": "AIプロンプト生成機能に使用", "setModel": "モデル設定", "savePath": "画像保存パス", "savePathPlaceholder": "デフォルト: デスクトップ/banana2", "savePathHint": "画像はこのディレクトリに保存されます。空の場合はデフォルトパスを使用。", "cancel": "キャンセル", "save": "設定を保存", "saved": "設定を保存しました" }, "aiPrompt": { "title": "AI プロンプト生成", "noKeyWarning": "⚠️ LLM APIが設定されていません。先に設定してください。", "goSettings": "設定へ", "label": "作成したい画像を説明してください", "placeholder": "あなたは脚本家です。水滸伝の武松打虎の物語に基づいて、動画制作用の9枚の画像を物語の順序通りに生成してください。各クリップは約6-10秒です。図1...図9の順に出力し、画像生成のスタイルプロンプトも出力してください。", "hint": "詳細な要件をAIに伝えて、詳細な画像生成内容を生成してください。", "cancel": "キャンセル", "generate": "✨ プロンプト生成", "generating": "生成中...", "resultLabel": "生成されたプロンプト", "regenerate": "再生成", "usePrompt": "このプロンプトを使用", "inputRequired": "説明を入力してください", "noApiKey": "先にLLM API Keyを設定してください", "generateFailed": "生成に失敗しました", "unknownError": "不明なエラー", "networkError": "ネットワークエラー" }, "error": { "title": "エラーメッセージ", "close": "閉じる", "unknownError": "不明なエラーが発生しました" }, "taskDetail": { "loading": "読み込み中...", "invalidId": "タスクIDが無効です", "notFound": "タスクが見つかりません", "generating": "⏳ 生成中", "failed": "❌ 生成失敗", "completed": "✅ 完了", "unknownStatus": "不明なステータス", "prompt": "プロンプト", "none": "なし", "errorMsg": "エラーメッセージ", "model": "モデル", "ratio": "比率", "resolution": "解像度", "cut": "分割", "noCut": "分割なし", "grid": "グリッド", "saveLocation": "保存場所", "copy": "コピー", "copied": "コピーしました", "originalLink": "元画像リンク", "taskId": "タスクID", "fetchImage": "📥 画像を取得", "fetching": "取得中...", "fetchSuccess": "成功!画像を保存しました", "stillProcessing": "タスクはまだ処理中です", "generateFailed": "生成に失敗しました", "fetchFailed": "取得に失敗しました", "networkError": "ネットワークエラー", "delete": "🗑️ 削除", "deleting": "削除中...", "confirmDelete": "このタスクを削除しますか?", "deleteFailed": "削除に失敗しました", "loadFailed": "読み込みに失敗しました" }, "viewer": { "info": "情報", "edit": "編集", "regenerate": "再生成", "openFolder": "フォルダを開く", "delete": "削除", "mainImage": "メイン.png" }, "messages": { "noWorks": "まだ作品がありません", "startCreate": "Nano Banana2 Proで創作を始めましょう!", "generating": "生成中...", "generateFailed": "生成に失敗しました", "unknownError": "不明なエラー", "loadingFailed": "読み込みに失敗しました", "confirmClose": "サービスを終了しますか?", "confirmDelete": "この作品を削除しますか?", "configSaved": "設定を保存しました!", "apiKeyRequired": "API Keyは空にできません!", "promptRequired": "プロンプトを入力してください!", "maxImages": "最大6枚までアップロードできます!", "maxImagesSelect": "最大6枚までアップロードできます!最初の{n}枚のみ選択されます。", "uploadFailed": "画像のアップロードに失敗しました", "generatePromptFailed": "生成に失敗しました、再試行してください", "inputRequired": "説明を入力してください!", "configApiKey": "先にAPI Keyを設定してください", "noResults": "一致する結果が見つかりません" }, "language": { "zh": "简体中文", "en": "English", "zh-TW": "繁體中文", "ja": "日本語", "ko": "한국어" } } FILE:public/lan/ko.json { "app": { "title": "윤시 AI 이미지 생성 및 분할 도구", "version": "1.1.0", "description": "원클릭 이미지 생성 및 분할, 완벽한 스타일 일관성, 영상 제작 도우미." }, "nav": { "editor": "편집기", "list": "목록", "language": "언어", "theme": "테마 전환", "settings": "설정", "close": "서비스 종료", "searchPlaceholder": "프롬프트 검색..." }, "editor": { "placeholder": "프롬프트 설명 입력...", "upload": "이미지 업로드", "submit": "제출", "aiPrompt": "AI 프롬프트 생성" }, "params": { "model": "모델", "size": "크기", "quality": "품질", "cut": "분할", "noCut": "분할 안함", "grid": "그리드" }, "sizes": { "1:1": "1:1 정사각형", "16:9": "16:9 가로", "9:16": "9:16 세로", "4:3": "4:3 표준", "3:4": "3:4 세로", "5:4": "5:4 사진" }, "qualities": { "1K": "1K (1024px)", "2K": "2K (2048px)", "4K": "4K (4096px)" }, "about": { "title": "윤시 AI 이미지 생성 및 분할 도구", "description": "원클릭 이미지 생성 및 분할, 완벽한 스타일 일관성, 영상 제작 도우미.", "features": "기능", "featuresText": "AI 이미지 생성, 분할, 로컬 디렉토리에 저장.", "version": "버전", "author": "작성자", "authorName": "샤오주", "email": "이메일", "wechat": "위챗" }, "settings": { "title": "설정", "configNote": "설정 설명", "configNote1": "API Key와 Platform Token은 Ace Data 플랫폼에서 제공됩니다.", "configNote2": "API Key는 이미지 생성에, Platform Token은 이미지 업로드에 사용됩니다.", "configNote3": "모든 키는 브라우저 localStorage에 저장됩니다.", "apiKey": "API Key", "apiKeyRequired": "필수", "apiKeyPlaceholder": "API Key 입력", "getApiKey": "API Key 받기 →", "platformToken": "Platform Token", "platformTokenOptional": "선택", "platformTokenPlaceholder": "Platform Token 입력", "platformTokenHint": "이미지 편집 기능에만 사용", "modelApiKey": "LLM API Key", "modelApiKeyPlaceholder": "AI 프롬프트 생성용", "modelApiKeyHint": "AI 프롬프트 생성 기능에 사용", "setModel": "모델 설정", "savePath": "이미지 저장 경로", "savePathPlaceholder": "기본값: 바탕화면/banana2", "savePathHint": "이미지가 이 디렉토리에 저장됩니다. 비워두면 기본 경로를 사용합니다.", "cancel": "취소", "save": "설정 저장", "saved": "설정이 저장되었습니다" }, "aiPrompt": { "title": "AI 프롬프트 생성", "noKeyWarning": "⚠️ LLM API가 설정되지 않았습니다. 먼저 설정에서 LLM을 구성하세요.", "goSettings": "설정으로 이동", "label": "원하는 이미지를 설명하세요", "placeholder": "당신은 시나리오 작가입니다. 수호지의 무송타호 이야기를 바탕으로, 이야기 순서대로 영상 제작용 9장의 이미지를 생성하세요. 각 클립은 약 6-10초입니다. 그림 1...그림 9 순서로 출력하고, 이미지 생성 스타일 프롬프트도 출력하세요.", "hint": "상세한 요구사항을 AI에게 전달하여 상세한 이미지 생성 콘텐츠를 생성하세요.", "cancel": "취소", "generate": "✨ 프롬프트 생성", "generating": "생성 중...", "resultLabel": "생성된 프롬프트", "regenerate": "재생성", "usePrompt": "이 프롬프트 사용", "inputRequired": "설명을 입력하세요", "noApiKey": "먼저 LLM API Key를 설정하세요", "generateFailed": "생성 실패", "unknownError": "알 수 없는 오류", "networkError": "네트워크 오류" }, "error": { "title": "오류 메시지", "close": "닫기", "unknownError": "알 수 없는 오류가 발생했습니다" }, "taskDetail": { "loading": "로딩 중...", "invalidId": "잘못된 작업 ID", "notFound": "작업을 찾을 수 없습니다", "generating": "⏳ 생성 중", "failed": "❌ 생성 실패", "completed": "✅ 완료", "unknownStatus": "알 수 없는 상태", "prompt": "프롬프트", "none": "없음", "errorMsg": "오류 메시지", "model": "모델", "ratio": "비율", "resolution": "해상도", "cut": "분할", "noCut": "분할 안함", "grid": "그리드", "saveLocation": "저장 위치", "copy": "복사", "copied": "복사됨", "originalLink": "원본 이미지 링크", "taskId": "작업 ID", "fetchImage": "📥 이미지 가져오기", "fetching": "가져오는 중...", "fetchSuccess": "성공! 이미지가 저장되었습니다", "stillProcessing": "작업이 아직 처리 중입니다", "generateFailed": "생성 실패", "fetchFailed": "가져오기 실패", "networkError": "네트워크 오류", "delete": "🗑️ 삭제", "deleting": "삭제 중...", "confirmDelete": "이 작업을 삭제하시겠습니까?", "deleteFailed": "삭제 실패", "loadFailed": "로딩 실패" }, "viewer": { "info": "정보", "edit": "편집", "regenerate": "재생성", "openFolder": "폴더 열기", "delete": "삭제", "mainImage": "메인.png" }, "messages": { "noWorks": "아직 작품이 없습니다", "startCreate": "Nano Banana2 Pro로 창작을 시작하세요!", "generating": "생성 중...", "generateFailed": "생성 실패", "unknownError": "알 수 없는 오류", "loadingFailed": "로딩 실패", "confirmClose": "서비스를 종료하시겠습니까?", "confirmDelete": "이 작품을 삭제하시겠습니까?", "configSaved": "설정이 저장되었습니다!", "apiKeyRequired": "API Key는 비워둘 수 없습니다!", "promptRequired": "프롬프트를 입력하세요!", "maxImages": "최대 6장까지 업로드할 수 있습니다!", "maxImagesSelect": "최대 6장까지 업로드할 수 있습니다! 처음 {n}장만 선택됩니다.", "uploadFailed": "이미지 업로드 실패", "generatePromptFailed": "생성 실패, 다시 시도하세요", "inputRequired": "설명을 입력하세요!", "configApiKey": "먼저 API Key를 설정하세요", "noResults": "일치하는 결과가 없습니다" }, "language": { "zh": "简体中文", "en": "English", "zh-TW": "繁體中文", "ja": "日本語", "ko": "한국어" } } FILE:public/lan/zh-TW.json { "app": { "title": "雲羲AI繪圖分影工具", "version": "1.1.0", "description": "一鍵生圖分影,完美風格一致性,視頻創作助手。" }, "nav": { "editor": "編輯器", "list": "列表", "language": "語言", "theme": "主題切換", "settings": "設置", "close": "關閉服務", "searchPlaceholder": "搜索提示詞..." }, "editor": { "placeholder": "輸入提示詞描述...", "upload": "上傳圖片", "submit": "提交", "aiPrompt": "AI生成提示詞" }, "params": { "model": "模型", "size": "尺寸", "quality": "質量", "cut": "切割", "noCut": "不切割", "grid": "宮格" }, "sizes": { "1:1": "1:1 方形", "16:9": "16:9 橫屏", "9:16": "9:16 豎屏", "4:3": "4:3 標準", "3:4": "3:4 豎版", "5:4": "5:4 照片" }, "qualities": { "1K": "1K (1024px)", "2K": "2K (2048px)", "4K": "4K (4096px)" }, "about": { "title": "雲羲AI繪圖分影工具", "description": "一鍵生圖分影,完美風格一致性,視頻創作助手。", "features": "功能", "featuresText": "AI生圖,分影,保存本地目錄。", "version": "版本", "author": "作者", "authorName": "小潴", "email": "Email", "wechat": "微信" }, "settings": { "title": "設置", "configNote": "配置說明", "configNote1": "APIKey和Platform Token均由 Ace Data 平台提供。", "configNote2": "APIKey用於生成圖片,Platform Token用於上傳圖片。", "configNote3": "所有密鑰都保存在瀏覽器本地存儲localStorage中。", "apiKey": "API Key", "apiKeyRequired": "必填", "apiKeyPlaceholder": "請輸入 API Key", "getApiKey": "獲取 API Key →", "platformToken": "Platform Token", "platformTokenOptional": "可選", "platformTokenPlaceholder": "請輸入 Platform Token", "platformTokenHint": "僅用於圖生圖/圖片編輯功能", "modelApiKey": "大模型 API Key", "modelApiKeyPlaceholder": "用於AI生成提示詞", "modelApiKeyHint": "用於AI生成提示詞功能", "setModel": "設置模型", "savePath": "圖片保存本地路徑", "savePathPlaceholder": "默認: 桌面/banana2", "savePathHint": "圖片將保存到此目錄。留空則使用默認路徑。", "cancel": "取消", "save": "保存設置", "saved": "設置已保存" }, "aiPrompt": { "title": "AI 生成提示詞", "noKeyWarning": "⚠️ 未配置大模型 API 請先到後端設置您使用的大模型。", "goSettings": "去設置", "label": "描述你想要的圖片", "placeholder": "你是一個影視編劇,根據水滸傳武松打虎的故事,嚴格按照故事發展的順序,生成9張用於視頻創作的首尾幀圖片,每個片段大概6-10秒。按圖1...圖9順序輸出,以及輸出圖片生成的風格prompt。", "hint": "請將你的詳細要求餵給AI,幫你生成詳細的圖片生成內容。", "cancel": "取消", "generate": "✨ 生成提示詞", "generating": "生成中...", "resultLabel": "生成的提示詞", "regenerate": "重新生成", "usePrompt": "使用此提示詞", "inputRequired": "請輸入描述", "noApiKey": "請先配置大模型 API Key", "generateFailed": "生成失敗", "unknownError": "未知錯誤", "networkError": "網絡錯誤" }, "error": { "title": "錯誤信息", "close": "關閉", "unknownError": "發生未知錯誤" }, "taskDetail": { "loading": "加載中...", "invalidId": "任務ID無效", "notFound": "任務不存在", "generating": "⏳ 生成中", "failed": "❌ 生成失敗", "completed": "✅ 已完成", "unknownStatus": "未知狀態", "prompt": "提示詞", "none": "無", "errorMsg": "錯誤信息", "model": "模型", "ratio": "比例", "resolution": "分辨率", "cut": "切割", "noCut": "不切割", "grid": "宮格", "saveLocation": "保存位置", "copy": "複製", "copied": "已複製", "originalLink": "原圖鏈接", "taskId": "任務ID", "fetchImage": "📥 獲取圖片", "fetching": "獲取中...", "fetchSuccess": "獲取成功!圖片已保存", "stillProcessing": "任務還在處理中", "generateFailed": "生成失敗", "fetchFailed": "獲取失敗", "networkError": "網絡錯誤", "delete": "🗑️ 刪除", "deleting": "刪除中...", "confirmDelete": "確定要刪除這個任務嗎?", "deleteFailed": "刪除失敗", "loadFailed": "加載失敗" }, "viewer": { "info": "信息", "edit": "編輯", "regenerate": "重新生成", "openFolder": "打開文件夾", "delete": "刪除", "mainImage": "主圖.png" }, "messages": { "noWorks": "您還沒有創作作品", "startCreate": "使用Nano Banana2 Pro開始創作吧!", "generating": "生成中...", "generateFailed": "生成失敗", "unknownError": "未知錯誤", "loadingFailed": "加載失敗", "confirmClose": "確定要關閉服務嗎?", "confirmDelete": "確定要刪除這個作品嗎?", "configSaved": "配置保存成功!", "apiKeyRequired": "API Key 不能為空!", "promptRequired": "請輸入提示詞描述!", "maxImages": "最多只能上傳6張圖片!", "maxImagesSelect": "最多只能上傳6張圖片!將只選擇前{n}張。", "uploadFailed": "圖片上傳失敗", "generatePromptFailed": "生成失敗,請重試", "inputRequired": "請輸入描述內容!", "configApiKey": "請先配置 API Key", "noResults": "未找到匹配的結果" }, "language": { "zh": "简体中文", "en": "English", "zh-TW": "繁體中文", "ja": "日本語", "ko": "한국어" } } FILE:public/lan/zh.json { "app": { "title": "云羲AI绘图分影工具", "version": "1.1.0", "description": "一键生图分影,完美风格一致性,视频创作助手。" }, "nav": { "editor": "编辑器", "list": "列表", "language": "语言", "theme": "主题切换", "settings": "设置", "close": "关闭服务", "searchPlaceholder": "搜索提示词..." }, "editor": { "placeholder": "输入提示词描述...", "upload": "上传图片", "submit": "提交", "aiPrompt": "AI生成提示词" }, "params": { "model": "模型", "size": "尺寸", "quality": "质量", "cut": "切割", "noCut": "不切割", "grid": "宫格" }, "sizes": { "1:1": "1:1 方形", "16:9": "16:9 横屏", "9:16": "9:16 竖屏", "4:3": "4:3 标准", "3:4": "3:4 竖版", "5:4": "5:4 照片" }, "qualities": { "1K": "1K (1024px)", "2K": "2K (2048px)", "4K": "4K (4096px)" }, "about": { "title": "云羲AI绘图分影工具", "description": "一键生图分影,完美风格一致性,视频创作助手。", "features": "功能", "featuresText": "AI生图,分影,保存本地目录。", "version": "版本", "author": "作者", "authorName": "小潴", "email": "Email", "wechat": "微信" }, "settings": { "title": "设置", "configNote": "配置说明", "configNote1": "APIKey和Platform Token均由 Ace Data 平台提供。", "configNote2": "APIKey用于生成图片,Platform Token用于上传图片。", "configNote3": "所有密钥都保存在浏览器本地存储localStorage中。", "apiKey": "API Key", "apiKeyRequired": "必填", "apiKeyPlaceholder": "请输入 API Key", "getApiKey": "获取 API Key →", "platformToken": "Platform Token", "platformTokenOptional": "可选", "platformTokenPlaceholder": "请输入 Platform Token", "platformTokenHint": "仅用于图生图/图片编辑功能", "modelApiKey": "大模型 API Key", "modelApiKeyPlaceholder": "用于AI生成提示词", "modelApiKeyHint": "用于AI生成提示词功能", "setModel": "设置模型", "savePath": "图片保存本地路径", "savePathPlaceholder": "默认: 桌面/banana2", "savePathHint": "图片将保存到此目录。留空则使用默认路径。", "cancel": "取消", "save": "保存设置", "saved": "设置已保存" }, "aiPrompt": { "title": "AI 生成提示词", "noKeyWarning": "⚠️ 未配置大模型 API 请先到后端设置您使用的大模型。", "goSettings": "去设置", "label": "描述你想要的图片", "placeholder": "你是一个影视编剧,根据水浒传武松打虎的故事,严格按照故事发展的顺序,生成9张用于视频创作的首尾帧图片,每个片段大概6-10秒。按图1...图9顺序输出,以及输出图片生成的风格prompt。", "hint": "请将你的详细要求喂给AI,帮你生成详细的图片生成内容。", "cancel": "取消", "generate": "✨ 生成提示词", "generating": "生成中...", "resultLabel": "生成的提示词", "regenerate": "重新生成", "usePrompt": "使用此提示词", "inputRequired": "请输入描述", "noApiKey": "请先配置大模型 API Key", "generateFailed": "生成失败", "unknownError": "未知错误", "networkError": "网络错误" }, "error": { "title": "错误信息", "close": "关闭", "unknownError": "发生未知错误" }, "taskDetail": { "loading": "加载中...", "invalidId": "任务ID无效", "notFound": "任务不存在", "generating": "⏳ 生成中", "failed": "❌ 生成失败", "completed": "✅ 已完成", "unknownStatus": "未知状态", "prompt": "提示词", "none": "无", "errorMsg": "错误信息", "model": "模型", "ratio": "比例", "resolution": "分辨率", "cut": "切割", "noCut": "不切割", "grid": "宫格", "saveLocation": "保存位置", "copy": "复制", "copied": "已复制", "originalLink": "原图链接", "taskId": "任务ID", "fetchImage": "📥 获取图片", "fetching": "获取中...", "fetchSuccess": "获取成功!图片已保存", "stillProcessing": "任务还在处理中", "generateFailed": "生成失败", "fetchFailed": "获取失败", "networkError": "网络错误", "delete": "🗑️ 删除", "deleting": "删除中...", "confirmDelete": "确定要删除这个任务吗?", "deleteFailed": "删除失败", "loadFailed": "加载失败" }, "viewer": { "info": "信息", "edit": "编辑", "regenerate": "重新生成", "openFolder": "打开文件夹", "delete": "删除", "mainImage": "主图.png" }, "messages": { "noWorks": "您还没有创作作品", "startCreate": "使用Nano Banana2 Pro开始创作吧!", "generating": "生成中...", "generateFailed": "生成失败", "unknownError": "未知错误", "loadingFailed": "加载失败", "confirmClose": "确定要关闭服务吗?", "confirmDelete": "确定要删除这个作品吗?", "configSaved": "配置保存成功!", "apiKeyRequired": "API Key 不能为空!", "promptRequired": "请输入提示词描述!", "maxImages": "最多只能上传6张图片!", "maxImagesSelect": "最多只能上传6张图片!将只选择前{n}张。", "uploadFailed": "图片上传失败", "generatePromptFailed": "生成失败,请重试", "inputRequired": "请输入描述内容!", "configApiKey": "请先配置 API Key", "noResults": "未找到匹配的结果" }, "language": { "zh": "简体中文", "en": "English", "zh-TW": "繁體中文", "ja": "日本語", "ko": "한국어" } } FILE:public/js/app.js // 全局状态 const state = { darkMode: false, showEditor: true, works: [], allWorks: [], // 用于搜索 selectedWork: null, hasApiKey: false, hasModelApiKey: false, config: null, selectedModel: 0, selectedSize: 0, selectedQuality: 1, selectedCut: 1, uploadedImages: [], currentLang: 'zh', i18n: null, searchKeyword: '', // 分页状态 currentPage: 1, totalPages: 1, total: 0 }; // 常量 const MAX_IMAGES = 6; // 语言简称映射 const langShortNames = { 'zh': '中', 'en': 'EN', 'zh-TW': '繁', 'ja': '日' }; // DOM 元素 const elements = { gallery: document.getElementById('gallery'), editor: document.getElementById('editor'), editorInput: document.getElementById('editor-input'), editorUpload: document.getElementById('editor-upload'), uploadBtn: document.getElementById('upload-btn'), fileInput: document.getElementById('file-input'), submitBtn: document.getElementById('submit-btn'), viewer: document.getElementById('viewer'), viewerImage: document.getElementById('viewer-image'), viewerCuts: document.getElementById('viewer-cuts'), langMenu: document.getElementById('lang-menu'), searchInput: document.getElementById('search-input') }; // 初始化 async function init() { // 加载语言 await loadLanguage(); // 加载主题状态 loadThemeState(); // 加载配置 loadConfigFromLocalStorage(); // 发送配置到后端 await sendConfigToBackend(); // 加载作品 await loadWorks(); // 设置事件监听 setupEventListeners(); // 设置滚动加载 setupScrollLoad(); // 加载参数 await loadParameters(); // 更新语言按钮显示 updateLangButton(); // 恢复未提交的提示词 restorePendingPrompt(); // 自动获取待处理任务 await autoFetchPendingTasks(); // 如果有待轮询的任务,启动轮询 if (pollingTasks.size > 0) { startPolling(); } } // 恢复未提交的提示词 function restorePendingPrompt() { const pendingPrompt = localStorage.getItem('banana_pending_prompt'); if (pendingPrompt) { elements.editorInput.textContent = pendingPrompt; } } // 保存提示词到localStorage function savePendingPrompt() { const prompt = elements.editorInput.textContent.trim(); if (prompt) { localStorage.setItem('banana_pending_prompt', prompt); } // 空提示词不保存,也不清除(保留之前的) } // 清除提示词 function clearPendingPrompt() { localStorage.removeItem('banana_pending_prompt'); } // 自动获取待处理任务 async function autoFetchPendingTasks() { const pendingWorks = state.works.filter(w => w.state === 1 || w.state === 99); if (pendingWorks.length === 0) return; // 批量提交获取请求 const fetchPromises = pendingWorks.map(work => fetchTaskStatus(work.id)); // 并行获取所有任务 await Promise.all(fetchPromises); } // 获取单个任务状态 async function fetchTaskStatus(id) { try { const apiKey = localStorage.getItem('banana_api_key') || ''; const response = await fetch(`/api/poll/id`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey }) }); const result = await response.json(); if (result.success && result.status === 'completed') { showToast('success', `任务 id 获取成功!`); // 更新单个任务数据 const workIndex = state.works.findIndex(w => w.id === id); if (workIndex !== -1 && result.data) { state.works[workIndex] = { ...state.works[workIndex], state: 10, path: result.data.path, response_data: result.data.response_data }; // 立即更新该任务的图片展示 renderGallery(); } } else if (result.success && result.status === 'pending') { // 任务还在处理中,加入轮询队列 pollingTasks.add(id); } } catch (error) { console.error(`任务 id 获取失败:`, error); } } // 轮询任务集合 let pollingTasks = new Set(); let pollingInterval = null; // 开始轮询 function startPolling() { if (pollingInterval) return; console.log('🔄 启动前端轮询,间隔 20 秒'); const apiKey = localStorage.getItem('banana_api_key') || ''; pollingInterval = setInterval(async () => { if (pollingTasks.size === 0) { stopPolling(); return; } console.log(`🔄 轮询中,待处理任务: pollingTasks.size 个`); const taskIds = Array.from(pollingTasks); for (const id of taskIds) { try { const response = await fetch(`/api/poll/id`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey }) }); const result = await response.json(); if (result.success && result.status === 'completed') { pollingTasks.delete(id); showToast('success', `任务 id 获取成功!`); // 更新数据 const workIndex = state.works.findIndex(w => w.id === id); if (workIndex !== -1 && result.data) { state.works[workIndex] = { ...state.works[workIndex], state: 10, path: result.data.path, response_data: result.data.response_data }; } renderGallery(); } else if (result.success && result.status === 'failed') { pollingTasks.delete(id); showToast('error', `任务 id 失败: result.msg || '未知错误'`); // 更新状态为失败 const workIndex = state.works.findIndex(w => w.id === id); if (workIndex !== -1) { state.works[workIndex].state = 99; state.works[workIndex].error = result.msg; } renderGallery(); } } catch (error) { console.error(`轮询任务 id 失败:`, error); } } }, 20000); // 每20秒轮询一次 } // 停止轮询 function stopPolling() { if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; } } // 加载语言 async function loadLanguage() { let savedLang = localStorage.getItem('banana_lang'); if (!savedLang) { const systemLang = navigator.language || navigator.userLanguage; if (systemLang.startsWith('zh-TW') || systemLang.startsWith('zh-HK')) { savedLang = 'zh-TW'; } else if (systemLang.startsWith('zh')) { savedLang = 'zh'; } else if (systemLang.startsWith('ja')) { savedLang = 'ja'; } else { savedLang = 'en'; } } state.currentLang = savedLang; try { const response = await fetch(`/lan/savedLang.json`); if (response.ok) { state.i18n = await response.json(); applyTranslations(); } } catch (error) { console.error('加载语言文件失败:', error); } } // 应用翻译 function applyTranslations() { if (!state.i18n) return; document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); const text = getNestedValue(state.i18n, key); if (text) el.textContent = text; }); document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.getAttribute('data-i18n-placeholder'); const text = getNestedValue(state.i18n, key); if (text) { el.placeholder = text; el.setAttribute('data-placeholder', text); } }); // 更新编辑器输入框的 placeholder const placeholder = getNestedValue(state.i18n, 'editor.placeholder'); if (placeholder) { elements.editorInput.setAttribute('data-placeholder', placeholder); } } // 获取嵌套对象的值 function getNestedValue(obj, path) { return path.split('.').reduce((acc, part) => acc && acc[part], obj); } // 获取翻译文本 function t(key) { return getNestedValue(state.i18n, key) || key; } // 更新语言按钮显示 function updateLangButton() { const langText = document.getElementById('lang-text'); if (langText && state.config && state.config.languages) { const lang = state.config.languages.find(l => l.code === state.currentLang); langText.textContent = lang ? lang.short : '中'; } } // 切换语言 function switchLanguage(lang) { localStorage.setItem('banana_lang', lang); // 刷新页面 window.location.reload(); } // 加载主题状态 function loadThemeState() { const savedTheme = localStorage.getItem('banana_dark_mode'); if (savedTheme !== null) { state.darkMode = savedTheme === 'true'; document.body.classList.toggle('dark', state.darkMode); updateThemeIcon(); } } // 更新主题图标 function updateThemeIcon() { const sunIcon = document.querySelector('.sun-icon'); const moonIcon = document.querySelector('.moon-icon'); if (sunIcon && moonIcon) { sunIcon.style.display = state.darkMode ? 'none' : 'block'; moonIcon.style.display = state.darkMode ? 'block' : 'none'; } } // 保存主题状态 function saveThemeState() { localStorage.setItem('banana_dark_mode', state.darkMode.toString()); } // 从 localStorage 加载配置 function loadConfigFromLocalStorage() { const apiKey = localStorage.getItem('banana_api_key') || ''; const platformToken = localStorage.getItem('banana_platform_token') || ''; const modelApiKey = localStorage.getItem('banana_model_api_key') || ''; const savePath = localStorage.getItem('banana_save_path') || ''; state.hasApiKey = !!apiKey; state.hasModelApiKey = !!modelApiKey; const savedModel = localStorage.getItem('banana_selected_model'); const savedSize = localStorage.getItem('banana_selected_size'); const savedQuality = localStorage.getItem('banana_selected_quality'); const savedCut = localStorage.getItem('banana_selected_cut'); if (savedModel !== null) state.selectedModel = parseInt(savedModel); if (savedSize !== null) state.selectedSize = parseInt(savedSize); if (savedQuality !== null) state.selectedQuality = parseInt(savedQuality); if (savedCut !== null) state.selectedCut = parseInt(savedCut); } // 发送配置到后端 async function sendConfigToBackend() { const apiKey = localStorage.getItem('banana_api_key') || ''; if (!apiKey) { showSettings(); return; } // 配置通过创作/轮询时传输,不再单独保存 } // 显示设置弹窗 function showSettings() { win('settings', '设置', 'settings.html', 520, 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'); } // 显示AI提示词弹窗 function showPromptModal() { win('ai-prompt', 'AI提示词工具 - 创建多图片prompt', 'ai-prompt.html', 700, 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'); } // 显示错误弹窗 function showErrorModal(errorMsg) { window._winErrorMsg = errorMsg; win('error', '错误信息', 'error.html', 500, '#fee2e2'); } // 显示关于弹窗 function showAboutModal() { win('about', '关于', 'about.html', 450, ''); } // 加载作品列表 async function loadWorks(page = 1, append = false, keyword = '') { try { const pageSize = 30; let url = `/api/works?page=page&pageSize=pageSize`; if (keyword) { url += `&keyword=encodeURIComponent(keyword)`; } const response = await fetch(url); const data = await response.json(); if (data.success) { if (append) { state.works = [...state.works, ...data.data]; } else { state.works = data.data; } state.allWorks = state.works; state.currentPage = data.pagination.page; state.totalPages = data.pagination.totalPages; state.total = data.pagination.total; state.searchKeyword = keyword; renderGallery(); // 加载成功,允许下次加载 if (data.data.length > 0) { window.can_load = 1; } } } catch (error) { console.error('加载作品失败:', error); } } // 加载更多 async function loadMoreWorks() { if (state.currentPage < state.totalPages) { await loadWorks(state.currentPage + 1, true, state.searchKeyword); } } // 搜索作品 - 从数据库查询 async function searchWorks(keyword) { const trimmedKeyword = keyword.trim(); window.can_load = 1; // 搜索时重置加载标志 await loadWorks(1, false, trimmedKeyword); } // 渲染瀑布流 function renderGallery() { const validWorks = state.works.filter(w => w.state !== -1); if (validWorks.length === 0) { elements.gallery.innerHTML = `<div style="width:100%;display:flex;max-width: 1600px;margin: 0 auto;float:left;position:absolute;justify-content:center"> <div style="grid-column: 1/-1; text-align: center; padding: 60px; opacity: 0.8;"> <div style="font-size: 48px; margin-bottom: 16px;">🎨</div> <div style="font-size: 18px; margin-bottom: 8px;">t('messages.noWorks')</div> <div style="font-size: 14px;">t('messages.startCreate')</div> </div> </div> `; return; } elements.gallery.innerHTML = validWorks.map(work => { const date = new Date(work.date); const dateStr = `date.getMonth()+1-date.getDate() date.getHours():String(date.getMinutes()).padStart(2, '0')`; // 获取比例类名 const ratioClass = work.ratio ? `ratio-', '-')` : 'ratio-1-1'; // 构建底部信息 const infoItems = []; infoItems.push(work.ratio || '1:1'); infoItems.push(work.quality || '2K'); if (work.cut > 1) infoItems.push(`work.cut宫格`); const infoText = infoItems.join(' · '); // 处理中或失败的任务 if (work.state === 1 || work.state === 99) { const statusClass = work.state === 1 ? 'status-pending' : 'status-failed'; const statusText = work.state === 1 ? '正在生成中' : '生成失败'; const icon = work.state === 1 ? `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>` : `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`; return ` <div class="image-card task-card statusClass ratioClass" onclick="showTaskDetail(work.id)"> <div class="task-status"> <div class="task-status-icon">icon</div> <div class="task-status-text">statusText</div> </div> <div class="info"> <div class="prompt" onclick="event.stopPropagation(); showTaskDetail(work.id)">work.prompt</div> <div class="meta"> <span>infoText</span> <span class="date">dateStr</span> </div> </div> work.state === 99 ? `<button class="card-delete-btn" onclick="event.stopPropagation(); deleteTask(${work.id)">🗑️</button>` : ''} </div> `; } // 已完成的任务 let httpPath = work.http_path; if (!httpPath && work.path) { // 兼容旧数据,尝试转换路径 httpPath = work.path.replace(/^[A-Z]:\\Users\\[^\\]+\\Desktop\\banana2/i, '/images'); } const imgSrc = `httpPath/thumb.png`; const mainSrc = `httpPath/main.png`; return ` <div class="image-card ratioClass" onclick="viewWork(work.id)" onerror="handleImageError(work.id, this)"> <img src="imgSrc" alt="work.prompt" onerror="this.onerror=null; this.src='mainSrc'; if(this.naturalWidth===0){this.parentElement.classList.add('image-error'); this.style.display='none';}"> <div class="info"> <div class="prompt" onclick="event.stopPropagation(); showTaskDetail(work.id)">work.prompt</div> <div class="meta"> <span>infoText</span> <span class="date">dateStr</span> <button class="folder-btn" onclick="event.stopPropagation(); openFolder(work.id)" title="打开文件夹">📁</button> </div> </div> </div> `; }).join(''); // 滚动加载更多(移除按钮,改用滚动检测) } // 滚动加载检测 window.can_load = 1; function setupScrollLoad() { $('main').on('scroll', function() { // 检查是否滚动到底部300px if ($(this).scrollTop() + $(this).innerHeight() >= this.scrollHeight - 300) { if (window.can_load == 1 && state.currentPage < state.totalPages) { window.can_load = 0; loadMoreWorks(); } } }); } // 图片加载失败处理 function handleImageError(id, element) { element.classList.add('image-error'); } // 打开文件夹 function openFolder(workId) { const work = state.works.find(w => w.id === workId); if (!work || !work.path) { showToast('error', '找不到任务路径'); return; } $.ajax({ url: '/api/open-folder', type: 'POST', contentType: 'application/json', data: JSON.stringify({ path: work.path }) }); } // 显示任务详情弹窗 function showTaskDetail(id) { // 保存任务ID到全局变量,供组件使用 window._winTaskId = id; win('task-detail', '任务详情', 'task-detail.html', 450, 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'); } // 复制到剪贴板 function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { showToast('success', '已复制到剪贴板'); }).catch(() => { showToast('error', '复制失败'); }); } // 获取图片 - 只查询task状态 async function retryTask(id) { const work = state.works.find(w => w.id === id); if (!work) return; // 不关闭弹窗 const btn = document.querySelector('.task-btn-retry'); const originalText = btn.textContent; btn.textContent = '获取中...'; btn.disabled = true; try { const apiKey = localStorage.getItem('banana_api_key') || ''; const response = await fetch(`/api/poll/id`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey }) }); const result = await response.json(); // 显示Toast通知 if (result.success) { if (result.status === 'completed') { showToast('success', '获取成功!图片已保存'); closeTaskDetail(); // 更新单个任务数据 const workIndex = state.works.findIndex(w => w.id === id); if (workIndex !== -1 && result.data) { state.works[workIndex] = { ...state.works[workIndex], state: 10, path: result.data.path, response_data: result.data.response_data }; } renderGallery(); } else if (result.status === 'pending') { showToast('warning', '任务还在处理中,已加入轮询队列'); pollingTasks.add(id); startPolling(); closeTaskDetail(); } else { // 显示详细信息 const debugInfo = result.debug || {}; const msg = `请求URL: debugInfo.request_url || 'N/A'\n请求数据: JSON.stringify(debugInfo.request_data || {, null, 2)}\n响应: JSON.stringify(debugInfo.response || result, null, 2)`; showToast('error', msg, true); } } else { showToast('error', result.msg || '获取失败', true); } } catch (error) { showToast('error', '获取失败:' + error.message, true); } finally { btn.textContent = originalText; btn.disabled = false; } } // Toast 通知系统 function showToast(type, message, persistent = false) { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast toast-type`; const icon = type === 'success' ? '✓' : type === 'warning' ? '⚠' : '✕'; const title = type === 'success' ? '成功' : type === 'warning' ? '警告' : '错误'; toast.innerHTML = ` <div class="toast-header"> <span class="toast-icon">icon</span> <span class="toast-title">title</span> <button class="toast-close" onclick="this.parentElement.parentElement.remove()">×</button> </div> <div class="toast-body">message</div> `; container.appendChild(toast); // 触发动画 setTimeout(() => toast.classList.add('show'), 10); // 自动关闭(非持久化) if (!persistent) { setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 15000); } } // 删除任务 async function deleteTask(id) { if (!confirm(t('messages.confirmDelete'))) return; try { const response = await fetch(`/api/admin/delete/id`, {method: 'POST'}); const result = await response.json(); if (result.success) { closeTaskDetail(); await loadWorks(); } else { showErrorModal(t('messages.generateFailed') + ':' + result.error); } } catch (error) { showErrorModal(t('messages.generateFailed') + ':' + error.message); } } // 渲染上传的图片 function renderUploadedImages() { if (state.uploadedImages.length === 0) { elements.editorUpload.style.display = 'none'; elements.uploadBtn.style.display = 'flex'; return; } elements.editorUpload.style.display = 'flex'; elements.uploadBtn.style.display = 'none'; let html = state.uploadedImages.map((img, index) => { // 获取图片预览 src let imgSrc = ''; if (img.base64) { imgSrc = img.base64; } else if (img.url) { imgSrc = img.url; } else if (img.relative_path) { // 相对路径可以直接用于预览(前端有路由) imgSrc = img.relative_path; } else if (img.file_path) { // 本地路径无法直接预览,使用占位图 imgSrc = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect fill="#f3f4f6" width="100" height="100"/><text x="50" y="50" text-anchor="middle" dy=".3em" fill="#6b7280" font-size="12">本地</text></svg>'); } const title = img.uploaded ? '已上传: ' + (img.url || '') : img.name; return ` <div class="upload-item ''"> <img src="imgSrc" alt="img.name" title="title" onerror="this.src='data:image/svg+xml,//www.w3.org/2000/svg\" width=\"100\" height=\"100\"><rect fill=\"#fee2e2\" width=\"100\" height=\"100\"/><text x=\"50\" y=\"50\" text-anchor=\"middle\" dy=\".3em\" fill=\"#ef4444\" font-size=\"10\">加载失败</text></svg>')'"> '' <button class="remove" onclick="removeUploadedImage(index)">×</button> </div> `; }).join(''); if (state.uploadedImages.length < MAX_IMAGES) { html += ` <div class="upload-item upload-add-btn" onclick="document.getElementById('file-input').click()"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="12" y1="5" x2="12" y2="19"></line> <line x1="5" y1="12" x2="19" y2="12"></line> </svg> </div> `; } // 添加测试上传按钮 const hasUnuploaded = state.uploadedImages.some(img => !img.uploaded); if (hasUnuploaded) { html += ` <div class="upload-item upload-test-btn" style="display:none" onclick="testUpload()" title="测试上传(不提交生成任务)"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> <polyline points="17 8 12 3 7 8"></polyline> <line x1="12" y1="3" x2="12" y2="15"></line> </svg> </div> `; } elements.editorUpload.innerHTML = html; } // 移除上传的图片 function removeUploadedImage(index) { state.uploadedImages.splice(index, 1); renderUploadedImages(); } // 处理图片上传 function handleImageUpload(files) { const fileArray = Array.from(files); if (fileArray.length === 0) return; const currentCount = state.uploadedImages.length; const availableSlots = MAX_IMAGES - currentCount; if (availableSlots <= 0) { showErrorModal(t('messages.maxImages')); return; } let filesToProcess = fileArray; if (fileArray.length > availableSlots) { showErrorModal(t('messages.maxImagesSelect').replace('{n}', availableSlots)); filesToProcess = fileArray.slice(0, availableSlots); } filesToProcess.forEach(file => { const reader = new FileReader(); reader.onload = (event) => { state.uploadedImages.push({ name: file.name, base64: event.target.result, file: file, uploaded: false, // 是否已上传到服务器 url: null // 上传后的 URL }); renderUploadedImages(); }; reader.readAsDataURL(file); }); } // 上传单张图片到服务器 async function uploadImageToServer(imgData) { try { // 构建上传请求数据,支持多种格式 const uploadData = { name: imgData.name, platform_token: localStorage.getItem('banana_platform_token') || '' }; if (imgData.base64) { uploadData.base64 = imgData.base64; } else if (imgData.file_path) { uploadData.file_path = imgData.file_path; } else if (imgData.relative_path) { uploadData.relative_path = imgData.relative_path; } else if (imgData.url) { uploadData.url = imgData.url; } else { throw new Error('图片缺少有效数据'); } console.log('📤 uploadImageToServer 上传数据:', uploadData); const response = await fetch('/api/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(uploadData) }); const result = await response.json(); if (result.success) { return { success: true, url: result.url, cached: result.cached, hash: result.hash }; } else { return { success: false, error: result.error || '上传失败' }; } } catch (error) { return { success: false, error: error.message }; } } // 查看作品 async function viewWork(id) { // 保存任务ID到全局变量,供组件使用 window._winViewerId = id; // 加载组件并插入到body try { const response = await fetch('/components/viewer.html'); const html = await response.text(); // 移除旧的viewer $('#viewer-layer').remove(); // 插入新的viewer $('body').append(html); } catch (error) { console.error('加载viewer组件失败:', error); } } // 切换viewer显示的图片 function switchViewerImage(src) { elements.viewerImage.src = src; state.currentViewerImage = src; } // 更新编辑器切换按钮状态 function updateEditorToggleState() { const btn = document.getElementById('btn-editor'); if (btn) { if (elements.editor.style.display === 'none') { btn.classList.remove('active'); } else { btn.classList.add('active'); } } } // 生成分辨率图标 function generateRatioIcon(ratio) { const parts = ratio.split(':').map(Number); if (parts.length !== 2) return '<div class="ratio-icon"><div class="ratio-box"></div></div>'; const [w, h] = parts; const maxSize = 14; let width, height; if (w >= h) { width = maxSize; height = Math.round(maxSize * h / w); } else { height = maxSize; width = Math.round(maxSize * w / h); } return `<div class="ratio-icon" style="width:maxSizepx;height:maxSizepx;"> <div class="ratio-box" style="width:widthpx;height:heightpx;"></div> </div>`; } // 生成切割图标 function generateCutIcon(num) { if (num === 1) return '<div class="cut-icon"><div class="cut-box single"></div></div>'; let cols, rows; if (num === 2) { cols = 2; rows = 1; } else if (num === 4) { cols = 2; rows = 2; } else if (num === 6) { cols = 3; rows = 2; } else if (num === 9) { cols = 3; rows = 3; } else { cols = 1; rows = 1; } let boxes = ''; for (let i = 0; i < num; i++) boxes += '<div class="cut-box"></div>'; return `<div class="cut-icon cols-cols rows-rows">boxes</div>`; } // 加载参数 async function loadParameters() { try { const response = await fetch('/api/get_set'); const data = await response.json(); if (!data.success) { console.error('加载配置失败:', data.error); return; } state.config = data.data; const config = data.data; // 模型列表 const modelMenu = document.getElementById('model-menu'); modelMenu.innerHTML = config.models.map((m, i) => ` <div class="param-item" onclick="selectModel(i)"> <span class="param-item-icon">m.logo</span> <span class="param-item-text">m.name</span> </div> `).join(''); // 语言列表 if (config.languages) { const langMenu = document.getElementById('lang-menu'); langMenu.innerHTML = config.languages.map(lang => ` <div class="lang-item" data-lang="lang.code">lang.name</div> `).join(''); // 重新绑定语言选项点击事件 document.querySelectorAll('.lang-item').forEach(item => { item.onclick = () => switchLanguage(item.getAttribute('data-lang')); }); } updateModelDisplay(); updateSizeOptions(); updateQualityOptions(); updateCutOptions(); } catch (error) { console.error('加载参数失败:', error); } } // 更新模型显示 function updateModelDisplay() { const config = state.config; if (!config || !config.models[state.selectedModel]) return; const model = config.models[state.selectedModel]; document.getElementById('model-btn').innerHTML = ` <span class="param-icon">model.logo</span> <span class="param-text">model.name</span> `; } // 获取可用的分辨率列表(优先使用 resolutions,否则从模型 size 生成) function getAvailableResolutions(config, model) { if (config.resolutions && config.resolutions.length > 0) { return config.resolutions; } // 从模型 size 数组生成 resolutions if (model.size && model.size.length > 0) { return model.size.map(ratio => ({ name: ratio, ratio: ratio })); } return []; } // 获取可用的质量列表(优先使用 qualities,否则从模型 quality 生成) function getAvailableQualities(config, model) { if (config.qualities && config.qualities.length > 0) { return config.qualities; } // 从模型 quality 数组生成 qualities if (model.quality && model.quality.length > 0) { return model.quality.map(size => ({ name: size, size: size })); } return []; } // 更新尺寸选项 function updateSizeOptions() { const config = state.config; if (!config || !config.models[state.selectedModel]) return; const model = config.models[state.selectedModel]; const sizeMenu = document.getElementById('size-menu'); const sizeSelector = document.getElementById('size-selector'); if (!model.size || model.size.length === 0) { sizeSelector.style.display = 'none'; return; } sizeSelector.style.display = 'block'; // 直接使用模型的尺寸数组 const sizes = model.size; // 确保选中的索引在有效范围内 if (state.selectedSize >= sizes.length) { state.selectedSize = sizes.length - 1; } if (state.selectedSize < 0) { state.selectedSize = 0; } sizeMenu.innerHTML = sizes.map((s, index) => ` <div class="param-item" onclick="selectSize(index)"> generateRatioIcon(s) <span class="param-item-text">t('sizes.' + s) || s</span> </div> `).join(''); updateSizeDisplay(); } // 更新尺寸显示 function updateSizeDisplay() { const config = state.config; if (!config || !config.models[state.selectedModel]) return; const model = config.models[state.selectedModel]; const sizes = model.size || []; if (!sizes[state.selectedSize]) return; const s = sizes[state.selectedSize]; document.getElementById('size-btn').innerHTML = ` generateRatioIcon(s) <span class="param-text">t('sizes.' + s) || s</span> `; } // 更新质量选项 function updateQualityOptions() { const config = state.config; if (!config || !config.models[state.selectedModel]) return; const model = config.models[state.selectedModel]; const qualityMenu = document.getElementById('quality-menu'); const qualitySelector = document.getElementById('quality-selector'); if (!model.quality || model.quality.length === 0) { qualitySelector.style.display = 'none'; return; } qualitySelector.style.display = 'block'; // 直接使用模型的质量数组 const qualities = model.quality; // 确保选中的索引在有效范围内 if (state.selectedQuality >= qualities.length) { state.selectedQuality = qualities.length - 1; } if (state.selectedQuality < 0) { state.selectedQuality = 0; } qualityMenu.innerHTML = qualities.map((q, index) => ` <div class="param-item" onclick="selectQuality(index)"> <icon class="quality-icon icon icon-fenbianshuai"></icon> <span class="param-item-text">t('qualities.' + q) || q</span> </div> `).join(''); updateQualityDisplay(); } // 更新质量显示 function updateQualityDisplay() { const config = state.config; if (!config || !config.models[state.selectedModel]) return; const model = config.models[state.selectedModel]; const qualities = model.quality || []; if (!qualities[state.selectedQuality]) return; const q = qualities[state.selectedQuality]; document.getElementById('quality-btn').innerHTML = ` <icon class="quality-icon icon icon-fenbianshuai"></icon> <span class="param-text">t('qualities.' + q) || q</span> `; } // 更新切割选项 function updateCutOptions() { const config = state.config; if (!config || !config.models[state.selectedModel]) return; const model = config.models[state.selectedModel]; const cutMenu = document.getElementById('cut-menu'); const cutSelector = document.getElementById('cut-selector'); if (!model.cut || model.cut.length === 0) { cutSelector.style.display = 'none'; state.selectedCut = 1; return; } cutSelector.style.display = 'block'; if (!model.cut.includes(state.selectedCut)) { state.selectedCut = model.cut[0] || 1; } const cutLabels = { 1: t('params.noCut'), 2: '2' + t('params.grid'), 4: '4' + t('params.grid'), 6: '6' + t('params.grid'), 9: '9' + t('params.grid') }; cutMenu.innerHTML = model.cut.map(value => ` <div class="param-item" onclick="selectCut(value)"> generateCutIcon(value) <span class="param-item-text">cutLabels[value] || value + t('params.grid')</span> </div> `).join(''); updateCutDisplay(); } // 更新切割显示 function updateCutDisplay() { const cutLabels = { 1: t('params.noCut'), 2: '2' + t('params.grid'), 4: '4' + t('params.grid'), 6: '6' + t('params.grid'), 9: '9' + t('params.grid') }; document.getElementById('cut-btn').innerHTML = ` generateCutIcon(state.selectedCut) <span class="param-text">cutLabels[state.selectedCut] || state.selectedCut + t('params.grid')</span> `; } // 选择模型 function selectModel(index) { state.selectedModel = index; localStorage.setItem('banana_selected_model', index.toString()); updateModelDisplay(); updateSizeOptions(); updateQualityOptions(); updateCutOptions(); closeAllMenus(); } // 选择尺寸 function selectSize(index) { state.selectedSize = index; localStorage.setItem('banana_selected_size', index.toString()); updateSizeDisplay(); closeAllMenus(); } // 选择质量 function selectQuality(index) { state.selectedQuality = index; localStorage.setItem('banana_selected_quality', index.toString()); updateQualityDisplay(); closeAllMenus(); } // 选择切割 function selectCut(value) { state.selectedCut = value; localStorage.setItem('banana_selected_cut', value.toString()); updateCutDisplay(); closeAllMenus(); } // 关闭所有菜单 function closeAllMenus() { document.querySelectorAll('.param-menu').forEach(menu => menu.classList.remove('show')); elements.langMenu.classList.remove('show'); } // 提交生成 async function submit() { const prompt = elements.editorInput.textContent.trim(); if (!prompt) { showErrorModal(t('messages.promptRequired')); return; } // 从localStorage获取配置 const apiKey = localStorage.getItem('banana_api_key') || ''; const platformToken = localStorage.getItem('banana_platform_token') || ''; const modelApiKey = localStorage.getItem('banana_model_api_key') || ''; if (!apiKey) { showSettings(); return; } elements.submitBtn.disabled = true; elements.submitBtn.innerHTML = '<div class="spinner" style="width:20px;height:20px;border-width:2px;"></div>'; try { const config = state.config; const currentModel = config?.models?.[state.selectedModel]; const model = currentModel?.model || 'nano-banana-pro'; // 尺寸和质量直接从模型配置中获取用户选择的值 const modelSizes = currentModel?.size || ['1:1']; const modelQualities = currentModel?.quality || ['1K', '2K', '4K']; const ratio = modelSizes[state.selectedSize] || '1:1'; const quality = modelQualities[state.selectedQuality] || '2K'; // 调试日志 console.log('📊 提交参数:', { selectedSize: state.selectedSize, selectedQuality: state.selectedQuality, modelSizes: modelSizes, modelQualities: modelQualities, ratio: ratio, quality: quality }); const platformToken = localStorage.getItem('banana_platform_token') || ''; // 先上传图片获取URL let imageUrls = []; if (state.uploadedImages.length > 0) { if (!platformToken) { showErrorModal('图生图功能需要配置 Platform Token'); elements.submitBtn.disabled = false; elements.submitBtn.innerHTML = ` <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="22" y1="2" x2="11" y2="13"></line> <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> </svg> `; return; } console.log(`📤 上传 state.uploadedImages.length 张图片...`); for (const img of state.uploadedImages) { try { // 构建上传请求数据,支持多种格式 const uploadData = { name: img.name, platform_token: platformToken }; if (img.base64) { uploadData.base64 = img.base64; } else if (img.file_path) { uploadData.file_path = img.file_path; } else if (img.relative_path) { uploadData.relative_path = img.relative_path; } else if (img.url) { uploadData.url = img.url; } else { console.error('图片缺少有效数据:', img.name); continue; } const uploadResponse = await fetch('/api/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(uploadData) }); const uploadResult = await uploadResponse.json(); if (uploadResult.success && uploadResult.url) { imageUrls.push(uploadResult.url); console.log(`✅ 图片上传成功: uploadResult.url (缓存: uploadResult.cached)`); } else { console.error('图片上传失败:', uploadResult.error); showErrorModal(`图片上传失败: uploadResult.error`); elements.submitBtn.disabled = false; elements.submitBtn.innerHTML = ` <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="22" y1="2" x2="11" y2="13"></line> <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> </svg> `; return; } } catch (uploadErr) { console.error('图片上传异常:', uploadErr); showErrorModal(`图片上传异常: uploadErr.message`); elements.submitBtn.disabled = false; elements.submitBtn.innerHTML = ` <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="22" y1="2" x2="11" y2="13"></line> <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> </svg> `; return; } } console.log(`✅ 所有图片上传完成,URLs:`, imageUrls); } const requestData = { // 配置信息 api_key: apiKey, platform_token: platformToken, model_api_key: modelApiKey, save_path: localStorage.getItem('banana_save_path') || '', // 生成参数 prompt, model: model, ratio: ratio, quality: quality, cut: state.selectedCut, // 已上传的图片URLs image_urls: imageUrls }; console.log(requestData) const response = await fetch('/api/generate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(requestData) }); const result = await response.json(); if (result.success) { // 清除保存的提示词 clearPendingPrompt(); elements.editorInput.textContent = ''; state.uploadedImages = []; renderUploadedImages(); await loadWorks(); // 将新任务加入轮询队列 if (result.work_id) { pollingTasks.add(result.work_id); startPolling(); console.log(`📋 任务 result.work_id 已加入轮询队列`); } } else { showErrorModal(result.error || result.message || JSON.stringify(result, null, 2)); } } catch (error) { showErrorModal(t('messages.generateFailed') + ':' + error.message); } finally { elements.submitBtn.disabled = false; elements.submitBtn.innerHTML = ` <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="22" y1="2" x2="11" y2="13"></line> <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> </svg> `; } } // 设置事件监听 function setupEventListeners() { // Logo点击 document.getElementById('logo-btn').onclick = showAboutModal; // 关闭服务 document.getElementById('btn-close').onclick = async () => { if (confirm(t('messages.confirmClose'))) { await fetch('/api/shutdown', {method: 'POST'}); window.close(); } }; // 设置按钮 document.getElementById('btn-settings').onclick = showSettings; // 主题切换 document.getElementById('btn-theme').onclick = () => { state.darkMode = !state.darkMode; document.body.classList.toggle('dark', state.darkMode); updateThemeIcon(); saveThemeState(); }; // 语言选择 document.getElementById('btn-lang').onclick = (e) => { e.stopPropagation(); closeAllMenus(); elements.langMenu.classList.toggle('show'); }; // 语言选项点击 document.querySelectorAll('.lang-item').forEach(item => { item.onclick = () => switchLanguage(item.getAttribute('data-lang')); }); // 搜索框 elements.searchInput.oninput = (e) => searchWorks(e.target.value); // 编辑器切换 document.getElementById('btn-editor').onclick = () => { state.showEditor = !state.showEditor; elements.editor.style.display = state.showEditor ? 'block' : 'none'; document.getElementById('btn-editor').classList.toggle('active', state.showEditor); }; // AI提示词按钮 document.getElementById('ai-prompt-btn').onclick = showPromptModal; // 文件上传 elements.uploadBtn.onclick = () => elements.fileInput.click(); // 编辑器输入监听 - 用户键盘输入时保存到 localStorage elements.editorInput.addEventListener('keyup', function() { const prompt = this.textContent.trim(); if (prompt) { localStorage.setItem('banana_pending_prompt', prompt); } else { localStorage.removeItem('banana_pending_prompt'); } }); elements.fileInput.onchange = (e) => { handleImageUpload(e.target.files); e.target.value = ''; }; // 提交 elements.submitBtn.onclick = submit; // 参数菜单 document.getElementById('model-btn').onclick = (e) => { e.stopPropagation(); closeAllMenus(); document.getElementById('model-menu').classList.toggle('show'); }; document.getElementById('size-btn').onclick = (e) => { e.stopPropagation(); closeAllMenus(); document.getElementById('size-menu').classList.toggle('show'); }; document.getElementById('quality-btn').onclick = (e) => { e.stopPropagation(); closeAllMenus(); document.getElementById('quality-menu').classList.toggle('show'); }; document.getElementById('cut-btn').onclick = (e) => { e.stopPropagation(); closeAllMenus(); document.getElementById('cut-menu').classList.toggle('show'); }; // 查看器 document.getElementById('viewer-close').onclick = () => { elements.viewer.style.display = 'none'; elements.editor.style.display = 'block'; updateEditorToggleState(); }; // 信息按钮 document.getElementById('action-info').onclick = () => { if (!state.selectedWork) return; showTaskDetail(state.selectedWork.id); elements.viewer.style.display = 'none'; }; // 打开文件夹 document.getElementById('action-folder').onclick = async () => { if (!state.selectedWork) return; try { const response = await fetch('/api/open-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.selectedWork.path }) }); const result = await response.json(); if (!result.success) showToast('error', result.error); } catch (error) { showToast('error', error.message); } }; // 编辑按钮 document.getElementById('action-edit').onclick = () => { if (!state.selectedWork) return; elements.editorInput.textContent = state.selectedWork.prompt; elements.viewer.style.display = 'none'; elements.editor.style.display = 'block'; updateEditorToggleState(); }; // 点击外部关闭菜单 document.addEventListener('click', (e) => { if (!e.target.closest('.param-selector') && !e.target.closest('.lang-selector')) { closeAllMenus(); } }); // 点击弹窗背景关闭 document.querySelectorAll('.modal').forEach(modal => { modal.onclick = (e) => { if (e.target === modal) modal.style.display = 'none'; }; }); // 键盘快捷键 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (elements.viewer.style.display === 'flex') { elements.viewer.style.display = 'none'; elements.editor.style.display = 'block'; } else { hideAllModals(); } } if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { if (elements.editorInput.textContent.trim()) submit(); } }); } // 启动应用 init(); FILE:public/js/jquery.min.js /*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ !function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0<t&&t-1 in e)}function fe(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}ce.fn=ce.prototype={jquery:t,constructor:ce,length:0,toArray:function(){return ae.call(this)},get:function(e){return null==e?ae.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=ce.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return ce.each(this,e)},map:function(n){return this.pushStack(ce.map(this,function(e,t){return n.call(e,t,e)}))},slice:function(){return this.pushStack(ae.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(ce.grep(this,function(e,t){return(t+1)%2}))},odd:function(){return this.pushStack(ce.grep(this,function(e,t){return t%2}))},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(0<=n&&n<t?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:s,sort:oe.sort,splice:oe.splice},ce.extend=ce.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==typeof a||v(a)||(a={}),s===u&&(a=this,s--);s<u;s++)if(null!=(e=arguments[s]))for(t in e)r=e[t],"__proto__"!==t&&a!==r&&(l&&r&&(ce.isPlainObject(r)||(i=Array.isArray(r)))?(n=a[t],o=i&&!Array.isArray(n)?[]:i||ce.isPlainObject(n)?n:{},i=!1,a[t]=ce.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},ce.extend({expando:"jQuery"+(t+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==i.call(e))&&(!(t=r(e))||"function"==typeof(n=ue.call(t,"constructor")&&t.constructor)&&o.call(n)===a)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e,t,n){m(e,{nonce:t&&t.nonce},n)},each:function(e,t){var n,r=0;if(c(e)){for(n=e.length;r<n;r++)if(!1===t.call(e[r],r,e[r]))break}else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},text:function(e){var t,n="",r=0,i=e.nodeType;if(!i)while(t=e[r++])n+=ce.text(t);return 1===i||11===i?e.textContent:9===i?e.documentElement.textContent:3===i||4===i?e.nodeValue:n},makeArray:function(e,t){var n=t||[];return null!=e&&(c(Object(e))?ce.merge(n,"string"==typeof e?[e]:e):s.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:se.call(t,e,n)},isXMLDoc:function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!l.test(t||n&&n.nodeName||"HTML")},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;r<n;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r=[],i=0,o=e.length,a=!n;i<o;i++)!t(e[i],i)!==a&&r.push(e[i]);return r},map:function(e,t,n){var r,i,o=0,a=[];if(c(e))for(r=e.length;o<r;o++)null!=(i=t(e[o],o,n))&&a.push(i);else for(o in e)null!=(i=t(e[o],o,n))&&a.push(i);return g(a)},guid:1,support:le}),"function"==typeof Symbol&&(ce.fn[Symbol.iterator]=oe[Symbol.iterator]),ce.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){n["[object "+t+"]"]=t.toLowerCase()});var pe=oe.pop,de=oe.sort,he=oe.splice,ge="[\\x20\\t\\r\\n\\f]",ve=new RegExp("^"+ge+"+|((?:^|[^\\\\])(?:\\\\.)*)"+ge+"+$","g");ce.contains=function(e,t){var n=t&&t.parentNode;return e===n||!(!n||1!==n.nodeType||!(e.contains?e.contains(n):e.compareDocumentPosition&&16&e.compareDocumentPosition(n)))};var f=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\x80-\uFFFF\w-]/g;function p(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e}ce.escapeSelector=function(e){return(e+"").replace(f,p)};var ye=C,me=s;!function(){var e,b,w,o,a,T,r,C,d,i,k=me,S=ce.expando,E=0,n=0,s=W(),c=W(),u=W(),h=W(),l=function(e,t){return e===t&&(a=!0),0},f="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",t="(?:\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+",p="\\["+ge+"*("+t+")(?:"+ge+"*([*^$|!~]?=)"+ge+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+t+"))|)"+ge+"*\\]",g=":("+t+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+p+")*)|.*)\\)|)",v=new RegExp(ge+"+","g"),y=new RegExp("^"+ge+"*,"+ge+"*"),m=new RegExp("^"+ge+"*([>+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="<a id='"+S+"' href='' disabled='disabled'></a><select id='"+S+"-\r\\' disabled='disabled'><option selected=''></option></select>",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0<I(t,T,null,[e]).length},I.contains=function(e,t){return(e.ownerDocument||e)!=T&&V(e),ce.contains(e,t)},I.attr=function(e,t){(e.ownerDocument||e)!=T&&V(e);var n=b.attrHandle[t.toLowerCase()],r=n&&ue.call(b.attrHandle,t.toLowerCase())?n(e,t,!C):void 0;return void 0!==r?r:e.getAttribute(t)},I.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},ce.uniqueSort=function(e){var t,n=[],r=0,i=0;if(a=!le.sortStable,o=!le.sortStable&&ae.call(e,0),de.call(e,l),a){while(t=e[i++])t===e[i]&&(r=n.push(i));while(r--)he.call(e,n[r],1)}return o=null,e},ce.fn.uniqueSort=function(){return this.pushStack(ce.uniqueSort(ae.apply(this)))},(b=ce.expr={cacheLength:50,createPseudo:F,match:D,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1<t.indexOf(i):"$="===r?i&&t.slice(-i.length)===i:"~="===r?-1<(" "+t.replace(v," ")+" ").indexOf(i):"|="===r&&(t===i||t.slice(0,i.length+1)===i+"-"))}},CHILD:function(d,e,t,h,g){var v="nth"!==d.slice(0,3),y="last"!==d.slice(-4),m="of-type"===e;return 1===h&&0===g?function(e){return!!e.parentNode}:function(e,t,n){var r,i,o,a,s,u=v!==y?"nextSibling":"previousSibling",l=e.parentNode,c=m&&e.nodeName.toLowerCase(),f=!n&&!m,p=!1;if(l){if(v){while(u){o=e;while(o=o[u])if(m?fe(o,c):1===o.nodeType)return!1;s=u="only"===d&&!s&&"nextSibling"}return!0}if(s=[y?l.firstChild:l.lastChild],y&&f){p=(a=(r=(i=l[S]||(l[S]={}))[d]||[])[0]===E&&r[1])&&r[2],o=a&&l.childNodes[a];while(o=++a&&o&&o[u]||(p=a=0)||s.pop())if(1===o.nodeType&&++p&&o===e){i[d]=[E,a,p];break}}else if(f&&(p=a=(r=(i=e[S]||(e[S]={}))[d]||[])[0]===E&&r[1]),!1===p)while(o=++a&&o&&o[u]||(p=a=0)||s.pop())if((m?fe(o,c):1===o.nodeType)&&++p&&(f&&((i=o[S]||(o[S]={}))[d]=[E,p]),o===e))break;return(p-=g)===h||p%h==0&&0<=p/h}}},PSEUDO:function(e,o){var t,a=b.pseudos[e]||b.setFilters[e.toLowerCase()]||I.error("unsupported pseudo: "+e);return a[S]?a(o):1<a.length?(t=[e,e,"",o],b.setFilters.hasOwnProperty(e.toLowerCase())?F(function(e,t){var n,r=a(e,o),i=r.length;while(i--)e[n=se.call(e,r[i])]=!(t[n]=r[i])}):function(e){return a(e,0,t)}):a}},pseudos:{not:F(function(e){var r=[],i=[],s=ne(e.replace(ve,"$1"));return s[S]?F(function(e,t,n,r){var i,o=s(e,null,r,[]),a=e.length;while(a--)(i=o[a])&&(e[a]=!(t[a]=i))}):function(e,t,n){return r[0]=e,s(r,null,n,i),r[0]=null,!i.pop()}}),has:F(function(t){return function(e){return 0<I(t,e).length}}),contains:F(function(t){return t=t.replace(O,P),function(e){return-1<(e.textContent||ce.text(e)).indexOf(t)}}),lang:F(function(n){return A.test(n||"")||I.error("unsupported lang: "+n),n=n.replace(O,P).toLowerCase(),function(e){var t;do{if(t=C?e.lang:e.getAttribute("xml:lang")||e.getAttribute("lang"))return(t=t.toLowerCase())===n||0===t.indexOf(n+"-")}while((e=e.parentNode)&&1===e.nodeType);return!1}}),target:function(e){var t=ie.location&&ie.location.hash;return t&&t.slice(1)===e.id},root:function(e){return e===r},focus:function(e){return e===function(){try{return T.activeElement}catch(e){}}()&&T.hasFocus()&&!!(e.type||e.href||~e.tabIndex)},enabled:z(!1),disabled:z(!0),checked:function(e){return fe(e,"input")&&!!e.checked||fe(e,"option")&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!b.pseudos.empty(e)},header:function(e){return q.test(e.nodeName)},input:function(e){return N.test(e.nodeName)},button:function(e){return fe(e,"input")&&"button"===e.type||fe(e,"button")},text:function(e){var t;return fe(e,"input")&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:X(function(){return[0]}),last:X(function(e,t){return[t-1]}),eq:X(function(e,t,n){return[n<0?n+t:n]}),even:X(function(e,t){for(var n=0;n<t;n+=2)e.push(n);return e}),odd:X(function(e,t){for(var n=1;n<t;n+=2)e.push(n);return e}),lt:X(function(e,t,n){var r;for(r=n<0?n+t:t<n?t:n;0<=--r;)e.push(r);return e}),gt:X(function(e,t,n){for(var r=n<0?n+t:n;++r<t;)e.push(r);return e})}}).pseudos.nth=b.pseudos.eq,{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})b.pseudos[e]=B(e);for(e in{submit:!0,reset:!0})b.pseudos[e]=_(e);function G(){}function Y(e,t){var n,r,i,o,a,s,u,l=c[e+" "];if(l)return t?0:l.slice(0);a=e,s=[],u=b.preFilter;while(a){for(o in n&&!(r=y.exec(a))||(r&&(a=a.slice(r[0].length)||a),s.push(i=[])),n=!1,(r=m.exec(a))&&(n=r.shift(),i.push({value:n,type:r[0].replace(ve," ")}),a=a.slice(n.length)),b.filter)!(r=D[o].exec(a))||u[o]&&!(r=u[o](r))||(n=r.shift(),i.push({value:n,type:o,matches:r}),a=a.slice(n.length));if(!n)break}return t?a.length:a?I.error(e):c(e,s).slice(0)}function Q(e){for(var t=0,n=e.length,r="";t<n;t++)r+=e[t].value;return r}function J(a,e,t){var s=e.dir,u=e.next,l=u||s,c=t&&"parentNode"===l,f=n++;return e.first?function(e,t,n){while(e=e[s])if(1===e.nodeType||c)return a(e,t,n);return!1}:function(e,t,n){var r,i,o=[E,f];if(n){while(e=e[s])if((1===e.nodeType||c)&&a(e,t,n))return!0}else while(e=e[s])if(1===e.nodeType||c)if(i=e[S]||(e[S]={}),u&&fe(e,u))e=e[s]||e;else{if((r=i[l])&&r[0]===E&&r[1]===f)return o[2]=r[2];if((i[l]=o)[2]=a(e,t,n))return!0}return!1}}function K(i){return 1<i.length?function(e,t,n){var r=i.length;while(r--)if(!i[r](e,t,n))return!1;return!0}:i[0]}function Z(e,t,n,r,i){for(var o,a=[],s=0,u=e.length,l=null!=t;s<u;s++)(o=e[s])&&(n&&!n(o,r,i)||(a.push(o),l&&t.push(s)));return a}function ee(d,h,g,v,y,e){return v&&!v[S]&&(v=ee(v)),y&&!y[S]&&(y=ee(y,e)),F(function(e,t,n,r){var i,o,a,s,u=[],l=[],c=t.length,f=e||function(e,t,n){for(var r=0,i=t.length;r<i;r++)I(e,t[r],n);return n}(h||"*",n.nodeType?[n]:n,[]),p=!d||!e&&h?f:Z(f,u,d,n,r);if(g?g(p,s=y||(e?d:c||v)?[]:t,n,r):s=p,v){i=Z(s,l),v(i,[],n,r),o=i.length;while(o--)(a=i[o])&&(s[l[o]]=!(p[l[o]]=a))}if(e){if(y||d){if(y){i=[],o=s.length;while(o--)(a=s[o])&&i.push(p[o]=a);y(null,s=[],i,r)}o=s.length;while(o--)(a=s[o])&&-1<(i=y?se.call(e,a):u[o])&&(e[i]=!(t[i]=a))}}else s=Z(s===t?s.splice(c,s.length):s),y?y(null,t,s,r):k.apply(t,s)})}function te(e){for(var i,t,n,r=e.length,o=b.relative[e[0].type],a=o||b.relative[" "],s=o?1:0,u=J(function(e){return e===i},a,!0),l=J(function(e){return-1<se.call(i,e)},a,!0),c=[function(e,t,n){var r=!o&&(n||t!=w)||((i=t).nodeType?u(e,t,n):l(e,t,n));return i=null,r}];s<r;s++)if(t=b.relative[e[s].type])c=[J(K(c),t)];else{if((t=b.filter[e[s].type].apply(null,e[s].matches))[S]){for(n=++s;n<r;n++)if(b.relative[e[n].type])break;return ee(1<s&&K(c),1<s&&Q(e.slice(0,s-1).concat({value:" "===e[s-2].type?"*":""})).replace(ve,"$1"),t,s<n&&te(e.slice(s,n)),n<r&&te(e=e.slice(n)),n<r&&Q(e))}c.push(t)}return K(c)}function ne(e,t){var n,v,y,m,x,r,i=[],o=[],a=u[e+" "];if(!a){t||(t=Y(e)),n=t.length;while(n--)(a=te(t[n]))[S]?i.push(a):o.push(a);(a=u(e,(v=o,m=0<(y=i).length,x=0<v.length,r=function(e,t,n,r,i){var o,a,s,u=0,l="0",c=e&&[],f=[],p=w,d=e||x&&b.find.TAG("*",i),h=E+=null==p?1:Math.random()||.1,g=d.length;for(i&&(w=t==T||t||i);l!==g&&null!=(o=d[l]);l++){if(x&&o){a=0,t||o.ownerDocument==T||(V(o),n=!C);while(s=v[a++])if(s(o,t||T,n)){k.call(r,o);break}i&&(E=h)}m&&((o=!s&&o)&&u--,e&&c.push(o))}if(u+=l,m&&l!==u){a=0;while(s=y[a++])s(c,f,t,n);if(e){if(0<u)while(l--)c[l]||f[l]||(f[l]=pe.call(r));f=Z(f)}k.apply(r,f),i&&!e&&0<f.length&&1<u+y.length&&ce.uniqueSort(r)}return i&&(E=h,w=p),c},m?F(r):r))).selector=e}return a}function re(e,t,n,r){var i,o,a,s,u,l="function"==typeof e&&e,c=!r&&Y(e=l.selector||e);if(n=n||[],1===c.length){if(2<(o=c[0]=c[0].slice(0)).length&&"ID"===(a=o[0]).type&&9===t.nodeType&&C&&b.relative[o[1].type]){if(!(t=(b.find.ID(a.matches[0].replace(O,P),t)||[])[0]))return n;l&&(t=t.parentNode),e=e.slice(o.shift().value.length)}i=D.needsContext.test(e)?0:o.length;while(i--){if(a=o[i],b.relative[s=a.type])break;if((u=b.find[s])&&(r=u(a.matches[0].replace(O,P),H.test(o[0].type)&&U(t.parentNode)||t))){if(o.splice(i,1),!(e=r.length&&Q(o)))return k.apply(n,r),n;break}}}return(l||ne(e,c))(r,t,!C,n,!t||H.test(e)&&U(t.parentNode)||t),n}G.prototype=b.filters=b.pseudos,b.setFilters=new G,le.sortStable=S.split("").sort(l).join("")===S,V(),le.sortDetached=$(function(e){return 1&e.compareDocumentPosition(T.createElement("fieldset"))}),ce.find=I,ce.expr[":"]=ce.expr.pseudos,ce.unique=ce.uniqueSort,I.compile=ne,I.select=re,I.setDocument=V,I.tokenize=Y,I.escape=ce.escapeSelector,I.getText=ce.text,I.isXML=ce.isXMLDoc,I.selectors=ce.expr,I.support=ce.support,I.uniqueSort=ce.uniqueSort}();var d=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&ce(e).is(n))break;r.push(e)}return r},h=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},b=ce.expr.match.needsContext,w=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1<se.call(n,e)!==r}):ce.filter(n,e,r)}ce.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?ce.find.matchesSelector(r,e)?[r]:[]:ce.find.matches(e,ce.grep(t,function(e){return 1===e.nodeType}))},ce.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(ce(e).filter(function(){for(t=0;t<r;t++)if(ce.contains(i[t],this))return!0}));for(n=this.pushStack([]),t=0;t<r;t++)ce.find(e,i[t],n);return 1<r?ce.uniqueSort(n):n},filter:function(e){return this.pushStack(T(this,e||[],!1))},not:function(e){return this.pushStack(T(this,e||[],!0))},is:function(e){return!!T(this,"string"==typeof e&&b.test(e)?ce(e):e||[],!1).length}});var k,S=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e<n;e++)if(ce.contains(this,t[e]))return!0})},closest:function(e,t){var n,r=0,i=this.length,o=[],a="string"!=typeof e&&ce(e);if(!b.test(e))for(;r<i;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(n.nodeType<11&&(a?-1<a.index(n):1===n.nodeType&&ce.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(1<o.length?ce.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?se.call(ce(e),this[0]):se.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(ce.uniqueSort(ce.merge(this.get(),ce(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),ce.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return d(e,"parentNode")},parentsUntil:function(e,t,n){return d(e,"parentNode",n)},next:function(e){return A(e,"nextSibling")},prev:function(e){return A(e,"previousSibling")},nextAll:function(e){return d(e,"nextSibling")},prevAll:function(e){return d(e,"previousSibling")},nextUntil:function(e,t,n){return d(e,"nextSibling",n)},prevUntil:function(e,t,n){return d(e,"previousSibling",n)},siblings:function(e){return h((e.parentNode||{}).firstChild,e)},children:function(e){return h(e.firstChild)},contents:function(e){return null!=e.contentDocument&&r(e.contentDocument)?e.contentDocument:(fe(e,"template")&&(e=e.content||e),ce.merge([],e.childNodes))}},function(r,i){ce.fn[r]=function(e,t){var n=ce.map(this,i,e);return"Until"!==r.slice(-5)&&(t=e),t&&"string"==typeof t&&(n=ce.filter(t,n)),1<this.length&&(j[r]||ce.uniqueSort(n),E.test(r)&&n.reverse()),this.pushStack(n)}});var D=/[^\x20\t\r\n\f]+/g;function N(e){return e}function q(e){throw e}function L(e,t,n,r){var i;try{e&&v(i=e.promise)?i.call(e).done(t).fail(n):e&&v(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n.apply(void 0,[e])}}ce.Callbacks=function(r){var e,n;r="string"==typeof r?(e=r,n={},ce.each(e.match(D)||[],function(e,t){n[t]=!0}),n):ce.extend({},r);var i,t,o,a,s=[],u=[],l=-1,c=function(){for(a=a||r.once,o=i=!0;u.length;l=-1){t=u.shift();while(++l<s.length)!1===s[l].apply(t[0],t[1])&&r.stopOnFalse&&(l=s.length,t=!1)}r.memory||(t=!1),i=!1,a&&(s=t?[]:"")},f={add:function(){return s&&(t&&!i&&(l=s.length-1,u.push(t)),function n(e){ce.each(e,function(e,t){v(t)?r.unique&&f.has(t)||s.push(t):t&&t.length&&"string"!==x(t)&&n(t)})}(arguments),t&&!i&&c()),this},remove:function(){return ce.each(arguments,function(e,t){var n;while(-1<(n=ce.inArray(t,s,n)))s.splice(n,1),n<=l&&l--}),this},has:function(e){return e?-1<ce.inArray(e,s):0<s.length},empty:function(){return s&&(s=[]),this},disable:function(){return a=u=[],s=t="",this},disabled:function(){return!s},lock:function(){return a=u=[],t||i||(s=t=""),this},locked:function(){return!!a},fireWith:function(e,t){return a||(t=[e,(t=t||[]).slice?t.slice():t],u.push(t),i||c()),this},fire:function(){return f.fireWith(this,arguments),this},fired:function(){return!!o}};return f},ce.extend({Deferred:function(e){var o=[["notify","progress",ce.Callbacks("memory"),ce.Callbacks("memory"),2],["resolve","done",ce.Callbacks("once memory"),ce.Callbacks("once memory"),0,"resolved"],["reject","fail",ce.Callbacks("once memory"),ce.Callbacks("once memory"),1,"rejected"]],i="pending",a={state:function(){return i},always:function(){return s.done(arguments).fail(arguments),this},"catch":function(e){return a.then(null,e)},pipe:function(){var i=arguments;return ce.Deferred(function(r){ce.each(o,function(e,t){var n=v(i[t[4]])&&i[t[4]];s[t[1]](function(){var e=n&&n.apply(this,arguments);e&&v(e.promise)?e.promise().progress(r.notify).done(r.resolve).fail(r.reject):r[t[0]+"With"](this,n?[e]:arguments)})}),i=null}).promise()},then:function(t,n,r){var u=0;function l(i,o,a,s){return function(){var n=this,r=arguments,e=function(){var e,t;if(!(i<u)){if((e=a.apply(n,r))===o.promise())throw new TypeError("Thenable self-resolution");t=e&&("object"==typeof e||"function"==typeof e)&&e.then,v(t)?s?t.call(e,l(u,o,N,s),l(u,o,q,s)):(u++,t.call(e,l(u,o,N,s),l(u,o,q,s),l(u,o,N,o.notifyWith))):(a!==N&&(n=void 0,r=[e]),(s||o.resolveWith)(n,r))}},t=s?e:function(){try{e()}catch(e){ce.Deferred.exceptionHook&&ce.Deferred.exceptionHook(e,t.error),u<=i+1&&(a!==q&&(n=void 0,r=[e]),o.rejectWith(n,r))}};i?t():(ce.Deferred.getErrorHook?t.error=ce.Deferred.getErrorHook():ce.Deferred.getStackHook&&(t.error=ce.Deferred.getStackHook()),ie.setTimeout(t))}}return ce.Deferred(function(e){o[0][3].add(l(0,e,v(r)?r:N,e.notifyWith)),o[1][3].add(l(0,e,v(t)?t:N)),o[2][3].add(l(0,e,v(n)?n:q))}).promise()},promise:function(e){return null!=e?ce.extend(e,a):a}},s={};return ce.each(o,function(e,t){var n=t[2],r=t[5];a[t[1]]=n.add,r&&n.add(function(){i=r},o[3-e][2].disable,o[3-e][3].disable,o[0][2].lock,o[0][3].lock),n.add(t[3].fire),s[t[0]]=function(){return s[t[0]+"With"](this===s?void 0:this,arguments),this},s[t[0]+"With"]=n.fireWith}),a.promise(s),e&&e.call(s,s),s},when:function(e){var n=arguments.length,t=n,r=Array(t),i=ae.call(arguments),o=ce.Deferred(),a=function(t){return function(e){r[t]=this,i[t]=1<arguments.length?ae.call(arguments):e,--n||o.resolveWith(r,i)}};if(n<=1&&(L(e,o.done(a(t)).resolve,o.reject,!n),"pending"===o.state()||v(i[t]&&i[t].then)))return o.then();while(t--)L(i[t],a(t),o.reject);return o.promise()}});var H=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;ce.Deferred.exceptionHook=function(e,t){ie.console&&ie.console.warn&&e&&H.test(e.name)&&ie.console.warn("jQuery.Deferred exception: "+e.message,e.stack,t)},ce.readyException=function(e){ie.setTimeout(function(){throw e})};var O=ce.Deferred();function P(){C.removeEventListener("DOMContentLoaded",P),ie.removeEventListener("load",P),ce.ready()}ce.fn.ready=function(e){return O.then(e)["catch"](function(e){ce.readyException(e)}),this},ce.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--ce.readyWait:ce.isReady)||(ce.isReady=!0)!==e&&0<--ce.readyWait||O.resolveWith(C,[ce])}}),ce.ready.then=O.then,"complete"===C.readyState||"loading"!==C.readyState&&!C.documentElement.doScroll?ie.setTimeout(ce.ready):(C.addEventListener("DOMContentLoaded",P),ie.addEventListener("load",P));var M=function(e,t,n,r,i,o,a){var s=0,u=e.length,l=null==n;if("object"===x(n))for(s in i=!0,n)M(e,t,s,n[s],!0,o,a);else if(void 0!==r&&(i=!0,v(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(ce(e),n)})),t))for(;s<u;s++)t(e[s],n,a?r:r.call(e[s],s,t(e[s],n)));return i?e:l?t.call(e):u?t(e[0],n):o},R=/^-ms-/,I=/-([a-z])/g;function W(e,t){return t.toUpperCase()}function F(e){return e.replace(R,"ms-").replace(I,W)}var $=function(e){return 1===e.nodeType||9===e.nodeType||!+e.nodeType};function B(){this.expando=ce.expando+B.uid++}B.uid=1,B.prototype={cache:function(e){var t=e[this.expando];return t||(t={},$(e)&&(e.nodeType?e[this.expando]=t:Object.defineProperty(e,this.expando,{value:t,configurable:!0}))),t},set:function(e,t,n){var r,i=this.cache(e);if("string"==typeof t)i[F(t)]=n;else for(r in t)i[F(r)]=t[r];return i},get:function(e,t){return void 0===t?this.cache(e):e[this.expando]&&e[this.expando][F(t)]},access:function(e,t,n){return void 0===t||t&&"string"==typeof t&&void 0===n?this.get(e,t):(this.set(e,t,n),void 0!==n?n:t)},remove:function(e,t){var n,r=e[this.expando];if(void 0!==r){if(void 0!==t){n=(t=Array.isArray(t)?t.map(F):(t=F(t))in r?[t]:t.match(D)||[]).length;while(n--)delete r[t[n]]}(void 0===t||ce.isEmptyObject(r))&&(e.nodeType?e[this.expando]=void 0:delete e[this.expando])}},hasData:function(e){var t=e[this.expando];return void 0!==t&&!ce.isEmptyObject(t)}};var _=new B,z=new B,X=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,U=/[A-Z]/g;function V(e,t,n){var r,i;if(void 0===n&&1===e.nodeType)if(r="data-"+t.replace(U,"-$&").toLowerCase(),"string"==typeof(n=e.getAttribute(r))){try{n="true"===(i=n)||"false"!==i&&("null"===i?null:i===+i+""?+i:X.test(i)?JSON.parse(i):i)}catch(e){}z.set(e,t,n)}else n=void 0;return n}ce.extend({hasData:function(e){return z.hasData(e)||_.hasData(e)},data:function(e,t,n){return z.access(e,t,n)},removeData:function(e,t){z.remove(e,t)},_data:function(e,t,n){return _.access(e,t,n)},_removeData:function(e,t){_.remove(e,t)}}),ce.fn.extend({data:function(n,e){var t,r,i,o=this[0],a=o&&o.attributes;if(void 0===n){if(this.length&&(i=z.get(o),1===o.nodeType&&!_.get(o,"hasDataAttrs"))){t=a.length;while(t--)a[t]&&0===(r=a[t].name).indexOf("data-")&&(r=F(r.slice(5)),V(o,r,i[r]));_.set(o,"hasDataAttrs",!0)}return i}return"object"==typeof n?this.each(function(){z.set(this,n)}):M(this,function(e){var t;if(o&&void 0===e)return void 0!==(t=z.get(o,n))?t:void 0!==(t=V(o,n))?t:void 0;this.each(function(){z.set(this,n,e)})},null,e,1<arguments.length,null,!0)},removeData:function(e){return this.each(function(){z.remove(this,e)})}}),ce.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=_.get(e,t),n&&(!r||Array.isArray(n)?r=_.access(e,t,ce.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=ce.queue(e,t),r=n.length,i=n.shift(),o=ce._queueHooks(e,t);"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,function(){ce.dequeue(e,t)},o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return _.get(e,n)||_.access(e,n,{empty:ce.Callbacks("once memory").add(function(){_.remove(e,[t+"queue",n])})})}}),ce.fn.extend({queue:function(t,n){var e=2;return"string"!=typeof t&&(n=t,t="fx",e--),arguments.length<e?ce.queue(this[0],t):void 0===n?this:this.each(function(){var e=ce.queue(this,t,n);ce._queueHooks(this,t),"fx"===t&&"inprogress"!==e[0]&&ce.dequeue(this,t)})},dequeue:function(e){return this.each(function(){ce.dequeue(this,e)})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,t){var n,r=1,i=ce.Deferred(),o=this,a=this.length,s=function(){--r||i.resolveWith(o,[o])};"string"!=typeof e&&(t=e,e=void 0),e=e||"fx";while(a--)(n=_.get(o[a],e+"queueHooks"))&&n.empty&&(r++,n.empty.add(s));return s(),i.promise(t)}});var G=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,Y=new RegExp("^(?:([+-])=|)("+G+")([a-z%]*)$","i"),Q=["Top","Right","Bottom","Left"],J=C.documentElement,K=function(e){return ce.contains(e.ownerDocument,e)},Z={composed:!0};J.getRootNode&&(K=function(e){return ce.contains(e.ownerDocument,e)||e.getRootNode(Z)===e.ownerDocument});var ee=function(e,t){return"none"===(e=t||e).style.display||""===e.style.display&&K(e)&&"none"===ce.css(e,"display")};function te(e,t,n,r){var i,o,a=20,s=r?function(){return r.cur()}:function(){return ce.css(e,t,"")},u=s(),l=n&&n[3]||(ce.cssNumber[t]?"":"px"),c=e.nodeType&&(ce.cssNumber[t]||"px"!==l&&+u)&&Y.exec(ce.css(e,t));if(c&&c[3]!==l){u/=2,l=l||c[3],c=+u||1;while(a--)ce.style(e,t,c+l),(1-o)*(1-(o=s()/u||.5))<=0&&(a=0),c/=o;c*=2,ce.style(e,t,c+l),n=n||[]}return n&&(c=+c||+u||0,i=n[1]?c+(n[1]+1)*n[2]:+n[2],r&&(r.unit=l,r.start=c,r.end=i)),i}var ne={};function re(e,t){for(var n,r,i,o,a,s,u,l=[],c=0,f=e.length;c<f;c++)(r=e[c]).style&&(n=r.style.display,t?("none"===n&&(l[c]=_.get(r,"display")||null,l[c]||(r.style.display="")),""===r.style.display&&ee(r)&&(l[c]=(u=a=o=void 0,a=(i=r).ownerDocument,s=i.nodeName,(u=ne[s])||(o=a.body.appendChild(a.createElement(s)),u=ce.css(o,"display"),o.parentNode.removeChild(o),"none"===u&&(u="block"),ne[s]=u)))):"none"!==n&&(l[c]="none",_.set(r,"display",n)));for(c=0;c<f;c++)null!=l[c]&&(e[c].style.display=l[c]);return e}ce.fn.extend({show:function(){return re(this,!0)},hide:function(){return re(this)},toggle:function(e){return"boolean"==typeof e?e?this.show():this.hide():this.each(function(){ee(this)?ce(this).show():ce(this).hide()})}});var xe,be,we=/^(?:checkbox|radio)$/i,Te=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="<textarea>x</textarea>",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="<option></option>",le.option=!!xe.lastChild;var ke={thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n<r;n++)_.set(e[n],"globalEval",!t||_.get(t[n],"globalEval"))}ke.tbody=ke.tfoot=ke.colgroup=ke.caption=ke.thead,ke.th=ke.td,le.option||(ke.optgroup=ke.option=[1,"<select multiple='multiple'>","</select>"]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d<h;d++)if((o=e[d])||0===o)if("object"===x(o))ce.merge(p,o.nodeType?[o]:o);else if(je.test(o)){a=a||f.appendChild(t.createElement("div")),s=(Te.exec(o)||["",""])[1].toLowerCase(),u=ke[s]||ke._default,a.innerHTML=u[1]+ce.htmlPrefilter(o)+u[2],c=u[0];while(c--)a=a.lastChild;ce.merge(p,a.childNodes),(a=f.firstChild).textContent=""}else p.push(t.createTextNode(o));f.textContent="",d=0;while(o=p[d++])if(r&&-1<ce.inArray(o,r))i&&i.push(o);else if(l=K(o),a=Se(f.appendChild(o),"script"),l&&Ee(a),n){c=0;while(o=a[c++])Ce.test(o.type||"")&&n.push(o)}return f}var De=/^([^.]*)(?:\.(.+)|)/;function Ne(){return!0}function qe(){return!1}function Le(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Le(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=qe;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return ce().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=ce.guid++)),e.each(function(){ce.event.add(this,t,i,r,n)})}function He(e,r,t){t?(_.set(e,r,!1),ce.event.add(e,r,{namespace:!1,handler:function(e){var t,n=_.get(this,r);if(1&e.isTrigger&&this[r]){if(n)(ce.event.special[r]||{}).delegateType&&e.stopPropagation();else if(n=ae.call(arguments),_.set(this,r,n),this[r](),t=_.get(this,r),_.set(this,r,!1),n!==t)return e.stopImmediatePropagation(),e.preventDefault(),t}else n&&(_.set(this,r,ce.event.trigger(n[0],n.slice(1),this)),e.stopPropagation(),e.isImmediatePropagationStopped=Ne)}})):void 0===_.get(e,r)&&ce.event.add(e,r,Ne)}ce.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=_.get(t);if($(t)){n.handler&&(n=(o=n).handler,i=o.selector),i&&ce.find.matchesSelector(J,i),n.guid||(n.guid=ce.guid++),(u=v.events)||(u=v.events=Object.create(null)),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof ce&&ce.event.triggered!==e.type?ce.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(D)||[""]).length;while(l--)d=g=(s=De.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=ce.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=ce.event.special[d]||{},c=ce.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&ce.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),ce.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=_.hasData(e)&&_.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(D)||[""]).length;while(l--)if(d=g=(s=De.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=ce.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||ce.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)ce.event.remove(e,d+t[l],n,r,!0);ce.isEmptyObject(u)&&_.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=new Array(arguments.length),u=ce.event.fix(e),l=(_.get(this,"events")||Object.create(null))[u.type]||[],c=ce.event.special[u.type]||{};for(s[0]=u,t=1;t<arguments.length;t++)s[t]=arguments[t];if(u.delegateTarget=this,!c.preDispatch||!1!==c.preDispatch.call(this,u)){a=ce.event.handlers.call(this,u,l),t=0;while((i=a[t++])&&!u.isPropagationStopped()){u.currentTarget=i.elem,n=0;while((o=i.handlers[n++])&&!u.isImmediatePropagationStopped())u.rnamespace&&!1!==o.namespace&&!u.rnamespace.test(o.namespace)||(u.handleObj=o,u.data=o.data,void 0!==(r=((ce.event.special[o.origType]||{}).handle||o.handler).apply(i.elem,s))&&!1===(u.result=r)&&(u.preventDefault(),u.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,u),u.result}},handlers:function(e,t){var n,r,i,o,a,s=[],u=t.delegateCount,l=e.target;if(u&&l.nodeType&&!("click"===e.type&&1<=e.button))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n<u;n++)void 0===a[i=(r=t[n]).selector+" "]&&(a[i]=r.needsContext?-1<ce(i,this).index(l):ce.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&s.push({elem:l,handlers:o})}return l=this,u<t.length&&s.push({elem:l,handlers:t.slice(u)}),s},addProp:function(t,e){Object.defineProperty(ce.Event.prototype,t,{enumerable:!0,configurable:!0,get:v(e)?function(){if(this.originalEvent)return e(this.originalEvent)}:function(){if(this.originalEvent)return this.originalEvent[t]},set:function(e){Object.defineProperty(this,t,{enumerable:!0,configurable:!0,writable:!0,value:e})}})},fix:function(e){return e[ce.expando]?e:new ce.Event(e)},special:{load:{noBubble:!0},click:{setup:function(e){var t=this||e;return we.test(t.type)&&t.click&&fe(t,"input")&&He(t,"click",!0),!1},trigger:function(e){var t=this||e;return we.test(t.type)&&t.click&&fe(t,"input")&&He(t,"click"),!0},_default:function(e){var t=e.target;return we.test(t.type)&&t.click&&fe(t,"input")&&_.get(t,"click")||fe(t,"a")}},beforeunload:{postDispatch:function(e){void 0!==e.result&&e.originalEvent&&(e.originalEvent.returnValue=e.result)}}}},ce.removeEvent=function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n)},ce.Event=function(e,t){if(!(this instanceof ce.Event))return new ce.Event(e,t);e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||void 0===e.defaultPrevented&&!1===e.returnValue?Ne:qe,this.target=e.target&&3===e.target.nodeType?e.target.parentNode:e.target,this.currentTarget=e.currentTarget,this.relatedTarget=e.relatedTarget):this.type=e,t&&ce.extend(this,t),this.timeStamp=e&&e.timeStamp||Date.now(),this[ce.expando]=!0},ce.Event.prototype={constructor:ce.Event,isDefaultPrevented:qe,isPropagationStopped:qe,isImmediatePropagationStopped:qe,isSimulated:!1,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=Ne,e&&!this.isSimulated&&e.preventDefault()},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=Ne,e&&!this.isSimulated&&e.stopPropagation()},stopImmediatePropagation:function(){var e=this.originalEvent;this.isImmediatePropagationStopped=Ne,e&&!this.isSimulated&&e.stopImmediatePropagation(),this.stopPropagation()}},ce.each({altKey:!0,bubbles:!0,cancelable:!0,changedTouches:!0,ctrlKey:!0,detail:!0,eventPhase:!0,metaKey:!0,pageX:!0,pageY:!0,shiftKey:!0,view:!0,"char":!0,code:!0,charCode:!0,key:!0,keyCode:!0,button:!0,buttons:!0,clientX:!0,clientY:!0,offsetX:!0,offsetY:!0,pointerId:!0,pointerType:!0,screenX:!0,screenY:!0,targetTouches:!0,toElement:!0,touches:!0,which:!0},ce.event.addProp),ce.each({focus:"focusin",blur:"focusout"},function(r,i){function o(e){if(C.documentMode){var t=_.get(this,"handle"),n=ce.event.fix(e);n.type="focusin"===e.type?"focus":"blur",n.isSimulated=!0,t(e),n.target===n.currentTarget&&t(n)}else ce.event.simulate(i,e.target,ce.event.fix(e))}ce.event.special[r]={setup:function(){var e;if(He(this,r,!0),!C.documentMode)return!1;(e=_.get(this,i))||this.addEventListener(i,o),_.set(this,i,(e||0)+1)},trigger:function(){return He(this,r),!0},teardown:function(){var e;if(!C.documentMode)return!1;(e=_.get(this,i)-1)?_.set(this,i,e):(this.removeEventListener(i,o),_.remove(this,i))},_default:function(e){return _.get(e.target,r)},delegateType:i},ce.event.special[i]={setup:function(){var e=this.ownerDocument||this.document||this,t=C.documentMode?this:e,n=_.get(t,i);n||(C.documentMode?this.addEventListener(i,o):e.addEventListener(r,o,!0)),_.set(t,i,(n||0)+1)},teardown:function(){var e=this.ownerDocument||this.document||this,t=C.documentMode?this:e,n=_.get(t,i)-1;n?_.set(t,i,n):(C.documentMode?this.removeEventListener(i,o):e.removeEventListener(r,o,!0),_.remove(t,i))}}}),ce.each({mouseenter:"mouseover",mouseleave:"mouseout",pointerenter:"pointerover",pointerleave:"pointerout"},function(e,i){ce.event.special[e]={delegateType:i,bindType:i,handle:function(e){var t,n=e.relatedTarget,r=e.handleObj;return n&&(n===this||ce.contains(this,n))||(e.type=r.origType,t=r.handler.apply(this,arguments),e.type=i),t}}}),ce.fn.extend({on:function(e,t,n,r){return Le(this,e,t,n,r)},one:function(e,t,n,r){return Le(this,e,t,n,r,1)},off:function(e,t,n){var r,i;if(e&&e.preventDefault&&e.handleObj)return r=e.handleObj,ce(e.delegateTarget).off(r.namespace?r.origType+"."+r.namespace:r.origType,r.selector,r.handler),this;if("object"==typeof e){for(i in e)this.off(i,t,e[i]);return this}return!1!==t&&"function"!=typeof t||(n=t,t=void 0),!1===n&&(n=qe),this.each(function(){ce.event.remove(this,e,n,t)})}});var Oe=/<script|<style|<link/i,Pe=/checked\s*(?:[^=]|=\s*.checked.)/i,Me=/^\s*<!\[CDATA\[|\]\]>\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n<r;n++)ce.event.add(t,i,s[i][n]);z.hasData(e)&&(o=z.access(e),a=ce.extend({},o),z.set(t,a))}}function $e(n,r,i,o){r=g(r);var e,t,a,s,u,l,c=0,f=n.length,p=f-1,d=r[0],h=v(d);if(h||1<f&&"string"==typeof d&&!le.checkClone&&Pe.test(d))return n.each(function(e){var t=n.eq(e);h&&(r[0]=d.call(this,e,t.html())),$e(t,r,i,o)});if(f&&(t=(e=Ae(r,n[0].ownerDocument,!1,n,o)).firstChild,1===e.childNodes.length&&(e=t),t||o)){for(s=(a=ce.map(Se(e,"script"),Ie)).length;c<f;c++)u=e,c!==p&&(u=ce.clone(u,!0,!0),s&&ce.merge(a,Se(u,"script"))),i.call(n[c],u,c);if(s)for(l=a[a.length-1].ownerDocument,ce.map(a,We),c=0;c<s;c++)u=a[c],Ce.test(u.type||"")&&!_.access(u,"globalEval")&&ce.contains(l,u)&&(u.src&&"module"!==(u.type||"").toLowerCase()?ce._evalUrl&&!u.noModule&&ce._evalUrl(u.src,{nonce:u.nonce||u.getAttribute("nonce")},l):m(u.textContent.replace(Me,""),u,l))}return n}function Be(e,t,n){for(var r,i=t?ce.filter(t,e):e,o=0;null!=(r=i[o]);o++)n||1!==r.nodeType||ce.cleanData(Se(r)),r.parentNode&&(n&&K(r)&&Ee(Se(r,"script")),r.parentNode.removeChild(r));return e}ce.extend({htmlPrefilter:function(e){return e},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=K(e);if(!(le.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||ce.isXMLDoc(e)))for(a=Se(c),r=0,i=(o=Se(e)).length;r<i;r++)s=o[r],u=a[r],void 0,"input"===(l=u.nodeName.toLowerCase())&&we.test(s.type)?u.checked=s.checked:"input"!==l&&"textarea"!==l||(u.defaultValue=s.defaultValue);if(t)if(n)for(o=o||Se(e),a=a||Se(c),r=0,i=o.length;r<i;r++)Fe(o[r],a[r]);else Fe(e,c);return 0<(a=Se(c,"script")).length&&Ee(a,!f&&Se(e,"script")),c},cleanData:function(e){for(var t,n,r,i=ce.event.special,o=0;void 0!==(n=e[o]);o++)if($(n)){if(t=n[_.expando]){if(t.events)for(r in t.events)i[r]?ce.event.remove(n,r):ce.removeEvent(n,r,t.handle);n[_.expando]=void 0}n[z.expando]&&(n[z.expando]=void 0)}}}),ce.fn.extend({detach:function(e){return Be(this,e,!0)},remove:function(e){return Be(this,e)},text:function(e){return M(this,function(e){return void 0===e?ce.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return $e(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Re(this,e).appendChild(e)})},prepend:function(){return $e(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Re(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return $e(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return $e(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(ce.cleanData(Se(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return ce.clone(this,e,t)})},html:function(e){return M(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Oe.test(e)&&!ke[(Te.exec(e)||["",""])[1].toLowerCase()]){e=ce.htmlPrefilter(e);try{for(;n<r;n++)1===(t=this[n]||{}).nodeType&&(ce.cleanData(Se(t,!1)),t.innerHTML=e);t=0}catch(e){}}t&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var n=[];return $e(this,arguments,function(e){var t=this.parentNode;ce.inArray(this,n)<0&&(ce.cleanData(Se(this)),t&&t.replaceChild(e,this))},n)}}),ce.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,a){ce.fn[e]=function(e){for(var t,n=[],r=ce(e),i=r.length-1,o=0;o<=i;o++)t=o===i?this:this.clone(!0),ce(r[o])[a](t),s.apply(n,t.get());return this.pushStack(n)}});var _e=new RegExp("^("+G+")(?!px)[a-z%]+$","i"),ze=/^--/,Xe=function(e){var t=e.ownerDocument.defaultView;return t&&t.opener||(t=ie),t.getComputedStyle(e)},Ue=function(e,t,n){var r,i,o={};for(i in t)o[i]=e.style[i],e.style[i]=t[i];for(i in r=n.call(e),t)e.style[i]=o[i];return r},Ve=new RegExp(Q.join("|"),"i");function Ge(e,t,n){var r,i,o,a,s=ze.test(t),u=e.style;return(n=n||Xe(e))&&(a=n.getPropertyValue(t)||n[t],s&&a&&(a=a.replace(ve,"$1")||void 0),""!==a||K(e)||(a=ce.style(e,t)),!le.pixelBoxStyles()&&_e.test(a)&&Ve.test(t)&&(r=u.width,i=u.minWidth,o=u.maxWidth,u.minWidth=u.maxWidth=u.width=a,a=n.width,u.width=r,u.minWidth=i,u.maxWidth=o)),void 0!==a?a+"":a}function Ye(e,t){return{get:function(){if(!e())return(this.get=t).apply(this,arguments);delete this.get}}}!function(){function e(){if(l){u.style.cssText="position:absolute;left:-11111px;width:60px;margin-top:1px;padding:0;border:0",l.style.cssText="position:relative;display:block;box-sizing:border-box;overflow:scroll;margin:auto;border:1px;padding:1px;width:60%;top:1%",J.appendChild(u).appendChild(l);var e=ie.getComputedStyle(l);n="1%"!==e.top,s=12===t(e.marginLeft),l.style.right="60%",o=36===t(e.right),r=36===t(e.width),l.style.position="absolute",i=12===t(l.offsetWidth/3),J.removeChild(u),l=null}}function t(e){return Math.round(parseFloat(e))}var n,r,i,o,a,s,u=C.createElement("div"),l=C.createElement("div");l.style&&(l.style.backgroundClip="content-box",l.cloneNode(!0).style.backgroundClip="",le.clearCloneStyle="content-box"===l.style.backgroundClip,ce.extend(le,{boxSizingReliable:function(){return e(),r},pixelBoxStyles:function(){return e(),o},pixelPosition:function(){return e(),n},reliableMarginLeft:function(){return e(),s},scrollboxSize:function(){return e(),i},reliableTrDimensions:function(){var e,t,n,r;return null==a&&(e=C.createElement("table"),t=C.createElement("tr"),n=C.createElement("div"),e.style.cssText="position:absolute;left:-11111px;border-collapse:separate",t.style.cssText="box-sizing:content-box;border:1px solid",t.style.height="1px",n.style.height="9px",n.style.display="block",J.appendChild(e).appendChild(t).appendChild(n),r=ie.getComputedStyle(t),a=parseInt(r.height,10)+parseInt(r.borderTopWidth,10)+parseInt(r.borderBottomWidth,10)===t.offsetHeight,J.removeChild(e)),a}}))}();var Qe=["Webkit","Moz","ms"],Je=C.createElement("div").style,Ke={};function Ze(e){var t=ce.cssProps[e]||Ke[e];return t||(e in Je?e:Ke[e]=function(e){var t=e[0].toUpperCase()+e.slice(1),n=Qe.length;while(n--)if((e=Qe[n]+t)in Je)return e}(e)||e)}var et=/^(none|table(?!-c[ea]).+)/,tt={position:"absolute",visibility:"hidden",display:"block"},nt={letterSpacing:"0",fontWeight:"400"};function rt(e,t,n){var r=Y.exec(t);return r?Math.max(0,r[2]-(n||0))+(r[3]||"px"):t}function it(e,t,n,r,i,o){var a="width"===t?1:0,s=0,u=0,l=0;if(n===(r?"border":"content"))return 0;for(;a<4;a+=2)"margin"===n&&(l+=ce.css(e,n+Q[a],!0,i)),r?("content"===n&&(u-=ce.css(e,"padding"+Q[a],!0,i)),"margin"!==n&&(u-=ce.css(e,"border"+Q[a]+"Width",!0,i))):(u+=ce.css(e,"padding"+Q[a],!0,i),"padding"!==n?u+=ce.css(e,"border"+Q[a]+"Width",!0,i):s+=ce.css(e,"border"+Q[a]+"Width",!0,i));return!r&&0<=o&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))||0),u+l}function ot(e,t,n){var r=Xe(e),i=(!le.boxSizingReliable()||n)&&"border-box"===ce.css(e,"boxSizing",!1,r),o=i,a=Ge(e,t,r),s="offset"+t[0].toUpperCase()+t.slice(1);if(_e.test(a)){if(!n)return a;a="auto"}return(!le.boxSizingReliable()&&i||!le.reliableTrDimensions()&&fe(e,"tr")||"auto"===a||!parseFloat(a)&&"inline"===ce.css(e,"display",!1,r))&&e.getClientRects().length&&(i="border-box"===ce.css(e,"boxSizing",!1,r),(o=s in e)&&(a=e[s])),(a=parseFloat(a)||0)+it(e,t,n||(i?"border":"content"),o,r,a)+"px"}function at(e,t,n,r,i){return new at.prototype.init(e,t,n,r,i)}ce.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Ge(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,aspectRatio:!0,borderImageSlice:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,scale:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeMiterlimit:!0,strokeOpacity:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=F(t),u=ze.test(t),l=e.style;if(u||(t=Ze(s)),a=ce.cssHooks[t]||ce.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"===(o=typeof n)&&(i=Y.exec(n))&&i[1]&&(n=te(e,t,i),o="number"),null!=n&&n==n&&("number"!==o||u||(n+=i&&i[3]||(ce.cssNumber[s]?"":"px")),le.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=F(t);return ze.test(t)||(t=Ze(s)),(a=ce.cssHooks[t]||ce.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=Ge(e,t,r)),"normal"===i&&t in nt&&(i=nt[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),ce.each(["height","width"],function(e,u){ce.cssHooks[u]={get:function(e,t,n){if(t)return!et.test(ce.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?ot(e,u,n):Ue(e,tt,function(){return ot(e,u,n)})},set:function(e,t,n){var r,i=Xe(e),o=!le.scrollboxSize()&&"absolute"===i.position,a=(o||n)&&"border-box"===ce.css(e,"boxSizing",!1,i),s=n?it(e,u,n,a,i):0;return a&&o&&(s-=Math.ceil(e["offset"+u[0].toUpperCase()+u.slice(1)]-parseFloat(i[u])-it(e,u,"border",!1,i)-.5)),s&&(r=Y.exec(t))&&"px"!==(r[3]||"px")&&(e.style[u]=t,t=ce.css(e,u)),rt(0,t,s)}}}),ce.cssHooks.marginLeft=Ye(le.reliableMarginLeft,function(e,t){if(t)return(parseFloat(Ge(e,"marginLeft"))||e.getBoundingClientRect().left-Ue(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),ce.each({margin:"",padding:"",border:"Width"},function(i,o){ce.cssHooks[i+o]={expand:function(e){for(var t=0,n={},r="string"==typeof e?e.split(" "):[e];t<4;t++)n[i+Q[t]+o]=r[t]||r[t-2]||r[0];return n}},"margin"!==i&&(ce.cssHooks[i+o].set=rt)}),ce.fn.extend({css:function(e,t){return M(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=Xe(e),i=t.length;a<i;a++)o[t[a]]=ce.css(e,t[a],!1,r);return o}return void 0!==n?ce.style(e,t,n):ce.css(e,t)},e,t,1<arguments.length)}}),((ce.Tween=at).prototype={constructor:at,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||ce.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(ce.cssNumber[n]?"":"px")},cur:function(){var e=at.propHooks[this.prop];return e&&e.get?e.get(this):at.propHooks._default.get(this)},run:function(e){var t,n=at.propHooks[this.prop];return this.options.duration?this.pos=t=ce.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):at.propHooks._default.set(this),this}}).init.prototype=at.prototype,(at.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=ce.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){ce.fx.step[e.prop]?ce.fx.step[e.prop](e):1!==e.elem.nodeType||!ce.cssHooks[e.prop]&&null==e.elem.style[Ze(e.prop)]?e.elem[e.prop]=e.now:ce.style(e.elem,e.prop,e.now+e.unit)}}}).scrollTop=at.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},ce.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},ce.fx=at.prototype.init,ce.fx.step={};var st,ut,lt,ct,ft=/^(?:toggle|show|hide)$/,pt=/queueHooks$/;function dt(){ut&&(!1===C.hidden&&ie.requestAnimationFrame?ie.requestAnimationFrame(dt):ie.setTimeout(dt,ce.fx.interval),ce.fx.tick())}function ht(){return ie.setTimeout(function(){st=void 0}),st=Date.now()}function gt(e,t){var n,r=0,i={height:e};for(t=t?1:0;r<4;r+=2-t)i["margin"+(n=Q[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function vt(e,t,n){for(var r,i=(yt.tweeners[t]||[]).concat(yt.tweeners["*"]),o=0,a=i.length;o<a;o++)if(r=i[o].call(n,t,e))return r}function yt(o,e,t){var n,a,r=0,i=yt.prefilters.length,s=ce.Deferred().always(function(){delete u.elem}),u=function(){if(a)return!1;for(var e=st||ht(),t=Math.max(0,l.startTime+l.duration-e),n=1-(t/l.duration||0),r=0,i=l.tweens.length;r<i;r++)l.tweens[r].run(n);return s.notifyWith(o,[l,n,t]),n<1&&i?t:(i||s.notifyWith(o,[l,1,0]),s.resolveWith(o,[l]),!1)},l=s.promise({elem:o,props:ce.extend({},e),opts:ce.extend(!0,{specialEasing:{},easing:ce.easing._default},t),originalProperties:e,originalOptions:t,startTime:st||ht(),duration:t.duration,tweens:[],createTween:function(e,t){var n=ce.Tween(o,l.opts,e,t,l.opts.specialEasing[e]||l.opts.easing);return l.tweens.push(n),n},stop:function(e){var t=0,n=e?l.tweens.length:0;if(a)return this;for(a=!0;t<n;t++)l.tweens[t].run(1);return e?(s.notifyWith(o,[l,1,0]),s.resolveWith(o,[l,e])):s.rejectWith(o,[l,e]),this}}),c=l.props;for(!function(e,t){var n,r,i,o,a;for(n in e)if(i=t[r=F(n)],o=e[n],Array.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),(a=ce.cssHooks[r])&&"expand"in a)for(n in o=a.expand(o),delete e[r],o)n in e||(e[n]=o[n],t[n]=i);else t[r]=i}(c,l.opts.specialEasing);r<i;r++)if(n=yt.prefilters[r].call(l,o,c,l.opts))return v(n.stop)&&(ce._queueHooks(l.elem,l.opts.queue).stop=n.stop.bind(n)),n;return ce.map(c,vt,l),v(l.opts.start)&&l.opts.start.call(o,l),l.progress(l.opts.progress).done(l.opts.done,l.opts.complete).fail(l.opts.fail).always(l.opts.always),ce.fx.timer(ce.extend(u,{elem:o,anim:l,queue:l.opts.queue})),l}ce.Animation=ce.extend(yt,{tweeners:{"*":[function(e,t){var n=this.createTween(e,t);return te(n.elem,e,Y.exec(t),n),n}]},tweener:function(e,t){v(e)?(t=e,e=["*"]):e=e.match(D);for(var n,r=0,i=e.length;r<i;r++)n=e[r],yt.tweeners[n]=yt.tweeners[n]||[],yt.tweeners[n].unshift(t)},prefilters:[function(e,t,n){var r,i,o,a,s,u,l,c,f="width"in t||"height"in t,p=this,d={},h=e.style,g=e.nodeType&&ee(e),v=_.get(e,"fxshow");for(r in n.queue||(null==(a=ce._queueHooks(e,"fx")).unqueued&&(a.unqueued=0,s=a.empty.fire,a.empty.fire=function(){a.unqueued||s()}),a.unqueued++,p.always(function(){p.always(function(){a.unqueued--,ce.queue(e,"fx").length||a.empty.fire()})})),t)if(i=t[r],ft.test(i)){if(delete t[r],o=o||"toggle"===i,i===(g?"hide":"show")){if("show"!==i||!v||void 0===v[r])continue;g=!0}d[r]=v&&v[r]||ce.style(e,r)}if((u=!ce.isEmptyObject(t))||!ce.isEmptyObject(d))for(r in f&&1===e.nodeType&&(n.overflow=[h.overflow,h.overflowX,h.overflowY],null==(l=v&&v.display)&&(l=_.get(e,"display")),"none"===(c=ce.css(e,"display"))&&(l?c=l:(re([e],!0),l=e.style.display||l,c=ce.css(e,"display"),re([e]))),("inline"===c||"inline-block"===c&&null!=l)&&"none"===ce.css(e,"float")&&(u||(p.done(function(){h.display=l}),null==l&&(c=h.display,l="none"===c?"":c)),h.display="inline-block")),n.overflow&&(h.overflow="hidden",p.always(function(){h.overflow=n.overflow[0],h.overflowX=n.overflow[1],h.overflowY=n.overflow[2]})),u=!1,d)u||(v?"hidden"in v&&(g=v.hidden):v=_.access(e,"fxshow",{display:l}),o&&(v.hidden=!g),g&&re([e],!0),p.done(function(){for(r in g||re([e]),_.remove(e,"fxshow"),d)ce.style(e,r,d[r])})),u=vt(g?v[r]:0,r,p),r in v||(v[r]=u.start,g&&(u.end=u.start,u.start=0))}],prefilter:function(e,t){t?yt.prefilters.unshift(e):yt.prefilters.push(e)}}),ce.speed=function(e,t,n){var r=e&&"object"==typeof e?ce.extend({},e):{complete:n||!n&&t||v(e)&&e,duration:e,easing:n&&t||t&&!v(t)&&t};return ce.fx.off?r.duration=0:"number"!=typeof r.duration&&(r.duration in ce.fx.speeds?r.duration=ce.fx.speeds[r.duration]:r.duration=ce.fx.speeds._default),null!=r.queue&&!0!==r.queue||(r.queue="fx"),r.old=r.complete,r.complete=function(){v(r.old)&&r.old.call(this),r.queue&&ce.dequeue(this,r.queue)},r},ce.fn.extend({fadeTo:function(e,t,n,r){return this.filter(ee).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(t,e,n,r){var i=ce.isEmptyObject(t),o=ce.speed(e,n,r),a=function(){var e=yt(this,ce.extend({},t),o);(i||_.get(this,"finish"))&&e.stop(!0)};return a.finish=a,i||!1===o.queue?this.each(a):this.queue(o.queue,a)},stop:function(i,e,o){var a=function(e){var t=e.stop;delete e.stop,t(o)};return"string"!=typeof i&&(o=e,e=i,i=void 0),e&&this.queue(i||"fx",[]),this.each(function(){var e=!0,t=null!=i&&i+"queueHooks",n=ce.timers,r=_.get(this);if(t)r[t]&&r[t].stop&&a(r[t]);else for(t in r)r[t]&&r[t].stop&&pt.test(t)&&a(r[t]);for(t=n.length;t--;)n[t].elem!==this||null!=i&&n[t].queue!==i||(n[t].anim.stop(o),e=!1,n.splice(t,1));!e&&o||ce.dequeue(this,i)})},finish:function(a){return!1!==a&&(a=a||"fx"),this.each(function(){var e,t=_.get(this),n=t[a+"queue"],r=t[a+"queueHooks"],i=ce.timers,o=n?n.length:0;for(t.finish=!0,ce.queue(this,a,[]),r&&r.stop&&r.stop.call(this,!0),e=i.length;e--;)i[e].elem===this&&i[e].queue===a&&(i[e].anim.stop(!0),i.splice(e,1));for(e=0;e<o;e++)n[e]&&n[e].finish&&n[e].finish.call(this);delete t.finish})}}),ce.each(["toggle","show","hide"],function(e,r){var i=ce.fn[r];ce.fn[r]=function(e,t,n){return null==e||"boolean"==typeof e?i.apply(this,arguments):this.animate(gt(r,!0),e,t,n)}}),ce.each({slideDown:gt("show"),slideUp:gt("hide"),slideToggle:gt("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,r){ce.fn[e]=function(e,t,n){return this.animate(r,e,t,n)}}),ce.timers=[],ce.fx.tick=function(){var e,t=0,n=ce.timers;for(st=Date.now();t<n.length;t++)(e=n[t])()||n[t]!==e||n.splice(t--,1);n.length||ce.fx.stop(),st=void 0},ce.fx.timer=function(e){ce.timers.push(e),ce.fx.start()},ce.fx.interval=13,ce.fx.start=function(){ut||(ut=!0,dt())},ce.fx.stop=function(){ut=null},ce.fx.speeds={slow:600,fast:200,_default:400},ce.fn.delay=function(r,e){return r=ce.fx&&ce.fx.speeds[r]||r,e=e||"fx",this.queue(e,function(e,t){var n=ie.setTimeout(e,r);t.stop=function(){ie.clearTimeout(n)}})},lt=C.createElement("input"),ct=C.createElement("select").appendChild(C.createElement("option")),lt.type="checkbox",le.checkOn=""!==lt.value,le.optSelected=ct.selected,(lt=C.createElement("input")).value="t",lt.type="radio",le.radioValue="t"===lt.value;var mt,xt=ce.expr.attrHandle;ce.fn.extend({attr:function(e,t){return M(this,ce.attr,e,t,1<arguments.length)},removeAttr:function(e){return this.each(function(){ce.removeAttr(this,e)})}}),ce.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?ce.prop(e,t,n):(1===o&&ce.isXMLDoc(e)||(i=ce.attrHooks[t.toLowerCase()]||(ce.expr.match.bool.test(t)?mt:void 0)),void 0!==n?null===n?void ce.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=ce.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!le.radioValue&&"radio"===t&&fe(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(D);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),mt={set:function(e,t,n){return!1===t?ce.removeAttr(e,n):e.setAttribute(n,n),n}},ce.each(ce.expr.match.bool.source.match(/\w+/g),function(e,t){var a=xt[t]||ce.find.attr;xt[t]=function(e,t,n){var r,i,o=t.toLowerCase();return n||(i=xt[o],xt[o]=r,r=null!=a(e,t,n)?o:null,xt[o]=i),r}});var bt=/^(?:input|select|textarea|button)$/i,wt=/^(?:a|area)$/i;function Tt(e){return(e.match(D)||[]).join(" ")}function Ct(e){return e.getAttribute&&e.getAttribute("class")||""}function kt(e){return Array.isArray(e)?e:"string"==typeof e&&e.match(D)||[]}ce.fn.extend({prop:function(e,t){return M(this,ce.prop,e,t,1<arguments.length)},removeProp:function(e){return this.each(function(){delete this[ce.propFix[e]||e]})}}),ce.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&ce.isXMLDoc(e)||(t=ce.propFix[t]||t,i=ce.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=ce.find.attr(e,"tabindex");return t?parseInt(t,10):bt.test(e.nodeName)||wt.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),le.optSelected||(ce.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),ce.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){ce.propFix[this.toLowerCase()]=this}),ce.fn.extend({addClass:function(t){var e,n,r,i,o,a;return v(t)?this.each(function(e){ce(this).addClass(t.call(this,e,Ct(this)))}):(e=kt(t)).length?this.each(function(){if(r=Ct(this),n=1===this.nodeType&&" "+Tt(r)+" "){for(o=0;o<e.length;o++)i=e[o],n.indexOf(" "+i+" ")<0&&(n+=i+" ");a=Tt(n),r!==a&&this.setAttribute("class",a)}}):this},removeClass:function(t){var e,n,r,i,o,a;return v(t)?this.each(function(e){ce(this).removeClass(t.call(this,e,Ct(this)))}):arguments.length?(e=kt(t)).length?this.each(function(){if(r=Ct(this),n=1===this.nodeType&&" "+Tt(r)+" "){for(o=0;o<e.length;o++){i=e[o];while(-1<n.indexOf(" "+i+" "))n=n.replace(" "+i+" "," ")}a=Tt(n),r!==a&&this.setAttribute("class",a)}}):this:this.attr("class","")},toggleClass:function(t,n){var e,r,i,o,a=typeof t,s="string"===a||Array.isArray(t);return v(t)?this.each(function(e){ce(this).toggleClass(t.call(this,e,Ct(this),n),n)}):"boolean"==typeof n&&s?n?this.addClass(t):this.removeClass(t):(e=kt(t),this.each(function(){if(s)for(o=ce(this),i=0;i<e.length;i++)r=e[i],o.hasClass(r)?o.removeClass(r):o.addClass(r);else void 0!==t&&"boolean"!==a||((r=Ct(this))&&_.set(this,"__className__",r),this.setAttribute&&this.setAttribute("class",r||!1===t?"":_.get(this,"__className__")||""))}))},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&-1<(" "+Tt(Ct(n))+" ").indexOf(t))return!0;return!1}});var St=/\r/g;ce.fn.extend({val:function(n){var r,e,i,t=this[0];return arguments.length?(i=v(n),this.each(function(e){var t;1===this.nodeType&&(null==(t=i?n.call(this,e,ce(this).val()):n)?t="":"number"==typeof t?t+="":Array.isArray(t)&&(t=ce.map(t,function(e){return null==e?"":e+""})),(r=ce.valHooks[this.type]||ce.valHooks[this.nodeName.toLowerCase()])&&"set"in r&&void 0!==r.set(this,t,"value")||(this.value=t))})):t?(r=ce.valHooks[t.type]||ce.valHooks[t.nodeName.toLowerCase()])&&"get"in r&&void 0!==(e=r.get(t,"value"))?e:"string"==typeof(e=t.value)?e.replace(St,""):null==e?"":e:void 0}}),ce.extend({valHooks:{option:{get:function(e){var t=ce.find.attr(e,"value");return null!=t?t:Tt(ce.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r<u;r++)if(((n=i[r]).selected||r===o)&&!n.disabled&&(!n.parentNode.disabled||!fe(n.parentNode,"optgroup"))){if(t=ce(n).val(),a)return t;s.push(t)}return s},set:function(e,t){var n,r,i=e.options,o=ce.makeArray(t),a=i.length;while(a--)((r=i[a]).selected=-1<ce.inArray(ce.valHooks.option.get(r),o))&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),ce.each(["radio","checkbox"],function(){ce.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=-1<ce.inArray(ce(e).val(),t)}},le.checkOn||(ce.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})});var Et=ie.location,jt={guid:Date.now()},At=/\?/;ce.parseXML=function(e){var t,n;if(!e||"string"!=typeof e)return null;try{t=(new ie.DOMParser).parseFromString(e,"text/xml")}catch(e){}return n=t&&t.getElementsByTagName("parsererror")[0],t&&!n||ce.error("Invalid XML: "+(n?ce.map(n.childNodes,function(e){return e.textContent}).join("\n"):e)),t};var Dt=/^(?:focusinfocus|focusoutblur)$/,Nt=function(e){e.stopPropagation()};ce.extend(ce.event,{trigger:function(e,t,n,r){var i,o,a,s,u,l,c,f,p=[n||C],d=ue.call(e,"type")?e.type:e,h=ue.call(e,"namespace")?e.namespace.split("."):[];if(o=f=a=n=n||C,3!==n.nodeType&&8!==n.nodeType&&!Dt.test(d+ce.event.triggered)&&(-1<d.indexOf(".")&&(d=(h=d.split(".")).shift(),h.sort()),u=d.indexOf(":")<0&&"on"+d,(e=e[ce.expando]?e:new ce.Event(d,"object"==typeof e&&e)).isTrigger=r?2:3,e.namespace=h.join("."),e.rnamespace=e.namespace?new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,e.result=void 0,e.target||(e.target=n),t=null==t?[e]:ce.makeArray(t,[e]),c=ce.event.special[d]||{},r||!c.trigger||!1!==c.trigger.apply(n,t))){if(!r&&!c.noBubble&&!y(n)){for(s=c.delegateType||d,Dt.test(s+d)||(o=o.parentNode);o;o=o.parentNode)p.push(o),a=o;a===(n.ownerDocument||C)&&p.push(a.defaultView||a.parentWindow||ie)}i=0;while((o=p[i++])&&!e.isPropagationStopped())f=o,e.type=1<i?s:c.bindType||d,(l=(_.get(o,"events")||Object.create(null))[e.type]&&_.get(o,"handle"))&&l.apply(o,t),(l=u&&o[u])&&l.apply&&$(o)&&(e.result=l.apply(o,t),!1===e.result&&e.preventDefault());return e.type=d,r||e.isDefaultPrevented()||c._default&&!1!==c._default.apply(p.pop(),t)||!$(n)||u&&v(n[d])&&!y(n)&&((a=n[u])&&(n[u]=null),ce.event.triggered=d,e.isPropagationStopped()&&f.addEventListener(d,Nt),n[d](),e.isPropagationStopped()&&f.removeEventListener(d,Nt),ce.event.triggered=void 0,a&&(n[u]=a)),e.result}},simulate:function(e,t,n){var r=ce.extend(new ce.Event,n,{type:e,isSimulated:!0});ce.event.trigger(r,null,t)}}),ce.fn.extend({trigger:function(e,t){return this.each(function(){ce.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return ce.event.trigger(e,t,n,!0)}});var qt=/\[\]$/,Lt=/\r?\n/g,Ht=/^(?:submit|button|image|reset|file)$/i,Ot=/^(?:input|select|textarea|keygen)/i;function Pt(n,e,r,i){var t;if(Array.isArray(e))ce.each(e,function(e,t){r||qt.test(n)?i(n,t):Pt(n+"["+("object"==typeof t&&null!=t?e:"")+"]",t,r,i)});else if(r||"object"!==x(e))i(n,e);else for(t in e)Pt(n+"["+t+"]",e[t],r,i)}ce.param=function(e,t){var n,r=[],i=function(e,t){var n=v(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(null==e)return"";if(Array.isArray(e)||e.jquery&&!ce.isPlainObject(e))ce.each(e,function(){i(this.name,this.value)});else for(n in e)Pt(n,e[n],t,i);return r.join("&")},ce.fn.extend({serialize:function(){return ce.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=ce.prop(this,"elements");return e?ce.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!ce(this).is(":disabled")&&Ot.test(this.nodeName)&&!Ht.test(e)&&(this.checked||!we.test(e))}).map(function(e,t){var n=ce(this).val();return null==n?null:Array.isArray(n)?ce.map(n,function(e){return{name:t.name,value:e.replace(Lt,"\r\n")}}):{name:t.name,value:n.replace(Lt,"\r\n")}}).get()}});var Mt=/%20/g,Rt=/#.*$/,It=/([?&])_=[^&]*/,Wt=/^(.*?):[ \t]*([^\r\n]*)$/gm,Ft=/^(?:GET|HEAD)$/,$t=/^\/\//,Bt={},_t={},zt="*/".concat("*"),Xt=C.createElement("a");function Ut(o){return function(e,t){"string"!=typeof e&&(t=e,e="*");var n,r=0,i=e.toLowerCase().match(D)||[];if(v(t))while(n=i[r++])"+"===n[0]?(n=n.slice(1)||"*",(o[n]=o[n]||[]).unshift(t)):(o[n]=o[n]||[]).push(t)}}function Vt(t,i,o,a){var s={},u=t===_t;function l(e){var r;return s[e]=!0,ce.each(t[e]||[],function(e,t){var n=t(i,o,a);return"string"!=typeof n||u||s[n]?u?!(r=n):void 0:(i.dataTypes.unshift(n),l(n),!1)}),r}return l(i.dataTypes[0])||!s["*"]&&l("*")}function Gt(e,t){var n,r,i=ce.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&ce.extend(!0,e,r),e}Xt.href=Et.href,ce.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Et.href,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(Et.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":zt,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":ce.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?Gt(Gt(e,ce.ajaxSettings),t):Gt(ce.ajaxSettings,e)},ajaxPrefilter:Ut(Bt),ajaxTransport:Ut(_t),ajax:function(e,t){"object"==typeof e&&(t=e,e=void 0),t=t||{};var c,f,p,n,d,r,h,g,i,o,v=ce.ajaxSetup({},t),y=v.context||v,m=v.context&&(y.nodeType||y.jquery)?ce(y):ce.event,x=ce.Deferred(),b=ce.Callbacks("once memory"),w=v.statusCode||{},a={},s={},u="canceled",T={readyState:0,getResponseHeader:function(e){var t;if(h){if(!n){n={};while(t=Wt.exec(p))n[t[1].toLowerCase()+" "]=(n[t[1].toLowerCase()+" "]||[]).concat(t[2])}t=n[e.toLowerCase()+" "]}return null==t?null:t.join(", ")},getAllResponseHeaders:function(){return h?p:null},setRequestHeader:function(e,t){return null==h&&(e=s[e.toLowerCase()]=s[e.toLowerCase()]||e,a[e]=t),this},overrideMimeType:function(e){return null==h&&(v.mimeType=e),this},statusCode:function(e){var t;if(e)if(h)T.always(e[T.status]);else for(t in e)w[t]=[w[t],e[t]];return this},abort:function(e){var t=e||u;return c&&c.abort(t),l(0,t),this}};if(x.promise(T),v.url=((e||v.url||Et.href)+"").replace($t,Et.protocol+"//"),v.type=t.method||t.type||v.method||v.type,v.dataTypes=(v.dataType||"*").toLowerCase().match(D)||[""],null==v.crossDomain){r=C.createElement("a");try{r.href=v.url,r.href=r.href,v.crossDomain=Xt.protocol+"//"+Xt.host!=r.protocol+"//"+r.host}catch(e){v.crossDomain=!0}}if(v.data&&v.processData&&"string"!=typeof v.data&&(v.data=ce.param(v.data,v.traditional)),Vt(Bt,v,t,T),h)return T;for(i in(g=ce.event&&v.global)&&0==ce.active++&&ce.event.trigger("ajaxStart"),v.type=v.type.toUpperCase(),v.hasContent=!Ft.test(v.type),f=v.url.replace(Rt,""),v.hasContent?v.data&&v.processData&&0===(v.contentType||"").indexOf("application/x-www-form-urlencoded")&&(v.data=v.data.replace(Mt,"+")):(o=v.url.slice(f.length),v.data&&(v.processData||"string"==typeof v.data)&&(f+=(At.test(f)?"&":"?")+v.data,delete v.data),!1===v.cache&&(f=f.replace(It,"$1"),o=(At.test(f)?"&":"?")+"_="+jt.guid+++o),v.url=f+o),v.ifModified&&(ce.lastModified[f]&&T.setRequestHeader("If-Modified-Since",ce.lastModified[f]),ce.etag[f]&&T.setRequestHeader("If-None-Match",ce.etag[f])),(v.data&&v.hasContent&&!1!==v.contentType||t.contentType)&&T.setRequestHeader("Content-Type",v.contentType),T.setRequestHeader("Accept",v.dataTypes[0]&&v.accepts[v.dataTypes[0]]?v.accepts[v.dataTypes[0]]+("*"!==v.dataTypes[0]?", "+zt+"; q=0.01":""):v.accepts["*"]),v.headers)T.setRequestHeader(i,v.headers[i]);if(v.beforeSend&&(!1===v.beforeSend.call(y,T,v)||h))return T.abort();if(u="abort",b.add(v.complete),T.done(v.success),T.fail(v.error),c=Vt(_t,v,t,T)){if(T.readyState=1,g&&m.trigger("ajaxSend",[T,v]),h)return T;v.async&&0<v.timeout&&(d=ie.setTimeout(function(){T.abort("timeout")},v.timeout));try{h=!1,c.send(a,l)}catch(e){if(h)throw e;l(-1,e)}}else l(-1,"No Transport");function l(e,t,n,r){var i,o,a,s,u,l=t;h||(h=!0,d&&ie.clearTimeout(d),c=void 0,p=r||"",T.readyState=0<e?4:0,i=200<=e&&e<300||304===e,n&&(s=function(e,t,n){var r,i,o,a,s=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}(v,T,n)),!i&&-1<ce.inArray("script",v.dataTypes)&&ce.inArray("json",v.dataTypes)<0&&(v.converters["text script"]=function(){}),s=function(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}(v,s,T,i),i?(v.ifModified&&((u=T.getResponseHeader("Last-Modified"))&&(ce.lastModified[f]=u),(u=T.getResponseHeader("etag"))&&(ce.etag[f]=u)),204===e||"HEAD"===v.type?l="nocontent":304===e?l="notmodified":(l=s.state,o=s.data,i=!(a=s.error))):(a=l,!e&&l||(l="error",e<0&&(e=0))),T.status=e,T.statusText=(t||l)+"",i?x.resolveWith(y,[o,l,T]):x.rejectWith(y,[T,l,a]),T.statusCode(w),w=void 0,g&&m.trigger(i?"ajaxSuccess":"ajaxError",[T,v,i?o:a]),b.fireWith(y,[T,l]),g&&(m.trigger("ajaxComplete",[T,v]),--ce.active||ce.event.trigger("ajaxStop")))}return T},getJSON:function(e,t,n){return ce.get(e,t,n,"json")},getScript:function(e,t){return ce.get(e,void 0,t,"script")}}),ce.each(["get","post"],function(e,i){ce[i]=function(e,t,n,r){return v(t)&&(r=r||n,n=t,t=void 0),ce.ajax(ce.extend({url:e,type:i,dataType:r,data:t,success:n},ce.isPlainObject(e)&&e))}}),ce.ajaxPrefilter(function(e){var t;for(t in e.headers)"content-type"===t.toLowerCase()&&(e.contentType=e.headers[t]||"")}),ce._evalUrl=function(e,t,n){return ce.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,converters:{"text script":function(){}},dataFilter:function(e){ce.globalEval(e,t,n)}})},ce.fn.extend({wrapAll:function(e){var t;return this[0]&&(v(e)&&(e=e.call(this[0])),t=ce(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(n){return v(n)?this.each(function(e){ce(this).wrapInner(n.call(this,e))}):this.each(function(){var e=ce(this),t=e.contents();t.length?t.wrapAll(n):e.append(n)})},wrap:function(t){var n=v(t);return this.each(function(e){ce(this).wrapAll(n?t.call(this,e):t)})},unwrap:function(e){return this.parent(e).not("body").each(function(){ce(this).replaceWith(this.childNodes)}),this}}),ce.expr.pseudos.hidden=function(e){return!ce.expr.pseudos.visible(e)},ce.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},ce.ajaxSettings.xhr=function(){try{return new ie.XMLHttpRequest}catch(e){}};var Yt={0:200,1223:204},Qt=ce.ajaxSettings.xhr();le.cors=!!Qt&&"withCredentials"in Qt,le.ajax=Qt=!!Qt,ce.ajaxTransport(function(i){var o,a;if(le.cors||Qt&&!i.crossDomain)return{send:function(e,t){var n,r=i.xhr();if(r.open(i.type,i.url,i.async,i.username,i.password),i.xhrFields)for(n in i.xhrFields)r[n]=i.xhrFields[n];for(n in i.mimeType&&r.overrideMimeType&&r.overrideMimeType(i.mimeType),i.crossDomain||e["X-Requested-With"]||(e["X-Requested-With"]="XMLHttpRequest"),e)r.setRequestHeader(n,e[n]);o=function(e){return function(){o&&(o=a=r.onload=r.onerror=r.onabort=r.ontimeout=r.onreadystatechange=null,"abort"===e?r.abort():"error"===e?"number"!=typeof r.status?t(0,"error"):t(r.status,r.statusText):t(Yt[r.status]||r.status,r.statusText,"text"!==(r.responseType||"text")||"string"!=typeof r.responseText?{binary:r.response}:{text:r.responseText},r.getAllResponseHeaders()))}},r.onload=o(),a=r.onerror=r.ontimeout=o("error"),void 0!==r.onabort?r.onabort=a:r.onreadystatechange=function(){4===r.readyState&&ie.setTimeout(function(){o&&a()})},o=o("abort");try{r.send(i.hasContent&&i.data||null)}catch(e){if(o)throw e}},abort:function(){o&&o()}}}),ce.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),ce.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return ce.globalEval(e),e}}}),ce.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),ce.ajaxTransport("script",function(n){var r,i;if(n.crossDomain||n.scriptAttrs)return{send:function(e,t){r=ce("<script>").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="<form></form><form></form>",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1<s&&(r=Tt(e.slice(s)),e=e.slice(0,s)),v(t)?(n=t,t=void 0):t&&"object"==typeof t&&(i="POST"),0<a.length&&ce.ajax({url:e,type:i||"GET",dataType:"html",data:t}).done(function(e){o=arguments,a.html(r?ce("<div>").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0<arguments.length?this.on(n,null,e,t):this.trigger(n)}});var en=/^[\s\uFEFF\xA0]+|([^\s\uFEFF\xA0])[\s\uFEFF\xA0]+$/g;ce.proxy=function(e,t){var n,r,i;if("string"==typeof t&&(n=e[t],t=e,e=n),v(e))return r=ae.call(arguments,2),(i=function(){return e.apply(t||this,r.concat(ae.call(arguments)))}).guid=e.guid=e.guid||ce.guid++,i},ce.holdReady=function(e){e?ce.readyWait++:ce.ready(!0)},ce.isArray=Array.isArray,ce.parseJSON=JSON.parse,ce.nodeName=fe,ce.isFunction=v,ce.isWindow=y,ce.camelCase=F,ce.type=x,ce.now=Date.now,ce.isNumeric=function(e){var t=ce.type(e);return("number"===t||"string"===t)&&!isNaN(e-parseFloat(e))},ce.trim=function(e){return null==e?"":(e+"").replace(en,"$1")},"function"==typeof define&&define.amd&&define("jquery",[],function(){return ce});var tn=ie.jQuery,nn=ie.$;return ce.noConflict=function(e){return ie.$===ce&&(ie.$=nn),e&&ie.jQuery===ce&&(ie.jQuery=tn),ce},"undefined"==typeof e&&(ie.jQuery=ie.$=ce),ce}); FILE:public/js/win.js /** * Nano Banana V2 - 弹窗管理器 * 依赖: jQuery */ // 全局翻译函数 - 供组件使用 // 如果主窗口有 t 函数则使用,否则返回 key 或默认值 function t(key, defaultValue) { if (typeof window.parent !== 'undefined' && typeof window.parent.t === 'function') { return window.parent.t(key); } if (typeof window.t === 'function') { return window.t(key); } // 返回默认值或 key return defaultValue || key; } // 弹窗管理器 const WinManager = { // 存储所有打开的窗口 windows: {}, // 窗口层级计数器 zIndex: 1000, /** * 打开一个弹窗 * @param {string} id - 窗口唯一ID * @param {string} title - 窗口标题 * @param {string} url - 组件URL (相对于 /components/) * @param {number} width - 窗口宽度,默认500 * @param {string} bg - 窗口图标背景色 * @returns {jQuery} 窗口jQuery对象 */ open: function(id, title, url, width = 500, bg = '') { // 如果窗口已存在,直接显示并置顶 if (this.windows[id]) { this.focus(id); return this.windows[id].element; } const self = this; this.zIndex++; // 创建窗口结构 const $win = $(` <div class="win-modal" id="win-id" style="z-index: this.zIndex;"> <div class="win-overlay"></div> <div class="win-container" style="width: widthpx;"> <div class="win-header"> <h3 class="win-title">title</h3> <button class="win-close" data-win="id">×</button> </div> <div class="win-body"> <div class="win-loading"> <div class="win-spinner"></div> <span>加载中...</span> </div> </div> </div> </div> `); // 添加到页面 $('body').append($win); // 存储窗口信息 this.windows[id] = { element: $win, title: title, url: url }; // 绑定关闭事件 $win.find('.win-close, .win-overlay').on('click', function() { self.close(id); }); // 点击窗口置顶 $win.on('mousedown', function() { self.focus(id); }); // AJAX 加载组件内容 if (url) { $.ajax({ url: `/components/url`, type: 'GET', success: function(html) { $win.find('.win-body').html(html); // 执行组件内的脚本 $win.find('.win-body script').each(function() { eval($(this).text()); }); }, error: function(xhr, status, error) { $win.find('.win-body').html(` <div class="win-error"> <span>⚠️</span> <p>加载失败: error</p> </div> `); } }); } // 显示动画 setTimeout(() => $win.addClass('win-show'), 10); return $win; }, /** * 关闭窗口 * @param {string} id - 窗口ID */ close: function(id) { if (this.windows[id]) { const $win = this.windows[id].element; $win.removeClass('win-show'); setTimeout(() => { $win.remove(); delete this.windows[id]; }, 300); } }, /** * 关闭所有窗口 */ closeAll: function() { for (let id in this.windows) { this.close(id); } }, /** * 窗口置顶 * @param {string} id - 窗口ID */ focus: function(id) { if (this.windows[id]) { this.zIndex++; this.windows[id].element.css('z-index', this.zIndex); } }, /** * 获取窗口 * @param {string} id - 窗口ID * @returns {jQuery|null} */ get: function(id) { return this.windows[id] ? this.windows[id].element : null; }, /** * 设置窗口标题 * @param {string} id - 窗口ID * @param {string} title - 新标题 */ setTitle: function(id, title) { if (this.windows[id]) { this.windows[id].element.find('.win-title').text(title); } }, /** * 设置窗口内容 * @param {string} id - 窗口ID * @param {string} content - HTML内容 */ setContent: function(id, content) { if (this.windows[id]) { this.windows[id].element.find('.win-body').html(content); } } }; // 快捷函数 function win(id, title, url, width = 500, bg = '') { return WinManager.open(id, title, url, width, bg); } // 关闭窗口 function winClose(id) { WinManager.close(id); } // 关闭所有窗口 function winCloseAll() { WinManager.closeAll(); } FILE:public/css/font.css @font-face { font-family: "icon"; /* Project id 5149379 */ src: url('data:application/x-font-woff2;charset=utf-8;base64,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') format('woff2'), url('//at.alicdn.com/t/c/font_5149379_61ivq9frtnf.woff?t=1774602088780') format('woff'), url('//at.alicdn.com/t/c/font_5149379_61ivq9frtnf.ttf?t=1774602088780') format('truetype'); } .icon { font-family: "icon" !important; font-size: 16px; font-style: normal; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .icon-fenxiang:before { content: "\e60a"; } .icon-VIP:before { content: "\e68e"; } .icon-kefu2:before { content: "\e62d"; } .icon-vip:before { content: "\e60c"; } .icon-shoping:before { content: "\e680"; } .icon-macbook-line:before { content: "\e720"; } .icon-bianji:before { content: "\f021"; } .icon-close-fill:before { content: "\e82a"; } .icon-upload1:before { content: "\e61a"; } .icon-help2:before { content: "\e83d"; } .icon-c108tupianyuanchicun:before { content: "\e609"; } .icon-linggan:before { content: "\e95d"; } .icon-a-chuangzuo2:before { content: "\e632"; } .icon-image-add:before { content: "\e6ab"; } .icon-image-add1:before { content: "\e6fd"; } .icon-chuangzuo1:before { content: "\e687"; } .icon-linggan3:before { content: "\e621"; } .icon-AIchuangzuo1:before { content: "\e7b1"; } .icon-image-plus:before { content: "\e80d"; } .icon-AI_zhineng:before { content: "\e695"; } .icon-Ai:before { content: "\e60b"; } .icon-image:before { content: "\e86e"; } .icon-image1:before { content: "\e622"; } .icon-creative_fill:before { content: "\e60f"; } .icon-AI:before { content: "\e69c"; } .icon-AI1:before { content: "\e8a3"; } .icon-image-up:before { content: "\e6c3"; } .icon-image-add2:before { content: "\ea05"; } .icon-image-edit:before { content: "\ea06"; } .icon-AI2:before { content: "\e6f8"; } .icon-fenbianshuai:before { content: "\e604"; } FILE:public/css/logo.css .logo{background: url("data:image/jpeg;base64,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LXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczpwaG90b3Nob3A9Imh0dHA6Ly9ucy5hZG9iZS5jb20vcGhvdG9zaG9wLzEuMC8iIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMi0xMS0wNVQyMjozNTowNyswODowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0xMS0wNlQwMDowMjozMiswODowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjItMTEtMDZUMDA6MDI6MzIrMDg6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvanBlZyIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDplYjYwYmE0Ny0wYmIzLTA3NDgtYTU4Ni1kNzlkMmI4YjA2NGQiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDo5M2FiMDZhMy03MmU5LWE2NDctYWE1Ni04MWYyZmI2MTM5NDgiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDplZmJmNzI5NS1hMjZmLTRlNDAtODQ1Yi0wMGQyNjliNDg0OTEiIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZWZiZjcyOTUtYTI2Zi00ZTQwLTg0NWItMDBkMjY5YjQ4NDkxIiBzdEV2dDp3aGVuPSIyMDIyLTExLTA1VDIyOjM1OjA3KzA4OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjNiODM1NGY3LTE2ZmEtYTI0NC04YzU0LTk4ZTA3YmJjZjQzMSIgc3RFdnQ6d2hlbj0iMjAyMi0xMS0wNlQwMDowMjoyNCswODowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDozMmUwZTkxMy1hZDIyLTcwNDktODgzOS0wMDgyNjYzZDhhNmMiIHN0RXZ0OndoZW49IjIwMjItMTEtMDZUMDA6MDI6MzIrMDg6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY29udmVydGVkIiBzdEV2dDpwYXJhbWV0ZXJzPSJmcm9tIGFwcGxpY2F0aW9uL3ZuZC5hZG9iZS5waG90b3Nob3AgdG8gaW1hZ2UvanBlZyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iZGVyaXZlZCIgc3RFdnQ6cGFyYW1ldGVycz0iY29udmVydGVkIGZyb20gYXBwbGljYXRpb24vdm5kLmFkb2JlLnBob3Rvc2hvcCB0byBpbWFnZS9qcGVnIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDplYjYwYmE0Ny0wYmIzLTA3NDgtYTU4Ni1kNzlkMmI4YjA2NGQiIHN0RXZ0OndoZW49IjIwMjItMTEtMDZUMDA6MDI6MzIrMDg6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MzJlMGU5MTMtYWQyMi03MDQ5LTg4MzktMDA4MjY2M2Q4YTZjIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmVmYmY3Mjk1LWEyNmYtNGU0MC04NDViLTAwZDI2OWI0ODQ5MSIgc3RSZWY6b3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOmVmYmY3Mjk1LWEyNmYtNGU0MC04NDViLTAwZDI2OWI0ODQ5MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8P3hwYWNrZXQgZW5kPSJ3Ij8+/+IMWElDQ19QUk9GSUxFAAEBAAAMSExpbm8CEAAAbW50clJHQiBYWVogB84AAgAJAAYAMQAAYWNzcE1TRlQAAAAASUVDIHNSR0IAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1IUCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARY3BydAAAAVAAAAAzZGVzYwAAAYQAAABsd3RwdAAAAfAAAAAUYmtwdAAAAgQAAAAUclhZWgAAAhgAAAAUZ1hZWgAAAiwAAAAUYlhZWgAAAkAAAAAUZG1uZAAAAlQAAABwZG1kZAAAAsQAAACIdnVlZAAAA0wAAACGdmlldwAAA9QAAAAkbHVtaQAAA/gAAAAUbWVhcwAABAwAAAAkdGVjaAAABDAAAAAMclRSQwAABDwAAAgMZ1RSQwAABDwAAAgMYlRSQwAABDwAAAgMdGV4dAAAAABDb3B5cmlnaHQgKGMpIDE5OTggSGV3bGV0dC1QYWNrYXJkIENvbXBhbnkAAGRlc2MAAAAAAAAAEnNSR0IgSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAAAAAAAAAAAAAABYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9kZXNjAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGRlc2MAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAACxSZWZlcmVuY2UgVmlld2luZyBDb25kaXRpb24gaW4gSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2aWV3AAAAAAATpP4AFF8uABDPFAAD7cwABBMLAANcngAAAAFYWVogAAAAAABMCVYAUAAAAFcf521lYXMAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAKPAAAAAnNpZyAAAAAAQ1JUIGN1cnYAAAAAAAAEAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4AYwBoAG0AcgB3AHwAgQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2wDgAOUA6wDwAPYA+wEBAQcBDQETARkBHwElASsBMgE4AT4BRQFMAVIBWQFgAWcBbgF1AXwBgwGLAZIBmgGhAakBsQG5AcEByQHRAdkB4QHpAfIB+gIDAgwCFAIdAiYCLwI4AkECSwJUAl0CZwJxAnoChAKOApgCogKsArYCwQLLAtUC4ALrAvUDAAMLAxYDIQMtAzgDQwNPA1oDZgNyA34DigOWA6IDrgO6A8cD0wPgA+wD+QQGBBMEIAQtBDsESARVBGMEcQR+BIwEmgSoBLYExATTBOEE8AT+BQ0FHAUrBToFSQVYBWcFdwWGBZYFpgW1BcUF1QXlBfYGBgYWBicGNwZIBlkGagZ7BowGnQavBsAG0QbjBvUHBwcZBysHPQdPB2EHdAeGB5kHrAe/B9IH5Qf4CAsIHwgyCEYIWghuCIIIlgiqCL4I0gjnCPsJEAklCToJTwlkCXkJjwmkCboJzwnlCfsKEQonCj0KVApqCoEKmAquCsUK3ArzCwsLIgs5C1ELaQuAC5gLsAvIC+EL+QwSDCoMQwxcDHUMjgynDMAM2QzzDQ0NJg1ADVoNdA2ODakNww3eDfgOEw4uDkkOZA5/DpsOtg7SDu4PCQ8lD0EPXg96D5YPsw/PD+wQCRAmEEMQYRB+EJsQuRDXEPURExExEU8RbRGMEaoRyRHoEgcSJhJFEmQShBKjEsMS4xMDEyMTQxNjE4MTpBPFE+UUBhQnFEkUahSLFK0UzhTwFRIVNBVWFXgVmxW9FeAWAxYmFkkWbBaPFrIW1hb6Fx0XQRdlF4kXrhfSF/cYGxhAGGUYihivGNUY+hkgGUUZaxmRGbcZ3RoEGioaURp3Gp4axRrsGxQbOxtjG4obshvaHAIcKhxSHHscoxzMHPUdHh1HHXAdmR3DHeweFh5AHmoelB6+HukfEx8+H2kflB+/H+ogFSBBIGwgmCDEIPAhHCFIIXUhoSHOIfsiJyJVIoIiryLdIwojOCNmI5QjwiPwJB8kTSR8JKsk2iUJJTglaCWXJccl9yYnJlcmhya3JugnGCdJJ3onqyfcKA0oPyhxKKIo1CkGKTgpaymdKdAqAio1KmgqmyrPKwIrNitpK50r0SwFLDksbiyiLNctDC1BLXYtqy3hLhYuTC6CLrcu7i8kL1ovkS/HL/4wNTBsMKQw2zESMUoxgjG6MfIyKjJjMpsy1DMNM0YzfzO4M/E0KzRlNJ402DUTNU01hzXCNf02NzZyNq426TckN2A3nDfXOBQ4UDiMOMg5BTlCOX85vDn5OjY6dDqyOu87LTtrO6o76DwnPGU8pDzjPSI9YT2hPeA+ID5gPqA+4D8hP2E/oj/iQCNAZECmQOdBKUFqQaxB7kIwQnJCtUL3QzpDfUPARANER0SKRM5FEkVVRZpF3kYiRmdGq0bwRzVHe0fASAVIS0iRSNdJHUljSalJ8Eo3Sn1KxEsMS1NLmkviTCpMcky6TQJNSk2TTdxOJU5uTrdPAE9JT5NP3VAnUHFQu1EGUVBRm1HmUjFSfFLHUxNTX1OqU/ZUQlSPVNtVKFV1VcJWD1ZcVqlW91dEV5JX4FgvWH1Yy1kaWWlZuFoHWlZaplr1W0VblVvlXDVchlzWXSddeF3JXhpebF69Xw9fYV+zYAVgV2CqYPxhT2GiYfViSWKcYvBjQ2OXY+tkQGSUZOllPWWSZedmPWaSZuhnPWeTZ+loP2iWaOxpQ2maafFqSGqfavdrT2una/9sV2yvbQhtYG25bhJua27Ebx5veG/RcCtwhnDgcTpxlXHwcktypnMBc11zuHQUdHB0zHUodYV14XY+dpt2+HdWd7N4EXhueMx5KnmJeed6RnqlewR7Y3vCfCF8gXzhfUF9oX4BfmJ+wn8jf4R/5YBHgKiBCoFrgc2CMIKSgvSDV4O6hB2EgITjhUeFq4YOhnKG14c7h5+IBIhpiM6JM4mZif6KZIrKizCLlov8jGOMyo0xjZiN/45mjs6PNo+ekAaQbpDWkT+RqJIRknqS45NNk7aUIJSKlPSVX5XJljSWn5cKl3WX4JhMmLiZJJmQmfyaaJrVm0Kbr5wcnImc951kndKeQJ6unx2fi5/6oGmg2KFHobaiJqKWowajdqPmpFakx6U4pammGqaLpv2nbqfgqFKoxKk3qamqHKqPqwKrdavprFys0K1ErbiuLa6hrxavi7AAsHWw6rFgsdayS7LCszizrrQltJy1E7WKtgG2ebbwt2i34LhZuNG5SrnCuju6tbsuu6e8IbybvRW9j74KvoS+/796v/XAcMDswWfB48JfwtvDWMPUxFHEzsVLxcjGRsbDx0HHv8g9yLzJOsm5yjjKt8s2y7bMNcy1zTXNtc42zrbPN8+40DnQutE80b7SP9LB00TTxtRJ1MvVTtXR1lXW2Ndc1+DYZNjo2WzZ8dp22vvbgNwF3IrdEN2W3hzeot8p36/gNuC94UThzOJT4tvjY+Pr5HPk/OWE5g3mlucf56noMui86Ubp0Opb6uXrcOv77IbtEe2c7ijutO9A78zwWPDl8XLx//KM8xnzp/Q09ML1UPXe9m32+/eK+Bn4qPk4+cf6V/rn+3f8B/yY/Sn9uv5L/tz/bf///+4AIUFkb2JlAGRAAAAAAQMAEAMCAwYAAAAAAAAAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQECAgICAgICAgICAgMDAwMDAwMDAwMBAQEBAQEBAQEBAQICAQICAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA//CABEIAfQB9AMBEQACEQEDEQH/xAEVAAEAAgICAwEBAAAAAAAAAAAABwgGCQQFAgMKAQsBAQACAgMBAQAAAAAAAAAAAAAGBwQFAQIDCAkQAAAFAwIDBwMDBAMBAQAAAAIDBAUGAAEHEAggMEBQERI1FhcJExQ2MRUKkDQ3GCIkOCMlEQACAgECAwMGBwkJDAkFAAABAgMEBREGACESMRMHECBBUWEiMEBxFDSUCFCBMkJSYiO0FZGhcjOzJHR1FrHBslPTNZW1drYXd5CCkqJDY3ODhMKTwyUmEgACAQEDBggJCQYDBgYDAAABAgMRACEEMUFRYRIFECBAcYGRIjIwobHRkrITMwZQwUJSYnIjcxTw4YLCJHSi0pOQ8UM0FRbiY4OjszXD0yX/2gAMAwEBAhEDEQAAAPv4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB12PmdRh7Dr8fM43n7+HHPt7dOX7Y/Z5OF3GZreT6eIAAAAAAAAAAAAAAAAAAAAA4nlkVeru8+w98Ozdg0j+88ePHOKarf4TpJPiOpkGM6zd8HwygAAB+8u4zNblO00eYbeOZzvIv3WbrBXaC3Dh+rkdprGons8jBAAAAAAAAAAAAAAAAHr696MU79SxDF7CFw7S+dO79tZHMcmfV4uaAAAAAAAHLKNno+wzcOodZ/QwkffQ2+90/J/L9ccAAAAAAAAAAAAAAACstfXdVCtL8AyjN0cub6vQAAAAAAAABF+lnWF66SAWRn1M24s757AAAAAAAAAAAAAAAGP4O31yUP8AZXB8ckD2dus8SeqeR28QAAAAAAAB4cd4JjNqcTz9wPb26bDrz+Ps228ZAAAAAAAAAAAAAAAqzXN7Vcrm9AAJK3ELzrZxUAAAAAAAAYjgSKKdHPAAJzmFXXStr5p9nboAAAAAAAAAAAAABjmu3NCaX+r8P1kiAA5/rizlJau8+egwrt75Lx59hx0AAAAABzCkcszpsfYgAdn74VqJ/Q9hrCqDkeniAAAAAAAAAAAABj+v29aa2uuII7YeF66SgACYpBXWRZemqV7bj+cvIPpPZzjRb75NF8+gAAAADqfDYQjG7OAAGUZujlzfV7mm6jVjLEpz29/MAAAAAAAAAAADg+GVWKsrw6XC2Xi7YzhbvqPDYcPzyeP09v3njuPfXZvsox589PmL2dpfH3trl8j+nXG/luxflrAAAAAPxzhWuk3SY2z8OO3I7eXM9cbtfbAyfN0fs56CS5LCp6nlUAAAAAAAAAAAAV6r634/j8tAAAAA+Tba278qG0tof0RI/wDOOzTGiwAHS8+ny/bO09HubO/a4+9rRfP8/eeuAAAAAE/z+pZGkUNAAAAAAAAAAAwTRSqutd3EAAAAANIGbO/hG3l+D+lDHfmS6/howAPlP2ltV29NluGxIZSn23n0a6+tZU6YgAAAAA7DIxLU2nQ/M9sYAAAAAAAAAD845q7WF6Y7rtuAAAAAOHz2+OHb3RDHfN+47S0T58cAAVy9dnYnz1vs44AAAAAAAEvy+u5mmVbgAAAAAAAAAYPo5RXKurkAi3FndftfbmL+W9yj10U/7CpJUyoGAAAAAAAAAAAPQ9q/6624dw7G4PXKkPJh9j9lTOS+mkHO98a2Fr0D7+/kAAAAAAAAAK+1/bseR6X+p6UX0P1NV7V3kcgHFndpR97N98r+15gADhMqEeLOiHiw8I4k/WM7nMXMeY5K/MBnTmrO5a0AAYh4yLXRHPseN8aaADsu2Dfff/KFhdjT4sFYFRyHIYeAAAAAAAAB6+vepFS/QXF8vemem+kqe6b6LAAAt9uPne6G6+agBxWRTjr9HVA6fRHQtsAABz2HaXtRV3O/zF3zUgcfj21gRf7kjjGmYAA93Pls0k/xHKmVA5FkUOsBP6kAAAAAAAAAxbV72sdZXf1nXO1JxH9A+B1zAAAOd2xdtcu/Pzte2vHVs/WT5/bsN8WQAAAABmvMY2genw3nKLCvOuuDXvHvr0AAAT3sKm2JyL477bLwLX2tQQAAAAAAAAEZRmbwPBLViPEsLWbGPtsAAADZdJvieYMyuhQHp9bVP638IKbf59+tz7Xua52P8wcCsTfaF+tw7lOavvjzEBLPNf7U/T4N8nWkGj+n6oam/gAABkfppdtcu/PxytvbPz7zvfFAAAAAAAAEMw2yYgiFhxPiT/WTGPt4AAADZlJ/iSXMuveAy9Nvl+j/AAmUKF8TDRdxbmwTmF7weanA1s8TrTBxaWzbmBbj+awA2o+nwdLvNeUk0n05UzUfQIAAAyf10e2eW/n8LO2dR+UbPRgAAAAAAACCoLakXxiccHjK1LRH9Auo6bEAADte+v20y38/uw7YcfJfqS8v0FAoXxMNF3FubBOYXvB5qcDWzxOtMHFpbNuYFuP5rADYh6fHtnuaNgXAtfXXHPsYAAAWI2NO7BpD8iCx9jU1m27jAHC6ZVW4telsZZQIAAAAAECQO2I1jU0FTNT9AUk0f06AABdfd/Mtt9v8+DGW707+X6MHIoXxMNF3FubBOYXvB5qcDWzxOtMHFpbNuYFuP5rADZX6fFNg+ah8HfWfGPtmJMSwQABzu2Js9lHw7IeRDxYmxKeifwn0G6K0r2zv5W7j212k2lP013Z3X+ZIAAAAAECQO2I1jU0H45ptpvpCoOn+h/Tx6ge3nzt5uPnm5m5+bf1wBqj8/vmLeJ2IGbf57+t07Y+a52TcwUCrzf6EOtxbmOauvxzDx2DD3B+v51921Yx/z2+vePfXsM4VkgDI/XS7ApB8kTTm1mBL05rzVhXf3hbGV0B6OPXYZYXyBpTpX9Md1l1fmcAAAAABAkDtiNY1NABhfjJoE19sYt5b3KPXRT1sKozb2jAAED8WtrL8/tzxdgAAAABdvv8AMV0+3zQAPxzDmHY8O4VjcHrlSHkw+fthUvZ9sEAThPav1HVn+hEybmt7IyOmJN2cIohBPqrbnbn57gAAAAAQJA7YjWNTQAAAAAACrPW96CdPrPhMoAAAD9cW57fPd8e/yn5ugAAAAAAxDbSKC8G04V0tl5xnRfMsyObOLN+IMwzI6AAAAABAkDtiNY1NABgfOTWnvtcbeud8Y9n+uo7zjzAAAwpJqu9b0jLib8RkAAe1551zFrL80nLPNfgAARr2y64dtp0T0kXjFs/11PZcdAB3Mo12QbbUWGkVP+7nzAAAAAAAECQO2I1jU0H45+HLOv755Miy+qdwJF4xfsWxKR+mPFqoAAAAAAAAAAdXz3+CzO+iNGfvYHEdgJe4wvtcwqH+gTHrcCxNiU9nm9igAAAAAAAAgSB2xGsamgqx33H8ujZ/WoAAtz00v9QXWfJgAAAAAAAAAA1x+so/mu7H6lAAGy7ziv8ASQ1vy4BYmxKezzexQAAAAAAAAQJA7YjWNTQYXzkfyvdp9dxvzlSpxh79ceuvnDybP4Tttz8oX/RL13zMAAAAAAAAAAIC7bL+WztPrfpHpPnXXbsPGBfOtkWb4Od9XhXf3qYHzsBYmxKezzexQAAAAAAAAQJA7YjWNTQDQzkWJqd9Zj9T2LUVqumn1f8ArLfmzybS+oHFqXYX5xoAAAAAAAAAAD5yMmz6K95D9UWLUM/9dbqB9pr89mRZX1kYlOXJ6aMCxNiU9nm9igAAAAAAAAgSB2xGsamgAAAAAAAAAAAAAAAAAAAAFibEp7PN7FAAAAAAAABAkDtiNY1NAAAAAAAAAAAAAAAAAAAAALE2JT2eb2KAAAAAAAACBIHbEaxqaAAAAAAAAAAAAAAAAAAAAAWJsSns83sUAAAAAAAAECQO2I1jU0AAAAAAAAAAAAAAAAAAAAAsTYlPZ5vYoAAAAAAAAIFgdrxpGpqAAAAAAAAAAAAAAAAAAAABYqxKdzvexUAAAAAAAAQzDbJiCIWGAAAAAAAAAAAAAAAAAAAABZ6zqPyfZ6MAAAAAAAAYjqZDWitLrAAAAAAAAAAAAAAAAAAAAHaZWDa61qD8+eoAAAAAAAAFcK5ubCdJJgAAAAAAAAAAAAAAAAAAAJ1nVWShJ4MAAAAAAAAAOtxs2tdb3T0Gv2wAAAAAAAAAAAAAAAAAAEmyaEzvO6p/QAAAAAAAAADh+ORDMNsmM41NvR5+oAAAAAAAAAAAAAAAAHb5mvmGX11JsmhAAAAAAAAAAAAHG8/bGtZu+F45P45AAAAAAAAAAAAAAA8uevbZWBkWx03lzwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//aAAgBAgABBQD+ikYrSFUN6awUKRNdqvJW21epm6rSRsvQZA1CoDs2mUA4o3s044pOU5Tk36jRNvqm2va9qvewbKHttT0fKr0c/uhtGqVJ3Jte9rlOC4miZK4l0RKUw6TuKJXq9zICM1DOlQTEqtOtI7CEIIAySQDdj9IzIE9mpXKDBUoWq1d+hSvDgkpylgv23RhfDmZSScUoK7Bmj1csOqAzwH9I4meI3WGPX0TewHJcW2oVB5qk/UIrhEWOxgOivewbGDuYZqAQgCY3Kzq29fO19+/hbje8PROBvgK4Yi8AblQRBGHrXJyIbiXdWcucOEk25Jtr2FbSwRCq9r25yo76x3CnvcJ7S8mt4ijSzy+rcXAluIUqTlZzgSIJnEhuK6aiCrnGgAAsKwixpfMVXvZPxISBGHU0Ow240AwGg6lSoKSkL1xq9RV7WvYaBOO92y1XbDK/bT6s2n0W2gte1rBtTaHvHocHwG8v9aNbixXu2nV+2n1ZsNqzZagN5AaCEIbaMDt9qZ1L85/eKOW231Wf3PDa173Jb73sFKnDQ0hA7GguUZzI+5/dkdO/OH2aTmITLAO0UC8Z/C3FWvSlb9MQV6i1/wByF3DGIwfMSqTEihOeWpI6W97Ws6LbrlnM/SiF4L2PWlhBxANMLt0MYW9wulf1f2qDU5anIobsKv3VRQHYVErU5/U3vYNjXNOC4nY69WdVFFuwL0UcUdbRMeNMoKMCcX0kkU/WX1e9rWVuAh34UjgIu9r2vbj8Vq8VeK9eK9eK9eKvFav14zzy05ahWapFwAGMsSNdY/WNqfrIejGKwAnmiPOpzU37+NsVX7+G4uVYVfrwXvYNlSgSk3ite4bo1H3BNRpR9Jf0b0d9FsoY7FgGK4xcYRXAIsdjAa3v38y1+6rX79XM3wJ+Q2m/TUUhO+3WdHKDPCipffwpOSgvcSTQV+G3FbW1+6+jsL/6cgm/hO0SmfWTdFKx/wDOlwfEk5KAPhScN9LfrrfS3Bb9Kdg/8+QQHxnaMw/qNfRSm/8A3KEGwwmAEWPjLBcwYA2ACr/prfS36630twB/SnEq5ifkNhXjP0j1+9q1NMCSWxytO8q+bKPMNHJLcVuNtS3DbS/6a30t+ut9LcAf0q9rXssTXTG8QQiGJKnsnK0jnljzKG9nGnyAlGYnUEKyf1osNmuW82UeYaq26/fe1w31ta97pG6/fwXt3X4LW4L6W4zSgHAUojU9+Aokw4SREBNbVrU/Zx6PNfqB1ksZaUzTj9QYNNRt/vZhzZR5hwGpiT6G0l3r9pHQGku1EpiSOIVu/nBtxGoExtCaas0iotrIDQAALDwMhAFLIUY4xJ3kUrTuzZB0dk7U/P6VoSwhvGqc+bKPMOfcPfzLB7+gRS0hoLe5ckdEzPG3B5sZCn4sSGCOJo0CBM2pubKPMOC1r3r6Zl6+kZX0x2ruvbkXta9eGvDevDevDevDevDevDevDevDVrWtyLBvevpmV9IyvpjtxNkdaXNEnibCnEEIQB6CUeYalEBtb9OC9rCscTYNum/WiyAhtwXDYVjifBwRzyzopR5hoC3ePiMt3g6Yq3eZxHW7y9Y55Z0Uo8w0tfuva9hWoQrBsFQG4tDheEvpgC8IrX77UIQQWAoCIWigXcDWOeWdFKPMNSjvBQlIaGMQ70WaMurKQdxhlzL9OUf4LCU2oQhDvRZwwV9yDuGO5gtY55Z0Uo8w7LjnlnRSjzDsuOeWdFKPMOy455Z0Uo8w7LjnlnRSm3/f7Ljlv/y+ilRX/LstmKuS2dE9pbq2/spEmErVBDYIeje266FV2THG65BXSK0pKwhwbj247sdlZBKRfp0x5BKktfGji7mFGkj7DSoVay7dHCU9+pNJKOCYxNZl/TjXXpxsr042V6cbK9ONlenGyvTjZXpxsr042V6cbK9ONlenGyvTjZXpxsr042V6cbK9ONlenGyvTjZXpxsr042V6cbK9ONlenGyvTjZXpxsr042V6cbK9ONlenGyvTjZXpxsq0da7UU0NpN7WsG39b7/9oACAEDAAEFAP6KRTevPouNPplBhz8KrQl6vXoh5oUKe7UOIv4KNj70VRqZQR2alSqVyhhw+n+hJsSfbp72uG9BCIV0cXellJYFak8SY09EIUSbk3ta9j2dqVUphTMdSuCLS6WMzog1iWLDXRM8YebjCHFtXNKzsIssZpkChRMaRaZDhiu0ib4KSCkbagQB6FdHGhwqO47LMf8ASZxBLKm9UlUIlPYOKInY4eshTfXb+kjKb6aTXK0TsqTdgMLQe+u6JGnb0mphYTS1BIk5/RAAIwaUgKZPqYWWcXL2AUbfuvw4zW7uGTI/Ab0UcR/XWcOUIyY9NwwDKH1rKyKnlRFG5M1MPCuShWpRgEWPQhKpVXGAZYuZ+tNSL7FFwuQLGN8gjZDuWoTnJTurZmhQ8q0KJM3po4uLMTcT8EAXSmFpMe3VCgSNqaaR4h2bOY1BAJx4n5cWmR1Io+U8EGlGEGdSiRnr1LS1p2lHQRCAJPIXEmwJUO1BlKa9Wk7fehSdDalMnNGEYxmDrF6YI1lfrTyksgduXa97XSyZQUEMnQ3q8nb6FKUtqHKhUfI3A2xhgzR6SyP2XE9TE2T9uScvFo7eHSaBsGT8JRRhxjLjYRgE0PjaUK2IR1aU7N42py5kuZLIFXTxNo/cl/Mx85AQvuklUhVv/DjRpIMtKp3dsUJ8hyQky+UVP01y09xWcxeiJcEixKahVdLa1xXYm0LU28wIhAFHshIjiJBOmtIhve978KF4dG0q973v0E5a+8PSxFv++dtY3jiVycLdgNIEPsVEPC44DSCDJMbyuMh6ggg9Scw4WlToBJgVgAE3BMTEF2wK4FBe449xxRotSlrkh5Jic7pIWh+2aqKKMPNx/iNG1lWta1tf1qf4jROxZpRpBvEkRLF57RhedOtkG3i3cRgGGl29i4J3HYDhRll23hGKzrgybN9nNndWY/ijEYdJY6RCCMcPTcDi2oHZJkbGB8WvpNUP2zr0ZYBGjSpwpU1YWhRf0uPNMKLETwJUqlcoh2BxGBaGJmYE/GtQIXJPLsEtS4LyxuseXapyDlR8GiKWHsfEeQSpJyHEBQ+QVNkn12no4yn+5fKbEBro4oESdtQ8a5GncUbq3mtTno2Nq14X4+xy2QlFy5VEmaYNsvibnDXjTCzGB0lfIzIxgdYhTqn+7bejghPjc6xWnCpn3JyunCmn2mDIaW3tWjG22dXIlsb05UwY0yC+sbaQO7kBubyypcykNajTJMOKmMcEEQRVgNOELTyJGnCrj+i4n7db0UBL7i6xcqCknnJyoqCrntIEZjguRIyG9FpCPOanXlGsD/v6nv8AZ65UZwMs6rASoIm3kSdUFFHNJKX9J96KBh7m2kKs5vWtLmmeWzjdHFM0Nq9ac4rqgAAmTfWEec1OvKNYH/f1Pf7PXP4AhmdYdfgM8u5Gan4DbFtJgHwv2qJIc4LcoYCeMbsHNgnlGmGZwWiM48zTgtWPSNLgtci1hHnNTryjWB/39T3+z1zevCsntAGMoeO5qRMGXiXLkjajnErPl7/pM/PMb4Ml2RkzxtIfkyR3aHNgc7XuG6w8U62+c2CeUaWve18f5hLsWScSoK1NOKIKyBmEqxd73FfXG0jBJofoxOVmpyIcEKgqYviZdwRt1A0OQFyIwqXvRDmo0cFyZrQvToe9u+jG+OUdcoXkxklhfA+SFnjiOf5HXzI7VSzWkWQ8wTm2I4JhTN2QHufbuWdGmeaQgvGtunNgnlHAwTCRxkTdnt1KD7+t/hcc9uhoX+YySTC4cTzq0PfADCYHmZwnhZtuC172uxZVmTEFJn6/hNz8jsF3zhKVoXBycHZTwPTopZJmvRQ3cDj7DmA3aAzbdDJBvE8xTiZ+yE+7n5alY4PzYJ5Rz8ZZcHHQI1qRxTcm97BtkfMiVAUMYzB85Ht+dchIMY7en+DPWRszxHGxiXc1ihYVJ91cPQJpVKnuZvfNgnlHAnRq1dwReRmV6Rk1GRuQE2NKNJFxxyYSKKHM24W1gkZ0ghofe6AV73QCve6AV73QCve6AUZnCBAs67hGksMpyZLJYHjToVqygRaSGWvEZNajY6/k2GWMsXAZmPIEHVO+fsrvBJxxyg3oIJ5RrFMet6JMWWWUDVWiRrip1B07SR0xZYzTI1j9qaiAhCAOq1tQORU6hZbBwTPzzooJ5RpHygHvvFIygHR/poWUA6U8U3KAdFdZn550UE8o0SqBpFKFYS4I6Xr0bYlbcntyp1AMJganzoBtjXTNC+7Y6FGlnlU6OyBmSMeSm9yX2va9qyW6ARR/WZ+edFBPKNYbORx8DllVuLLeH50fVFR6ZPEeuRlRiGRJ5Krky7p4fkD9lTOuVkgC3R3cXlTUenTww2DlRhunkD8skThrM/POignlHZcz886KCeUdlzPzzooJ5R2XM/POignlHZcz886KCXt+09lzK/e+9FAVFrg7Lkiiyl86KMOFm947KcloG5AMQhi6OLvNnRD2TM3myo/pG9eobVTQ8pHhP2PJpOBEC973v0qVWpRHtM2THWJPIUl9hrnVvbQvMyUK7fr1JClQmGTKn4m3rN9r1m+V6zfK9Zvles3yvWb5XrN8r1m+V6zfK9Zvles3yvWb5XrN8r1m+V6zfK9Zvles3yvWb5XrN8r1m+V6zfK9Zvles3yvWb5XrN8r1m+V6zfK9Zvles3yvWb5XrN8r1m+VeZPt7KJE9KbXFcV/wCt9//aAAgBAQABBQD+ike7tSWjZpFyaHkSKgoWTI1avc6OUHJUZFReQYoZRMsjSiiFiRVbsxevRtiOVbhlf3UKz/8AeLAisK1CEEAXCcRpupdla9LMgShXSlzclvJte4bpZE+oqRZLkaa6DKbabTdImR21n+dSGNbHdxDwSqZ3lsf27sI00ogrKuS1E0ctMQ5IQXh7tlJSZTi9OrsLoP0prmMhabzDL5pMT0xxkFdBHdCuSOaPsHPU9EnK1iKz7V36SarPqr9cDz0SFb2BKpAmi0fcnBW7L9STRkGpFIFiXojDAFFrlQlqzUk41Odj2VgmUW6/cXI7+LhhTj9Qjopi4/at/Dg+bExp4KNLPL62SSVFHEk8eFr7KuFsXDbVxZgDi9JpkrHONkrS7tT828y97Ws/OV3Ny4WY0RLtEpkqjxqRWmXJ+rkchSR1A5uax3WzFrOJW8UUGaNjrfJunZNme2LN+dMr7jcjfDd8gMz2p7juY+jMAz8UWazlrjUPlp8dVEHkqSepcnFK1In58Vv7jQwAMCriLOpEZBS70ODLLVeEu1qDCHS90cITliLLLJLr+THkZY14joAxlj2h5UUZw2s8u9rCsvhSQ8Y4Q52vaEu16BBl16LggaTQ5oIuUUUQXpAZZdsUdTPpNd5X8v8Ak5tSuzlp8N61Q4fGlwyORx+HsG8b+Rk2sDzkf5bvkVycuw78tvyC4ak21jPLTuf278zH0mu7Iunn0huzNPM+fXbm6Zr2QafHBjhdifYlw/yQ908yYzvi++DgvcdBp/8Ax/vjulzIV/GTx7aRYQw3BtveJeY1OShocG5enc0PSiEEIZS9CfXrmOLcgd2/f18AmYohNNg/wh7m8pZrLLLJL4c0bRNs+4qSkkkpyegxg+XCPpcgO92xg1mWZIHCRu+65eIf+0uQvG0brnAA4bmWBTYfUKlSZEnlO5OCsRjhurlhoyN009ANg3VtB44zMYzMUmjauObF6VSUsTdJklzusfqPPJTE5a3AuL4de9731te9r4m3AuTAcnPIVEcTg5NzSlkO5LFrEJ13fC8SrdhkU6990eUvEm3W5KJu2bvnEF2HdDjN2EyyJhkabim02Y4Ex5BylJ8iLeBoeXVgcMO5vSzq2mNXP71h6M00BJS5WNctrcpks363HtsyWaBTwLlyJsR5F3UAJHIJTI5Ws42x1c2VXj/dM+tY43J2GXNeqtUnQpcoZBXZEk/EmUqESnEGQgZDidYzX/bP/RzVZ9lGKenQhjZ3VzVvTnxtjirZ3FidiH5k0enpsjrVlrMT3kxy5cEn8jx484+nzLkaPablJOYyQPkbc5OYxZCpiWfYPPR5SU/TZazssGixTycCrBrMUabocjGu79puPy4PCeJJDmDKcpe9hm5CV5ODru0zavwXidwyxlB0etjG4aR5ii+mGcin46mQRBGGt1ywY3/kQ5YNvlujWp+8bOiywd3m1nFCNwxVycFIRoMVU6uBLQ1ubiqd3LT5F/8Az/Xxt/531+TD/GFfGV+da4KkJkkxdW69CMD1yIShG5THSFnfXi/RZUH3vNObendm1/ZVkce+NiZ1kheWltTs7XWWDBlYz1+Rf/z/AF8bf+d9fkw/xhXxlfnWu000Y8cVuKipkix/yNtcVMep1pjwfiimrm4J2ltwfuvj2ZpZzcpfkGm4/GBrmTx7b8YHN5ek0axvkP1+Rf8A8/18bf8AnfX5MP8AGFfGV+da7ZGsbdiqjSizy8wY1VY8knE2Ni95cMYQNNj2KaY4/F8y7osfYdWR75AYqtcI9IWWVsoghGFuSgxbu35uUvyDS9rCtlnbucI5QnUJD9U6c9Ufibbufc4IQhDrmaHmQrIem5PEZ2bMRSHF2Roo+bB9uksxvbXdxhJyzliZwxvkJqe9im3yS4hjGjQ1rXx0jTGmjMf0k8YZZgzZKwrJoCdwRiIyKYuOJ8NtWOU+qORXiGIdvGML7gcp7ltsuJozif4/JC4rI5ToZaZ7w+blL8g4JXjyGzUDxtTYjx/6nu3jaNqTGQOKY6hsKBw57xcLIcYMLGUPmbYsVmkC4L2sK0pwPjmUDcNp1rjI2nOIhx/bFBWwbSytLCj4I0xo5NjdpcsjbSst7it1zBlfGex2GlR3FWec+xbEcW2QQBdKMnc3KX5Bz82bfypeY4trgzreSEIhCw3tyXOpxZZZJfOcd2rDiN2zbu6ieUI3hzbdkHMxS/ZRnluUQnYfkR0WwSCRnG8Z5uUvyDgm+U8Y4zTPPyO7AmEwHyk/HaMbB8gOxaUGR6TRuXNvHMceQ+eJpHtFvcana7lMgf8ArLlmv9Zcs1/rLlmv9Zcs1/rLlmitsOVjBMW0Z/OHBsKwGBD455mbD+LAO3yS/H8ymlfKR8dpwo7v02QS05oemeQN/ATt0xNk9DH9p2BY6pTpk6MjoMpfkGghBAH5M/nYzpl3IT4/PknddcZ5fytheRfC98y033QTPpnx7aIyyfIV85W5XczM3BxcHZbrh/P+b9v0h+GH5dn/AHuX1xx+L9FlL8g03zSV4huyvi2BSR4iW+Tpvl1krxFPjZ4vh2krxFvks1xx+L9FlL8g0yRBmfJ+O8xYql2Dcq1hbCGWNxGRM/fxyc9Y02zubY5MrjXwg7cXfcF8hHTbqcLEbjNtsmjT9DJJW2/bFm/dpkzeZ/HpzhgHCZpRhBlfx4duLvlrfRrjj8X6LKX5Br8s3wxNW+p429/xmc8v7/tR2V7cNlcHrfZ8Sm1HfcVM/wCNDvPapn8cfx44z+PHDPT/ACtfBoLdxP8AbP8Axl8muj3tx2t4G2l4/rfj8Lu1Pe2pXfxo96pE72L7JsV7DME644/F+iyl+Qdl44/F+iyl+Qdl44/F+iyl+Qdl44/F+iyl+Qdl44/F+iyna/7/ANl44te0X6LKyS9jey4YkujjHRTdpE7x7splbTHd0LAAoHRziOCYnTsnHEcEhS9I7NSN6QyGOr46s7HhMIMcjLWta3SrkCNzTP2NFqYSlKpRm9htbE7vI45jhG3ita1rdQpRpFpaiCRVRf24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXr24i9e3EXoOOYta6SIRpFcIQgD/W+//aAAgBAgIGPwD/AGKR9piY152A+e1+MXoqfIDa6ZjzK3zgW/4h/h/fbuy+iPPa8uP4fMbX4kjnVvNbs42PpNPLS34cqtzEHyfJrzzyBIlFSTkAs0e64F9kPpuCSdYUEUHPU6hZIN6xKoY0DrUAfeBJu1g3aKX2BBu4CSaAWIOIDNoXteMXeOxGGwnSx+Yf5rGk4RdCgDxmp8dj7ad252J8p8DUG+w9li5ANG0SOo3WHtNiQaxQ/wCGnksBiMOyHSO0PmPiNgIMSrNoyHqND4uF8Ju2NZJVNGc90HQAKbRGc1ArpsF3jh0eEnKlzDXQkg813PZMThZQ8LZCPIc4Izg3j5DZ3YBAKknIAMpNjBAxG70PZH1j9Y/yjMNZPD7HHTUlgOyM5Zfo3ar10AAVN9iuDgCj6zXnqFw6SbVxGIZtRN3QMg6ByICPEFk0N2h47x0EWnjWEpjHGypBuFcp0ggVplvpfwhr2wbntr/MPtDxi46RHPC4aJwCCM4PyEN0YZ+0wBkIzDMnTlOqgzniBTkYU83JVjGRR4z+6nE/6TiH/Cc1jJzNnXmbKPta2+QcRjZMiLcNJyKOk0FpcRM1ZXYknWeIGGUGtlcZCK8jLHIBZ5DnPEV0YhwagjKCMhtBiv8Ai91xoYZeu5hqI+QMJu1Dd7xvGqj1j1cZoTlF45v9/l5H7MHtN5M/Glw2IJ/TSgX/AFWGemgitaX3DRZXRgVIqCMh5dtyXynurnPmAzn57YjETmrkjmAAFANXGSQZj4s9gwNx4aKpJtQi/wAMzju5BzftfxoSPrCwikq2EJvGdda/OMh57JLE4aNhUEcsMsl7m5Vzk+YZz89LPPO9ZD1AaBoAt7YDsNl1Hjx7WvqrwLGMhy81gqLQWLAfiD9qeFmK5dnjq9Owpr05uDYck4VjeNH2hr0jPz0sskbAowqCM45VJiJmpGo/3AazkFmnlN2QDMBmHn0ngIIutUAqdX7627M3i/fa6RfHbvL1nzWvdfH5rAySV1C6wVRQDglfQAOv/dwyJmBPhKHJYmN9nVlFrnXx+a3eXrPmtfIvjt2pvF++1Wq3P+6wVQAOEYPEN/Tsbifok/Mc+g36eVfp4m/poz6TZzzDIOk5/CTDm+fhl5/mHGAAqTYGZqah57XRDpv8tqezA5rrPGTkPhf0szf1EYu+0unnGQ9B08o9lG1J5bhqGc/MOeubwuycjCnTm4ZWGSvku4zykXg0Hz2McQBYZTotUkEc3mpb3Qrz2Z27xPhYsREe2p6xnB1EXWixER7Dio83OMh5MSTdaWav4eRfujJ15ec+FqMtgs1zacxsRG1XOjNr47BHIB5FJgHNx7S/zD5+g8mdFP4kvZHN9I9V3TxKM9W0C8/ut+HCKaz5qW7idR89vxIRTUfPWwCvR9BuPKSSaCxCVc6snX+63ZiUc9T5rXohHMfPaksRGsX+a21E4I/bNww4hO8jA+cdIutHKhqjKCOYivJfYg9iJadJvPzDo4CSbrGOA0j05z5h4+MI5yTHpzjziwINx8Dk4mTwJdzzDTbtGiZhm/fxQ6MQwt7OSgl8v79XCYWPaianQbx846OSM7HsgVPRaWZu87E9Zrwfp0N30vN858B+nc3fR83Gu8FfxSzGgFi57gyDV+/jhlNCLBj3xcfP08DQk9mRCOkXjxV5Ji2BvK7PpEDyE8DucgBPVZnY9omvgFde8DWyOMhAPJNgG9zToz/N4EJXssKfOP218GFmzK4rzVv8XJIIhlaTxAH5yOCYjV4yPBRV1+U8NOQwroBPWf3eBiYZmHl4cPLXvIp6wDyPBR6Ax69kfNwTDVXqIPgoQdZ6yeRDghbSCOr/AH+BiXSw8vDg2+zTqJHzcjw66IvKx83AyHIRTrs8bd4GngEjXvE0sqDIAB1cB5EWA7SmvRn8/R4H2hHZQeM5PPwwDQW9Yn5+JJK/cVSTzAVNpMJ+mMUlCVq1doDLmFDS+l91b7vDQ/kj1n4f1EYvAv5tPRn1c3gP1EgvPd8/Tm5KQcljQfhnJ5ujjqiirE2CfSyk6+GP77eW3sGBlxdO4tLtG0TcK6LznpQ1sFxO73jjOcMHpzjZXxVOq0eIw8geFxUEZD+2Qg3g3G+1DkskcIoiYsKBoRmpT0Wp4aH8kes/EMmHHOvm83VYgih4gAFTYSYgXZl8/m6+TmOQVU/tdYsBtRafPo8nF2Y1qf2y22jfKc+jUOJiMWBUxrI1NOyCQLSnGyExgF3Nb2JOSuapN50A0oaWnxmDg9lNFQ3FiGBYCh2idNxy1tvHDMfw43Rhq2wwPqg9fB2LwcaB0K4BPUK+Gh/JHrPxfxIwTpz9dvw5SOcV81vfCnNb8SUnmFPPb8OMA6c/Xxtfhq8YnY2W0i7xZPFbsT9Y/fa+cU5v32q7Fj1Dxee2yigLq4smGk93JtqeZrj5bNtR9oVBB7siE5QddKg5QRQjKLRYTCRujO1ZA1Lgt4AIygtfW7u3gVs+IJHtZnrzKLlB8Z5iLS0lVscQQiAgmpyFhmUZb8tKC+z7wkBMUIN5zuwI6aAknQaafDQ/kj1n5BUeEvycg/RT4N2oa7SkZ78hp5bNhl3SraGkN6nSoWhB17XOCLPJCAmHA77VAJ0CgJOsi4Z76C1EjjfWrgetsnxWU46ZIos4B2m6PojnqaaDaPCYSPZiXrJzknOT+4XUHhofyR6z8W4Vt3D1W7ht3D1Wv8Dl4MlslslslsnBl8DcCbdw27ht3D1cZMTi8OTOWIqGYXA3XA08Vg64EMw+uWYdROz1iwVFAUC4C4DkMP5I9Z+IC4q1ruJQiottrk5PS1WFW4tGFRbaXu8RPvt5eRw/kj1n4UGscd+Y8nTn478RPvt5eRw/kj1n4QRYEZDwVY0FiCKLwnSbuTq2g2qMnBVjdYhhTRw0zniJ99vLyOH8kes/E2W7tuytTarHguvXRa8GtqnJyjZYXW7C367VY38FMq2yGtto8RPvt5eRw/kj1n+TE++3l5HD+SPWf5MT77eXkcP5I9Z/kxPvt5eRw/kj1n+TE++3l5HCf/JHrN8mR/eby8jwc4FxDKeihHlPyZg0IvK19Ilvn5HMqisidodGXrFen5Lgw6/Sa/UMpPQK2VVFFApyQvGv9NIarqOdejNq5j8lHGzL+I4oupdP8Xk5+Svh51qh6wcxGsftdYxyiqHutmI+Y6Rm5r/khMXi0phheAfpf+Hy81qDJyZoZ4w0ZzH9rjrFmkwLbcf1Tcw5jkPiPPYpLGyuMxFD4/kSmHgZhpyAc5N1llxhEkv1foj/ADdNBqPKtmaJXXQQD5bVOFAOosPEDTxW92/pG3cf0jbuP6Rt3H9I27j+kbdx/SNu4/pG3cf0jbuP6Rt3H9I27j+kbdx/SNu4/pG3cf0jbuP6Rt3H9I27j+kbdx/SNu4/pG3cf0jbuP6Rt3H9I27j+kbdx/SNu4/pG3cf0jbuP6Rt3H9I27j+kbdx/SNu4/pG3cf0ja+Jj/EbApg0rr7XrVsABQf7b/8A/9oACAEDAgY/AP8AYpD2GBmfmRj5Bbs7tcc+yvrEWvwyrzuvzE2v9iP4j/lt3oPSP+W1yxHmfzgWuwQbmdPnYW7e7Juhdr1a2pPA6H7SkeUfJsOEwcLSYmRqKqipJ/bKcgF5usk3xDjHOIIr7OIgBdTOQ20dOyAAchYX2lxnw5iJJGQVML0LEZ/ZsAKnQpFTmatASCKEcAVQSxzCwK4Mxoc79nxHtdS2BxuP5wi/zN/lsCcKZGGd2J8QovisP0+Eij+6qjyDwJBFRY+33fETp2QD1ih8diYfaQt9lqjqbaPjFi2DxaSDQwKH+YdZFicXgnVB9KlV9Jajx8MW8t/TvBhnAKRpQSMpyMxYEIDmGyWIN+zZn3HjpI8UBcspDI2qoUMvP2hqz2mwG8cO0WKQ3qfEQchByggkEZD8hpFEhaRiAABUkm4AAZSTcBZcXjEDb7mXtnL7NTf7NT65GU3VKgcIxe6cLXDYtS7ZAqyA0epOTaqH0klqC6yvvLFF2+qlw6WN56AttnB4RI9YHaPOxqx6TyImXCBJT9JOyee64/xA2wcsmIEu64j7R1YUY7PdU5VYFqbWSq1FOEpRU3pECYpNf1GzlGz/AFT2hnBnweLiKYmNirKcoIuI/fkOUXfIR+J8fFWNCVgBzsLmk/hvVftbRuKg8RnA7cZDdGQ+I16OSy4gjtSNdzLd5a9XE/7lwMX9TEAJgB3o8gfnTIT9S80CfIOB3VBc0r0J+qovdv4VBOvJnthsDhIwuGiQKo0ACg5zpOc3niPE4qjAg8xuNpYH7yMR1Z+nLyNY0FXYgDnOS0OHXIigc+k9Jv4kkUqBomUgg3ggihBGcEXG2M3cAf01duInPG1dm/OVvQnOVPyBvPf0q319jH4nkPqAH7w40WNQdl+y3OMh6Rd0cjOIYfhxCv8AEcnVeeenGw28MGo/W4YkUybSPS6ukMBs1uvbTZo5EKyKaEEUIIzEcu9nENmBe+5yKNA0scw66C+2AweFWkQBOsksSSTnJ/cLuNNh2+kLjoIyHr8VnjcUdSQRoIy8JXDYeSRhlCqWp1A2ZJEKuMoIoRzg+FoMtooiPxT2m5z5rh0cbGKcns2PSBUeMWM0NE3gBc2ZtTfM2UaxdaTD4iMpMhoQc37ZjkIvHLBh4rohe7ZlHzk5hn5gSI8LhY9mJesnOSc5Oc/NYYJ2pMlaDSpNbuYk11U4+I2M+yTz0FfPwYXd6GiuasfqoL2PPS4ayLR4TBQLHAoyDPrJyknOTebT4uKIf9SgUsrAXsovKHTUV2dDUpcTXwmDWTu7Y683jpx5IA348goBqNxJ1UqBr6eD2kQC49B2W+sPqtq0HMdVbPDMhWVSQQcoIzcqhwmGSsrmg0DSTqAvOq0eFgFTlZs7NnJ+YZhdwBlYhhkIyiwVmWRftC/rFD11t+JggeZqeUG3bwrjmIPmtfFL1L/mt2YZSeZR/NYrhcOEOkmp6BcOutmkkYlyaknOeDeuMI7SRog/jJJ9QcFDbeODUUSOZ1H3Qx2fFTwgINCLBMTCJAM4ND03EHxW7UMoPMp/mFvdy9S/5rdjDSHnoPnNvw8EOlvMo8tiI9iMahf1mviAs0kjlnOUk1PC28cIn9Yg7QH01HlZRkzkXZhyr9XiE/rZhnyqmULznK3QM3hN9J9KsR/+Th3sBk21PWik+PjJDDGWlYgAAVJJyAAZTZJ994goTf7NKVH3nNRXSFB+9YBN0xsdL1cn0iR1CzRtuyONiLmjARhrGzQHpBGq2M3e7bRiciukZQaZqgg0zeFGOwyUwkxvAyK+UjmbKOkZAOUe3mWuEgox0Fvor851Chy+FGHlakWJQpq261TroVGthw72nQ1QzsAdIXsg9IHGxm+JVDSo/s0+z2QWPOQwAOjaGez7u3SqPilud2vVT9VQMrDOSaA3UJrTalmilTQyADrTZPjNiBudPbUy+0NK/d2a/wCLptiMdimBnlbaNLhzAaALhqHhZ8HOPw3WnMcxGsGhFp8JOKSxsQfmI1EXjUeTBQKk2gw1PxiNp9bnL1XKNQ8KroxDg1BFxBGQi0eG32xixSintKEo+s0qVY57tnPUZBKm68UJse6kLs1olfpE0pdlAFSTStBfapy8aeHAY6SKKTvBTSuao0HWKHXYkm/kMO9YlvFEfm+i3X2TzqOTRyOtYYBtnnHdHXfzKeIk+CwHssC2SaYlEI0rcXca0VhpNlbe3xBIz51hjCgczOXrz7A5rU/X7xrp9pF/+ilmbdPxBIj5hNGrg87IUpz7B5rST4zAe2wK5ZYSXQDSwoHQa3VRr5THh8NC0k7miqoLMxOYAVJOoWSfeTxYDDnM/blpp9mtw5ndGGiw/Xb6xkr/AGBHGOpllPjsfY7xx6PreJh1exB8Ys0m5N9xzH6kqmM8wdS4J51Ua7fpt9bukgkOQkVVqfUcVRv4SaZ+HEYSXuSIRzVyHnBvGsWlw8opIjFTzg0PJTiWH4k7k/wr2V+c9PBHDDGzzOwVVAJJJNAABeSTcAMtoN7fE8Cz70IDLCaNHFnG0Mkkgz1qinIGIDWAAu4lDktPvb4ZhSDewqzRCixzadkZI5DmIojHvAEl7SQTxskyMVZWBBUg0IIN4INxByHjphcDhJJsS2RI1Z2PMqgk9VlkmwUWDhOedwD6CB3B1Mq2Vt6fE3azrFD5Hd/5BYGbHbwkbXJEo6hDXxm1NjF10+2/8Nvw8VvCM6pIz60LfNZju34mlQ5hLEr9bK6U59k81mfBrhsZGM0cmy9NayhBXUrNqt+m3tu6fDT6JEZK6xUCo1io18eLdm64qsb3c12I0zu50aBlY3C+yrgYRJvArR53A9o2kD6iaEXVtFjfxZcBvLCJPhHF6uKjnGgjMwoQbwQbPvfdG3LuEteDe8BOQMfpRk3K+UGivfRm4BiVH4c6V/iW5vFsnnPJEjQVdiAOc3C2HwydyNFUdApwf937yhq5JXDKRkAueXnJqiaKMc6kcf8A7v3bDSVSFxIA7wNFSXnBoj6QVN2yxPEhwmDw7y4qRgqogLMxOQACpJtFjvjHEFQaEYaI380sorTWsdT/AOYDdYYTc27YcNBnCKAW1s3ec62JOvwD4TeODinwrZUkUOp6GBFpcX8KT/pMZl9i5LQsdCte8ZP8a5AFUX2l3bvjBPBjFzNkIzMrCqspzMpIOniQ4bDxl8RI4VVGVmY0AGskgC0OBRVbHyAPPIMryUyA/UTuoNFWptM1eNLh8RErwSKVZWFQykUIINxBFxFpcJECd2TD2kBN/YJvQnO0Z7Jzldlj3uBcSB2oZAf4W7J8ZXq5Ju9COyr7Z/gBYeMDgwG7YPfYiZI153YKDzCtTbCbvwibOGgjVFGhVAA6aC85zf4DF4DFptYaaNkcaVYFT4jbeG7J/fYeZ4zrKMVrzGlRq4cLuzduHaXGzOFRRnJ8QAFSxNygEkgA2WRlWbf0i/izUyVyxxVvVBnNzOb2u2VXwb7u3vh60qY5BQSRMfpI3VVTVWAowN1pd1bxWo70cgHZlStzLoOZlyq1ReKE8DbxmSsGBiMg0e0bsR9Q23GgqPAzY5UrisC4lU59gkJIOahDn7g4Mdh6XvEwHPSo8dOSYmYi5IadLMPmB4Ph9HFVV5H6Uhkcf4gPBb+VBRWMT9LQxlv8RPC/xXjYv6/FgrDUXpCDQsNBlYZfqKtDRjXggwjMRFeWIy7IvNNZuGqtbCCHBRCKlKbINecmpPOam0OPwcYSGRtllGQNQkFRmBANRkFLsvEWCYn9Oil2pcSAQAK6yRXVWl9hAmCiENKU2Vp03X9OW0GIwi7OGmr2cysKVpqINwzUOag4cThkjH/VYAZMO2fbAvSv1ZQNk5q7LHuixVgQwNCDlB4PiDFgdt8QiHmRCw/+Q+B37hXFVkwcy9cbDhxmHpckrr1MRyPecukoOoMfnHB8OyuaK0jp0yRPGP8AEw8F8QOhqqvGnSkUaN/iB4MFgIffTypGvO7BR4zbCYDCps4aCJY0GhUUKo6gOF/yG8q8EH9wvqPxMb+T/MODAfmn1eJv2CFKYeVxMv8A6yh2poAcuANA4PiHBV7aTxv0OjL/APj8Dv7FuaCPBzN0iNqDpNAOHeS0yvX0gG+fkeMfTPTqVfPwYTH4c0xEEqyKftIwYeMWwG9cI1cPiIlddW0KkHWpqpGYgjwGO3pi2phoImduZRWg1nIBnJAtjd4Yg1xE8ryN952LHxng+FFfJ+vhPSHBHjA4j/kN5V4IP7hfUfiY38n+YcGA/NPq8Td7LlbdsZPP7aceQDq4IsJO9MNjkMN+QSVDRHnLDYGt/AjdUb/1ePkC0z+zQh3PXsJrDHhxZ0qh/wACj5uJhMBh6fqJ5UjWpoNp2CrU5hUipzWwXxD/ANZjx+DLrHPsxGP2Lv3SKu+3GWGxtkRnaKDY7V3hcT/ct6kfD/2lvSbZw8rk4dibldu9ETmDntJ9uovLjwH/AGjuubahjcHEsDcXU1WKufYPaf7YUXFWHDuLeTmkcGMhkb7qyKW8QPEf8hvKvBB/cL6j8TG/k/zDgwH5p9XiYqFTUYbDxRdNDKer2tDr4EkjcrIpBBBoQReCDmIOQ2jaWQDfMChZ0yEnIJVH1XpW7utVcgBPGxGPx06xYSJCzscgA/agAvJoACTbE7zYFcIvYhQ/RjUmlftMSWbLeaA0A4Zvy08lv+qQNHgtwbRAnmDH2hBo3sY1FZNk3FiUSoKhyylQ825Pi3DYvFgV9nJC2HDalcSTCpzbQUVykC+2M3PvjBPh954d9mSNxQqcvMQQQVYEqykMpKkGwZTRhbE4veB9pisR8PNI5P0p4IdvaPPPEG1eGxP9y3qR8IINCLQbm+LpiGUBUxJvBGQCbPXN7W+uV6GrmOfDyq8LiqspDKQchBFQQdI4kk08qpCoqzMQFAGUkm4AaTafc/wjNtSNVXxIuAGQiHOTm9pkGVKkhwWY1Y8TdONMm1i4kEM2n2kYCknW67MnM/DBi3BMV6sBl2TlprFx10pYTw4uNoqVrtC7n0awaEWh3fg5A8UbbTMMhahAAOegJqRdeKZOIs01f07qUbPQEghqaiBXVWl9vbpi4zDSu1tCnXW7ptBhsI21h4a1bMzGmTSABcc5JzUPDjN44yTYwkEbSOdCqCTzmguGc3W3lvfEe+xM7yEaNpiQo1KKKNQ4YN67qxBjxUfSrKcqOPpK2ccxFCARFhpZFw2+6UMLG5zpiY02wfq98X1BA2jxGx2+MckMN9Ab2cj6KKO0x1AGmU0F9v0mHVoNxI1Vjr2nIyPKRcToUVVdLHtcTce4WcquNxWFgLDKoldULdAJPRbAR/DWCjTGSMmFwq7NY4VVCS+zkbYRQFU3F2UsGUMDun4d+It6/rt244yKQ0cStGyxvIHRo0Q0qlGVqrskkAEVt8H77iQDGYqCeKQi7aGHaJkJ0n8dlqb6BRkA4P6rsyJ8Mu1DmefDsyqde3IF5/DYn+5b1I+LXc+9JI4q1MZo0Z01jaq1P1gA2uyrvTcMEx0xu0XTRhKK81BzWr/23NtfmrTr2Pmsy7r3DBC2mR2l6aKIhXnJHPb/APsb0kkhrURiiRjRSNaKSPrEFtfGOHx8hG48YQsuiNh3JaaBUh6fQNbyqiyujBkYVBF4IOQg5wfCn4M3VNUBg2KYG6oNVhrqNHk0EKtahwOICDQiyQrvAYrCrkTEAyUGgPVZOYbZAzCwGO+GavnMc1B6LRmnpGx9h8Mys/2plUdYjbyWePduHw+DQ/SAMkg/ifsf+3XXZ8ZvPGyz4psrOxY8wrkAzAUAzDi4DfODp+rwksEyVybcTK611VUVtB7LFEQuVdWWhmwmJVSCrrpXaKuposiNtIQCjjH/ABBv/F4fEQYeBkwjRE1Zpaq7srAGNkiqhWrAmU7LELW2H3THG4wW7cOIwSCA0sh9pKykgVAHs4zT6SNfS2B28BLF8LpIrYjEMpVDGDVo42NA8jjsgLXYrttQC/DfB+EdVx28pEBQXbGGgYOTQd0NIsaKLgy+0A7pHhsT/ct6kfIINw/EbNJuQXRy3s8A+qRleIZgKsguUMKKIcbgMSk2EkFVdGDKw1EXeY3HwRZjQC2I3J8I4hZd4EFXxC3pFmIiOR5PtiqJmLN3XkkctIxJJJqSTeSSbyScp8PH8T7s+IsPAZGKGKWN7vZnZqHQtWuWmwKa7Rb6n+PpYqEbcOESiTAGoWVptpWT7JhJFaq6NQ2wuE3i74re0jCuHgKNJHGbzJJtMqoMmwrEM9aqNkMwDYnGYyAn6MuGZj/7RlX/ABWlT4W3bicfvAjsmRfYQA5ixJMppl2RGtcm2uW2L+IN/wCL9rvCY8yoo7sca/RRRcBzsxLFmPhcT/ct6kfF2cLhZJG0IpbyA2qu4sXTXE48oFv/AKPEegbEybkxYGn2TkdYWlik0TI+hgQeo+AMu5N5PEhNWjPajf70bVUml20AGAyMLJH8QbgJfO+HbL/6UmT/AFbBpJMXEdDQ1P8AgZx47f8APz/6Mnmt/wA/P/oyea3/AD8/+jJ5rf8APz/6Mnmt/wA/P/oyeaxK4rEudAhb+ag8dmXcu4cRLJmMzJEo10QykjVVSdIs+Hx2O9ju5v8AgwgpGRoa8u/M7MK3gDwFMJg5ZT9hGbyA2qu4sV0xuPKBb/6PEegbEy7kxYXT7J6dezSxSRCrjMRQ9R4r7m+H97om6lAcRvDC4DOKsQzJt3m+m3TQBZ8PL8UvBAwoRBHFC3RIiCUdDi0k+IlZ53YlmYlmYm8kk1JJOUm88hxP9y3qR8SDG76w4n3gwDbDXpHX6OzkZh9ItUVuAuqVjiQLGMgAAA5gLuIYcbhY5YjmdQw8YPXZt8boBGCDASRkk7FTQMpN+ySQCDUgkUNLhyZIo1LSMQABlJJoAOc2in3lAmI3kQCdobSIdCqbjT6zAkm8bIusFRQFGQC4DiGHH4OOaPQyg05ibwdYINk3lu0sd2u2yVJqY2N4vylTQ0JvBuJNRxJfy08nI8T/AHLepHw7lhkFY2xUQOsF1qOnJx99xyDs/pZT0hCQeggHk+5UkHZE210qCw8YHH3ysguEQYc6srDxjiS/lp5OR4n+5b1I+HD4qL3kUiuOdSCPGLYbG4dqwSoGHMRWh1jIRmN3BJjMfiFiwyZSfIBlJOYCpOYWmwuLg9hu5iBHIctdMgyKGzEXL9KoqwV0YMhFQReCDkIOcHgxy7X4+IHslGnb7/QE2umgz8n3fvACohmViNIB7Q6RUWinhcNE6hlIyEEVBGojgfGbxxAjhGTSx+qoysToHOaCptNhN4RDDRM/4Tk3EZKSHIrE317t+ySKVYEG7gbAhvx8U4UDPsqQ7HmuVT97iS/lp5OR4n+5b1I+Id34+Npd1kkrs02oycuyCQCpN5WooakZSCV3VgZJZ6XGSiIOgEs3N2ee36jeWJLkd1Rci6lXINZvJzk8CxQye1wNb4nqVGnYOVDzdmt5U228Rg8Sk4HdAVgTqbaWvOQvNYYiZdjDICI461Cg5STnZrto0zAZAOULuze0byYFe4y0LID9EgkbS1yX1XIARQBk3NgHeb60tFUa9lSS3SVscVvLFNJLmrkUaFUXKNQGs38CYct+o3eP+G5NVH2HvK81GXQtb7GRsJiRiKdyikV+9tUprIB1WfHYvsrSiIMiLmA0nOTnOgUA4Zfy08nI8T/ct6kfyZL+Wnk5Hif7lvUj+TJfy08nI8T/AHLepH8mS/lp5OR4n+5b1I/kyX8tPJyPEjP+ob1I/kyb7ieTke8cKTeCrjpqD5B1/Jm8ZAbg+z6AC+Ucjw7u1IZOw3M1KHoYAnVX5LxWMfIiEjW2RR0mgszuauTUnSTl5Iscr/1sQAbSwzP05/tc4+Sl3Zh3rDEauRnfJTmX1idHJY8XhmpIvURnU6Qf3i8Cwmgako7yHKp+cHMc+o1A+R5MBu+SuMNzMMkekD7fq891qnLyZMRhZiky5CPIcxBzg3Gyxb0T2U31wCUPOLyvjGsWEuHmV4znUgjrHyIWxmKVDorVjzKKk9VLPh92qYYDcWPfPNS5egk6xktU5eU+0w87xvpUlT4qWAGPLD7Sq3jIr47e+j9AW97H6At72P0Bb3sfoC3vY/QFvex+gLe9j9AW97H6At72P0Bb3sfoC3vY/QFvex+gLe9j9AW97H6At72P0Bb3sfoC3vY/QFvex+gLe9j9AW97H6At72P0Bb3sfoC3vY/QFvex+gLe9j9AW97H6At72P0Bb3sfoC3vY/QFvex+gLe9j9AW97H6AtdOg/gX5wbES7xkp9mieoFsWYksf9t//9oACAEBAQY/AP8AoUj85yePrkdomuV4jr6tHkB14PXmax0/xSzz/udxFJrwem7PLp+RStDX5O8ij45ftBv4NVf/AKpl4/AyQ/8Aixf3rJ45veT+FU1/wJH40OSaI/8AmUro/fWu6j93gd3mseNf8dOtf9Y7rjWtarWBprrBPFMNPX+jZuX3NsZDIWYadKpE01izO4SKKNe1mY+knkANSxIABJ4krbQx1ZakbFRkstHLJNZAOneQUo5YFrRkjl3hdip5qp5CHHbxqVKaTusUWZoiSKvC7HRfn9aWSYxxMTzlRulPSgGrAMpDKwDKykEMCNQQRyII8jO7KiKCWZiFVQO0sx0AA4ZXyKWpV1/RUFNokjtHep/NgR6i44ZcbiR6ema9Pr8nVXrgfyvBAvJUQ/iU68UenySOss40/h8H55kLtrXtFi1PMPk0kdgB7PgQVJBB1BBIIPrBHMHgfNsvfjUdiGzLJF/9mVni/e4AsmnfX09/XEMmn5r1TAgPtKnhVyOPtU2OgMkDpbiHrZgRBKo9gVjwooZKtNI3ZAX7mwfkrzCOY6exdPLYw21qtXJ3artDbydsyPj4J0JWSCtDA8UluSNtQz9axhhoA47Ej3PjKdyi7APZxcb1btdSebiGWeWvaVfyP0R/O9HFfK4i5Fdo2l6op4ie0cnjkRgskM0bcmRgGU8iPuHJPPIkMMMbyzSyMqRxRRqXkkkdiFREQEknkAOGx+Plki21j5iKkQ6kORnTVTkbK8iQ2p7lG/AQ6kBmOnkbH56903cBKtOuCHls2sdIheiERQWc1+h4deSqiJqefDx4akldOYFq7pLMR+UteM9zGw/OaQezgtkb9m1z1EbyEQqfzIE6YY/+qo+I6jhVgvyTwrp/NrutqHQdir3h72JfYjLxkIIaklHN3kFCparyh4IxPytWU6ik8EsdYP3enX0yFT1eVZNZLGEuuiZbHg69Sfgi5VViFS7XXmOwSL7jEcmWrkKE8dqncgjsVrER1SWGVQyMOwjkeYOhB5EA/cIbJxc/TNYjjnz0sbaNHXcCStjdRzVrA0klHL9H0DmHYeYkTHSO5G9c69gf+MiPyl06R/C+KwU1Oq1Iepx6prGjEH5IlT93zP7F5Ob+ZX5Hlwkkjcq19tXlogtyWK9zZByAmBABMn3Bymet6MlCszxRE9JsWnIiqVge0Gey6rr6ASfRxcyd+Uz3L9mW1Zlb8eWZy7aDsVAToqjkqgAch5kU0Z6ZIZElQ+p42DKfvEcV7Uf4E8SSga66dSglT7UbUH2j4m8sjBY40aR2PYqIpZmPsCjizbfXqnmeTQ8+lWPuJ8iJoPveZFYgkeGeCRJoZY2KyRSxMHjkRhoVdHUEEdhHGOzBKC6FNPKRpoBFkqwVbGijkiTqyzIPQkgH3Awm1YJCF6WzWQVTyYkyVMfG2n5PTOxU+tT6vOmxsje/ATPACe2GRh3qgeqOU6/9f4mKaNpNebpOnateMhpT/wBc6L7QT513E5FnGMzMcciuNXFW9U6umbuxz7uWu7CQrq3uLyOnCTQyJLFKqvHJGwdHRhqrI6kqysDyI+Pd9ORLalDCpTVgJJ3H4zHn3cCH8JyPYNToOMpkL7h53eGMBR0xxRRV4kjiiTU9MaAcu0nXUkkk+dXuJqe6cd4o/Hib3ZU9WrITp6joeI5Y2DxyoskbDsZHUMrD2FT5at7xE3/srYNK/K0FG5vTdWC2tVuTp09UNWxnL9GKxKvWuqoSRqPXxTzODyePzOIyMCWsflcTdrZHG360n8XYp3qck1W1A+nJ0ZlPr+FJJ0A5knsA9Z4nnU6woe4rD0dzGSA3/uMS33/Oxzr2/PK6H+DJIsbj76OeFrWC9nEyP+kg11krFj701XqOg582Tkrew8+IbdSZJ686B4pYzqrKf31YHkQdCCNDz+ONbsaSTydSU6obR7EwGunpKxR6gu3oHrJAM1+9KZZ5j8iRoNeiGJNSEijB0A++dSSeDko0LV7IQSsoJEU6Ksej6fgrIqgg+k6j5fOqd5r7pnSMntMSzOF+8vMD2DyeJ3j7lalfLZHa+Mgx+zNu2ZWhj3LvvcFqLEbUw8rRsk/zA5Kyti8Yj3sePrzyLqUHGf8AFbxm3pmN8b23FakntZLK2XeCjWaWSSvhsHj1IpYLAY4SFKtKqkVavHyRBz12X4Wbn3ResfZ38Zty4/Z27NtZO7NLhNnbl3HZixu3fETCRTO0ODs0sxPBFlni6I7WMeRpleWvWeL4TItFr1irIOXaEYdMjD1dMZJ9nnw2ihFWlIszyEaK00fvRRIfxn69GPqA9o14EM5ebE2HHziAasYHOg+dVx6HUfhKPw1HrAIisV5EmgmRZIpYyGSRHGqspHaCD8asX7knd166F2PIs7diRRgkdUkrkKo9JPEt60ekH3K0AOqVq4JKRJ2annqzae8xJ9nkZHVXRgVZHUMrKeRDKQQQRwXRJajE6n5tIAmv/pyrIij2L08fosk6+oSVlf8AfWaP+5x+ju1W/hpKn+CJOOU1A/JLOP7tYce/YoqPZJOx/c+bgfv8K9629gAgmGFO5Q6ehpCzuyn2BT7eEiiRY441CIijRVVRoFAHYAPJ9l/wmgsOlLeviFv3f2RgRmAlk8ONvYTA47vwpAMevibOVVuRZNRzXlwkkbskiMro6MVdHUhldGUhlZWGoI5g8fZ58W70/wA5yviB4OeH248/L1F//wCmu7Zx39p4y50LmDcCWULctenX4QqwDKwIZSAQQRoQQeRBHDSUrD1CxJMLJ30IJ9CHqR41++2nHuWaLj8550P7ncMP3+P46gPlmn/vVjx+ku1F9fQsz/4SR8fpckx9Yjqhf+807a/ucBpRYtkc9JpAseo/NhWMkewk8LFDGkUSDRI41CIo9QVQAPKmHyEv/wCutSaVpXPKlZkPYSfwa87H3vQrHq5AsfjRx9STXG4+RlBU6patjVJJ+XJkj5pH28tSPwvhPsaZsIzUZKPjvimkAPTFbhn8JLaI57A1iGdio9Iib1eX7K09pmaWPa28KSliCfm+N8UN846moIJ91alVAB6ANPOzO6t15vFbb2zt3G3MzntwZy/WxeHw2Jx0D2r2SyeRuSQ1aVKpXjZ5JJGVEUEk8ZfZP2MPD7FbuioTTUm8ZfFCvlYsBfkRjHJZ2hsClYw+Yt0tPeguZS3ULMPeoMmjPPczH2pfEPbsUsjtDj/DiTFeGdGnExJSvB/YXG4G5LHEp0DzzTTEDVnY8+MfuGj9pPxB8QadWzFLkNq+L+Zu+Ju3M5UV1abHXk3TPey9CCyq9Jlx9ylbQH9HKh58eEPj7hsa+Gq+J+zMbuGzhXn+dfsTM6y4/cWFS30RG7DiNwUrNaOcpGZkiDlFLdI+EOLuSdWQx8a9DudXtUxoqSEnm0kBIVvWCp5kn4watd+m/kw8ERU6NDXAAsTgjmrdLBFPI9Taj8H4Wxv3bWPlyG5Ps8bvp+JliGtE01ubYdmhb27vxYUVTpBi6uQq5ey5ICVcVI3PTTy/ZV2PlKz0srS8GtpZvLUZU6JqGW3nUO9MpQsJ+LZpX9wSRSD8tD53hJ9kPbN+9h9r7r2uPGHxLNWSSFd11RuTKbd2Nt6xLGyd7i8VldsZG/ZrP1xzWfmUhAaBScL9oT7VuS3TtDwz3NDFkfDnw125LHhd174wj+/X3ZuPMXKlufb208oo1oQV4lvZGuwsrNXgMDWTjdsbV8S/CzJrEBFuHZ3ibuDMXmmVdFe1S8RW3xiJYnfnIkcEBI1CsnIiKzP9rfecm0xYRp8JF4TYSHcUlQOTJDFuh98T42Gw8fJZTiJFVuZjYcuNg+CvhrRs4/Y/hxt2ntzAQXrRu5CWCuXms5DJ3OiJbWUy1+eW1akVI0exM5VEUhR8JVyNVtJq0gfp10WRD7ssL6fiSxkqfYfXxVv1W6oLUSyp6115NG2nY8bgqw9DA/FizEKqgszE6BVA1JJPIADi1dDE11b5vSU66LUhJEZAPYZSS5HoZj8LexOVo1Mni8pTtY7JY3IVoblDIULsD1rlG7UsJJBaqW68rRyRurI6MVYEEjjcHiH9izG1fErwwzV21lU8JbGbx+J39sA2ZHnnxODs7huUcZvLbFIkimfna5eOIpA8Np0NmXaub+054Y5Xwg8C9oZylm94V94T46vuPfsOKsx2hsnAberXLWUStnpIlhuX7CV60NJ5WhklnCRlIokSKKJFjiijVUjjjRQqIiKAqIigAADQDztmbw8cPBbY3ibuTw/7xdq5PdWLN56NaWylx8dcriaOpnMP87TvRSvx2qiyM7CMF3LRV68UcFeCNIYIIUWKGGGJQkUUUSBUjjjRQFUAAAaD4jZwM7+6/Vco9R7HUD51Auv5SgSADs0Y+n4tNDG3TZybfMo9Doywspa0/r07kdHsLjzJauSyvz7KRah8Rh1W/ejcdsdgiSOpTkHL3ZpY30OoB4dcBtKnBGCQk+XvzWncehnq0o6axH2CZ/l46v2VtHp/xf7Oy/T2+v8AbvXr9/hVz20qc8ZIDzYi/PUdB6WWtdjurKfYZU+XiKrj8r+z8pLoExGZRKN2Rz2JXYySU7khOuiwyu+g1Kj4zNbuWIKlWujSz2bMscFeCNebSTTSskcaKO0kgDiStho7u6bcZKl6IWpjA68ipyNpS8g17GhhlQjsbhji9t7epRk+6t58lkpFHtkgtYtCfb0D5OAbGH2pPHr7yrTy0MmnqWQZmRR99TwkO5ds3McCQrXMTajyMQJ/HepYjpTRRr6el5W07Aezg3dt5inlIlCmaOFylqsW/BW3SmWO3VZtOXeIuvo18tTIQH9LUnjmUa6Bwp9+Nj+TKmqn2HivbgbqhswxTxN645UDofYelviopI2sOLgSHQHVfnE4Wedh7ekoh9qeSazZljgr14pJp55nWKGGGJS8kssjlUjjjRSWYkAAani1gNkWpsbg0Z4LOZhLw5HL6aq/zWT3ZaGPb0dOk0q82KqSnBJJJJ1JPMkntJPpJ8zUciOYI7QeK2B3rZnymBYpBXy8pefJYcHRU79/elv49PSG6po1/BLACPiG1WmisVrEUc9eeCRZYZ4ZVDxSxSIWSSORGBVgSCDqPPkvZS/TxtKLnLbv2oKdaMf+ZPYeOJOz0nh4q+Tu7hsISrRYGi00QbsGl289CjKn50ckg09fZwyYTZI6OfRYyuYPUfV1U6lLRfvTngiti9p01190rQyc0oHqZ5sw0Z+8g4173b4H5Iw/un920W5/Lx+mo7TuLy1E2MyMZ09PS1bLwAE+0H5OEXM7KpWAdA8uMy89MqPSyV7VO91/IZV+XhIsi+Y25K2ilslj/nFTrPoWxipb79Gv4zxxgenQc+PnmAzGNzFbl1TY65BbWMt2JMIXdoZOXNXCsPSPPmzebmKopMVOnEVNzI2ypaOpUjYjqc6asx0WNQWYgDhnydlqmIjkLUcDUkdaFZQT3bzD3TeuBe2aQa6k9ART0jzIMphchaxmQrN1Q2qkrRSLzBZG092WGTTR0YMjjkwI5cR7f3D3FDdccZMLppFTzkcS6vJVQnSC+iAtJANQwBePl1Inkak7ay4udoRqdT82n1mgJ9PJi6j1BR8UkmkPTHFG8sjfkpGpdj95Rxbuy/xluzNYfnro00jSFR7F6tB7PJ/w8w1gpEiQ2dzTRPoZXkCzVMR1KRpGkZWaYc+osi8ulwfP/wCHuZsF4JlmsbamlfUwTIGnt4kMx/ipkDTQjl0urrz61A8yxkMjbr0aNSJp7Vu3NHXrV4k5tJNNKypGg9ZPE+L8OqiTlS0bblycLdzqOXXi8ZIFaQelZLGg1/8ACI0PBv7jzOQzFklijXbDyRwhjqUq1wRXqRa/iRIij1fAR38RkbuLvQnWK3j7U1SwnMHRZoHR+kkcxrofTxBj9+Vf2/j9VT9sUo4a+Zrr2dc8C91SyKqAPRDJ2ku55cQ5nbuTrZTHz8hNAx64pAAzQWYHCT1bKBh1RyKrjUctCPMs3bkyV6lOvNatWJD0xwV68bSzTSN6EjjQkn1DizlJWkjxVVpKmCoMSFqY9X5SOgJUXLpUSTNzPUQuvSi6edBcqTy1rVWaOxWsQO0c0E8LiSKaKRSGSSN1BBHMEcQX7BRc3jWXH52FAFBtogaK7HGNOmDIRe+AAFVw6DXo18j02bRMjUljC66Az1/5xGfvRLIPv/FMtIDo0sAqJ6z87kSu4HyRSMfveTK5q19GxOOu5Kca6FoqVeSw6qefvOseg9p4yGXvyGW7k7lm9akOvvT2pXmk6QSelAz6AdgGg+AoZWhKYbuNuV71SUa+5YqypNExAI1AdBqPSOXGIzdYaV8vjaWSiXXUolytHYEbHQe/H3nSfUR5b2bzNuOjjMbXezbsyk9Mca6AKqgFpJZXIREUFndgqgkgcPCjTY3atSYnF4UPp3vQSEv5QxkpYvSDmF1McAPSmp6nf4OPMbftlAxRb+OmLPj8pWViTXuwBgG0BPRINJIySVI1OtfPYdzG2ogyWOldWtYu+qq0tWfpADqQeqOQALIhB0B1UeRMRWkMdrdF5KDlT0v+zaqi3fKkc9JHWGJh6UlI+BrYt5CtDc9aXFzoTpGLkKSW8bMR6ZBNG0K+oTnyYu5rosF6s0h7P0RlVZhr+dExHxSlWB0NnIq7e2OvBMSP+3Ip+95N2yRkq8tfHU+R01S7mMdUmHyGCZvgtqPISzwR5OmSefuVczkIYB8i11Qfe8sewsbYIxWAaOxmO7b3buakjDpBIRyeLGV5ANOzvnfqGqKR5Nx75qV4beaiNTE7dq2QzVZM5lZvm9SW0qsjSVqMQksugKmRYSgKltRLuPO+IO7ruYknNhLa53IVRUct1KmOrU569XGQRk+5HXSKNPxVHGf8M9/ZKfPZ3beIjz+C3BcYy5S/g47lbG5Cpl7J0a7Zx9u9WMU79U0qTMJGJQE+WzuHBJC+6s9lK+2NtSWIkngoX7lW5csZaavIGjsDHUKMrRo4MbWGjDhkLKW3Hf8AEPek+daY2Fyn9psxFbhkLdQ+ayQ24/mkcZ/ASLoRAAFAAA43DtffNs5PdmxzjZI85IiR2c5gcn86irSXhEqpLksbZpNHLNopmjliLBpBI7eSndlmcYHKPFjtw19SYzSkk0jvBBqDPjJH71SB1FOtBoHPCujK6OoZHUhlZWGqsrDUMrA6gjt8m0qBJ7uth71xV15B7t1IXOnrK49f3Pgdr3oz0vU3DhrKn/0cjWcg+sELofWPLjrZOps0algn2zQRyHX26t8TwkGv4Ed6Yj1941VFP3u7P7vk3fDGCXip073LmQmOylC/MfkENZtfZ8FtKKRSrz17946jTVL+Wv24G+Q15k8mSytj6PjKFzIT89P0NKvJZl5nkPcjPGQyt6Qy3cndtX7ch1/SWbk72J35kn3pJCfLT/5hbc/1ZuHybk/5WZ7/AHp2V5nh9/t7J/u9lPJ4nf7JYj/XDeZte1YkMluhWlwlpmPUxbETPSrs7Eks8lGOJ2J5lmPk2hktD3dnF5GiG9HXStwTkE+glcgNPX8DtWhGCzW9xYaA6ehHyFcSOfzUj1Y+oDy4Z9demqYfq80sGn3u7+J4+P8AJxgf/t2rC/8A4/JkMVcXrqZOlbx9pRpq1e5BJXmUagjUxyHjK4LIJ0XMVesUpuRCuYJCqzR69sU8ejofSjA/AYzB49Ou5lbtejXGhKq9iRU7x9OyKFSXc9iqpPo4xuIpjpqYuhUx1ZTpqIKVeOtFrpy17uMa+3yb8ePXqO1c1GdO3omozQyfe7tz5lL/AJh7c/1ZuHybk/5WZ7/enZXmeH3+3sn+72U8nid/sliP9cN5mWRtSsO8slHH6gjYfASlRz/LkJ+/5J79WMyXdsWlzKhRq7UBG9fJoPUkdeQTt7IPgTnpYiaG1qklouRqjZK/HLToQn0dSxtNMPUYh6/LQX/Fy3UHs1uTSf3X8zIZW31ipjKVvIWTEhkkFenBJZm7uMc5H7uM6KO08uMltA7ctbWyCVrF/Am1lYckubqVGHzmN1jpUvmOSirsJu5UzoY1kIk9z3vhaf8AU9f9dyHl/t/g65lt0a6w7jrQr1ST0IF0gyqqo6nejGOibtPcBW5CNtfP/wCIGcrmKzbgeHbdaZemSKnOvTYyzKw6ka5ETHB2awl25h1Pl3TholLzZTbuZoQKBqTPax1iGDQelhK4I9vmUv8AmHtz/Vm4fJuT/lZnv96dleZ4ff7eyf7vZTyeJ3+yWI/1w3mUbDqVOay+XyigjQlBMmLRiO3R1xmo9YIPkkhmjSWGZHilikUPHJHIpR43RgVZHUkEHkQeJUgikfbmVkls4O2QzLHGW6pcZPIdf51RLdPM6yR9L9pYDzqeKxdWW7kL88dapVhXqkmmkOiqOwKo7WYkKqgkkAE8U8IhjmyEpN3M3EHK1kp0QS9DEBjXrIixRagaonUQGZvLX/pVz+V4OCtJc3NusRpLLgMM8CjHJKgkgbM5Cdu5oNYiYMkarNP0lWMYRlYxV9y7AzOBx8kio2RxuarbgeBWOnfT0pMbhJO6TXVu7eR9PwVY8jjtxbdyVbLYXLV1tY/IVHLwzwsSp5MFkilikVkkjcLJFIrI6qykBlZQysCrKwBVlI0KsDqCCDzHFPH4lfmtDEeLkOMqQxe6IMBncutI1V00BEeEypi9APw1P+pq/wCu5DylWAZWBDKQCCCNCCDyII4tbj8Pq6ukpee9thCqNG51aSbCFiEaNjz+akgqeURIKxiWrbgmq2YHaKevYieGeGVDo8csUirJG6nkQQCPMirVYJrNid1igr143mnmkc6JHFFGrSSOxOgABJ4q7i8QawiijKT0dsPo0kzjRo5c10krHEvI/NgSzHlL0gNGyqqhVUBVVQAqqBoFUDkAB2DzM/jFiMePu2XzOHIGkbY3JySTxxxetaU/eV/liPl3HsihPDVzjNUzG3J7LFKozeJl7+tBacBjHXvwtLWZ9D3Qm69D06GXbe4NkbnxmbjnNcUJcNekksyBiqtRkghlgyMEpGscsDSRyDmrEHXjP+J+/MZZwGY3Hh49v7fwF6NoMrVwktyrk8jfy1R/0lGa/boVlhgkCTIkLl1AdfMsYHb5h/tXt/K19z7cgnlSvDkrdSrcpWsRJYkKxVzkKF6QRM5WMWEj62VOpg228hsfdlTPrMa/7Hl29lf2hJKG6AsFZarS2FdvwGjDK4IKkgg8bi3ZvmlJid074/ZsNfAzkfPMJgcZ86lgGRVSRXyOUs3DJJASWhjijDdMhkRfJjsPjojPfyl2tQqRDX37FqVYYgxAPSgZ9WbsVdSeQ4wu36fOvhsZTx0b6dJl+awJE87j/GWJFLt+cx8trBZ6otuhaAOmvTPWnUHurdSbQtBagLEqw9ZBBUkGe7BFLm9s9TNDmKsRZ6kWvJMtXTqam6dne84X5aMCegeYmM27jLGRskr3rxr01qkbHTvrtp+mCrCNDzdh1Hkup0HHz+28WU3Tah6LWSCH5vRjcDvKeLWQB0iJ5PKwEkunYq+55m591LGsz7bwm6M5FA2vTPNi6Vm7DC2mh6ZpYQp+XjKzb0yVyzjqsNvde6rCzlMjmrVq9HGlJbI1eub1y0XlkUArDGyoUZkYZ7eGz8Edr5na8eOsRvWyWUtVclWnydLHS070GTu3VMhS4WjlTolMqgMzKSOPETbM8skmOwWVwGVx6uzMsEu4K+XhvRRak93GzYKN+kaDrdm7WJPBNI95FY8aqFdZI+YanhNxVq9iyhHanzTGvID6Rz+Gp/1NX/Xch5um4sFUuWAvRHkIw1XJRKB7oS/VaKyUQ8wjM0frU8M+C3VlMapJKw5KjWyyrrz6FkglxTqo9BIYgduvGn9ssd0a/hfsmz16fwPnmmun53CvnN1ZPIqCC0ONo1sUD+YZbEuVZl9ZAUkdmnH/APO4KpTsFSkmQkDW8nKCPfDX7TS2Vjc8yiMsfqUectzFQht0beWazjFUAPkargNcxJblq8wjDwa8hKvTyDseHilR45Y3aOSORSjxuhKujowDK6sNCDzB+FHiRnaxjLxSwbVrTJo/dzK0VrNlGAKrLEzRVz+MjO+mhjY+YVYAggggjUEHkQQeRBHEllsS+DvSks9zb8iUOtzz6npNFPjWZm5swhDtrzbhmxe9SsZJ6YchhQ7qPR1Wa+RRXP8A7S8AWd60oo9ebQYWed9PYsmRrLrp7eI5szcy24pUIJgmlXG49yOfOClpc7fR84II9HEeOwuNpYujHzStRrxVouogAyMsSr3kr6e87asx5knzcrtvIhjQ3BSz2Fu9GnX80yleejY6CeQcQznT28Wu/opJYqpNRtVrKzR4feG2bE6SR2adlQzLFYauksUqdT1p06JFJWSI4raW1Mfl8Vay+Tht7uq5OOHpq1sSYrNOjWs15ZIr8FvKFJlk0jZRVHUilwBbz80td8nvTLvkZIoZopJquJxyNQxUFpI2cxTSyfOLADaHu515A68ZTusrQu74s1J623tvV7ENm7HfmjaODIZOtGztTxtF271zL0d90d2mrHld8Q8hFNLjNm1bjpdm6mW3ubOQTU4Yut+U8kGPsWZ5CCWjcxE6dan4an/U1f8AXch8Qs7q2akFTczBpcjjGZIKeeYDUzxyMViqZVgObNpFOeblW1drGNytK1jr9SQxWadyGSvYhkHPpkilVXXUHUHTQg6jl8EFUFmYhVVQSzMToAAOZJPFTcviBUko4iMpPS25OrRXsoRoySZSM6SUqGv/AITaTS+kIn4ccMMaRRRIscUUaqkccaKFSONFAVERQAABoB8PNsjNbPy+TWpHDeTKYvI09ZVyCd/3bUbcVcRmE8tRM3V26Ds4n23W8J6V3rWX5nmd3XBNbw00q9DWsTXxAgs1rnSAQ63VQ9IDxyLqpu5DDx1sHgKkUgi3Dnktw42/fVgi4/HmvBPYuOp1MskaNHCBox6yqMUp4/b2VCkqLWL3LVgiYdhKjKrirIVh60B9nEEm+c1hNrYlXU2YcfYOczcqA6tFXjhjjxcJcDTvGsP0E692+mnGP2ntOgKGJx6sR1N3tq7ak0NnIZCz0q1q9addXcgAABVCoqqPhaf9TV/13Ieal3xI8Rth+H1ORDJHb3vu/b+1KzouoZ0nz2QoRMikHUg6DTgxZD7Z32ZmkXXrTG+M+ws2yFeTI/7FzmQ6HBGhU6MD6OHjX7Y/gMGTUMX31jY0Oh09ySQrHJz/ACSeXEUGD+2N9mO7amIENI+OPhtUyEpPYI8fd3HWuuefYIzpxFmtqbhwe58POdIMtt7LUM1jZj0q2kV/G2LNWQ9LA8nPIj4AV9zYWtekjQpXvp1VsnUBJIFa/AUsIgc9XdktEx/CU8SS7S3Yqxknu6O4apJQdoDZPHKesej6IPlPBWKHAXVHZJWzARG+QXK1STn7VHH+asX/AKbof5Tj/NWL/wBN0P8AKcf5qxf+m6H+U4/zVi/9N0P8px/mrF/6bof5TgK9HDQAn8OXM1io9pECzPp8g4R9ybqxNCHUM8OGrW8nOy+lO9uJi4oXI/G6ZQD6DxFbxmLOQy8WhXN5lkvX43A/Dqju46lFuZ96GJHIOhY/ACTxO8V/DXw5jaPvQ+/N9bX2ghi017wNuDKY9THp6ezgw3ftnfZplde39l+MOyM4gOoUjvcJmMhHqCezXX9w8FU+2P4CqVYKe933jK66sSBo07Rqy8uZBIHp4jrbc+2B9mXK3JtBFQr+OXhquSkLEAdGOm3JFeOpIH8X2nTivl8Dlsbm8VbXrqZPEX6uSx9pAdOuvdpSzVpl19KsR5se4927es2c7K0lSXJU81mKEkleo5SvG1avdWj+iQ6dXddRHaTy4juQ7Fr5SzEwZHz+Syuar6g6gSY29dkxUw1/LgbiGrUghq1a8aQ161eJIIIIY1CxxQwxKscUaKNAqgADs+I0/wCpq/67kPKzMwVVBZmYgKqgalmJ0AAA5njePhJ9kbe2S8IvArbmUv7ej8Qtn2Gx3iN4qPj55KlrcdTdcJGT2dtO7PEXxkGMepfmraS2pz33zWvczu5Mzltw5vIymfIZjOZG5lsrenb8Ka5kL81i3alOnNndj5kG7fCLxI3z4ZbmrvGyZzYm6c1tbIsI2LCGxZw1ym9qq2pDwy9cUisVZSpINT7KP2pbWOyfi1bw2RyHhb4p06VPDy+IabeoTZTM7U3dicdDWxcW7qeEqT3qt6pDXgvVasyTRrZRZLfxbMbk3BkauIwO38XkM3m8telWCli8Riqk1/JZG5M3uw1aVOu8kjHkqKTxubZ/2ft57l8Bfs9U71vG4Cts25Y254j76xcLyV48/vTd9CSLPYtczCWcYfHT1qkMEohsm3InfG1k8reuZPJXpns3chkLM127csSHqkntW7LyT2JpG5szsWJ7T5kW6fBLxX394W52KaOaS5src+VwUd7uypEGXo07MePzVKQKBJXuRTwSr7roy8uMz4AfaCixFb7Qmz9uSbnwm7sPTr4bF+K+1MfPUpZizZwtZYsdiN7YOa7DLZhpLHUu1ZWmgggWvMvmV/6Vc/lfidP+pq/67kPL9rnde3pJoM7t37NHjjmcParllmpZHH+Gm5bNPIRlQWDY+eNZ/R/F9o7fP+yBncFLLFkYPtKeC1JVhLB7VLM+IWAw2Vxx6AXMWUxWQmrOBzKSkfF/td5XByyw3rPhbJtyV4Swf9k7x3Dgto59NVBPdy4HOWVf0dBOvLz/ALJl7Cyyx2Mh4g3ttXFiLaTYfde0dx7czMUyDUSRDGZOVyCCFKBuRUEeWv8A0q5/K/E6f9TV/wBdyHl374abhDNgPEPZe6djZxUVWc4fduDvYDJhVf3WY0sg+gPInjxD8Hd+0Hxu8fDTd+e2buCsyOsbX8FkJ6L26bOB3+NyMcS2Ksy6pPWlSRCVYE8YHwp8Fti57xC37uOcRY7A4Gr30kcCvGlnKZS5K0WPwmDx4kD2r9yWCnVj9+WRF58bV8RvC/eNfxe8ecLQvZXxg8I8NXSCjZqSrHZr1PB/ITxVru5MvtqFXjs1rixTZkkvSSOVY6li/h8zj72Jy+KuWcdlMXk6k9DI43IUpnr3KN+jajis07lSxG0csUiq8bqVYAgjyeDuRXHyzbQ8Cr//ABz3lku7Jr447Ilin2RD3jAQm5kfEObFqkXUJDAk8qgiF9Pi3jl4FzSw1pfFPwv3js7GXbGvcY3PZXC2otuZaUBWJTE58VrJGh17rjcG0N04q5gtz7VzeV23uPCZCIw38PncHenxmXxd2E6mK3j8hVkikX8V0I8mK8JvAfYuV3tuvItHLcetGa+C2xiTKkVjcW7s9MFxu3cBTLjrsWHXvHKxRLJO8cT7R8T/AAN3Lc+0HuPAbY7/AMddjYTENDnKmbSWzctZ/wAJ8XHGMluratClJHVloSK2ZZ65txRyJZerRkhmjeGaF3ililRo5IpI2KPHIjgMjowIIIBBHkq+Mc9CVtlfZt2lnd1ZLIyRFqEu896YjK7I2Xg2fQgXpK+TyWUh7Av7IJJ1KhvLX/pVz+V+J0/6mr/ruQ8yv45eCe4NueHX2i6eMqYfcS7mju19leKuHxcC1sQNxX8RSyORwe68JTRa9bJJVtrYqRxVbCBIoZoK1z7TXjNsDw72bBYVreG8LJMlvrfGVgjf9LWhvZrDbf2vtz5wn8XaJyxT8aqezj+w/wBn/wAPMdtaO5HW/tLuu4Rlt+b1tVVbu7u7d2Wk/aOTKSySPFWUw0KjSuK1eFGK+S3uPd2An8NvGY1xFS8Z/D2vRobjuPDCIqkG9sVLF+yN+Y+FY40/nipkIoIxFXuV01BTE7H8U/ATeOybNzu6+8cvmd37QyVOj3nSbed2ku1NxvVsd373c0b2SHo7wcT7D2re/tf4h7ws0s14seJ1qgmPubuzVKCaHHY7HUu9syYnaO3IrUyY+m00rK0888jGWeT4xe+0T9mPcG0tieMW4Eg/4k7L3e97F7L8QbtWGOvDuzH5fEY7Kz7d3lLUhWK4j1ZKWUZUmd61gTy2qWX+1p407W2ntWCaOaxsvwXe7ufd2XgVh3lKzu3c2DxG39rSNzPewUs2CvLRCdVreGngB4c4Lw+2zGYZ8k+PiezndzZKGLuf2zu3cd57Gb3Ll2QkCa3PKYkIjiCRKqLxld816Uvgb455DvrE/il4f4uk9Tc1+QHSz4i7JeShit3TFiS9yGbHZaUhRJceNFj4gwVLxP8As/3tiTXVD79bPbyqXKmKEg7ye1s99myXDl+5BKVYbc1dpNFa0ikuuH8FvDPvstae0+f39vzJVYa2d8QN5268Fe9nsjDC80dClDBXjrUKKSSR0qcSIXllM08vlr/0q5/K/E6f9TV/13Ifcyv/AEq5/K/E6f8AU1f9dyH3Mr/0q5/K/E6f9TV/13Ifcyv/AEq5/K/E6f8AU1f9dyH3Mr/0q5/K/E6Teg4eAD5Rdvk/4Q+5lYn02rhHtHfEf3R8TxF4D3Wjs1Hb1FGjmiB/hCR/3PuZiImGjPWNk69v88lktLr7QkwHxO3HGvXYqdN+uANSXrhu8RR2lnrs4A9LEfculjowdbM6rIw7Y4V9+eX/ANuFWb73CRxqEjjRURR2KiAKqj2AD4o80EZGNvu81UqPchkJ6pqh05L3ZOqetCO0g/cps3bj6bN6MJTRh70VIkMZSD2NaYAj8wD8o/FZsfdTrhmHJhoJIZV17uaJiD0yRk8vQRqDqCRwa9pS8DljVuIpENmMert6JVB95CdVPrBBP3HiyuWiMeOQiSvWkUhr7DmrMp0IqA8+f8Z2D3efAAAAA0AHIADsAHoA+LSU71eOzXlHvRyDXmOx0YaNHIuvJlIYeg8PYwcnz2vzb5nMyJbjHbpG7dMVhQP4LejRjz4aC3XmrTL+FFPE8Ug9pSRVbQ/cQJjqM9hddGm6eiunr7yxJ0wqR6tdT6BxHbzLx5C0pDJWUE0YWHPVw4DWmH5wVPzTyPAAGgHIAdgHqHxnurlWvaj/AMXZhjmTn6emRWAPBZsUkTH0157UAHyRxzLEP+zx9HtD/wCZN/fJ4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvH0e19cl4+j2vrkvGpq2X9jXJwP+6yngNBh6hYdjWFe4QfWDbefQ+0cBUVVVQAqqAqqB2AAaAAf9N//AP/Z") no-repeat center,#eee;background-size: cover;} FILE:public/css/style.css /* 鍏ㄥ眬鏍峰紡 */ :root { --primary-color: #0A2540; --accent-color: #00F5D4; --accent-hover: #00D4B8; --surface: rgba(255, 255, 255, 0.05); --surface-hover: rgba(255, 255, 255, 0.08); --border: rgba(255, 255, 255, 0.1); --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1); --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.15); --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.2); } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; background: linear-gradient(135deg, #F8FAFC 0%, #E2E8F0 100%); color: #1E293B; overflow: hidden; height: 100vh; } body.dark { background: linear-gradient(135deg, #051424 0%, #073e72 100%); color: #fff; } /* 瀵艰埅鏍?*/ .navbar { height: 50px; background: rgba(255, 255, 255, 0.5); backdrop-filter: blur(20px); border-bottom: 1px solid rgba(0, 0, 0, 0.1); display: flex; align-items: center; padding: 0 16px; position: fixed; top: 0; left: 0; right: 0; z-index: 1000; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } body.dark .navbar { background: rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .nav-left { display: flex; align-items: center; gap: 12px; } .logo { width: 28px; height: 28px; border-radius: 50%; background-size: cover; background-position: center; background-color: #eee; } .title { font-size: 14px; font-weight: 600; } .nav-right { display: flex; align-items: center; gap: 8px; margin-left: auto; margin-right: 5px; } .nav-btn { width: 32px; height: 32px; border-radius: 50%; background: #eee; border: none; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; color: inherit; } .nav-btn:hover { background: rgba(0, 0, 0, 0.1); transform: scale(1.05); } .nav-btn.active { background: #000; color: #fff; } body.dark .nav-btn.active { background: #fff; color: #000; } body.dark .nav-btn { background: rgba(255, 255, 255, 0.1); border: none; } body.dark .nav-btn:hover { background: rgba(255, 255, 255, 0.15); } /* 涓诲唴瀹瑰尯 */ .main-content { padding-top: 40px; padding-bottom: 200px; height: 100vh; overflow-y: auto; } /* 鐎戝竷娴?*/ .gallery { column-count: 5; column-gap: 12px; padding: 24px; max-width: 1600px; margin: 0 auto; } @media (max-width: 1200px) { .gallery { column-count: 4; } } @media (max-width: 768px) { .gallery { column-count: 2; column-gap: 12px; padding: 16px; } } @media (max-width: 480px) { .gallery { column-count: 1; } } /* 鍥剧墖鍗$墖 */ .image-card { position: relative; border-radius: 15px; overflow: hidden; background: #ffffff; border: 1px solid rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); break-inside: avoid; margin-bottom: 12px; } .image-card:hover { transform: translateY(-6px); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12); } body.dark .image-card { background: #1a1a2e; border: 1px solid rgba(255, 255, 255, 0.1); } body.dark .image-card:hover { box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3); } .image-card img { width: 100%; display: block; transition: transform 0.3s; } .image-card:hover img { transform: scale(1.02); } .image-card .loading { width: 100%; height: 200px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; } .image-card .error { width: 100%; height: 200px; background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; padding: 16px; text-align: center; } .image-card .info { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.2) 60%, transparent 100%); padding: 30px 12px 10px; color: white; } .image-card .prompt { font-size: 12px; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .image-card .date { font-size: 11px; opacity: 0.7; } /* 缂栬緫鍣?*/ .editor { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); width: 55%; max-width: 800px; background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(24px); border: 1px solid var(--border); border-radius: 20px; box-shadow: var(--shadow-lg); z-index: 1000; } body.dark .editor { background: rgba(30, 41, 59, 0.9); } @media (max-width: 768px) { .editor { width: 90%; bottom: 16px; } } .editor-upload { display: flex; gap: 8px; padding: 12px; padding-top: 8px; padding-bottom: 8px; border-bottom: 1px solid #ddd; overflow-x: auto; } .upload-item { position: relative; flex-shrink: 0; } .upload-item img { width: 56px; height: 56px; object-fit: cover; border-radius: 8px; cursor: pointer; } .upload-item .remove { position: absolute; top: -4px; right: -4px; width: 18px; height: 18px; background: #000; border-radius: 50%; border: none; color: white; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; } .editor-main { display: flex; padding: 12px; gap: 12px; } .upload-btn { width: 44px; height: 44px; border-radius: 12px; background: rgba(0, 0, 0, 0.05); border:none; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; flex-shrink: 0; } .upload-btn:hover { background: rgba(0, 0, 0, 0.1); border-color: rgba(0, 0, 0, 0.15); } body.dark .upload-btn { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2);color:rgba(255, 255, 255, 0.6); } body.dark .upload-btn:hover { background: rgba(255, 255, 255, 0.15); } .editor-input { flex: 1; min-height: 44px; max-height: 50vh; padding: 12px; background: rgba(0, 0, 0, 0.03); border-radius: 12px; outline: none; overflow-y: auto; font-size: 14px; line-height: 1.5; position: relative; } body.dark .editor-input { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); } body.dark .editor-input:focus { background: rgba(255, 255, 255, 0.08); } .editor-input .placeholder { color: #999; pointer-events: none; position: absolute; left: 12px; top: 12px; } body.dark .editor-input .placeholder { color: #666; } .editor-input:empty::before { content: '杈撳叆鎻愮ず璇嶆弿杩板浘鐗?..'; color: #999; pointer-events: none; } body.dark .editor-input:empty::before { color: #666; } .editor-controls { display: flex; align-items: center; gap: 8px; padding: 12px; padding-top: 6px; padding-bottom: 6px; background: rgba(0, 0, 0, 0.05); border-bottom-right-radius: 20px; border-bottom-left-radius: 20px; } .param-selector { position: relative; } .param-btn { height: 30px; padding: 6px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; color: inherit; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 6px; transition: all 0.2s; } .param-btn:hover { background: var(--surface-hover); } .param-menu { position: absolute; bottom: 100%; left: 0; margin-bottom: 8px; background: #ffffff; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); display: none; min-width: 160px; z-index: 10000; max-height: 300px; overflow-y: auto; } body.dark .param-menu { background: #1a1a2e; border: 1px solid rgba(255, 255, 255, 0.1); } .param-menu.show { display: block; } .param-item { padding: 10px 14px; cursor: pointer; transition: all 0.2s; white-space: nowrap; font-size: 13px; color: #333; } .param-item:hover { background: rgba(0, 0, 0, 0.05); } body.dark .param-item { color: #fff; } body.dark .param-item:hover { background: rgba(255, 255, 255, 0.1); } .submit-btn { width: 44px; height: 44px; border-radius: 50%; background: linear-gradient(135deg, #000 0%, #333 100%); border: none; color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); margin-left: auto; } .submit-btn:hover { transform: scale(1.05); box-shadow: 0 6px 10px rgba(0, 0, 0, 0.3); } .submit-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } body.dark .submit-btn{ background: #fff; color: #000; box-shadow: 0 4px 8px rgba(255, 255, 255, 0.1); } body.dark .submit-btn:hover{ transform: scale(1.05); box-shadow: 0 6px 10px rgba(255, 255, 255, 0.1); } /* 璁剧疆寮圭獥 */ .modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px; background: transparent; } .modal-content { background: #ffffff; border: none; border-radius: 30px; width: 100%; max-width: 520px; max-height: 85vh; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); } body.dark .modal-content { background: #1a1a2e; } .modal-header { display: flex; align-items: center; gap: 12px; padding: 20px; border-bottom: 1px solid rgba(0, 0, 0, 0.08); } body.dark .modal-header { border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .modal-icon { width: 40px; height: 40px; border-radius: 12px; background: linear-gradient(135deg, #00D4B8 0%, #94fff1 100%); display: flex; align-items: center; justify-content: center; font-size: 20px; } .modal-header h2 { flex: 1; font-size: 18px; font-weight: 600; } .modal-close { width: 40px; height: 40px; border-radius: 50%; background: rgba(0, 0, 0, 0.05); border: none; color: inherit; font-size: 30px; cursor: pointer; transition: all 0.2s; } .modal-close:hover { background: rgba(0, 0, 0, 0.1); } body.dark .modal-close { background: rgba(255, 255, 255, 0.1); } body.dark .modal-close:hover { background: rgba(255, 255, 255, 0.15); } .modal-body { padding: 20px; overflow-y: auto; } .info-box { background: linear-gradient(135deg, rgba(0, 245, 212, 0.1) 0%, rgba(0, 212, 184, 0.1) 100%); border: 1px solid rgba(0, 245, 212, 0.3); border-radius: 12px; padding: 12px; margin-bottom: 15px; } .info-box h3 { font-size: 14px; color: #00B8A0; margin-bottom:5px; font-weight: 600; } .info-box ul { list-style: none; font-size: 13px; color: #666; line-height: 1.6; } body.dark .info-box ul { color: #aaa; padding-top: 0; margin-top: 0; } .info-box li { margin-bottom:3px; } .form-group { margin-bottom: 10px; } .form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 10px; color: #333; } body.dark .form-group label { color: #fff; } .required { color: #00B8A0; } .optional { color: #999; } .form-group input { width: 100%; padding: 12px 14px; background: #f8f9fa; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 10px; color: #333; font-size: 14px; outline: none; transition: all 0.2s; } .form-group input:focus { border-color: #00F5D4; background: #fff; box-shadow: 0 0 0 3px rgba(127, 237, 222, 0.1); } body.dark .form-group input { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; } body.dark .form-group input:focus { background: rgba(255, 255, 255, 0.08); } .link { display: inline-flex; align-items: center; gap: 4px; margin-top: 10px; font-size: 13px; color: #00B8A0; text-decoration: none; transition: color 0.2s; } .link:hover { color: #00F5D4; } .hint { display: block; margin-top: 10px; font-size: 12px; color: #999; } .input-group { display: flex; gap: 10px; } .btn-secondary { padding: 12px 20px; background: #f8f9fa; border: 1px solid rgba(0, 0, 0, 0.1); border-radius:15px; color: #333; font-size: 14px; cursor: pointer; transition: all 0.2s; } .btn-secondary:hover { background: #e9ecef; } body.dark .btn-secondary { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; } body.dark .btn-secondary:hover { background: rgba(255, 255, 255, 0.15); } .btn-primary { padding: 12px 20px; background: linear-gradient(135deg, #00D4B8 0%, #64fbe7 100%); border: none; border-radius: 10px; color: #0A2540; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; } .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 245, 212, 0.3); } .modal-footer { display: flex; gap: 12px; padding: 20px; border-top: 1px solid rgba(0, 0, 0, 0.08); } body.dark .modal-footer { border-top: 1px solid rgba(255, 255, 255, 0.1); } .modal-footer button { flex: 1; } /* 鍥剧墖鏌ョ湅鍣?*/ .viewer { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(8px); z-index: 600; display: flex; align-items: center; justify-content: center; padding: 48px; } .viewer-close { position: absolute; top: 56px; right: 24px; width: 40px; height: 40px; border-radius: 50%; background: var(--surface); border: 1px solid var(--border); color: white; font-size: 24px; cursor: pointer; } .viewer-content { display: flex; gap: 24px; max-width: 90%; max-height: 80vh; } .viewer-image-container { position: relative; max-width: 50%; } .viewer-actions { position: absolute; top: -48px; left: 0; right: 0; display: flex; justify-content: center; gap: 8px; } .action-btn { width: 36px; height: 36px; border-radius: 50%; background: var(--surface); border: 1px solid var(--border); color: white; font-size: 16px; cursor: pointer; } .action-btn.delete { background: rgba(255, 71, 87, 0.8); } .viewer-image { max-width: 100%; max-height: 75vh; border-radius: 8px; box-shadow: var(--shadow-lg); } .viewer-cuts { display: flex; flex-direction: column; gap: 12px; max-height: 75vh; overflow-y: auto; } .viewer-cuts img { width: 128px; height: 128px; object-fit: cover; border-radius: 8px; cursor: pointer; transition: transform 0.2s; } .viewer-cuts img:hover { transform: scale(1.05); } .viewer-info { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--surface); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; font-size: 13px; } /* 鍔犺浇鍔ㄧ敾 */ .spinner { width: 40px; height: 40px; border: 3px solid rgba(255, 255, 255, 0.3); border-top-color: #00F5D4; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* 鑴夊啿鍔ㄧ敾 */ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .pulse { animation: pulse 2s ease-in-out infinite; } /* 娣″叆鍔ㄧ敾 */ @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .fade-in { animation: fadeIn 0.3s ease-out; } /* 缂╂斁鍔ㄧ敾 */ @keyframes scaleIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } .scale-in { animation: scaleIn 0.3s ease-out; } /* 婊氬姩鏉?*/ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.3); } body.dark ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); } body.dark ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* 鍝嶅簲寮忎紭鍖?*/ @media (max-width: 768px) { .editor-controls { flex-wrap: wrap; gap: 6px; } .param-btn { height: 28px; padding: 4px 10px; font-size: 11px; } .param-btn .param-icon { font-size: 12px; } .submit-btn { width: 40px; height: 40px; } .viewer-content { flex-direction: column; } .viewer-image-container { max-width: 100%; } .viewer-cuts { flex-direction: row; flex-wrap: wrap; justify-content: center; } .viewer-cuts img { width: 80px; height: 80px; } } @media (max-width: 480px) { .navbar { padding: 0 12px; } .nav-btn { width: 28px; height: 28px; } .title { font-size: 13px; } .editor { width: 95%; border-radius: 12px; } .editor-main { padding: 10px; } .editor-controls { padding: 10px; } .upload-btn { width: 40px; height: 40px; } .editor-input { min-height: 40px; padding: 10px; font-size: 13px; } } /* 鎵撳嵃鏍峰紡 */ @media print { .navbar, .editor, .viewer-actions { display: none !important; } .main-content { padding-top: 0; padding-bottom: 0; } .image-card { break-inside: avoid; } } /* 閫夋嫨棰勮鎸夐挳 */ .select-btn { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; display: flex; align-items: center; justify-content: center; cursor: pointer; color: white; transition: all 0.3s; flex-shrink: 0; } .select-btn:hover { transform: scale(1.05); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); } body.dark .select-btn { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } /* 棰勮鎻愮ず璇嶅垪琛?*/ .prompt-list { max-height: 400px; overflow-y: auto; } .prompt-item { padding: 14px 16px; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: all 0.2s; font-size: 14px; line-height: 1.5; background: rgba(255, 255, 255, 0.5); } .prompt-item:hover { border-color: #667eea; background: rgba(102, 126, 234, 0.05); } .prompt-item.selected { border-color: #667eea; background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); color: #333; } body.dark .prompt-item { background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.1); } body.dark .prompt-item:hover { border-color: #667eea; background: rgba(102, 126, 234, 0.1); } body.dark .prompt-item.selected { background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%); color: #fff; } /* 閿欒寮圭獥 */ .error-modal-content { max-width: 560px; } .error-content { background: #fee2e2; color: #dc2626; padding: 16px; border-radius: 8px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; } body.dark .error-content { background: rgba(220, 38, 38, 0.1); color: #fca5a5; } /* 鍏充簬寮圭獥 */ .about-content { display: flex; flex-direction: column; gap: 12px; } .about-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: rgba(0, 0, 0, 0.03); border-radius: 8px; } body.dark .about-item { background: rgba(255, 255, 255, 0.05); } .about-label { font-weight: 600; color: #666; } body.dark .about-label { color: #aaa; } .about-value { color: #333; } body.dark .about-value { color: #fff; } .about-links { margin-top: 16px; text-align: center; } /* 鍥剧墖涓婁紶鍖哄煙澧炲己 */ .editor-upload { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px; background: rgba(0, 0, 0, 0.02); border-radius: 8px; margin-bottom: 8px; } body.dark .editor-upload { background: rgba(255, 255, 255, 0.03); } .upload-item { position: relative; width: 64px; height: 64px; border-radius: 8px; overflow: hidden; border: 2px solid rgba(0, 0, 0, 0.1); } body.dark .upload-item { border-color: rgba(255, 255, 255, 0.1); } .upload-item img { width: 100%; height: 100%; object-fit: cover; } .upload-item .remove { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; border-radius: 50%; background: rgba(255, 71, 87, 0.9); color: white; border: none; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s; } .upload-item:hover .remove { opacity: 1; } /* 缂栬緫鍣ㄨ緭鍏ユ鍗犱綅绗?*/ .editor-input:empty::before { content: attr(data-placeholder); color: #999; pointer-events: none; position: absolute; } .editor-input { position: relative; } body.dark .editor-input:empty::before { color: #666; } /* 鍒嗚鲸鐜囨瘮渚嬪浘鏍?*/ .ratio-icon { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .ratio-box { background: #333; border-radius: 3px; transition: all 0.2s; width: 16px;height: 16px; } body.dark .ratio-box { background: #fff; } /* 鍒囧壊鍥炬爣 */ .cut-icon { width: 20px; height: 20px; display: grid; gap: 1.5px; flex-shrink: 0; align-items: center; justify-content: center; } .cut-icon.cols-2 { grid-template-columns: repeat(2, 1fr); } .cut-icon.cols-3 { grid-template-columns: repeat(3, 1fr); } .cut-icon.rows-2 { grid-template-rows: repeat(2, 1fr); } .cut-icon.rows-3 { grid-template-rows: repeat(3, 1fr); } .cut-box { background: #333; border-radius: 1px; min-width: 4px; min-height: 4px; } .cut-box.single { width: 12px; height: 12px; border-radius: 2px; } /* 2瀹牸 - 2涓暱鏂瑰舰 */ .cut-icon.cols-2.rows-1 { width: 16px; height: 10px; } .cut-icon.cols-2.rows-1 .cut-box { width: 5px; height: 12px; border-radius: 1px; } /* 4瀹牸 - 灏忔柟鍧?*/ .cut-icon.cols-2.rows-2 { width: 14px; height: 14px; } .cut-icon.cols-2.rows-2 .cut-box { width: 6px; height: 6px; } /* 6瀹牸鍜?瀹牸 */ .cut-icon.cols-3 .cut-box { width: 5px; height: 5px; } body.dark .cut-box { background: #fff; } /* 鍙傛暟鎸夐挳瀵归綈 */ .param-btn { display: flex; align-items: center; gap: 6px; } .param-btn .param-icon, .param-btn .ratio-icon, .param-btn .cut-icon, .param-btn .quality-icon { flex-shrink: 0; } .param-btn .param-text { line-height: 1; } /* 鍙傛暟鑿滃崟椤?*/ .param-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; transition: all 0.2s; border-radius: 6px; } .param-item:hover { background: rgba(102, 126, 234, 0.1); } .param-item-icon { font-size: 16px; width: 20px; text-align: center; } .param-item-text { font-size: 13px; } /* AI鎻愮ず璇嶆寜閽?*/ .ai-prompt-btn { width: 26px; height: 26px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; display: flex; align-items: center; justify-content: center; cursor: pointer; color: white; transition: all 0.3s; flex-shrink: 0; position: absolute; right: 70px; } .ai-prompt-btn:hover { transform: scale(1.05); box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); } .ai-prompt-btn:active { transform: scale(0.98); } body.dark .ai-prompt-btn { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } /* 鍥剧墖涓婁紶鍖哄煙 - 娣诲姞鎸夐挳 */ .upload-add-btn { background: rgba(0, 0, 0, 0.05); border: 2px dashed rgba(0, 0, 0, 0.2); cursor: pointer; display: flex; align-items: center; justify-content: center; color: #666; transition: all 0.2s; } .upload-add-btn:hover { background: rgba(102, 126, 234, 0.1); border-color: #667eea; color: #667eea; } body.dark .upload-add-btn { background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.2); color: #aaa; } body.dark .upload-add-btn:hover { background: rgba(102, 126, 234, 0.15); border-color: #667eea; color: #667eea; } /* AI鎻愮ず璇嶈緭鍏ユ */ #ai-prompt-input { width: 100%; min-height: 100px; padding: 12px; border: 2px solid rgba(0, 0, 0, 0.1); border-radius: 8px; font-size: 14px; resize: vertical; font-family: inherit; transition: border-color 0.2s; } #ai-prompt-input:focus { outline: none; border-color: #667eea; } body.dark #ai-prompt-input { background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.1); color: #fff; } body.dark #ai-prompt-input:focus { border-color: #667eea; } /* 璇█閫夋嫨鍣?*/ .lang-selector { position: relative; } .lang-menu { position: absolute; top: 100%; right: 0; margin-top: 8px; background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); min-width: 120px; overflow: hidden; opacity: 0; visibility: hidden; transform: translateY(-10px); transition: all 0.2s; z-index: 1000; } .lang-menu.show { opacity: 1; visibility: visible; transform: translateY(0); } body.dark .lang-menu { background: #1a1a2e; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); } .lang-item { padding: 10px 16px; cursor: pointer; font-size: 13px; transition: all 0.2s; } .lang-item:hover { background: rgba(102, 126, 234, 0.1); } body.dark .lang-item:hover { background: rgba(102, 126, 234, 0.2); } .lang-item.active { background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); color: #667eea; } /* 鎼滅储妗?*/ .search-box { display: flex; align-items: center; background: rgba(0, 0, 0, 0.05); border-radius: 20px; padding: 6px 12px; margin-right: 8px; transition: all 0.3s; } .search-box:focus-within { background: rgba(102, 126, 234, 0.1); box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); } body.dark .search-box { background: rgba(255, 255, 255, 0.1); } body.dark .search-box:focus-within { background: rgba(102, 126, 234, 0.15); } .search-box svg { color: #666; flex-shrink: 0; } body.dark .search-box svg { color: #aaa; } .search-box input { border: none; background: transparent; outline: none; font-size: 13px; width: 120px; margin-left: 6px; color: inherit; } .search-box input::placeholder { color: #999; } body.dark .search-box input::placeholder { color: #666; } /* 璇█鎸夐挳鏂囨湰 */ .lang-btn { width: auto !important; width: 32px !important;height: 32px; overflow: hidden; gap: 4px; border-radius: 50% !important; } .lang-text { font-size:14px; font-weight: 500; white-space: nowrap; } /* 涓婚鍥炬爣鍒囨崲 */ #btn-theme { position: relative; } #btn-theme .sun-icon, #btn-theme .moon-icon { position: absolute; transition: all 0.3s; } body.dark #btn-theme .sun-icon { opacity: 0; transform: rotate(180deg); } body.dark #btn-theme .moon-icon { opacity: 1; transform: rotate(0deg); } #btn-theme .moon-icon { opacity: 0; transform: rotate(-180deg); } /* Quality 鍥炬爣 */ .quality-icon { width: 16px; height: 16px; flex-shrink: 0; } /* API Key 璀﹀憡 */ .api-key-warning { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #fef3c7; border-radius: 8px; margin-bottom: 16px; color: #92400e; font-size: 13px; } body.dark .api-key-warning { background: rgba(251, 191, 36, 0.15); color: #fbbf24; } .link-btn { background: none; border: none; color: #667eea; cursor: pointer; font-size: 13px; font-weight: 600; text-decoration: underline; padding: 0; } .link-btn:hover { color: #5a6fd6; } /* LLM 淇℃伅 */ .llm-info { display: flex; align-items: center; padding: 10px 16px; background: rgba(102, 126, 234, 0.1); border-radius: 8px; margin-bottom: 16px; font-size: 13px; } .llm-label { color: #666; } body.dark .llm-label { color: #aaa; } .llm-name { color: #667eea; font-weight: 600; margin-left: 4px; } /* 浠诲姟鍗$墖鏍峰紡 */ .task-card { position: relative; cursor: pointer; border-radius: 15px; overflow: hidden; } /* 鏍规嵁姣斾緥璁剧疆楂樺害 - 榛樿浣跨敤1:1 */ .image-card.ratio-1-1 { height: calc((100vw - 180px) / 5); } .image-card.ratio-16-9 { height: calc((100vw - 180px) / 5 * 9 / 16); } .image-card.ratio-9-16 { height: calc((100vw - 180px) / 5 * 16 / 9); } .image-card.ratio-4-3 { height: calc((100vw - 180px) / 5 * 3 / 4); } .image-card.ratio-3-4 { height: calc((100vw - 180px) / 5 * 4 / 3); } .image-card.ratio-5-4 { height: calc((100vw - 180px) / 5 * 4 / 5); } /* 榛樿楂樺害 */ .image-card { height: calc((100vw - 180px) / 5); } /* 鍥剧墖鍔犺浇澶辫触鏍峰紡 */ .image-card.image-error { background: linear-gradient(135deg, #374151 0%, #1f2937 100%); display: flex; align-items: center; justify-content: center; } .image-card.image-error::before { content: '鉂?鍥剧墖鍔犺浇澶辫触'; color: #9ca3af; font-size: 14px; } /* 鍗$墖搴曢儴淇℃伅 */ .image-card .info .meta { display: flex; align-items: center; gap: 8px; font-size: 10px; color: rgba(255,255,255,0.7); margin-top: 4px; } .image-card .info .meta .date { margin-left: auto; } .folder-btn { background: transparent; border: none; cursor: pointer; font-size: 12px; padding: 2px; opacity: 0.7; transition: opacity 0.2s; } .folder-btn:hover { opacity: 1; } /* 鍗$墖鍒犻櫎鎸夐挳 */ .card-delete-btn { position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.5); border: none; border-radius: 50%; width: 28px; height: 28px; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; z-index: 10; transition: background 0.2s; } .card-delete-btn:hover { background: rgba(239, 68, 68, 0.8); } .task-card.status-pending { background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); background-size: 400% 400%; animation: gradientFlow 8s ease infinite; border: none; } .task-card.status-failed { background: linear-gradient(135deg, rgba(102, 102, 102, 0.8) 0%, rgba(51, 51, 51, 0.8) 100%); background-size: 400% 400%; animation: gradientFlow 8s ease infinite; border: none; } /* 鍏夋晥鍔ㄧ敾 - 宸茬鐢?*/ /* .task-card::before { content: ''; position: absolute; top: -50%; right: -50%; width: 80%; height: 200%; background: linear-gradient(135deg, transparent, rgba(255,255,255,0.15), transparent); animation: shine 3s infinite; z-index: 1; transform: rotate(-45deg); } @keyframes shine { 0% { top: -50%; right: -50%; } 50%, 100% { top: 100%; right: 100%; } } */ @keyframes gradientFlow { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } .task-status { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; padding: 16px; position: relative; z-index: 2; } .task-status-icon { width: 36px; height: 36px; margin-bottom: 12px; animation: spin 3s linear infinite; } .task-status-icon svg { width: 100%; height: 100%; fill: none; stroke: white; stroke-width: 2; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .task-status-text { font-size: 13px; font-weight: 600; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); } .task-card .info { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%); padding: 12px 10px 10px; z-index: 2; } .task-card .info .prompt { color: white; font-size: 11px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .task-card .info .date { color: rgba(255,255,255,0.7); font-size: 10px; margin-top: 4px; } body.dark .task-card.status-pending, body.dark .task-card.status-failed { /* 鏆楄壊妯″紡涓嬩繚鎸佺浉鍚岀殑娓愬彉 */ } /* 浠诲姟璇︽儏寮圭獥 */ .task-detail-modal-content { max-width: 380px; padding: 0; position: relative; border-radius: 16px; overflow: hidden; } .task-detail-modal-content .modal-close { position: absolute; top: 12px; right: 12px; width: 28px; height: 28px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); border: none; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: white; transition: all 0.2s; z-index: 10; } .task-detail-modal-content .modal-close:hover { background: rgba(255, 255, 255, 0.3); } .task-detail-header { padding: 24px 20px; text-align: center; color: white; } .task-detail-header.pending { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } .task-detail-header.failed { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%); } .task-detail-header.completed { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } .task-detail-status { font-size: 20px; font-weight: 700; margin-bottom: 4px; } .task-detail-id { font-size: 11px; opacity: 0.8; } .task-detail-body { padding: 12px 16px; } .task-detail-row { display: flex; padding: 8px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.05); font-size: 12px; } body.dark .task-detail-row { border-bottom-color: rgba(255, 255, 255, 0.05); } .task-detail-row:last-child { border-bottom: none; } .task-detail-label { width: 60px; flex-shrink: 0; color: #888; font-size: 12px; } body.dark .task-detail-label { color: #aaa; } .task-detail-value { flex: 1; font-size: 12px; word-break: break-all; color: #333; } body.dark .task-detail-value { color: #ddd; } .task-id-text { font-family: monospace; font-size: 10px; background: rgba(0, 0, 0, 0.05); padding: 3px 6px; border-radius: 4px; } .task-path { cursor: pointer; color: #667eea; text-decoration: underline; } .task-path:hover { color: #764ba2; } .task-link { cursor: pointer; color: #667eea; word-break: break-all; } .task-link:hover { color: #764ba2; text-decoration: underline; } .error-row { background: rgba(239, 68, 68, 0.05); margin: 0 -16px; padding: 8px 16px !important; } .error-row .task-detail-value { color: #dc2626; font-size: 11px; } body.dark .error-row { background: rgba(239, 68, 68, 0.1); } body.dark .error-row .task-detail-value { color: #fca5a5; } .task-detail-image { margin: 8px 0; border-radius: 8px; overflow: hidden; } .task-detail-image img { width: 100%; max-height: 200px; object-fit: cover; } .task-detail-footer { padding: 12px 16px; display: flex; gap: 10px; border-top: 1px solid rgba(0, 0, 0, 0.05); } body.dark .task-detail-footer { border-top-color: rgba(255, 255, 255, 0.05); } .task-btn { flex: 1; padding: 10px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; } .task-btn-retry { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } .task-btn-retry:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); } .task-btn-delete { background: #f0f0f0; color: #666; } .task-btn-delete:hover { background: #e0e0e0; } body.dark .task-btn-delete { background: rgba(255, 255, 255, 0.1); color: #aaa; } body.dark .task-btn-delete:hover { background: rgba(255, 255, 255, 0.15); } /* Toast 閫氱煡鏍峰紡 */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column-reverse; gap: 10px; max-width: 250px; } .toast { width: 250px; border-radius: 12px; overflow: hidden; transform: translateX(120%); transition: transform 0.3s ease; color: white; } .toast.show { transform: translateX(0); } .toast-success { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } .toast-warning { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); } .toast-error { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); } .toast-header { display: flex; align-items: center; padding: 10px 14px; gap: 8px; background: rgba(255, 255, 255, 0.1); } .toast-icon { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; background: rgba(255, 255, 255, 0.2); } .toast-title { flex: 1; font-weight: 600; font-size: 13px; } .toast-close { width: 20px; height: 20px; border: none; background: transparent; color: white; font-size: 16px; cursor: pointer; border-radius: 4px; display: flex; align-items: center; justify-content: center; opacity: 0.8; } .toast-close:hover { opacity: 1; background: rgba(255, 255, 255, 0.1); } .toast-body { padding: 10px 14px; font-size: 12px; line-height: 1.4; max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; } /* 鏆楄壊妯″紡涓嬩繚鎸佺浉鍚屾牱寮?*/ body.dark .toast { /* 淇濇寔娓愬彉鑳屾櫙 */ } /* 上传状态样式 */ .upload-item.uploaded { border: 2px solid #10b981; } .upload-item.uploaded img { opacity: 0.9; } .upload-badge { position: absolute; bottom: 2px; right: 2px; width: 16px; height: 16px; background: #10b981; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; } /* 测试上传按钮 */ .upload-test-btn { background: linear-gradient(135deg, #3b82f6, #2563eb) !important; cursor: pointer; transition: all 0.2s; } .upload-test-btn:hover { transform: scale(1.05); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); } .upload-test-btn svg { color: #fff; } FILE:public/css/win.css /** * 弹窗样式 - win.js 配套 */ /* 弹窗遮罩 */ .win-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s ease; } .win-modal.win-show { opacity: 1; } .win-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); } /* 弹窗容器 */ .win-container { position: relative; background: var(--bg-secondary, #fff); border-radius: 12px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); max-height: 90vh; display: flex; flex-direction: column; transform: scale(0.9) translateY(20px); transition: transform 0.3s ease; overflow: hidden; } .win-show .win-container { transform: scale(1) translateY(0); } /* 弹窗头部 */ .win-header { display: flex; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border-color, #e5e7eb); gap: 12px; } .win-icon { width: 36px; height: 36px; border-radius: 10px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; } .win-title { flex: 1; margin: 0; font-size: 16px; font-weight: 600; color: var(--text-primary, #1f2937); } .win-close { width: 32px; height: 32px; border: none; background: transparent; font-size: 24px; color: var(--text-secondary, #6b7280); cursor: pointer; border-radius: 8px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .win-close:hover { background: var(--bg-hover, #f3f4f6); color: var(--text-primary, #1f2937); } /* 弹窗内容 */ .win-body { padding: 20px; overflow-y: auto; flex: 1; } /* 加载状态 */ .win-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px; color: var(--text-secondary, #6b7280); gap: 12px; } .win-spinner { width: 32px; height: 32px; border: 3px solid var(--border-color, #e5e7eb); border-top-color: var(--primary-color, #667eea); border-radius: 50%; animation: win-spin 1s linear infinite; } @keyframes win-spin { to { transform: rotate(360deg); } } /* 错误状态 */ .win-error { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px; color: var(--error-color, #dc2626); gap: 8px; } .win-error span { font-size: 32px; } /* 暗色主题 */ body.dark .win-overlay { background: rgba(0, 0, 0, 0.7); } body.dark .win-container { background: #1f2937; } body.dark .win-header { border-bottom-color: #374151; } body.dark .win-title { color: #f9fafb; } body.dark .win-close { color: #9ca3af; } body.dark .win-close:hover { background: #374151; color: #f9fafb; } body.dark .win-body { color: #e5e7eb; } body.dark .win-form-group label { color: #d1d5db; } body.dark .win-form-group input, body.dark .win-form-group textarea, body.dark .win-form-group select { background: #374151; border-color: #4b5563; color: #f9fafb; } body.dark .win-form-group input:focus, body.dark .win-form-group textarea:focus, body.dark .win-form-group select:focus { border-color: #60a5fa; box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); } body.dark .win-form-group .hint { color: #9ca3af; } body.dark .win-btn-secondary { background: #4b5563; color: #e5e7eb; } body.dark .win-btn-secondary:hover { background: #6b7280; } body.dark .win-btn-primary { background: #4f46e5; } body.dark .win-btn-primary:hover { background: #6366f1; } body.dark .win-footer { border-top-color: #374151; } body.dark .win-info-box { background: #1e3a5f; border-color: #2563eb; } body.dark .win-info-box h4 { color: #60a5fa; } body.dark .win-info-box ul { color: #9ca3af; } /* 表单样式 */ .win-form-group { margin-bottom: 16px; } .win-form-group label { display: block; margin-bottom: 6px; font-size: 14px; font-weight: 500; color: var(--text-primary, #1f2937); } .win-form-group input, .win-form-group textarea, .win-form-group select { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color, #e5e7eb); border-radius: 8px; font-size: 14px; background: var(--bg-primary, #fff); color: var(--text-primary, #1f2937); transition: border-color 0.2s, box-shadow 0.2s; } .win-form-group input:focus, .win-form-group textarea:focus, .win-form-group select:focus { outline: none; border-color: var(--primary-color, #667eea); box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); } .win-form-group .hint { display: block; margin-top: 4px; font-size: 12px; color: var(--text-secondary, #6b7280); } /* 按钮样式 */ .win-btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; } .win-btn-primary { background: var(--primary-color, #667eea); color: #fff; } .win-btn-primary:hover { background: var(--primary-hover, #5a67d8); } .win-btn-secondary { background: var(--bg-hover, #f3f4f6); color: var(--text-primary, #1f2937); } .win-btn-secondary:hover { background: var(--bg-active, #e5e7eb); } .win-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 16px 20px; border-top: 1px solid var(--border-color, #e5e7eb); } /* 信息提示框 */ .win-info-box { background: var(--bg-info, #eff6ff); border: 1px solid var(--border-info, #bfdbfe); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; } .win-info-box h4 { margin: 0 0 8px 0; font-size: 14px; color: var(--info-color, #1d4ed8); } .win-info-box ul { margin: 0; padding-left: 20px; font-size: 13px; color: var(--text-secondary, #6b7280); } .win-info-box li { margin-bottom: 4px; } FILE:public/components/about.html <!-- 关于组件 --> <div class="win-about"> <div style="text-align: center; margin-bottom: 24px;"> <div style="font-size: 40px; display: flex;justify-content: center;" ><logo class="logo" style="width:60px;height:60px;border-radius: 50%;"></logo></div> <h2 style="margin: 0; font-size: 24px;" data-i18n="about.title">云羲AI绘图分影工具</h2> <p style="color: var(--text-secondary, #6b7280); margin: 8px 0 0;" data-i18n="about.description">一键生图分影,完美风格一致性,视频创作助手。</p> </div> <div class="aboutbox" style="background: var(--bg-hover, #f3f4f6); border-radius: 8px; padding: 16px;padding-top: 5px;padding-bottom: 5px;"> <div style="display: flex; justify-content: space-between; padding: 8px 0;border-bottom: 1px solid var(--border-color, #e5e7eb);"> <span style="color: var(--text-secondary, #6b7280);" data-i18n="about.features">功能</span> <span style="font-weight: 500;font-size: 14px;" data-i18n="about.featuresText">AI生图,分影,保存本地目录。</span> </div> <div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border-color, #e5e7eb);"> <span style="color: var(--text-secondary, #6b7280);" data-i18n="about.version">版本</span> <span style="font-weight: 500;">V 1.1.0</span> </div> <div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border-color, #e5e7eb);"> <span style="color: var(--text-secondary, #6b7280);" data-i18n="about.author">作者</span> <span style="font-weight: 500;" data-i18n="about.authorName">小潴</span> </div> <div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border-color, #e5e7eb);"> <span style="color: var(--text-secondary, #6b7280);" data-i18n="about.email">Email</span> <span style="font-weight: 500;">[email protected]</span> </div> <div style="display: flex; justify-content: space-between; padding: 8px 0; "> <span style="color: var(--text-secondary, #6b7280);" data-i18n="about.wechat">微信</span> <span style="font-weight: 500;">jakeycis</span> </div> </div> </div> <style> .aboutbox *{font-size: 14px !important;} body.dark .aboutbox { background: rgba(255, 255, 255, 0.1) !important; } </style> FILE:public/components/ai-prompt.html <!-- AI提示词生成组件 --> <div class="win-ai-prompt"> <!-- API Key 未配置提示 --> <div class="win-info-box" id="ai-no-key" style="display: none; background: #fef3c7; border-color: #fcd34d;"> <p style="margin: 0; color: #92400e;" data-i18n="aiPrompt.noKeyWarning">⚠️ 未配置大模型 API 请先到后端设置您使用的大模型。</p> <a class="win-btn win-btn-secondary" style="margin-top: 20px;float: left;float: right;background: #000;color: #fff;padding-left: 30px;padding-right: 30px; text-decoration: none;" href="/set.html" target="_blank" data-i18n="aiPrompt.goSettings">去设置</a> </div> <div id="ai-form"> <div class="win-form-group"> <label data-i18n="aiPrompt.label">描述你想要的图片</label> <textarea id="ai-prompt-input" style="height:200px;" data-i18n-placeholder="aiPrompt.placeholder" placeholder="你是一个影视编剧,根据水浒传武松打虎的故事,严格按照故事发展的顺序,生成9张用于视频创作的首尾帧图片,每个片段大概6-10秒。按图1...图9顺序输出,以及输出图片生成的风格prompt。">你是一个影视编剧,根据水浒传武松打虎的故事,严格按照故事发展的顺序,生成9张用于视频创作的首尾帧图片,每个片段大概6-10秒。按图1...图9顺序输出,以及输出图片生成的风格prompt。</textarea> <span class="hint" data-i18n="aiPrompt.hint">请将你的详细要求喂给AI,帮你生成详细的图片生成内容。</span> </div> <div class="win-footer" style="border-top: none; padding: 0; margin-top: 16px;"> <button class="win-btn win-btn-secondary" onclick="winClose('ai-prompt')" data-i18n="aiPrompt.cancel">取消</button> <button class="win-btn win-btn-primary" id="ai-generate-btn" data-i18n="aiPrompt.generate">✨ 生成提示词</button> </div> </div> <!-- 生成结果 --> <div id="ai-result" style="display: none;"> <div class="win-form-group"> <label data-i18n="aiPrompt.resultLabel">生成的提示词</label> <textarea id="ai-result-text" rows="6" readonly style="background: var(--bg-hover, #f3f4f6);"></textarea> </div> <div class="win-footer" style="border-top: none; padding: 0;"> <button class="win-btn win-btn-secondary" onclick="$('#ai-result').hide(); $('#ai-form').show();" data-i18n="aiPrompt.regenerate">重新生成</button> <button class="win-btn win-btn-primary" id="ai-use-btn" data-i18n="aiPrompt.usePrompt">使用此提示词</button> </div> </div> </div> <script> (function() { // 检查是否配置了 API Key const modelApiKey = localStorage.getItem('banana_model_api_key'); if (!modelApiKey) { $('#ai-no-key').show(); $('#ai-form').hide(); return; } // 生成提示词 $('#ai-generate-btn').on('click', function() { const input = $('#ai-prompt-input').val().trim(); if (!input) { alert(t('aiPrompt.inputRequired')); return; } // 从 localStorage 获取 API Key const modelApiKey = localStorage.getItem('banana_model_api_key'); if (!modelApiKey) { alert(t('aiPrompt.noApiKey')); return; } const $btn = $(this); $btn.prop('disabled', true).text(t('aiPrompt.generating')); $.ajax({ url: '/api/generate-prompt', type: 'POST', contentType: 'application/json', data: JSON.stringify({ input: input, api_key: modelApiKey }), success: function(res) { if (res.success && res.prompt) { $('#ai-result-text').val(res.prompt); $('#ai-form').hide(); $('#ai-result').show(); // 自动填充到编辑器 const editorInput = document.getElementById('editor-input'); if (editorInput) { editorInput.textContent = res.prompt; // 保存到 localStorage localStorage.setItem('banana_pending_prompt', res.prompt); } // 自动关闭窗口 setTimeout(function() { winClose('ai-prompt'); }, 500); } else { alert(t('aiPrompt.generateFailed') + ': ' + (res.error || t('aiPrompt.unknownError'))); } }, error: function(xhr) { const res = xhr.responseJSON || {}; if (res.needConfig) { alert(res.error + '\n\n请前往设置页面配置 LLM URL 和模型'); } else { alert(t('aiPrompt.generateFailed') + ': ' + (res.error || t('aiPrompt.networkError'))); } }, complete: function() { $btn.prop('disabled', false).text(t('aiPrompt.generate')); } }); }); // 使用提示词 $('#ai-use-btn').on('click', function() { const prompt = $('#ai-result-text').val(); if (prompt) { // 设置到编辑器 const editorInput = document.getElementById('editor-input'); if (editorInput) { editorInput.textContent = prompt; } } winClose('ai-prompt'); }); })(); </script> FILE:public/components/error.html <!-- 错误提示组件 --> <div class="win-error-display"> <div class="win-error-icon" style="text-align: center; font-size: 48px; margin-bottom: 16px;">⚠️</div> <div class="win-error-content" id="win-error-msg" style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 16px; color: #991b1b; font-size: 14px; max-height: 300px; overflow-y: auto;"></div> <div class="win-footer" style="border-top: none; padding: 0; margin-top: 16px;"> <button class="win-btn win-btn-primary" onclick="winClose('error')" data-i18n="error.close">关闭</button> </div> </div> <script> (function() { // 从 URL 参数或全局变量获取错误信息 const errorMsg = window._winErrorMsg || t('error.unknownError'); $('#win-error-msg').html(errorMsg.replace(/\n/g, '<br>')); })(); </script> FILE:public/components/settings.html <!-- 设置组件 --> <div class="win-settings"> <div class="win-info-box"> <h4 data-i18n="settings.configNote">配置说明</h4> <ul> <li data-i18n="settings.configNote1">APIKey和Platform Token均由 <a href="https://share.acedata.cloud/r/1uN88BrUTQ" target="_blank" class="link">Ace Data</a> 平台提供。</li> <li data-i18n="settings.configNote2">APIKey用于生成图片,Platform Token用于上传图片。</li> <li data-i18n="settings.configNote3">所有密钥都保存在浏览器本地存储localStorage中。</li> </ul> </div> <div class="win-form-group"> <label data-i18n="settings.apiKey">API Key <span class="required" style="color: #dc2626;">*</span></label> <input type="password" id="win-api-key" data-i18n-placeholder="settings.apiKeyPlaceholder" placeholder="请输入 API Key"> <a href="https://share.acedata.cloud/r/1uN88BrUTQ" target="_blank" class="link" style="font-size: 12px;" data-i18n="settings.getApiKey">获取 API Key →</a> </div> <div class="win-form-group"> <label data-i18n="settings.platformToken">Platform Token <span style="color: #6b7280;" data-i18n="settings.platformTokenOptional">(可选)</span></label> <input type="password" id="win-platform-token" data-i18n-placeholder="settings.platformTokenPlaceholder" placeholder="请输入 Platform Token"> <span class="hint" data-i18n="settings.platformTokenHint">仅用于图生图/图片编辑功能</span> </div> <div class="win-form-group"> <label data-i18n="settings.modelApiKey">大模型 API Key</label> <input type="password" id="win-model-api-key" data-i18n-placeholder="settings.modelApiKeyPlaceholder" placeholder="用于AI生成提示词"> <span class="hint"><span data-i18n="settings.modelApiKeyHint">用于AI生成提示词功能</span><a href="/set.html" target="_blank" class="link" style="padding-left: 10px;" data-i18n="settings.setModel">设置模型</a></span> </div> <div class="win-form-group"> <label data-i18n="settings.savePath">图片保存本地路径</label> <input type="text" id="win-save-path" data-i18n-placeholder="settings.savePathPlaceholder" placeholder="默认: 桌面/banana2"> <span class="hint" data-i18n="settings.savePathHint">图片将保存到此目录。留空则使用默认路径。</span> </div> <div class="win-footer"> <button class="win-btn win-btn-secondary" onclick="winClose('settings')" data-i18n="settings.cancel">取消</button> <button class="win-btn win-btn-primary" id="win-settings-save" data-i18n="settings.save">保存设置</button> </div> </div> <script> (function() { // 加载已保存的配置 const apiKey = localStorage.getItem('banana_api_key') || ''; const platformToken = localStorage.getItem('banana_platform_token') || ''; const modelApiKey = localStorage.getItem('banana_model_api_key') || ''; const savePath = localStorage.getItem('banana_save_path') || ''; $('#win-api-key').val(apiKey); $('#win-platform-token').val(platformToken); $('#win-model-api-key').val(modelApiKey); $('#win-save-path').val(savePath); // 保存配置 $('#win-settings-save').on('click', function() { const apiKey = $('#win-api-key').val().trim(); const platformToken = $('#win-platform-token').val().trim(); const modelApiKey = $('#win-model-api-key').val().trim(); const savePath = $('#win-save-path').val().trim(); // 只有非空值才保存,避免覆盖已有值 if (apiKey) localStorage.setItem('banana_api_key', apiKey); if (platformToken) localStorage.setItem('banana_platform_token', platformToken); if (modelApiKey) localStorage.setItem('banana_model_api_key', modelApiKey); if (savePath) localStorage.setItem('banana_save_path', savePath); // 显示成功消息 if (typeof showToast === 'function') { showToast('success', t('settings.saved')); } winClose('settings'); }); })(); </script> FILE:public/components/task-detail.html <!-- 任务详情组件 --> <div class="win-task-detail" id="win-task-detail-content"> <div class="task-detail-loading" style="text-align:center;padding:40px;"> <div class="win-spinner"></div> <p style="margin-top:12px;color:var(--text-secondary,#6b7280);" data-i18n="taskDetail.loading">加载中...</p> </div> </div> <script> (function() { const taskId = window._winTaskId; if (!taskId) { $('#win-task-detail-content').html('<p style="text-align:center;padding:40px;color:#dc2626;" data-i18n="taskDetail.invalidId">任务ID无效</p>'); return; } // 从后端获取任务数据 $.get(`/api/work/taskId`, function(res) { if (!res.success || !res.data) { $('#win-task-detail-content').html('<p style="text-align:center;padding:40px;color:#dc2626;" data-i18n="taskDetail.notFound">任务不存在</p>'); return; } const work = res.data; const date = new Date(work.date); const dateStr = `date.getFullYear()-String(date.getMonth()+1).padStart(2,'0')-String(date.getDate()).padStart(2,'0') String(date.getHours()).padStart(2,'0'):String(date.getMinutes()).padStart(2,'0')`; let statusText = ''; let statusClass = ''; if (work.state === 1) { statusText = t('taskDetail.generating'); statusClass = 'status-pending'; } else if (work.state === 99) { statusText = t('taskDetail.failed'); statusClass = 'status-failed'; } else if (work.state === 10) { statusText = t('taskDetail.completed'); statusClass = 'status-completed'; } else { statusText = t('taskDetail.unknownStatus'); statusClass = 'status-unknown'; } // 获取图片URL let imageUrl = ''; if (work.response_data) { try { const responseData = JSON.parse(work.response_data); imageUrl = responseData.image_url || ''; } catch (e) {} } // 构建信息 const infoItems = []; infoItems.push(work.ratio || '1:1'); infoItems.push(work.quality || '2K'); if (work.cut > 1) infoItems.push(`work.cutt('params.grid')`); const infoText = infoItems.join(' · '); const content = ` <div class="task-detail-header statusClass"> <div class="task-detail-status">statusText</div> <div class="task-detail-date">dateStr</div> </div> <div class="task-detail-body"> <div class="task-detail-section"> <h4 data-i18n="taskDetail.prompt">t('taskDetail.prompt')</h4> <div class="task-detail-prompt">work.prompt || t('taskDetail.none')</div> </div> #dc2626;" data-i18n="taskDetail.errorMsg">${t('taskDetail.errorMsg')</h4> <div class="task-detail-prompt" style="background:#fef2f2;color:#991b1b;">work.error</div> </div> ` : ''} <div class="task-detail-grid"> <div class="task-detail-item"> <span class="label" data-i18n="taskDetail.model">t('taskDetail.model')</span> <span class="value">work.model || 'nano-banana-pro'</span> </div> <div class="task-detail-item"> <span class="label" data-i18n="taskDetail.ratio">t('taskDetail.ratio')</span> <span class="value">1'</span> </div> <div class="task-detail-item"> <span class="label" data-i18n="taskDetail.resolution">t('taskDetail.resolution')</span> <span class="value">work.quality || '2K'</span> </div> <div class="task-detail-item"> <span class="label" data-i18n="taskDetail.cut">t('taskDetail.cut')</span> <span class="value">t('taskDetail.noCut')</span> </div> </div> work.path ? ` <div class="task-detail-section"> <h4 data-i18n="taskDetail.saveLocation">${t('taskDetail.saveLocation')</h4> <div class="task-detail-path" onclick="openFolder('work.path')"> <span>📁 work.path</span> <button class="copy-btn" onclick="event.stopPropagation(); navigator.clipboard.writeText('work.path'); alert('t('taskDetail.copied')');">t('taskDetail.copy')</button> </div> </div> ` : ''} imageUrl ? ` <div class="task-detail-section"> <h4 data-i18n="taskDetail.originalLink">${t('taskDetail.originalLink')</h4> <div class="task-detail-link" onclick="navigator.clipboard.writeText('imageUrl'); alert('t('taskDetail.copied')');"> imageUrl.substring(0, 60)'' </div> </div> ` : ''} <div class="task-detail-section"> <h4 data-i18n="taskDetail.taskId">t('taskDetail.taskId')</h4> <div class="task-detail-taskid"> <span>work.task_id</span> <button class="copy-btn" onclick="navigator.clipboard.writeText('work.task_id'); alert('t('taskDetail.copied')');">t('taskDetail.copy')</button> </div> </div> </div> <div class="task-detail-footer"> work.state === 1 || work.state === 99 ? `<button class="win-btn win-btn-secondary" id="task-retry-btn">${t('taskDetail.fetchImage')</button>` : ''} <button class="win-btn win-btn-primary" id="task-delete-btn" style="background:#dc2626;">t('taskDetail.delete')</button> </div> `; $('#win-task-detail-content').html(content); // 重试按钮 $('#task-retry-btn').on('click', function() { const $btn = $(this); $btn.prop('disabled', true).text(t('taskDetail.fetching')); // 从 localStorage 获取 API Key const apiKey = localStorage.getItem('banana_api_key') || ''; $.ajax({ url: `/api/poll/taskId`, type: 'POST', contentType: 'application/json', data: JSON.stringify({ api_key: apiKey }), success: function(result) { if (result.success) { if (result.status === 'completed') { alert(t('taskDetail.fetchSuccess')); winClose('task-detail'); // 刷新列表 if (window.loadWorks) window.loadWorks(); } else if (result.status === 'pending') { alert(t('taskDetail.stillProcessing')); } else if (result.status === 'failed') { alert(t('taskDetail.generateFailed') + ': ' + (result.msg || t('taskDetail.unknownError'))); } } else { alert(t('taskDetail.fetchFailed') + ': ' + (result.msg || t('taskDetail.unknownError'))); } }, error: function(xhr) { alert(t('taskDetail.networkError') + ': ' + (xhr.responseJSON?.error || xhr.statusText)); }, complete: function() { $btn.prop('disabled', false).text(t('taskDetail.fetchImage')); } }); }); // 删除按钮 $('#task-delete-btn').on('click', function() { if (!confirm(t('taskDetail.confirmDelete'))) return; const $btn = $(this); $btn.prop('disabled', true).text(t('taskDetail.deleting')); $.post(`/api/admin/delete/taskId`, function(res) { if (res.success) { winClose('task-detail'); if (window.loadWorks) window.loadWorks(); } else { alert(t('taskDetail.deleteFailed') + ': ' + res.error); } }).fail(function() { alert(t('taskDetail.networkError')); }); }); }).fail(function(xhr) { $('#win-task-detail-content').html(`<p style="text-align:center;padding:40px;color:#dc2626;">t('taskDetail.loadFailed'): xhr.statusText</p>`); }); // 打开文件夹函数 window.openFolder = function(folderPath) { $.ajax({ url: '/api/open-folder', type: 'POST', contentType: 'application/json', data: JSON.stringify({ path: folderPath }) }); }; })(); </script> <style> .task-detail-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; } .task-detail-header.status-pending { background: #fef3c7; color: #92400e; } .task-detail-header.status-failed { background: #fee2e2; color: #991b1b; } .task-detail-header.status-completed { background: #d1fae5; color: #065f46; } .task-detail-status { font-weight: 600; } .task-detail-date { font-size: 13px; opacity: 0.8; } .task-detail-body { font-size: 14px; } .task-detail-section { margin-bottom: 16px; } .task-detail-section h4 { margin: 0 0 8px 0; font-size: 13px; color: var(--text-secondary, #6b7280); font-weight: 500; } .task-detail-prompt { background: var(--bg-hover, #f3f4f6); padding: 12px; border-radius: 8px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; } .task-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; } .task-detail-item { display: flex; justify-content: space-between; padding: 8px 12px; background: var(--bg-hover, #f3f4f6); border-radius: 6px; } .task-detail-item .label { color: var(--text-secondary, #6b7280); } .task-detail-item .value { font-weight: 500; } .task-detail-path { display: flex; align-items: center; justify-content: space-between; gap: 8px; background: var(--bg-hover, #f3f4f6); padding: 10px 12px; border-radius: 6px; cursor: pointer; } .task-detail-path:hover { background: var(--bg-active, #e5e7eb); } .task-detail-path span { flex: 1; font-size: 13px; word-break: break-all; } .task-detail-link { background: var(--bg-hover, #f3f4f6); padding: 10px 12px; border-radius: 6px; font-size: 12px; word-break: break-all; cursor: pointer; color: var(--primary-color, #3b82f6); } .task-detail-link:hover { text-decoration: underline; } .task-detail-taskid { display: flex; align-items: center; gap: 8px; background: var(--bg-hover, #f3f4f6); padding: 10px 12px; border-radius: 6px; font-family: monospace; font-size: 12px; word-break: break-all; } .task-detail-taskid span { flex: 1; } .copy-btn { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border-color, #e5e7eb); background: var(--bg-primary, #fff); border-radius: 4px; cursor: pointer; flex-shrink: 0; } .copy-btn:hover { background: var(--bg-hover, #f3f4f6); } .task-detail-footer { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color, #e5e7eb); } </style> FILE:public/components/viewer.html <!-- 图片查看器组件 - 全屏层 --> <div class="viewer-layer" id="viewer-layer"> <!-- 关闭按钮 - 右上角 --> <button class="viewer-close-btn" id="viewer-close-btn" title="关闭">✕</button> <div class="viewer-container"> <!-- 左侧按钮区 --> <div class="viewer-sidebar"> <button class="viewer-side-btn icon icon-creative_fill" style="font-size: 22px;color: rgb(11, 204, 204);" id="viewer-info-btn" title="" data-i18n-title="viewer.info"></button> <button class="viewer-side-btn" id="viewer-folder-btn" title="" data-i18n-title="viewer.openFolder">📁</button> <button class="viewer-side-btn icon icon-a-chuangzuo2" style="font-size: 22px;color: crimson;" id="viewer-edit-btn" title="" data-i18n-title="viewer.regenerate"></button> <button class="viewer-side-btn icon icon-chuangzuo1" id="viewer-add-btn" title="编辑图片" style="font-size: 22px;color: rgb(7, 176, 58);"></button> <!-- 参考图片区域 --> <div class="viewer-ref-images" id="viewer-ref-images" style="display: none;"> <div class="viewer-ref-title">参考图</div> <div class="viewer-ref-list" id="viewer-ref-list"></div> </div> </div> <!-- 中间图片展示区 --> <div class="viewer-main"> <img id="viewer-main-img" src="" alt="" class="viewer-main-img"> </div> <!-- 右侧图片列表区 - 仅当 cut > 1 时显示 --> <div class="viewer-list" id="viewer-list" style="display: none;"> <div class="viewer-list-columns"> <div class="viewer-list-col" id="viewer-list-left"></div> <div class="viewer-list-col" id="viewer-list-right"></div> </div> </div> </div> </div> <script> (function() { const workId = window._winViewerId; if (!workId) return; // 隐藏编辑器 const editor = document.getElementById('editor'); const editorDisplay = editor ? editor.style.display : 'block'; if (editor){editor.style.display = 'none';$('#btn-editor').removeClass('active')} // 从后端获取任务数据 $.get(`/api/work/workId`, function(res) { if (!res.success || !res.data) { alert(t('taskDetail.notFound')); closeViewer(); return; } const work = res.data; window._currentViewerWork = work; // 检查任务状态 if (work.state !== 10) { alert(t('taskDetail.notFound')); closeViewer(); return; } // 使用 http_path(后端已计算好) const httpPath = work.http_path || `/images/workId`; const mainSrc = `httpPath/main.png`; const thumbSrc = `httpPath/thumb.png`; // 设置主图 $('#viewer-main-img').attr('src', mainSrc); // 解析 request_data 获取参考图片 let refImages = []; try { const requestData = JSON.parse(work.request_data || '{}'); // 从 request_data.image_urls 获取 if (requestData.image_urls && requestData.image_urls.length > 0) { refImages = requestData.image_urls; } } catch (e) { console.error('解析 request_data 失败:', e); } // 显示参考图片 if (refImages.length > 0) { $('#viewer-ref-images').show(); const refHtml = refImages.map((url, idx) => ` <div class="viewer-ref-item" data-url="url" title="点击查看参考图 idx + 1"> <img src="url" alt="参考图 idx + 1" onerror="this.parentElement.style.display='none'"> </div> `).join(''); $('#viewer-ref-list').html(refHtml); // 点击参考图在主窗口显示 $('.viewer-ref-item').on('click', function() { const url = $(this).data('url'); $('#viewer-main-img').attr('src', url); }); } // 切割图列表 - 仅当 cut > 1 时显示 if (work.cut > 1) { $('#viewer-list').show(); let leftHtml = ''; let rightHtml = ''; // 主图放左边 - 使用默认值或尝试获取翻译 const mainImageLabel = (typeof t === 'function') ? t('viewer.mainImage') : '主图.png'; leftHtml += ` <div class="viewer-list-item active" data-src="mainSrc"> <img src="thumbSrc" alt="mainImageLabel" onerror="if(this.src!==this.dataset.fallback){this.src=this.dataset.fallback}else{this.style.display='none'}" data-fallback="mainSrc"> <div class="viewer-list-item-label">mainImageLabel</div> </div> `; // 切割图 - 偶数放左边,奇数放右边 for (let i = 1; i <= work.cut; i++) { const itemHtml = ` <div class="viewer-list-item" data-src="httpPath/i.png"> <img src="httpPath/i.png" alt="切割图 i" onerror="this.parentElement.style.display='none'"> <div class="viewer-list-item-label">i.png</div> </div> `; if (i % 2 === 0) { leftHtml += itemHtml; // 偶数:2, 4, 6... } else { rightHtml += itemHtml; // 奇数:1, 3, 5... } } $('#viewer-list-left').html(leftHtml); $('#viewer-list-right').html(rightHtml); // 点击列表项切换主图 $('.viewer-list-item').on('click', function() { const src = $(this).data('src'); $('#viewer-main-img').attr('src', src); $('.viewer-list-item').removeClass('active'); $(this).addClass('active'); }); } // 绑定按钮事件 // 详情 $('#viewer-info-btn').on('click', function() { closeViewer(); window._winTaskId = workId; win('task-detail', '任务详情', 'task-detail.html', 450); }); // 打开文件夹 $('#viewer-folder-btn').on('click', function() { $.ajax({ url: '/api/open-folder', type: 'POST', contentType: 'application/json', data: JSON.stringify({ path: work.path }) }); }); // 编辑 $('#viewer-edit-btn').on('click', function() { const editorInput = document.getElementById('editor-input'); if (editorInput && work.prompt) { editorInput.textContent = work.prompt; } closeViewer(); }); // 添加到编辑器 $('#viewer-add-btn').on('click', function() { const editorInput = document.getElementById('editor-input'); const editorElement = document.getElementById('editor'); const editorUpload = document.getElementById('editor-upload'); const uploadBtn = document.getElementById('upload-btn'); // 设置提示词 if (editorInput && work.prompt) { editorInput.textContent = work.prompt; } // 获取当前 main 中显示的图片 src const currentSrc = $('#viewer-main-img').attr('src'); if (!currentSrc || !work.path) { return; } // 使用全局 state 变量 if (typeof state !== 'undefined' && state.uploadedImages) { // 从 currentSrc 提取文件名,统一处理路径分隔符 let filename = 'main.png'; const normalizedSrc = currentSrc.replace(/\\/g, '/'); const lastSlashIndex = normalizedSrc.lastIndexOf('/'); if (lastSlashIndex !== -1 && lastSlashIndex < normalizedSrc.length - 1) { filename = normalizedSrc.substring(lastSlashIndex + 1); } // 组合完整的本地路径 const localFilePath = work.path + '\\' + filename; // 检查是否已存在 const exists = state.uploadedImages.some(img => img.file_path === localFilePath || img.url === currentSrc ); if (!exists) { state.uploadedImages.push({ name: filename, uploaded: false, fromWork: true, file_path: localFilePath, url: currentSrc }); if (typeof renderUploadedImages === 'function') { renderUploadedImages(); } } } // 显示编辑器 if (editorElement) { editorElement.style.display = 'block'; } if (editorUpload && state && state.uploadedImages && state.uploadedImages.length > 0) { editorUpload.style.display = 'flex'; } if (uploadBtn && state && state.uploadedImages && state.uploadedImages.length > 0) { uploadBtn.style.display = 'none'; } closeViewer(); }); // 关闭 $('#viewer-close-btn').on('click', closeViewer); // 点击背景关闭 $('#viewer-layer').on('click', function(e) { if (e.target === this) { closeViewer(); } }); // ESC 关闭 $(document).on('keydown.viewer', function(e) { if (e.key === 'Escape') { closeViewer(); } }); }).fail(function() { alert('加载失败'); closeViewer(); }); function closeViewer() { $('#viewer-layer').removeClass('show'); setTimeout(() => { $('#viewer-layer').remove(); $(document).off('keydown.viewer'); // 恢复编辑器显示 if (editor) { editor.style.display = editorDisplay; $('#btn-editor').addClass('active') } }, 300); } window.closeViewerLayer = closeViewer; // 显示动画 setTimeout(() => $('#viewer-layer').addClass('show'), 10); })(); </script> <style> .viewer-layer { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 800; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); opacity: 0; transition: opacity 0.3s ease; } .viewer-layer.show { opacity: 1; } body.dark .viewer-layer { background: rgba(17, 24, 39, 0.95); } /* 关闭按钮 - 右上角 */ .viewer-close-btn { position: absolute; top: 70px; right: 40px; width: 40px; height: 40px; border: none; background: #000; color: #fff; border-radius: 50%; font-size: 18px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; z-index: 10; } .viewer-close-btn:hover { transform: scale(1.1); background: #333; } body.dark .viewer-close-btn { background: #4b5563; } body.dark .viewer-close-btn:hover { background: #6b7280; } /* 容器 */ .viewer-container { display: flex; height: 100%; justify-content: center; padding: 20px; padding-top: 70px; padding-bottom: 50px; gap: 10px; } /* 左侧按钮区 */ .viewer-sidebar { width: 50px; display: flex; flex-direction: column; padding: 10px 0; gap: 12px; align-items: center; } .viewer-side-btn { width: 44px; height: 44px; border: none; background: #eee; border-radius: 50%; font-size: 18px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; } .viewer-side-btn:hover { background: #ddd; transform: scale(1.05); } body.dark .viewer-side-btn { background: #374151; } body.dark .viewer-side-btn:hover { background: #4b5563; } /* 参考图片区域 */ .viewer-ref-images { margin-top: 16px; padding-top: 16px; border-top: 1px solid #e5e7eb; width: 100%; } body.dark .viewer-ref-images { border-top-color: #374151; } .viewer-ref-title { font-size: 10px; color: #6b7280; text-align: center; margin-bottom: 8px; } body.dark .viewer-ref-title { color: #9ca3af; } .viewer-ref-list { display: flex; flex-direction: column; gap: 8px; align-items: center; } .viewer-ref-item { width: 44px; height: 44px; border-radius: 8px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: all 0.2s; } .viewer-ref-item:hover { border-color: #3b82f6; transform: scale(1.05); } .viewer-ref-item img { width: 100%; height: 100%; object-fit: cover; } /* 中间图片展示区 */ .viewer-main { flex: 1; max-width: 50%; display: flex; align-items: flex-start; justify-content: center; overflow: hidden; } .viewer-main-img { max-width: 100%; max-height: 100%; object-fit: contain; margin: 0 auto; } /* 右侧图片列表区 */ .viewer-list { width: 25%; display: flex; flex-direction: column; min-width: 0; } .viewer-list-columns { display: flex; gap: 10px; overflow-y: auto; padding: 5px; height: 100%; } .viewer-list-col { width: 50%; float: left; } .viewer-list-item { position: relative; border-radius: 8px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: all 0.2s; margin-bottom: 10px; float: left; width: 100%; } .viewer-list-item:hover { border-color: var(--primary-color, #3b82f6); } .viewer-list-item.active { border-color: var(--primary-color, #3b82f6); box-shadow: 0 0 0 3px rgba(59, 130, 246, 1); } .viewer-list-item img { width: 100%; height: auto; display: block; float: left; } /* 图片名称标签 */ .viewer-list-item-label { position: absolute; bottom: 0; left: 0; right: 0; height: 20px; background: rgba(0, 0, 0, 0.2); color: #fff; font-size: 10px; display: flex; align-items: center; justify-content: center; } /* 暗色主题 */ body.dark .viewer-list-item { background: var(--bg-hover, #374151); } body.dark .viewer-list-item-label { background: rgba(0, 0, 0, 0.5); } /* 滚动条样式 */ .viewer-list-columns::-webkit-scrollbar { width: 6px; } .viewer-list-columns::-webkit-scrollbar-track { background: transparent; } .viewer-list-columns::-webkit-scrollbar-thumb { background: var(--border-color, #e5e7eb); border-radius: 3px; } .viewer-list-columns::-webkit-scrollbar-thumb:hover { background: var(--text-secondary, #6b7280); } body.dark .viewer-list-columns::-webkit-scrollbar-thumb { background: #4b5563; } body.dark .viewer-list-columns::-webkit-scrollbar-thumb:hover { background: #6b7280; } </style> FILE:docs/zh/SKILL.md --- name: Nano-Banana-V2 version: 1.1.0 description: AI图片生成与智能切割工具,基于AceData Nano Banana模型,支持多分辨率多尺寸生成,自动切割为2/4/6/9宫格,自带瀑布流作品管理、批量下载功能。支持中/英/繁/日/韩五种语言。 author: 小潴 (Xiao Zhu) email: [email protected] wechat: jakeycis tags: [ai-image, banana2, nodejs, openclaw, skill, multi-language] icon: https://ext.zjhn.com/banana2/logo.png homepage: https://banana2.zjhn.com repository: https://github.com/xiyunnet/banana2 license: MIT --- # Nano Banana V2 - AI 图片创作工具 **[English](../en/SKILL.md)** | **简体中文** ## 功能说明 Nano Banana V2 是一个功能强大的 AI 图片生成与智能切割工具,基于 AceData Nano Banana 系列模型,提供完整的 Web 界面进行图片生成、编辑、切割和管理。 ### 核心功能 #### 🎨 图片生成 - **多种模型支持**:Nano Banana Pro、Nano Banana 2 - **多种分辨率**:1:1(方形)、16:9(横屏)、9:16(竖屏)、4:3(标准)、3:4(竖版) - **多种质量**:1K (1024px)、2K (2048px)、4K (4096px) - **图生图**:支持最多 6 张参考图进行图片编辑 #### ✂️ 智能切割 - **多种宫格**:支持 2/4/6/9 宫格切割 - **智能适配**:自动识别横竖比例,智能选择最佳切割方式 #### 🖼️ 作品管理 - **瀑布流展示**:自动适配不同图片比例,美观展示所有作品 - **缩略图生成**:自动生成缩略图 - **批量下载**:支持打包下载 - **文件管理**:一键打开文件目录 #### 🌐 多语言支持 - **简体中文** (zh) - **English** (en) - **繁體中文** (zh-TW) - **日本語** (ja) - **한국어** (ko) #### 🎨 用户体验 - **双主题**:支持亮色/暗色主题切换 - **实时预览**:图片预览、切割图预览 - **现代UI**:磨砂玻璃质感,流畅动画,瀑布流布局 - **AI 提示词生成**:集成大模型,智能生成提示词 --- ## 快速开始 ### 1. 安装依赖 ```bash cd ~/.openclaw/workspace/skills/nano-banana-v2 npm install ``` ### 2. 启动服务 ```bash npm start ``` ### 3. 访问应用 浏览器打开 http://localhost:2688 --- ## 配置说明 ### 获取 API Key 首次使用需要配置 API Key: 1. 访问:https://share.acedata.cloud/r/1uN88BrUTQ 2. 注册并获取 API Key 3. 在设置页面填写 API Key ### 配置项 | 配置项 | 必填 | 说明 | |--------|------|------| | API Key | ✅ | 用于图片生成 | | Platform Token | ❌ | 用于图生图功能 | | 大模型 API Key | ❌ | 用于 AI 生成提示词 | | 保存路径 | ❌ | 图片保存目录(默认:桌面/banana2) | --- ## 使用说明 ### 生成图片 1. 在底部编辑器输入提示词 2. 选择模型、分辨率、质量 3. 选择切割方式(可选) 4. 点击生成按钮 5. 等待生成完成 ### 图生图 1. 点击上传按钮选择图片(最多 6 张) 2. 输入描述 3. 点击生成 ### AI 生成提示词 1. 点击 AI 按钮打开提示词生成窗口 2. 输入简单描述 3. 点击生成,AI 会生成详细的提示词 ### 查看作品 - 点击图片查看大图 - 查看切割图列表 - 下载、删除作品 --- ## 技术栈 - **后端**:Node.js + Express + SQLite - **前端**:HTML5 + CSS3 + JavaScript (jQuery) - **图片处理**:Sharp - **API**:AceData Nano Banana API --- ## 目录结构 ``` nano-banana-v2/ ├── server/ │ ├── index.js # 主服务器 │ └── services/ # 服务类 │ ├── database.js # 数据库服务 │ ├── request.js # 请求处理 │ ├── task.js # 任务处理 │ └── upload.js # 上传处理 ├── public/ │ ├── index.html # 主页面 │ ├── list.html # 列表页面 │ ├── set.html # 设置页面 │ ├── components/ # UI 组件 │ ├── css/ # 样式文件 │ ├── js/ # 应用逻辑 │ └── lan/ # 多语言文件 ├── config/ │ ├── set.json # 主配置文件 │ ├── system.prompt # AI 系统提示词 │ └── cut_*.prompt # 切割提示词 ├── database/ │ ├── 1.sql # 数据库初始化 │ └── 2.sql # 数据库迁移 ├── package.json ├── README.md ├── LICENSE └── CHANGELOG.md ``` --- ## API 接口 | 方法 | 路径 | 说明 | |------|------|------| | GET | /api/get_set | 获取配置 | | POST | /api/generate | 提交生成任务 | | POST | /api/poll/:id | 轮询任务状态 | | POST | /api/upload | 上传图片 | | GET | /api/works | 获取作品列表 | | GET | /api/work/:id | 获取单个作品 | | POST | /api/tasks/add | 手动添加任务 | | POST | /api/admin/delete/:id | 删除作品 | | POST | /api/open-folder | 打开文件夹 | | POST | /api/generate-prompt | AI 生成提示词 | | POST | /api/shutdown | 关闭服务 | --- ## 更新日志 ### v1.1.0 (2026-03-30) - 新增韩语 (한국어) 支持 - 完善所有组件的多语言翻译 - 优化图片路径动态映射 - 清理冗余 API 接口 - 修复已知问题 ### v1.0.0 (2026-03-26) - 初始版本发布 --- ## 开源信息 - **许可证**:MIT License - **仓库地址**:https://github.com/xiyunnet/banana2 - **问题反馈**:https://github.com/xiyunnet/banana2/issues - **主页**:https://banana2.zjhn.com --- ## 联系方式 - **作者**:小潴 (Xiao Zhu) - **Email**: [email protected] - **WeChat**: jakeycis FILE:database/1.sql -- 创建任务表 CREATE TABLE IF NOT EXISTS creat ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, state INTEGER DEFAULT 1, task_id TEXT, prompt TEXT, size TEXT, quality TEXT, task_response TEXT, task_times INTEGER DEFAULT 0, cut INTEGER DEFAULT 1, path TEXT, filename TEXT, ext TEXT, error TEXT, last_request INTEGER, model TEXT, ratio TEXT, request_data TEXT, respond TEXT ); -- 创建上传表 CREATE TABLE IF NOT EXISTS upload ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, state INTEGER DEFAULT 1, timeout INTEGER, url TEXT, filename TEXT, file_hash TEXT ); -- 创建索引 CREATE INDEX IF NOT EXISTS idx_creat_state ON creat(state); CREATE INDEX IF NOT EXISTS idx_creat_task_id ON creat(task_id); CREATE INDEX IF NOT EXISTS idx_upload_timeout ON upload(timeout); CREATE INDEX IF NOT EXISTS idx_upload_file_hash ON upload(file_hash); FILE:database/2.sql -- 添加新字段到creat表 ALTER TABLE creat ADD COLUMN callback_url TEXT; ALTER TABLE creat ADD COLUMN poll_count INTEGER DEFAULT 0; ALTER TABLE creat ADD COLUMN response_data TEXT; FILE:database/info.txt 0 FILE:config/cut_2.text #上下切割图片生成规则 - 生成上下2张图片的结合,每张图片遵循用户提供的prompt,用于多张图片生成故事情节。 - 图片叙事逻辑严格根据用户提供的prompt,如果没有说明则按照故事情节的发展进行。 - 严格保证图片的风格。 - 每张图片之间不能有空的间隔,不需要对图片进行文本说明。 FILE:config/cut_4.text #四宫格图片生成规则 - 生成2x2的图片,每张图片遵循用户提供的prompt,用于多张图片生成故事情节。 - 图片叙事逻辑严格根据用户提供的prompt,如果没有说明则按照故事情节的发展进行。 - 确保每张小图的风格完全一致。 - 每张小图的尺寸与整图保持一致,例如整图是9:16,小图也是9:16。 - 每张图片之间不能有空的间隔,不需要对图片进行文本说明。 FILE:config/cut_6.text #六宫格图片生成规则 - 生成2x3的图片,每张图片遵循用户提供的prompt,用于多张图片生成故事情节。 - 图片叙事逻辑严格根据用户提供的prompt,如果没有说明则按照故事情节的发展进行。 - 确保每张小图的风格完全一致。 - 每张图片之间不能有空的间隔,不需要对图片进行文本说明。 FILE:config/cut_9.text #九宫格图片生成规则 - 生成3x3的图片,每张图片遵循用户提供的prompt,用于多张图片生成故事情节。 - 图片叙事逻辑严格根据用户提供的prompt,如果没有说明则按照故事情节的发展进行。 - 确保每张小图的风格完全一致。 - *严格保证每张小图的尺寸比例与生成图保持一致,例如生成图是9:16,所有小图也是9:16。 - 每张图片之间不能有空的间隔,不要对图片进行文本说明。 FILE:config/set.json { "server": { "url": "https://api.acedata.cloud/nano-banana/images", "task_url": "https://api.acedata.cloud/nano-banana/tasks", "upload_url": "https://platform.acedata.cloud/api/v1/files/", "port": 2688, "upload_timeout": 86400000 }, "models": [ { "name": "Nano Banana Pro", "displayName": "Nano Banana Pro", "model": "nano-banana-pro", "logo": "🍌", "description": "专业版 - 高质量生成", "size": [ "1:1", "16:9", "9:16", "4:3", "3:4", "5:4", "6:3" ], "quality": [ "1K", "2K", "4K" ], "cut": [ 1, 2, 4, 9 ], "max": 1, "request": { "size": "aspect_ratio", "quality": "resolution" } }, { "name": "Nano Banana 2", "displayName": "Nano Banana 2", "model": "nano-banana-2", "logo": "🍌", "description": "标准版 - 快速生成", "size": [ "1:1", "16:9", "9:16", "4:3", "3:4" ], "quality": [ "1K", "2K", "4K" ], "cut": [ 1, 2, 4, 6, 9 ], "max": 1, "request": { "size": "aspect_ratio", "quality": "resolution" } } ], "llm": { "url": "https://ark.cn-beijing.volces.com/api/coding/v3", "model": "doubao-seed-2.0-pro", "name": "doubao-seed-2.0-pro" }, "languages": [ { "code": "zh", "name": "简体中文", "short": "中" }, { "code": "en", "name": "English", "short": "EN" }, { "code": "zh-TW", "name": "繁體中文", "short": "繁" }, { "code": "ja", "name": "日本語", "short": "日" }, { "code": "ko", "name": "한국어", "short": "한" } ], "polling": { "interval": 20000, "max_times": 60 }, "cut": { "padding": 3, "mode": "none", "grid": 9, "line_width": 0, "line_color": "#ffffff", "format": "png", "quality": 90 }, "default_save_path": "" } FILE:config/system.text 严格根据用户的提示词进行创作。如果需要生成多张图片,必须严格按照故事发展顺序。 生成的图片prompt,字数100个左右,要对人物,背景,表情细节都有表述。 生成格式: 故事的概要(所需要生成的故事) 人物设定(包括人物的发行,服饰等) 生成风格(整体图片生成的风格) 图片1:.. 图片2:.. ... 图片n:..
AI图片生成与智能切割工具,基于AceData Nano Banana模型,支持多分辨率多尺寸生成,自动切割为2/4/6/9宫格,自带瀑布流作品管理、批量下载功能。使用场景:(1) 输入prompt生成AI图片并自动切割成九宫格等多宫格 (2) 上传图片进行智能多宫格切割 (3) 管理生成的图片作品,支持打包下载
---
name: Nano-Banana-Cut
description: AI图片生成与智能切割工具,基于AceData Nano Banana模型,支持多分辨率多尺寸生成,自动切割为2/4/6/9宫格,自带瀑布流作品管理、批量下载功能。使用场景:(1) 输入prompt生成AI图片并自动切割成九宫格等多宫格 (2) 上传图片进行智能多宫格切割 (3) 管理生成的图片作品,支持打包下载
---
# Nano-Banana-Cut AI图片生成切割工具
## 📖 功能说明
banana-cut 是一个功能强大的 AI 图片生成与智能切割工具,基于 AceData Nano Banana 系列模型,提供完整的 Web 界面进行图片生成、切割和管理。
### 核心功能
#### 🎨 图片生成
- **多种模型支持**:Nano Banana Pro、Nano Banana 2
- **多种分辨率**:1:1(方形)、16:9(横屏)、9:16(竖屏)、4:3(标准)、3:4(竖版)
- **多种质量**:1K (1024px)、2K (2048px)、4K (4096px)
- **图生图**:支持最多上传 6 张参考图进行图片编辑
#### ✂️ 智能切割
- **多种宫格**:支持 2/4/6/9 宫格切割
- **智能适配**:自动识别横竖比例,智能选择最佳切割方式
- **独立工具**:提供独立的切割 API,可单独使用
#### 🖼️ 作品管理
- **瀑布流展示**:自动适配不同图片比例,美观展示所有作品
- **缩略图生成**:自动生成 480P 缩略图
- **批量下载**:支持 ZIP 打包下载(原图 + 切割图 + 缩略图 + 生成信息)
- **文件管理**:一键打开文件目录
#### 🎯 任务管理
- **多任务并发**:支持多个任务同时轮询,互不阻塞
- **智能轮询**:第一次延迟 1 分钟,之后每 30 秒查询一次
- **任务恢复**:服务重启后自动恢复未完成任务
- **错误重试**:失败任务支持一键重新获取
#### 🎨 用户体验
- **双主题**:支持亮色/暗色主题切换
- **实时预览**:图片预览、切割图预览
- **本地存储**:自动保存用户配置(模型、分辨率、质量、宫格数)
---
## 🚀 快速开始
### 1. 启动服务
```bash
cd C:\Users\86137\.openclaw\workspace\skills\banana-cut
python server.py
```
访问地址:http://localhost:697
### 2. 配置 API 密钥
首次访问会自动弹出配置窗口,需要填写:
- **API_KEY(必填)**:用于图片生成功能
- **PLATFORM_TOKEN(可选)**:仅图生图/图片编辑功能需要
**获取密钥**:https://share.acedata.cloud/r/1uN88BrUTQ
配置会保存到 `.env` 文件中。
### 3. 生成图片
1. 输入提示词(支持中英文)
2. 选择模型、分辨率、质量
3. 选择宫格数(1/2/4/6/9)
4. 点击生成按钮
5. 等待任务完成(前端自动轮询)
### 4. 图生图(可选)
1. 点击左下角图片图标上传参考图(最多 6 张)
2. 输入提示词描述编辑需求
3. 点击生成按钮
---
## 📁 文件结构
```
banana-cut/
├── server.py # Flask 主服务器
├── task.py # 独立任务处理脚本
├── cut.py # 图片切割工具
├── upload.py # 图片上传工具
├── set.json # 配置文件(模型、分辨率、质量)
├── prompt.md # 提示词模板
├── .env # 环境变量(API密钥)
├── .env.example # 环境变量示例
├── SKILL.md # 本文档
├── data/
│ └── works.db # SQLite 数据库
└── templates/
├── index.html # 主页面(瀑布流)
└── admin.html # 管理后台
```
---
## ⚙️ 配置说明
### set.json 配置文件
```json
{
"server": {
"url": "https://api.acedata.cloud/nano-banana/images",
"task_url": "https://api.acedata.cloud/nano-banana/tasks",
"upload_url": "https://platform.acedata.cloud/api/v1/files/",
"apikey": ""
},
"models": [
{"name": "Nano Banana Pro", "model": "nano-banana-pro", "logo": "🍌"},
{"name": "Nano Banana 2", "model": "nano-banana-2", "logo": "🍌"}
],
"resolutions": [
{"name": "1:1 方形", "ratio": "1:1", "icon": "■"},
{"name": "16:9 横屏", "ratio": "16:9", "icon": "▬"},
{"name": "9:16 竖屏", "ratio": "9:16", "icon": "▮"},
{"name": "4:3 标准", "ratio": "4:3", "icon": "▭"},
{"name": "3:4 竖版", "ratio": "3:4", "icon": "▯"}
],
"qualities": [
{"name": "1K (1024px)", "size": "1K", "icon": "🖥️"},
{"name": "2K (2048px)", "size": "2K", "icon": "🖥️"},
{"name": "4K (4096px)", "size": "4K", "icon": "🖥️"}
],
"save_path": "C:/Users/86137/Desktop/banana"
}
```
**注意事项**:
- `apikey` 字段已废弃,请使用 `.env` 文件配置
- `save_path` 可自定义图片保存路径
### .env 环境变量
```bash
# 请访问 https://share.acedata.cloud/r/1uN88BrUTQ 获取以下配置
API_KEY=your-api-key-here
PLATFORM_TOKEN=your-platform-token-here
```
### prompt.md 提示词模板
生成图片时会自动读取该文件,将 `{num}` 替换为选择的宫格数,可自定义模板内容。
```markdown
##生成要求:##
严格生成一张{num}宫格的高质量图片,每个格子的内容根据输入的提示词完全独立...
##用户要求:##
{prompt}
```
---
## 🗄️ 数据库结构
数据库文件:`data/works.db`
### works 表结构
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键,自增 |
| model | TEXT | 使用的模型 |
| date | TEXT | 生成日期,格式 YYYY-MM-DD HH:MM:SS |
| state | INTEGER | 状态:1=生成中,10=成功,99=失败,0=文件丢失 |
| prompt | TEXT | 生成提示词 |
| ratio | TEXT | 分辨率比例 |
| quality | TEXT | 图片质量 |
| task_id | TEXT | 模型返回的任务ID |
| num | INTEGER | 切割宫格数:1/2/4/6/9 |
| path | TEXT | 保存目录路径 |
| filename | TEXT | 主图文件名 |
| ext | TEXT | 图片扩展名 |
| request_data | TEXT | 请求参数JSON |
| error | TEXT | 错误信息 |
| respond | TEXT | 接口返回内容JSON |
---
## 💾 保存目录结构
每个生成任务会创建独立目录:`保存路径/YYYYMMDDHHMMSS/`
目录下包含:
- `main.ext`:生成的原始主图
- `480p.ext`:480P 缩略图
- `1.ext` ~ `n.ext`:切割后的子图(n=宫格数)
- `data.txt`:生成信息和参数说明
---
## 🔌 API 接口
### 配置相关
#### GET /api/config/check
检查配置状态
**返回**:
```json
{
"success": true,
"msg": "配置正常"
}
```
#### POST /api/config/save
保存配置
**参数**:
```json
{
"api_key": "your-api-key",
"platform_token": "your-platform-token"
}
```
#### GET /api/config
获取配置(模型、分辨率、质量)
### 图片生成
#### POST /api/generate
生成图片
**参数**:
```json
{
"prompt": "图片描述",
"model": 0,
"ratio": 0,
"quality": 1,
"num": 9,
"images": []
}
```
**返回**:
```json
{
"success": true,
"msg": "任务提交成功",
"work_id": 1
}
```
#### GET /api/poll/:id
轮询任务状态
**返回**:
```json
{
"success": true,
"data": {
"state": 10,
"error": "",
"task_id": "xxx",
"respond": {}
}
}
```
### 作品管理
#### GET /api/works
获取所有作品列表
#### GET /api/work/:id
获取单个作品详情
#### GET /api/download/:id
打包下载作品
#### POST /api/open-folder/:id
打开文件目录
### 图片上传
#### POST /api/upload
上传图片(需要 PLATFORM_TOKEN)
### 图片切割
#### POST /api/cut
切割图片
**参数**:
```json
{
"path": "图片路径",
"num": 9,
"out": "输出目录(可选)"
}
```
### 管理接口
#### GET /api/admin/works
获取所有任务(管理后台)
#### POST /api/admin/retry/:id
重试任务
#### POST /api/admin/close/:id
关闭任务
#### POST /api/admin/delete/:id
删除任务
#### POST /api/shutdown
关闭服务
---
## 🛠️ 独立工具使用
### cut.py - 图片切割工具
```bash
python cut.py -path 图片路径 -num 9 [-out 输出目录]
```
**参数**:
- `-path`:图片路径(必填)
- `-num`:宫格数 2/4/6/9(必填)
- `-out`:输出目录(可选,默认原图片目录)
### task.py - 任务处理工具
```bash
python task.py -id 任务ID
python task.py -task_id 模型任务ID
```
**参数**:
- `-id`:数据库任务ID
- `-task_id`:模型返回的任务ID
- `-num`:宫格数(可选,默认读取数据库)
### upload.py - 图片上传工具
```bash
python upload.py -file 图片路径
```
---
## 🎯 使用场景
### 场景1:生成九宫格图片
1. 打开 http://localhost:697
2. 输入提示词:"生成一张3x3九宫格电影海报图片"
3. 选择 9 宫格
4. 点击生成
5. 等待完成,查看瀑布流展示
### 场景2:图生图编辑
1. 上传参考图(最多6张)
2. 输入描述:"参考原图风格,生成..."
3. 点击生成
4. 查看结果
### 场景3:管理已生成作品
1. 点击作品查看详情
2. 下载 ZIP 打包
3. 打开文件目录
4. 以图生图
### 场景4:失败任务重试
1. 切换到错误记录视图(导航栏感叹号按钮)
2. 点击失败任务查看详情
3. 点击"重新获取"
4. 查看实时日志
---
## 🔧 故障排除
### 问题1:未配置 API_KEY
**症状**:自动弹出配置窗口
**解决**:填写 API_KEY,保存后刷新页面
### 问题2:图片生成失败
**症状**:任务状态为失败
**解决**:
1. 查看错误详情
2. 检查 API_KEY 是否正确
3. 点击"重新获取"重试
### 问题3:服务启动失败
**症状**:端口被占用
**解决**:
```bash
# 查看端口占用
netstat -ano | findstr :697
# 结束进程
taskkill /F /PID 进程ID
# 重新启动
python server.py
```
### 问题4:瀑布流布局错乱
**症状**:图片显示异常
**解决**:刷新页面,前端会重新初始化 Masonry
---
## 📊 性能优化
### 多任务并发
- 支持多个任务同时轮询
- 每个任务独立定时器,互不阻塞
- 第一次延迟 1 分钟,之后每 30 秒查询
### 前端轮询
- 轮询逻辑完全由前端控制
- 减少服务器压力
- 支持页面刷新自动恢复
### 数据库优化
- 使用 SQLite 轻量级数据库
- 自动检测并添加新字段
- 支持旧数据库平滑升级
---
## 🔄 更新日志
### v2.0.0 (2026-03-25)
- ✅ 修复数据库表结构缺少 respond 字段
- ✅ 移除 API Key 硬编码,改用环境变量
- ✅ 修复全局锁导致任务阻塞问题
- ✅ 优化任务恢复机制
- ✅ 修复瀑布流缩小问题
- ✅ 修复弹窗重叠问题
- ✅ 优化轮询策略(第一次1分钟,之后30秒)
- ✅ 支持多任务并发轮询
- ✅ 清理无用文件
### v1.0.0 (2026-03-23)
- 🎉 初始版本发布
- ✅ 基础图片生成功能
- ✅ 智能切割功能
- ✅ 瀑布流展示
- ✅ 批量下载功能
---
## 📝 开发者备注
### 技术栈
- **后端**:Flask + SQLite + PIL
- **前端**:jQuery + Masonry + imagesLoaded
- **API**:AceData Nano Banana API
### 扩展建议
1. 添加更多 AI 模型支持
2. 添加图片滤镜功能
3. 添加批量生成功能
4. 添加用户认证系统
5. 添加 WebSocket 实时推送
### 贡献代码
欢迎提交 Issue 和 Pull Request!
---
## 📄 许可证
MIT License
---
## 🆘 获取帮助
- **文档**:本 SKILL.md 文件
- **API 官网**:https://share.acedata.cloud/r/1uN88BrUTQ
- **问题反馈**:在 OpenClaw 社区提问
## 联系我们
**email:** [email protected]
**微信** jakeycis
如果有功能开发需求,可以联系我,由于开放语言和方案的改变,这个skill还有一些小BUG没完成。
FILE:cut.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import os
from PIL import Image
def main():
parser = argparse.ArgumentParser(description='图片N宫格裁剪工具')
parser.add_argument('-path', required=True, type=str, help='输入图片的完整路径')
parser.add_argument('-num', required=True, type=int, choices=[2,4,6,9], help='宫格数量,支持2/4/6/9')
parser.add_argument('-out', type=str, default=None, help='可选:输出目录,不填则默认保存到原图片目录')
args = parser.parse_args()
# 检查文件是否存在
if not os.path.exists(args.path):
print(f"错误:图片文件 {args.path} 不存在")
return
# 打开图片
try:
img = Image.open(args.path)
except Exception as e:
print(f"打开图片失败:{str(e)}")
return
width, height = img.size
# 宫格配置:(行数, 列数)
grid_map = {
2: (1, 2), # 1行2列 左右两格
4: (2, 2), # 2行2列 四宫格
6: (2, 3), # 2行3列 六宫格
9: (3, 3) # 3行3列 九宫格
}
rows, cols = grid_map[args.num]
# 计算每格尺寸
cell_width = width // cols
cell_height = height // rows
# 获取文件信息
file_base, file_ext = os.path.splitext(os.path.basename(args.path))
# 处理输出目录
if args.out:
# 创建输出目录如果不存在
os.makedirs(args.out, exist_ok=True)
save_dir = args.out
# 指定输出目录时,文件名为 1.ext, 2.ext...
name_format = "{}{}"
else:
save_dir = os.path.dirname(args.path)
# 不指定输出目录时,保留原文件名前缀
name_format = f"{file_base}_{{}}{{}}"
# 裁剪并保存
index = 1
for r in range(rows):
for c in range(cols):
# 计算裁剪坐标
left = c * cell_width
top = r * cell_height
right = left + cell_width
bottom = top + cell_height
# 裁剪
cropped_img = img.crop((left, top, right, bottom))
# 生成保存路径
filename = name_format.format(index, file_ext)
save_path = os.path.join(save_dir, filename)
# 保存
cropped_img.save(save_path)
print(f"成功生成:{save_path}")
index += 1
print(f"\n裁剪完成,共生成 {args.num} 张图片,保存目录:{save_dir}")
if __name__ == "__main__":
main()
FILE:prompt.md
##生成要求:##
严格生成一张{num}宫格的高质量图片,每个格子的内容根据输入的提示词完全独立,并保持完整统一的风格,每个格子不设置边框。如过提示词中没有文字方案,则不输出文字。小图细节丰富,画质清晰,风格统一。
##用户要求:##
{prompt}
FILE:server.py
# -*- coding: utf-8 -*-
import warnings
# 过滤requests依赖版本警告
warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("ignore", message="urllib3 (.*) or chardet/(.*) doesn't match a supported version!")
from flask import Flask, request, jsonify, send_from_directory, send_file
from flask_cors import CORS
import sqlite3
import json
import requests
import datetime
import os
import sys
from PIL import Image
import zipfile
import io
import time
import threading
import subprocess
from dotenv import load_dotenv
# 加载.env配置
load_dotenv()
API_KEY = os.getenv('API_KEY')
PLATFORM_TOKEN = os.getenv('PLATFORM_TOKEN')
app = Flask(__name__, static_folder='static', static_url_path='/static')
CORS(app)
PORT = 697
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, 'data')
DB_PATH = os.path.join(DATA_DIR, 'works.db')
CONFIG_PATH = os.path.join(BASE_DIR, 'set.json')
PROMPT_PATH = os.path.join(BASE_DIR, 'prompt.md')
# 全局轮询锁,防止并发请求
polling_lock = False
# 初始化目录
os.makedirs(DATA_DIR, exist_ok=True)
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
CONFIG = json.load(f)
SAVE_BASE_PATH = CONFIG.get('save_path', os.path.join(os.path.expanduser("~"), "Desktop", "banana"))
os.makedirs(SAVE_BASE_PATH, exist_ok=True)
# 检查API_KEY配置,创建.env文件如果不存在
env_path = os.path.join(BASE_DIR, '.env')
if not os.path.exists(env_path):
with open(env_path, 'w', encoding='utf-8') as f:
f.write("# 请访问 https://share.acedata.cloud/r/1uN88BrUTQ 获取以下配置\n")
f.write("API_KEY=\n")
f.write("PLATFORM_TOKEN=\n")
# 配置校验接口
@app.route('/api/config/check', methods=['GET'])
def check_config():
if not API_KEY:
return jsonify({"success": False, "msg": "未配置API_KEY", "need_config": True})
return jsonify({"success": True, "msg": "配置正常"})
# 初始化数据库
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS works (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model TEXT NOT NULL,
date TEXT NOT NULL,
state INTEGER DEFAULT 1,
prompt TEXT NOT NULL,
ratio TEXT NOT NULL,
quality TEXT NOT NULL,
task_id TEXT,
num INTEGER DEFAULT 1,
path TEXT,
filename TEXT,
ext TEXT,
request_data TEXT,
error TEXT,
respond TEXT
)
''')
# 检查是否需要添加 respond 字段(兼容旧数据库)
c.execute("PRAGMA table_info(works)")
columns = [col[1] for col in c.fetchall()]
if 'respond' not in columns:
print("检测到旧数据库,正在添加 respond 字段...")
c.execute("ALTER TABLE works ADD COLUMN respond TEXT")
print("respond 字段添加成功")
conn.commit()
conn.close()
init_db()
# 获取数据库连接
def get_db_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# 切割图片
def cut_image(image_path, num, output_dir, ext):
try:
img = Image.open(image_path)
width, height = img.size
is_landscape = width > height
if num == 1:
img.save(os.path.join(output_dir, f'1.{ext}'))
return True
elif num == 2:
if is_landscape:
w = width // 2
for i in range(2):
box = (i*w, 0, (i+1)*w, height)
region = img.crop(box)
region.save(os.path.join(output_dir, f'{i+1}.{ext}'))
else:
h = height // 2
for i in range(2):
box = (0, i*h, width, (i+1)*h)
region = img.crop(box)
region.save(os.path.join(output_dir, f'{i+1}.{ext}'))
elif num == 4:
w = width // 2
h = height // 2
idx = 1
for i in range(2):
for j in range(2):
box = (j*w, i*h, (j+1)*w, (i+1)*h)
region = img.crop(box)
region.save(os.path.join(output_dir, f'{idx}.{ext}'))
idx += 1
elif num == 6:
if is_landscape:
w = width // 3
h = height // 2
idx = 1
for i in range(2):
for j in range(3):
box = (j*w, i*h, (j+1)*w, (i+1)*h)
region = img.crop(box)
region.save(os.path.join(output_dir, f'{idx}.{ext}'))
idx += 1
else:
w = width // 2
h = height // 3
idx = 1
for i in range(3):
for j in range(2):
box = (j*w, i*h, (j+1)*w, (i+1)*h)
region = img.crop(box)
region.save(os.path.join(output_dir, f'{idx}.{ext}'))
idx += 1
elif num == 9:
w = width // 3
h = height // 3
idx = 1
for i in range(3):
for j in range(3):
box = (j*w, i*h, (j+1)*w, (i+1)*h)
region = img.crop(box)
region.save(os.path.join(output_dir, f'{idx}.{ext}'))
idx += 1
return True
except Exception as e:
print(f"图片切割失败: {e}")
return False
# 轮询获取任务结果
def poll_task(task_id, work_id):
global polling_lock
if polling_lock:
print(f"已有任务在轮询中,跳过任务 {work_id}")
return
polling_lock = True
try:
conn = get_db_connection()
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"accept": "application/json"
}
max_retries = 50
retry_count = 0
print("\n" + "="*50)
print(f"开始处理任务 work_id={work_id}, task_id={task_id}")
print(f"请求地址: {CONFIG['server']['task_url']}")
print(f"API Key: {CONFIG['server']['apikey'][:10]}...")
while retry_count < max_retries:
try:
print(f"\n第 {retry_count+1} 次查询...")
# 按照接口文档要求,仅提交id字段
payload = {
"id": task_id
}
print(f"请求参数: {json.dumps(payload, indent=2)}")
response = requests.post(CONFIG['server']['task_url'], headers=headers, json=payload, timeout=120)
print(f"返回状态码: {response.status_code}")
print(f"返回原始内容: {repr(response.text)}")
response.raise_for_status()
data = response.json()
print(f"JSON解析成功,返回内容: {data}")
# 处理空返回的情况
if not data or not data.get('response'):
print(f"[警告] 返回数据为空或无response字段,继续重试...")
raise Exception("返回数据为空")
if data['response'].get('success') and data['response'].get('data'):
image_url = data['response']['data'][0].get('image_url')
print(f"[成功] 生成成功,图片地址: {image_url}")
if not image_url:
error_msg = "图片地址为空"
print(f"[错误] {error_msg}")
conn.execute("UPDATE works SET state = 99, error = ? WHERE id = ?", (error_msg, work_id))
conn.commit()
break
now = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
save_dir = os.path.join(SAVE_BASE_PATH, now)
os.makedirs(save_dir, exist_ok=True)
try:
print(f"开始下载图片: {image_url}")
ext = image_url.split('.')[-1].split('?')[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp']:
ext = 'png'
main_path = os.path.join(save_dir, f'main.{ext}')
img_response = requests.get(image_url, stream=True, timeout=60)
img_response.raise_for_status()
with open(main_path, 'wb') as f:
for chunk in img_response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"图片下载完成,大小: {os.path.getsize(main_path)} bytes")
except Exception as e:
error_msg = f"图片下载失败: {str(e)}"
print(f"[错误] {error_msg}")
conn.execute("UPDATE works SET state = 99, error = ? WHERE id = ?", (error_msg, work_id))
conn.commit()
break
img = Image.open(main_path)
img.thumbnail((480, 480))
thumb_path = os.path.join(save_dir, f'480p.{ext}')
img.save(thumb_path)
work = conn.execute("SELECT num FROM works WHERE id = ?", (work_id,)).fetchone()
cut_image(main_path, work['num'], save_dir, ext)
work_data = conn.execute("SELECT * FROM works WHERE id = ?", (work_id,)).fetchone()
with open(os.path.join(save_dir, 'data.txt'), 'w', encoding='utf-8') as f:
f.write(f"ID: {work_data['id']}\n")
f.write(f"生成时间: {work_data['date']}\n")
f.write(f"模型: {work_data['model']}\n")
f.write(f"提示词: {work_data['prompt']}\n")
f.write(f"分辨率: {work_data['ratio']}\n")
f.write(f"质量: {work_data['quality']}\n")
f.write(f"切割方式: {work_data['num']}宫格\n")
f.write(f"任务ID: {task_id}\n")
f.write(f"原始图片地址: {image_url}\n")
conn.execute('''
UPDATE works SET state = 10, path = ?, filename = ?, ext = ? WHERE id = ?
''', (save_dir, f'main.{ext}', ext, work_id))
conn.commit()
break
elif data.get('response') and not data['response'].get('success'):
error_msg = data['response'].get('error', '生成失败')
print(f"[错误] 生成失败: {error_msg}")
conn.execute("UPDATE works SET state = 99, error = ? WHERE id = ?", (error_msg, work_id))
conn.commit()
break
except requests.exceptions.RequestException as e:
error_msg = f"网络请求失败: {str(e)}"
print(f"[警告] {error_msg},继续重试...")
conn.execute("UPDATE works SET error = ? WHERE id = ?", (error_msg, work_id))
conn.commit()
except json.JSONDecodeError as e:
error_msg = f"返回数据解析失败: {str(e)}"
print(f"[警告] {error_msg},继续重试...")
conn.execute("UPDATE works SET error = ? WHERE id = ?", (error_msg, work_id))
conn.commit()
except Exception as e:
import traceback
error_msg = f"请求异常: {str(e)}"
print(f"[警告] {error_msg},继续重试...")
conn.execute("UPDATE works SET error = ? WHERE id = ?", (str(e), work_id))
conn.commit()
retry_count += 1
# 前30次每6秒查询一次,30次后每60秒查询一次
if retry_count < 30:
time.sleep(6)
else:
time.sleep(60)
if retry_count >= max_retries:
error_msg = f"轮询超时,共尝试{max_retries}次"
print(f"[错误] {error_msg}")
conn.execute("UPDATE works SET state = 99, error = ? WHERE id = ?", (error_msg, work_id))
conn.commit()
conn.close()
print("="*50)
print(f"任务处理结束 work_id={work_id}")
finally:
polling_lock = False
# 启动时恢复未完成的任务
def resume_pending_tasks():
conn = get_db_connection()
# 恢复处理中(1)和失败但有task_id(99)的任务,重新查询
pending_works = conn.execute("SELECT id, task_id FROM works WHERE state = 1 OR (state = 99 AND task_id IS NOT NULL)").fetchall()
conn.close()
for work in pending_works:
if work['task_id']:
print(f"恢复任务: work_id={work['id']}, task_id={work['task_id']}")
threading.Thread(target=poll_task, args=(work['task_id'], work['id']), daemon=True).start()
# 【注意】任务恢复由前端 loadWorks() 自动处理,无需服务端干预
# resume_pending_tasks()
# 路由
@app.route('/')
def index():
return send_from_directory('templates', 'index.html')
# 管理后台页面
@app.route('/admin')
def admin_page():
return send_from_directory(os.path.join(BASE_DIR, 'templates'), 'admin.html')
# 管理后台获取所有任务接口
@app.route('/api/admin/works', methods=['GET'])
def admin_get_works():
try:
conn = get_db_connection()
works = conn.execute('SELECT * FROM works ORDER BY id DESC').fetchall()
conn.close()
result = [dict(work) for work in works]
return jsonify({"success": True, "data": result})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
# 手动重试任务接口
@app.route('/api/admin/retry/<int:id>', methods=['POST'])
def admin_retry_task(id):
try:
conn = get_db_connection()
work = conn.execute('SELECT id, task_id FROM works WHERE id = ?', (id,)).fetchone()
if not work or not work['task_id']:
return jsonify({"success": False, "msg": "任务不存在或无task_id"})
# 重置状态为处理中,清空错误信息,由前端发起轮询
conn.execute("UPDATE works SET state = 1, error = '' WHERE id = ?", (id,))
conn.commit()
conn.close()
return jsonify({"success": True, "msg": "任务已重置为处理中,请等待前端轮询结果"})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
# 关闭任务接口
@app.route('/api/admin/close/<int:id>', methods=['POST'])
def admin_close_task(id):
try:
conn = get_db_connection()
conn.execute("UPDATE works SET state = 99, error = '手动关闭任务' WHERE id = ?", (id,))
conn.commit()
conn.close()
return jsonify({"success": True, "msg": "任务已关闭,状态变为失败"})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
# 保存配置接口
@app.route('/api/config/save', methods=['POST'])
def save_config():
try:
data = request.get_json()
api_key = data.get('api_key', '').strip()
platform_token = data.get('platform_token', '').strip()
if not api_key:
return jsonify({"success": False, "msg": "API_KEY为必填项,请填写"}), 400
# 写入.env文件
env_path = os.path.join(BASE_DIR, '.env')
with open(env_path, 'w', encoding='utf-8') as f:
f.write(f"API_KEY={api_key}\n")
f.write(f"PLATFORM_TOKEN={platform_token}\n")
# 全局更新配置
global API_KEY, PLATFORM_TOKEN
API_KEY = api_key
PLATFORM_TOKEN = platform_token
return jsonify({"success": True, "msg": "配置保存成功,请刷新页面生效"})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
# 图片上传接口
@app.route('/api/upload', methods=['POST'])
def upload_file():
try:
if not PLATFORM_TOKEN:
return jsonify({"success": False, "msg": "未配置PLATFORM_TOKEN,请先配置密钥", "need_config": True}), 401
if 'file' not in request.files:
return jsonify({"success": False, "msg": "没有上传文件"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"success": False, "msg": "没有选择文件"}), 400
# 保存临时文件
temp_dir = os.path.join(BASE_DIR, 'temp')
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, file.filename)
file.save(temp_path)
# 调用upload.py上传
from upload import upload_image, init_upload_table
init_upload_table()
result = upload_image(temp_path)
# 删除临时文件
os.remove(temp_path)
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
# 删除任务接口
@app.route('/api/admin/delete/<int:id>', methods=['POST'])
def admin_delete_task(id):
try:
conn = get_db_connection()
conn.execute("DELETE FROM works WHERE id = ?", (id,))
conn.commit()
conn.close()
return jsonify({"success": True, "msg": "任务已删除"})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
@app.route('/api/config', methods=['GET'])
def get_config():
return jsonify({
"success": True,
"data": {
"models": CONFIG['models'],
"resolutions": CONFIG['resolutions'],
"qualities": CONFIG['qualities']
}
})
@app.route('/api/generate', methods=['POST'])
def generate():
try:
data = request.get_json()
prompt = data.get('prompt', '')
model = data.get('model', 0)
ratio_idx = data.get('ratio', 0)
quality_idx = data.get('quality', 0)
num = data.get('num', 1)
images = data.get('images', [])
if not prompt and not images:
return jsonify({"success": False, "msg": "请输入提示词或上传参考图"}), 400
with open(PROMPT_PATH, 'r', encoding='utf-8') as f:
system_prompt = f.read()
full_prompt = system_prompt.replace('{num}', str(num)).replace('{prompt}', prompt)
model_config = CONFIG['models'][model]
ratio_config = CONFIG['resolutions'][ratio_idx]
quality_config = CONFIG['qualities'][quality_idx]
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
}
# 有参考图时使用edit模式,否则使用generate模式
if images and len(images) > 0:
payload = {
"action": "edit",
"model": model_config['model'],
"prompt": full_prompt,
"aspect_ratio": ratio_config['ratio'],
"resolution": quality_config['size'],
"count": 1,
"image_urls": images,
"callback_url": "aaa"
}
else:
payload = {
"action": "generate",
"model": model_config['model'],
"prompt": full_prompt,
"aspect_ratio": ratio_config['ratio'],
"resolution": quality_config['size'],
"num_images": 1,
"callback_url": "aaa"
}
print(f"生成请求URL: {CONFIG['server']['url']}")
print(f"请求头: {json.dumps(headers, indent=2)}")
print(f"请求payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
response = requests.post(CONFIG['server']['url'], headers=headers, json=payload, timeout=120)
print(f"返回状态码: {response.status_code}")
print(f"返回内容: {repr(response.text)}")
response.raise_for_status()
result = response.json()
if not result.get('task_id'):
return jsonify({"success": False, "msg": "获取任务ID失败: " + result.get('error', '未知错误')}), 400
task_id = result['task_id']
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO works (model, date, prompt, ratio, quality, task_id, num, request_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (model_config['name'], now, prompt, ratio_config['name'], quality_config['name'], task_id, num, json.dumps(payload)))
work_id = cursor.lastrowid
conn.commit()
conn.close()
# 【已修复】不再后端启动轮询,改由前端控制轮询,支持多任务并发
# threading.Thread(target=poll_task, args=(task_id, work_id), daemon=True).start()
return jsonify({"success": True, "msg": "任务提交成功", "work_id": work_id})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
@app.route('/api/works', methods=['GET'])
def get_works():
try:
conn = get_db_connection()
works = conn.execute('SELECT * FROM works WHERE state > 0 ORDER BY date DESC').fetchall()
conn.close()
result = []
for work in works:
item = dict(work)
# 仅对成功完成的任务(状态10)检测图片是否存在
if item['state'] == 10 and item['path']:
thumb_path = os.path.join(item['path'], f'480p.{item["ext"]}')
if not os.path.exists(thumb_path):
conn = get_db_connection()
conn.execute("UPDATE works SET state = 0 WHERE id = ?", (item['id'],))
conn.commit()
conn.close()
item['state'] = 0
result.append(item)
return jsonify({"success": True, "data": result})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
@app.route('/api/work/<int:id>', methods=['GET'])
def get_work(id):
try:
conn = get_db_connection()
work = conn.execute('SELECT * FROM works WHERE id = ?', (id,)).fetchone()
conn.close()
if not work:
return jsonify({"success": False, "msg": "作品不存在"}), 404
work_dict = dict(work)
# 仅对成功完成的任务(状态10)检测图片是否存在
if work_dict['state'] == 10 and work_dict['path']:
main_path = os.path.join(work_dict['path'], f'main.{work_dict["ext"]}')
if not os.path.exists(main_path):
conn = get_db_connection()
conn.execute("UPDATE works SET state = 0 WHERE id = ?", (id,))
conn.commit()
conn.close()
work_dict['state'] = 0
return jsonify({"success": True, "data": work_dict})
images = []
for i in range(1, work_dict['num'] + 1):
img_path = f'/{work_dict["path"].replace(os.sep, "/")}/{i}.{work_dict["ext"]}'
images.append(img_path)
work_dict['images'] = images
work_dict['main_image'] = f'/{work_dict["path"].replace(os.sep, "/")}/main.{work_dict["ext"]}'
work_dict['thumb_image'] = f'/{work_dict["path"].replace(os.sep, "/")}/480p.{work_dict["ext"]}'
return jsonify({"success": True, "data": work_dict})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
# 全局锁,防止同一个任务重复执行
running_tasks = set()
@app.route('/api/poll/<int:id>', methods=['GET'])
def poll_work(id):
try:
conn = get_db_connection()
work = conn.execute('SELECT id, state, error, task_id FROM works WHERE id = ?', (id,)).fetchone()
if not work:
conn.close()
return jsonify({"success": False, "msg": "任务不存在"}), 404
respond_data = None
# 处理中且没有在执行的任务,调用task.py异步处理
if work['state'] == 1 and work['task_id'] and id not in running_tasks:
# 标记任务正在执行,防止重复调用
running_tasks.add(id)
# 后台线程调用task.py处理任务
def run_task():
try:
cmd = [sys.executable, os.path.join(BASE_DIR, 'task.py'), '-id', str(id)]
subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', timeout=300)
finally:
# 执行完成移除锁
if id in running_tasks:
running_tasks.remove(id)
threading.Thread(target=run_task, daemon=True).start()
respond_data = {"msg": "任务已开始处理,请稍候"}
# 读取最新的任务状态
conn = get_db_connection()
work = conn.execute('SELECT id, state, error, task_id, respond FROM works WHERE id = ?', (id,)).fetchone()
conn.close()
respond = None
if work['respond']:
try:
respond = json.loads(work['respond'])
except:
respond = work['respond']
return jsonify({
"success": True,
"data": {
"state": work['state'],
"error": work['error'],
"task_id": work['task_id'],
"task_url": CONFIG['server']['task_url'],
"respond": respond
}
})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
@app.route('/api/download/<int:id>', methods=['GET'])
def download_work(id):
try:
conn = get_db_connection()
work = conn.execute('SELECT * FROM works WHERE id = ?', (id,)).fetchone()
conn.close()
if not work or work['state'] != 10 or not work['path']:
return jsonify({"success": False, "msg": "作品不存在或未生成完成"}), 404
memory_file = io.BytesIO()
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
main_path = os.path.join(work['path'], f'main.{work["ext"]}')
if os.path.exists(main_path):
zf.write(main_path, f'main.{work["ext"]}')
thumb_path = os.path.join(work['path'], f'480p.{work["ext"]}')
if os.path.exists(thumb_path):
zf.write(thumb_path, f'480p.{work["ext"]}')
for i in range(1, work['num'] + 1):
img_path = os.path.join(work['path'], f'{i}.{work["ext"]}')
if os.path.exists(img_path):
zf.write(img_path, f'{i}.{work["ext"]}')
data_path = os.path.join(work['path'], 'data.txt')
if os.path.exists(data_path):
zf.write(data_path, 'data.txt')
memory_file.seek(0)
return send_file(
memory_file,
mimetype='application/zip',
as_attachment=True,
download_name=f'{work["id"]}_{work["prompt"][:20].replace(" ", "_")}.zip'
)
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
@app.route('/api/open-folder/<int:id>', methods=['POST'])
def open_folder(id):
try:
conn = get_db_connection()
work = conn.execute('SELECT path FROM works WHERE id = ?', (id,)).fetchone()
conn.close()
if not work or not work['path'] or not os.path.exists(work['path']):
return jsonify({"success": False, "msg": "目录不存在"}), 404
os.startfile(work['path'])
return jsonify({"success": True, "msg": "已打开文件夹"})
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
@app.route('/api/shutdown', methods=['POST'])
def shutdown():
try:
os._exit(0)
except Exception as e:
return jsonify({"success": False, "msg": str(e)}), 500
# 图片N宫格裁剪接口
@app.route('/api/cut', methods=['POST'])
def api_cut_image():
try:
data = request.get_json()
path = data.get('path')
num = data.get('num')
out = data.get('out', None)
if not path or not num:
return jsonify({"success": False, "msg": "参数缺失,path(图片路径)和num(宫格数)为必填项"})
# 构造cut.py命令
cmd = [sys.executable, os.path.join(BASE_DIR, 'cut.py'), '-path', path, '-num', str(num)]
if out:
cmd.extend(['-out', out])
# 执行裁剪命令
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
if result.returncode == 0:
return jsonify({
"success": True,
"msg": "图片裁剪成功",
"output_log": result.stdout
})
else:
return jsonify({
"success": False,
"msg": "图片裁剪失败",
"error_log": result.stderr
})
except Exception as e:
return jsonify({"success": False, "msg": f"接口异常:{str(e)}"}), 500
@app.route('/<path:filepath>')
def serve_file(filepath):
if '..' in filepath or filepath.startswith('/'):
return "Invalid path", 403
full_path = os.path.join('/', filepath)
if os.path.exists(full_path) and os.path.isfile(full_path):
return send_from_directory(os.path.dirname(full_path), os.path.basename(full_path))
return "File not found", 404
if __name__ == '__main__':
import sys
if len(sys.argv) >= 3 and sys.argv[1] == '-task_id':
task_id = sys.argv[2]
print(f"手动查询任务: {task_id}")
conn = get_db_connection()
# 先查询是否已存在该task_id的任务
exist_work = conn.execute("SELECT id, num FROM works WHERE task_id = ?", (task_id,)).fetchone()
if exist_work:
work_id = exist_work['id']
num = exist_work['num']
print(f"找到已存在任务 work_id={work_id}, 宫格数={num}")
poll_task(task_id, work_id)
else:
# 不存在则新建,默认1宫格
cursor = conn.cursor()
cursor.execute('''
INSERT INTO works (model, date, prompt, ratio, quality, task_id, num, request_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', ("手动查询任务", datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), "手动导入任务", "1:1 方形", "2K", task_id, 1, "{}"))
work_id = cursor.lastrowid
conn.commit()
poll_task(task_id, work_id)
conn.close()
print(f"任务处理完成,work_id={work_id}")
else:
print(f"banana-cut 服务启动成功,访问地址:http://localhost:{PORT}")
app.run(host='0.0.0.0', port=PORT, debug=False)
FILE:set.json
{
"server": {
"url": "https://api.acedata.cloud/nano-banana/images",
"task_url": "https://api.acedata.cloud/nano-banana/tasks",
"upload_url": "https://platform.acedata.cloud/api/v1/files/",
},
"models": [
{"name": "Nano Banana Pro", "model": "nano-banana-pro", "logo": "🍌"},
{"name": "Nano Banana 2", "model": "nano-banana-2", "logo": "🍌"}
],
"resolutions": [
{"name": "1:1 方形", "ratio": "1:1", "icon": "■"},
{"name": "16:9 横屏", "ratio": "16:9", "icon": "▬"},
{"name": "9:16 竖屏", "ratio": "9:16", "icon": "▮"},
{"name": "4:3 标准", "ratio": "4:3", "icon": "▭"},
{"name": "3:4 竖版", "ratio": "3:4", "icon": "▯"}
],
"qualities": [
{"name": "1K (1024px)", "size": "1K", "icon": "🖥️"},
{"name": "2K (2048px)", "size": "2K", "icon": "🖥️"},
{"name": "4K (4096px)", "size": "4K", "icon": "🖥️"}
],
"save_path": "C:/Users/86137/Desktop/banana"
}
FILE:task.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import warnings
# 过滤requests依赖版本警告
warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("ignore", message="urllib3 (.*) or chardet/(.*) doesn't match a supported version!")
import argparse
import sqlite3
import requests
import json
import os
import datetime
import subprocess
from PIL import Image
from dotenv import load_dotenv
# 加载.env配置
load_dotenv()
API_KEY = os.getenv('API_KEY')
# 基础配置
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, 'set.json')
DB_PATH = os.path.join(BASE_DIR, 'data', 'works.db')
# 加载配置
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
CONFIG = json.load(f)
SAVE_BASE_PATH = CONFIG.get('save_path', os.path.join(os.path.expanduser("~"), "Desktop", "banana"))
os.makedirs(SAVE_BASE_PATH, exist_ok=True)
# 如果.env中没有API_KEY,则使用set.json中的apikey
if not API_KEY and CONFIG['server'].get('apikey'):
API_KEY = CONFIG['server']['apikey']
print(f"[警告] 使用set.json中的apikey,建议迁移到.env文件")
def get_db_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def main():
parser = argparse.ArgumentParser(description='独立任务处理脚本')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('-task_id', type=str, help='模型接口返回的task_id')
group.add_argument('-id', type=int, help='数据库中任务的主键ID')
parser.add_argument('-num', type=int, default=1, choices=[1,2,4,6,9], help='宫格数,默认读取数据库值')
args = parser.parse_args()
# 1. 查询数据库任务
conn = get_db_connection()
work = None
if args.id:
work = conn.execute('SELECT * FROM works WHERE id = ?', (args.id,)).fetchone()
if not work:
print(f"错误:数据库中未找到ID={args.id}的任务")
conn.close()
return
task_id = work['task_id']
work_id = args.id
print(f"找到已有任务: work_id={work_id}, task_id={task_id}")
else:
task_id = args.task_id
work = conn.execute('SELECT * FROM works WHERE task_id = ?', (task_id,)).fetchone()
if not work:
print(f"错误:数据库中未找到task_id={task_id}的任务,请先在前端提交任务")
conn.close()
return
work_id = work['id']
print(f"找到已有任务: work_id={work_id}, task_id={task_id}")
num = work['num']
print(f"任务配置: 宫格数={num}")
# 2. 调用接口查询任务
print("正在查询模型接口...")
# 使用环境变量中的 API_KEY,如果没有则使用配置文件中的
api_key = API_KEY or CONFIG['server'].get('apikey', '')
if not api_key:
error_msg = "未配置API_KEY,请在.env文件或set.json中配置"
print(f"[错误] {error_msg}")
conn.execute("UPDATE works SET state = 99, error = ? WHERE id = ?", (error_msg, work_id))
conn.commit()
return
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"accept": "application/json"
}
payload = {"id": task_id,"action": "retrieve"}
try:
response = requests.post(CONFIG['server']['task_url'], headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
print(f"接口返回成功: {json.dumps(data, indent=2, ensure_ascii=False)}")
# 只有接口明确返回success=false且有error信息时才判定为失败
if data.get('response') and not data['response'].get('success') and data['response'].get('error'):
error_msg = data['response'].get('error', '接口返回失败')
print(f"任务明确失败: {error_msg}")
conn.execute("UPDATE works SET state = 99, error = ?, respond = ? WHERE id = ?", (error_msg, json.dumps(data), work_id))
conn.commit()
return
# 其他情况(返回空、无response、无data等)都视为临时问题,继续重试
if not data.get('response') or not data['response'].get('data') or len(data['response']['data']) == 0:
print(f"接口返回数据不完整,继续重试...")
raise Exception("临时数据异常,继续重试")
# 3. 下载图片
image_url = data['response']['data'][0].get('image_url')
if not image_url:
error_msg = "图片URL为空"
print(f"任务失败: {error_msg}")
conn.execute("UPDATE works SET state = 99, error = ?, respond = ? WHERE id = ?", (error_msg, json.dumps(data), work_id))
conn.commit()
return
print(f"开始下载图片: {image_url}")
now = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
save_dir = os.path.join(SAVE_BASE_PATH, now)
os.makedirs(save_dir, exist_ok=True)
ext = image_url.split('.')[-1].split('?')[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp']:
ext = 'png'
main_path = os.path.join(save_dir, f'main.{ext}')
img_response = requests.get(image_url, stream=True, timeout=60)
img_response.raise_for_status()
with open(main_path, 'wb') as f:
for chunk in img_response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"图片下载完成,保存到: {main_path}, 大小: {os.path.getsize(main_path)} bytes")
# 4. 生成缩略图
img = Image.open(main_path)
img.thumbnail((480, 480))
thumb_path = os.path.join(save_dir, f'480p.{ext}')
img.save(thumb_path)
print("缩略图生成完成")
# 5. 调用cut.py切割图片(仅num>1时切割)
if num > 1:
print(f"开始切割图片,宫格数={num}")
cut_cmd = [
'python', os.path.join(BASE_DIR, 'cut.py'),
'-path', main_path,
'-num', str(num),
'-out', save_dir
]
result = subprocess.run(cut_cmd, capture_output=True, text=True, encoding='utf-8')
if result.returncode == 0:
print(f"图片切割成功: {result.stdout}")
else:
print(f"图片切割失败: {result.stderr}")
else:
print(f"宫格数=1,不需要切割")
# 单张的情况复制一份命名为1.ext
single_path = os.path.join(save_dir, f'1.{ext}')
with open(main_path, 'rb') as f_src, open(single_path, 'wb') as f_dst:
f_dst.write(f_src.read())
print(f"已生成单张图片: 1.{ext}")
# 6. 保存data.txt
with open(os.path.join(save_dir, 'data.txt'), 'w', encoding='utf-8') as f:
f.write(f"ID: {work_id}\n")
f.write(f"生成时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"模型: {work['model']}\n")
f.write(f"提示词: {work['prompt']}\n")
f.write(f"分辨率: {work['ratio']}\n")
f.write(f"质量: {work['quality']}\n")
f.write(f"切割方式: {num}宫格\n")
f.write(f"任务ID: {task_id}\n")
f.write(f"原始图片地址: {image_url}\n")
f.write(f"\n接口返回内容:\n{json.dumps(data, indent=2, ensure_ascii=False)}")
# 7. 更新数据库
conn.execute('''
UPDATE works SET state = 10, path = ?, filename = ?, ext = ?, error = '', respond = ? WHERE id = ?
''', (save_dir, f'main.{ext}', ext, json.dumps(data), work_id))
conn.commit()
print(f"任务处理完成,work_id={work_id},保存路径: {save_dir}")
except Exception as e:
import traceback
error_msg = f"处理失败: {str(e)}"
print(f"[错误] {error_msg}")
traceback.print_exc()
conn.execute("UPDATE works SET state = 99, error = ? WHERE id = ?", (error_msg, work_id))
conn.commit()
finally:
conn.close()
if __name__ == "__main__":
main()
FILE:upload.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import sqlite3
import requests
import os
import hashlib
import datetime
import json
from dotenv import load_dotenv
# 加载配置
load_dotenv()
PLATFORM_TOKEN = os.getenv('PLATFORM_TOKEN')
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, 'data', 'works.db')
UPLOAD_URL = "https://platform.acedata.cloud/api/v1/files/"
def get_db_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# 初始化上传记录表
def init_upload_table():
conn = get_db_connection()
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS uploaded_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_hash TEXT NOT NULL UNIQUE,
file_path TEXT NOT NULL,
url TEXT NOT NULL,
upload_time INTEGER NOT NULL,
expire_time INTEGER NOT NULL
)
''')
conn.commit()
conn.close()
# 计算文件MD5哈希
def get_file_md5(file_path):
hash_md5 = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
# 上传图片
def upload_image(file_path):
if not os.path.exists(file_path):
return {"success": False, "msg": "文件不存在"}
# 计算文件哈希
file_hash = get_file_md5(file_path)
now = int(datetime.datetime.now().timestamp())
# 查询数据库是否有未过期的记录
conn = get_db_connection()
record = conn.execute('''
SELECT url FROM uploaded_files
WHERE file_hash = ? AND expire_time > ?
''', (file_hash, now)).fetchone()
if record:
conn.close()
return {"success": True, "url": record['url'], "from_cache": True}
# 上传到服务器
try:
headers = {
"authorization": f"Bearer {PLATFORM_TOKEN}"
}
files = {
"file": open(file_path, "rb")
}
response = requests.post(UPLOAD_URL, headers=headers, files=files, timeout=60)
response.raise_for_status()
result = response.json()
url = result.get('url')
if not url:
return {"success": False, "msg": "上传失败,返回URL为空"}
# 保存到数据库,有效期24小时
expire_time = now + 86400
conn.execute('''
INSERT OR REPLACE INTO uploaded_files
(file_hash, file_path, url, upload_time, expire_time)
VALUES (?, ?, ?, ?, ?)
''', (file_hash, file_path, url, now, expire_time))
conn.commit()
conn.close()
return {"success": True, "url": url, "from_cache": False}
except Exception as e:
conn.close()
return {"success": False, "msg": f"上传失败: {str(e)}"}
def main():
init_upload_table()
parser = argparse.ArgumentParser(description='图片上传工具')
parser.add_argument('-file', required=True, type=str, help='要上传的图片路径')
args = parser.parse_args()
result = upload_image(args.file)
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:templates/admin.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>任务管理后台</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/css/font-awesome.min.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
body {
background: #f5f7fa;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
color: #333;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #2ed573;
color: #fff;
}
.btn-primary:hover {
background: #26bd64;
}
.btn-warning {
background: #ffa502;
color: #fff;
}
.btn-warning:hover {
background: #e69500;
}
.btn-danger {
background: #ff4757;
color: #fff;
}
.btn-danger:hover {
background: #e8414f;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.table-container {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #eee;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #333;
position: sticky;
top: 0;
}
tr:hover {
background: #f5f7fa;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: #fff;
}
.status-1 { background: #ffa502; } /* 处理中 */
.status-10 { background: #2ed573; } /* 成功 */
.status-99 { background: #ff4757; } /* 失败 */
.status-0 { background: #a4b0be; } /* 无效 */
.error-text {
color: #ff4757;
font-size: 12px;
}
.prompt-text {
font-size: 12px;
color: #666;
}
.footer {
margin-top: 20px;
text-align: right;
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 8px;
color: #fff;
font-size: 14px;
z-index: 2000;
animation: slideDown 0.3s ease;
}
.toast.success { background: #2ed573; }
.toast.error { background: #ff4757; }
@keyframes slideDown {
from { top: 0; opacity: 0; }
to { top: 20px; opacity: 1; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔧 任务管理后台</h1>
<button class="btn btn-primary" id="refresh-btn">
<i class="fa fa-refresh"></i> 刷新数据
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>模型</th>
<th>生成时间</th>
<th>状态</th>
<th>提示词</th>
<th>宫格数</th>
<th>分辨率</th>
<th>质量</th>
<th>Task ID</th>
<th>保存路径</th>
<th>错误信息</th>
<th>操作</th>
</tr>
</thead>
<tbody id="works-table">
</tbody>
</table>
</div>
<div class="footer">
<button class="btn btn-primary" id="export-btn">
<i class="fa fa-download"></i> 导出所有数据到本地
</button>
</div>
</div>
<script>
let worksData = [];
// 显示提示
function showToast(msg, type = 'success') {
const toast = $(`<div class="toast type">msg</div>`);
$('body').append(toast);
setTimeout(() => toast.remove(), 3000);
}
// 加载数据
function loadWorks() {
$.get('/api/admin/works', function(res) {
if (res.success) {
worksData = res.data;
renderTable(worksData);
showToast('数据加载成功');
} else {
showToast('加载失败:' + res.msg, 'error');
}
}).fail(function() {
showToast('网络错误', 'error');
});
}
// 渲染表格
function renderTable(data) {
const tbody = $('#works-table');
tbody.empty();
data.forEach(work => {
const statusText = {
0: '无效',
1: '处理中',
10: '成功',
99: '失败'
}[work.state] || '未知';
const tr = $(`
<tr>
<td>work.id</td>
<td>work.model || '-'</td>
<td>work.date || '-'</td>
<td><span class="status-badge status-work.state">statusText</span></td>
<td><span class="prompt-text" title="work.prompt || '-'">'-'</span></td>
<td>work.num || 1 宫格</td>
<td>work.ratio || '-'</td>
<td>work.quality || '-'</td>
<td><span title="work.task_id || '-'">'-'</span></td>
<td><span title="work.path || '-'">'-'</span></td>
<td><span class="error-text" title="work.error || '-'">'-'</span></td>
<td>
<div style="display:flex;gap:4px;">
work.state == 1 ? `<button class="btn btn-secondary btn-sm close-btn" data-id="${work.id">关闭</button>` : ''}
work.task_id ? `<button class="btn btn-warning btn-sm retry-btn" data-id="${work.id">重试</button>` : ''}
<button class="btn btn-danger btn-sm delete-btn" data-id="work.id">删除</button>
</div>
</td>
</tr>
`);
tbody.append(tr);
});
// 绑定重试按钮事件
$('.retry-btn').on('click', function() {
const id = $(this).data('id');
if (!confirm('确定要重新查询该任务吗?')) return;
$.post(`/api/admin/retry/id`, function(res) {
if (res.success) {
showToast(res.msg);
setTimeout(loadWorks, 1000);
} else {
showToast(res.msg, 'error');
}
}).fail(function() {
showToast('网络错误', 'error');
});
});
// 绑定关闭任务按钮事件
$('.close-btn').on('click', function() {
const id = $(this).data('id');
if (!confirm('确定要关闭该处理中任务吗?关闭后状态变为失败,不再处理。')) return;
$.post(`/api/admin/close/id`, function(res) {
if (res.success) {
showToast(res.msg);
setTimeout(loadWorks, 1000);
} else {
showToast(res.msg, 'error');
}
}).fail(function() {
showToast('网络错误', 'error');
});
});
// 绑定删除任务按钮事件
$('.delete-btn').on('click', function() {
const id = $(this).data('id');
if (!confirm('确定要删除该任务吗?此操作不可恢复!')) return;
$.post(`/api/admin/delete/id`, function(res) {
if (res.success) {
showToast(res.msg);
setTimeout(loadWorks, 1000);
} else {
showToast(res.msg, 'error');
}
}).fail(function() {
showToast('网络错误', 'error');
});
});
}
// 导出数据
$('#export-btn').on('click', function() {
const dataStr = JSON.stringify(worksData, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `banana-cut-tasks-new Date().toISOString().split('T')[0].json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('数据导出成功');
});
// 刷新按钮
$('#refresh-btn').on('click', loadWorks);
// 页面加载完成后加载数据
$(document).ready(loadWorks);
</script>
</body>
</html>
FILE:templates/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>banana-cut 图片生成切割工具</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/css/font-awesome.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/masonry.pkgd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/imagesloaded.pkgd.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #000000;
--text-secondary: #6c757d;
--border-color: #dee2e6;
--accent-color: #2ed573;
--danger-color: #ff4757;
--nav-bg: rgba(255,255,255,0.85);
--input-bg: rgba(248,249,250,0.95);
--popup-bg: #ffffff;
}
body.dark {
--bg-primary: #0f0f13;
--bg-secondary: #1a1a22;
--bg-card: rgba(30, 30, 40, 0.95);
--text-primary: #ffffff;
--text-secondary: rgba(255,255,255,0.5);
--border-color: rgba(255,255,255,0.1);
--accent-color: #2ed573;
--danger-color: #ff4757;
--nav-bg: rgba(20, 20, 30, 0.85);
--input-bg: rgba(30, 30, 40, 0.95);
--popup-bg: #1e1e28;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow-x: hidden;
transition: all 0.3s;
}
/* 导航栏 */
nav {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
background: var(--nav-bg);
backdrop-filter: blur(20px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 1000;
border-bottom: 1px solid var(--border-color);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 600;
}
.logo-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #ffd93d 0%, #ff6b6b 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.nav-right {
display: flex;
gap: 12px;
}
.nav-btn {
width: 38px;
height: 38px;
border-radius: 8px;
background: rgba(128,128,128,0.1);
border: none;
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
font-size: 16px;
}
.nav-btn:hover {
background: rgba(128,128,128,0.2);
}
.nav-btn.active {
background: var(--accent-color);
color: #fff;
}
.nav-btn.danger:hover {
background: var(--danger-color);
color: #fff;
}
/* 主容器 */
.main-container {
margin-top: 56px;
min-height: calc(100vh - 56px);
padding: 24px;
padding-bottom: 180px;
transition: all 0.3s;
}
.main-container.detail-mode {
padding-left: 22%;
}
/* 瀑布流 */
.grid {
margin: 0 auto;
width: 100%;
max-width: 1600px;
padding: 0 24px;
}
.grid-item {
width: 240px;
margin-bottom: 16px;
border-radius: 12px;
overflow: hidden;
background: var(--bg-card);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.grid-item:hover {
transform: translateY(-4px);
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.grid-item img {
width: 100%;
display: block;
}
/* 失败任务删除按钮 */
.grid-delete-btn {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.8);
border: none;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
z-index: 10;
transition: all 0.3s;
}
.grid-delete-btn:hover {
background: #ff4757;
transform: scale(1.1);
}
/* 失败任务重试按钮 */
.grid-retry-btn {
width: 100%;
padding: 6px 0;
margin-top: 8px;
background: #2ed573;
color: #fff;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.grid-retry-btn:hover {
background: #26bd64;
}
.grid-item-info {
padding: 12px;
}
.grid-item-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grid-item-meta {
font-size: 11px;
color: var(--text-secondary);
display: flex;
justify-content: space-between;
}
.status-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
}
.status-pending { background: #ffa502; color: #fff; }
.status-success { background: #2ed573; color: #fff; }
.status-error { background: #ff4757; color: #fff; }
.status-lost { background: #a4b0be; color: #fff; }
/* 左侧列表(详情模式) */
.left-list {
position: fixed;
left: 0;
top: 56px;
bottom: 0;
width: 20%;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
overflow-y: auto;
padding: 16px 0;
z-index: 999;
transform: translateX(-100%);
transition: transform 0.3s;
}
.main-container.detail-mode .left-list {
transform: translateX(0);
}
.list-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s;
}
.list-item:hover, .list-item.active {
background: var(--accent-color);
color: #fff;
}
.list-item-title {
font-size: 14px;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-item-date {
font-size: 11px;
opacity: 0.7;
}
/* 详情内容区 */
.detail-container {
display: none;
gap: 24px;
height: calc(100vh - 120px);
}
.main-container.detail-mode .detail-container {
display: flex;
}
.main-container.detail-mode .grid {
display: none;
}
.main-image-area {
flex: 1;
max-width: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border-radius: 12px;
border: 1px solid var(--border-color);
overflow: hidden;
padding: 20px;
position: relative;
}
.main-image-area img {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
}
.image-action-bar {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 10px;
}
.image-action-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0,0,0,0.6);
border: none;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.3s;
}
.image-action-btn:hover {
background: var(--accent-color);
transform: scale(1.1);
}
.thumbnails-area {
width: 120px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.thumb-item {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.3s;
}
.thumb-item.active {
border-color: var(--accent-color);
}
.thumb-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 底部输入区 */
.bottom-panel {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: 55%;
z-index: 900;
transition: transform 0.3s;
}
.bottom-panel.hidden {
transform: translateX(-50%) translateY(200%);
}
.input-container {
background: var(--input-bg);
backdrop-filter: blur(20px);
border-radius: 16px;
border: 1px solid var(--border-color);
padding: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
/* 图片上传区 */
.upload-area {
display: none;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.upload-area.show {
display: flex;
}
.uploaded-image {
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
position: relative;
border: 1px solid var(--border-color);
}
.uploaded-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.uploaded-image .remove-btn {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0,0,0,0.7);
color: #fff;
border: none;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.upload-btn {
width: 60px;
height: 60px;
border-radius: 8px;
border: 2px dashed var(--border-color);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all 0.3s;
}
.upload-btn:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
#file-input {
display: none;
}
/* 输入框 */
#prompt-input {
width: 100%;
min-height: 60px;
max-height: 200px;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 15px;
line-height: 1.5;
outline: none;
overflow-y: auto;
padding: 0;
margin-bottom: 12px;
}
#prompt-input:empty:before {
content: "输入你想要的图片描述,支持中文和英文...";
color: var(--text-secondary);
opacity: 0.6;
}
/* 参数选择区 */
.params-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.params-left {
display: flex;
gap: 16px;
align-items: center;
}
.param-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
background: rgba(128,128,128,0.1);
border: none;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.param-btn:hover {
background: rgba(128,128,128,0.2);
}
.cut-options {
display: flex;
gap: 8px;
align-items: center;
}
.cut-option {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(128,128,128,0.1);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.3s;
position: relative;
display: grid;
padding: 3px;
gap: 1px;
}
.cut-option.active {
background: var(--accent-color);
border-color: var(--accent-color);
}
.cut-option.active .grid-cell {
background: #fff;
}
.grid-cell {
width: 100%;
height: 100%;
background: var(--text-primary);
opacity: 0.7;
border-radius: 1px;
transition: all 0.3s;
}
/* 1宫格 */
.cut-option[data-num="1"] {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
/* 2宫格 */
.cut-option[data-num="2"] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
/* 4宫格 */
.cut-option[data-num="4"] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
/* 6宫格 */
.cut-option[data-num="6"] {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
/* 9宫格 */
.cut-option[data-num="9"] {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
}
#submit-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--accent-color);
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
#submit-btn:hover:not(:disabled) {
transform: scale(1.05);
}
#submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 弹出层 */
.popup {
position: absolute;
background: var(--popup-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
min-width: 220px;
max-height: 300px;
overflow-y: auto;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
z-index: 950;
transform: translateY(10px);
}
.popup.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* 图片预览弹窗 */
.image-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: pointer;
}
.image-preview-modal.show {
display: flex;
}
.image-preview-modal img {
max-width: 90%;
max-height: 90%;
border-radius: 8px;
}
/* 输入区域布局 */
.input-main {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 12px;
}
.input-wrapper {
position: relative;
flex: 1;
}
#prompt-input {
flex: 1;
margin: 0 !important;
padding-right: 40px;
}
.prompt-action-btn {
position: absolute;
top: 0;
right: 0;
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(128,128,128,0.1);
border: none;
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.3s;
}
.prompt-action-btn:hover {
background: rgba(128,128,128,0.2);
}
.keywords-popup {
position: absolute;
bottom: 40px;
right: 0;
background: var(--popup-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
min-width: 280px;
max-height: 400px;
overflow-y: auto;
display: none;
z-index: 10;
}
.keywords-popup.show {
display: block;
}
.keyword-group {
padding: 10px;
border-bottom: 1px solid var(--border-color);
}
.keyword-group:last-child {
border-bottom: none;
}
.keyword-group-title {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
font-weight: 500;
}
.keyword-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.keyword-tag {
padding: 4px 8px;
background: rgba(128,128,128,0.1);
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.keyword-tag:hover {
background: var(--accent-color);
color: #fff;
}
.popup.show {
opacity: 1;
visibility: visible;
transform: translateX(40px) translateY(-50px);
}
.popup-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.popup-item:last-child {
border-bottom: none;
}
.popup-item:hover {
background: rgba(128,128,128,0.1);
}
.popup-item.active {
color: var(--accent-color);
}
/* 全局提示 */
.toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 8px;
color: #fff;
font-size: 14px;
z-index: 2000;
animation: slideDown 0.3s ease;
}
.toast.success { background: #2ed573; }
.toast.error { background: #ff4757; }
.toast.info { background: #3742fa; }
@keyframes slideDown {
from { top: 60px; opacity: 0; }
to { top: 80px; opacity: 1; }
}
/* 全局弹窗通用z-index规则 */
.log-modal-overlay, .error-modal-overlay {
z-index: 99999 !important;
}
.config-modal-overlay {
z-index: 99998 !important;
}
.toast {
z-index: 100000 !important;
}
/* 配置弹窗 */
.config-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
}
.config-modal {
background: var(--bg-card);
border-radius: 12px;
width: 90%;
max-width: 500px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.config-modal-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.config-modal-header h3 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.config-modal-body {
padding: 20px;
}
.config-info {
background: rgba(128,128,128,0.1);
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
}
.config-info a {
color: var(--accent-color);
text-decoration: none;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--input-bg);
color: var(--text-primary);
font-size: 14px;
}
.config-modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #2ed573;
color: #fff;
}
.btn-primary:hover {
background: #26bd64;
}
.btn-secondary {
background: rgba(128,128,128,0.1);
color: var(--text-primary);
}
.btn-secondary:hover {
background: rgba(128,128,128,0.2);
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(128,128,128,0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(128,128,128,0.3);
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 100px 0;
color: var(--text-secondary);
}
.empty-state i {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.2;
}
</style>
</head>
<body>
<!-- 导航栏 -->
<nav>
<div class="logo">
<div class="logo-icon">✂️</div>
<span>图片切割器</span>
</div>
<div class="nav-right">
<button class="nav-btn" id="error-btn" title="错误记录">
<i class="fa fa-exclamation-triangle"></i>
</button>
<button class="nav-btn" id="download-btn" title="打包下载" style="display: none;">
<i class="fa fa-download"></i>
</button>
<button class="nav-btn" id="config-btn" title="配置API密钥">
<i class="fa fa-cog"></i>
</button>
<button class="nav-btn" id="theme-toggle-btn" title="切换主题">
<i class="fa fa-moon-o"></i>
</button>
<button class="nav-btn" id="toggle-input-btn" title="显示/隐藏输入框">
<i class="fa fa-keyboard-o"></i>
</button>
<button class="nav-btn danger" id="shutdown-btn" title="关闭服务">
<i class="fa fa-power-off"></i>
</button>
</div>
</nav>
<!-- 左侧列表(详情模式) -->
<div class="left-list" id="left-list"></div>
<!-- 主容器 -->
<div class="main-container" id="main-container">
<!-- 瀑布流 -->
<div class="grid" id="works-grid"></div>
<!-- 详情内容 -->
<div class="detail-container" id="detail-container">
<div class="main-image-area">
<div class="image-action-bar">
<button class="image-action-btn" id="open-folder-btn" title="打开文件目录">
<i class="fa fa-folder-open"></i>
</button>
<button class="image-action-btn" id="edit-image-btn" title="以图生图">
<i class="fa fa-magic"></i>
</button>
<button class="image-action-btn" id="close-detail-btn" title="关闭详情">
<i class="fa fa-times"></i>
</button>
</div>
<img id="main-image" src="" alt="主图">
</div>
<div class="thumbnails-area" id="thumbnails-area"></div>
</div>
</div>
<!-- 底部输入区 -->
<div class="bottom-panel" id="bottom-panel">
<div class="input-container">
<!-- 图片上传区 -->
<div class="upload-area" id="upload-area" >
<label class="upload-btn" for="file-input">
<i class="fa fa-image"></i>
</label>
<input type="file" id="file-input" accept="image/*" multiple>
</div>
<!-- 提示词输入区域 -->
<div class="input-main">
<button class="param-btn" id="upload-trigger-btn" title="上传参考图" style="width:40px;height:40px;font-size:24px;color:#666">
<i class="fa fa-image"></i>
</button>
<div id="prompt-input" contenteditable="plaintext-only"></div>
</div>
<!-- 参数栏 -->
<div class="params-bar">
<div class="params-left">
<button class="param-btn" id="model-btn">
<span>🍌</span>
<span>Nano Banana v2</span>
</button>
<button class="param-btn" id="ratio-btn">
<span>■</span>
<span>1:1 方形</span>
</button>
<button class="param-btn" id="quality-btn">
<span>■</span>
<span>2K (2048px)</span>
</button>
<div class="cut-options">
<button class="cut-option active" data-num="1" title="无切割">
<div class="grid-cell"></div>
</button>
<button class="cut-option" data-num="2" title="2宫格">
<div class="grid-cell"></div>
<div class="grid-cell"></div>
</button>
<button class="cut-option" data-num="4" title="4宫格">
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
</button>
<button class="cut-option" data-num="6" title="6宫格">
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
</button>
<button class="cut-option" data-num="9" title="9宫格">
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
<div class="grid-cell"></div>
</button>
</div>
</div>
<button id="submit-btn" title="生成图片">
<i class="fa fa-magic"></i>
</button>
</div>
</div>
</div>
<!-- 弹出层 -->
<div class="popup" id="model-popup"></div>
<div class="popup" id="ratio-popup"></div>
<div class="popup" id="quality-popup"></div>
<!-- 图片预览弹窗 -->
<div class="image-preview-modal" id="image-preview-modal">
<img id="preview-image" src="" alt="预览图片">
</div>
<script>
let config = {};
let currentModel = 0;
let currentRatio = 0;
let currentQuality = 1; // 默认2K
let currentNum = 1;
let uploadedImages = [];
let inputVisible = true;
let showErrorMode = false;
let currentWorkId = null;
// 多任务并发轮询管理器
let pollingIntervals = {}; // 存储每个任务的轮询定时器
let pollingCount = {}; // 存储每个任务的轮询次数
// 全局提示
function showToast(msg, type = 'success') {
const toast = $(`<div class="toast type">msg</div>`);
$('body').append(toast);
setTimeout(() => {
toast.fadeOut(300, () => toast.remove());
}, 3000);
}
// 初始化
$(function() {
// 初始化主题
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
$('body').addClass('dark');
$('#theme-toggle-btn i').removeClass('fa-moon-o').addClass('fa-sun-o');
}
// 加载配置
loadConfig();
// 加载上次保存的参数
const savedModel = localStorage.getItem('banana_cut_model');
const savedRatio = localStorage.getItem('banana_cut_ratio');
const savedQuality = localStorage.getItem('banana_cut_quality');
const savedNum = localStorage.getItem('banana_cut_num');
if (savedModel !== null) currentModel = parseInt(savedModel);
if (savedRatio !== null) currentRatio = parseInt(savedRatio);
if (savedQuality !== null) currentQuality = parseInt(savedQuality);
if (savedNum !== null) {
currentNum = parseInt(savedNum);
$('.cut-option').removeClass('active');
$(`.cut-option[data-num="currentNum"]`).addClass('active');
}
// 加载作品
loadWorks();
// 主题切换
$('#theme-toggle-btn').on('click', function() {
if ($('body').hasClass('dark')) {
$('body').removeClass('dark');
$(this).find('i').removeClass('fa-sun-o').addClass('fa-moon-o');
localStorage.setItem('theme', 'light');
} else {
$('body').addClass('dark');
$(this).find('i').removeClass('fa-moon-o').addClass('fa-sun-o');
localStorage.setItem('theme', 'dark');
}
});
// 输入框切换
$('#toggle-input-btn').on('click', function() {
inputVisible = !inputVisible;
if (inputVisible) {
$('#bottom-panel').removeClass('hidden');
$(this).html('<i class="fa fa-keyboard-o"></i>');
} else {
$('#bottom-panel').addClass('hidden');
$(this).html('<i class="fa fa-pencil"></i>');
}
});
// 错误记录切换
$('#error-btn').on('click', function() {
showErrorMode = !showErrorMode;
if (showErrorMode) {
$(this).addClass('active');
showToast('已切换到错误记录视图', 'info');
} else {
$(this).removeClass('active');
showToast('已切换到正常视图', 'info');
}
loadWorks();
});
// 关闭服务
$('#shutdown-btn').on('click', function() {
if (confirm('确定要关闭服务吗?')) {
$.post('/api/shutdown', function() {
showToast('服务已关闭', 'info');
setTimeout(() => window.close(), 1500);
}).fail(function() {
showToast('服务已关闭', 'info');
setTimeout(() => window.close(), 1500);
});
}
});
// 下载按钮
$('#download-btn').on('click', function() {
if (!currentWorkId) return;
window.open(`/api/download/currentWorkId`);
});
// 上传触发按钮
$('#upload-trigger-btn').on('click', function() {
$('#file-input').click();
});
// 图片上传
$('#file-input').on('change', function(e) {
const files = e.target.files;
if (uploadedImages.length + files.length > 6) {
showToast('最多只能上传6张图片', 'error');
return;
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith('image/')) continue;
const reader = new FileReader();
reader.onload = function(e) {
const imgData = e.target.result;
uploadedImages.push(imgData);
renderUploadedImages();
// 显示上传区域,隐藏触发按钮
$('#upload-area').addClass('show');
$('#upload-trigger-btn').hide();
};
reader.readAsDataURL(file);
}
$(this).val('');
});
// 切割选项选择
$('.cut-option').on('click', function() {
$('.cut-option').removeClass('active');
$(this).addClass('active');
currentNum = parseInt($(this).data('num'));
localStorage.setItem('banana_cut_num', currentNum);
});
// 提交按钮
$('#submit-btn').on('click', function() {
const prompt = $('#prompt-input').text().trim();
if (!prompt && uploadedImages.length === 0) {
showToast('请输入提示词或上传参考图', 'error');
return;
}
if (!confirm('确定要生成图片吗?')) {
return;
}
const btn = $(this);
btn.prop('disabled', true);
btn.find('i').addClass('spin');
$.ajax({
url: '/api/generate',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
prompt: prompt,
model: currentModel,
ratio: currentRatio,
quality: currentQuality,
num: currentNum,
images: uploadedImages
}),
success: function(res) {
if (res.success) {
showToast('任务提交成功,正在生成中...', 'success');
$('#prompt-input').text('');
uploadedImages = [];
renderUploadedImages();
// 开始轮询
startPolling(res.work_id);
// 刷新列表
setTimeout(loadWorks, 1000);
} else {
showToast('生成失败:' + res.msg, 'error');
}
},
error: function(xhr) {
showToast('生成失败:' + (xhr.responseJSON?.msg || '网络错误'), 'error');
},
complete: function() {
btn.prop('disabled', false);
btn.find('i').removeClass('spin');
}
});
});
// 点击空白处关闭弹出层
$(document).on('click', function(e) {
if (!$(e.target).closest('.param-btn, .popup').length) {
$('.popup').removeClass('show');
}
});
// 图片预览弹窗关闭
$('#image-preview-modal').on('click', function() {
$(this).removeClass('show');
});
// 配置按钮点击事件
$('#config-btn').on('click', function() {
showConfigModal();
});
});
// 加载配置
function loadConfig() {
$.get('/api/config', function(res) {
if (res.success) {
config = res.data;
// 渲染弹出层
renderPopup('model', config.models);
renderPopup('ratio', config.resolutions);
renderPopup('quality', config.qualities);
// 更新按钮显示为上次保存的参数
if (config.models[currentModel]) {
$('#model-btn span:first').text(config.models[currentModel].logo);
$('#model-btn span:last').text(config.models[currentModel].name);
}
if (config.resolutions[currentRatio]) {
$('#ratio-btn span:first').text(config.resolutions[currentRatio].icon);
$('#ratio-btn span:last').text(config.resolutions[currentRatio].name);
}
if (config.qualities[currentQuality]) {
$('#quality-btn span:first').text(config.qualities[currentQuality].icon);
$('#quality-btn span:last').text(config.qualities[currentQuality].name);
}
}
});
}
// 渲染弹出层
function renderPopup(type, items) {
const popup = $(`#type-popup`);
popup.empty();
items.forEach((item, index) => {
const itemEl = $(`
<div class="popup-item" data-index="index">
<span>item.logo || item.icon</span>
<span>item.name</span>
</div>
`);
itemEl.on('click', function() {
const idx = parseInt($(this).data('index'));
if (type === 'model') {
currentModel = idx;
$('#model-btn span:first').text(item.logo);
$('#model-btn span:last').text(item.name);
localStorage.setItem('banana_cut_model', idx);
} else if (type === 'ratio') {
currentRatio = idx;
$('#ratio-btn span:first').text(item.icon);
$('#ratio-btn span:last').text(item.name);
localStorage.setItem('banana_cut_ratio', idx);
} else if (type === 'quality') {
currentQuality = idx;
$('#quality-btn span:first').text(item.icon);
$('#quality-btn span:last').text(item.name);
localStorage.setItem('banana_cut_quality', idx);
}
popup.removeClass('show');
});
popup.append(itemEl);
});
// 绑定按钮点击事件
$(`#type-btn`).on('click', function(e) {
e.stopPropagation();
$('.popup').not(`#type-popup`).removeClass('show');
if (popup.hasClass('show')) {
popup.removeClass('show');
return;
}
// 动态计算弹出层位置,在按钮正上方
const btnOffset = $(this).offset();
const btnWidth = $(this).outerWidth();
const popupWidth = popup.outerWidth();
const left = btnOffset.left + (btnWidth / 2) - (popupWidth / 2) + $(window).scrollLeft();
const top = btnOffset.top - popup.outerHeight() - 10 + $(window).scrollTop();
popup.css({
left: left + 'px',
top: top + 'px'
});
popup.addClass('show');
});
}
// 渲染已上传图片
function renderUploadedImages() {
const area = $('#upload-area');
area.find('.uploaded-image').remove();
uploadedImages.forEach((img, index) => {
const imgEl = $(`
<div class="uploaded-image">
<img src="img" alt="上传图片" class="uploaded-img-preview">
<button class="remove-btn" data-index="index">
<i class="fa fa-times"></i>
</button>
</div>
`);
imgEl.find('.remove-btn').on('click', function(e) {
e.stopPropagation();
const idx = parseInt($(this).data('index'));
uploadedImages.splice(idx, 1);
renderUploadedImages();
});
// 点击图片预览
imgEl.find('img').on('click', function() {
$('#preview-image').attr('src', img);
$('#image-preview-modal').addClass('show');
});
area.prepend(imgEl);
});
// 控制上传按钮显示
if (uploadedImages.length >= 6) {
$('.upload-btn').hide();
} else {
$('.upload-btn').show();
}
// 如果没有图片了,隐藏上传区域,显示触发按钮
if (uploadedImages.length === 0) {
$('#upload-area').removeClass('show');
$('#upload-trigger-btn').show();
}
}
// 加载作品列表
function loadWorks() {
$.get(`/api/works?error=0`, function(res) {
if (res.success) {
const grid = $('#works-grid');
// 【修复1】销毁旧的 Masonry 实例,防止瀑布流缩小
if (grid.data('masonry')) {
grid.masonry('destroy');
}
grid.empty();
if (res.data.length === 0) {
grid.html('<div class="empty-state"><i class="fa fa-image"></i><h3>还没有生成的作品</h3><p>在下方输入框输入描述开始创作吧</p></div>');
return;
}
res.data.forEach(work => {
let statusHtml = '';
let imgSrc = '';
if (work.state === 1) {
statusHtml = '<span class="status-badge status-pending">生成中</span>';
imgSrc = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQwIiBoZWlnaHQ9IjI0MCIgdmlld0JveD0iMCAwIDI0MCAyNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyNDAiIGhlaWdodD0iMjQwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMjAgNjBMMTIwIDE4MCIgc3Ryb2tlPSIjQ0RDRURGIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8cGF0aCBkPSJNNjAgMTIwTDE4MCAxMjAiIHN0cm9rZT0iI0NERURERiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+';
// 轮询生成中的任务
startPolling(work.id);
} else if (work.state === 10) {
statusHtml = '<span class="status-badge status-success">成功</span>';
imgSrc = `/work.path.replace(/\\/g, '/')/480p.work.ext`;
} else if (work.state === 99) {
statusHtml = '<span class="status-badge status-error">失败</span>';
imgSrc = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQwIiBoZWlnaHQ9IjI0MCIgdmlld0JveD0iMCAwIDI0MCAyNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyNDAiIGhlaWdodD0iMjQwIiBmaWxsPSIjRkNGM0YzIi8+CjxwYXRoIGQ9Ik0xMjAgNzBMMTIwIDEzMCIgc3Ryb2tlPSIjRkY0NzU3IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8Y2lyY2xlIGN4PSIxMjAiIGN5PSIxNzAiIHI9IjgiIGZpbGw9IiNGRjQ3NTciLz4KPC9zdmc+';
} else if (work.state === 0) {
statusHtml = '<span class="status-badge status-lost">丢失</span>';
imgSrc = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQwIiBoZWlnaHQ9IjI0MCIgdmlld0JveD0iMCAwIDI0MCAyNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyNDAiIGhlaWdodD0iMjQwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik04MCA4MEwxNjAgMTYwIiBzdHJva2U9IiNBNEIwQkUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+CjxwYXRoIGQ9Ik0xNjAgODBMODAgMTYwIiBzdHJva2U9IiNBNEIwQkUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPg==';
}
let deleteBtn = '';
let retryBtn = '';
if (work.state === 99 && work.task_id) {
deleteBtn = `<button class="grid-delete-btn" data-id="work.id"><i class="fa fa-times"></i></button>`;
retryBtn = `<button class="grid-retry-btn" data-id="work.id"><i class="fa fa-refresh"></i> 重新获取</button>`;
}
const item = $(`
<div class="grid-item" data-id="work.id">
deleteBtn
<img src="imgSrc" alt="work.prompt">
<div class="grid-item-info">
<div class="grid-item-title">work.prompt.substring(0, 20)''</div>
<div class="grid-item-meta">
<span>work.date.substring(5, 16)</span>
statusHtml
</div>
retryBtn
</div>
</div>
`);
// 点击卡片事件
item.on('click', function(e) {
if ($(e.target).closest('.grid-delete-btn, .grid-retry-btn').length > 0) {
return;
}
if (work.state === 10) {
openWorkDetail(work.id);
} else if (work.state === 99) {
showErrorDetail(work);
} else if (work.state === 0) {
showToast('文件已丢失', 'error');
}
});
grid.append(item);
});
// 初始化瀑布流
imagesLoaded(grid, function() {
grid.masonry({
itemSelector: '.grid-item',
columnWidth: 240,
gutter: 16,
fitWidth: true
});
});
// 打开文件夹按钮
$('#open-folder-btn').on('click', function() {
if (!currentWorkId) return;
$.post(`/api/open-folder/currentWorkId`, function(res) {
if (res.success) {
showToast(res.msg, 'success');
} else {
showToast(res.msg, 'error');
}
}).fail(function() {
showToast('打开文件夹失败', 'error');
});
});
// 关闭详情按钮
$('#close-detail-btn').on('click', function() {
$('#main-container').removeClass('detail-mode');
$('#download-btn').hide();
currentWorkId = null;
// 如果不是编辑状态,显示输入框
if (inputVisible) {
$('#bottom-panel').removeClass('hidden');
$('#toggle-input-btn').html('<i class="fa fa-keyboard-o"></i>');
}
});
// 以图生图按钮
$('#edit-image-btn').on('click', function() {
// 清空已上传图片,添加当前主图
uploadedImages = [$('#main-image').attr('src')];
renderUploadedImages();
// 显示上传区域和输入框
$('#upload-area').addClass('show');
$('#upload-trigger-btn').hide();
inputVisible = true;
$('#bottom-panel').removeClass('hidden');
$('#toggle-input-btn').html('<i class="fa fa-keyboard-o"></i>');
// 滚动到底部
$('html, body').animate({ scrollTop: $(document).height() }, 300);
showToast('已将当前图片添加为参考图,可输入提示词开始以图生图', 'info');
});
}
});
}
// 【修复3&4】开始轮询任务 - 支持多任务并发轮询,第一次1分钟,之后每30秒
function startPolling(workId) {
// 如果已经在轮询中,先清除旧的定时器
if (pollingIntervals[workId]) {
clearInterval(pollingIntervals[workId]);
}
// 初始化轮询计数器
pollingCount[workId] = 0;
console.log(`[轮询] 开始轮询任务 workId`);
// 第一次轮询延迟1分钟(60000ms)
setTimeout(function() {
pollTask(workId);
// 之后每30秒轮询一次
pollingIntervals[workId] = setInterval(function() {
pollTask(workId);
}, 30000);
}, 60000);
}
// 轮询单个任务
function pollTask(workId) {
pollingCount[workId]++;
console.log(`[轮询] 第 pollingCount[workId] 次轮询任务 workId`);
$.get(`/api/poll/workId`, function(res) {
if (res.success) {
console.log(`[轮询] 任务 workId 状态: res.data.state, 错误: res.data.error || '无'`);
// 任务完成或失败时停止轮询
if (res.data.state === 10 || res.data.state === 99) {
stopPolling(workId);
loadWorks();
if (res.data.state === 10) {
showToast(`任务 workId 生成成功!`, 'success');
} else {
showToast(`任务 workId 生成失败: res.data.error`, 'error');
}
}
}
}).fail(function(xhr) {
console.error(`[轮询] 任务 workId 查询失败:`, xhr.responseText);
// 失败不停止轮询,继续重试
});
}
// 停止轮询
function stopPolling(workId) {
if (pollingIntervals[workId]) {
clearInterval(pollingIntervals[workId]);
delete pollingIntervals[workId];
console.log(`[轮询] 停止轮询任务 workId`);
}
if (pollingCount[workId]) {
delete pollingCount[workId];
}
}
// 打开作品详情
function openWorkDetail(id) {
currentWorkId = id;
$('#main-container').addClass('detail-mode');
$('#download-btn').show();
// 隐藏提示词输入框
$('#bottom-panel').addClass('hidden');
// 加载左侧列表
$.get('/api/works', function(res) {
if (res.success) {
const list = $('#left-list');
list.empty();
res.data.forEach(work => {
if (work.state !== 10) return;
const item = $(`
<div class="list-item ''" data-id="work.id">
<div class="list-item-title">work.prompt.substring(0, 20)''</div>
<div class="list-item-date">work.date.substring(5, 16)</div>
</div>
`);
item.on('click', function() {
const workId = parseInt($(this).data('id'));
openWorkDetail(workId);
});
list.append(item);
});
}
});
// 加载作品详情
$.get(`/api/work/id`, function(res) {
if (res.success) {
const data = res.data;
if (data.state === 0) {
showToast('图片文件已丢失', 'error');
return;
}
// 设置主图
$('#main-image').attr('src', data.main_image);
// 渲染缩略图
const thumbArea = $('#thumbnails-area');
thumbArea.empty();
// 主图缩略图
const mainThumb = $(`
<div class="thumb-item active" data-src="data.main_image">
<img src="data.thumb_image" alt="主图">
</div>
`);
mainThumb.on('click', function() {
$('.thumb-item').removeClass('active');
$(this).addClass('active');
$('#main-image').attr('src', $(this).data('src'));
});
thumbArea.append(mainThumb);
// 切割图缩略图
data.images.forEach((img, index) => {
const thumb = $(`
<div class="thumb-item" data-src="img">
<img src="img" alt="切割图index + 1">
</div>
`);
thumb.on('click', function() {
$('.thumb-item').removeClass('active');
$(this).addClass('active');
$('#main-image').attr('src', $(this).data('src'));
});
thumbArea.append(thumb);
});
}
});
}
/* 【修复2】显示错误详情弹窗 - 确保z-index不重叠 */
function showErrorDetail(work) {
// 先关闭所有其他弹窗
$('.error-modal-overlay, .log-modal-overlay, .config-modal-overlay').remove();
const modal = $(`
<div class="error-modal-overlay" style="z-index:99999;">
<div class="error-modal">
<div class="error-modal-header">
<h3>❌ 任务失败详情</h3>
<button class="error-modal-close"><i class="fa fa-times"></i></button>
</div>
<div class="error-modal-body">
<div class="info-row">
<span class="label">任务ID:</span>
<span class="value">work.id</span>
</div>
<div class="info-row">
<span class="label">Task ID:</span>
<span class="value">work.task_id || '-'</span>
</div>
<div class="info-row">
<span class="label">错误信息:</span>
<span class="value error-text">work.error || '未知错误'</span>
</div>
<div class="info-row">
<span class="label">生成时间:</span>
<span class="value">work.date || '-'</span>
</div>
<div class="info-row">
<span class="label">提示词:</span>
<span class="value">work.prompt || '-'</span>
</div>
<div class="info-row">
<span class="label">宫格数:</span>
<span class="value">work.num || 1 宫格</span>
</div>
</div>
<div class="error-modal-footer">
work.task_id ? `<button class="btn btn-primary retry-task-btn" data-id="${work.id"><i class="fa fa-refresh"></i> 重新获取</button>` : ''}
<button class="btn btn-secondary close-modal-btn">关闭</button>
</div>
</div>
</div>
<style>
.error-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.error-modal {
background: var(--bg-card);
border-radius: 12px;
width: 90%;
max-width: 600px;
overflow: hidden;
}
.error-modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.error-modal-header h3 {
margin: 0;
font-size: 18px;
}
.error-modal-close {
background: none;
border: none;
font-size: 20px;
color: var(--text-secondary);
cursor: pointer;
}
.error-modal-body {
padding: 20px;
max-height: 400px;
overflow-y: auto;
}
.info-row {
display: flex;
margin-bottom: 12px;
gap: 10px;
}
.info-row .label {
width: 80px;
font-weight: 500;
color: var(--text-secondary);
flex-shrink: 0;
}
.info-row .value {
flex: 1;
word-break: break-all;
}
.error-modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #2ed573;
color: #fff;
}
.btn-primary:hover {
background: #26bd64;
}
.btn-secondary {
background: rgba(128,128,128,0.1);
color: var(--text-primary);
}
.btn-secondary:hover {
background: rgba(128,128,128,0.2);
}
.log-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
}
.log-modal {
background: var(--bg-card);
border-radius: 12px;
width: 90%;
max-width: 800px;
height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.log-modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.log-modal-content {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #1a1a1a;
color: #fff;
font-family: monospace;
font-size: 13px;
line-height: 1.5;
}
.log-modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
text-align: right;
}
</style>
`);
$('body').append(modal);
// 关闭弹窗
modal.find('.error-modal-close, .close-modal-btn').on('click', function() {
modal.remove();
});
modal.on('click', function(e) {
if ($(e.target).hasClass('error-modal-overlay')) {
modal.remove();
}
});
// 重新获取按钮
modal.find('.retry-task-btn').on('click', function() {
const workId = $(this).data('id');
modal.remove();
retryTask(workId);
});
}
/* 重试任务 */
function retryTask(workId) {
// 先关闭所有其他弹窗
$('.error-modal-overlay, .log-modal-overlay, .config-modal-overlay').remove();
// 显示日志弹窗
const logModal = $(`
<div class="log-modal-overlay" style="z-index:99999;">
<div class="log-modal">
<div class="log-modal-header">
<h3>🔄 任务重新获取中...</h3>
<button class="error-modal-close close-log-btn"><i class="fa fa-times"></i></button>
</div>
<div class="log-modal-content" id="log-content">
<div>开始重新查询任务 ID: workId</div>
</div>
<div class="log-modal-footer">
<button class="btn btn-secondary close-log-btn">关闭</button>
</div>
</div>
</div>
`);
$('body').append(logModal);
// 关闭日志弹窗
logModal.find('.close-log-btn').on('click', function() {
logModal.remove();
});
logModal.on('click', function(e) {
if ($(e.target).hasClass('log-modal-overlay')) {
logModal.remove();
}
});
const logContent = $('#log-content');
// 调用重试接口
$.post(`/api/admin/retry/workId`, function(res) {
if (res.success) {
logContent.append(`<div style="color: #2ed573;">✅ res.msg</div>`);
// 开始轮询更新日志(使用新的轮询机制)
startPolling(workId);
// 额外的日志轮询
let logPollCount = 0;
const logPollInterval = setInterval(function() {
$.get(`/api/poll/workId`, function(res) {
logPollCount++;
if (res.success) {
logContent.append(`<div>[第 logPollCount 次查询] 状态: res.data.state === 10 ? '成功' : '失败' | 错误: res.data.error || '-'</div>`);
// 滚动到底部
logContent.scrollTop(logContent[0].scrollHeight);
if (res.data.state === 10 || res.data.state === 99) {
clearInterval(logPollInterval);
if (res.data.state === 10) {
logContent.append(`<div style="color: #2ed573;">✅ 任务执行成功!</div>`);
showToast('任务重新获取成功!', 'success');
} else {
logContent.append(`<div style="color: #ff4757;">❌ 任务执行失败: res.data.error</div>`);
showToast('任务重新获取失败', 'error');
}
logContent.scrollTop(logContent[0].scrollHeight);
// 刷新列表
setTimeout(loadWorks, 1000);
}
}
}).fail(function() {
logContent.append(`<div style="color: #ffa502;">⚠️ 查询请求失败,继续重试...</div>`);
logContent.scrollTop(logContent[0].scrollHeight);
});
}, 3000);
} else {
logContent.append(`<div style="color: #ff4757;">❌ 重试失败: res.msg</div>`);
}
}).fail(function() {
logContent.append(`<div style="color: #ff4757;">❌ 网络错误,重试失败</div>`);
});
}
// 绑定删除按钮事件
$(document).on('click', '.grid-delete-btn', function(e) {
e.stopPropagation();
const workId = $(this).data('id');
if (!confirm('确定要删除该条任务记录吗?此操作不可恢复!')) {
return;
}
// 这里可以调用删除接口,暂时先前端移除并更新数据库
$.post(`/api/admin/delete/workId`, function(res) {
if (res.success) {
showToast('删除成功', 'success');
loadWorks();
} else {
showToast('删除失败: ' + res.msg, 'error');
}
}).fail(function() {
showToast('网络错误', 'error');
});
});
// 绑定重试按钮事件
$(document).on('click', '.grid-retry-btn', function(e) {
e.stopPropagation();
const workId = $(this).data('id');
retryTask(workId);
});
// 显示配置弹窗
function showConfigModal() {
// 先关闭所有其他弹窗
$('.error-modal-overlay, .log-modal-overlay, .config-modal-overlay').remove();
const modal = $(`
<div class="config-modal-overlay" style="z-index:99998;">
<div class="config-modal">
<div class="config-modal-header">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg, #667eea 0%, #764ba2 100%);display:flex;align-items:center;justify-content:center;font-size:20px;">🔑</div>
<h3>配置API密钥</h3>
</div>
</div>
<div class="config-modal-body">
<div class="config-info" style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);border-left:4px solid #667eea;">
<div style="margin-bottom:8px;font-weight:500;">📝 配置说明</div>
<div style="margin-bottom:6px;">• <b>API_KEY(必填)</b>:用于图片生成功能,必须填写</div>
<div style="margin-bottom:6px;">• <b>PLATFORM_TOKEN(可选)</b>:仅图生图/图片编辑功能需要,普通生成可留空</div>
<div>密钥获取地址:<a href="https://share.acedata.cloud/r/1uN88BrUTQ" target="_blank" style="font-weight:500;">https://share.acedata.cloud/r/1uN88BrUTQ</a></div>
</div>
<div class="form-group">
<label style="font-weight:600;">API_KEY <span style="color:#ff4757;">*</span></label>
<input type="text" id="input-api-key" placeholder="粘贴API_KEY到这里" style="transition:all 0.3s;" onfocus="this.style.borderColor='#667eea'">
</div>
<div class="form-group">
<label style="font-weight:600;">PLATFORM_TOKEN <span style="color:#a4b0be;font-weight:normal;">(可选)</span></label>
<input type="text" id="input-platform-token" placeholder="图生图功能需要时填写" style="transition:all 0.3s;" onfocus="this.style.borderColor='#667eea'">
</div>
</div>
<div class="config-modal-footer" style="gap:12px;">
<button class="btn btn-secondary" id="close-config-btn" style="padding:10px 24px;border-radius:8px;">取消</button>
<button class="btn btn-primary" id="save-config-btn" style="padding:10px 24px;border-radius:8px;background:linear-gradient(135deg, #667eea 0%, #764ba2 100%);">保存配置</button>
</div>
</div>
</div>
`);
$('body').append(modal);
// 关闭按钮
modal.find('#close-config-btn').on('click', function() {
modal.remove();
});
// 保存配置
modal.find('#save-config-btn').on('click', function() {
const api_key = $('#input-api-key').val().trim();
const platform_token = $('#input-platform-token').val().trim();
if (!api_key) {
showToast('API_KEY为必填项,请填写', 'error');
return;
}
$.ajax({
url: '/api/config/save',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({api_key, platform_token}),
success: function(res) {
if (res.success) {
showToast(res.msg, 'success');
modal.remove();
setTimeout(() => location.reload(), 1000);
} else {
showToast(res.msg, 'error');
}
},
error: function(xhr) {
showToast('保存失败:' + (xhr.responseJSON?.msg || '网络错误'), 'error');
}
});
});
// 点击遮罩关闭
modal.on('click', function(e) {
if ($(e.target).hasClass('config-modal-overlay')) {
modal.remove();
}
});
}
// 全局AJAX错误拦截,检测401未授权
$(document).ajaxError(function(event, xhr, settings, thrownError) {
if (xhr.status === 401 || xhr.responseJSON?.need_config) {
showConfigModal();
}
});
// 页面加载完成后首先检测配置
$(document).ready(function() {
// 检测配置状态,未配置直接弹出窗口
$.get('/api/config/check', function(res) {
if (!res.success && res.need_config) {
showConfigModal();
}
}).fail(function(xhr) {
if (xhr.responseJSON?.need_config) {
showConfigModal();
}
});
});
</script>
</body>
</html>
专业LRC歌词创作工具,支持歌曲音频波形可视化、歌词时间轴精准打点、LRC导入/导出、播放实时高亮、毫秒级时间戳编辑、自动本地存储防止数据丢失。前端使用jQuery+WaveSurfer.js开发,后端Python Flask,默认端口698,界面紧凑高效。使用场景:(1) 为歌曲制作LRC歌词文件 (2) 编辑...
---
name: lrc
description: 专业LRC歌词创作工具,支持歌曲音频波形可视化、歌词时间轴精准打点、LRC导入/导出、播放实时高亮、毫秒级时间戳编辑、自动本地存储防止数据丢失。前端使用jQuery+WaveSurfer.js开发,后端Python Flask,默认端口698,界面紧凑高效。使用场景:(1) 为歌曲制作LRC歌词文件 (2) 编辑/校准已有LRC歌词时间轴 (3) 批量调整歌词时间点 (4) 可视化歌词同步制作
---
# LRC 歌词创作工具
## 🎵 简介
专为歌词创作者打造的高效可视化LRC制作工具,整合波形预览、精准打点、实时同步、自动保存等核心功能,大幅提升歌词制作效率,支持从0到1制作新歌词,也支持导入已有LRC校准时间轴。
### 核心特点
- 🎧 **音频波形可视化**:基于WaveSurfer.js的专业波形渲染,直观展示音频节奏点
- 📥 **LRC导入功能**:直接导入现有LRC文件,自动解析时间轴和歌词内容
- ⏱️ **毫秒级精度打点**:点击「打点」按钮即可在当前播放位置创建歌词条目,时间精度到0.001秒
- ✍️ **高效歌词编辑**:紧凑列表布局,同屏展示更多歌词,支持批量编辑、上移/下移调整顺序
- 🎯 **时间自由微调**:直接点击时间数字修改,自动同步相邻条目时间,避免手动调整误差
- 🎶 **播放实时同步**:播放时当前时间对应的歌词自动高亮(蓝色背景标记),并平滑滚动到可见区域
- ⌨️ **全键盘快捷键**:支持空格播放/暂停、左右箭头快进/后退1秒,无需频繁切换鼠标
- 💾 **自动防丢保存**:所有编辑自动保存到浏览器本地存储,刷新页面/重启浏览器数据不丢失
- 📌 **控制栏固定置顶**:播放控制条滚动时保持在顶部可见,编辑长歌词时无需来回滚动
- 💾 **标准LRC导出**:一键导出符合行业标准的LRC格式文件,兼容所有播放器
- 🛑 **一键关闭服务**:页面右上角按钮直接关闭后台服务,无需手动操作终端
- 🎨 **现代化UI设计**:响应式布局,支持桌面端和移动端,操作流畅直观
## 🚀 快速开始
### 启动服务
在OpenClaw中直接输入「运行lrc」即可自动启动,或手动执行:
```bash
cd ~/.openclaw/workspace/skills/lrc
python start_server.py
```
脚本会自动检测并安装所需依赖(Flask、pydub、numpy),无需手动配置。
### 基础使用流程
1. **访问页面**:打开浏览器访问 `http://localhost:698`
2. **上传歌曲**:点击顶部「上传歌曲」按钮,选择音频文件(支持mp3/wav/flac/ogg/m4a等常见格式)
3. **导入LRC(可选)**:如果已有歌词文本,点击「导入LRC」直接加载已有时间轴和歌词
4. **制作歌词**:播放音频,在歌词开始位置点击「打点」创建条目,输入歌词内容
5. **调整校准**:直接修改时间戳微调,或拖动播放位置重新打点
6. **导出文件**:完成后点击「导出LRC」下载最终歌词文件
## ⌨️ 快捷键列表
| 快捷键 | 功能 | 生效范围 |
|--------|------|----------|
| 空格键 | 播放/暂停 | 非输入框焦点时 |
| ← 左箭头 | 后退1秒 | 非输入框焦点时 |
| → 右箭头 | 前进1秒 | 非输入框焦点时 |
## 📋 功能详解
### 顶部操作栏
- **上传歌曲**:选择要制作歌词的音频文件
- **导入LRC**:导入现有.lrc文件,自动解析时间轴和歌词内容
- **导出LRC**:生成并下载标准LRC格式文件
- **关闭服务**:一键停止后台服务
### 播放控制区
- **后退1s**:当前播放位置后退1秒
- **播放/暂停**:控制音频播放状态
- **前进1s**:当前播放位置前进1秒
- **打点**:在当前播放位置创建新的歌词条目
- **清空**:清空所有歌词条目(不会删除音频)
- **时间显示**:左侧为当前播放时间,右侧为歌曲总时长
### 歌词列表功能
- **开始时间**:歌词开始播放的时间点,可直接编辑修改
- **结束时间**:歌词结束播放的时间点,修改后自动同步下一条的开始时间
- **歌词输入框**:输入对应时间点的歌词内容
- **上移/下移**:调整歌词条目顺序
- **删除**:删除当前歌词条目
## ❓ 常见问题
### Q: 导入MP3文件提示错误怎么办?
A: 处理MP3格式需要安装FFmpeg,下载地址:https://ffmpeg.org/download.html,安装后添加到系统PATH即可。WAV/FLAC格式无需FFmpeg可直接使用。
### Q: 刷新页面后歌词丢失了怎么办?
A: 工具会自动保存所有编辑到浏览器本地存储,只要不是清除浏览器缓存,刷新页面会自动恢复之前的编辑内容。
### Q: 导出的LRC在播放器中不显示时间怎么办?
A: 导出的LRC为标准格式,支持所有主流播放器,如果不显示请检查播放器是否支持LRC歌词显示,或歌词文件名是否和音频文件名一致。
## 📁 文件结构
```
lrc/
├── SKILL.md # 技能说明文档
├── start_server.py # 一键启动脚本(自动安装依赖)
└── web/
├── app.py # Flask Web 后端服务
├── requirements.txt # 依赖包列表
└── templates/
└── index.html # 前端页面(所有逻辑内置)
```
## 🔒 关闭服务
使用完成后,点击页面右上角红色「关闭服务」按钮,确认后即可自动停止后台服务。也可以在终端按 `Ctrl+C` 手动停止。
**作者**:Jakey
**email**:[email protected]
**wechat**:jakeycis
**版本**:1.1.0
**日期**:2026-03-20
FILE:start_server.py
#!/usr/bin/env python3
import os
import sys
import subprocess
import importlib
def install_package(package):
"""安装Python包"""
subprocess.check_call([sys.executable, "-m", "pip", "install", package])
def check_dependencies():
"""检查并安装依赖"""
required_packages = [
"flask",
"pydub",
"numpy"
]
print("检查依赖中...")
for package in required_packages:
try:
importlib.import_module(package)
print(f"{package} 已安装")
except ImportError:
print(f"正在安装 {package}...")
install_package(package)
print("所有依赖安装完成")
if __name__ == "__main__":
# 设置控制台编码为UTF-8
import sys
sys.stdout.reconfigure(encoding='utf-8')
# 检查依赖
check_dependencies()
# 切换到web目录
web_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
os.chdir(web_dir)
# 启动Flask服务
print("启动LRC歌词创作工具服务...")
print("访问地址: http://localhost:698")
print("按 Ctrl+C 停止服务")
subprocess.run([sys.executable, "app.py"])
FILE:web/app.py
#!/usr/bin/env python3
from flask import Flask, render_template, request, jsonify
import os
import sys
import json
import numpy as np
from pydub import AudioSegment
import tempfile
import signal
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = tempfile.gettempdir()
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 最大100MB
# 允许的音频格式
ALLOWED_EXTENSIONS = {'mp3', 'wav', 'flac', 'ogg', 'm4a'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_waveform_data(file_path, num_points=1000):
"""生成音频波形数据"""
try:
audio = AudioSegment.from_file(file_path)
samples = np.array(audio.get_array_of_samples())
# 合并声道
if audio.channels == 2:
samples = samples.reshape((-1, 2)).mean(axis=1)
# 采样到指定点数
if len(samples) > num_points:
samples = samples[::len(samples)//num_points][:num_points]
# 归一化
samples = samples / np.max(np.abs(samples))
return {
'duration': audio.duration_seconds * 1000,
'waveform': samples.tolist(),
'sample_rate': audio.frame_rate
}
except Exception as e:
return {'error': str(e)}
@app.route('/')
def index():
return render_template('index.html')
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': '没有选择文件'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': '没有选择文件'}), 400
if file and allowed_file(file.filename):
# 保存临时文件
temp_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
file.save(temp_path)
# 生成波形数据
waveform = get_waveform_data(temp_path)
# 删除临时文件
os.unlink(temp_path)
return jsonify(waveform)
return jsonify({'error': '不支持的文件格式'}), 400
@app.route('/shutdown', methods=['POST'])
def shutdown():
"""关闭服务"""
try:
import sys
os.kill(os.getpid(), signal.SIGTERM)
return jsonify({'status': 'success', 'message': '服务已关闭'})
except:
try:
# Windows兼容退出
os._exit(0)
except:
return jsonify({'status': 'error', 'message': '关闭失败,请手动停止服务'}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=698, debug=False)
FILE:web/requirements.txt
flask>=2.3.0
pydub>=0.25.1
numpy>=1.24.0
FILE:web/templates/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LRC 歌词创作工具</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/wavesurfer.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background: #f5f7fa;
color: #333;
line-height: 1.6;
padding-bottom: 1rem;
}
/* 顶部导航 */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.7rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 100;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.header h1 {
font-size: 1.2rem;
font-weight: 600;
}
.header-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.header-btn {
background: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.3);
padding: 0.35rem 0.7rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.3s;
font-size: 0.8rem;
}
.header-btn:hover {
background: rgba(255,255,255,0.3);
}
.close-btn {
background: #ff4757;
color: white;
border: none;
padding: 0.35rem 0.7rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.3s;
font-size: 0.8rem;
}
.close-btn:hover {
background: #ff3742;
}
#fileInput, #lrcInput {
display: none;
}
/* 主容器 */
.container {
max-width: 1200px;
margin: 1rem auto;
padding: 0 2rem;
}
/* 文件信息 */
#fileInfo {
background: white;
padding: 0.8rem 1.2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
margin-bottom: 1rem;
color: #333;
font-weight: 500;
font-size: 0.95rem;
}
/* 波形区域 */
.waveform-section {
background: white;
padding: 1rem;
border-radius: 12px;
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
margin-bottom: 1rem;
position: sticky;
top: 60px;
z-index: 90;
}
#waveform {
margin: 0.5rem 0;
border-radius: 8px;
overflow: hidden;
}
.controls {
display: flex;
gap: 0.7rem;
flex-wrap: wrap;
align-items: center;
margin-top: 0.5rem;
}
.control-btn {
background: #f0f2f5;
color: #333;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.3s;
}
.control-btn:hover {
background: #e5e7eb;
}
.control-btn.primary {
background: #667eea;
color: white;
}
.control-btn.primary:hover {
background: #5a6fd8;
}
.control-btn.danger {
background: #ff4757;
color: white;
}
.control-btn.danger:hover {
background: #ff3742;
}
.time-display {
padding: 0.4rem 0.6rem;
background: #f0f2f5;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
}
/* 歌词列表区域 */
.lyrics-section {
background: white;
padding: 1rem;
border-radius: 12px;
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
}
.lyrics-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #eee;
font-size: 0.95rem;
}
.lyrics-header h3 {
font-size: 1rem;
font-weight: 600;
}
.lyric-item {
display: grid;
grid-template-columns: 90px 90px 1fr 90px;
gap: 0.6rem;
align-items: center;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #f8f8f8;
transition: background 0.2s;
min-height: 36px;
}
.lyric-item:hover {
background: #fafbfc;
}
.lyric-item.active {
background: #e8f0fe;
border-left: 3px solid #667eea;
}
.time-input {
padding: 0.3rem 0.5rem;
border: 1px solid #ddd;
border-radius: 3px;
font-family: monospace;
font-size: 0.8rem;
width: 100%;
height: 28px;
}
.lyric-input {
padding: 0.3rem 0.5rem;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 0.85rem;
width: 100%;
resize: none;
height: 28px;
line-height: 22px;
}
.item-actions {
display: flex;
gap: 0.3rem;
}
.item-btn {
padding: 0.2rem 0.4rem;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 0.75rem;
transition: background 0.2s;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.item-btn.up {
background: #f0f2f5;
}
.item-btn.down {
background: #f0f2f5;
}
.item-btn.delete {
background: #ffebee;
color: #c62828;
}
.item-btn:hover {
opacity: 0.8;
}
/* 响应式 */
@media (max-width: 768px) {
.lyric-item {
grid-template-columns: 1fr;
gap: 0.5rem;
padding: 0.5rem;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.header {
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
}
.header-left {
flex-direction: column;
gap: 0.8rem;
}
.waveform-section {
position: relative;
top: auto;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>🎵 LRC 歌词创作工具</h1>
<div class="header-buttons">
<input type="file" id="fileInput" accept="audio/*">
<button class="header-btn" id="uploadBtn">📁 上传歌曲</button>
<input type="file" id="lrcInput" accept=".lrc">
<button class="header-btn" id="importLrcBtn">📝 导入LRC</button>
<button class="header-btn" id="exportBtn">💾 导出LRC</button>
</div>
</div>
<button class="close-btn" id="closeService">关闭服务</button>
</div>
<div class="container">
<!-- 文件信息 -->
<div id="fileInfo" style="display: none;">请上传歌曲开始创作</div>
<!-- 波形区域 -->
<div class="waveform-section">
<div id="waveform"></div>
<div class="controls">
<button class="control-btn" id="playBtn">▶️ 播放 (空格)</button>
<button class="control-btn primary" id="markBtn">⏱️ 打点</button>
<button class="control-btn danger" id="clearBtn">🗑️ 清空</button>
<div class="time-display" id="currentTime">00:00.000</div>
<div class="time-display" id="duration">00:00.000</div>
<button class="control-btn" id="prevBtn">⏪ 后退1s</button>
<button class="control-btn" id="nextBtn">⏩ 前进1s</button>
</div>
</div>
<!-- 歌词列表区域 -->
<div class="lyrics-section">
<div class="lyrics-header">
<h3>歌词列表</h3>
<span id="lyricCount">0 个条目</span>
</div>
<div id="lyricsList">
<!-- 歌词条目会动态插入这里 -->
</div>
</div>
</div>
<script>
$(document).ready(function() {
let wavesurfer = null;
let lyrics = [];
let currentFile = null;
let saveTimer = null;
// 初始化WaveSurfer
function initWaveSurfer() {
if (wavesurfer) {
wavesurfer.destroy();
}
wavesurfer = WaveSurfer.create({
container: '#waveform',
waveColor: '#667eea',
progressColor: '#764ba2',
cursorColor: '#ff4757',
barWidth: 2,
barRadius: 3,
height: 70,
barGap: 2,
responsive: true
});
// 时间更新
wavesurfer.on('audioprocess', function() {
const time = wavesurfer.getCurrentTime() * 1000;
$('#currentTime').text(formatTime(time));
highlightCurrentLyric(time);
});
// 加载完成
wavesurfer.on('ready', function() {
const duration = wavesurfer.getDuration() * 1000;
$('#duration').text(formatTime(duration));
});
// 播放状态变化
wavesurfer.on('play', function() {
$('#playBtn').text('⏸️ 暂停 (空格)');
});
wavesurfer.on('pause', function() {
$('#playBtn').text('▶️ 播放 (空格)');
});
}
// 格式化时间为 分钟:秒.毫秒
function formatTime(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const milliseconds = Math.floor(ms % 1000);
return `minutes.toString().padStart(2, '0'):seconds.toString().padStart(2, '0').milliseconds.toString().padStart(3, '0')`;
}
// 解析时间字符串为毫秒
function parseTime(timeStr) {
const parts = timeStr.split(':');
const minutes = parseInt(parts[0]);
const secParts = parts[1].split('.');
const seconds = parseInt(secParts[0]);
const milliseconds = parseInt(secParts[1] || 0);
return minutes * 60000 + seconds * 1000 + milliseconds;
}
// 解析LRC时间标签
function parseLrcTime(tag) {
const match = tag.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\]/);
if (match) {
const minutes = parseInt(match[1]);
const seconds = parseInt(match[2]);
const milliseconds = parseInt(match[3].padEnd(3, '0'));
return minutes * 60000 + seconds * 1000 + milliseconds;
}
return null;
}
// 高亮当前播放的歌词
function highlightCurrentLyric(currentTime) {
let activeIndex = -1;
for (let i = 0; i < lyrics.length; i++) {
if (currentTime >= lyrics[i].start && currentTime < lyrics[i].end) {
activeIndex = i;
break;
}
}
$('.lyric-item').removeClass('active');
if (activeIndex >= 0) {
$(`.lyric-item[data-index="activeIndex"]`).addClass('active');
// 滚动到可见区域
const activeItem = $(`.lyric-item[data-index="activeIndex"]`);
if (activeItem.length) {
const container = $('#lyricsList');
const itemTop = activeItem.position().top;
const containerScrollTop = container.scrollTop();
const containerHeight = container.height();
if (itemTop < 0 || itemTop > containerHeight - 40) {
container.scrollTop(containerScrollTop + itemTop - 40);
}
}
}
}
// 自动保存到localStorage
function autoSave() {
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(() => {
const saveData = {
lyrics: lyrics,
fileName: currentFile ? currentFile.name : null
};
localStorage.setItem('lrc_editor_data', JSON.stringify(saveData));
}, 500);
}
// 从localStorage加载
function loadFromStorage() {
const saved = localStorage.getItem('lrc_editor_data');
if (saved) {
try {
const data = JSON.parse(saved);
if (data.lyrics && Array.isArray(data.lyrics)) {
lyrics = data.lyrics;
renderLyrics();
if (data.fileName) {
$('#fileInfo').text(`当前歌曲: data.fileName`).show();
}
}
} catch (e) {
console.error('加载保存数据失败', e);
}
}
}
// 上传按钮点击
$('#uploadBtn').click(function() {
$('#fileInput').click();
});
// 导入LRC按钮点击
$('#importLrcBtn').click(function() {
$('#lrcInput').click();
});
// LRC文件选择
$('#lrcInput').change(function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const content = e.target.result;
const lines = content.split('\n');
const newLyrics = [];
lines.forEach(line => {
line = line.trim();
if (!line) return;
// 提取所有时间标签
const timeTags = line.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\]/g);
if (!timeTags) return;
// 提取歌词文本(去掉所有时间标签)
const text = line.replace(/\[(\d{2}):(\d{2})\.(\d{2,3})\]/g, '').trim();
if (!text) return;
// 为每个时间标签创建歌词条目
timeTags.forEach(tag => {
const time = parseLrcTime(tag);
if (time !== null) {
newLyrics.push({
start: time,
end: time + 5000, // 默认5秒,后面自动调整
text: text
});
}
});
});
// 按时间排序
newLyrics.sort((a, b) => a.start - b.start);
// 调整结束时间
for (let i = 0; i < newLyrics.length; i++) {
if (i < newLyrics.length - 1) {
newLyrics[i].end = newLyrics[i + 1].start;
} else if (wavesurfer) {
newLyrics[i].end = wavesurfer.getDuration() * 1000;
}
}
lyrics = newLyrics;
renderLyrics();
autoSave();
alert(`成功导入 lyrics.length 句歌词`);
};
reader.readAsText(file, 'utf-8');
$(this).val('');
});
// 音频文件选择
$('#fileInput').change(function(e) {
const file = e.target.files[0];
if (!file) return;
currentFile = file;
$('#fileInfo').text(`当前歌曲: file.name ((file.size / 1024 / 1024).toFixed(2) MB)`).show();
// 初始化波形
initWaveSurfer();
wavesurfer.loadBlob(file);
// 调整现有歌词的结束时间
wavesurfer.on('ready', function() {
const duration = wavesurfer.getDuration() * 1000;
if (lyrics.length > 0) {
lyrics[lyrics.length - 1].end = duration;
renderLyrics();
}
});
autoSave();
});
// 后退1秒
$('#prevBtn').click(function() {
if (wavesurfer) {
const current = wavesurfer.getCurrentTime();
wavesurfer.seekTo(Math.max(0, current - 1) / wavesurfer.getDuration());
}
});
// 前进1秒
$('#nextBtn').click(function() {
if (wavesurfer) {
const current = wavesurfer.getCurrentTime();
const duration = wavesurfer.getDuration();
wavesurfer.seekTo(Math.min(duration, current + 1) / duration);
}
});
// 播放/暂停
$('#playBtn').click(function() {
if (wavesurfer) {
wavesurfer.playPause();
}
});
// 键盘快捷键
$(document).keydown(function(e) {
// 不在输入框中时响应快捷键
if ($('input:focus, textarea:focus').length === 0) {
if (e.code === 'Space') {
e.preventDefault();
if (wavesurfer) {
wavesurfer.playPause();
}
} else if (e.code === 'ArrowLeft') {
e.preventDefault();
if (wavesurfer) {
const current = wavesurfer.getCurrentTime();
wavesurfer.seekTo(Math.max(0, current - 1) / wavesurfer.getDuration());
}
} else if (e.code === 'ArrowRight') {
e.preventDefault();
if (wavesurfer) {
const current = wavesurfer.getCurrentTime();
const duration = wavesurfer.getDuration();
wavesurfer.seekTo(Math.min(duration, current + 1) / duration);
}
}
}
});
// 打点
$('#markBtn').click(function() {
if (!wavesurfer) {
alert('请先上传歌曲');
return;
}
const currentTime = wavesurfer.getCurrentTime() * 1000;
// 计算结束时间(下一个打点的时间,或者如果是最后一个则到歌曲结束)
let endTime = wavesurfer.getDuration() * 1000;
if (lyrics.length > 0) {
// 把上一个的结束时间设为当前时间
lyrics[lyrics.length - 1].end = currentTime;
}
// 添加新的歌词条目
lyrics.push({
start: currentTime,
end: endTime,
text: ''
});
renderLyrics();
autoSave();
});
// 渲染歌词列表
function renderLyrics() {
$('#lyricsList').empty();
$('#lyricCount').text(`lyrics.length 个条目`);
lyrics.forEach((item, index) => {
const itemHtml = `
<div class="lyric-item" data-index="index">
<input type="text" class="time-input start-time" value="formatTime(item.start)">
<input type="text" class="time-input end-time" value="formatTime(item.end)">
<input type="text" class="lyric-input" placeholder="输入歌词..." value="item.text.replace(/"/g, '"')">
<div class="item-actions">
<button class="item-btn up" title="上移">↑</button>
<button class="item-btn down" title="下移">↓</button>
<button class="item-btn delete" title="删除">×</button>
</div>
</div>
`;
$('#lyricsList').append(itemHtml);
});
}
// 开始时间修改
$(document).on('change', '.start-time', function() {
const index = $(this).closest('.lyric-item').data('index');
const timeStr = $(this).val();
try {
const time = parseTime(timeStr);
lyrics[index].start = time;
// 同步上一个的结束时间
if (index > 0) {
lyrics[index - 1].end = time;
$(`.lyric-item[data-index="index-1"] .end-time`).val(formatTime(time));
}
autoSave();
} catch (e) {
alert('时间格式错误,请使用 00:00.000 格式');
$(this).val(formatTime(lyrics[index].start));
}
});
// 结束时间修改
$(document).on('change', '.end-time', function() {
const index = $(this).closest('.lyric-item').data('index');
const timeStr = $(this).val();
try {
const time = parseTime(timeStr);
lyrics[index].end = time;
// 同步下一个的开始时间
if (index < lyrics.length - 1) {
lyrics[index + 1].start = time;
$(`.lyric-item[data-index="index+1"] .start-time`).val(formatTime(time));
}
autoSave();
} catch (e) {
alert('时间格式错误,请使用 00:00.000 格式');
$(this).val(formatTime(lyrics[index].end));
}
});
// 歌词修改
$(document).on('input', '.lyric-input', function() {
const index = $(this).closest('.lyric-item').data('index');
lyrics[index].text = $(this).val();
autoSave();
});
// 上移
$(document).on('click', '.up', function() {
const index = $(this).closest('.lyric-item').data('index');
if (index > 0) {
[lyrics[index], lyrics[index - 1]] = [lyrics[index - 1], lyrics[index]];
renderLyrics();
autoSave();
}
});
// 下移
$(document).on('click', '.down', function() {
const index = $(this).closest('.lyric-item').data('index');
if (index < lyrics.length - 1) {
[lyrics[index], lyrics[index + 1]] = [lyrics[index + 1], lyrics[index]];
renderLyrics();
autoSave();
}
});
// 删除
$(document).on('click', '.delete', function() {
const index = $(this).closest('.lyric-item').data('index');
if (confirm('确定要删除这个歌词条目吗?')) {
lyrics.splice(index, 1);
// 修复时间
if (index < lyrics.length && index > 0) {
lyrics[index - 1].end = lyrics[index].start;
}
renderLyrics();
autoSave();
}
});
// 清空所有
$('#clearBtn').click(function() {
if (confirm('确定要清空所有歌词吗?')) {
lyrics = [];
localStorage.removeItem('lrc_editor_data');
renderLyrics();
}
});
// 导出LRC
$('#exportBtn').click(function() {
if (lyrics.length === 0) {
alert('没有歌词可以导出');
return;
}
let lrcContent = '[ti:]\n[ar:]\n[al:]\n[by:LRC歌词创作工具]\n\n';
lyrics.forEach(item => {
if (item.text.trim() === '') return;
const timeStr = formatTime(item.start);
lrcContent += `[timeStr]item.text\n`;
});
// 下载文件
const blob = new Blob([lrcContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (currentFile ? currentFile.name.replace(/\.[^/.]+$/, '') : 'lyrics') + '.lrc';
a.click();
URL.revokeObjectURL(url);
});
// 关闭服务
$('#closeService').click(function() {
if (confirm('确定要关闭后台服务吗?')) {
$.post('/shutdown', function() {
alert('服务已关闭,页面将自动关闭');
window.close();
}).fail(function() {
alert('关闭失败,请手动停止服务');
});
}
});
// 页面加载时读取localStorage
loadFromStorage();
});
</script>
</body>
</html>
Local Markdown Editor with Live Preview - A web-based markdown editor that opens a Flask server and provides real-time editing with live preview, sync scroll...
---
name: xi-markdown
description: Local Markdown Editor with Live Preview - A web-based markdown editor that opens a Flask server and provides real-time editing with live preview, sync scrolling, and toolbars for all markdown tags.
security: local-only
permissions: file-read, file-write, local-network
risk-level: low
---
# XI Markdown Editor 本地Markdown编辑器
**English** | **中文**
## ⚠️ Security Notice / 安全声明
**English:**
This skill runs a **local-only** Flask server for markdown editing. It does NOT:
- Connect to external servers (except CDN for libraries)
- Send data outside your local machine
- Require internet access for core functionality
- Execute arbitrary code
**Security Features:**
- Localhost-only server (127.0.0.1)
- No external network connections
- File operations limited to user-specified paths
- All code is open and inspectable
**中文:**
此技能运行**仅限本地**的Flask服务器进行Markdown编辑。它**不会**:
- 连接到外部服务器(CDN库除外)
- 将数据发送到本地机器之外
- 需要互联网访问核心功能
- 执行任意代码
**安全特性:**
- 仅限本地主机服务器 (127.0.0.1)
- 无外部网络连接
- 文件操作仅限于用户指定的路径
- 所有代码都是开放且可检查的
---
## Overview / 概述
**English:**
A web-based markdown editor that runs locally with a Flask server and provides real-time editing with live preview. Features include sync scrolling between editor and preview, comprehensive markdown toolbar, and direct file saving.
**中文:**
基于Web的Markdown编辑器,本地运行Flask服务器,提供实时编辑和预览功能。包括编辑器与预览同步滚动、完整的Markdown工具栏、直接文件保存。无需安装,就可以在本地直接编辑markdown。
---
## Features / 功能特性
**English:**
- **Real-time Preview**: Edit on left, see live preview on right
- **Sync Scrolling**: Scroll one side, the other follows automatically
- **Toolbar**: Icons for all markdown tags (H1, H2, bold, italic, lists, etc.)
- **File Operations**: Open any markdown file, edit, save back to original file
- **Local Server**: Flask-based backend with RESTful API
- **Responsive Design**: Works on desktop and mobile
- **Dark Theme**: Modern dark UI with white text
- **URL Parameter Support**: Open files via URL parameter `?file=path`
- **Auto Server Shutdown**: Server automatically closes when page is closed
- **Theme Toggle**: Switch between dark and light themes
- **Relative Path Support**: Support for relative paths from workspace
**中文:**
- **实时预览**: 左侧编辑,右侧实时预览
- **同步滚动**: 滚动一侧,另一侧自动跟随
- **工具栏**: 所有Markdown标签图标(H1、H2、粗体、斜体、列表等)
- **文件操作**: 打开任何markdown文件,编辑后保存回原文件
- **本地服务器**: 基于Flask的后端,提供RESTful API
- **响应式设计**: 支持桌面和移动设备
- **深色主题**: 现代深色UI配白色文字
- **URL参数支持**: 通过URL参数 `?file=路径` 打开文件
- **自动服务器关闭**: 页面关闭时服务器自动关闭
- **主题切换**: 在深色和浅色主题间切换
- **相对路径支持**: 支持相对于工作空间的相对路径
---
## Installation & Setup / 安装与设置
### Requirements / 依赖要求
```bash
pip install flask flask-cors markdown
```
### Quick Start / 快速开始
**English:**
```bash
# Method 1: Direct Python command with file parameter
python app.py "path\to\your\file.md" --port 996 --force
# Method 2: Open current skill's documentation
python app.py SKILL.md --port 996 --force
# Method 3: Open empty editor
python app.py --port 996 --force
# Method 4: Open via URL parameter in browser
# After starting server, open in browser:
# http://localhost:996/?file=path/to/your/file.md
# Method 5: Using relative path (from workspace)
python app.py "skills\ace-banana2\SKILL.md" --port 996 --force
```
**中文:**
```bash
# 方法1: 直接Python命令带文件参数
python app.py "文件路径\文件名.md" --port 996 --force
# 方法2: 打开本技能的文档
python app.py SKILL.md --port 996 --force
# 方法3: 打开空编辑器
python app.py --port 996 --force
# 方法4: 通过浏览器URL参数打开
# 启动服务器后,在浏览器中打开:
# http://localhost:996/?file=路径/文件.md
# 方法5: 使用相对路径(相对于工作空间)
python app.py "skills\ace-banana2\SKILL.md" --port 996 --force
```
---
## Usage / 使用方法
### Opening Files / 打开文件
**English:**
1. **Start editor with a file**:
```bash
python app.py "C:\path\to\file.md"
```
2. **Browser automatically opens** with URL parameter:
- `http://localhost:996/?file=C:\path\to\file.md`
- URL parameter automatically loads the file
3. **Alternative method**: Direct URL access
```
http://localhost:996/?file=path/to/file.md
```
- Relative paths are supported (from workspace)
4. **Edit file** with toolbar and see live preview
5. **Click "Save"** button to save changes back to original file
**中文:**
1. **使用文件启动编辑器**:
```bash
python app.py "C:\路径\文件.md"
```
2. **浏览器自动打开**并带URL参数:
- `http://localhost:996/?file=C:\路径\文件.md`
- URL参数自动加载文件
3. **替代方法**:直接URL访问
```
http://localhost:996/?file=路径/文件.md
```
- 支持相对路径(相对于工作空间)
4. **使用工具栏编辑文件**并查看实时预览
5. **点击"保存"按钮**将更改保存回原文件
### Toolbar Features / 工具栏功能
**Icons available:**
- **H1-H6**: Headers 标题 (with number badges in light theme)
- **B**: Bold 粗体
- **I**: Italic 斜体
- **S**: Strikethrough 删除线
- **Q**: Blockquote 引用
- **Code**: Inline code 行内代码
- **</>**: Code block 代码块
- **List**: Unordered list 无序列表
- **1.**: Ordered list 有序列表
- **Link**: Hyperlink 链接
- **Image**: Insert image 插入图片
- **Table**: Insert table 插入表格
- **HR**: Horizontal rule 水平线
- **🌙/☀️**: Theme toggle 主题切换
- **💾**: Save 保存
- **📁**: Open file 打开文件
- **🔌**: Shutdown server 关闭服务器
- **Undo/Redo**: Undo and redo 撤销/重做
---
## File Structure / 文件结构
```
xi-markdown/
├── SKILL.md # This documentation
├── scripts/
│ ├── app.py # Flask server with API (enhanced)
│ ├── index.html # Frontend HTML with JavaScript (redesigned)
│ ├── test.md # Test markdown file
│ └── styles.css # CSS styles (optional)
└── references/
└── api_docs.md # API documentation
```
---
## API Endpoints / API接口
### GET `/`
- Returns the main HTML page
- Supports URL parameter: `?file=path` (opens file via AJAX)
### GET `/api/health`
- Health check endpoint
- Returns: `{"status": "ok", "timestamp": "..."}`
### GET `/api/file`
- Returns current file content
- Query parameter: `path` (file path, supports relative paths)
### POST `/api/file`
- Saves content to file
- JSON body: `{"path": "file_path", "content": "file_content"}`
- Supports relative paths (resolved to workspace)
### GET `/api/preview`
- Converts markdown to HTML for preview
- Query parameter: `markdown` (markdown text)
### GET `/api/open`
- Opens a file in the editor
- Query parameter: `path` (file path, supports relative paths)
### GET `/api/shutdown`
- Shuts down the server
- Called automatically when page closes
---
## Keyboard Shortcuts / 键盘快捷键
- **Ctrl+S**: Save file 保存文件
- **Ctrl+O**: Open file dialog 打开文件对话框
- **Ctrl+Z**: Undo 撤销
- **Ctrl+Y**: Redo 重做
- **Ctrl+B**: Bold 粗体
- **Ctrl+I**: Italic 斜体
- **Ctrl+K**: Insert link 插入链接
---
## New Features / 新功能
### URL Parameter Support / URL参数支持
**English:**
Open files directly via URL parameter:
```
http://localhost:996/?file=skills/xi-markdown/SKILL.md
```
- URL is automatically encoded
- Supports relative paths from workspace
- AJAX loads file content dynamically
**中文:**
通过URL参数直接打开文件:
```
http://localhost:996/?file=skills/xi-markdown/SKILL.md
```
- URL自动编码
- 支持相对于工作空间的相对路径
- AJAX动态加载文件内容
### Auto Server Shutdown / 自动服务器关闭
**English:**
- Server automatically shuts down when page is closed
- Uses `navigator.sendBeacon()` for reliable delivery
- Also supports manual shutdown via 🔌 icon
**中文:**
- 页面关闭时服务器自动关闭
- 使用 `navigator.sendBeacon()` 确保请求送达
- 也支持通过🔌图标手动关闭
### Theme Support / 主题支持
**English:**
- Switch between dark and light themes
- H1-H6 icons show number badges in light theme
- Responsive CSS for both themes
**中文:**
- 在深色和浅色主题间切换
- 浅色主题下H1-H6图标显示数字标记
- 两个主题都有响应式CSS
## Examples / 示例
### Edit a skill's documentation:
```bash
python app.py "skills\a-stock-get\SKILL.md"
# or via URL: http://localhost:996/?file=skills/a-stock-get/SKILL.md
```
### Edit Ace Banana2 skill:
```bash
python app.py "skills\ace-banana2\SKILL.md" --port 996 --force
```
### Edit your own notes:
```bash
python app.py "C:\Users\YourName\notes.md"
```
### Start empty and create new file:
```bash
python app.py
# Then use "Save As" in the editor
```
### Open via URL parameter only:
```bash
# Start server without file
python app.py --port 996 --force
# Then open in browser:
# http://localhost:996/?file=skills/xi-markdown/SKILL.md
```
---
## Notes / 注意事项
**English:**
- The server runs on `localhost:996` by default
- Only one file can be edited at a time
- Changes are auto-saved locally (in browser) every 30 seconds
- Click "Save" to write to original file
- Server automatically closes when page is closed
- Use `Ctrl+C` to manually stop the server if needed
- URL parameters require URL encoding for special characters
**中文:**
- 服务器默认运行在 `localhost:996`
- 一次只能编辑一个文件
- 每30秒自动保存到本地(浏览器存储)
- 点击"保存"将写入原文件
- 页面关闭时服务器自动关闭
- 需要时使用 `Ctrl+C` 手动停止服务器
- URL参数中的特殊字符需要URL编码
---
## Development / 开发
### Adding new features:
1. Edit `app.py` for backend changes
2. Edit `index.html` for frontend changes
3. Add new CSS in `<style>` tags or separate file
4. Test with different markdown files
### Debug mode:
```bash
python app.py --debug
```
---
## Todo / 待办事项
- [x] Basic editor with live preview
- [x] Sync scrolling between editor and preview
- [x] Complete markdown toolbar
- [x] File open/save functionality
- [x] URL parameter support for file loading
- [x] Auto server shutdown on page close
- [x] Theme toggle (dark/light)
- [x] Relative path support
- [ ] Auto-save with configurable interval
- [ ] Multiple file tabs
- [ ] Search and replace
- [ ] Export to PDF/HTML
- [ ] Custom themes
- [ ] Plugin system
- [ ] Image upload and management
- [ ] Keyboard shortcut customization
---
## References / 参考资料
- [Markdown Guide](https://www.markdownguide.org/)
- [Flask Documentation](https://flask.palletsprojects.com/)
- [Showdown.js](https://github.com/showdownjs/showdown) - Markdown parser
- [CodeMirror](https://codemirror.net/) - Text editor component
---
**Version**: 1.2.0
**Last Updated**: 2026-03-14
**Author**: XI Markdown Editor Team
**Email**: [email protected]
**Wechat**: jakeycis
### Changelog / 更新日志
#### v1.2.0 (2026-03-14)
- ✅ Added URL parameter support (`?file=path`)
- ✅ Added auto server shutdown on page close
- ✅ Added theme toggle (dark/light)
- ✅ Added relative path support
- ✅ Fixed H1-H6 icon badges in light theme
- ✅ Enhanced API endpoints
- ✅ Improved user experience
#### v1.1.0 (2026-03-14)
- ✅ Redesigned UI with modern toolbar
- ✅ Added sync scrolling
- ✅ Added comprehensive markdown toolbar
- ✅ Added file operations (open/save)
#### v1.0.0 (2026-03-14)
- ✅ Initial release
- ✅ Basic markdown editor with live preview
- ✅ Flask-based local server
FILE:scripts/app.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Markdown Editor - Flask Server
羲Markdown编辑器 - Flask服务器
A web-based markdown editor with real-time preview and file operations.
基于Web的Markdown编辑器,提供实时预览和文件操作。
"""
import os
import sys
import json
import webbrowser
import argparse
import socket
import time
from datetime import datetime
from flask import Flask, request, jsonify, send_file, render_template_string
from flask_cors import CORS
import markdown
# Fix encoding for Windows
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Create Flask app
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# Global variables
current_file_path = None
file_content_cache = {}
def read_file(filepath):
"""Read file with proper encoding handling"""
try:
if not os.path.exists(filepath):
return ""
# Try different encodings
encodings = ['utf-8', 'gbk', 'gb2312', 'utf-8-sig', 'latin-1']
for encoding in encodings:
try:
with open(filepath, 'r', encoding=encoding) as f:
return f.read()
except UnicodeDecodeError:
continue
# If all encodings fail, try binary read
with open(filepath, 'rb') as f:
return f.read().decode('utf-8', errors='ignore')
except Exception as e:
print(f"Error reading file {filepath}: {e}")
return f"# Error reading file\n\nCannot read file: {filepath}\n\nError: {str(e)}"
def save_file(filepath, content):
"""Save file with UTF-8 encoding"""
try:
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(filepath), exist_ok=True)
# Save with UTF-8 encoding
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
# Update cache
file_content_cache[filepath] = content
return True, "File saved successfully"
except Exception as e:
return False, f"Error saving file: {str(e)}"
def markdown_to_html(markdown_text):
"""Convert markdown to HTML with extensions"""
try:
# Configure markdown extensions
extensions = [
'extra', # Extra features
'codehilite', # Syntax highlighting
'toc', # Table of contents
'tables', # Tables support
'fenced_code', # Fenced code blocks
'nl2br', # Newline to break
'sane_lists', # Sane lists
]
# Convert markdown to HTML
html = markdown.markdown(
markdown_text,
extensions=extensions,
output_format='html5'
)
# Add CSS for code highlighting
html = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; padding: 20px; }}
h1, h2, h3, h4, h5, h6 {{ margin-top: 1.5em; margin-bottom: 0.5em; }}
pre {{ background: #f5f5f5; padding: 15px; border-radius: 5px; overflow: auto; }}
code {{ background: #f5f5f5; padding: 2px 5px; border-radius: 3px; font-family: 'SFMono-Regular', Consolas, monospace; }}
blockquote {{ border-left: 4px solid #ddd; padding-left: 15px; margin-left: 0; color: #666; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #f5f5f5; }}
img {{ max-width: 100%; height: auto; }}
</style>
</head>
<body>
{html}
</body>
</html>
"""
return html
except Exception as e:
return f"<div style='color: red;'>Error converting markdown: {str(e)}</div>"
@app.route('/')
def index():
"""Serve the main HTML page"""
# Read the index.html file
html_path = os.path.join(os.path.dirname(__file__), 'index.html')
if not os.path.exists(html_path):
return "index.html not found", 404
with open(html_path, 'r', encoding='utf-8') as f:
html_content = f.read()
# Check if file parameter is provided in URL
file_param = request.args.get('file')
if file_param:
# If file parameter is in URL, inject it
html_content = html_content.replace(
'const initialFilePath = null;',
f'const initialFilePath = "{file_param}";'
)
elif current_file_path:
# If server started with file, redirect to URL with parameter
import urllib.parse
encoded_path = urllib.parse.quote(current_file_path, safe='')
return f'''
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0; url=/?file={encoded_path}">
</head>
<body>
<p>Redirecting to editor with file: {current_file_path}</p>
<script>
window.location.href = '/?file={encoded_path}';
</script>
</body>
</html>
'''
return html_content
@app.route('/api/file', methods=['GET'])
def get_file():
"""Get file content"""
filepath = request.args.get('path')
if not filepath:
return jsonify({
'success': False,
'error': 'No file path provided'
})
if not os.path.exists(filepath):
return jsonify({
'success': False,
'error': f'File not found: {filepath}'
})
try:
content = read_file(filepath)
return jsonify({
'success': True,
'content': content,
'path': filepath,
'filename': os.path.basename(filepath),
'size': len(content),
'modified': datetime.fromtimestamp(os.path.getmtime(filepath)).isoformat()
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
})
@app.route('/api/file', methods=['POST'])
def save_file_api():
"""Save file content"""
try:
data = request.get_json()
filepath = data.get('path')
content = data.get('content', '')
if not filepath:
return jsonify({
'success': False,
'error': 'No file path provided'
})
# Handle relative paths
if not os.path.isabs(filepath):
# Try to resolve relative to workspace
workspace_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
filepath = os.path.join(workspace_dir, filepath)
success, message = save_file(filepath, content)
return jsonify({
'success': success,
'message': message,
'path': filepath,
'size': len(content),
'saved_at': datetime.now().isoformat()
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
})
@app.route('/api/preview', methods=['GET'])
def preview_markdown():
"""Convert markdown to HTML for preview"""
markdown_text = request.args.get('markdown', '')
try:
html = markdown_to_html(markdown_text)
return jsonify({
'success': True,
'html': html,
'length': len(markdown_text)
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e),
'html': f'<div style="color: red;">Error: {str(e)}</div>'
})
@app.route('/api/open', methods=['GET'])
def open_file():
"""Open a file for editing"""
filepath = request.args.get('path')
if not filepath:
return jsonify({
'success': False,
'error': 'No file path provided'
})
# Handle relative paths
if not os.path.isabs(filepath):
# Try to resolve relative to workspace
workspace_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
filepath = os.path.join(workspace_dir, filepath)
if not os.path.exists(filepath):
return jsonify({
'success': False,
'error': f'File not found: {filepath}'
})
try:
content = read_file(filepath)
# Update global current file
global current_file_path
current_file_path = filepath
return jsonify({
'success': True,
'content': content,
'path': filepath,
'filename': os.path.basename(filepath)
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
})
@app.route('/api/health', methods=['GET'])
def health_check():
"""Health check endpoint"""
return jsonify({
'status': 'healthy',
'service': 'xi-markdown-editor',
'version': '1.0.0',
'timestamp': datetime.now().isoformat(),
'current_file': current_file_path
})
@app.route('/api/shutdown', methods=['GET'])
def shutdown():
"""Shutdown the server"""
import threading
def shutdown_server():
time.sleep(1)
os._exit(0)
threading.Thread(target=shutdown_server).start()
return jsonify({
'success': True,
'message': 'Server shutting down...'
})
@app.route('/api/files', methods=['GET'])
def list_files():
"""List markdown files in a directory"""
directory = request.args.get('directory', '.')
if not os.path.exists(directory):
directory = os.path.dirname(directory) if directory else '.'
if not os.path.exists(directory):
return jsonify({
'success': False,
'error': f'Directory not found: {directory}'
})
try:
files = []
for filename in os.listdir(directory):
filepath = os.path.join(directory, filename)
if os.path.isfile(filepath) and filename.lower().endswith(('.md', '.markdown', '.txt')):
files.append({
'name': filename,
'path': filepath,
'size': os.path.getsize(filepath),
'modified': datetime.fromtimestamp(os.path.getmtime(filepath)).isoformat()
})
# Sort by modified time (newest first)
files.sort(key=lambda x: x['modified'], reverse=True)
return jsonify({
'success': True,
'directory': directory,
'files': files
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
})
def check_port_in_use(port, host='localhost'):
"""Check if a port is already in use"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
result = s.connect_ex((host, port))
return result == 0
except:
return False
def stop_server(port, host='localhost'):
"""Try to stop server on given port"""
try:
import requests
try:
requests.get(f'http://{host}:{port}/api/shutdown', timeout=1)
except:
pass
except:
pass
def main():
"""Main function to start the server"""
parser = argparse.ArgumentParser(
description='XI Markdown Editor - Local markdown editor with live preview',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
app.py 启动空编辑器
app.py file.md 打开指定文件
app.py --port 8080 指定端口
app.py --no-browser 不自动打开浏览器
app.py --debug 调试模式
"""
)
parser.add_argument('file', nargs='?', default=None,
help='Markdown file to open')
parser.add_argument('--port', type=int, default=996,
help='Port to run server on (default: 996)')
parser.add_argument('--host', default='localhost',
help='Host to bind to (default: localhost)')
parser.add_argument('--no-browser', action='store_true',
help='Do not open browser automatically')
parser.add_argument('--debug', action='store_true',
help='Run in debug mode')
parser.add_argument('--force', action='store_true',
help='Force restart even if port in use')
args = parser.parse_args()
# Check if port is already in use
if check_port_in_use(args.port, args.host):
if args.force:
print(f"⚠️ Port {args.port} is in use, trying to stop existing server...")
stop_server(args.port, args.host)
time.sleep(1)
else:
print(f"❌ Port {args.port} is already in use!")
print(f" Use --force to restart or --port to specify different port")
sys.exit(1)
# Set global current file path
global current_file_path
if args.file:
filepath = os.path.abspath(args.file)
if os.path.exists(filepath):
current_file_path = filepath
print(f"📄 Opening file: {filepath}")
else:
print(f"⚠️ File not found: {filepath}")
print(" Starting with empty editor")
current_file_path = None
else:
current_file_path = None
# Open browser if not disabled
if not args.no_browser:
url = f"http://{args.host}:{args.port}"
print(f"🌐 Opening browser: {url}")
webbrowser.open(url)
# Start Flask server
print("🚀 Starting XI Markdown Editor...")
print(f" Host: {args.host}")
print(f" Port: {args.port}")
print(f" Debug: {args.debug}")
print(f" Current file: {current_file_path or 'None'}")
print("\n📋 Available endpoints:")
print(f" {url}/ - Editor interface")
print(f" {url}/api/health - Health check")
print(f" {url}/api/file - File operations")
print(f" {url}/api/preview - Markdown preview")
print(f" {url}/api/shutdown - Shutdown server")
print("\n🛑 Press Ctrl+C to stop server")
print("=" * 60)
try:
app.run(
host=args.host,
port=args.port,
debug=args.debug,
use_reloader=False
)
except KeyboardInterrupt:
print("\n👋 Server stopped by user")
sys.exit(0)
except Exception as e:
print(f"❌ Server error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()
FILE:scripts/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XI Markdown Editor</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.5/purify.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0d1117;
color: #c9d1d9;
height: 100vh;
overflow: hidden;
}
/* Navigation - Redesigned */
.navbar {
background: #161b22;
color: white;
height: 50px;
display: flex;
align-items: center;
padding: 0 15px;
border-bottom: 1px solid #30363d;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.logo-text {
font-size: 18px;
font-weight: bold;
color: #58a6ff;
margin-right:50px;
}
.logo-text small {
font-size: 12px;
color: #8b949e;
margin-left: 5px;
}
.toolbar {
display: flex;
gap: 4px;
flex: 1;
overflow-x: auto;
padding: 0 10px;
}
.toolbar::-webkit-scrollbar {
height: 2px;
}
.toolbar::-webkit-scrollbar-track {
background: #30363d;
}
.toolbar::-webkit-scrollbar-thumb {
background: #58a6ff;
}
.tool-btn {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s;
position: relative;margin-left: 4px;
}
.tool-btn:hover {
background: #30363d;
border-color: #58a6ff;
}
.tool-btn.active {
background: #1f6feb;
border-color: #1f6feb;
color: white;
}
.tool-btn i {
font-size: 14px;
}
/* Header numbers for H1, H2, H3 */
.header-badge {
position: absolute;
bottom: 0px;
right: 0px;
font-size: 8px;
background: #1f6feb;
color: white;
width: 12px;
height: 12px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.action-btn {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s;
}
.action-btn:hover {
background: #30363d;
border-color: #58a6ff;
}
.action-btn.save {
background: #238636;
border-color: #238636;
color: white;
display: none;
}
.action-btn.save:hover {
background: #2ea043;
}
.action-btn.open {
background: #8957e5;
border-color: #8957e5;
color: white;
}
.action-btn.open:hover {
background: #9b70e9;
}
.action-btn.shutdown {
background: #da3633;
border-color: #da3633;
color: white;
}
.action-btn.shutdown:hover {
background: #f85149;
}
.action-btn.theme {
background: #21262d;
border-color: #30363d;
}
.action-btn.theme:hover {
background: #30363d;
}
/* Editor Container */
.editor-container {
display: flex;
height: calc(100vh - 70px); /* 50px navbar + 20px statusbar */
margin-top: 50px;
}
.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #30363d;
background: #0d1117;
}
.preview-pane {
flex: 1;
display: flex;
flex-direction: column;
background: #0d1117;
}
/* Remove pane headers */
.editor-area {
flex: 1;
padding: 20px;
overflow: auto;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 14px;
line-height: 1.6;
background: #0d1117;
color: #c9d1d9;
border: none;
outline: none;
resize: none;
width: 100%;
height: 100%;
}
.editor-area:focus {
outline: none;
}
.preview-area {
flex: 1;
padding: 20px;
overflow: auto;
background: #0d1117;
}
.preview-content {
max-width: 800px;
margin: 0 auto;
}
/* Status Bar */
.status-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 20px;
background: #161b22;
border-top: 1px solid #30363d;
display: flex;
align-items: center;
padding: 0 15px;
font-size: 11px;
color: #8b949e;
z-index: 1000;
}
.status-left {
flex: 1;
display: flex;
align-items: center;
gap: 20px;
}
.status-right {
display: flex;
align-items: center;
gap: 15px;
}
.status-item {
display: flex;
align-items: center;
gap: 5px;
}
.file-status {
color: #8b949e;
}
.file-status.modified {
color: #f85149;
}
.file-status.saved {
color: #238636;
}
.filename-display {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-selector {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.theme-selector i {
font-size: 12px;
}
/* Preview Styling */
.preview-content h1,
.preview-content h2,
.preview-content h3,
.preview-content h4,
.preview-content h5,
.preview-content h6 {
color: #c9d1d9;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.preview-content h1 { font-size: 2em; border-bottom: 1px solid #30363d; padding-bottom: 0.3em; }
.preview-content h2 { font-size: 1.5em; border-bottom: 1px solid #30363d; padding-bottom: 0.3em; }
.preview-content h3 { font-size: 1.25em; }
.preview-content h4 { font-size: 1em; }
.preview-content h5 { font-size: 0.875em; }
.preview-content h6 { font-size: 0.85em; color: #8b949e; }
.preview-content p {
margin: 16px 0;
line-height: 1.6;
}
.preview-content a {
color: #58a6ff;
text-decoration: none;
}
.preview-content a:hover {
text-decoration: underline;
}
.preview-content code {
background: rgba(110, 118, 129, 0.4);
padding: 0.2em 0.4em;
border-radius: 6px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 85%;
}
.preview-content pre {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 16px;
overflow: auto;
margin: 16px 0;
}
.preview-content pre code {
background: none;
padding: 0;
border-radius: 0;
}
.preview-content blockquote {
border-left: 4px solid #30363d;
padding-left: 16px;
margin: 16px 0;
color: #8b949e;
}
.preview-content ul,
.preview-content ol {
padding-left: 2em;
margin: 16px 0;
}
.preview-content li {
margin: 8px 0;
}
.preview-content table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
.preview-content th,
.preview-content td {
border: 1px solid #30363d;
padding: 8px 12px;
text-align: left;
}
.preview-content th {
background: #161b22;
font-weight: 600;
}
.preview-content tr:nth-child(even) {
background: rgba(110, 118, 129, 0.1);
}
.preview-content img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.preview-content hr {
height: 1px;
background: #30363d;
border: none;
margin: 24px 0;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(13, 17, 23, 0.8);
z-index: 2000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 500px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
background: none;
border: none;
color: #8b949e;
font-size: 20px;
cursor: pointer;
padding: 5px;
}
.modal-close:hover {
color: #c9d1d9;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 6px;
color: #8b949e;
font-size: 14px;
}
.form-input {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-family: inherit;
font-size: 14px;
}
.form-input:focus {
outline: none;
border-color: #58a6ff;
}
.form-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 24px;
}
/* Toast */
.toast {
position: fixed;
bottom: 30px;
right: 20px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px 20px;
color: #c9d1d9;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 3000;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success {
border-left: 4px solid #238636;
}
.toast.error {
border-left: 4px solid #f85149;
}
.toast.info {
border-left: 4px solid #1f6feb;
}
.toast i {
font-size: 18px;
}
.toast.success i {
color: #238636;
}
.toast.error i {
color: #f85149;
}
.toast.info i {
color: #1f6feb;
}
/* Loading */
.loading {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(13, 17, 23, 0.8);
z-index: 4000;
align-items: center;
justify-content: center;
}
.loading.active {
display: flex;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid #30363d;
border-top: 3px solid #58a6ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Light Theme */
body.light-theme {
background: #f6f8fa;
color: #24292f;
}
body.light-theme .navbar {
background: #ffffff;
border-bottom: 1px solid #d0d7de;
}
body.light-theme .logo-text {
color: #0969da;
}
body.light-theme .tool-btn {
background: #f6f8fa;
border: 1px solid #d0d7de;
color: #24292f;
}
body.light-theme .tool-btn:hover {
background: #eaeef2;
border-color: #0969da;
}
body.light-theme .action-btn {
background: #f6f8fa;
border: 1px solid #d0d7de;
color: #24292f;
}
body.light-theme .action-btn:hover {
background: #eaeef2;
border-color: #0969da;
}
body.light-theme .status-bar {
background: #ffffff;
border-top: 1px solid #d0d7de;
color: #57606a;
}
body.light-theme .editor-area {
background: #ffffff;
color: #24292f;
}
body.light-theme .preview-area {
background: #ffffff;
}
body.light-theme .preview-content h1,
body.light-theme .preview-content h2,
body.light-theme .preview-content h3,
body.light-theme .preview-content h4,
body.light-theme .preview-content h5,
body.light-theme .preview-content h6 {
color: #24292f;
}
body.light-theme .preview-content a {
color: #0969da;
}
body.light-theme .preview-content code {
background: rgba(175, 184, 193, 0.2);
}
body.light-theme .preview-content pre {
background: #f6f8fa;
border: 1px solid #d0d7de;
}
body.light-theme .preview-content blockquote {
border-left: 4px solid #d0d7de;
color: #57606a;
}
body.light-theme .preview-content th {
background: #f6f8fa;
}
body.light-theme .preview-content tr:nth-child(even) {
background: rgba(234, 238, 242, 0.5);
}
/* Light theme header badge fix */
body.light-theme .header-badge {
background: #000000;
color: #ffffff;
}
/* Responsive */
@media (max-width: 1200px) {
.toolbar {
gap: 3px;
}
}
@media (max-width: 768px) {
.editor-container {
flex-direction: column;
}
.editor-pane,
.preview-pane {
height: 50%;
}
.editor-pane {
border-right: none;
border-bottom: 1px solid #30363d;
}
.filename-display {
max-width: 100px;
}
.modal-content {
width: 95%;
padding: 16px;
}
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar">
<div class="logo-text">
Xi Markdown
</div>
<div class="toolbar" id="toolbar">
<!-- Headers with numbers -->
<button class="tool-btn" data-action="h1" title="Heading 1">
<i class="fas fa-heading"></i>
<span class="header-badge">1</span>
</button>
<button class="tool-btn" data-action="h2" title="Heading 2">
<i class="fas fa-heading"></i>
<span class="header-badge">2</span>
</button>
<button class="tool-btn" data-action="h3" title="Heading 3">
<i class="fas fa-heading"></i>
<span class="header-badge">3</span>
</button>
<!-- Text Formatting -->
<button class="tool-btn" data-action="bold" title="Bold (Ctrl+B)">
<i class="fas fa-bold"></i>
</button>
<button class="tool-btn" data-action="italic" title="Italic (Ctrl+I)">
<i class="fas fa-italic"></i>
</button>
<button class="tool-btn" data-action="strike" title="Strikethrough">
<i class="fas fa-strikethrough"></i>
</button>
<!-- Lists -->
<button class="tool-btn" data-action="ul" title="Unordered List">
<i class="fas fa-list-ul"></i>
</button>
<button class="tool-btn" data-action="ol" title="Ordered List">
<i class="fas fa-list-ol"></i>
</button>
<!-- Code -->
<button class="tool-btn" data-action="code" title="Inline Code">
<i class="fas fa-code"></i>
</button>
<button class="tool-btn" data-action="codeblock" title="Code Block">
<i class="fas fa-file-code"></i>
</button>
<!-- Links & Images -->
<button class="tool-btn" data-action="link" title="Insert Link (Ctrl+K)">
<i class="fas fa-link"></i>
</button>
<button class="tool-btn" data-action="image" title="Insert Image">
<i class="fas fa-image"></i>
</button>
<!-- Block Elements -->
<button class="tool-btn" data-action="quote" title="Blockquote">
<i class="fas fa-quote-right"></i>
</button>
<button class="tool-btn" data-action="hr" title="Horizontal Rule">
<i class="fas fa-minus"></i>
</button>
<button class="tool-btn" data-action="table" title="Insert Table">
<i class="fas fa-table"></i>
</button>
</div>
<div class="action-buttons">
<button class="action-btn open" id="openBtn" title="Open File (Ctrl+O)">
<i class="fas fa-folder-open"></i>
</button>
<button class="action-btn save" id="saveBtn" title="Save File (Ctrl+S)">
<i class="fas fa-save"></i>
</button>
<button class="action-btn theme" id="themeBtn" title="Toggle Theme">
<i class="fas fa-moon"></i>
</button>
<button class="action-btn shutdown" id="shutdownBtn" title="Shutdown Server">
<i class="fas fa-power-off"></i>
</button>
</div>
</nav>
<!-- Editor Container -->
<div class="editor-container">
<!-- Editor Pane -->
<div class="editor-pane">
<textarea
class="editor-area"
id="editor"
placeholder="# 开始编辑您的Markdown文档 使用上方工具栏或快捷键: • Ctrl+B: 粗体 • Ctrl+I: 斜体 • Ctrl+K: 插入链接 • Ctrl+S: 保存 • Ctrl+O: 打开文件"
spellcheck="false"
autofocus
></textarea>
</div>
<!-- Preview Pane -->
<div class="preview-pane">
<div class="preview-area" id="preview">
<div class="preview-content" id="previewContent">
<!-- Preview content will be inserted here -->
</div>
</div>
</div>
</div>
<!-- Status Bar -->
<div class="status-bar">
<div class="status-left">
<div class="status-item" id="editorStatus">
<i class="fas fa-code"></i>
<span>行: 1, 列: 1, 字符: 0</span>
</div>
<div class="status-item file-status" id="fileStatus">
<i class="fas fa-file"></i>
<span>未修改</span>
</div>
</div>
<div class="status-right">
<div class="status-item" id="previewStatus">
<i class="fas fa-eye"></i>
<span>预览已更新</span>
</div>
<div class="status-item filename-display" id="filenameDisplay" title="未打开文件">
<i class="fas fa-file-alt"></i>
<span>未打开文件</span>
</div>
</div>
</div>
<!-- Open File Modal -->
<div class="modal" id="openModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">
<i class="fas fa-folder-open"></i>
<span>打开文件</span>
</div>
<button class="modal-close" id="closeOpenModal">×</button>
</div>
<div class="form-group">
<label class="form-label" for="filePath">文件路径</label>
<input type="text" class="form-input" id="filePath"
placeholder="例如: C:\Users\YourName\document.md">
</div>
<div class="form-group">
<label class="form-label">最近文件</label>
<div class="recent-files" id="recentFiles">
<!-- Recent files will be loaded here -->
</div>
</div>
<div class="form-buttons">
<button class="action-btn open" id="openFileBtn">
<i class="fas fa-folder-open"></i>
打开
</button>
<button class="tool-btn" id="cancelOpenBtn">
取消
</button>
</div>
</div>
</div>
<!-- Toast Notification -->
<div class="toast" id="toast">
<i class="fas fa-info-circle"></i>
<span id="toastMessage">消息</span>
</div>
<!-- Loading Overlay -->
<div class="loading" id="loading">
<div class="spinner"></div>
</div>
<script>
// Global variables
let currentFilePath = null;
let originalContent = '';
let isDirty = false;
let autoSaveTimer = null;
let syncScrollEnabled = true;
let isDarkTheme = true;
// Initialize on DOM loaded
document.addEventListener('DOMContentLoaded', function() {
// Initialize components
initEditor();
initToolbar();
initModals();
initEventListeners();
loadInitialFile();
// Set up auto-save
startAutoSave();
// Show welcome message
showToast('XI Markdown编辑器已就绪', 'info');
});
// Initialize editor
function initEditor() {
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
// Sync scrolling
editor.addEventListener('scroll', function() {
if (syncScrollEnabled) {
const scrollPercent = editor.scrollTop / (editor.scrollHeight - editor.clientHeight);
preview.scrollTop = scrollPercent * (preview.scrollHeight - preview.clientHeight);
}
});
preview.addEventListener('scroll', function() {
if (syncScrollEnabled) {
const scrollPercent = preview.scrollTop / (preview.scrollHeight - preview.clientHeight);
editor.scrollTop = scrollPercent * (editor.scrollHeight - editor.clientHeight);
}
});
// Update editor status
editor.addEventListener('input', function() {
updateEditorStatus();
updatePreview();
markDirty();
});
editor.addEventListener('keyup', updateEditorStatus);
// Keyboard shortcuts
editor.addEventListener('keydown', function(e) {
// Ctrl+S - Save
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
saveFile();
}
// Ctrl+O - Open
if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
showOpenModal();
}
// Ctrl+B - Bold
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
insertText('**bold text**', 4, 13);
}
// Ctrl+I - Italic
if (e.ctrlKey && e.key === 'i') {
e.preventDefault();
insertText('*italic text*', 1, 12);
}
// Ctrl+K - Link
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
insertText('[link text](https://example.com)', 1, 10, 34);
}
});
}
// Initialize toolbar
function initToolbar() {
const toolbar = document.getElementById('toolbar');
const actions = {
h1: { before: '# ', after: '', placeholder: '一级标题' },
h2: { before: '## ', after: '', placeholder: '二级标题' },
h3: { before: '### ', after: '', placeholder: '三级标题' },
bold: { before: '**', after: '**', placeholder: '粗体文字' },
italic: { before: '*', after: '*', placeholder: '斜体文字' },
strike: { before: '~~', after: '~~', placeholder: '删除线文字' },
ul: { before: '- ', after: '', placeholder: '列表项' },
ol: { before: '1. ', after: '', placeholder: '列表项' },
code: { before: '`', after: '`', placeholder: '代码' },
codeblock: { before: '```\n', after: '\n```', placeholder: '代码内容' },
link: { before: '[', middle: '](https://example.com)', after: '', placeholder: '链接文字' },
image: { before: '', after: '', placeholder: '图片描述' },
quote: { before: '> ', after: '', placeholder: '引用文字' },
hr: { before: '\n---\n', after: '', placeholder: '' },
table: {
before: '\n| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| ',
after: ' | Cell 2 | Cell 3 |',
placeholder: 'Cell 1'
}
};
toolbar.addEventListener('click', function(e) {
const btn = e.target.closest('.tool-btn');
if (!btn) return;
const action = btn.dataset.action;
if (!actions[action]) return;
const config = actions[action];
if (config.middle) {
insertText(config.before + config.placeholder + config.middle + config.after,
config.before.length, config.before.length + config.placeholder.length);
} else {
insertText(config.before + config.placeholder + config.after,
config.before.length, config.before.length + config.placeholder.length);
}
});
}
// Initialize modals
function initModals() {
// Open modal
document.getElementById('openBtn').addEventListener('click', showOpenModal);
document.getElementById('closeOpenModal').addEventListener('click', hideOpenModal);
document.getElementById('cancelOpenBtn').addEventListener('click', hideOpenModal);
document.getElementById('openFileBtn').addEventListener('click', openFileFromModal);
// Save button
document.getElementById('saveBtn').addEventListener('click', saveFile);
// Theme toggle
document.getElementById('themeBtn').addEventListener('click', toggleTheme);
// Shutdown button
document.getElementById('shutdownBtn').addEventListener('click', shutdownServer);
// Close modals on outside click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === modal) {
modal.classList.remove('active');
}
});
});
}
// Initialize event listeners
function initEventListeners() {
// Warn before leaving if unsaved changes
window.addEventListener('beforeunload', function(e) {
if (isDirty) {
e.preventDefault();
e.returnValue = '您有未保存的更改。确定要离开吗?';
}
});
// Send shutdown request when page is closing
window.addEventListener('pagehide', function() {
sendShutdownRequest();
});
window.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') {
sendShutdownRequest();
}
});
// Also try to catch browser closing
window.addEventListener('unload', function() {
sendShutdownRequest();
});
}
// Send shutdown request to server
function sendShutdownRequest() {
// Use navigator.sendBeacon for reliable delivery
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/shutdown');
} else {
// Fallback for older browsers
fetch('/api/shutdown', {
method: 'GET',
keepalive: true
}).catch(() => {});
}
}
// Get URL parameter
function getUrlParam(name) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(name);
}
// Load initial file
function loadInitialFile() {
// 1. First check URL parameter
const urlFile = getUrlParam('file');
if (urlFile) {
// Decode and open file from URL
const filePath = decodeURIComponent(urlFile);
console.log('Opening file from URL:', filePath);
openFile(filePath);
return;
}
// 2. Check if there's an initial file path from server
if (typeof initialFilePath !== 'undefined' && initialFilePath !== null) {
openFile(initialFilePath);
}
// 3. Otherwise start with empty editor
showToast('XI Markdown编辑器已就绪', 'info');
}
// Show open modal
function showOpenModal() {
updateRecentFilesList();
document.getElementById('openModal').classList.add('active');
document.getElementById('filePath').focus();
}
// Hide open modal
function hideOpenModal() {
document.getElementById('openModal').classList.remove('active');
}
// Open file from modal
function openFileFromModal() {
const filePath = document.getElementById('filePath').value.trim();
if (!filePath) {
showToast('请输入文件路径', 'error');
return;
}
openFile(filePath);
hideOpenModal();
}
// Open file
function openFile(filePath) {
showLoading();
fetch(`/api/open?path=encodeURIComponent(filePath)`)
.then(response => response.json())
.then(data => {
if (data.success) {
currentFilePath = filePath;
originalContent = data.content;
document.getElementById('editor').value = data.content;
document.getElementById('filenameDisplay').textContent = data.filename;
document.getElementById('filenameDisplay').title = filePath;
updatePreview();
updateEditorStatus();
resetDirty();
showToast(`已打开: data.filename`, 'success');
// Add to recent files
addToRecentFiles(filePath);
} else {
showToast(`打开失败: data.error`, 'error');
}
})
.catch(error => {
showToast(`网络错误: error.message`, 'error');
})
.finally(() => {
hideLoading();
});
}
// Save file
function saveFile() {
if (!currentFilePath) {
showToast('请先打开文件', 'error');
return;
}
const content = document.getElementById('editor').value;
showLoading();
fetch('/api/file', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: currentFilePath,
content: content
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
originalContent = content;
resetDirty();
showToast(`已保存: data.path`, 'success');
} else {
showToast(`保存失败: data.error`, 'error');
}
})
.catch(error => {
showToast(`网络错误: error.message`, 'error');
})
.finally(() => {
hideLoading();
});
}
// Update editor status
function updateEditorStatus() {
const editor = document.getElementById('editor');
const text = editor.value;
const cursorPos = editor.selectionStart;
// Calculate line and column
const lines = text.substring(0, cursorPos).split('\n');
const line = lines.length;
const column = lines[lines.length - 1].length + 1;
// Update status
document.getElementById('editorStatus').innerHTML =
`<i class="fas fa-code"></i><span>行: line, 列: column, 字符: text.length</span>`;
}
// Update preview
function updatePreview() {
const markdown = document.getElementById('editor').value;
fetch(`/api/preview?markdown=encodeURIComponent(markdown)`)
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('previewContent').innerHTML = DOMPurify.sanitize(data.html);
document.getElementById('previewStatus').innerHTML =
`<i class="fas fa-eye"></i><span>预览已更新</span>`;
} else {
document.getElementById('previewContent').innerHTML = `<div style="color: red;">预览错误: data.error</div>`;
document.getElementById('previewStatus').innerHTML =
`<i class="fas fa-exclamation-triangle"></i><span>预览错误</span>`;
}
})
.catch(error => {
document.getElementById('previewContent').innerHTML = `<div style="color: red;">网络错误: error.message</div>`;
document.getElementById('previewStatus').innerHTML =
`<i class="fas fa-exclamation-triangle"></i><span>网络错误</span>`;
});
}
// Mark file as dirty
function markDirty() {
if (!isDirty) {
isDirty = true;
document.getElementById('saveBtn').style.display = 'flex';
document.getElementById('fileStatus').innerHTML =
`<i class="fas fa-exclamation-circle"></i><span class="modified">已修改</span>`;
document.getElementById('fileStatus').className = 'status-item file-status modified';
}
}
// Reset dirty state
function resetDirty() {
isDirty = false;
document.getElementById('saveBtn').style.display = 'none';
document.getElementById('fileStatus').innerHTML =
`<i class="fas fa-check-circle"></i><span class="saved">已保存</span>`;
document.getElementById('fileStatus').className = 'status-item file-status saved';
}
// Insert text at cursor
function insertText(text, selectStart, selectEnd, cursorPos) {
const editor = document.getElementById('editor');
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = editor.value.substring(start, end);
// Replace selected text or insert at cursor
const newText = editor.value.substring(0, start) + text + editor.value.substring(end);
editor.value = newText;
// Set cursor position
if (cursorPos !== undefined) {
editor.setSelectionRange(start + cursorPos, start + cursorPos);
} else if (selectStart !== undefined && selectEnd !== undefined) {
editor.setSelectionRange(start + selectStart, start + selectEnd);
} else {
editor.setSelectionRange(start + text.length, start + text.length);
}
// Update preview
updatePreview();
updateEditorStatus();
markDirty();
editor.focus();
}
// Start auto-save
function startAutoSave() {
// Auto-save every 30 seconds
autoSaveTimer = setInterval(() => {
if (isDirty && currentFilePath) {
// In a real app, this would save to local storage
localStorage.setItem('xi_markdown_autosave_' + currentFilePath,
document.getElementById('editor').value);
console.log('Auto-saved to local storage');
}
}, 30000);
}
// Add to recent files
function addToRecentFiles(filePath) {
let recentFiles = JSON.parse(localStorage.getItem('xi_markdown_recent_files') || '[]');
// Remove if already exists
recentFiles = recentFiles.filter(f => f !== filePath);
// Add to beginning
recentFiles.unshift(filePath);
// Keep only last 10
recentFiles = recentFiles.slice(0, 10);
localStorage.setItem('xi_markdown_recent_files', JSON.stringify(recentFiles));
updateRecentFilesList();
}
// Update recent files list
function updateRecentFilesList() {
const recentFiles = JSON.parse(localStorage.getItem('xi_markdown_recent_files') || '[]');
const container = document.getElementById('recentFiles');
if (recentFiles.length === 0) {
container.innerHTML = '<div style="color: #8b949e; padding: 10px; text-align: center;">无最近文件</div>';
return;
}
let html = '';
recentFiles.forEach(filePath => {
const filename = filePath.split('\\').pop();
html += `
<div class="recent-file" style="padding: 8px 12px; margin: 4px 0; background: #21262d; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
onclick="openFile('filePath.replace(/'/g, "\\'")'); hideOpenModal();">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">filename</span>
<i class="fas fa-external-link-alt" style="color: #8b949e; font-size: 12px;"></i>
</div>
`;
});
container.innerHTML = html;
}
// Toggle theme
function toggleTheme() {
isDarkTheme = !isDarkTheme;
const themeBtn = document.getElementById('themeBtn');
if (isDarkTheme) {
document.body.classList.remove('light-theme');
document.body.classList.add('dark-theme');
themeBtn.innerHTML = '<i class="fas fa-moon"></i>';
themeBtn.title = '切换到浅色主题';
} else {
document.body.classList.remove('dark-theme');
document.body.classList.add('light-theme');
themeBtn.innerHTML = '<i class="fas fa-sun"></i>';
themeBtn.title = '切换到深色主题';
}
}
// Shutdown server
function shutdownServer() {
if (confirm('确定要关闭服务器吗?')) {
showLoading();
fetch('/api/shutdown')
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('服务器正在关闭...', 'info');
setTimeout(() => {
// Try to close the window
if (window.close) {
window.close();
} else {
showToast('服务器已关闭,请手动关闭窗口', 'info');
}
}, 1000);
}
})
.catch(error => {
showToast('无法关闭服务器', 'error');
hideLoading();
});
}
}
// Show toast notification
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
const messageEl = document.getElementById('toastMessage');
messageEl.textContent = message;
toast.className = `toast type`;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// Show loading overlay
function showLoading() {
document.getElementById('loading').classList.add('active');
}
// Hide loading overlay
function hideLoading() {
document.getElementById('loading').classList.remove('active');
}
// Make functions available globally
window.openFile = openFile;
window.hideOpenModal = hideOpenModal;
window.updateRecentFilesList = updateRecentFilesList;
// Initialize recent files list
updateRecentFilesList();
</script>
</body>
</html>Specialized A-share stock data collector. Automatically fetch and store daily/weekly/monthly historical K-line data for all A-share stocks in SQLite database.
---
name: a-stock-get
description: Specialized A-share stock data collector. Automatically fetch and store daily/weekly/monthly historical K-line data for all A-share stocks in SQLite database.
---
# A-Share-Get / A股数据获取工具
**English** | **中文**
---
## Overview / 概述
**English:**
A specialized data collection tool for Chinese A-share market. Automatically fetches and stores stock list, daily, weekly, and monthly historical K-line data into a local SQLite database. Designed specifically for quantitative analysts who need a complete local copy of A-share market data.
**中文:**
专门针对中国A股市场的数据获取工具。自动采集并存储股票列表、日线、周线、月线历史K线数据到本地SQLite数据库。专为需要完整A股本地数据副本的量化分析师设计。
---
## Features / 功能特性
**English:**
- **Automated Stock List Management**: Fetches and updates all tradable stocks from A-share markets (60* Shanghai, 30* ChiNext, 00* Shenzhen)
- **Multi-Frequency Data Collection**: Supports parallel fetching for daily, weekly, and monthly K-line data
- **SQLite Database Storage**: Local persistent storage, easy to query for quantitative analysis
- **Automatic Filtering**: Excludes delisted and pre-IPO stocks automatically
- **Data Integrity**: Tracks last fetch timestamp for each frequency, supports incremental updates
- **Multiple Data Sources**: EastMoney/Sina/Tencent with automatic failover
**中文:**
- **自动化股票列表管理**: 获取并更新A股市场全部可交易股票(沪市60*、创业板30*、深市00*)
- **多频率数据采集**: 支持并行获取日线、周线、月线K线数据
- **SQLite数据库存储**: 本地持久化存储,便于量化分析查询
- **自动过滤**: 自动排除退市和未上市股票
- **数据完整性**: 记录每种频率最后获取时间戳,支持增量更新
- **多数据源冗余**: 东方财富/新浪/腾讯,自动故障切换
---
## Database Schema / 数据库架构
### Stock List Table (stocks)
| Column / 字段 | Type / 类型 | Description / 说明 |
|---------------|-------------|-------------------|
| code | TEXT | Stock code (e.g., 600519) / 股票代码 |
| name | TEXT | Stock name (e.g., 贵州茅台) / 股票名称 |
| market | TEXT | Market type (60/30/00) / 市场类型 |
| day_get | TIMESTAMP | Last daily data fetch time / 最后日线数据获取时间 |
| week_get | TIMESTAMP | Last weekly data fetch time / 最后周线数据获取时间 |
| month_get | TIMESTAMP | Last monthly data fetch time / 最后月线数据获取时间 |
| status | TEXT | Stock status (active/delisted) / 股票状态 |
| created_at | TIMESTAMP | Record creation time / 记录创建时间 |
---
## Installation & Setup / 安装与设置
### Step 1: Database Initialization / 第一步:数据库初始化
**English:**
Run the initialization script to create the database and tables:
```bash
python scripts/init_db.py
```
**中文:**
运行初始化脚本创建数据库和表:
```bash
python scripts/init_db.py
```
### Step 2: Fetch Stock List / 第二步:获取股票列表
**English:**
Fetch all tradable stocks from A-share markets:
```bash
python scripts/fetch_stocks.py
```
**中文:**
从A股市场获取所有可交易股票列表:
```bash
python scripts/fetch_stocks.py
```
### Step 3: Start Data Collection / 第三步:启动数据收集
**English:**
```bash
# Enhanced data fetching with external events
python scripts/day.py get all --limit 10
python scripts/week.py get all --limit 10
python scripts/month.py get all --limit 10
# Traditional usage (fetch all active stocks)
python scripts/day.py
python scripts/week.py
python scripts/month.py
# Database reset and fetch tool
python scripts/db_reset.py reset status
python scripts/db_reset.py fetch day 000001
# Parallel fetching for large batches
python scripts/day_parallel.py
python scripts/week_parallel.py
python scripts/month_parallel.py
```
**中文:**
```bash
# 增强版数据获取(支持外部事件)
python scripts/day.py get all --limit 10
python scripts/week.py get all --limit 10
python scripts/month.py get all --limit 10
# 传统用法(获取所有活跃股票)
python scripts/day.py
python scripts/week.py
python scripts/month.py
# 数据库重置与获取工具
python scripts/db_reset.py reset status
python scripts/db_reset.py fetch day 000001
# 并行获取大量数据
python scripts/day_parallel.py
python scripts/week_parallel.py
python scripts/month_parallel.py
```
---
## File Structure / 文件结构
```
a-stock-get/
├── SKILL.md # This documentation
├── scripts/
│ ├── init_db.py # Database initialization
│ ├── fetch_stocks.py # Fetch stock list from API
│ ├── day.py # Enhanced daily data fetch with external events
│ ├── day_original.py # Original daily data fetch (backup)
│ ├── day_parallel.py # Parallel daily data fetch
│ ├── db_reset.py # Database reset and fetch tool
│ ├── week.py # Enhanced weekly data fetch with external events
│ ├── week_original.py # Original weekly data fetch (backup)
│ ├── week_parallel.py # Parallel weekly data fetch
│ ├── month.py # Enhanced monthly data fetch with external events
│ ├── month_original.py # Original monthly data fetch (backup)
│ ├── month_parallel.py # Parallel monthly data fetch
│ ├── data_validation.py # Data validation and integrity check
│ ├── data_repair.py # Data repair tool
│ ├── schedule_config.py # OpenClaw cron job configuration
│ └── README.md # Detailed usage documentation
├── references/
│ └── data_sources.md # Data source documentation
└── D:\xistock\ # Data directory (external)
└── stock.db # SQLite database
```
---
## Data Sources / 数据来源
**English:**
- **East Money API**: Real-time stock quotes and listing information
- **Sina Finance**: Historical data for technical analysis
- **Tencent Finance**: Alternative data source for redundancy
**中文:**
- **东方财富API**: 实时股票行情和上市信息
- **新浪财经**: 技术分析历史数据
- **腾讯财经**: 冗余备用数据源
---
## Requirements / 依赖要求
```bash
pip install requests
pip install sqlite3
pip install pandas
pip install akshare # Chinese stock data library
```
---
## Notes / 注意事项
**English:**
- Database file is stored at `D:\xistock\stock.db` for data persistence
- Stock list should be updated regularly (e.g., weekly) to capture new listings and delistings
- API rate limits apply when fetching data; parallel mode improves speed
- This system is for research and educational purposes; comply with local regulations for actual trading
**中文:**
- 数据库文件存储在 `D:\xistock\stock.db` 确保数据持久化
- 股票列表应定期更新(如每周)以捕捉新股上市和退市变化
- 获取数据受API速率限制,并行模式提升速度
- 本系统仅用于研究和教育目的;实际交易请遵守当地法规
## Todo / 待办事项
- [x] Add incremental update mode (only fetch stocks not updated today)
- [x] Add external event control to day.py, week.py, and month.py
- [x] Create database reset and fetch tool (db_reset.py)
- [x] Add data validation and integrity checks (data_validation.py)
- [x] Add data repair tool (data_repair.py)
- [x] Add OpenClaw cron job configuration (schedule_config.py)
- [ ] Integrate with OpenClaw heartbeat for scheduled automatic updates
- [ ] Add advanced analytics and reporting features
---
## References / 参考资料
- [AkShare Documentation](https://akshare.readthedocs.io/) - Chinese financial data library
- [SQLite Python Tutorial](https://docs.python.org/3/library/sqlite3.html)
---
**Version**: 1.0.0
**Last Updated**: 2026-03-13
**Author**: jakey
**Email**: [email protected]
**Wechat**: jakeycis
FILE:scripts/data_repair.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Data Repair Tool for XI Stock System
羲股票监控系统数据修复工具
This script repairs common issues found in the stock database and data files.
本脚本修复股票数据库和数据文件中发现的常见问题。
"""
import os
import sys
import sqlite3
import shutil
from datetime import datetime, timedelta
import argparse
# Fix encoding for Windows console
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIRS = {
"day": "D:\\xistock\\day",
"week": "D:\\xistock\\week",
"month": "D:\\xistock\\month"
}
BACKUP_DIR = "D:\\xistock\\backup"
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- 数据库未找到: {DB_PATH}")
print(" 请先运行 init_db.py 和 fetch_stocks.py!")
exit(1)
return sqlite3.connect(DB_PATH)
def create_backup():
"""Create backup of database before repair"""
print("=" * 70)
print("创建数据库备份")
print("=" * 70)
if not os.path.exists(BACKUP_DIR):
os.makedirs(BACKUP_DIR)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_file = os.path.join(BACKUP_DIR, f"stock_backup_{timestamp}.db")
try:
shutil.copy2(DB_PATH, backup_file)
print(f"✅ 数据库备份已创建: {backup_file}")
print(f" 原始文件: {DB_PATH}")
print(f" 备份大小: {os.path.getsize(backup_file):,} 字节")
return backup_file
except Exception as e:
print(f"❌ 备份创建失败: {e}")
return None
def repair_database_structure():
"""Repair database structure issues"""
print("\n" + "=" * 70)
print("修复数据库结构")
print("=" * 70)
conn = get_db_connection()
cursor = conn.cursor()
repairs = []
try:
# 1. Check if table exists, create if not
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='stocks'")
if not cursor.fetchone():
print("⚠️ 表 'stocks' 不存在,正在创建...")
cursor.execute('''
CREATE TABLE stocks (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
market TEXT NOT NULL,
day_get TIMESTAMP,
week_get TIMESTAMP,
month_get TIMESTAMP,
status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
repairs.append("✅ 创建表 'stocks'")
# 2. Check and add missing columns
cursor.execute("PRAGMA table_info(stocks)")
existing_columns = [row[1] for row in cursor.fetchall()]
required_columns = [
('code', 'TEXT PRIMARY KEY'),
('name', 'TEXT NOT NULL'),
('market', 'TEXT NOT NULL'),
('day_get', 'TIMESTAMP'),
('week_get', 'TIMESTAMP'),
('month_get', 'TIMESTAMP'),
('status', 'TEXT DEFAULT "active"'),
('created_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP')
]
for col_name, col_type in required_columns:
if col_name not in existing_columns:
print(f"⚠️ 缺少列 '{col_name}',正在添加...")
try:
cursor.execute(f"ALTER TABLE stocks ADD COLUMN {col_name} {col_type}")
repairs.append(f"✅ 添加列 '{col_name}'")
except Exception as e:
repairs.append(f"❌ 添加列 '{col_name}' 失败: {e}")
# 3. Create indexes if missing
indexes = [
("idx_market", "CREATE INDEX idx_market ON stocks(market)"),
("idx_status", "CREATE INDEX idx_status ON stocks(status)"),
("idx_day_get", "CREATE INDEX idx_day_get ON stocks(day_get)"),
("idx_week_get", "CREATE INDEX idx_week_get ON stocks(week_get)"),
("idx_month_get", "CREATE INDEX idx_month_get ON stocks(month_get)")
]
for idx_name, idx_sql in indexes:
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='index' AND name='{idx_name}'")
if not cursor.fetchone():
print(f"⚠️ 缺少索引 '{idx_name}',正在创建...")
try:
cursor.execute(idx_sql)
repairs.append(f"✅ 创建索引 '{idx_name}'")
except Exception as e:
repairs.append(f"❌ 创建索引 '{idx_name}' 失败: {e}")
conn.commit()
except Exception as e:
repairs.append(f"❌ 数据库结构修复错误: {e}")
conn.rollback()
finally:
conn.close()
return repairs
def repair_duplicate_stocks():
"""Remove duplicate stock entries"""
print("\n" + "=" * 70)
print("修复重复股票记录")
print("=" * 70)
conn = get_db_connection()
cursor = conn.cursor()
repairs = []
try:
# Find duplicates
cursor.execute("""
SELECT code, COUNT(*) as cnt,
GROUP_CONCAT(rowid) as ids
FROM stocks
GROUP BY code
HAVING COUNT(*) > 1
""")
duplicates = cursor.fetchall()
if not duplicates:
repairs.append("✅ 无重复股票记录")
return repairs
print(f"发现 {len(duplicates)} 个重复股票代码")
for code, count, ids in duplicates:
id_list = ids.split(',')
keep_id = id_list[0] # Keep the first record
delete_ids = id_list[1:] # Delete the rest
print(f"🔄 处理重复股票: {code} (出现 {count} 次)")
print(f" 保留记录: {keep_id}")
print(f" 删除记录: {', '.join(delete_ids)}")
# Delete duplicate records
placeholders = ','.join(['?'] * len(delete_ids))
cursor.execute(f"DELETE FROM stocks WHERE rowid IN ({placeholders})", delete_ids)
repairs.append(f"✅ 删除重复股票 {code}: 保留1条,删除{len(delete_ids)}条")
conn.commit()
except Exception as e:
repairs.append(f"❌ 重复记录修复错误: {e}")
conn.rollback()
finally:
conn.close()
return repairs
def repair_missing_timestamps():
"""Set NULL timestamps for old records"""
print("\n" + "=" * 70)
print("修复缺失的时间戳")
print("=" * 70)
conn = get_db_connection()
cursor = conn.cursor()
repairs = []
current_time = datetime.now()
try:
# Define thresholds for each frequency
thresholds = [
('day_get', timedelta(days=1)),
('week_get', timedelta(days=7)),
('month_get', timedelta(days=30))
]
for field, threshold in thresholds:
# Count records with NULL or old timestamps
cursor.execute(f"""
SELECT COUNT(*)
FROM stocks
WHERE {field} IS NULL OR {field} < ?
""", (current_time - threshold,))
count = cursor.fetchone()[0]
if count > 0:
print(f"🔄 修复 {field}: {count} 条记录需要更新")
# Set NULL for old timestamps
cursor.execute(f"""
UPDATE stocks
SET {field} = NULL
WHERE {field} < ?
""", (current_time - threshold,))
repairs.append(f"✅ 修复 {field}: 重置 {count} 条过时记录")
conn.commit()
except Exception as e:
repairs.append(f"❌ 时间戳修复错误: {e}")
conn.rollback()
finally:
conn.close()
return repairs
def repair_data_files():
"""Repair data file issues"""
print("\n" + "=" * 70)
print("修复数据文件")
print("=" * 70)
repairs = []
for frequency, data_dir in DATA_DIRS.items():
print(f"\n检查 {frequency}线数据目录: {data_dir}")
if not os.path.exists(data_dir):
print(f"⚠️ 目录不存在,正在创建: {data_dir}")
os.makedirs(data_dir)
repairs.append(f"✅ 创建目录: {data_dir}")
continue
# Check for empty files
empty_files = []
corrupted_files = []
for filename in os.listdir(data_dir):
if not filename.endswith('.txt'):
continue
filepath = os.path.join(data_dir, filename)
try:
# Check if file is empty
if os.path.getsize(filepath) == 0:
empty_files.append(filename)
continue
# Check file content
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
if len(lines) < 2: # Less than header + 1 data line
corrupted_files.append(filename)
else:
# Check header
if lines[0].strip() != "date,open,close,high,low,change_pct":
corrupted_files.append(filename)
except Exception:
corrupted_files.append(filename)
# Report and repair
if empty_files:
print(f"⚠️ 发现 {len(empty_files)} 个空文件")
for filename in empty_files[:5]: # Show first 5
print(f" - {filename}")
# Option to delete empty files
if len(empty_files) > 0:
repair = input(f"\n删除 {len(empty_files)} 个空文件? (y/n): ")
if repair.lower() == 'y':
for filename in empty_files:
filepath = os.path.join(data_dir, filename)
os.remove(filepath)
repairs.append(f"✅ 删除 {len(empty_files)} 个空文件 ({frequency}线)")
if corrupted_files:
print(f"⚠️ 发现 {len(corrupted_files)} 个损坏文件")
for filename in corrupted_files[:5]:
print(f" - {filename}")
# Option to delete corrupted files
if len(corrupted_files) > 0:
repair = input(f"\n删除 {len(corrupted_files)} 个损坏文件? (y/n): ")
if repair.lower() == 'y':
for filename in corrupted_files:
filepath = os.path.join(data_dir, filename)
os.remove(filepath)
repairs.append(f"✅ 删除 {len(corrupted_files)} 个损坏文件 ({frequency}线)")
if not empty_files and not corrupted_files:
repairs.append(f"✅ {frequency}线数据文件正常")
return repairs
def sync_database_with_files():
"""Sync database with existing data files"""
print("\n" + "=" * 70)
print("同步数据库与数据文件")
print("=" * 70)
repairs = []
conn = get_db_connection()
cursor = conn.cursor()
try:
# Get all stocks from database
cursor.execute("SELECT code, name FROM stocks WHERE status = 'active'")
db_stocks = {row[0]: row[1] for row in cursor.fetchall()}
# Check each frequency directory
for frequency, data_dir in DATA_DIRS.items():
if not os.path.exists(data_dir):
continue
files = [f for f in os.listdir(data_dir) if f.endswith('.txt')]
file_codes = set()
for filename in files:
# Extract code from filename
try:
if '_' in filename:
code = filename.split('_')[-1].replace('.txt', '')
file_codes.add(code)
except:
pass
# Find stocks in database but missing files
missing_files = set(db_stocks.keys()) - file_codes
if missing_files:
print(f"🔄 {frequency}线: {len(missing_files)} 只股票缺少数据文件")
# Mark these stocks as needing update
placeholders = ','.join(['?'] * len(missing_files))
cursor.execute(f"""
UPDATE stocks
SET {frequency}_get = NULL
WHERE code IN ({placeholders})
""", list(missing_files))
repairs.append(f"✅ 标记 {len(missing_files)} 只股票需要更新{frequency}线数据")
conn.commit()
except Exception as e:
repairs.append(f"❌ 同步修复错误: {e}")
conn.rollback()
finally:
conn.close()
return repairs
def generate_repair_report(repairs, backup_file=None):
"""Generate repair report"""
print("\n" + "=" * 70)
print("修复报告")
print("=" * 70)
if backup_file:
print(f"📂 备份文件: {backup_file}")
if not repairs:
print("✅ 无需修复,系统状态良好")
return
print(f"完成 {len(repairs)} 项修复:")
print("-" * 70)
for i, repair in enumerate(repairs, 1):
print(f"{i}. {repair}")
print("-" * 70)
successful = len([r for r in repairs if r.startswith('✅')])
failed = len([r for r in repairs if r.startswith('❌')])
print(f"✅ 成功: {successful} 项")
if failed > 0:
print(f"❌ 失败: {failed} 项")
# Save report
report_file = f"repair_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
with open(report_file, 'w', encoding='utf-8') as f:
f.write(f"XI Stock System Repair Report\n")
f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"备份文件: {backup_file or '无'}\n")
f.write(f"修复项数: {len(repairs)}\n\n")
if repairs:
f.write("修复记录:\n")
for i, repair in enumerate(repairs, 1):
f.write(f"{i}. {repair}\n")
f.write(f"\n总结:\n")
f.write(f" 成功: {successful} 项\n")
f.write(f" 失败: {failed} 项\n")
print(f"\n📄 修复报告已保存: {report_file}")
def main():
"""Main function"""
parser = argparse.ArgumentParser(
description='Data Repair Tool for XI Stock System',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
data_repair.py --fix-all - 修复所有问题(推荐)
data_repair.py --fix-db - 只修复数据库问题
data_repair.py --fix-files - 只修复数据文件问题
data_repair.py --fix-timestamps - 只修复时间戳问题
data_repair.py --fix-duplicates - 只修复重复记录
安全提示:
1. 修复前会自动创建数据库备份
2. 数据文件修复需要确认
3. 建议先运行 data_validation.py 检查问题
"""
)
parser.add_argument('--fix-all', action='store_true',
help='修复所有问题')
parser.add_argument('--fix-db', action='store_true',
help='修复数据库结构问题')
parser.add_argument('--fix-files', action='store_true',
help='修复数据文件问题')
parser.add_argument('--fix-timestamps', action='store_true',
help='修复时间戳问题')
parser.add_argument('--fix-duplicates', action='store_true',
help='修复重复记录问题')
parser.add_argument('--fix-sync', action='store_true',
help='修复数据库与文件同步问题')
parser.add_argument('--no-backup', action='store_true',
help='不创建备份(不推荐)')
args = parser.parse_args()
# If no specific fix specified, default to all
if not any([args.fix_all, args.fix_db, args.fix_files,
args.fix_timestamps, args.fix_duplicates, args.fix_sync]):
args.fix_all = True
all_repairs = []
try:
print("=" * 70)
print("XI Stock System Data Repair Tool")
print("羲股票监控系统数据修复工具")
print("=" * 70)
# Create backup unless disabled
backup_file = None
if not args.no_backup:
backup_file = create_backup()
if not backup_file:
print("⚠️ 备份失败,继续修复? (y/n): ")
if input().lower() != 'y':
return 1
else:
print("⚠️ 警告: 未创建备份,直接进行修复")
confirm = input("继续? (y/n): ")
if confirm.lower() != 'y':
return 1
# Perform repairs based on arguments
if args.fix_all or args.fix_db:
repairs = repair_database_structure()
all_repairs.extend(repairs)
if args.fix_all or args.fix_duplicates:
repairs = repair_duplicate_stocks()
all_repairs.extend(repairs)
if args.fix_all or args.fix_timestamps:
repairs = repair_missing_timestamps()
all_repairs.extend(repairs)
if args.fix_all or args.fix_files:
repairs = repair_data_files()
all_repairs.extend(repairs)
if args.fix_all or args.fix_sync:
repairs = sync_database_with_files()
all_repairs.extend(repairs)
# Generate report
generate_repair_report(all_repairs, backup_file)
print("\n🎉 修复完成!")
print("建议运行以下命令验证修复效果:")
print(" python data_validation.py all")
except KeyboardInterrupt:
print("\n\n- 用户中断")
return 1
except Exception as e:
print(f"\n- 错误: {e}")
import traceback
traceback.print_exc()
return 1
return 0
if __name__ == "__main__":
exit(main())
FILE:scripts/data_validation.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Data Validation and Integrity Check for XI Stock System
羲股票监控系统数据验证与完整性检查
This script validates data integrity and checks for issues in the stock database and data files.
本脚本验证数据完整性并检查股票数据库和数据文件的问题。
"""
import os
import sys
import sqlite3
import json
import csv
from datetime import datetime, timedelta
import argparse
# Fix encoding for Windows console
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIRS = {
"day": "D:\\xistock\\day",
"week": "D:\\xistock\\week",
"month": "D:\\xistock\\month"
}
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- 数据库未找到: {DB_PATH}")
print(" 请先运行 init_db.py 和 fetch_stocks.py!")
exit(1)
return sqlite3.connect(DB_PATH)
def check_database_integrity():
"""Check database integrity and structure"""
print("=" * 70)
print("数据库完整性检查")
print("=" * 70)
conn = get_db_connection()
cursor = conn.cursor()
issues = []
try:
# 1. Check if table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='stocks'")
if not cursor.fetchone():
issues.append("❌ 表 'stocks' 不存在")
else:
print("✅ 表 'stocks' 存在")
# 2. Check table structure
cursor.execute("PRAGMA table_info(stocks)")
columns = [row[1] for row in cursor.fetchall()]
required_columns = ['code', 'name', 'market', 'day_get', 'week_get', 'month_get', 'status', 'created_at']
for col in required_columns:
if col not in columns:
issues.append(f"❌ 缺少列: {col}")
else:
print(f"✅ 列 '{col}' 存在")
# 3. Check total records
cursor.execute("SELECT COUNT(*) FROM stocks")
total = cursor.fetchone()[0]
print(f"📊 总股票记录: {total}")
if total == 0:
issues.append("⚠️ 数据库中没有股票记录")
# 4. Check active stocks
cursor.execute("SELECT COUNT(*) FROM stocks WHERE status = 'active'")
active = cursor.fetchone()[0]
print(f"📈 活跃股票: {active} ({active/total*100:.1f}%)")
# 5. Check timestamp fields
timestamp_fields = ['day_get', 'week_get', 'month_get']
for field in timestamp_fields:
cursor.execute(f"SELECT COUNT(*) FROM stocks WHERE {field} IS NOT NULL")
updated = cursor.fetchone()[0]
print(f"🕒 {field}: {updated} 已更新 ({updated/total*100:.1f}%)")
# 6. Check for duplicate codes
cursor.execute("SELECT code, COUNT(*) FROM stocks GROUP BY code HAVING COUNT(*) > 1")
duplicates = cursor.fetchall()
if duplicates:
for code, count in duplicates:
issues.append(f"❌ 重复股票代码: {code} (出现 {count} 次)")
else:
print("✅ 无重复股票代码")
# 7. Check for invalid market codes
cursor.execute("SELECT DISTINCT market FROM stocks")
markets = [row[0] for row in cursor.fetchall()]
print(f"🏢 市场类型: {', '.join(markets)}")
invalid_markets = [m for m in markets if m not in ['00', '30', '60']]
if invalid_markets:
issues.append(f"❌ 无效市场类型: {invalid_markets}")
except Exception as e:
issues.append(f"❌ 数据库检查错误: {e}")
finally:
conn.close()
return issues
def check_data_files(frequency):
"""Check data files for specific frequency"""
print(f"\n{'='*70}")
print(f"{frequency}线数据文件检查")
print(f"{'='*70}")
data_dir = DATA_DIRS.get(frequency)
if not data_dir or not os.path.exists(data_dir):
return [f"❌ 数据目录不存在: {data_dir or frequency}"]
issues = []
try:
# Count files
files = [f for f in os.listdir(data_dir) if f.endswith('.txt')]
print(f"📁 数据文件数量: {len(files)}")
if len(files) == 0:
issues.append(f"⚠️ {frequency}线数据目录为空")
return issues
# Check first 5 files for format
sample_files = files[:5] if len(files) > 5 else files
for filename in sample_files:
filepath = os.path.join(data_dir, filename)
try:
with open(filepath, 'r', encoding='utf-8') as f:
# Check header
header = f.readline().strip()
if header != "date,open,close,high,low,change_pct":
issues.append(f"❌ 文件头格式错误: {filename}")
continue
# Check data lines
lines = f.readlines()
if not lines:
issues.append(f"⚠️ 空数据文件: {filename}")
continue
# Check line format
for i, line in enumerate(lines[:10], 1): # Check first 10 lines
parts = line.strip().split(',')
if len(parts) != 6:
issues.append(f"❌ 数据行格式错误 {filename}:{i}")
break
# Check if values can be parsed
try:
date_str = parts[0]
float(parts[1]) # open
float(parts[2]) # close
float(parts[3]) # high
float(parts[4]) # low
float(parts[5]) # change_pct
except ValueError:
issues.append(f"❌ 数据值解析错误 {filename}:{i}")
break
print(f"✅ 文件检查通过: {filename}")
except Exception as e:
issues.append(f"❌ 文件读取错误 {filename}: {e}")
# Check file naming consistency
for filename in files[:20]: # Check first 20 files
if '_' not in filename:
issues.append(f"❌ 文件名格式错误: {filename}")
break
print(f"✅ 文件命名格式检查通过")
except Exception as e:
issues.append(f"❌ 数据文件检查错误: {e}")
return issues
def check_database_files_sync():
"""Check synchronization between database and data files"""
print(f"\n{'='*70}")
print("数据库与文件同步检查")
print(f"{'='*70}")
issues = []
conn = get_db_connection()
cursor = conn.cursor()
try:
# Get all active stocks from database
cursor.execute("SELECT code, name FROM stocks WHERE status = 'active'")
db_stocks = {row[0]: row[1] for row in cursor.fetchall()}
# Check each frequency directory
for frequency, data_dir in DATA_DIRS.items():
if not os.path.exists(data_dir):
print(f"⚠️ 目录不存在: {data_dir}")
continue
files = [f for f in os.listdir(data_dir) if f.endswith('.txt')]
file_codes = set()
for filename in files:
# Extract code from filename (format: 名称_代码.txt)
try:
if '_' in filename:
code = filename.split('_')[-1].replace('.txt', '')
file_codes.add(code)
except:
pass
# Find missing files
missing_files = set(db_stocks.keys()) - file_codes
if missing_files:
sample_missing = list(missing_files)[:5]
issues.append(f"⚠️ {frequency}线缺失文件: {len(missing_files)} 只股票 (示例: {', '.join(sample_missing)})")
# Find extra files (not in database)
extra_files = file_codes - set(db_stocks.keys())
if extra_files:
sample_extra = list(extra_files)[:5]
issues.append(f"⚠️ {frequency}线多余文件: {len(extra_files)} 个文件 (示例: {', '.join(sample_extra)})")
print(f"📊 {frequency}线: 数据库 {len(db_stocks)} 只股票, 文件 {len(files)} 个")
except Exception as e:
issues.append(f"❌ 同步检查错误: {e}")
finally:
conn.close()
return issues
def check_timestamp_consistency():
"""Check timestamp consistency and freshness"""
print(f"\n{'='*70}")
print("时间戳一致性检查")
print(f"{'='*70}")
issues = []
conn = get_db_connection()
cursor = conn.cursor()
try:
current_time = datetime.now()
# Check each timestamp field
timestamp_fields = [
('day_get', '日线', timedelta(days=1)),
('week_get', '周线', timedelta(days=7)),
('month_get', '月线', timedelta(days=30))
]
for field, name, threshold in timestamp_fields:
# Get oldest timestamp
cursor.execute(f"""
SELECT code, name, {field}
FROM stocks
WHERE {field} IS NOT NULL
ORDER BY {field} ASC
LIMIT 5
""")
oldest = cursor.fetchall()
if oldest:
oldest_date = datetime.strptime(oldest[0][2], '%Y-%m-%d %H:%M:%S')
age_days = (current_time - oldest_date).days
print(f"📅 {name}最旧数据: {oldest[0][0]} {oldest[0][1]} ({oldest_date})")
print(f" 数据年龄: {age_days} 天")
if age_days > threshold.days:
issues.append(f"⚠️ {name}数据过旧: {age_days} 天")
# Count stocks needing update
cursor.execute(f"SELECT COUNT(*) FROM stocks WHERE {field} IS NULL OR {field} < ?",
(current_time - threshold,))
need_update = cursor.fetchone()[0]
print(f"🔄 需要更新{name}: {need_update} 只股票")
except Exception as e:
issues.append(f"❌ 时间戳检查错误: {e}")
finally:
conn.close()
return issues
def generate_report(issues):
"""Generate validation report"""
print(f"\n{'='*70}")
print("验证报告")
print(f"{'='*70}")
if not issues:
print("🎉 所有检查通过!系统健康状态良好。")
return True
print(f"发现 {len(issues)} 个问题:")
print("-" * 70)
for i, issue in enumerate(issues, 1):
print(f"{i}. {issue}")
print("-" * 70)
# Categorize issues
critical = [i for i in issues if i.startswith('❌')]
warning = [i for i in issues if i.startswith('⚠️')]
if critical:
print(f"❌ 严重问题: {len(critical)} 个")
print(" 建议立即处理这些问题")
if warning:
print(f"⚠️ 警告: {len(warning)} 个")
print(" 建议在方便时处理这些警告")
return len(critical) == 0
def main():
"""Main function"""
parser = argparse.ArgumentParser(
description='Data Validation and Integrity Check for XI Stock System',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
data_validation.py all - 运行所有检查
data_validation.py db - 只检查数据库
data_validation.py files - 只检查数据文件
data_validation.py sync - 检查数据库与文件同步
data_validation.py timestamps - 检查时间戳一致性
"""
)
parser.add_argument('check_type',
choices=['all', 'db', 'files', 'sync', 'timestamps'],
default='all',
help='检查类型')
args = parser.parse_args()
all_issues = []
try:
if args.check_type in ['all', 'db']:
issues = check_database_integrity()
all_issues.extend(issues)
if args.check_type in ['all', 'files']:
for frequency in ['day', 'week', 'month']:
issues = check_data_files(frequency)
all_issues.extend(issues)
if args.check_type in ['all', 'sync']:
issues = check_database_files_sync()
all_issues.extend(issues)
if args.check_type in ['all', 'timestamps']:
issues = check_timestamp_consistency()
all_issues.extend(issues)
# Generate final report
is_healthy = generate_report(all_issues)
# Save report to file
report_file = f"validation_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
with open(report_file, 'w', encoding='utf-8') as f:
f.write(f"XI Stock System Validation Report\n")
f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"检查类型: {args.check_type}\n")
f.write(f"发现问题: {len(all_issues)} 个\n\n")
if all_issues:
f.write("问题列表:\n")
for i, issue in enumerate(all_issues, 1):
f.write(f"{i}. {issue}\n")
else:
f.write("✅ 所有检查通过,系统健康状态良好。\n")
print(f"\n📄 报告已保存: {report_file}")
if not is_healthy:
print(f"\n⚠️ 系统存在严重问题,建议运行修复脚本:")
print(" python data_repair.py --fix-all")
return 1
except KeyboardInterrupt:
print("\n\n- 用户中断")
return 1
except Exception as e:
print(f"\n- 错误: {e}")
import traceback
traceback.print_exc()
return 1
return 0
if __name__ == "__main__":
exit(main())
FILE:scripts/day.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Stock Daily Data Fetcher - Enhanced Version
羲股票监控系统 - 日线数据获取(增强版)
This script fetches daily K-line data for stocks from Tencent Finance API.
本脚本从腾讯财经API获取股票的日K线数据。
新增功能:
day.py get [ac] - 获取股票数据
- ac: 股票代码(支持逗号分隔多个股票)
- all: 获取所有未更新股票
- rand: 随机获取5只未更新股票
- 默认:获取5只未更新股票
"""
import os
import sys
import sqlite3
import requests
import json
import random
import argparse
from datetime import datetime, timedelta
# Fix encoding for Windows console
# 修复Windows控制台编码问题
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\day"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS = 800
FREQ = "day"
DEFAULT_LIMIT = 5
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- 数据库未找到: {DB_PATH}")
print(" 请先运行 init_db.py 和 fetch_stocks.py!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_stocks_by_codes(stock_codes):
"""Get stocks by specific codes"""
conn = get_db_connection()
cursor = conn.cursor()
# 处理逗号分隔的股票代码
codes = [code.strip() for code in stock_codes.split(',')]
# 构建查询
placeholders = ','.join(['?'] * len(codes))
query = f"""
SELECT code, name, market
FROM stocks
WHERE code IN ({placeholders}) AND status = 'active'
ORDER BY code
"""
cursor.execute(query, codes)
stocks = cursor.fetchall()
conn.close()
return stocks
def get_all_stocks_to_update(limit=None):
"""Get all stocks that need to be updated (day_get is NULL or old)"""
conn = get_db_connection()
cursor = conn.cursor()
# 获取当前时间
current_time = datetime.now()
query = """
SELECT code, name, market
FROM stocks
WHERE status = 'active'
AND (day_get IS NULL OR day_get < ?)
ORDER BY day_get ASC NULLS FIRST
"""
cursor.execute(query, (current_time,))
stocks = cursor.fetchall()
conn.close()
# 应用限制
if limit and limit > 0:
stocks = stocks[:limit]
return stocks
def get_random_stocks_to_update(limit=5):
"""Get random stocks that need to be updated"""
conn = get_db_connection()
cursor = conn.cursor()
# 获取当前时间
current_time = datetime.now()
query = """
SELECT code, name, market
FROM stocks
WHERE status = 'active'
AND (day_get IS NULL OR day_get < ?)
"""
cursor.execute(query, (current_time,))
all_stocks = cursor.fetchall()
conn.close()
# 随机选择
if len(all_stocks) > limit:
stocks = random.sample(all_stocks, limit)
else:
stocks = all_stocks
return stocks
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch daily K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS},qfq"
url = f"{BASE_URL}?_var=kline_dayqfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_dayqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqday
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
if 'qfqday' not in stock_data:
print(f"- {stock_code} {stock_name}: No qfqday found")
return None
return stock_data['qfqday']
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def save_to_file(stock_code, stock_name, data):
"""
Save processed data to file, one data point per line
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
# Invalid chars: \ / : * ? " < > |
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Write header
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath
def update_database_last_fetch(stock_code):
"""Update the day_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
UPDATE stocks
SET day_get = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_stocks(stocks):
"""Fetch data for given list of stocks"""
total = len(stocks)
if total == 0:
print("没有需要更新的股票")
return
print("=" * 70)
print("XI Stock Daily Data Fetcher")
print("羲股票监控系统 - 日线数据获取")
print("=" * 70)
print(f"股票数量: {total}")
print(f"数据目录: {DATA_DIR}")
print("-" * 70)
success = 0
failed = 0
for i, (code, name, market) in enumerate(stocks, 1):
print(f"[{i}/{total}] 获取 {code} {name}...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("失败")
failed += 1
continue
# Process data
bars = raw_data
processed = process_data(bars)
if not processed:
print("无数据")
failed += 1
continue
# Save to file
save_to_file(code, name, processed)
# Update database
update_database_last_fetch(code)
print(f"成功 ({len(processed)} 个数据点)")
success += 1
# Add delay to avoid hitting rate limit
if i % 10 == 0:
import time
time.sleep(1)
# Final summary
print("-" * 70)
print("任务完成!")
print("我辛苦了!")
print("-" * 70)
print(f"总股票数: {total}")
print(f"成功获取: {success}")
print(f"失败: {failed}")
print(f"数据保存到: {DATA_DIR}")
print("-" * 70)
print("数据库已更新最后获取时间戳")
print("=" * 70)
def fetch_all_stocks_original():
"""Original function to fetch all stocks (backward compatibility)"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
stocks = cursor.fetchall()
conn.close()
fetch_stocks(stocks)
def main():
"""Main function with command line arguments"""
parser = argparse.ArgumentParser(
description='XI Stock Daily Data Fetcher - Enhanced Version',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
传统用法:
day.py - 获取所有活跃股票数据
增强用法:
day.py get 000001 - 获取单只股票数据
day.py get 000001,000002 - 获取多只股票数据
day.py get all - 获取所有未更新股票
day.py get all --limit 10 - 获取10只未更新股票
day.py get rand - 随机获取5只未更新股票
day.py get rand --limit 3 - 随机获取3只未更新股票
day.py get - 获取5只未更新股票(默认)
"""
)
subparsers = parser.add_subparsers(dest='command', help='命令')
# get command
get_parser = subparsers.add_parser('get', help='获取股票数据')
get_parser.add_argument('ac', nargs='?', default='',
help='股票代码(all/rand/股票代码)')
get_parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
help=f'获取股票数量限制 (默认: {DEFAULT_LIMIT})')
args = parser.parse_args()
try:
if not args.command:
# 传统用法:没有参数时获取所有股票
print("使用传统模式:获取所有活跃股票数据...")
fetch_all_stocks_original()
elif args.command == 'get':
ac = args.ac.strip().lower() if args.ac else ''
limit = args.limit
if not ac:
# 默认:获取5只未更新股票
print(f"未指定股票,获取 {limit} 只未更新股票...")
stocks = get_all_stocks_to_update(limit)
fetch_stocks(stocks)
elif ac == 'all':
# 获取所有未更新股票
if limit > 0:
print(f"获取 {limit} 只未更新股票...")
stocks = get_all_stocks_to_update(limit)
else:
print("获取所有未更新股票...")
stocks = get_all_stocks_to_update()
fetch_stocks(stocks)
elif ac == 'rand':
# 随机获取未更新股票
print(f"随机获取 {limit} 只未更新股票...")
stocks = get_random_stocks_to_update(limit)
fetch_stocks(stocks)
else:
# 获取指定股票代码
print(f"获取指定股票: {ac}")
stocks = get_stocks_by_codes(ac)
if not stocks:
print(f"未找到股票代码: {ac}")
return
fetch_stocks(stocks)
except KeyboardInterrupt:
print("\n\n- 用户中断")
exit(1)
except Exception as e:
print(f"\n- 错误: {e}")
import traceback
traceback.print_exc()
exit(1)
if __name__ == "__main__":
main()
FILE:scripts/day_original.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Stock Daily Data Fetcher
羲股票监控系统 - 日线数据获取
This script fetches daily K-line data for all stocks from Tencent Finance API.
本脚本从腾讯财经API获取所有股票的日K线数据。
- Runs after 15:00 on every trading day
- 在每个工作日15:00后执行
- Saves 800 historical data points per stock
- 每只股票保存800个历史数据点
"""
import os
import sys
import sqlite3
import requests
import json
from datetime import datetime
# Fix encoding for Windows console
# 修复Windows控制台编码问题
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\day"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS = 800
FREQ = "day"
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- Database not found: {DB_PATH}")
print(" Please run init_db.py and fetch_stocks.py first!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_stocks_to_fetch():
"""Get list of stocks that need to be fetched (all active stocks)"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
stocks = cursor.fetchall()
conn.close()
return stocks
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch daily K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS},qfq"
url = f"{BASE_URL}?_var=kline_dayqfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_dayqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqday
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
if 'qfqday' not in stock_data:
print(f"- {stock_code} {stock_name}: No qfqday found")
return None
return stock_data['qfqday']
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def save_to_file(stock_code, stock_name, data):
"""
Save processed data to file, one data point per line
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
# Invalid chars: \ / : * ? " < > |
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Write header
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath
def update_database_last_fetch(stock_code):
"""Update the day_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
UPDATE stocks
SET day_get = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_all_stocks():
"""Fetch data for all stocks"""
stocks = get_stocks_to_fetch()
total = len(stocks)
print("=" * 70)
print("XI Stock Daily Data Fetcher")
print("羲股票监控系统 - 日线数据获取")
print("=" * 70)
print(f"Total active stocks: {total}")
print(f"Data directory: {DATA_DIR}")
print("-" * 70)
success = 0
failed = 0
for i, (code, name, market) in enumerate(stocks, 1):
print(f"[{i}/{total}] Fetching {code} {name}...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("FAILED")
failed += 1
continue
# Process data
bars = raw_data
processed = process_data(bars)
if not processed:
print("NO DATA")
failed += 1
continue
# Save to file
save_to_file(code, name, processed)
# Update database
update_database_last_fetch(code)
print(f"OK ({len(processed)} points)")
success += 1
# Add delay to avoid hitting rate limit
if i % 10 == 0:
import time
time.sleep(1)
# Final summary
print("-" * 70)
print("任务完成!")
print("我辛苦了!")
print("-" * 70)
print(f"Total stocks: {total}")
print(f"Successfully fetched: {success}")
print(f"Failed: {failed}")
print(f"Data saved to: {DATA_DIR}")
print("-" * 70)
print("Database updated with last fetch timestamps")
print("=" * 70)
if __name__ == "__main__":
try:
fetch_all_stocks()
except KeyboardInterrupt:
print("\n\n- Interrupted by user")
exit(1)
except Exception as e:
print(f"\n- Error: {e}")
import traceback
traceback.print_exc()
exit(1)
FILE:scripts/day_parallel.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
A-Share-Get Daily Data Fetcher - Parallel Incremental Version
A股数据获取 - 日线数据增量获取(并行版本)
Usage:
python scripts/day_parallel.py asc # 获取前一半(升序),每次2-3只
python scripts/day_parallel.py desc # 获取后一半(降序),每次2-3只
Run both in separate processes to speed up incremental updates!
在两个独立进程中同时运行,加速增量更新!
Incremental update logic:
- Read existing file, get latest date
- Only fetch data newer than latest date (add to file, don't overwrite)
- If file doesn't exist, fetch full 800 points
- Process max 100 stocks per run (incremental) / 800 (new file)
- Update database timestamp after successful fetch
"""
import os
import sys
import sqlite3
import requests
import json
from datetime import datetime
# Fix encoding for Windows console
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\day"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS_FULL = 800 # When file doesn't exist
MAX_STOCKS_PER_RUN_INCREMENTAL = 100 # Max stocks to process per run when incremental update
FREQ = "day"
BATCH_SIZE = 3 # Process 2-3 stocks per batch to avoid long runtime
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- Database not found: {DB_PATH}")
print(" Please run init_db.py and fetch_stocks.py first!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_latest_date_from_file(filepath):
"""
Read existing file and get the latest date
Returns None if file doesn't exist or empty
"""
if not os.path.exists(filepath):
return None
try:
with open(filepath, 'r', encoding='utf-8') as f:
# Skip header
next(f)
latest_date = None
# Read all lines to get the last date
for line in f:
line = line.strip()
if not line:
continue
date = line.split(',')[0]
latest_date = date
return latest_date
except Exception as e:
print(f" Warning: Cannot read existing file: {e}")
return None
def get_stocks_to_fetch(order):
"""
Get list of stocks that need to be fetched (incremental)
- Selects stocks that have never been fetched or haven't been fetched recently
- order: 'asc' or 'desc' - split into two partitions
- Returns up to MAX_STOCKS_PER_RUN_INCREMENTAL stocks
"""
conn = get_db_connection()
cursor = conn.cursor()
# Get all active stocks ordered by code
cursor.execute("""
SELECT code, name, market, day_get
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
all_stocks = cursor.fetchall()
total = len(all_stocks)
half = total // 2
# Split into two partitions
if order == 'asc':
partition_stocks = all_stocks[:half]
elif order == 'desc':
all_stocks.reverse()
partition_stocks = all_stocks[:half]
else:
print(f"- Invalid order: {order}. Use 'asc' or 'desc'")
exit(1)
# Filter stocks that need update (any stock that hasn't been fetched today)
today = datetime.now().strftime('%Y-%m-%d')
need_update = []
for stock in partition_stocks:
code, name, market, day_get = stock
# If never fetched or last fetched not today, needs update
if day_get is None or not day_get.startswith(today):
need_update.append((code, name, market))
# Process all need update today, large safety limit to prevent infinite run
# MAX_STOCKS_PER_RUN_INCREMENTAL is original limit, now we allow 10x that
if len(need_update) > MAX_STOCKS_PER_RUN_INCREMENTAL * 10:
need_update = need_update[:MAX_STOCKS_PER_RUN_INCREMENTAL * 10]
print(f"Partition {order}:")
print(f" Total in partition: {len(partition_stocks)}")
print(f" Need update today: {len(need_update)}")
print(f" Will process all {len(need_update)} stocks (safety limit: {MAX_STOCKS_PER_RUN_INCREMENTAL * 10})")
conn.close()
return need_update
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch daily K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS_FULL},qfq"
url = f"{BASE_URL}?_var=kline_dayqfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_dayqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqday
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
if 'qfqday' not in stock_data:
print(f"- {stock_code} {stock_name}: No qfqday found")
return None
return stock_data['qfqday']
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def get_data_points_to_fetch(processed_new, latest_date):
"""
Filter new data points that are after latest_date in existing file
If latest_date is None, return all
"""
if latest_date is None:
return processed_new, DATA_POINTS_FULL
# Find all data points after latest_date
new_data = []
for item in processed_new:
date = item[0]
if date > latest_date:
new_data.append(item)
return new_data, len(processed_new)
def append_to_file(stock_code, stock_name, new_data, latest_date):
"""
Append new data points to existing file, don't overwrite
If file doesn't exist, create new with header
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Filter new data (only data after latest_date)
data_to_write, total_fetched = get_data_points_to_fetch(new_data, latest_date)
if not data_to_write:
# No new data to add
return filepath, 0
if latest_date is None:
# Create new file
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data_to_write:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
else:
# Append to existing file
with open(filepath, 'a', encoding='utf-8') as f:
for item in data_to_write:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath, len(data_to_write)
def update_database_last_fetch(stock_code):
"""Update the day_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
UPDATE stocks
SET day_get = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_all_stocks(order):
"""Fetch data incrementally for stocks in this partition - process {BATCH_SIZE} per run"""
stocks = get_stocks_to_fetch(order)
total_need_update = len(stocks)
print("=" * 70)
print(f"A-Share-Get Daily Data Fetcher - Incremental Parallel ({order})")
print("A股数据获取 - 日线增量更新(并行)")
print("=" * 70)
print(f"Data directory: {DATA_DIR}")
print(f"Processing {BATCH_SIZE} stocks per run (max {MAX_STOCKS_PER_RUN_INCREMENTAL} total)")
print("-" * 70)
success_total = 0
failed_total = 0
new_points_total = 0
# Process in batches of BATCH_SIZE
from itertools import islice
def batch_generator(iterable, batch_size):
iterator = iter(iterable)
for first in iterator:
yield [first] + list(islice(iterator, batch_size - 1))
for batch_idx, batch in enumerate(batch_generator(stocks, BATCH_SIZE), 1):
print(f"\n>> Batch {batch_idx}, processing {len(batch)} stocks:")
for i, (code, name, market) in enumerate(batch, 1):
global_i = (batch_idx - 1) * BATCH_SIZE + i
print(f" [{global_i}/{total_need_update}] {code} {name}: ", end=' ')
sys.stdout.flush()
# Get existing file's latest date
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{code}.txt"
filepath = os.path.join(DATA_DIR, filename)
latest_date = get_latest_date_from_file(filepath)
if latest_date:
print(f"latest={latest_date}, fetching new data...", end=' ')
else:
print("new file, fetching full data...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("FAILED")
failed_total += 1
continue
# Process data
processed = process_data(raw_data)
if not processed:
print("NO DATA")
failed_total += 1
continue
# Append new data to file (only add missing)
filepath, new_count = append_to_file(code, name, processed, latest_date)
if new_count == 0:
print("no new data")
# Still update database to mark as updated today
update_database_last_fetch(code)
continue
# Update database
update_database_last_fetch(code)
print(f"OK added {new_count} new points")
success_total += 1
new_points_total += new_count
# Add delay to avoid hitting rate limit
import time
time.sleep(1.5)
# Final summary
print("-" * 70)
print("增量更新完成!")
print("-" * 70)
print(f"Partition ({order}):")
print(f" - Need update: {total_need_update}")
print(f" - Successfully processed: {success_total}")
print(f" - Failed: {failed_total}")
print(f" - New data points added: {new_points_total}")
print(f"Data directory: {DATA_DIR}")
print("-" * 70)
print("Database updated with last fetch timestamps")
print("=" * 70)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage:")
print(" python scripts/day_parallel.py asc # First half (ascending)")
print(" python scripts/day_parallel.py desc # Second half (descending)")
print("\nRun both in separate processes for parallel fetching!")
exit(1)
order = sys.argv[1].lower()
try:
fetch_all_stocks(order)
except KeyboardInterrupt:
print("\n\n- Interrupted by user")
exit(1)
except Exception as e:
print(f"\n- Error: {e}")
import traceback
traceback.print_exc()
exit(1)
FILE:scripts/db_reset.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Database Reset and Fetch Tool for XI Stock System
羲股票监控系统数据库重置与数据获取工具
This script resets specific timestamp fields and fetches data for stocks.
本脚本重置数据库中的特定时间戳字段并获取股票数据。
"""
import os
import sys
import sqlite3
import argparse
import requests
import json
import random
from datetime import datetime, timedelta
# Fix encoding for Windows console
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS = 800
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- 数据库未找到: {DB_PATH}")
print(" 请先运行 init_db.py 和 fetch_stocks.py!")
exit(1)
return sqlite3.connect(DB_PATH)
# ==================== RESET FUNCTIONS ====================
def reset_day_get():
"""Reset day_get field for all stocks"""
conn = get_db_connection()
cursor = conn.cursor()
try:
cursor.execute("""
UPDATE stocks
SET day_get = NULL
""")
affected_rows = cursor.rowcount
conn.commit()
print("=" * 70)
print("重置 day_get 字段")
print("=" * 70)
print(f"已重置 {affected_rows} 只股票的 day_get 字段为 NULL")
print("所有股票现在都标记为需要更新日线数据")
print("=" * 70)
except Exception as e:
print(f"- 重置失败: {e}")
conn.rollback()
finally:
conn.close()
def reset_week_get():
"""Reset week_get field for all stocks"""
conn = get_db_connection()
cursor = conn.cursor()
try:
cursor.execute("""
UPDATE stocks
SET week_get = NULL
""")
affected_rows = cursor.rowcount
conn.commit()
print("=" * 70)
print("重置 week_get 字段")
print("=" * 70)
print(f"已重置 {affected_rows} 只股票的 week_get 字段为 NULL")
print("所有股票现在都标记为需要更新周线数据")
print("=" * 70)
except Exception as e:
print(f"- 重置失败: {e}")
conn.rollback()
finally:
conn.close()
def reset_month_get():
"""Reset month_get field for all stocks"""
conn = get_db_connection()
cursor = conn.cursor()
try:
cursor.execute("""
UPDATE stocks
SET month_get = NULL
""")
affected_rows = cursor.rowcount
conn.commit()
print("=" * 70)
print("重置 month_get 字段")
print("=" * 70)
print(f"已重置 {affected_rows} 只股票的 month_get 字段为 NULL")
print("所有股票现在都标记为需要更新月线数据")
print("=" * 70)
except Exception as e:
print(f"- 重置失败: {e}")
conn.rollback()
finally:
conn.close()
def reset_all():
"""Reset all timestamp fields for all stocks"""
conn = get_db_connection()
cursor = conn.cursor()
try:
cursor.execute("""
UPDATE stocks
SET day_get = NULL,
week_get = NULL,
month_get = NULL
""")
affected_rows = cursor.rowcount
conn.commit()
print("=" * 70)
print("重置所有时间戳字段")
print("=" * 70)
print(f"已重置 {affected_rows} 只股票的所有时间戳字段")
print("重置字段: day_get, week_get, month_get")
print("所有股票现在都标记为需要更新全部数据")
print("=" * 70)
except Exception as e:
print(f"- 重置失败: {e}")
conn.rollback()
finally:
conn.close()
def show_status():
"""Show current database status"""
conn = get_db_connection()
cursor = conn.cursor()
try:
# Total stocks
cursor.execute("SELECT COUNT(*) FROM stocks")
total = cursor.fetchone()[0]
# Stocks with day_get
cursor.execute("SELECT COUNT(*) FROM stocks WHERE day_get IS NOT NULL")
day_updated = cursor.fetchone()[0]
# Stocks with week_get
cursor.execute("SELECT COUNT(*) FROM stocks WHERE week_get IS NOT NULL")
week_updated = cursor.fetchone()[0]
# Stocks with month_get
cursor.execute("SELECT COUNT(*) FROM stocks WHERE month_get IS NOT NULL")
month_updated = cursor.fetchone()[0]
print("=" * 70)
print("数据库状态报告")
print("=" * 70)
print(f"总股票数: {total}")
print(f"日线数据已更新: {day_updated} ({day_updated/total*100:.1f}%)")
print(f"周线数据已更新: {week_updated} ({week_updated/total*100:.1f}%)")
print(f"月线数据已更新: {month_updated} ({month_updated/total*100:.1f}%)")
print("=" * 70)
except Exception as e:
print(f"- 查询失败: {e}")
finally:
conn.close()
# ==================== FETCH FUNCTIONS ====================
def get_stocks_by_codes(stock_codes):
"""Get stocks by specific codes"""
conn = get_db_connection()
cursor = conn.cursor()
# 处理逗号分隔的股票代码
codes = [code.strip() for code in stock_codes.split(',')]
# 构建查询
placeholders = ','.join(['?'] * len(codes))
query = f"""
SELECT code, name, market
FROM stocks
WHERE code IN ({placeholders}) AND status = 'active'
ORDER BY code
"""
cursor.execute(query, codes)
stocks = cursor.fetchall()
conn.close()
return stocks
def get_all_stocks_to_update(frequency, limit=None):
"""Get all stocks that need to be updated for specific frequency"""
conn = get_db_connection()
cursor = conn.cursor()
# 获取当前时间
current_time = datetime.now()
# 根据频率确定字段名
if frequency == 'day':
timestamp_field = 'day_get'
elif frequency == 'week':
timestamp_field = 'week_get'
elif frequency == 'month':
timestamp_field = 'month_get'
else:
timestamp_field = 'day_get'
query = f"""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
AND ({timestamp_field} IS NULL OR {timestamp_field} < ?)
ORDER BY {timestamp_field} ASC NULLS FIRST
"""
cursor.execute(query, (current_time,))
stocks = cursor.fetchall()
conn.close()
# 应用限制
if limit and limit > 0:
stocks = stocks[:limit]
return stocks
def get_random_stocks_to_update(frequency, limit=5):
"""Get random stocks that need to be updated for specific frequency"""
conn = get_db_connection()
cursor = conn.cursor()
# 获取当前时间
current_time = datetime.now()
# 根据频率确定字段名
if frequency == 'day':
timestamp_field = 'day_get'
elif frequency == 'week':
timestamp_field = 'week_get'
elif frequency == 'month':
timestamp_field = 'month_get'
else:
timestamp_field = 'day_get'
query = f"""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
AND ({timestamp_field} IS NULL OR {timestamp_field} < ?)
"""
cursor.execute(query, (current_time,))
all_stocks = cursor.fetchall()
conn.close()
# 随机选择
if len(all_stocks) > limit:
stocks = random.sample(all_stocks, limit)
else:
stocks = all_stocks
return stocks
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name, frequency):
"""Fetch K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{frequency},,,{DATA_POINTS},qfq"
url = f"{BASE_URL}?_var=kline_{frequency}qfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_{frequency}qfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfq{frequency}
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
data_key = f"qfq{frequency}"
if data_key not in stock_data:
print(f"- {stock_code} {stock_name}: No {data_key} found")
return None
return stock_data[data_key]
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def save_to_file(stock_code, stock_name, data, frequency):
"""
Save processed data to file, one data point per line
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# 确定数据目录
if frequency == 'day':
data_dir = "D:\\xistock\\day"
elif frequency == 'week':
data_dir = "D:\\xistock\\week"
elif frequency == 'month':
data_dir = "D:\\xistock\\month"
else:
data_dir = "D:\\xistock\\unknown"
# Create directory if not exists
if not os.path.exists(data_dir):
os.makedirs(data_dir)
# Sanitize filename - remove all invalid Windows filename characters
# Invalid chars: \ / : * ? " < > |
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(data_dir, filename)
# Write header
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath
def update_database_last_fetch(stock_code, frequency):
"""Update the timestamp field in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 根据频率确定字段名
if frequency == 'day':
timestamp_field = 'day_get'
elif frequency == 'week':
timestamp_field = 'week_get'
elif frequency == 'month':
timestamp_field = 'month_get'
else:
timestamp_field = 'day_get'
cursor.execute(f"""
UPDATE stocks
SET {timestamp_field} = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_stocks_data(stocks, frequency, data_type):
"""Fetch data for given list of stocks"""
total = len(stocks)
if total == 0:
print("没有需要更新的股票")
return
print("=" * 70)
print(f"XI Stock {data_type.capitalize()} Data Fetcher")
print(f"羲股票监控系统 - {data_type}数据获取")
print("=" * 70)
print(f"股票数量: {total}")
print(f"数据频率: {frequency}")
print("-" * 70)
success = 0
failed = 0
for i, (code, name, market) in enumerate(stocks, 1):
print(f"[{i}/{total}] 获取 {code} {name}...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name, frequency)
if raw_data is None:
print("失败")
failed += 1
continue
# Process data
bars = raw_data
processed = process_data(bars)
if not processed:
print("无数据")
failed += 1
continue
# Save to file
save_to_file(code, name, processed, frequency)
# Update database
update_database_last_fetch(code, frequency)
print(f"成功 ({len(processed)} 个数据点)")
success += 1
# Add delay to avoid hitting rate limit
if i % 10 == 0:
import time
time.sleep(1)
# Final summary
print("-" * 70)
print("任务完成!")
print("-" * 70)
print(f"总股票数: {total}")
print(f"成功获取: {success}")
print(f"失败: {failed}")
print("-" * 70)
print("数据库已更新最后获取时间戳")
print("=" * 70)
def fetch_day_data(action, limit=None):
"""Fetch day data based on action"""
if action == 'all':
print("获取所有未更新日线股票...")
stocks = get_all_stocks_to_update('day', limit)
elif action == 'rand':
limit = limit or 5
print(f"随机获取 {limit} 只未更新日线股票...")
stocks = get_random_stocks_to_update('day', limit)
else:
print(f"获取指定日线股票: {action}")
stocks = get_stocks_by_codes(action)
if not stocks:
print(f"未找到股票代码: {action}")
return
fetch_stocks_data(stocks, 'day', '日线')
def fetch_week_data(action, limit=None):
"""Fetch week data based on action"""
if action == 'all':
print("获取所有未更新周线股票...")
stocks = get_all_stocks_to_update('week', limit)
elif action == 'rand':
limit = limit or 5
print(f"随机获取 {limit} 只未更新周线股票...")
stocks = get_random_stocks_to_update('week', limit)
else:
print(f"获取指定周线股票: {action}")
stocks = get_stocks_by_codes(action)
if not stocks:
print(f"未找到股票代码: {action}")
return
fetch_stocks_data(stocks, 'week', '周线')
def fetch_month_data(action, limit=None):
"""Fetch month data based on action"""
if action == 'all':
print("获取所有未更新月线股票...")
stocks = get_all_stocks_to_update('month', limit)
elif action == 'rand':
limit = limit or 5
print(f"随机获取 {limit} 只未更新月线股票...")
stocks = get_random_stocks_to_update('month', limit)
else:
print(f"获取指定月线股票: {action}")
stocks = get_stocks_by_codes(action)
if not stocks:
print(f"未找到股票代码: {action}")
return
fetch_stocks_data(stocks, 'month', '月线')
def main():
"""Main function"""
parser = argparse.ArgumentParser(
description='Database Reset and Fetch Tool for XI Stock System',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
重置功能:
db_reset.py reset day - 重置所有股票的day_get字段
db_reset.py reset week - 重置所有股票的week_get字段
db_reset.py reset month - 重置所有股票的month_get字段
db_reset.py reset all - 重置所有时间戳字段
db_reset.py reset status - 显示数据库状态
获取功能:
db_reset.py fetch day 000001 - 获取单只日线股票数据
db_reset.py fetch day 000001,000002 - 获取多只日线股票数据
db_reset.py fetch day all - 获取所有未更新日线股票
db_reset.py fetch day all --limit 10 - 获取10只未更新日线股票
db_reset.py fetch day rand - 随机获取5只未更新日线股票
db_reset.py fetch day rand --limit 3 - 随机获取3只未更新日线股票
db_reset.py fetch week ... - 周线数据获取
db_reset.py fetch month ... - 月线数据获取
注意事项:
1. 重置后会标记所有股票为需要更新状态
2. 请谨慎操作,建议在非交易时段进行
3. 重置后运行相应的数据获取脚本更新数据
"""
)
subparsers = parser.add_subparsers(dest='command', help='命令', required=True)
# reset command
reset_parser = subparsers.add_parser('reset', help='重置数据库字段')
reset_parser.add_argument('action',
choices=['day', 'week', 'month', 'all', 'status'],
help='执行的操作')
# fetch command
fetch_parser = subparsers.add_parser('fetch', help='获取股票数据')
fetch_parser.add_argument('frequency',
choices=['day', 'week', 'month'],
help='数据频率')
fetch_parser.add_argument('action',
help='股票代码(all/rand/股票代码)')
fetch_parser.add_argument('--limit', type=int,
help='获取股票数量限制')
args = parser.parse_args()
try:
if args.command == 'reset':
if args.action == 'day':
reset_day_get()
elif args.action == 'week':
reset_week_get()
elif args.action == 'month':
reset_month_get()
elif args.action == 'all':
reset_all()
elif args.action == 'status':
show_status()
elif args.command == 'fetch':
if args.frequency == 'day':
fetch_day_data(args.action, args.limit)
elif args.frequency == 'week':
fetch_week_data(args.action, args.limit)
elif args.frequency == 'month':
fetch_month_data(args.action, args.limit)
except KeyboardInterrupt:
print("\n\n- 用户中断")
exit(1)
except Exception as e:
print(f"\n- 错误: {e}")
import traceback
traceback.print_exc()
exit(1)
if __name__ == "__main__":
main()
FILE:scripts/fetch_stocks.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Stock Fetcher - Stock List Acquisition
羲股票监控系统 - 股票列表获取
This script fetches tradable stocks from A-share markets (60*, 30*, 00*)
and stores them in the database, excluding delisted and pre-IPO stocks.
本脚本从A股市场获取可交易股票(60*、30*、00*),并排除退市和未上市股票。
"""
import sqlite3
import os
import sys
import time
from datetime import datetime
try:
import akshare as ak
import pandas as pd
except ImportError:
print("- Required packages not installed!")
print(" Please run: pip install akshare pandas")
sys.exit(1)
# Database path
DB_PATH = "D:\\xistock\\stock.db"
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"✗ Database not found: {DB_PATH}")
print(" Please run init_db.py first!")
sys.exit(1)
return sqlite3.connect(DB_PATH)
def fetch_stock_list():
"""
Fetch tradable A-share stocks from Shanghai and Shenzhen markets
获取沪市和深市可交易的A股股票
"""
print("=" * 60)
print("Fetching A-Share Stock List")
print("获取A股股票列表")
print("=" * 60)
try:
# Get Shanghai A-shares (60*)
print("\n[1/3] Fetching Shanghai A-shares (60*)...")
sh_stocks = ak.stock_info_sh_name_code()
sh_stocks = sh_stocks[sh_stocks['SECUCODE'].str.startswith('60')]
print(f" + Found {len(sh_stocks)} Shanghai stocks")
# Get Shenzhen A-shares (00* and 30*)
print("\n[2/3] Fetching Shenzhen A-shares (00* and 30*)...")
sz_stocks = ak.stock_info_sz_name_code()
sz_stocks_00 = sz_stocks[sz_stocks['A股代码'].astype(str).str.startswith('00')]
sz_stocks_30 = sz_stocks[sz_stocks['A股代码'].astype(str).str.startswith('30')]
print(f" + Found {len(sz_stocks_00)} Shenzhen main board stocks (00*)")
print(f" + Found {len(sz_stocks_30)} Shenzhen ChiNext stocks (30*)")
except Exception as e:
print(f"- Error fetching from akshare: {e}")
print(" Trying alternative method...")
return fetch_stock_list_alternative()
# Combine all stocks
all_stocks = []
# Process Shanghai stocks
for _, stock in sh_stocks.iterrows():
code = str(stock['SECUCODE']).split('.')[0]
name = stock['SECURITY_ABBR']
all_stocks.append({
'code': code,
'name': name,
'market': '60'
})
# Process Shenzhen 00* stocks
for _, stock in sz_stocks_00.iterrows():
code = str(stock['A股代码']).zfill(6)
name = stock['A股简称']
all_stocks.append({
'code': code,
'name': name,
'market': '00'
})
# Process Shenzhen 30* stocks
for _, stock in sz_stocks_30.iterrows():
code = str(stock['A股代码']).zfill(6)
name = stock['A股简称']
all_stocks.append({
'code': code,
'name': name,
'market': '30'
})
print(f"\n[3/3] Total stocks collected: {len(all_stocks)}")
return all_stocks
def fetch_stock_list_alternative():
"""
Alternative method to fetch stock list
备用方法获取股票列表
"""
print("\nUsing alternative data source...")
try:
# Get all A-share stocks
all_stocks_df = ak.stock_info_a_code_name()
all_stocks = []
for _, stock in all_stocks_df.iterrows():
code = str(stock['code']).zfill(6)
name = stock['name']
# Determine market type
if code.startswith('60'):
market = '60'
elif code.startswith('00'):
market = '00'
elif code.startswith('30'):
market = '30'
else:
continue # Skip other markets
all_stocks.append({
'code': code,
'name': name,
'market': market
})
print(f"+ Found {len(all_stocks)} stocks")
return all_stocks
except Exception as e:
print(f"- Alternative method also failed: {e}")
return []
def save_to_database(stocks):
"""
Save stock list to database
保存股票列表到数据库
"""
if not stocks:
print("- No stocks to save!")
return 0
conn = get_db_connection()
cursor = conn.cursor()
# Count existing stocks
cursor.execute("SELECT COUNT(*) FROM stocks")
existing_count = cursor.fetchone()[0]
print(f"\nFound {existing_count} existing stocks in database")
# Insert or update stocks
inserted = 0
updated = 0
for stock in stocks:
try:
# Check if stock already exists
cursor.execute("SELECT code FROM stocks WHERE code = ?", (stock['code'],))
if cursor.fetchone():
# Update existing record
cursor.execute("""
UPDATE stocks
SET name = ?, market = ?, status = 'active'
WHERE code = ?
""", (stock['name'], stock['market'], stock['code']))
updated += 1
else:
# Insert new record
cursor.execute("""
INSERT INTO stocks (code, name, market, status)
VALUES (?, ?, ?, 'active')
""", (stock['code'], stock['name'], stock['market']))
inserted += 1
except sqlite3.Error as e:
print(f"- Error saving stock {stock['code']}: {e}")
continue
conn.commit()
conn.close()
print(f"\n+ Database update completed:")
print(f" - Newly inserted: {inserted}")
print(f" - Updated: {updated}")
print(f" - Total in database: {existing_count + inserted}")
return existing_count + inserted
def show_stock_stats():
"""
Show statistics of stocks in database
显示数据库中的股票统计信息
"""
conn = get_db_connection()
cursor = conn.cursor()
print("\n" + "=" * 60)
print("Current Stock Statistics")
print("当前股票统计信息")
print("=" * 60)
# Total count
cursor.execute("SELECT COUNT(*) FROM stocks WHERE status = 'active'")
total = cursor.fetchone()[0]
print(f"\nTotal active stocks: {total}")
# Count by market
cursor.execute("""
SELECT market, COUNT(*) as count
FROM stocks
WHERE status = 'active'
GROUP BY market
ORDER BY market
""")
print("\nBy market:")
for market, count in cursor.fetchall():
market_name = {
'60': 'Shanghai (沪市)',
'00': 'Shenzhen Main (深市主板)',
'30': 'Shenzhen ChiNext (创业板)'
}.get(market, market)
print(f" - {market_name}: {count}")
# Show sample stocks
print("\nSample stocks:")
cursor.execute("""
SELECT code, name, market FROM stocks
WHERE status = 'active'
ORDER BY RANDOM()
LIMIT 5
""")
for code, name, market in cursor.fetchall():
print(f" - {code} ({market}): {name}")
conn.close()
if __name__ == "__main__":
print("XI Stock Fetcher")
print("羲股票监控系统 - 股票列表获取")
print("=" * 60)
try:
# Check if database exists
if not os.path.exists(DB_PATH):
print(f"- Database not found: {DB_PATH}")
print(" Please run init_db.py first!")
sys.exit(1)
# Fetch stock list
stocks = fetch_stock_list()
if not stocks:
print("- No stocks fetched!")
sys.exit(1)
# Save to database
total = save_to_database(stocks)
# Show statistics
show_stock_stats()
print("\n" + "=" * 60)
print("任务完成!")
print("我辛苦了!")
print("-" * 60)
print(f"成功获取并保存了 {total} 只可交易A股股票")
print("数据已存入数据库: D:\\xistock\\stock.db")
print("=" * 60)
print("+ Stock list acquisition completed successfully!")
print("=" * 60)
except Exception as e:
print(f"\n- Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
FILE:scripts/init_db.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Stock Database Initialization Script
羲股票监控系统数据库初始化脚本
This script initializes the SQLite database and creates the stocks table.
本脚本初始化 SQLite 数据库并创建股票列表表。
"""
import sqlite3
import os
from datetime import datetime
# Database path
DB_DIR = "D:\\xistock"
DB_PATH = os.path.join(DB_DIR, "stock.db")
def init_database():
"""Initialize database and create tables"""
# Create directory if not exists
if not os.path.exists(DB_DIR):
os.makedirs(DB_DIR)
print(f"+ Created directory: {DB_DIR}")
# Connect to database
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Create stocks table
cursor.execute('''
CREATE TABLE IF NOT EXISTS stocks (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
market TEXT NOT NULL,
day_get TIMESTAMP,
week_get TIMESTAMP,
month_get TIMESTAMP,
status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Create index for faster queries
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_market ON stocks(market)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_status ON stocks(status)
''')
conn.commit()
conn.close()
print(f"+ Database initialized successfully: {DB_PATH}")
print("+ Table 'stocks' created with columns:")
print(" - code: Stock code (PRIMARY KEY)")
print(" - name: Stock name")
print(" - market: Market type (60/30/00)")
print(" - day_get: Last daily data fetch time")
print(" - week_get: Last weekly data fetch time")
print(" - month_get: Last monthly data fetch time")
print(" - status: Stock status (active/delisted)")
print(" - created_at: Record creation time")
if __name__ == "__main__":
print("=" * 60)
print("XI Stock Database Initialization")
print("羲股票监控系统数据库初始化")
print("=" * 60)
print(f"Database path: {DB_PATH}")
print("-" * 60)
try:
init_database()
print("-" * 60)
print("任务完成!")
print("我辛苦了!")
print("-" * 60)
print("+ Database initialized at: D:\\xistock\\stock.db")
print("+ Initialization completed successfully!")
except Exception as e:
print(f"- Error: {e}")
exit(1)
FILE:scripts/month.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Stock Monthly Data Fetcher - Enhanced Version
羲股票监控系统 - 月线数据获取(增强版)
This script fetches monthly K-line data for stocks from Tencent Finance API.
本脚本从腾讯财经API获取股票的月K线数据。
新增功能:
month.py get [ac] - 获取股票数据
- ac: 股票代码(支持逗号分隔多个股票)
- all: 获取所有未更新股票
- rand: 随机获取5只未更新股票
- 默认:获取5只未更新股票
"""
import os
import sys
import sqlite3
import requests
import json
import random
import argparse
from datetime import datetime, timedelta
# Fix encoding for Windows console
# 修复Windows控制台编码问题
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\month"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS = 800
FREQ = "month"
DEFAULT_LIMIT = 5
TIMESTAMP_FIELD = "month_get"
DATA_TYPE = "月线"
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- 数据库未找到: {DB_PATH}")
print(" 请先运行 init_db.py 和 fetch_stocks.py!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_stocks_by_codes(stock_codes):
"""Get stocks by specific codes"""
conn = get_db_connection()
cursor = conn.cursor()
# 处理逗号分隔的股票代码
codes = [code.strip() for code in stock_codes.split(',')]
# 构建查询
placeholders = ','.join(['?'] * len(codes))
query = f"""
SELECT code, name, market
FROM stocks
WHERE code IN ({placeholders}) AND status = 'active'
ORDER BY code
"""
cursor.execute(query, codes)
stocks = cursor.fetchall()
conn.close()
return stocks
def get_all_stocks_to_update(limit=None):
"""Get all stocks that need to be updated (month_get is NULL or old)"""
conn = get_db_connection()
cursor = conn.cursor()
# 获取当前时间
current_time = datetime.now()
query = f"""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
AND ({TIMESTAMP_FIELD} IS NULL OR {TIMESTAMP_FIELD} < ?)
ORDER BY {TIMESTAMP_FIELD} ASC NULLS FIRST
"""
cursor.execute(query, (current_time,))
stocks = cursor.fetchall()
conn.close()
# 应用限制
if limit and limit > 0:
stocks = stocks[:limit]
return stocks
def get_random_stocks_to_update(limit=5):
"""Get random stocks that need to be updated"""
conn = get_db_connection()
cursor = conn.cursor()
# 获取当前时间
current_time = datetime.now()
query = f"""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
AND ({TIMESTAMP_FIELD} IS NULL OR {TIMESTAMP_FIELD} < ?)
"""
cursor.execute(query, (current_time,))
all_stocks = cursor.fetchall()
conn.close()
# 随机选择
if len(all_stocks) > limit:
stocks = random.sample(all_stocks, limit)
else:
stocks = all_stocks
return stocks
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch monthly K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS},qfq"
url = f"{BASE_URL}?_var=kline_{FREQ}qfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_monthqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqmonth
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
data_key = f"qfq{FREQ}"
if data_key not in stock_data:
print(f"- {stock_code} {stock_name}: No {data_key} found")
return None
return stock_data[data_key]
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def save_to_file(stock_code, stock_name, data):
"""
Save processed data to file, one data point per line
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
# Invalid chars: \ / : * ? " < > |
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Write header
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath
def update_database_last_fetch(stock_code):
"""Update the month_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute(f"""
UPDATE stocks
SET {TIMESTAMP_FIELD} = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_stocks(stocks):
"""Fetch data for given list of stocks"""
total = len(stocks)
if total == 0:
print("没有需要更新的股票")
return
print("=" * 70)
print(f"XI Stock {DATA_TYPE.capitalize()} Data Fetcher")
print(f"羲股票监控系统 - {DATA_TYPE}数据获取")
print("=" * 70)
print(f"股票数量: {total}")
print(f"数据目录: {DATA_DIR}")
print("-" * 70)
success = 0
failed = 0
for i, (code, name, market) in enumerate(stocks, 1):
print(f"[{i}/{total}] 获取 {code} {name}...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("失败")
failed += 1
continue
# Process data
bars = raw_data
processed = process_data(bars)
if not processed:
print("无数据")
failed += 1
continue
# Save to file
save_to_file(code, name, processed)
# Update database
update_database_last_fetch(code)
print(f"成功 ({len(processed)} 个数据点)")
success += 1
# Add delay to avoid hitting rate limit
if i % 10 == 0:
import time
time.sleep(1)
# Final summary
print("-" * 70)
print("任务完成!")
print("我辛苦了!")
print("-" * 70)
print(f"总股票数: {total}")
print(f"成功获取: {success}")
print(f"失败: {failed}")
print(f"数据保存到: {DATA_DIR}")
print("-" * 70)
print("数据库已更新最后获取时间戳")
print("=" * 70)
def fetch_all_stocks_original():
"""Original function to fetch all stocks (backward compatibility)"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
stocks = cursor.fetchall()
conn.close()
fetch_stocks(stocks)
def main():
"""Main function with command line arguments"""
parser = argparse.ArgumentParser(
description=f'XI Stock {DATA_TYPE.capitalize()} Data Fetcher - Enhanced Version',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"""
使用示例:
传统用法:
month.py - 获取所有活跃股票数据
增强用法:
month.py get 000001 - 获取单只股票数据
month.py get 000001,000002 - 获取多只股票数据
month.py get all - 获取所有未更新股票
month.py get all --limit 10 - 获取10只未更新股票
month.py get rand - 随机获取5只未更新股票
month.py get rand --limit 3 - 随机获取3只未更新股票
month.py get - 获取5只未更新股票(默认)
"""
)
subparsers = parser.add_subparsers(dest='command', help='命令')
# get command
get_parser = subparsers.add_parser('get', help='获取股票数据')
get_parser.add_argument('ac', nargs='?', default='',
help='股票代码(all/rand/股票代码)')
get_parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
help=f'获取股票数量限制 (默认: {DEFAULT_LIMIT})')
args = parser.parse_args()
try:
if not args.command:
# 传统用法:没有参数时获取所有股票
print(f"使用传统模式:获取所有活跃股票{DATA_TYPE}数据...")
fetch_all_stocks_original()
elif args.command == 'get':
ac = args.ac.strip().lower() if args.ac else ''
limit = args.limit
if not ac:
# 默认:获取5只未更新股票
print(f"未指定股票,获取 {limit} 只未更新股票...")
stocks = get_all_stocks_to_update(limit)
fetch_stocks(stocks)
elif ac == 'all':
# 获取所有未更新股票
if limit > 0:
print(f"获取 {limit} 只未更新股票...")
stocks = get_all_stocks_to_update(limit)
else:
print("获取所有未更新股票...")
stocks = get_all_stocks_to_update()
fetch_stocks(stocks)
elif ac == 'rand':
# 随机获取未更新股票
print(f"随机获取 {limit} 只未更新股票...")
stocks = get_random_stocks_to_update(limit)
fetch_stocks(stocks)
else:
# 获取指定股票代码
print(f"获取指定股票: {ac}")
stocks = get_stocks_by_codes(ac)
if not stocks:
print(f"未找到股票代码: {ac}")
return
fetch_stocks(stocks)
except KeyboardInterrupt:
print("\n\n- 用户中断")
exit(1)
except Exception as e:
print(f"\n- 错误: {e}")
import traceback
traceback.print_exc()
exit(1)
if __name__ == "__main__":
main()
FILE:scripts/month_original.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Stock Monthly Data Fetcher
羲股票监控系统 - 月线数据获取
This script fetches monthly K-line data for all stocks from Tencent Finance API.
本脚本从腾讯财经API获取所有股票的月K线数据。
- Monthly fetching for long-term trend analysis
- 月线获取用于长期趋势分析
- Saves 800 historical data points per stock
- 每只股票保存800个历史数据点
"""
import os
import sys
import sqlite3
import requests
import json
from datetime import datetime
# Fix encoding for Windows console
# 修复Windows控制台编码问题
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\month"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS = 800
FREQ = "month"
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- Database not found: {DB_PATH}")
print(" Please run init_db.py and fetch_stocks.py first!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_stocks_to_fetch():
"""Get list of stocks that need to be fetched (all active stocks)"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
stocks = cursor.fetchall()
conn.close()
return stocks
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch monthly K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS},qfq"
url = f"{BASE_URL}?_var=kline_monthqfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_monthqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqmonth
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
if 'qfqmonth' not in stock_data:
print(f"- {stock_code} {stock_name}: No qfqmonth found")
return None
return stock_data['qfqmonth']
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def save_to_file(stock_code, stock_name, data):
"""
Save processed data to file, one data point per line
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
# Invalid chars: \ / : * ? " < > |
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Write header
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath
def update_database_last_fetch(stock_code):
"""Update the month_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
UPDATE stocks
SET month_get = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_all_stocks():
"""Fetch data for all stocks"""
stocks = get_stocks_to_fetch()
total = len(stocks)
print("=" * 70)
print("XI Stock Monthly Data Fetcher")
print("羲股票监控系统 - 月线数据获取")
print("=" * 70)
print(f"Total active stocks: {total}")
print(f"Data directory: {DATA_DIR}")
print("-" * 70)
success = 0
failed = 0
for i, (code, name, market) in enumerate(stocks, 1):
print(f"[{i}/{total}] Fetching {code} {name}...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("FAILED")
failed += 1
continue
# Process data
bars = raw_data
processed = process_data(bars)
if not processed:
print("NO DATA")
failed += 1
continue
# Save to file
save_to_file(code, name, processed)
# Update database
update_database_last_fetch(code)
print(f"OK ({len(processed)} points)")
success += 1
# Add delay to avoid hitting rate limit
if i % 10 == 0:
import time
time.sleep(1)
# Final summary
print("-" * 70)
print("任务完成!")
print("我辛苦了!")
print("-" * 70)
print(f"Total stocks: {total}")
print(f"Successfully fetched: {success}")
print(f"Failed: {failed}")
print(f"Data saved to: {DATA_DIR}")
print("-" * 70)
print("Database updated with last fetch timestamps")
print("=" * 70)
if __name__ == "__main__":
try:
fetch_all_stocks()
except KeyboardInterrupt:
print("\n\n- Interrupted by user")
exit(1)
except Exception as e:
print(f"\n- Error: {e}")
import traceback
traceback.print_exc()
exit(1)
FILE:scripts/month_parallel.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
A-Share-Get Monthly Data Fetcher - Parallel Incremental Version
A股数据获取 - 月线数据增量获取(并行版本)
Usage:
python scripts/month_parallel.py asc # 获取前一半(升序),每次2-3只
python scripts/month_parallel.py desc # 获取后一半(降序),每次2-3只
Run both in separate processes to speed up incremental updates!
在两个独立进程中同时运行,加速增量更新!
Incremental update logic:
- Read existing file, get latest date
- Only fetch data newer than latest date (add to file, don't overwrite)
- If file doesn't exist, fetch full 800 points
- Process max 100 stocks per run (incremental) / 800 (new file)
- Update database timestamp after successful fetch
"""
import os
import sys
import sqlite3
import requests
import json
from datetime import datetime
# Fix encoding for Windows console
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\month"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS_FULL = 800 # When file doesn't exist
MAX_STOCKS_PER_RUN_INCREMENTAL = 100 # Max stocks to process per run when incremental update
FREQ = "month"
BATCH_SIZE = 3 # Process 2-3 stocks per batch to avoid long runtime
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- Database not found: {DB_PATH}")
print(" Please run init_db.py and fetch_stocks.py first!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_latest_date_from_file(filepath):
"""
Read existing file and get the latest date
Returns None if file doesn't exist or empty
"""
if not os.path.exists(filepath):
return None
try:
with open(filepath, 'r', encoding='utf-8') as f:
# Skip header
next(f)
latest_date = None
# Read all lines to get the last date
for line in f:
line = line.strip()
if not line:
continue
date = line.split(',')[0]
latest_date = date
return latest_date
except Exception as e:
print(f" Warning: Cannot read existing file: {e}")
return None
def get_stocks_to_fetch(order):
"""
Get list of stocks that need to be fetched (incremental)
- Selects stocks that have never been fetched or haven't been fetched recently
- order: 'asc' or 'desc' - split into two partitions
- Returns up to MAX_STOCKS_PER_RUN_INCREMENTAL stocks
"""
conn = get_db_connection()
cursor = conn.cursor()
# Get all active stocks ordered by code
cursor.execute("""
SELECT code, name, market, month_get
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
all_stocks = cursor.fetchall()
total = len(all_stocks)
half = total // 2
# Split into two partitions
if order == 'asc':
partition_stocks = all_stocks[:half]
elif order == 'desc':
all_stocks.reverse()
partition_stocks = all_stocks[:half]
else:
print(f"- Invalid order: {order}. Use 'asc' or 'desc'")
exit(1)
# Filter stocks that need update (any stock that hasn't been fetched today)
today = datetime.now().strftime('%Y-%m-%d')
need_update = []
for stock in partition_stocks:
if len(stock) == 3:
code, name, market = stock
month_get = None
else:
code, name, market, month_get = stock
# If never fetched or last fetched not today, needs update
if month_get is None or not month_get.startswith(today):
need_update.append((code, name, market))
# Process all need update today, large safety limit to prevent infinite run
# MAX_STOCKS_PER_RUN_INCREMENTAL is original limit, now we allow 10x that
if len(need_update) > MAX_STOCKS_PER_RUN_INCREMENTAL * 10:
need_update = need_update[:MAX_STOCKS_PER_RUN_INCREMENTAL * 10]
print(f"Partition {order}:")
print(f" Total in partition: {len(partition_stocks)}")
print(f" Need update today: {len(need_update)}")
print(f" Will process all {len(need_update)} stocks (safety limit: {MAX_STOCKS_PER_RUN_INCREMENTAL * 10})")
print(f" Will process: {len(need_update)} (max {MAX_STOCKS_PER_RUN_INCREMENTAL})")
conn.close()
return need_update
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch monthly K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS_FULL},qfq"
url = f"{BASE_URL}?_var=kline_monthqfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_monthqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqmonth
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
if 'qfqmonth' not in stock_data:
print(f"- {stock_code} {stock_name}: No qfqmonth found")
return None
return stock_data['qfqmonth']
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def get_data_points_to_fetch(processed_new, latest_date):
"""
Filter new data points that are after latest_date in existing file
If latest_date is None, return all
"""
if latest_date is None:
return processed_new, DATA_POINTS_FULL
# Find all data points after latest_date
new_data = []
for item in processed_new:
date = item[0]
if date > latest_date:
new_data.append(item)
return new_data, len(processed_new)
def append_to_file(stock_code, stock_name, new_data, latest_date):
"""
Append new data points to existing file, don't overwrite
If file doesn't exist, create new with header
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Filter new data (only data after latest_date)
data_to_write, total_fetched = get_data_points_to_fetch(new_data, latest_date)
if not data_to_write:
# No new data to add
return filepath, 0
if latest_date is None:
# Create new file
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data_to_write:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
else:
# Append to existing file
with open(filepath, 'a', encoding='utf-8') as f:
for item in data_to_write:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath, len(data_to_write)
def update_database_last_fetch(stock_code):
"""Update the month_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
UPDATE stocks
SET month_get = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_all_stocks(order):
"""Fetch data incrementally for stocks in this partition - process {BATCH_SIZE} per run"""
stocks = get_stocks_to_fetch(order)
total_need_update = len(stocks)
print("=" * 70)
print(f"A-Share-Get Monthly Data Fetcher - Incremental Parallel ({order})")
print("A股数据获取 - 月线增量更新(并行)")
print("=" * 70)
print(f"Data directory: {DATA_DIR}")
print(f"Processing {BATCH_SIZE} stocks per run (max {MAX_STOCKS_PER_RUN_INCREMENTAL} total)")
print("-" * 70)
success_total = 0
failed_total = 0
new_points_total = 0
# Process in batches of BATCH_SIZE
from itertools import islice
def batch_generator(iterable, batch_size):
iterator = iter(iterable)
for first in iterator:
yield [first] + list(islice(iterator, batch_size - 1))
for batch_idx, batch in enumerate(batch_generator(stocks, BATCH_SIZE), 1):
print(f"\n>> Batch {batch_idx}, processing {len(batch)} stocks:")
for i, (code, name, market) in enumerate(batch, 1):
global_i = (batch_idx - 1) * BATCH_SIZE + i
print(f" [{global_i}/{total_need_update}] {code} {name}: ", end=' ')
sys.stdout.flush()
# Get existing file's latest date
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{code}.txt"
filepath = os.path.join(DATA_DIR, filename)
latest_date = get_latest_date_from_file(filepath)
if latest_date:
print(f"latest={latest_date}, fetching new data...", end=' ')
else:
print("new file, fetching full data...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("FAILED")
failed_total += 1
continue
# Process data
processed = process_data(raw_data)
if not processed:
print("NO DATA")
failed_total += 1
continue
# Append new data to file (only add missing)
filepath, new_count = append_to_file(code, name, processed, latest_date)
if new_count == 0:
print("no new data")
# Still update database to mark as updated today
update_database_last_fetch(code)
continue
# Update database
update_database_last_fetch(code)
print(f"OK added {new_count} new points")
success_total += 1
new_points_total += new_count
# Add delay to avoid hitting rate limit
import time
time.sleep(1.5)
# Final summary
print("-" * 70)
print("增量更新完成!")
print("-" * 70)
print(f"Partition ({order}):")
print(f" - Need update: {total_need_update}")
print(f" - Successfully processed: {success_total}")
print(f" - Failed: {failed_total}")
print(f" - New data points added: {new_points_total}")
print(f"Data directory: {DATA_DIR}")
print("-" * 70)
print("Database updated with last fetch timestamps")
print("=" * 70)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage:")
print(" python scripts/month_parallel.py asc # First half (ascending)")
print(" python scripts/month_parallel.py desc # Second half (descending)")
print("\nRun both in separate processes for parallel incremental fetching!")
exit(1)
order = sys.argv[1].lower()
try:
fetch_all_stocks(order)
except KeyboardInterrupt:
print("\n\n- Interrupted by user")
exit(1)
except Exception as e:
print(f"\n- Error: {e}")
import traceback
traceback.print_exc()
exit(1)
FILE:scripts/README.md
# XI Stock Data Fetcher - Enhanced System
# 羲股票监控系统 - 增强版数据获取
## 📋 核心文件
### 主脚本
- `day.py` - 增强版日线数据获取(支持外部事件)
- `week.py` - 增强版周线数据获取(支持外部事件)
- `month.py` - 增强版月线数据获取(支持外部事件)
- `db_reset.py` - 数据库重置与数据获取工具
### 并行版本(批量处理)
- `day_parallel.py` - 并行日线数据获取
- `week_parallel.py` - 并行周线数据获取
- `month_parallel.py` - 并行月线数据获取
### 基础脚本
- `init_db.py` - 数据库初始化
- `fetch_stocks.py` - 获取股票列表
### 备份文件
- `day_original.py` - 原始日线获取脚本备份
- `week_original.py` - 原始周线获取脚本备份
- `month_original.py` - 原始月线获取脚本备份
## 🚀 快速使用
### 传统用法(获取所有活跃股票)
```bash
python day.py
python week.py
python month.py
```
### 增强用法(支持外部事件)
#### 获取单只股票
```bash
python day.py get 000001
python week.py get 000001
python month.py get 000001
```
#### 获取多只股票(逗号分隔)
```bash
python day.py get 000001,000002
python week.py get 000001,000002
python month.py get 000001,000002
```
#### 获取所有未更新股票
```bash
python day.py get all
python week.py get all
python month.py get all
# 限制数量
python day.py get all --limit 10
```
#### 随机获取股票
```bash
python day.py get rand
python week.py get rand
python month.py get rand
# 指定数量
python day.py get rand --limit 3
```
#### 默认行为(获取5只未更新股票)
```bash
python day.py get
python week.py get
python month.py get
```
### 数据库管理工具
#### 重置功能
```bash
python db_reset.py reset day # 重置日线时间戳
python db_reset.py reset week # 重置周线时间戳
python db_reset.py reset month # 重置月线时间戳
python db_reset.py reset all # 重置所有时间戳
python db_reset.py reset status # 查看数据库状态
```
#### 直接获取功能
```bash
# 日线数据获取
python db_reset.py fetch day 000001
python db_reset.py fetch day all --limit 5
python db_reset.py fetch day rand --limit 3
# 周线数据获取
python db_reset.py fetch week 000001
python db_reset.py fetch week all --limit 5
# 月线数据获取
python db_reset.py fetch month 000001
python db_reset.py fetch month all --limit 5
```
## 📊 智能更新逻辑
1. **增量更新**:只获取 `timestamp_field < 当前时间` 的股票
2. **优先级**:从未更新的股票(NULL)优先
3. **随机选择**:`rand` 模式用于分散更新压力
4. **批量处理**:支持逗号分隔的多股票代码
5. **数量限制**:`--limit` 参数控制获取数量
## 🗄️ 数据库字段
### stocks 表结构
| 字段 | 类型 | 说明 |
|------|------|------|
| code | TEXT | 股票代码(主键) |
| name | TEXT | 股票名称 |
| market | TEXT | 市场类型 |
| day_get | TIMESTAMP | 最后日线获取时间 |
| week_get | TIMESTAMP | 最后周线获取时间 |
| month_get | TIMESTAMP | 最后月线获取时间 |
| status | TEXT | 状态(active/inactive) |
| created_at | TIMESTAMP | 创建时间 |
## 📁 数据文件位置
- **日线数据**: `D:\xistock\day\股票名称_股票代码.txt`
- **周线数据**: `D:\xistock\week\股票名称_股票代码.txt`
- **月线数据**: `D:\xistock\month\股票名称_股票代码.txt`
### 文件格式
```
date,open,close,high,low,change_pct
2024-01-01,10.50,10.80,11.00,10.40,2.857
2024-01-02,10.85,10.70,11.10,10.60,-0.926
...
```
## ⚙️ 初始化步骤
1. **初始化数据库**
```bash
python init_db.py
```
2. **获取股票列表**
```bash
python fetch_stocks.py
```
3. **开始数据收集**
```bash
# 传统方式(获取所有股票)
python day.py
python week.py
python month.py
# 或使用增强方式
python day.py get all --limit 10
```
## ⚠️ 注意事项
1. **API限制**:腾讯财经API有速率限制,脚本包含延迟机制
2. **网络连接**:需要稳定的网络连接
3. **磁盘空间**:确保 `D:\xistock` 有足够空间
4. **交易时段**:建议在非交易时段运行
5. **数据完整性**:重置操作会标记所有股票为需要更新
## 🔧 故障排除
### 数据库不存在
```bash
# 运行初始化脚本
python init_db.py
python fetch_stocks.py
```
### 股票代码不存在
```bash
# 检查股票是否在数据库中
python fetch_stocks.py # 更新股票列表
```
### 网络连接失败
- 检查网络连接
- 脚本会自动重试失败股票
- 可以单独重试失败股票
## 📈 性能建议
1. **批量处理**:使用逗号分隔一次获取多只股票
2. **合理限制**:根据网络情况设置合适的 `--limit` 值
3. **定时任务**:配置Windows任务计划或Linux cron
4. **并行处理**:对于大量股票,使用 `*_parallel.py` 脚本
---
**版本**: 2.0.0 (增强版)
**最后更新**: 2026-03-14
**功能**: 支持外部事件控制、智能更新、批量处理
FILE:scripts/schedule_config.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Schedule Configuration for XI Stock System
羲股票监控系统定时任务配置
This script provides configuration for OpenClaw cron jobs to schedule automatic updates.
本脚本提供OpenClaw定时任务的配置,用于安排自动更新。
"""
import json
from datetime import datetime, timedelta
def generate_cron_config():
"""Generate cron job configuration for OpenClaw"""
config = {
"name": "XI Stock System - Daily Updates",
"description": "Automated stock data fetching for A-share market",
"jobs": [
{
"name": "Daily Stock Data Update",
"description": "Fetch daily data for 10 stocks each time",
"schedule": {
"kind": "cron",
"expr": "30 15 * * 1-5", # 工作日15:30(收盘后)
"tz": "Asia/Shanghai"
},
"payload": {
"kind": "agentTurn",
"message": "Run daily stock data update: python day.py get --limit 10",
"model": "deepseek-v3.2"
},
"sessionTarget": "isolated",
"delivery": {
"mode": "announce",
"channel": "telegram"
}
},
{
"name": "Weekly Reset and Update",
"description": "Reset week_get and fetch weekly data on Monday",
"schedule": {
"kind": "cron",
"expr": "0 9 * * 1", # 周一9:00
"tz": "Asia/Shanghai"
},
"payload": {
"kind": "agentTurn",
"message": "Reset week_get and fetch weekly data: python db_reset.py reset week && python week.py get all --limit 20",
"model": "deepseek-v3.2"
},
"sessionTarget": "isolated",
"delivery": {
"mode": "announce",
"channel": "telegram"
}
},
{
"name": "Monthly Reset and Update",
"description": "Reset month_get and fetch monthly data on 1st of month",
"schedule": {
"kind": "cron",
"expr": "0 9 1 * *", # 每月1日9:00
"tz": "Asia/Shanghai"
},
"payload": {
"kind": "agentTurn",
"message": "Reset month_get and fetch monthly data: python db_reset.py reset month && python month.py get all --limit 20",
"model": "deepseek-v3.2"
},
"sessionTarget": "isolated",
"delivery": {
"mode": "announce",
"channel": "telegram"
}
},
{
"name": "Database Status Check",
"description": "Check database status every day at 10:00",
"schedule": {
"kind": "cron",
"expr": "0 10 * * *", # 每天10:00
"tz": "Asia/Shanghai"
},
"payload": {
"kind": "agentTurn",
"message": "Check database status: python db_reset.py reset status",
"model": "deepseek-v3.2"
},
"sessionTarget": "isolated",
"delivery": {
"mode": "announce",
"channel": "telegram"
}
}
]
}
return config
def generate_heartbeat_config():
"""Generate heartbeat configuration for periodic checks"""
config = {
"name": "XI Stock System - Heartbeat Checks",
"description": "Periodic checks during heartbeats",
"checks": [
{
"name": "Check Database Status",
"command": "python db_reset.py reset status",
"frequency": "every_heartbeat", # 每次心跳时检查
"condition": "always"
},
{
"name": "Check if Need Daily Update",
"command": "python day.py get --limit 1 --test-only",
"frequency": "every_4_hours",
"condition": "if_business_hours"
},
{
"name": "Check Data Directory",
"command": "dir D:\\xistock\\day /b | find /c \".txt\"",
"frequency": "daily",
"condition": "always"
}
]
}
return config
def generate_quick_commands():
"""Generate quick commands for manual execution"""
commands = {
"daily_update_10": "python day.py get --limit 10",
"daily_update_all": "python day.py get all",
"weekly_update": "python week.py get all --limit 20",
"monthly_update": "python month.py get all --limit 20",
"reset_all": "python db_reset.py reset all",
"status": "python db_reset.py reset status",
"fetch_single": "python db_reset.py fetch day 000001",
"fetch_random": "python db_reset.py fetch day rand --limit 5",
"parallel_day": "python day_parallel.py",
"parallel_week": "python week_parallel.py",
"parallel_month": "python month_parallel.py"
}
return commands
def main():
"""Main function to display configuration"""
print("=" * 70)
print("XI Stock System - Schedule Configuration")
print("羲股票监控系统 - 定时任务配置")
print("=" * 70)
# Generate configurations
cron_config = generate_cron_config()
heartbeat_config = generate_heartbeat_config()
quick_commands = generate_quick_commands()
print("\n📅 Cron Job Configuration (for OpenClaw):")
print("-" * 70)
for job in cron_config["jobs"]:
print(f" • {job['name']}")
print(f" 时间: {job['schedule']['expr']} ({job['schedule']['tz']})")
print(f" 命令: {job['payload']['message']}")
print()
print("\n💓 Heartbeat Checks:")
print("-" * 70)
for check in heartbeat_config["checks"]:
print(f" • {check['name']}")
print(f" 频率: {check['frequency']}")
print(f" 条件: {check['condition']}")
print(f" 命令: {check['command']}")
print()
print("\n⚡ Quick Commands (for manual execution):")
print("-" * 70)
for name, cmd in quick_commands.items():
print(f" {name:20} → {cmd}")
print("\n📋 To add cron jobs to OpenClaw:")
print("-" * 70)
print("1. Save this configuration to a file")
print("2. Use OpenClaw cron tool to add jobs:")
print(" openclaw cron add --config schedule_config.json")
print("\n3. Or add manually using OpenClaw web interface")
print("=" * 70)
# Save configuration to files
with open("schedule_config.json", "w", encoding="utf-8") as f:
json.dump(cron_config, f, ensure_ascii=False, indent=2)
with open("quick_commands.json", "w", encoding="utf-8") as f:
json.dump(quick_commands, f, ensure_ascii=False, indent=2)
print("\n✅ Configuration files saved:")
print(" - schedule_config.json")
print(" - quick_commands.json")
if __name__ == "__main__":
main()
FILE:scripts/week.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Stock Weekly Data Fetcher - Enhanced Version
羲股票监控系统 - 周线数据获取(增强版)
This script fetches weekly K-line data for stocks from Tencent Finance API.
本脚本从腾讯财经API获取股票的周K线数据。
新增功能:
week.py get [ac] - 获取股票数据
- ac: 股票代码(支持逗号分隔多个股票)
- all: 获取所有未更新股票
- rand: 随机获取5只未更新股票
- 默认:获取5只未更新股票
"""
import os
import sys
import sqlite3
import requests
import json
import random
import argparse
from datetime import datetime, timedelta
# Fix encoding for Windows console
# 修复Windows控制台编码问题
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\week"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS = 800
FREQ = "week"
DEFAULT_LIMIT = 5
TIMESTAMP_FIELD = "week_get"
DATA_TYPE = "周线"
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- 数据库未找到: {DB_PATH}")
print(" 请先运行 init_db.py 和 fetch_stocks.py!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_stocks_by_codes(stock_codes):
"""Get stocks by specific codes"""
conn = get_db_connection()
cursor = conn.cursor()
# 处理逗号分隔的股票代码
codes = [code.strip() for code in stock_codes.split(',')]
# 构建查询
placeholders = ','.join(['?'] * len(codes))
query = f"""
SELECT code, name, market
FROM stocks
WHERE code IN ({placeholders}) AND status = 'active'
ORDER BY code
"""
cursor.execute(query, codes)
stocks = cursor.fetchall()
conn.close()
return stocks
def get_all_stocks_to_update(limit=None):
"""Get all stocks that need to be updated (week_get is NULL or old)"""
conn = get_db_connection()
cursor = conn.cursor()
# 获取当前时间
current_time = datetime.now()
query = f"""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
AND ({TIMESTAMP_FIELD} IS NULL OR {TIMESTAMP_FIELD} < ?)
ORDER BY {TIMESTAMP_FIELD} ASC NULLS FIRST
"""
cursor.execute(query, (current_time,))
stocks = cursor.fetchall()
conn.close()
# 应用限制
if limit and limit > 0:
stocks = stocks[:limit]
return stocks
def get_random_stocks_to_update(limit=5):
"""Get random stocks that need to be updated"""
conn = get_db_connection()
cursor = conn.cursor()
# 获取当前时间
current_time = datetime.now()
query = f"""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
AND ({TIMESTAMP_FIELD} IS NULL OR {TIMESTAMP_FIELD} < ?)
"""
cursor.execute(query, (current_time,))
all_stocks = cursor.fetchall()
conn.close()
# 随机选择
if len(all_stocks) > limit:
stocks = random.sample(all_stocks, limit)
else:
stocks = all_stocks
return stocks
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch weekly K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS},qfq"
url = f"{BASE_URL}?_var=kline_{FREQ}qfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_weekqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqweek
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
data_key = f"qfq{FREQ}"
if data_key not in stock_data:
print(f"- {stock_code} {stock_name}: No {data_key} found")
return None
return stock_data[data_key]
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def save_to_file(stock_code, stock_name, data):
"""
Save processed data to file, one data point per line
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
# Invalid chars: \ / : * ? " < > |
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Write header
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath
def update_database_last_fetch(stock_code):
"""Update the week_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute(f"""
UPDATE stocks
SET {TIMESTAMP_FIELD} = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_stocks(stocks):
"""Fetch data for given list of stocks"""
total = len(stocks)
if total == 0:
print("没有需要更新的股票")
return
print("=" * 70)
print(f"XI Stock {DATA_TYPE.capitalize()} Data Fetcher")
print(f"羲股票监控系统 - {DATA_TYPE}数据获取")
print("=" * 70)
print(f"股票数量: {total}")
print(f"数据目录: {DATA_DIR}")
print("-" * 70)
success = 0
failed = 0
for i, (code, name, market) in enumerate(stocks, 1):
print(f"[{i}/{total}] 获取 {code} {name}...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("失败")
failed += 1
continue
# Process data
bars = raw_data
processed = process_data(bars)
if not processed:
print("无数据")
failed += 1
continue
# Save to file
save_to_file(code, name, processed)
# Update database
update_database_last_fetch(code)
print(f"成功 ({len(processed)} 个数据点)")
success += 1
# Add delay to avoid hitting rate limit
if i % 10 == 0:
import time
time.sleep(1)
# Final summary
print("-" * 70)
print("任务完成!")
print("我辛苦了!")
print("-" * 70)
print(f"总股票数: {total}")
print(f"成功获取: {success}")
print(f"失败: {failed}")
print(f"数据保存到: {DATA_DIR}")
print("-" * 70)
print("数据库已更新最后获取时间戳")
print("=" * 70)
def fetch_all_stocks_original():
"""Original function to fetch all stocks (backward compatibility)"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
stocks = cursor.fetchall()
conn.close()
fetch_stocks(stocks)
def main():
"""Main function with command line arguments"""
parser = argparse.ArgumentParser(
description=f'XI Stock {DATA_TYPE.capitalize()} Data Fetcher - Enhanced Version',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"""
使用示例:
传统用法:
week.py - 获取所有活跃股票数据
增强用法:
week.py get 000001 - 获取单只股票数据
week.py get 000001,000002 - 获取多只股票数据
week.py get all - 获取所有未更新股票
week.py get all --limit 10 - 获取10只未更新股票
week.py get rand - 随机获取5只未更新股票
week.py get rand --limit 3 - 随机获取3只未更新股票
week.py get - 获取5只未更新股票(默认)
"""
)
subparsers = parser.add_subparsers(dest='command', help='命令')
# get command
get_parser = subparsers.add_parser('get', help='获取股票数据')
get_parser.add_argument('ac', nargs='?', default='',
help='股票代码(all/rand/股票代码)')
get_parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
help=f'获取股票数量限制 (默认: {DEFAULT_LIMIT})')
args = parser.parse_args()
try:
if not args.command:
# 传统用法:没有参数时获取所有股票
print(f"使用传统模式:获取所有活跃股票{DATA_TYPE}数据...")
fetch_all_stocks_original()
elif args.command == 'get':
ac = args.ac.strip().lower() if args.ac else ''
limit = args.limit
if not ac:
# 默认:获取5只未更新股票
print(f"未指定股票,获取 {limit} 只未更新股票...")
stocks = get_all_stocks_to_update(limit)
fetch_stocks(stocks)
elif ac == 'all':
# 获取所有未更新股票
if limit > 0:
print(f"获取 {limit} 只未更新股票...")
stocks = get_all_stocks_to_update(limit)
else:
print("获取所有未更新股票...")
stocks = get_all_stocks_to_update()
fetch_stocks(stocks)
elif ac == 'rand':
# 随机获取未更新股票
print(f"随机获取 {limit} 只未更新股票...")
stocks = get_random_stocks_to_update(limit)
fetch_stocks(stocks)
else:
# 获取指定股票代码
print(f"获取指定股票: {ac}")
stocks = get_stocks_by_codes(ac)
if not stocks:
print(f"未找到股票代码: {ac}")
return
fetch_stocks(stocks)
except KeyboardInterrupt:
print("\n\n- 用户中断")
exit(1)
except Exception as e:
print(f"\n- 错误: {e}")
import traceback
traceback.print_exc()
exit(1)
if __name__ == "__main__":
main()
FILE:scripts/week_original.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XI Stock Weekly Data Fetcher
羲股票监控系统 - 周线数据获取
This script fetches weekly K-line data for all stocks from Tencent Finance API.
本脚本从腾讯财经API获取所有股票的周K线数据。
- Weekly fetching for long-term trend analysis
- 周线获取用于长期趋势分析
- Saves 800 historical data points per stock
- 每只股票保存800个历史数据点
"""
import os
import sys
import sqlite3
import requests
import json
from datetime import datetime
# Fix encoding for Windows console
# 修复Windows控制台编码问题
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\week"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS = 800
FREQ = "week"
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- Database not found: {DB_PATH}")
print(" Please run init_db.py and fetch_stocks.py first!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_stocks_to_fetch():
"""Get list of stocks that need to be fetched (all active stocks)"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT code, name, market
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
stocks = cursor.fetchall()
conn.close()
return stocks
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch weekly K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS},qfq"
url = f"{BASE_URL}?_var=kline_weekqfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_weekqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqweek
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
if 'qfqweek' not in stock_data:
print(f"- {stock_code} {stock_name}: No qfqweek found")
return None
return stock_data['qfqweek']
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def save_to_file(stock_code, stock_name, data):
"""
Save processed data to file, one data point per line
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
# Invalid chars: \ / : * ? " < > |
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Write header
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath
def update_database_last_fetch(stock_code):
"""Update the week_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
UPDATE stocks
SET week_get = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_all_stocks():
"""Fetch data for all stocks"""
stocks = get_stocks_to_fetch()
total = len(stocks)
print("=" * 70)
print("XI Stock Weekly Data Fetcher")
print("羲股票监控系统 - 周线数据获取")
print("=" * 70)
print(f"Total active stocks: {total}")
print(f"Data directory: {DATA_DIR}")
print("-" * 70)
success = 0
failed = 0
for i, (code, name, market) in enumerate(stocks, 1):
print(f"[{i}/{total}] Fetching {code} {name}...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("FAILED")
failed += 1
continue
# Process data
bars = raw_data
processed = process_data(bars)
if not processed:
print("NO DATA")
failed += 1
continue
# Save to file
save_to_file(code, name, processed)
# Update database
update_database_last_fetch(code)
print(f"OK ({len(processed)} points)")
success += 1
# Add delay to avoid hitting rate limit
if i % 10 == 0:
import time
time.sleep(1)
# Final summary
print("-" * 70)
print("任务完成!")
print("我辛苦了!")
print("-" * 70)
print(f"Total stocks: {total}")
print(f"Successfully fetched: {success}")
print(f"Failed: {failed}")
print(f"Data saved to: {DATA_DIR}")
print("-" * 70)
print("Database updated with last fetch timestamps")
print("=" * 70)
if __name__ == "__main__":
try:
fetch_all_stocks()
except KeyboardInterrupt:
print("\n\n- Interrupted by user")
exit(1)
except Exception as e:
print(f"\n- Error: {e}")
import traceback
traceback.print_exc()
exit(1)
FILE:scripts/week_parallel.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
A-Share-Get Weekly Data Fetcher - Parallel Incremental Version
A股数据获取 - 周线数据增量获取(并行版本)
Usage:
python scripts/week_parallel.py asc # 获取前一半(升序),每次2-3只
python scripts/week_parallel.py desc # 获取后一半(降序),每次2-3只
Run both in separate processes to speed up incremental updates!
在两个独立进程中同时运行,加速增量更新!
Incremental update logic:
- Read existing file, get latest date
- Only fetch data newer than latest date (add to file, don't overwrite)
- If file doesn't exist, fetch full 800 points
- Process max 100 stocks per run (incremental) / 800 (new file)
- Update database timestamp after successful fetch
"""
import os
import sys
import sqlite3
import requests
import json
from datetime import datetime
# Fix encoding for Windows console
if sys.stdout.encoding.lower() != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# Configuration
DB_PATH = "D:\\xistock\\stock.db"
DATA_DIR = "D:\\xistock\\week"
BASE_URL = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get"
DATA_POINTS_FULL = 800 # When file doesn't exist
MAX_STOCKS_PER_RUN_INCREMENTAL = 100 # Max stocks to process per run when incremental update
FREQ = "week"
BATCH_SIZE = 3 # Process 2-3 stocks per batch to avoid long runtime
def get_db_connection():
"""Get database connection"""
if not os.path.exists(DB_PATH):
print(f"- Database not found: {DB_PATH}")
print(" Please run init_db.py and fetch_stocks.py first!")
exit(1)
return sqlite3.connect(DB_PATH)
def get_latest_date_from_file(filepath):
"""
Read existing file and get the latest date
Returns None if file doesn't exist or empty
"""
if not os.path.exists(filepath):
return None
try:
with open(filepath, 'r', encoding='utf-8') as f:
# Skip header
next(f)
latest_date = None
# Read all lines to get the last date
for line in f:
line = line.strip()
if not line:
continue
date = line.split(',')[0]
latest_date = date
return latest_date
except Exception as e:
print(f" Warning: Cannot read existing file: {e}")
return None
def get_stocks_to_fetch(order):
"""
Get list of stocks that need to be fetched (incremental)
- Selects stocks that have never been fetched or haven't been fetched recently
- order: 'asc' or 'desc' - split into two partitions
- Returns up to MAX_STOCKS_PER_RUN_INCREMENTAL stocks
"""
conn = get_db_connection()
cursor = conn.cursor()
# Get all active stocks ordered by code
cursor.execute("""
SELECT code, name, market, week_get
FROM stocks
WHERE status = 'active'
ORDER BY code
""")
all_stocks = cursor.fetchall()
total = len(all_stocks)
half = total // 2
# Split into two partitions
if order == 'asc':
partition_stocks = all_stocks[:half]
elif order == 'desc':
all_stocks.reverse()
partition_stocks = all_stocks[:half]
else:
print(f"- Invalid order: {order}. Use 'asc' or 'desc'")
exit(1)
# Filter stocks that need update (any stock that hasn't been fetched today)
today = datetime.now().strftime('%Y-%m-%d')
need_update = []
for stock in partition_stocks:
if len(stock) == 3:
code, name, market = stock
week_get = None
else:
code, name, market, week_get = stock
# If never fetched or last fetched not today, needs update
if week_get is None or not week_get.startswith(today):
need_update.append((code, name, market))
# Process all need update today, large safety limit to prevent infinite run
# MAX_STOCKS_PER_RUN_INCREMENTAL is original limit, now we allow 10x that
if len(need_update) > MAX_STOCKS_PER_RUN_INCREMENTAL * 10:
need_update = need_update[:MAX_STOCKS_PER_RUN_INCREMENTAL * 10]
print(f"Partition {order}:")
print(f" Total in partition: {len(partition_stocks)}")
print(f" Need update today: {len(need_update)}")
print(f" Will process all {len(need_update)} stocks (safety limit: {MAX_STOCKS_PER_RUN_INCREMENTAL * 10})")
print(f" Will process: {len(need_update)} (max {MAX_STOCKS_PER_RUN_INCREMENTAL})")
conn.close()
return need_update
def format_stock_code(code):
"""Format stock code for Tencent Finance API (sh600001, sz000001)"""
if code.startswith('60'):
return f"sh{code}"
else:
return f"sz{code}"
def fetch_stock_data(stock_code, stock_name):
"""Fetch weekly K-line data from Tencent Finance API"""
try:
tencent_code = format_stock_code(stock_code)
params = f"{tencent_code},{FREQ},,,{DATA_POINTS_FULL},qfq"
url = f"{BASE_URL}?_var=kline_weekqfq¶m={params}"
response = requests.get(url, timeout=30)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"- {stock_code} {stock_name}: HTTP {response.status_code}")
return None
# Response is in format: kline_weekqfq = {...};
content = response.text
if '=' in content:
json_str = content.split('=', 1)[1].rstrip(';')
else:
json_str = content
data = json.loads(json_str)
# Data structure: data -> tencent_code -> qfqweek
if 'data' not in data or tencent_code not in data['data']:
print(f"- {stock_code} {stock_name}: No data found")
return None
stock_data = data['data'][tencent_code]
if 'qfqweek' not in stock_data:
print(f"- {stock_code} {stock_name}: No qfqweek found")
return None
return stock_data['qfqweek']
except Exception as e:
print(f"- {stock_code} {stock_name}: Error fetching - {str(e)}")
return None
def process_data(data):
"""
Process raw data, extract date, open, close, high, low and calculate change
返回格式列表: [date, open, close, high, low, change]
"""
processed = []
# Data format from Tencent: [date, open, close, high, low, volume, ...]
for bar in data:
if len(bar) >= 5:
date = bar[0]
open_p = float(bar[1])
close = float(bar[2])
high = float(bar[3])
low = float(bar[4])
processed.append([date, open_p, close, high, low])
# Calculate change (涨跌幅)
for i in range(len(processed)):
if i == 0:
change = 0.0
else:
prev_close = processed[i-1][2]
if prev_close == 0:
change = 0.0
else:
change = ((processed[i][2] - prev_close) / prev_close) * 100
processed[i].append(round(change, 3))
return processed
def get_data_points_to_fetch(processed_new, latest_date):
"""
Filter new data points that are after latest_date in existing file
If latest_date is None, return all
"""
if latest_date is None:
return processed_new, DATA_POINTS_FULL
# Find all data points after latest_date
new_data = []
for item in processed_new:
date = item[0]
if date > latest_date:
new_data.append(item)
return new_data, len(processed_new)
def append_to_file(stock_code, stock_name, new_data, latest_date):
"""
Append new data points to existing file, don't overwrite
If file doesn't exist, create new with header
Format: date,open,close,high,low,change
Filename: 名称_代码.txt
"""
# Create directory if not exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Sanitize filename - remove all invalid Windows filename characters
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = stock_name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{stock_code}.txt"
filepath = os.path.join(DATA_DIR, filename)
# Filter new data (only data after latest_date)
data_to_write, total_fetched = get_data_points_to_fetch(new_data, latest_date)
if not data_to_write:
# No new data to add
return filepath, 0
if latest_date is None:
# Create new file
with open(filepath, 'w', encoding='utf-8') as f:
f.write("date,open,close,high,low,change_pct\n")
for item in data_to_write:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
else:
# Append to existing file
with open(filepath, 'a', encoding='utf-8') as f:
for item in data_to_write:
date, open_p, close, high, low, change = item
line = f"{date},{open_p:.2f},{close:.2f},{high:.2f},{low:.2f},{change:.3f}\n"
f.write(line)
return filepath, len(data_to_write)
def update_database_last_fetch(stock_code):
"""Update the week_get timestamp in database to current date"""
conn = get_db_connection()
cursor = conn.cursor()
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
UPDATE stocks
SET week_get = ?
WHERE code = ?
""", (current_time, stock_code))
conn.commit()
conn.close()
def fetch_all_stocks(order):
"""Fetch data incrementally for stocks in this partition - process {BATCH_SIZE} per run"""
stocks = get_stocks_to_fetch(order)
total_need_update = len(stocks)
print("=" * 70)
print(f"A-Share-Get Weekly Data Fetcher - Incremental Parallel ({order})")
print("A股数据获取 - 周线增量更新(并行)")
print("=" * 70)
print(f"Data directory: {DATA_DIR}")
print(f"Processing {BATCH_SIZE} stocks per run (max {MAX_STOCKS_PER_RUN_INCREMENTAL} total)")
print("-" * 70)
success_total = 0
failed_total = 0
new_points_total = 0
# Process in batches of BATCH_SIZE
from itertools import islice
def batch_generator(iterable, batch_size):
iterator = iter(iterable)
for first in iterator:
yield [first] + list(islice(iterator, batch_size - 1))
for batch_idx, batch in enumerate(batch_generator(stocks, BATCH_SIZE), 1):
print(f"\n>> Batch {batch_idx}, processing {len(batch)} stocks:")
for i, (code, name, market) in enumerate(batch, 1):
global_i = (batch_idx - 1) * BATCH_SIZE + i
print(f" [{global_i}/{total_need_update}] {code} {name}: ", end=' ')
sys.stdout.flush()
# Get existing file's latest date
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
safe_name = name
for c in invalid_chars:
safe_name = safe_name.replace(c, '_')
filename = f"{safe_name}_{code}.txt"
filepath = os.path.join(DATA_DIR, filename)
latest_date = get_latest_date_from_file(filepath)
if latest_date:
print(f"latest={latest_date}, fetching new data...", end=' ')
else:
print("new file, fetching full data...", end=' ')
sys.stdout.flush()
# Fetch data
raw_data = fetch_stock_data(code, name)
if raw_data is None:
print("FAILED")
failed_total += 1
continue
# Process data
processed = process_data(raw_data)
if not processed:
print("NO DATA")
failed_total += 1
continue
# Append new data to file (only add missing)
filepath, new_count = append_to_file(code, name, processed, latest_date)
if new_count == 0:
print("no new data")
# Still update database to mark as updated today
update_database_last_fetch(code)
continue
# Update database
update_database_last_fetch(code)
print(f"OK added {new_count} new points")
success_total += 1
new_points_total += new_count
# Add delay to avoid hitting rate limit
import time
time.sleep(1.5)
# Final summary
print("-" * 70)
print("增量更新完成!")
print("-" * 70)
print(f"Partition ({order}):")
print(f" - Need update: {total_need_update}")
print(f" - Successfully processed: {success_total}")
print(f" - Failed: {failed_total}")
print(f" - New data points added: {new_points_total}")
print(f"Data directory: {DATA_DIR}")
print("-" * 70)
print("Database updated with last fetch timestamps")
print("=" * 70)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage:")
print(" python scripts/week_parallel.py asc # First half (ascending)")
print(" python scripts/week_parallel.py desc # Second half (descending)")
print("\nRun both in separate processes for parallel incremental fetching!")
exit(1)
order = sys.argv[1].lower()
try:
fetch_all_stocks(order)
except KeyboardInterrupt:
print("\n\n- Interrupted by user")
exit(1)
except Exception as e:
print(f"\n- Error: {e}")
import traceback
traceback.print_exc()
exit(1)
🌐 完整的 AceData Suno V5 AI音乐创作Web应用,支持简易/自定义两种创作模式,包含浏览器可视化界面,自动保存音乐文件到桌面,支持在线播放和下载。使用 Suno V5 模型通过 AceData API 生成 AI 音乐,无限购买昂贵的订阅也可以创作出完美的音乐。
---
name: ace-suno-v5
description: 🌐 完整的 AceData Suno V5 AI音乐创作Web应用,支持简易/自定义两种创作模式,包含浏览器可视化界面,自动保存音乐文件到桌面,支持在线播放和下载。使用 Suno V5 模型通过 AceData API 生成 AI 音乐,无限购买昂贵的订阅也可以创作出完美的音乐。
---
# Ace Suno V5 - AI 音乐创作平台
## 🎵 简介
基于 AceData Suno API 打造的完整 AI 音乐创作 Web 应用,提供美观易用的浏览器界面,让你轻松通过 AI 创作高质量音乐。
### 核心特点
- OpenClaw命令打开创作音乐可以直接打开界面,.py文件,直接生成,不产生额外的tokens开销
- ✨ **美观现代UI**:居中大窗口,风格标签快速选择
- 🔄 **双模式创作**:简易模式 / 自定义模式一键切换
- 简易模式:仅输入描述即可创作
- 自定义模式:支持自定义歌词、标题、风格
- 🏷️ **快速标签选择**:分五大类预置标签(风格/情感/乐器/氛围/性别),点击添加
- 🎼 **完整参数支持**:支持所有 API 参数,包括片段替换、人声补全、伴奏补全等高级功能
- 💾 **自动保存**:生成完成自动保存到桌面 `music/YYYY-MM-DD/` 文件夹
- ▶️ **在线播放**:页面底部展示历史生成,直接在线试听
- 🔑 **API Key 本地保存**:输入一次保存在浏览器localStorage,无文件溢出风险,下次无需重复输入
- 🛑 **一键关闭服务**:右上角圆形按钮一键关闭后台服务
## 🚀 快速开始
### 启动服务
在终端中输入:
```bash
cd ~/.openclaw/workspace/skills/ace-suno-v5
python start_server.py
```
脚本会自动检测依赖,如果缺少 Flask 和 Requests 会自动安装,然后启动服务。
脚本会自动检测依赖,如果缺少 Flask 和 Requests 会自动安装,然后启动服务。
### 开始创作
1. 打开浏览器访问:`http://localhost:1688`
2. 第一次访问会提示输入 AceData API Key
- 如果还没有,可以在这里申请:https://share.acedata.cloud/r/1uN88BrUTQ
- API Key 会保存在浏览器本地,下次打开自动填充
3. 选择创作模式:
- **简易模式**:直接输入你想要的音乐描述,点击标签添加风格标签
- **自定义模式**:填写歌曲标题、歌词,添加风格标签
4. 打开/关闭「纯音乐」开关
5. 点击「立即创作」按钮,等待生成完成
6. 生成完成自动保存到桌面,在页面下方可以直接试听或下载
## 📋 功能说明
### 模式切换
- **简易模式**:适合灵感创作,只需描述想要的音乐
- **自定义模式**:适合精确创作,可以自定义歌词和标题
### 标签分类
| 分类 | 说明 |
|------|------|
| 风格 | 流行、摇滚、嘻哈、爵士、电子... |
| 情感 | 快乐、悲伤、激情、平静、浪漫... |
| 乐器 | 钢琴、吉他、小提琴、鼓、贝斯... |
| 氛围 | 温暖、夏日、冬日、夜晚、梦幻... |
| 声音性别 | 男声/女声 (二选一) |
点击大类显示该分类下标签,点击标签添加到当前文本中,点击 × 删除已添加标签。
### 支持的操作类型 (action)
- `generate` - 生成新歌曲(默认)
- `extend` - 延长现有歌曲(以下限plus版)
- `cover` - 翻唱歌曲
- `stems` - 提取音轨
- `remaster` - 重新母带处理
- `replace_section` - 替换片段
- `concat` - 拼接歌曲
### 高级功能
- 纯音乐开关:生成纯音乐
- 支持为已有纯音乐补充人声 (overpainting)
- 支持为清唱添加伴奏 (underpainting)
- 支持继续延长已有音频
- 支持片段替换
## 📁 文件保存
生成的文件自动保存到:
```
~/Desktop/music/YYYY-MM-DD/
```
每个歌曲保存三个文件:
- `{标题}_{ID}.mp3` - 音频文件
- `{标题}_{ID}.jpg` - 封面图片
- `{标题}_{ID}.txt` - 歌词文本
## 💻 Python 客户端使用
如果你想在代码中直接调用,可以使用 Python 客户端:
```python
from scripts.suno_client import AceSunoClient
# 初始化客户端
client = AceSunoClient(api_key="your-acedata-api-key")
# 简易模式生成
response = client.generate(
prompt="一首轻快的流行歌曲,带有温暖的旋律",
model="chirp-v5",
vocal_gender="m"
)
# 自定义模式生成
response = client.generate(
model="chirp-v5",
custom=True,
title="冬日恋歌",
style="流行,温暖,抒情",
lyric="[Verse]\n雪花飘落...\n[Chorus]\n冬日恋歌..."
)
# 自动保存所有文件
output_dir = client.save_generation(response['data'])
print(f"文件已保存到: {output_dir}")
```
## 📋 项目结构
```
ace-suno-v5/
├── SKILL.md # 本文档
├── start_server.py # 一键启动脚本(自动安装依赖)
├── scripts/
│ └── suno_client.py # Python API 客户端
└── web/
├── app.py # Flask Web 后端
├── requirements.txt # 依赖列表
├── generation_history.json # 生成历史记录
└── templates/
└── index.html # 前端页面
```
## 🎯 模型版本
支持所有 Suno 模型版本,默认使用最新的 `chirp-v5`:
- `chirp-v5` - 最新版本(推荐)
- `chirp-v4-5-plus`(以下可以自己添加)
- `chirp-v4-5`
- `chirp-v3-5`
- `chirp-v4`
- `chirp-v3`
## 🔒 关闭服务
使用完后,点击页面右上角黑色圆形关闭按钮,确认后即可关闭后台服务。
## 📝 获取 API Key
本技能使用 AceData 提供的 Suno API,需要 API Key 才能使用。
申请地址:https://share.acedata.cloud/r/1uN88BrUTQ
---
**作者**:Jakey
**版本**:1.0.0
**日期**:2026-03-12
**寄语**: 龙虾好吃又好用
FILE:start_server.py
#!/usr/bin/env python3
"""
Start Ace Suno V5 Web Server
Automatically install dependencies if missing
"""
import subprocess
import sys
import os
def check_and_install(package):
"""Check if package is installed, install if not"""
try:
__import__(package)
print("[OK] " + package + " already installed")
return True
except ImportError:
print("Installing " + package + "...")
subprocess.check_call([sys.executable, "-m", "pip", "install", package])
return False
def main():
print("=" * 50)
print("Starting Ace Suno V5 Music Generation Server")
print("=" * 50)
print()
# Check dependencies
print("Checking dependencies...")
check_and_install('flask')
check_and_install('requests')
print()
# Change to web directory and start server
script_dir = os.path.dirname(os.path.abspath(__file__))
web_dir = os.path.join(script_dir, 'web')
os.chdir(web_dir)
print()
print("Starting server at http://localhost:1688")
print("Open browser and visit http://localhost:1688 to start creating music")
print("Click the black circle button on top right to close server")
print()
# Start the server
os.execvp(sys.executable, [sys.executable, "app.py"])
if __name__ == "__main__":
main()
FILE:web/app.py
from flask import Flask, render_template, request, jsonify, send_from_directory
import os
import sys
import datetime
import json
# Add parent directory to path to import suno_client
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from scripts.suno_client import AceSunoClient
app = Flask(__name__)
# Configuration
BASE_MUSIC_DIR = os.path.join(os.path.expanduser("~"), "Desktop", "music")
HISTORY_FILE = os.path.join(os.path.dirname(__file__), 'generation_history.json')
# Create directories if they don't exist
os.makedirs(BASE_MUSIC_DIR, exist_ok=True)
if not os.path.exists(HISTORY_FILE):
with open(HISTORY_FILE, 'w') as f:
json.dump([], f)
def save_to_history(track_info):
"""Save generated track to history"""
history = []
if os.path.exists(HISTORY_FILE):
with open(HISTORY_FILE, 'r') as f:
history = json.load(f)
history.insert(0, track_info)
# Keep only last 50 entries
if len(history) > 50:
history = history[:50]
with open(HISTORY_FILE, 'w') as f:
json.dump(history, f, indent=2)
def get_history():
"""Get generation history"""
if os.path.exists(HISTORY_FILE):
with open(HISTORY_FILE, 'r') as f:
return json.load(f)
return []
@app.route('/')
def index():
"""Render main page"""
return render_template('index.html')
@app.route('/generate', methods=['POST'])
def generate():
"""Handle music generation request"""
try:
data = request.get_json()
api_key = data.pop('api_key', None)
client = AceSunoClient(api_key=api_key)
# Extract parameters from request - only add when true or non-empty
params = {
'action': data.get('action', 'generate'),
'prompt': data.get('prompt', ''),
'model': data.get('model', 'chirp-v5'),
}
# Only add boolean params when true
custom_val = data.get('custom', False)
if custom_val:
params['custom'] = True
instrumental_val = data.get('instrumental', False)
if instrumental_val:
params['instrumental'] = True
# Add optional parameters if provided
optional_fields = [
'lyric', 'title', 'style', 'style_negative', 'audio_weight',
'audio_id', 'vocal_gender', 'weirdness', 'lyric_prompt', 'callback_url',
'overpainting_start', 'overpainting_end', 'underpainting_start', 'underpainting_end',
'persona_id', 'continue_at', 'style_influence', 'replace_section_end', 'replace_section_start'
]
for field in optional_fields:
if field in data and data[field] not in ['', None]:
if field in ['audio_weight', 'overpainting_start', 'overpainting_end',
'underpainting_start', 'underpainting_end', 'continue_at',
'style_influence', 'replace_section_end', 'replace_section_start']:
params[field] = float(data[field])
elif field == 'weirdness':
params[field] = int(data[field])
else:
params[field] = data[field]
# Call API
try:
response = client.generate(**params)
except Exception as e:
# Return parameters for debugging
error_msg = f"{str(e)}\n\nSent parameters:\n{repr(params)}"
return jsonify({'error': error_msg, 'params': params}), 400
if not response.get('success'):
error_msg = response.get('message', 'Generation failed')
return jsonify({'error': error_msg, 'params': params, 'response': response}), 400
# Save files to desktop
output_dir = client.save_generation(response['data'], BASE_MUSIC_DIR)
# Save to history
for track in response['data']:
track_info = {
'id': track['id'],
'title': track.get('title', 'Untitled'),
'prompt': params.get('prompt', ''),
'model': params.get('model'),
'created_at': track.get('created_at'),
'duration': track.get('duration'),
'audio_url': track.get('audio_url'),
'image_url': track.get('image_url'),
'local_dir': output_dir,
'file_name': f"{track.get('title', 'untitled').replace(' ', '_')}_{track['id']}.mp3"
}
save_to_history(track_info)
return jsonify({
'success': True,
'output_dir': output_dir,
'tracks': response['data']
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/history')
def history():
"""Get generation history"""
return jsonify(get_history())
@app.route('/download/<date>/<filename>')
def download(date, filename):
"""Download generated file"""
directory = os.path.join(BASE_MUSIC_DIR, date)
return send_from_directory(directory, filename, as_attachment=True)
@app.route('/music/<path:filename>')
def serve_music(filename):
"""Serve music file for playback"""
return send_from_directory(BASE_MUSIC_DIR, filename)
@app.route('/shutdown', methods=['POST'])
def shutdown():
"""Shutdown the server"""
import os
os._exit(0)
if __name__ == '__main__':
app.run(debug=True, port=1688)
FILE:web/requirements.txt
flask
requests
FILE:web/templates/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ace Suno V5 - AI音乐创作</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.main-container {
width: 100%;
max-width: 700px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 20px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
/* Top Bar */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
background: transparent;
}
.mode-switch {
display: inline-flex;
background: #eee;
border-radius: 25px;
padding: 4px;
}
.mode-btn {
padding: 8px 24px;
background: transparent;
color: #666;
border: none;
border-radius: 21px;
cursor: pointer;
font-weight: 600;
font-size: 15px;
transition: all 0.3s;
width: auto;
}
.mode-btn.active {
background: white;
color: #333;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.instrumental-toggle-container {
display: inline-flex;
background: #eee;
border-radius: 25px;
padding: 4px;
align-items: center;
}
.instrumental-toggle-btn {
width: 40px;
height: 28px;
border-radius: 14px;
background: #eee;
position: relative;
cursor: pointer;
transition: all 0.3s;
}
.instrumental-toggle-btn.active {
background: #ff4757;
}
.instrumental-toggle-btn::after {
content: '';
position: absolute;
width: 22px;
height: 22px;
background: white;
border-radius: 50%;
top: 3px;
left: 3px;
transition: left 0.3s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.instrumental-toggle-btn.active::after {
left: 15px;
}
.instrumental-label {
padding-right: 12px;
padding-left: 8px;
font-weight: 600;
color: #333;
font-size: 15px;
}
.shutdown-btn {
background: #ff4757;
color: white;
border: none;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
transition: all 0.3s;
width: auto;
}
.shutdown-btn:hover {
background: #ff3838;
box-shadow: 0 3px 10px rgba(255, 71, 87, 0.4);
}
/* Content Area */
.content-area {
padding: 0 24px 24px;
}
/* Simple Mode */
.prompt-container {
margin-bottom: 20px;
}
.prompt-container label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 15px;
}
.prompt-container textarea {
width: 100%;
min-height: 140px;
padding: 14px;
border: 2px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
font-size: 15px;
resize: vertical;
background: rgba(255, 255, 255, 0.8);
transition: border-color 0.3s;
}
.prompt-container textarea:focus {
outline: none;
border-color: #667eea;
}
/* Custom Mode */
.custom-title {
margin-bottom: 16px;
}
.custom-title label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: #333;
}
.custom-title input {
width: 100%;
padding: 12px 14px;
border: 2px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
font-size: 15px;
background: rgba(255, 255, 255, 0.8);
}
.custom-title input:focus {
outline: none;
border-color: #667eea;
}
.custom-lyric-container {
margin-bottom: 16px;
}
.custom-lyric-container label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: #333;
}
.custom-lyric-container textarea {
width: 100%;
min-height: 240px;
padding: 14px;
border: 2px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
font-size: 15px;
resize: vertical;
background: rgba(255, 255, 255, 0.8);
}
.custom-lyric-container textarea:focus {
outline: none;
border-color: #667eea;
}
/* Style Categories - always visible */
.style-categories {
margin-bottom: 20px;
padding: 16px;
background: rgba(248, 249, 250, 0.7);
border-radius: 12px;
}
.style-categories label {
display: block;
margin-bottom: 12px;
font-weight: 600;
color: #333;
}
.category-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.category-btn {
padding: 6px 14px;
background: white;
border: 1px solid #ddd;
border-radius: 20px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
width: auto;
}
.category-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.tag-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px;
background: white;
border-radius: 8px;
min-height: 46px;
border: 1px solid #eee;
}
.tag-item {
padding: 4px 10px;
background: white;
border: 1px solid #667eea;
color: #667eea;
border-radius: 16px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.tag-item:hover {
background: #667eea;
color: white;
}
.selected-tags {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.selected-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: #667eea;
color: white;
border-radius: 16px;
font-size: 12px;
}
.remove-tag {
cursor: pointer;
font-weight: bold;
width: 16px;
height: 16px;
line-height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255,255,255,0.3);
}
/* Action Button */
.generate-container {
margin-bottom: 24px;
}
.generate-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 18px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
letter-spacing: 0.5px;
}
.generate-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5);
}
.generate-btn:active {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
}
.generate-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* History Section */
.history-section {
border-top: 2px solid rgba(0, 0, 0, 0.08);
padding-top: 20px;
}
.history-section h3 {
margin-bottom: 14px;
color: #333;
font-size: 17px;
}
.track-list {
max-height: 360px;
overflow-y: auto;
}
.track-card {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
padding: 10px;
margin-bottom: 10px;
display: grid;
grid-template-columns: 56px 1fr;
gap: 10px;
align-items: center;
background: rgba(255, 255, 255, 0.6);
}
.track-cover {
width: 56px;
height: 56px;
border-radius: 8px;
object-fit: cover;
background: #eee;
}
.track-info h4 {
margin-bottom: 3px;
color: #333;
font-size: 14px;
}
.track-info p {
color: #666;
font-size: 12px;
margin-bottom: 5px;
}
.track-actions {
display: flex;
gap: 6px;
}
.track-actions button, .track-actions a {
padding: 4px 8px;
font-size: 11px;
width: auto;
text-decoration: none;
border-radius: 4px;
}
audio {
width: 100%;
margin-top: 4px;
height: 28px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
}
/* Modal for API Key */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-overlay.show {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 16px;
width: 90%;
max-width: 480px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
}
.modal-content h2 {
margin-bottom: 16px;
color: #333;
}
.modal-content p {
margin-bottom: 16px;
color: #666;
line-height: 1.6;
}
.modal-content a {
color: #667eea;
word-break: break-all;
}
.modal-content input {
width: 100%;
padding: 12px;
border: 2px solid rgba(0,0,0,0.08);
border-radius: 8px;
margin-bottom: 16px;
font-size: 15px;
}
.modal-buttons {
display: flex;
gap: 12px;
}
.modal-buttons button {
flex: 1;
width: auto;
border-radius: 8px;
padding: 12px 20px;
font-weight: 600;
transition: all 0.3s;
}
.modal-buttons button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-cancel {
background: #f0f0f0 !important;
color: #666 !important;
box-shadow: none !important;
}
.btn-cancel:hover {
background: #e0e0e0 !important;
}
.loading {
text-align: center;
padding: 16px;
display: none;
}
.loading.active {
display: block;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
background: #fee;
color: #c33;
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
display: none;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
.error.show {
display: block;
}
.hidden {
display: none !important;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #667eea;
}
/* Floating Shutdown Button */
.floating-shutdown {
position: fixed;
top: 30px;
right: 30px;
width: 60px;
height: 60px;
border-radius: 50%;
background: #111111;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
transition: all 0.3s;
z-index: 9999;
border: none;font-size:30px
}
.floating-shutdown:hover {
transform: scale(1.1);
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.4);
background: #333333;
}
</style>
</head>
<body>
<!-- Floating Shutdown Button -->
<div class="floating-shutdown" onclick="shutdownServer()" title="关闭服务">
<svg width="32" height="32" viewBox="2 3 24 24" fill="none" stroke="currentColor" stroke-width="2" style="border: none;">
<line x1="9" y1="9" x2="20" y2="20"></line>
<line x1="20" y1="9" x2="9" y2="20"></line>
</svg>
</div>
<div class="main-container">
<!-- Top Bar -->
<div class="top-bar">
<div class="mode-switch">
<button class="mode-btn active" data-mode="simple" onclick="switchMode('simple')">简易模式</button>
<button class="mode-btn" data-mode="custom" onclick="switchMode('custom')">自定义</button>
</div>
<div class="instrumental-toggle-container">
<div class="instrumental-toggle-btn" id="instrumentalToggle" onclick="toggleInstrumental()"></div>
<span class="instrumental-label">纯音乐</span>
</div>
</div>
<!-- Content Area -->
<div class="content-area">
<!-- Simple Mode Content -->
<div id="simpleMode">
<div class="prompt-container">
<label>描述你想要的音乐</label>
<textarea id="simplePrompt" placeholder="例如:一首轻快的流行歌曲,带有温暖的旋律,适合夏日聆听..."></textarea>
</div>
</div>
<!-- Custom Mode Content -->
<div id="customMode" class="hidden">
<div class="custom-title">
<label>歌曲标题</label>
<input type="text" id="customTitle" placeholder="输入歌曲标题">
</div>
<div class="custom-lyric-container">
<label>歌词</label>
<textarea id="customLyric" placeholder="[Verse] 第一句歌词 第二句歌词 [Chorus] 副歌部分..."></textarea>
</div>
<div class="custom-lyric-container" style="margin-bottom: 10px;">
<label>风格描述</label>
<textarea id="customStyle" placeholder="描述音乐风格,点击标签快速添加..." style="min-height: 60px;"></textarea>
</div>
</div>
<!-- Style Categories - Always Visible -->
<div class="style-categories">
<label>快速添加标签</label>
<div class="category-buttons">
<button class="category-btn active" data-category="style">风格</button>
<button class="category-btn" data-category="mood">情感</button>
<button class="category-btn" data-category="instrument">乐器</button>
<button class="category-btn" data-category="atmosphere">氛围</button>
<button class="category-btn" data-category="gender">声音性别</button>
</div>
<div class="tag-container" id="tagContainer"></div>
<div class="selected-tags" id="selectedTags"></div>
</div>
<div class="error" id="errorMessage"></div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>正在生成音乐,请稍候...</p>
</div>
<div class="generate-container">
<button class="generate-btn" id="generateBtn" onclick="handleGenerate()">立即创作 🎶</button>
</div>
<!-- Generated Tracks History -->
<div class="history-section">
<h3>已生成音乐</h3>
<div class="track-list" id="trackList">
<div class="empty-state">
<p>您还没有创作音乐</p>
</div>
</div>
</div>
</div>
</div>
<!-- API Key Modal -->
<div class="modal-overlay" id="apiKeyModal">
<div class="modal-content">
<h2>需要 API Key</h2>
<p>还没有配置 AceData API Key,请输入你的 API Key。如果还没有,可以点击下方链接申请:</p>
<p><a href="https://share.acedata.cloud/r/1uN88BrUTQ" target="_blank">https://share.acedata.cloud/r/1uN88BrUTQ</a></p>
<input type="password" id="modalApiKey" placeholder="输入你的 API Key">
<div class="modal-buttons">
<button class="btn-cancel" onclick="closeApiModal()">取消</button>
<button onclick="saveApiKey()">保存</button>
</div>
</div>
</div>
<script>
// Predefined tags by category
const tagsByCategory = {
style: [
'流行', '摇滚', '嘻哈', '说唱', '爵士', '蓝调', '古典', '电子',
'舞曲', '民谣', '乡村', 'R&B', '灵魂乐', '雷鬼', '朋克', '金属',
],
mood: [
'快乐', '悲伤', '激情', '平静', '浪漫', '忧郁', '振奋', '放松',
],
instrument: [
'钢琴', '吉他', '小提琴', '鼓', '贝斯', '萨克斯', '小号', '长笛',
'合成器', '人声', '二胡', '古筝', '大提琴', '电吉他', '贝斯'
],
atmosphere: [
'温暖', '寒冷', '夏日', '冬日', '夜晚', '清晨', '城市', '自然',
'梦幻', '史诗', '黑暗', '光明', '神秘', '安静', '嘈杂'
],
gender: ['男声', '女声']
};
let currentCategory = 'style';
let selectedTags = [];
let currentMode = 'simple';
let isInstrumental = false;
let apiKey = localStorage.getItem('suno_api_key') || '';
// Initialize
document.addEventListener('DOMContentLoaded', function() {
renderTags();
renderSelectedTags();
loadHistory();
if (apiKey) {
document.getElementById('modalApiKey').value = apiKey;
}
});
// Switch mode
function switchMode(mode) {
currentMode = mode;
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.mode === mode) {
btn.classList.add('active');
}
});
if (mode === 'simple') {
document.getElementById('simpleMode').classList.remove('hidden');
document.getElementById('customMode').classList.add('hidden');
} else {
document.getElementById('simpleMode').classList.add('hidden');
document.getElementById('customMode').classList.remove('hidden');
}
updateSelectedTagsText();
}
// Toggle instrumental
function toggleInstrumental() {
isInstrumental = !isInstrumental;
const toggle = document.getElementById('instrumentalToggle');
if (isInstrumental) {
toggle.classList.add('active');
} else {
toggle.classList.remove('active');
}
}
// Switch category
document.querySelectorAll('.category-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
currentCategory = this.dataset.category;
renderTags();
});
});
// Render tags in container
function renderTags() {
const container = document.getElementById('tagContainer');
container.innerHTML = '';
tagsByCategory[currentCategory].forEach(tag => {
if (!selectedTags.includes(tag)) {
const tagEl = document.createElement('div');
tagEl.className = 'tag-item';
tagEl.textContent = tag;
tagEl.onclick = () => addTag(tag);
container.appendChild(tagEl);
}
});
}
// Render selected tags
function renderSelectedTags() {
const container = document.getElementById('selectedTags');
container.innerHTML = '';
selectedTags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'selected-tag';
tagEl.innerHTML = `tag <span class="remove-tag" onclick="removeTag('tag')">×</span>`;
container.appendChild(tagEl);
});
updateSelectedTagsText();
}
// Update text in input based on mode
function updateSelectedTagsText() {
const tagText = selectedTags.filter(t => !['男声', '女声'].includes(t)).join(', ');
if (currentMode === 'simple') {
const promptEl = document.getElementById('simplePrompt');
let currentText = (promptEl.value || '').trim();
// Remove existing tag text from the end, add fresh
// We keep the user's original text and just append tags
const hasExistingTags = currentText.includes(',');
const baseText = currentText.split(',')[0].trim();
if (tagText && baseText) {
promptEl.value = `baseText, tagText`;
} else if (tagText) {
promptEl.value = tagText;
} else if (baseText) {
promptEl.value = baseText;
} else {
promptEl.value = '';
}
} else {
const styleEl = document.getElementById('customStyle');
let currentStyle = (styleEl.value || '').trim();
// Get manual user input that's not tags
const existingParts = currentStyle.split(',').map(t => t.trim()).filter(t => t);
const manualTags = existingParts.filter(t => !Object.values(tagsByCategory).flat().includes(t));
let combined = [...selectedTags.filter(t => !['男声', '女声'].includes(t))];
if (manualTags.length > 0) {
combined = [...combined, ...manualTags];
}
styleEl.value = combined.filter(t => t).join(', ');
}
}
// Add tag
function addTag(tag) {
if (tag === '男声') {
if (selectedTags.includes('男声')) {
selectedTags = selectedTags.filter(t => t !== '男声');
} else {
selectedTags = selectedTags.filter(t => t !== '女声');
selectedTags.push('男声');
}
} else if (tag === '女声') {
if (selectedTags.includes('女声')) {
selectedTags = selectedTags.filter(t => t !== '女声');
} else {
selectedTags = selectedTags.filter(t => t !== '男声');
selectedTags.push('女声');
}
} else {
if (!selectedTags.includes(tag)) {
selectedTags.push(tag);
}
}
renderTags();
renderSelectedTags();
}
// Remove tag
function removeTag(tag) {
selectedTags = selectedTags.filter(t => t !== tag);
renderTags();
renderSelectedTags();
}
// Get vocal gender from selected tags
function getVocalGender() {
if (selectedTags.includes('男声')) return 'm';
if (selectedTags.includes('女声')) return 'f';
return '';
}
// Handle generate
function handleGenerate() {
if (!apiKey) {
openApiModal();
return;
}
generateMusic();
}
// Open API modal
function openApiModal() {
document.getElementById('apiKeyModal').classList.add('show');
}
// Close API modal
function closeApiModal() {
document.getElementById('apiKeyModal').classList.remove('show');
}
// Save API key
function saveApiKey() {
apiKey = document.getElementById('modalApiKey').value.trim();
if (apiKey) {
localStorage.setItem('suno_api_key', apiKey);
closeApiModal();
generateMusic();
}
}
// Collect data and generate - uses OpenClaw backend
async function generateMusic() {
const generateBtn = document.getElementById('generateBtn');
const loading = document.getElementById('loading');
const errorMessage = document.getElementById('errorMessage');
generateBtn.disabled = true;
loading.classList.add('active');
errorMessage.classList.remove('show');
// Collect parameters - default model chirp-v5
const model = 'chirp-v5';
const vocalGender = getVocalGender();
let data = {
api_key: apiKey,
model: model,
action: 'generate',
instrumental: isInstrumental
};
if (currentMode === 'simple') {
const prompt = document.getElementById('simplePrompt').value.trim();
data.prompt = prompt;
data.custom = false;
} else {
const title = document.getElementById('customTitle').value.trim();
const style = document.getElementById('customStyle').value.trim();
const lyric = document.getElementById('customLyric').value.trim();
data.prompt = '';
data.custom = true;
data.title = title;
data.style = style;
data.lyric = lyric;
}
if (vocalGender) {
data.vocal_gender = vocalGender;
}
try {
const response = await fetch('/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '生成失败');
}
alert(`生成成功!文件已保存到:result.output_dir`);
// Clear form for new generation
if (currentMode === 'simple') {
document.getElementById('simplePrompt').value = '';
} else {
document.getElementById('customTitle').value = '';
document.getElementById('customStyle').value = '';
document.getElementById('customLyric').value = '';
selectedTags = [];
renderTags();
renderSelectedTags();
}
loadHistory();
} catch (error) {
errorMessage.textContent = error.message;
errorMessage.classList.add('show');
} finally {
generateBtn.disabled = false;
loading.classList.remove('active');
}
}
// Load history
async function loadHistory() {
const container = document.getElementById('trackList');
try {
const response = await fetch('/history');
const history = await response.json();
if (!history || history.length === 0) {
container.innerHTML = '<div class="empty-state"><p>您还没有创作音乐</p></div>';
return;
}
container.innerHTML = '';
history.forEach(track => {
const card = document.createElement('div');
card.className = 'track-card';
const date = track.created_at ? new Date(track.created_at).toLocaleString() : 'Unknown';
const duration = track.duration ? `Math.round(track.duration)s` : '';
card.innerHTML = `
<img src="track.image_url" class="track-cover" onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width=%2256%22 height=%2256%22 viewBox=%220 0 56 56%22%3E%3Crect fill=%22%23eee%22 width=%2256%22 height=%2256%22%3E%3C%2Frect%3E%3Ctext x=%2250%25%22 y=%2250%25%22 dominant-baseline=%22middle%22 text-anchor=%22middle%22 fill=%22%23999%22%3E🎵%3C%2Ftext%3E%3C%2Fsvg%3E'">
<div class="track-info">
<h4>escapeHtml(track.title)</h4>
<p>escapeHtml(track.prompt || track.style || '') • date duration</p>
track.audio_url ? `<audio controls src="${track.audio_url"></audio>` : ''}
<div class="track-actions">
<a href="/download/getCurrentDate()/track.file_name" download>下载 MP3</a>
track.lyric ? `<button onclick="showLyrics('${escapeHtml(track.title)', 'escapeHtml(track.lyric || '').replace(/'/g, ''')')">歌词</button>` : ''}
</div>
</div>
`;
container.appendChild(card);
});
} catch (error) {
container.innerHTML = '<div class="empty-state"><p>您还没有创作音乐</p></div>';
}
}
function showLyrics(title, lyric) {
alert(`歌曲:title\n\nlyric.replace(/\\n/g, '\n')`);
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getCurrentDate() {
const today = new Date();
return today.toISOString().split('T')[0];
}
// Shutdown server
async function shutdownServer() {
if (confirm('确定要关闭音乐创作服务吗?')) {
try {
await fetch('/shutdown', { method: 'POST' });
} catch (e) {
// Connection will be closed before response, expected
}
alert('服务已关闭,可以关闭此页面了');
}
}
</script>
</body>
</html>
FILE:scripts/suno_client.py
import requests
import os
import datetime
from typing import Optional, Dict, List, Any
class AceSunoClient:
"""Python client for AceData Suno v5 music generation API"""
def __init__(self, api_key: Optional[str] = None, base_url: str = "https://api.acedata.cloud/suno"):
"""
Initialize the AceData Suno client
Args:
api_key: AceData API key (defaults to ACE_DATA_API_KEY environment variable)
base_url: Base URL for the API
"""
self.api_key = api_key or os.getenv("ACE_DATA_API_KEY")
if not self.api_key:
raise ValueError("API key must be provided or set as ACE_DATA_API_KEY environment variable")
self.base_url = base_url
self.session = requests.Session()
self.session.headers.update({
"accept": "application/json",
"Content-Type": "application/json",
"authorization": f"Bearer {self.api_key}"
})
def generate(
self,
prompt: str = "",
action: str = "generate",
model: str = "chirp-v5",
lyric: Optional[str] = None,
custom: bool = False,
instrumental: bool = False,
title: Optional[str] = None,
style: Optional[str] = None,
style_negative: Optional[str] = None,
audio_weight: Optional[float] = None,
audio_id: Optional[str] = None,
vocal_gender: Optional[str] = None,
weirdness: Optional[int] = None,
lyric_prompt: Optional[str] = None,
callback_url: Optional[str] = None,
overpainting_start: Optional[float] = None,
overpainting_end: Optional[float] = None,
underpainting_start: Optional[float] = None,
underpainting_end: Optional[float] = None,
persona_id: Optional[str] = None,
continue_at: Optional[float] = None,
style_influence: Optional[float] = None,
replace_section_end: Optional[float] = None,
replace_section_start: Optional[float] = None,
**kwargs
) -> Dict[str, Any]:
"""
Generate music with Suno v5 API
Args:
action: Generation action (generate, extend, cover, etc.)
prompt: Inspiration mode prompt
model: Model version (chirp-v3, chirp-v4, chirp-v3-5, chirp-v4-5, chirp-v4-5-plus, chirp-v5)
lyric: Custom lyrics for custom mode
custom: Whether to use custom mode (default: False)
instrumental: Generate instrumental in inspiration mode
title: Song title for custom mode
style: Song style for custom mode
style_negative: Excluded styles
audio_weight: Reference audio weight (0-1)
audio_id: Reference audio ID
vocal_gender: Vocal gender (f for female, m for male)
weirdness: Weirdness level
lyric_prompt: Prompt for lyric generation (when custom=true and lyric empty)
overpainting_start: Add vocals to instrumental - start time (seconds)
overpainting_end: Add vocals to instrumental - end time (seconds)
underpainting_start: Add accompaniment to acapella - start time (seconds)
underpainting_end: Add accompaniment to acapella - end time (seconds)
persona_id: Artist song ID
continue_at: Continue existing audio at time (seconds)
style_influence: Style influence advanced parameter
replace_section_end: Replace section - end time
replace_section_start: Replace section - start time
Returns:
API response with generated audio data
"""
endpoint = f"{self.base_url}/audios"
# Base required parameters
payload: Dict[str, Any] = {
"action": action,
"model": model
}
# Only add parameters when they are true or have non-empty values
if prompt and prompt.strip():
payload["prompt"] = prompt.strip()
if custom:
payload["custom"] = custom # only send when true
if instrumental:
payload["instrumental"] = instrumental # only send when true
if lyric and lyric.strip():
payload["lyric"] = lyric.strip()
if title and title.strip():
payload["title"] = title.strip()
if style and style.strip():
payload["style"] = style.strip()
if style_negative and style_negative.strip():
payload["style_negative"] = style_negative.strip()
if audio_weight is not None:
payload["audio_weight"] = audio_weight
if audio_id and audio_id.strip():
payload["audio_id"] = audio_id.strip()
if vocal_gender and vocal_gender.strip():
payload["vocal_gender"] = vocal_gender.strip()
if weirdness is not None:
payload["weirdness"] = weirdness
if lyric_prompt and lyric_prompt.strip():
payload["lyric_prompt"] = lyric_prompt.strip()
if callback_url and callback_url.strip():
payload["callback_url"] = callback_url.strip()
if overpainting_start is not None:
payload["overpainting_start"] = overpainting_start
if overpainting_end is not None:
payload["overpainting_end"] = overpainting_end
if underpainting_start is not None:
payload["underpainting_start"] = underpainting_start
if underpainting_end is not None:
payload["underpainting_end"] = underpainting_end
if persona_id and persona_id.strip():
payload["persona_id"] = persona_id.strip()
if continue_at is not None:
payload["continue_at"] = continue_at
if style_influence is not None:
payload["style_influence"] = style_influence
if replace_section_end is not None:
payload["replace_section_end"] = replace_section_end
if replace_section_start is not None:
payload["replace_section_start"] = replace_section_start
# Add any additional parameters
payload.update(kwargs)
response = self.session.post(endpoint, json=payload)
response.raise_for_status()
return response.json()
def download_file(self, url: str, output_path: str) -> None:
"""Download file from URL to local path"""
response = requests.get(url, stream=True)
response.raise_for_status()
with open(output_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
def save_generation(self, data: List[Dict[str, Any]], base_dir: str = "C:\\Users\\86137\\Desktop\\music") -> str:
"""
Save generated music and images to desktop music folder with date
Args:
data: Generated data from API response
base_dir: Base directory to save files
Returns:
Path to the created date directory
"""
today = datetime.datetime.now().strftime("%Y-%m-%d")
output_dir = os.path.join(base_dir, today)
os.makedirs(output_dir, exist_ok=True)
for track in data:
track_id = track["id"]
title = track.get("title", f"untitled_{track_id}").replace(" ", "_").replace("/", "_")
# Download audio
if track.get("audio_url"):
audio_path = os.path.join(output_dir, f"{title}_{track_id}.mp3")
self.download_file(track["audio_url"], audio_path)
# Download image
if track.get("image_url"):
ext = os.path.splitext(track["image_url"])[1] or ".jpg"
image_path = os.path.join(output_dir, f"{title}_{track_id}{ext}")
self.download_file(track["image_url"], image_path)
# Save lyric
if track.get("lyric"):
lyric_path = os.path.join(output_dir, f"{title}_{track_id}.txt")
with open(lyric_path, "w", encoding="utf-8") as f:
f.write(track["lyric"])
return output_dir
Generate and edit images using the AceData Nano Banana API. Supports models like nano-banana-2, custom aspect ratios (default 16:9), and resolutions (default...
---
name: ace-banana2
description: Generate and edit images using the AceData Nano Banana API. Supports models like nano-banana-2, custom aspect ratios (default 16:9), and resolutions (default 2K). Handles batch generation and image-to-image (edit) tasks with local files. Use when the user wants to generate or edit high-quality images via Nano Banana.
---
# Ace Banana2 Image Generation / Ace Banana2 图像生成
**English** | **中文**
---
## Overview / 概述
**English:**
Ace Banana2 is a powerful image generation and editing skill that leverages the AceData Nano Banana API. It provides a seamless workflow for creating high-quality images from text prompts or editing existing images with AI-powered transformations. The skill supports multiple models, customizable parameters, and automatic saving of generated images to your desktop.
**中文:**
Ace Banana2 是一个功能强大的图像生成和编辑技能,基于 AceData Nano Banana API。它提供了一个无缝的工作流程,可以从文本提示生成高质量图像,或使用 AI 驱动的转换编辑现有图像。该技能支持多种模型、可自定义参数,并自动将生成的图像保存到桌面。
AceData模型调用价格非常划算,每张照片不超过0.5元,还是很吸引人的。
---
## Model Introduction / 模型介绍
**English:**
The Nano Banana API offers several cutting‑edge image generation models:
- **nano‑banana‑2** (default): Professional‑quality image generation with flash speed. Ideal for most creative tasks.
- **nano‑banana‑pro**: Enhanced model for image‑to‑image editing and higher‑fidelity outputs.
- **nano‑banana**: The original model, suitable for general‑purpose generation.
All models support resolutions up to **4K** and aspect ratios such as **16:9**, **1:1**, **4:3**, etc.
**中文:**
Nano Banana API 提供多种先进的图像生成模型:
- **nano‑banana‑2**(默认):具有快速生成速度的专业级图像生成模型,适合大多数创意任务。
- **nano‑banana‑pro**:增强模型,适用于图像到图像的编辑和更高保真度的输出。
- **nano‑banana**:原始模型,适合通用生成,不推荐使用。
所有模型支持高达 **4K** 的分辨率和 **16:9**、**9:16**、**1:1**、**4:3**、**3:4** 等宽高比。
---
## API Key Application / API 密钥申请说明
**English:**
To use this skill, you need an AceData API key (Bearer Token). Follow these steps:
1. Visit the [AceData registration page](https://share.acedata.cloud/r/1uN83988Tn).
2. Sign up or log in to your AceData account.
3. Navigate to the **API Keys** section in your dashboard.
4. Generate a new API key (Bearer Token) with access to the **Nano Banana** service.
5. Copy the key and keep it secure.
**中文:**
使用本技能需要 AceData API 密钥(Bearer Token)。请按以下步骤操作:
1. 访问 [AceData 注册页面](https://share.acedata.cloud/r/1uN88BrUTQ)。
2. 注册或登录您的 AceData 账户。
3. 在控制台中转到 **API Keys** 部分。
4. 生成一个新的 API 密钥(Bearer Token),确保其具有 **Nano Banana** 服务的访问权限。
5. 复制密钥并妥善保管。
---
## Installation & Usage Steps / 安装与使用步骤
### Step 1: Install Dependencies / 第一步:安装依赖
**English:**
Ensure you have Python 3.7+ installed. Then install required packages:
```bash
pip install requests pillow
```
**中文:**
确保已安装 Python 3.7+,然后安装所需包:
```bash
pip install requests pillow
```
### Step 2: Configure API Key / 第二步:配置 API 密钥
**English:**
Run the script once, and it will prompt you to enter your Bearer Token. The token will be saved in a `.env` file inside the skill directory for future use.
**中文:**
运行脚本一次,它将提示您输入 Bearer Token。令牌将保存在技能目录的 `.env` 文件中,供以后使用。
### Step 3: Run the Script / 第三步:运行脚本
**English:**
Navigate to the skill directory and execute:
```bash
python scripts/generate_images.py
```
You will be prompted for a text description (prompt) or can provide command‑line arguments.
**中文:**
进入技能目录并执行:
```bash
python scripts/generate_images.py
```
系统将提示您输入文本描述(提示词),或者您可以直接提供命令行参数。
---
## Features / 功能特性
**English:**
- **Dual‑mode Operation**: Supports both text‑to‑image (`generate`) and image‑to‑image (`edit`) workflows.
- **Local & Remote Images**: Upload up to 4 local images (converted to Base64) or provide image URLs.
- **CDN Upload Priority**: Local images are first uploaded to AceData CDN to obtain public URLs, avoiding Base64 encoding overhead and improving transmission efficiency.
- **Automatic Image Resizing**: Large images are automatically resized to comply with API limits.
- **Batch Generation**: Generate multiple images in a single request.
- **Smart Saving**: Images are saved to a dated folder on your desktop with unique timestamps.
- **Detailed Logging**: Full JSON response is displayed for debugging and transparency.
**中文:**
- **双模式操作**:支持文生图(`generate`)和图生图(`edit`)工作流。
- **本地与远程图像**:最多上传 4 张本地图像或提供图像 URL。
- **CDN 上传优先**:本地图像优先上传至 AceData CDN 获取公开 URL,避免 Base64 编码的数据膨胀,提高传输效率。
- **自动图像调整**:大图像自动调整大小以符合 API 限制。
- **批量生成**:单次请求生成多张图像。
- **智能保存**:图像保存到桌面的日期文件夹中,文件名包含唯一时间戳。
- **详细日志**:显示完整的 JSON 响应,便于调试和透明化。
---
## Parameters / 参数详解
| Parameter / 参数 | Default / 默认值 | Description / 说明 |
|------------------|------------------|-------------------|
| `--prompt` | (required for generate) | Text description of the desired image / 期望图像的文本描述 |
| `--count` | `1` | Number of images to generate / 要生成的图像数量 |
| `--model` | `nano-banana-2` | Model to use (`nano-banana-2`, `nano-banana-pro`, `nano-banana`) / 使用的模型 |
| `--resolution` | `2K` | Output resolution (`2K`, `4K`, etc.) / 输出分辨率 |
| `--aspect_ratio` | `16:9` | Aspect ratio (`16:9`, `1:1`, `4:3`, etc.) / 宽高比 |
| `--image` | (optional) | Local image paths or URLs for edit mode (max 4) / 编辑模式的本地图像路径或 URL(最多 4 个) |
| `--api_key` | (optional) | Bearer Token for AceData API / AceData API 的 Bearer Token |
---
## Examples / 示例
### Example 1: Basic Text‑to‑Image / 示例 1:基础文生图
**English:**
```bash
python scripts/generate_images.py --prompt "a serene mountain landscape at sunset" --count 2 --resolution "4K"
```
**中文:**
```bash
python scripts/generate_images.py --prompt "日落时宁静的山景" --count 2 --resolution "4K"
```
### Example 2: Image Editing / 示例 2:图像编辑
**English:**
```bash
python scripts/generate_images.py --image "input.jpg" --prompt "make it look like a watercolor painting"
```
**中文:**
```bash
python scripts/generate_images.py --image "input.jpg" --prompt "让它看起来像水彩画"
```
### Example 3: Batch Generation with Custom Aspect Ratio / 示例 3:自定义宽高比的批量生成
**English:**
```bash
python scripts/generate_images.py --prompt "cyberpunk city street" --count 4 --aspect_ratio "1:1"
```
**中文:**
```bash
python scripts/generate_images.py --prompt "赛博朋克城市街道" --count 4 --aspect_ratio "1:1"
```
---
## Notes / 注意事项
**English:**
- The API may have rate limits and usage quotas. Check your AceData dashboard for details.
- Generated images are stored on AceData's CDN for a limited time; download them promptly.
- For large images (>1 MB), the script automatically resizes them to avoid timeout errors.
- Ensure your internet connection is stable during generation (requests can take up to 180 seconds).
**中文:**
- API 可能有速率限制和使用配额。请查看您的 AceData 控制台了解详情。
- 生成的图像在 AceData 的 CDN 上存储时间有限,请及时下载。
---
## References / 参考资料
- [API Documentation](references/api_docs.md) – Detailed Nano Banana API specifications.
- [AceData Cloud Platform](https://share.acedata.cloud/r/1uN88BrUTQ) – Manage your API keys and usage.
FILE:scripts/generate_images.py
import os
import requests
import base64
import time
import json
import argparse
import shutil
from datetime import datetime
from pathlib import Path
from PIL import Image
import io
# Configuration
SKILL_DIR = Path(__file__).parent.parent
ENV_FILE = SKILL_DIR / ".env"
API_URL = "https://api.acedata.cloud/nano-banana/images"
SHARE_URL = "https://share.acedata.cloud/r/1uN88BrUTQ"
def backup_skill_files():
"""Backup skill files to D:/backup/skill-name/date/ directory."""
try:
# Define backup root directory
backup_root = Path("D:/backup")
if not backup_root.exists():
backup_root.mkdir(parents=True)
print(f"[*] Created backup root directory: {backup_root}")
# Skill name from directory name
skill_name = SKILL_DIR.name
today = datetime.now().strftime("%Y-%m-%d")
# Backup directory path: D:/backup/skill-name/YYYY-MM-DD/
backup_dir = backup_root / skill_name / today
backup_dir.mkdir(parents=True, exist_ok=True)
# Files to backup
files_to_backup = [
SKILL_DIR / "SKILL.md",
SKILL_DIR / ".env",
Path(__file__), # Current script (generate_images.py)
SKILL_DIR / "references" / "api_docs.md"
]
backup_count = 0
for source_file in files_to_backup:
if source_file.exists():
# Create relative path for backup
if source_file.is_relative_to(SKILL_DIR):
rel_path = source_file.relative_to(SKILL_DIR)
else:
rel_path = source_file.name
# Ensure backup subdirectory exists
target_dir = backup_dir / rel_path.parent
target_dir.mkdir(parents=True, exist_ok=True)
# Copy file
target_file = backup_dir / rel_path
shutil.copy2(source_file, target_file)
backup_count += 1
print(f"[*] Backed up: {rel_path}")
# Also backup all Python scripts in scripts directory
scripts_dir = SKILL_DIR / "scripts"
if scripts_dir.exists():
for script_file in scripts_dir.glob("*.py"):
rel_script = script_file.relative_to(SKILL_DIR)
target_script_dir = backup_dir / rel_script.parent
target_script_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(script_file, backup_dir / rel_script)
backup_count += 1
print(f"[*] Backed up script: {rel_script}")
print(f"[+] Backup completed: {backup_count} files saved to {backup_dir}")
return True
except Exception as e:
print(f"[!] Backup failed: {e}")
return False
def get_api_key(passed_key=None):
"""Check for API key in environment or .env file. Prompt if missing."""
if passed_key:
with open(ENV_FILE, "w") as f:
f.write(f"ACEDATA_API_KEY={passed_key}\n")
return passed_key
if os.getenv("ACEDATA_API_KEY"):
return os.getenv("ACEDATA_API_KEY")
if ENV_FILE.exists():
with open(ENV_FILE, "r") as f:
for line in f:
if line.startswith("ACEDATA_API_KEY="):
return line.strip().split("=", 1)[1].strip('"')
return None
def resize_image_if_needed(image_path, target_size_mb=10.0):
"""Resize image to be under target_size_mb."""
try:
path = Path(image_path)
original_size = path.stat().st_size / (1024 * 1024)
if original_size <= target_size_mb:
return image_to_base64(image_path)
print(f"[*] Image too large ({original_size:.2f}MB). Shrinking to <{target_size_mb}MB...")
with Image.open(image_path) as img:
# Convert to RGB if needed
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
quality = 90
while True:
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=quality)
size = buffer.tell() / (1024 * 1024)
if size <= target_size_mb or quality <= 10:
break
quality -= 10
encoded_string = base64.b64encode(buffer.getvalue()).decode("utf-8")
print(f"[*] Shrunk to {size:.2f}MB (Quality: {quality})")
return f"data:image/jpeg;base64,{encoded_string}"
except Exception as e:
print(f"[!] Error resizing image: {e}")
return image_to_base64(image_path)
def image_to_base64(image_path):
"""Read a local image and convert to Base64."""
try:
path = Path(image_path)
if not path.exists():
print(f"[!] File not found: {image_path}")
return None
with open(path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
ext = path.suffix.lower()
mime_type = "image/png"
if ext in [".jpg", ".jpeg"]: mime_type = "image/jpeg"
elif ext == ".gif": mime_type = "image/gif"
elif ext == ".webp": mime_type = "image/webp"
return f"data:{mime_type};base64,{encoded_string}"
except Exception as e:
print(f"[!] Error encoding image: {e}")
return None
def upload_to_cdn(image_path, token):
"""Upload a local image to AceData CDN and return the public URL."""
try:
path = Path(image_path)
if not path.exists():
print(f"[!] File not found: {image_path}")
return None
url = "https://platform.acedata.cloud/api/v1/files/"
headers = {"authorization": f"Bearer {token}"}
with open(path, "rb") as f:
# Determine MIME type based on file extension
ext = path.suffix.lower()
mime_type = "image/png" if ext == ".png" else "image/jpeg"
files = {"file": (path.name, f, mime_type)}
print(f"[*] Uploading {path.name} to CDN...")
resp = requests.post(url, headers=headers, files=files, timeout=60)
if resp.status_code == 200:
result = resp.json()
cdn_url = result.get("url")
if cdn_url:
print(f"[+] CDN URL: {cdn_url}")
return cdn_url
else:
print(f"[!] No URL in response: {result}")
return None
else:
print(f"[!] Upload failed: {resp.status_code} - {resp.text}")
return None
except Exception as e:
print(f"[!] Error uploading to CDN: {e}")
return None
def main():
parser = argparse.ArgumentParser(description="Generate images using Nano Banana API.")
parser.add_argument("--prompt", type=str, help="Text description of the image.")
parser.add_argument("--count", type=int, default=1, help="Number of images to generate.")
parser.add_argument("--model", type=str, default="nano-banana-2", help="Model to use.")
parser.add_argument("--resolution", type=str, default="2K", help="Resolution (e.g., 2K).")
parser.add_argument("--aspect_ratio", type=str, default="16:9", help="Aspect ratio (e.g., 16:9).")
parser.add_argument("--image", type=str, nargs='*', help="Local image paths or URLs for edit mode (up to 4).")
parser.add_argument("--api_key", type=str, help="Bearer Token for AceData API.")
args = parser.parse_args()
token = get_api_key(args.api_key)
if not token:
print("\n[!] ACEDATA_API_KEY not found.")
print(f"[!] Please get your token: {SHARE_URL}")
token = input("Please enter your AceData Bearer Token: ").strip()
if token:
with open(ENV_FILE, "w") as f: f.write(f"ACEDATA_API_KEY={token}\n")
else: exit(1)
prompt = args.prompt
if not prompt:
prompt = input("\nEnter prompt (or leave blank for edit): ").strip()
image_inputs = args.image
use_resized = False
def prepare_payload(resize=False):
payload = {
"action": "generate",
"model": "nano-banana-2",
"prompt": prompt or "No prompt provided",
"count": args.count,
"resolution": args.resolution,
"aspect_ratio": args.aspect_ratio
}
if image_inputs:
processed_urls = []
for inp in image_inputs[:4]:
if inp.startswith(("http://", "https://")):
processed_urls.append(inp)
else:
# First try to upload to CDN
cdn_url = upload_to_cdn(inp, token)
if cdn_url:
processed_urls.append(cdn_url)
else:
# Fall back to Base64 (with optional resizing)
print(f"[*] CDN upload failed for {inp}, falling back to Base64")
b64 = resize_image_if_needed(inp) if resize else image_to_base64(inp)
if b64:
processed_urls.append(b64)
if processed_urls:
payload["action"] = "edit"
payload["image_urls"] = processed_urls
# Edit requires nano-banana or nano-banana-pro
payload["model"] = "nano-banana-pro"
return payload
if not prompt and not image_inputs:
print("[!] Either prompt or image is required."); return
headers = {"authorization": f"Bearer {token}", "accept": "application/json", "content-type": "application/json"}
attempts = 0
max_attempts = 3
while attempts < max_attempts:
attempts += 1
payload = prepare_payload(resize=use_resized)
print(f"\n[*] Attempt {attempts}/{max_attempts}: model={payload['model']}, action={payload['action']}")
try:
print("[*] Sending request (180s timeout)...", flush=True)
resp = requests.post(API_URL, json=payload, headers=headers, timeout=180)
print(f"[*] Response: {resp.status_code}")
try:
data = resp.json()
except:
print(f"[!] Failed to parse JSON. Body: {resp.text}"); break
if data.get("success"):
print("[+] Success!")
desktop = Path(os.path.join(os.environ['USERPROFILE'], 'Desktop'))
today = datetime.now().strftime("%Y-%m-%d")
output_dir = desktop / today
output_dir.mkdir(parents=True, exist_ok=True)
for idx, item in enumerate(data.get("data", [])):
img_url = item.get("image_url")
if img_url:
img_resp = requests.get(img_url)
timestamp = int(time.time() * 1000)
filename = f"banana_{timestamp}_{idx}.png"
with open(output_dir / filename, "wb") as f: f.write(img_resp.content)
print(f"[OK] Saved: Desktop/{today}/{filename}")
print("\n[Return Data]\n", json.dumps(data, indent=2, ensure_ascii=False))
return
else:
print(f"[!] API Error: {json.dumps(data.get('error', data))}")
break # Non-timeout errors usually shouldn't be retried blindly
except (requests.exceptions.Timeout, requests.exceptions.ReadTimeout):
print(f"[!] Timeout on attempt {attempts}.")
if attempts == max_attempts and image_inputs:
ans = input("[?] 3 attempts failed with timeout. Resize images to ~10MB and retry? (y/n): ").lower()
if ans == 'y':
attempts = 0 # Reset attempts for the resized run
use_resized = True
print("[*] Restarting with resized images...")
else: break
elif attempts < max_attempts:
print("[*] Retrying in 5 seconds...")
time.sleep(5)
except Exception as e:
print(f"[!] Error: {e}"); break
if __name__ == "__main__":
main()
FILE:references/api_docs.md
# Nano Banana Images API Documentation
## Overview
This API supports image generation and editing.
- **Base URL**: `https://api.acedata.cloud`
- **Endpoint**: `POST /nano-banana/images`
- **Authentication**: `Bearer {token}` in `Authorization` header.
## Request Parameters
- `action`: `generate` or `edit`
- `model`: `nano-banana`, `nano-banana-2` (default for this skill), `nano-banana-pro`
- `prompt`: Text description of the image.
- `count`: Number of images to generate.
- `image_urls`: Array of image URLs or Base64 strings (required for `edit`).
## Example Response
```json
{
"success": true,
"task_id": "...",
"data": [
{
"prompt": "...",
"image_url": "..."
}
]
}
```
## Note on Errors
- **403 Forbidden**: Usually means the API Key is invalid, has no quota, or the specific service (Nano Banana) hasn't been enabled for this key.
Generate and edit images using the AceData Nano Banana API. Supports models like nano-banana-2, custom aspect ratios (default 16:9), and resolutions (default...
---
name: ace-banana2
description: Generate and edit images using the AceData Nano Banana API. Supports models like nano-banana-2, custom aspect ratios (default 16:9), and resolutions (default 2K). Handles batch generation and image-to-image (edit) tasks with local files. Use when the user wants to generate or edit high-quality images via Nano Banana.
---
# Ace Banana2 Image Generation / Ace Banana2 图像生成
**English** | **中文**
---
## Overview / 概述
**English:**
Ace Banana2 is a powerful image generation and editing skill that leverages the AceData Nano Banana API. It provides a seamless workflow for creating high-quality images from text prompts or editing existing images with AI-powered transformations. The skill supports multiple models, customizable parameters, and automatic saving of generated images to your desktop.
**中文:**
Ace Banana2 是一个功能强大的图像生成和编辑技能,基于 AceData Nano Banana API。它提供了一个无缝的工作流程,可以从文本提示生成高质量图像,或使用 AI 驱动的转换编辑现有图像。该技能支持多种模型、可自定义参数,并自动将生成的图像保存到桌面。
---
## Model Introduction / 模型介绍
**English:**
The Nano Banana API offers several cutting‑edge image generation models:
- **nano‑banana‑2** (default): Professional‑quality image generation with flash speed. Ideal for most creative tasks.
- **nano‑banana‑pro**: Enhanced model for image‑to‑image editing and higher‑fidelity outputs.
- **nano‑banana**: The original model, suitable for general‑purpose generation.
All models support resolutions up to **4K** and aspect ratios such as **16:9**, **1:1**, **4:3**, etc.
**中文:**
Nano Banana API 提供多种先进的图像生成模型:
- **nano‑banana‑2**(默认):具有快速生成速度的专业级图像生成模型,适合大多数创意任务。
- **nano‑banana‑pro**:增强模型,适用于图像到图像的编辑和更高保真度的输出。
- **nano‑banana**:原始模型,适合通用生成。
所有模型支持高达 **4K** 的分辨率和 **16:9**、**1:1**、**4:3** 等宽高比。
---
## API Key Application / API 密钥申请说明
**English:**
To use this skill, you need an AceData API key (Bearer Token). Follow these steps:
1. Visit the [AceData registration page](https://share.acedata.cloud/r/1uN83988Tn).
2. Sign up or log in to your AceData account.
3. Navigate to the **API Keys** section in your dashboard.
4. Generate a new API key (Bearer Token) with access to the **Nano Banana** service.
5. Copy the key and keep it secure.
**中文:**
使用本技能需要 AceData API 密钥(Bearer Token)。请按以下步骤操作:
1. 访问 [AceData 注册页面](https://share.acedata.cloud/r/1uN83988Tn)。
2. 注册或登录您的 AceData 账户。
3. 在控制台中转到 **API Keys** 部分。
4. 生成一个新的 API 密钥(Bearer Token),确保其具有 **Nano Banana** 服务的访问权限。
5. 复制密钥并妥善保管。
---
## Installation & Usage Steps / 安装与使用步骤
### Step 1: Install Dependencies / 第一步:安装依赖
**English:**
Ensure you have Python 3.7+ installed. Then install required packages:
```bash
pip install requests pillow
```
**中文:**
确保已安装 Python 3.7+,然后安装所需包:
```bash
pip install requests pillow
```
### Step 2: Configure API Key / 第二步:配置 API 密钥
**English:**
Run the script once, and it will prompt you to enter your Bearer Token. The token will be saved in a `.env` file inside the skill directory for future use.
**中文:**
运行脚本一次,它将提示您输入 Bearer Token。令牌将保存在技能目录的 `.env` 文件中,供以后使用。
### Step 3: Run the Script / 第三步:运行脚本
**English:**
Navigate to the skill directory and execute:
```bash
python scripts/generate_images.py
```
You will be prompted for a text description (prompt) or can provide command‑line arguments.
**中文:**
进入技能目录并执行:
```bash
python scripts/generate_images.py
```
系统将提示您输入文本描述(提示词),或者您可以直接提供命令行参数。
---
## Features / 功能特性
**English:**
- **Dual‑mode Operation**: Supports both text‑to‑image (`generate`) and image‑to‑image (`edit`) workflows.
- **Local & Remote Images**: Upload up to 4 local images (converted to Base64) or provide image URLs.
- **Automatic Image Resizing**: Large images are automatically resized to comply with API limits.
- **Batch Generation**: Generate multiple images in a single request.
- **Smart Saving**: Images are saved to a dated folder on your desktop with unique timestamps.
- **Detailed Logging**: Full JSON response is displayed for debugging and transparency.
**中文:**
- **双模式操作**:支持文生图(`generate`)和图生图(`edit`)工作流。
- **本地与远程图像**:最多上传 4 张本地图像(转换为 Base64)或提供图像 URL。
- **自动图像调整**:大图像自动调整大小以符合 API 限制。
- **批量生成**:单次请求生成多张图像。
- **智能保存**:图像保存到桌面的日期文件夹中,文件名包含唯一时间戳。
- **详细日志**:显示完整的 JSON 响应,便于调试和透明化。
---
## Parameters / 参数详解
| Parameter / 参数 | Default / 默认值 | Description / 说明 |
|------------------|------------------|-------------------|
| `--prompt` | (required for generate) | Text description of the desired image / 期望图像的文本描述 |
| `--count` | `1` | Number of images to generate / 要生成的图像数量 |
| `--model` | `nano-banana-2` | Model to use (`nano-banana-2`, `nano-banana-pro`, `nano-banana`) / 使用的模型 |
| `--resolution` | `2K` | Output resolution (`2K`, `4K`, etc.) / 输出分辨率 |
| `--aspect_ratio` | `16:9` | Aspect ratio (`16:9`, `1:1`, `4:3`, etc.) / 宽高比 |
| `--image` | (optional) | Local image paths or URLs for edit mode (max 4) / 编辑模式的本地图像路径或 URL(最多 4 个) |
| `--api_key` | (optional) | Bearer Token for AceData API / AceData API 的 Bearer Token |
---
## Examples / 示例
### Example 1: Basic Text‑to‑Image / 示例 1:基础文生图
**English:**
```bash
python scripts/generate_images.py --prompt "a serene mountain landscape at sunset" --count 2 --resolution "4K"
```
**中文:**
```bash
python scripts/generate_images.py --prompt "日落时宁静的山景" --count 2 --resolution "4K"
```
### Example 2: Image Editing / 示例 2:图像编辑
**English:**
```bash
python scripts/generate_images.py --image "input.jpg" --prompt "make it look like a watercolor painting"
```
**中文:**
```bash
python scripts/generate_images.py --image "input.jpg" --prompt "让它看起来像水彩画"
```
### Example 3: Batch Generation with Custom Aspect Ratio / 示例 3:自定义宽高比的批量生成
**English:**
```bash
python scripts/generate_images.py --prompt "cyberpunk city street" --count 4 --aspect_ratio "1:1"
```
**中文:**
```bash
python scripts/generate_images.py --prompt "赛博朋克城市街道" --count 4 --aspect_ratio "1:1"
```
---
## Notes / 注意事项
**English:**
- The API may have rate limits and usage quotas. Check your AceData dashboard for details.
- Generated images are stored on AceData's CDN for a limited time; download them promptly.
- For large images (>1 MB), the script automatically resizes them to avoid timeout errors.
- Ensure your internet connection is stable during generation (requests can take up to 180 seconds).
**中文:**
- API 可能有速率限制和使用配额。请查看您的 AceData 控制台了解详情。
- 生成的图像在 AceData 的 CDN 上存储时间有限,请及时下载。
- 对于大图像(>1 MB),脚本会自动调整大小以避免超时错误。
- 生成期间请确保网络连接稳定(请求可能长达 180 秒)。
---
## References / 参考资料
- [API Documentation](references/api_docs.md) – Detailed Nano Banana API specifications.
- [AceData Cloud Platform](https://platform.acedata.cloud) – Manage your API keys and usage.
FILE:scripts/generate_images.py
import os
import requests
import base64
import time
import json
import argparse
from datetime import datetime
from pathlib import Path
from PIL import Image
import io
# Configuration
SKILL_DIR = Path(__file__).parent.parent
ENV_FILE = SKILL_DIR / ".env"
API_URL = "https://api.acedata.cloud/nano-banana/images"
SHARE_URL = "https://share.acedata.cloud/r/1uN88BrUTQ"
def get_api_key(passed_key=None):
"""Check for API key in environment or .env file. Prompt if missing."""
if passed_key:
with open(ENV_FILE, "w") as f:
f.write(f"ACEDATA_API_KEY={passed_key}\n")
return passed_key
if os.getenv("ACEDATA_API_KEY"):
return os.getenv("ACEDATA_API_KEY")
if ENV_FILE.exists():
with open(ENV_FILE, "r") as f:
for line in f:
if line.startswith("ACEDATA_API_KEY="):
return line.strip().split("=", 1)[1].strip('"')
return None
def resize_image_if_needed(image_path, target_size_mb=1.0):
"""Resize image to be under target_size_mb."""
try:
path = Path(image_path)
original_size = path.stat().st_size / (1024 * 1024)
if original_size <= target_size_mb:
return image_to_base64(image_path)
print(f"[*] Image too large ({original_size:.2f}MB). Shrinking to <{target_size_mb}MB...")
with Image.open(image_path) as img:
# Convert to RGB if needed
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
quality = 90
while True:
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=quality)
size = buffer.tell() / (1024 * 1024)
if size <= target_size_mb or quality <= 10:
break
quality -= 10
encoded_string = base64.b64encode(buffer.getvalue()).decode("utf-8")
print(f"[*] Shrunk to {size:.2f}MB (Quality: {quality})")
return f"data:image/jpeg;base64,{encoded_string}"
except Exception as e:
print(f"[!] Error resizing image: {e}")
return image_to_base64(image_path)
def image_to_base64(image_path):
"""Read a local image and convert to Base64."""
try:
path = Path(image_path)
if not path.exists():
print(f"[!] File not found: {image_path}")
return None
with open(path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
ext = path.suffix.lower()
mime_type = "image/png"
if ext in [".jpg", ".jpeg"]: mime_type = "image/jpeg"
elif ext == ".gif": mime_type = "image/gif"
elif ext == ".webp": mime_type = "image/webp"
return f"data:{mime_type};base64,{encoded_string}"
except Exception as e:
print(f"[!] Error encoding image: {e}")
return None
def main():
parser = argparse.ArgumentParser(description="Generate images using Nano Banana API.")
parser.add_argument("--prompt", type=str, help="Text description of the image.")
parser.add_argument("--count", type=int, default=1, help="Number of images to generate.")
parser.add_argument("--model", type=str, default="nano-banana-2", help="Model to use.")
parser.add_argument("--resolution", type=str, default="2K", help="Resolution (e.g., 2K).")
parser.add_argument("--aspect_ratio", type=str, default="16:9", help="Aspect ratio (e.g., 16:9).")
parser.add_argument("--image", type=str, nargs='*', help="Local image paths or URLs for edit mode (up to 4).")
parser.add_argument("--api_key", type=str, help="Bearer Token for AceData API.")
args = parser.parse_args()
token = get_api_key(args.api_key)
if not token:
print("\n[!] ACEDATA_API_KEY not found.")
print(f"[!] Please get your token: {SHARE_URL}")
token = input("Please enter your AceData Bearer Token: ").strip()
if token:
with open(ENV_FILE, "w") as f: f.write(f"ACEDATA_API_KEY={token}\n")
else: exit(1)
prompt = args.prompt
if not prompt:
prompt = input("\nEnter prompt (or leave blank for edit): ").strip()
image_inputs = args.image
use_resized = False
def prepare_payload(resize=False):
payload = {
"action": "generate",
"model": "nano-banana-2",
"prompt": prompt or "No prompt provided",
"count": args.count,
"resolution": args.resolution,
"aspect_ratio": args.aspect_ratio
}
if image_inputs:
processed_urls = []
for inp in image_inputs[:4]:
if inp.startswith(("http://", "https://")):
processed_urls.append(inp)
else:
b64 = resize_image_if_needed(inp) if resize else image_to_base64(inp)
if b64: processed_urls.append(b64)
if processed_urls:
payload["action"] = "edit"
payload["image_urls"] = processed_urls
# Edit requires nano-banana or nano-banana-pro
payload["model"] = "nano-banana-pro"
return payload
if not prompt and not image_inputs:
print("[!] Either prompt or image is required."); return
headers = {"authorization": f"Bearer {token}", "accept": "application/json", "content-type": "application/json"}
attempts = 0
max_attempts = 3
while attempts < max_attempts:
attempts += 1
payload = prepare_payload(resize=use_resized)
print(f"\n[*] Attempt {attempts}/{max_attempts}: model={payload['model']}, action={payload['action']}")
try:
print("[*] Sending request (180s timeout)...", flush=True)
resp = requests.post(API_URL, json=payload, headers=headers, timeout=180)
print(f"[*] Response: {resp.status_code}")
try:
data = resp.json()
except:
print(f"[!] Failed to parse JSON. Body: {resp.text}"); break
if data.get("success"):
print("[+] Success!")
desktop = Path(os.path.join(os.environ['USERPROFILE'], 'Desktop'))
today = datetime.now().strftime("%Y-%m-%d")
output_dir = desktop / today
output_dir.mkdir(parents=True, exist_ok=True)
for idx, item in enumerate(data.get("data", [])):
img_url = item.get("image_url")
if img_url:
img_resp = requests.get(img_url)
timestamp = int(time.time() * 1000)
filename = f"banana_{timestamp}_{idx}.png"
with open(output_dir / filename, "wb") as f: f.write(img_resp.content)
print(f"[OK] Saved: Desktop/{today}/{filename}")
print("\n[Return Data]\n", json.dumps(data, indent=2, ensure_ascii=False))
return
else:
print(f"[!] API Error: {json.dumps(data.get('error', data))}")
break # Non-timeout errors usually shouldn't be retried blindly
except (requests.exceptions.Timeout, requests.exceptions.ReadTimeout):
print(f"[!] Timeout on attempt {attempts}.")
if attempts == max_attempts and image_inputs:
ans = input("[?] 3 attempts failed with timeout. Resize images to ~1MB and retry? (y/n): ").lower()
if ans == 'y':
attempts = 0 # Reset attempts for the resized run
use_resized = True
print("[*] Restarting with resized images...")
else: break
elif attempts < max_attempts:
print("[*] Retrying in 5 seconds...")
time.sleep(5)
except Exception as e:
print(f"[!] Error: {e}"); break
if __name__ == "__main__":
main()
FILE:references/api_docs.md
# Nano Banana Images API Documentation
## Overview
This API supports image generation and editing.
- **Base URL**: `https://api.acedata.cloud`
- **Endpoint**: `POST /nano-banana/images`
- **Authentication**: `Bearer {token}` in `Authorization` header.
## Request Parameters
- `action`: `generate` or `edit`
- `model`: `nano-banana`, `nano-banana-2` (default for this skill), `nano-banana-pro`
- `prompt`: Text description of the image.
- `count`: Number of images to generate.
- `image_urls`: Array of image URLs or Base64 strings (required for `edit`).
## Example Response
```json
{
"success": true,
"task_id": "...",
"data": [
{
"prompt": "...",
"image_url": "..."
}
]
}
```
## Note on Errors
- **403 Forbidden**: Usually means the API Key is invalid, has no quota, or the specific service (Nano Banana) hasn't been enabled for this key.