diff --git a/electron/main.ts b/electron/main.ts index b4a9883..d38a934 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -17,6 +17,7 @@ import { ServiceManager } from "./services/ServiceManager"; import { HostsManager } from "./services/HostsManager"; import { GitManager } from "./services/GitManager"; import { PythonManager } from "./services/PythonManager"; +import { LogManager } from "./services/LogManager"; import { ConfigStore } from "./services/ConfigStore"; // 获取图标路径 @@ -120,6 +121,7 @@ const serviceManager = new ServiceManager(configStore); const hostsManager = new HostsManager(); const gitManager = new GitManager(configStore); const pythonManager = new PythonManager(configStore); +const logManager = new LogManager(configStore); function createWindow() { const appIcon = createWindowIcon(); @@ -575,9 +577,11 @@ ipcMain.handle("config:setBasePath", (_, path: string) => ); // ==================== 应用设置 ==================== -// 设置开机自启(以管理员模式,使用任务计划程序) +// 设置开机自启(以管理员模式,使用任务计划程序,静默启动) ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => { - const { execSync } = require("child_process"); + const { execSync, exec } = require("child_process"); + const { writeFileSync, unlinkSync, existsSync } = require("fs"); + const { join } = require("path"); const exePath = app.getPath("exe"); const taskName = "PHPerDevManager"; @@ -601,12 +605,18 @@ ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => { // 忽略删除失败(可能任务不存在) } - // 创建任务计划程序任务,以最高权限运行 - const command = `schtasks /create /tn "${taskName}" /tr "\\"${exePath}\\"" /sc onlogon /rl highest /f`; + // 创建 VBS 启动脚本(确保静默启动) + const appDir = require("path").dirname(exePath); + const vbsPath = join(appDir, "silent_start.vbs"); + const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.Run """${exePath.replace(/\\/g, "\\\\")}""", 0, False`; + writeFileSync(vbsPath, vbsContent); + + // 创建任务计划程序任务,运行 VBS 脚本实现静默启动 + const command = `schtasks /create /tn "${taskName}" /tr "wscript.exe \\"${vbsPath}\\"" /sc onlogon /rl highest /f`; execSync(command, { encoding: "buffer", windowsHide: true }); configStore.set("autoLaunch", true); - return { success: true, message: "已启用开机自启(管理员模式)" }; + return { success: true, message: "已启用开机自启(静默模式)" }; } else { // 删除任务计划程序任务 try { @@ -617,6 +627,18 @@ ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => { } catch (e) { // 忽略删除失败 } + + // 删除 VBS 脚本 + const appDir = require("path").dirname(exePath); + const vbsPath = join(appDir, "silent_start.vbs"); + if (existsSync(vbsPath)) { + try { + unlinkSync(vbsPath); + } catch (e) { + // 忽略删除失败 + } + } + configStore.set("autoLaunch", false); return { success: true, message: "已禁用开机自启" }; } @@ -650,6 +672,38 @@ ipcMain.handle("app:getAutoLaunch", async () => { } }); +// 获取应用版本信息 +ipcMain.handle("app:getVersion", async () => { + const { existsSync, readFileSync } = require("fs"); + const { join } = require("path"); + + const version = app.getVersion(); + let buildTime = ""; + let buildDate = ""; + + // 尝试读取版本信息文件 + try { + const versionFilePath = app.isPackaged + ? join(process.resourcesPath, "public", "version.json") + : join(__dirname, "..", "public", "version.json"); + + if (existsSync(versionFilePath)) { + const versionInfo = JSON.parse(readFileSync(versionFilePath, "utf-8")); + buildTime = versionInfo.buildTime || ""; + buildDate = versionInfo.buildDate || ""; + } + } catch (e) { + // 忽略错误 + } + + return { + version, + buildTime, + buildDate, + isPackaged: app.isPackaged + }; +}); + // 设置启动时最小化到托盘 ipcMain.handle("app:setStartMinimized", (_, enabled: boolean) => { configStore.set("startMinimized", enabled); @@ -666,3 +720,13 @@ ipcMain.handle("app:quit", () => { isQuitting = true; app.quit(); }); + +// ==================== 日志管理 ==================== +ipcMain.handle("log:getFiles", () => logManager.getLogFiles()); +ipcMain.handle("log:read", (_, logPath: string, lines?: number) => + logManager.readLog(logPath, lines) +); +ipcMain.handle("log:clear", (_, logPath: string) => logManager.clearLog(logPath)); +ipcMain.handle("log:getDirectory", (_, type: 'nginx' | 'php' | 'mysql' | 'sites', version?: string) => + logManager.getLogDirectory(type, version) +); diff --git a/electron/preload.ts b/electron/preload.ts index 13883bc..10c598b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -166,12 +166,22 @@ contextBridge.exposeInMainWorld('electronAPI', { setBasePath: (path: string) => ipcRenderer.invoke('config:setBasePath', path) }, + // 日志管理 + log: { + getFiles: () => ipcRenderer.invoke('log:getFiles'), + read: (logPath: string, lines?: number) => ipcRenderer.invoke('log:read', logPath, lines), + clear: (logPath: string) => ipcRenderer.invoke('log:clear', logPath), + getDirectory: (type: 'nginx' | 'php' | 'mysql' | 'sites', version?: string) => + ipcRenderer.invoke('log:getDirectory', type, version) + }, + // 应用设置 app: { setAutoLaunch: (enabled: boolean) => ipcRenderer.invoke('app:setAutoLaunch', enabled), getAutoLaunch: () => ipcRenderer.invoke('app:getAutoLaunch'), setStartMinimized: (enabled: boolean) => ipcRenderer.invoke('app:setStartMinimized', enabled), getStartMinimized: () => ipcRenderer.invoke('app:getStartMinimized'), + getVersion: () => ipcRenderer.invoke('app:getVersion') as Promise<{ version: string; buildTime: string; buildDate: string; isPackaged: boolean }>, setAutoStartServices: (enabled: boolean) => ipcRenderer.invoke('app:setAutoStartServices', enabled), getAutoStartServices: () => ipcRenderer.invoke('app:getAutoStartServices'), quit: () => ipcRenderer.invoke('app:quit') diff --git a/electron/services/LogManager.ts b/electron/services/LogManager.ts new file mode 100644 index 0000000..016af42 --- /dev/null +++ b/electron/services/LogManager.ts @@ -0,0 +1,277 @@ +import { ConfigStore } from './ConfigStore' +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import { join, basename } from 'path' + +export interface LogFile { + name: string + path: string + size: number + modifiedTime: Date + type: 'nginx' | 'nginx-error' | 'nginx-access' | 'php' | 'mysql' | 'mysql-error' | 'site-access' | 'site-error' +} + +export interface LogContent { + content: string + totalLines: number + fileSize: number +} + +export class LogManager { + private configStore: ConfigStore + + constructor(configStore: ConfigStore) { + this.configStore = configStore + } + + /** + * 获取所有可用的日志文件列表 + */ + async getLogFiles(): Promise<{ nginx: LogFile[], php: LogFile[], mysql: LogFile[], sites: LogFile[] }> { + const result = { + nginx: [] as LogFile[], + php: [] as LogFile[], + mysql: [] as LogFile[], + sites: [] as LogFile[] + } + + // Nginx 日志 + const nginxPath = this.configStore.getNginxPath() + const nginxLogsDir = join(nginxPath, 'logs') + if (existsSync(nginxLogsDir)) { + const files = this.scanLogDir(nginxLogsDir) + for (const file of files) { + if (file.name.includes('error')) { + result.nginx.push({ ...file, type: 'nginx-error' }) + } else if (file.name.includes('access')) { + result.nginx.push({ ...file, type: 'nginx-access' }) + } else { + result.nginx.push({ ...file, type: 'nginx' }) + } + } + } + + // PHP 日志 - 检查每个 PHP 版本的日志 + const phpVersions = this.configStore.get('phpVersions') || [] + for (const version of phpVersions) { + const phpPath = this.configStore.getPhpPath(version) + const phpLogsDir = join(phpPath, 'logs') + if (existsSync(phpLogsDir)) { + const files = this.scanLogDir(phpLogsDir) + for (const file of files) { + result.php.push({ ...file, type: 'php', name: `[${version}] ${file.name}` }) + } + } + // 也检查 php.ini 中配置的 error_log + const phpErrorLog = join(phpPath, 'php_errors.log') + if (existsSync(phpErrorLog)) { + const stat = statSync(phpErrorLog) + result.php.push({ + name: `[${version}] php_errors.log`, + path: phpErrorLog, + size: stat.size, + modifiedTime: stat.mtime, + type: 'php' + }) + } + } + + // MySQL 日志 + const mysqlVersions = this.configStore.get('mysqlVersions') || [] + for (const version of mysqlVersions) { + const mysqlPath = this.configStore.getMysqlPath(version) + // MySQL 日志通常在 data 目录下 + const mysqlDataDir = join(mysqlPath, 'data') + if (existsSync(mysqlDataDir)) { + const files = readdirSync(mysqlDataDir) + for (const file of files) { + if (file.endsWith('.err') || file.endsWith('.log')) { + const filePath = join(mysqlDataDir, file) + const stat = statSync(filePath) + const logType = file.includes('error') || file.endsWith('.err') ? 'mysql-error' : 'mysql' + result.mysql.push({ + name: `[${version}] ${file}`, + path: filePath, + size: stat.size, + modifiedTime: stat.mtime, + type: logType + }) + } + } + } + // 也检查 logs 目录 + const mysqlLogsDir = join(mysqlPath, 'logs') + if (existsSync(mysqlLogsDir)) { + const files = this.scanLogDir(mysqlLogsDir) + for (const file of files) { + result.mysql.push({ ...file, type: 'mysql', name: `[${version}] ${file.name}` }) + } + } + } + + // 站点日志 - Nginx sites logs + const sites = this.configStore.get('sites') || [] + for (const site of sites) { + // 站点日志通常在 nginx/logs 目录下,以域名命名 + const siteAccessLog = join(nginxLogsDir, `${site.domain}.access.log`) + const siteErrorLog = join(nginxLogsDir, `${site.domain}.error.log`) + + if (existsSync(siteAccessLog)) { + const stat = statSync(siteAccessLog) + result.sites.push({ + name: `${site.domain} - 访问日志`, + path: siteAccessLog, + size: stat.size, + modifiedTime: stat.mtime, + type: 'site-access' + }) + } + + if (existsSync(siteErrorLog)) { + const stat = statSync(siteErrorLog) + result.sites.push({ + name: `${site.domain} - 错误日志`, + path: siteErrorLog, + size: stat.size, + modifiedTime: stat.mtime, + type: 'site-error' + }) + } + } + + return result + } + + /** + * 读取日志文件内容 + * @param logPath 日志文件路径 + * @param lines 读取的行数(从末尾开始),默认 500 行 + */ + async readLog(logPath: string, lines: number = 500): Promise { + if (!existsSync(logPath)) { + return { content: '日志文件不存在', totalLines: 0, fileSize: 0 } + } + + try { + const stat = statSync(logPath) + const fileSize = stat.size + + // 如果文件小于 1MB,直接读取全部内容 + if (fileSize < 1024 * 1024) { + const content = readFileSync(logPath, 'utf-8') + const allLines = content.split('\n') + const totalLines = allLines.length + + // 取最后 N 行 + const lastLines = allLines.slice(-lines).join('\n') + return { content: lastLines, totalLines, fileSize } + } + + // 大文件:从末尾读取 + const content = await this.readLastLines(logPath, lines) + return { content, totalLines: lines, fileSize } + } catch (error: any) { + return { content: `读取日志失败: ${error.message}`, totalLines: 0, fileSize: 0 } + } + } + + /** + * 从文件末尾读取指定行数 + */ + private async readLastLines(filePath: string, lines: number): Promise { + const fs = await import('fs/promises') + const stat = await fs.stat(filePath) + const fileSize = stat.size + + // 估算需要读取的字节数(假设每行约 200 字节) + const bytesToRead = Math.min(fileSize, lines * 200) + const startPosition = Math.max(0, fileSize - bytesToRead) + + const buffer = Buffer.alloc(bytesToRead) + const fd = await fs.open(filePath, 'r') + await fd.read(buffer, 0, bytesToRead, startPosition) + await fd.close() + + const content = buffer.toString('utf-8') + const allLines = content.split('\n') + + // 第一行可能不完整,跳过 + const completeLines = startPosition === 0 ? allLines : allLines.slice(1) + + return completeLines.slice(-lines).join('\n') + } + + /** + * 清空日志文件 + */ + async clearLog(logPath: string): Promise<{ success: boolean, message: string }> { + if (!existsSync(logPath)) { + return { success: false, message: '日志文件不存在' } + } + + try { + const fs = await import('fs/promises') + await fs.writeFile(logPath, '') + return { success: true, message: '日志已清空' } + } catch (error: any) { + return { success: false, message: `清空日志失败: ${error.message}` } + } + } + + /** + * 扫描目录中的日志文件 + */ + private scanLogDir(dir: string): LogFile[] { + const files: LogFile[] = [] + + if (!existsSync(dir)) { + return files + } + + try { + const items = readdirSync(dir) + for (const item of items) { + const filePath = join(dir, item) + const stat = statSync(filePath) + + if (stat.isFile() && (item.endsWith('.log') || item.endsWith('.err'))) { + files.push({ + name: item, + path: filePath, + size: stat.size, + modifiedTime: stat.mtime, + type: 'nginx' // 默认类型,调用方会覆盖 + }) + } + } + } catch (error) { + console.error('扫描日志目录失败:', error) + } + + return files.sort((a, b) => b.modifiedTime.getTime() - a.modifiedTime.getTime()) + } + + /** + * 获取日志文件路径(用于在文件管理器中打开) + */ + getLogDirectory(type: 'nginx' | 'php' | 'mysql' | 'sites', version?: string): string { + switch (type) { + case 'nginx': + return join(this.configStore.getNginxPath(), 'logs') + case 'php': + if (version) { + return join(this.configStore.getPhpPath(version), 'logs') + } + return join(this.configStore.getBasePath(), 'php') + case 'mysql': + if (version) { + return join(this.configStore.getMysqlPath(version), 'data') + } + return join(this.configStore.getBasePath(), 'mysql') + case 'sites': + return join(this.configStore.getNginxPath(), 'logs') + default: + return this.configStore.getBasePath() + } + } +} + diff --git a/electron/services/NginxManager.ts b/electron/services/NginxManager.ts index ce865bc..f13e92e 100644 --- a/electron/services/NginxManager.ts +++ b/electron/services/NginxManager.ts @@ -331,9 +331,12 @@ export class NginxManager { return { success: true, message: 'Nginx 已经在运行' } } - // 启动 Nginx - const child = spawn(nginxExe, [], { - cwd: nginxPath, + // 使用 VBScript 静默启动 Nginx + const vbsPath = join(nginxPath, 'start_nginx.vbs') + const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.CurrentDirectory = "${nginxPath.replace(/\\/g, '\\\\')}"\nWshShell.Run """${nginxExe.replace(/\\/g, '\\\\')}""", 0, False` + writeFileSync(vbsPath, vbsContent) + + const child = spawn('wscript.exe', [vbsPath], { detached: true, stdio: 'ignore', windowsHide: true diff --git a/electron/services/RedisManager.ts b/electron/services/RedisManager.ts index dada3c3..5ec0972 100644 --- a/electron/services/RedisManager.ts +++ b/electron/services/RedisManager.ts @@ -307,15 +307,16 @@ export class RedisManager { await this.createDefaultConfig() } - // 使用相对路径启动(避免 Cygwin 路径问题) - // Redis Windows 版本使用 Cygwin,需要在正确的工作目录下用相对路径 + // 使用 VBScript 静默启动 Redis(避免黑窗口闪烁) const configFileName = 'redis.windows.conf' - const child = spawn(redisServer, [configFileName], { - cwd: redisPath, + const vbsPath = join(redisPath, 'start_redis.vbs') + const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.CurrentDirectory = "${redisPath.replace(/\\/g, '\\\\')}"\nWshShell.Run """${redisServer.replace(/\\/g, '\\\\')}""" & " " & "${configFileName}", 0, False` + writeFileSync(vbsPath, vbsContent) + + const child = spawn('wscript.exe', [vbsPath], { detached: true, stdio: 'ignore', - windowsHide: true, - shell: false + windowsHide: true }) child.unref() diff --git a/electron/services/ServiceManager.ts b/electron/services/ServiceManager.ts index 826fc04..994ce77 100644 --- a/electron/services/ServiceManager.ts +++ b/electron/services/ServiceManager.ts @@ -1,7 +1,7 @@ import { ConfigStore } from './ConfigStore' import { exec, spawn } from 'child_process' import { promisify } from 'util' -import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from 'fs' +import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs' import { join } from 'path' const execAsync = promisify(exec) @@ -562,13 +562,40 @@ export class ServiceManager { } private async startProcess(exe: string, args: string[], cwd: string): Promise { - const child = spawn(exe, args, { - cwd, - detached: true, - stdio: 'ignore', - windowsHide: true - }) - child.unref() + // 使用 VBScript 来完全隐藏窗口启动进程 + const argsStr = args.map(a => `"${a}"`).join(' ') + const command = args.length > 0 ? `"${exe}" ${argsStr}` : `"${exe}"` + + const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.Run ${JSON.stringify(command)}, 0, False` + const vbsPath = join(cwd, `start_${Date.now()}.vbs`) + + try { + writeFileSync(vbsPath, vbsContent) + await execAsync(`cscript //nologo "${vbsPath}"`, { + cwd, + windowsHide: true, + timeout: 10000 + }) + // 延迟删除 VBS 文件 + setTimeout(() => { + try { + if (existsSync(vbsPath)) { + unlinkSync(vbsPath) + } + } catch (e) { + // 忽略删除失败 + } + }, 2000) + } catch (error) { + // 如果 VBS 方式失败,回退到 spawn + const child = spawn(exe, args, { + cwd, + detached: true, + stdio: 'ignore', + windowsHide: true + }) + child.unref() + } } } diff --git a/package.json b/package.json index 917b016..b60aa15 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,14 @@ "main": "dist-electron/main.js", "scripts": { "dev": "vite", - "build": "vue-tsc --noEmit && vite build && electron-builder", + "build": "node scripts/bump-version.js && vue-tsc --noEmit && vite build && electron-builder", + "build:patch": "node scripts/bump-version.js patch && vue-tsc --noEmit && vite build && electron-builder", + "build:minor": "node scripts/bump-version.js minor && vue-tsc --noEmit && vite build && electron-builder", + "build:major": "node scripts/bump-version.js major && vue-tsc --noEmit && vite build && electron-builder", + "build:nobump": "vue-tsc --noEmit && vite build && electron-builder", "preview": "vite preview", "electron:dev": "vite", - "electron:build": "vite build && electron-builder" + "electron:build": "node scripts/bump-version.js && vite build && electron-builder" }, "author": "PHPer", "license": "MIT", diff --git a/scripts/bump-version.js b/scripts/bump-version.js new file mode 100644 index 0000000..1973b28 --- /dev/null +++ b/scripts/bump-version.js @@ -0,0 +1,58 @@ +/** + * 自动更新版本号脚本 + * 每次打包时自动增加 patch 版本号 + * + * 用法: + * node scripts/bump-version.js # patch: 1.0.0 -> 1.0.1 + * node scripts/bump-version.js minor # minor: 1.0.0 -> 1.1.0 + * node scripts/bump-version.js major # major: 1.0.0 -> 2.0.0 + */ + +const fs = require('fs') +const path = require('path') + +const packagePath = path.join(__dirname, '..', 'package.json') +const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8')) + +const currentVersion = pkg.version +const [major, minor, patch] = currentVersion.split('.').map(Number) + +// 获取命令行参数 +const bumpType = process.argv[2] || 'patch' + +let newVersion +switch (bumpType) { + case 'major': + newVersion = `${major + 1}.0.0` + break + case 'minor': + newVersion = `${major}.${minor + 1}.0` + break + case 'patch': + default: + newVersion = `${major}.${minor}.${patch + 1}` + break +} + +// 更新 package.json +pkg.version = newVersion +fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n') + +// 生成构建时间戳 +const buildTime = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + +console.log(`✅ 版本号已更新: ${currentVersion} -> ${newVersion}`) +console.log(`📦 构建时间: ${buildTime}`) + +// 将版本信息写入一个文件,供应用读取 +const versionInfo = { + version: newVersion, + buildTime: new Date().toISOString(), + buildDate: new Date().toLocaleDateString('zh-CN') +} + +const versionFilePath = path.join(__dirname, '..', 'public', 'version.json') +fs.writeFileSync(versionFilePath, JSON.stringify(versionInfo, null, 2)) + +console.log(`📄 版本信息已写入: public/version.json`) + diff --git a/src/App.vue b/src/App.vue index 1eec99d..fdb8faf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -62,10 +62,10 @@
- - - - + + + +
@@ -83,6 +83,21 @@ const isDark = ref(true) const startingAll = ref(false) const stoppingAll = ref(false) +// 缓存的视图列表 - 避免页面切换闪烁 +const cachedViews = [ + 'Dashboard', + 'PhpManager', + 'MysqlManager', + 'NginxManager', + 'RedisManager', + 'NodeManager', + 'PythonManager', + 'GitManager', + 'SitesManager', + 'HostsManager', + 'Settings' +] + // 从 store 获取服务状态 const serviceStatus = computed(() => ({ nginx: store.serviceStatus.nginx, @@ -352,14 +367,5 @@ onUnmounted(() => { background: var(--bg-content); } -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.15s ease; -} - -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} diff --git a/src/components/LogViewer.vue b/src/components/LogViewer.vue new file mode 100644 index 0000000..94a074c --- /dev/null +++ b/src/components/LogViewer.vue @@ -0,0 +1,512 @@ + + + + + + diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index 3a4a3a8..7a658f9 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -248,10 +248,9 @@ class="mini-site-card" > {{ site.domain }} @@ -277,10 +276,16 @@ 安装路径 - - - 打开目录 - +
+ + + 查看日志 + + + + 打开目录 + +
@@ -289,14 +294,23 @@
+ + + diff --git a/src/views/GitManager.vue b/src/views/GitManager.vue index 19eb543..d3217ab 100644 --- a/src/views/GitManager.vue +++ b/src/views/GitManager.vue @@ -177,6 +177,11 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' +// 定义组件名称以便 KeepAlive 正确缓存 +defineOptions({ + name: 'GitManager' +}) + interface GitVersion { version: string path: string diff --git a/src/views/HostsManager.vue b/src/views/HostsManager.vue index 40fbc47..f3d6410 100644 --- a/src/views/HostsManager.vue +++ b/src/views/HostsManager.vue @@ -89,6 +89,11 @@ import { ref, reactive, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' +// 定义组件名称以便 KeepAlive 正确缓存 +defineOptions({ + name: 'HostsManager' +}) + interface HostEntry { ip: string domain: string diff --git a/src/views/MysqlManager.vue b/src/views/MysqlManager.vue index 199a8d2..7baee07 100644 --- a/src/views/MysqlManager.vue +++ b/src/views/MysqlManager.vue @@ -87,9 +87,13 @@ 密码 - + 配置 + + + 日志 + + + +